Skip to content
This repository has been archived by the owner on Jul 17, 2022. It is now read-only.

Commit

Permalink
feat: update auth context and implement cookie renewal
Browse files Browse the repository at this point in the history
  • Loading branch information
coderbyheart committed Aug 25, 2021
1 parent 7ce5f2c commit ee40367
Show file tree
Hide file tree
Showing 43 changed files with 472 additions and 273 deletions.
Empty file added db/migrations/.gitkeep
Empty file.
21 changes: 21 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Authentication

The backend authenticates requests using signed cookies so they can contain user information so that it does not have to be fetched for every request.

The cookie contains the user's id.

Cookies are sent [`secure` and `HttpOnly`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) when users register their account, or when they login using username and password.

Cookies expire after 30 minutes and the client is responsible to renew cookies by calling the `GET /me/cookie` endpoint before they expire.

When renewing cookies the server will re-check if the user still exists and if they haven't changed their password. For this a hash of the user's password hash, email, username, and id will be generated and included in the cookie. If any of these properties changes, the cookie cannot be renewed and the user has to log-in again.

## Admin permissions

Admin permission are granted via the `isAdmin` flag on the `UserAccount`.

## Configuration

These environment variables control the authentication:

- `COOKIE_SECRET`: sets the secret used to sign cookies, default value is a random string
4 changes: 2 additions & 2 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ enum GroupType {
type UserProfile {
id: Int!
isAdmin: Boolean!
groupId: Int
username: String!
name: String!
group: Group
}

enum OfferStatus {
Expand Down
19 changes: 12 additions & 7 deletions src/apolloServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
AuthenticationError,
} from 'apollo-server-express'
import depthLimit from 'graphql-depth-limit'
import { AuthContext, authenticateWithToken } from './authenticateRequest'
import {
AuthContext,
authCookieName,
decodeAuthCookie,
} from './authenticateRequest'
import generateCsv, { CsvRow } from './generateCsv'
import resolvers from './resolvers'
import typeDefs from './typeDefs'
Expand All @@ -23,13 +27,14 @@ export const serverConfig: ApolloServerExpressConfig = {
resolvers,
validationRules: [depthLimit(7)],
async context({ req }): Promise<AuthenticatedContext> {
const auth = await authenticateWithToken(req.signedCookies.token)

if (!('userAccount' in auth)) {
throw new AuthenticationError(auth.message)
try {
return {
auth: decodeAuthCookie(req.signedCookies[authCookieName]),
services: { generateCsv },
}
} catch (err) {
throw new AuthenticationError(err.message)
}

return { auth: auth as AuthContext, services: { generateCsv } }
},
}

Expand Down
101 changes: 62 additions & 39 deletions src/authenticateRequest.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,77 @@
import * as crypto from 'crypto'
import { CookieOptions } from 'express'
import { Strategy as CookieStrategy } from 'passport-cookie'
import UserAccount from './models/user_account'

const fakeAccount = UserAccount.build({
username: '',
token: '',
passwordHash: '',
})
type AuthCookiePayload = {
/** user ID */
i: number
/** user is admin */
a: boolean
/** user hash */
c: string
}

export type AuthContext = {
userAccount: UserAccount
userId: number
isAdmin: boolean
userHash: string
}

export type ErrorInfo = {
message: string
}

export const fakeAdminAuth: AuthContext = {
userAccount: fakeAccount,
isAdmin: true,
}

export const fakeUserAuth: AuthContext = {
userAccount: fakeAccount,
isAdmin: false,
}

export const authenticateWithToken = async (
token: string,
): Promise<AuthContext | ErrorInfo> => {
try {
const userAccount = await UserAccount.findOne({
where: { token },
})
if (userAccount === null) return { message: 'User not found for token.' }
return { userAccount, isAdmin: false }
} catch (err) {
return err
}
}
export const userHash = (user: UserAccount): string =>
crypto
.createHash('sha1')
.update(`${user.id}:${user.username}:${user.passwordHash}`)
.digest('hex')

export const authTokenCookieName = 'token'
export const authCookieName = 'auth'
export const cookieAuthStrategy = new CookieStrategy(
{
cookieName: authTokenCookieName,
cookieName: authCookieName,
signed: true,
},
async (token: string, done: any) => {
const res = await authenticateWithToken(token)
if ('userAccount' in res) return done(null, res)
return done(null, false, res)
async (value: string, done: any) => {
try {
return done(null, decodeAuthCookie(value))
} catch (error) {
return done(
null,
false,
new Error(`Failed to decode cookie payload: ${error.message}!`),
)
}
},
)

export const authCookie = (
user: UserAccount,
lifetimeInMinutes: number = 30,
): [string, string, CookieOptions] => [
authCookieName,
JSON.stringify({
i: user.id,
a: false,
c: userHash(user),
}),
{
signed: true,
secure: true,
httpOnly: true,
expires: new Date(Date.now() + lifetimeInMinutes * 60 * 1000),
},
]

export const userToAuthContext = (user: UserAccount): AuthContext => ({
isAdmin: user.isAdmin,
userId: user.id,
userHash: userHash(user),
})

export const decodeAuthCookie = (value: string): AuthContext => {
const {
i: userId,
a: isAdmin,
c: userHash,
} = JSON.parse(value) as AuthCookiePayload
return { userId, isAdmin, userHash }
}
37 changes: 0 additions & 37 deletions src/getProfile.ts

This file was deleted.

File renamed without changes.
8 changes: 8 additions & 0 deletions src/input-validation/trimAll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const trimAll = (o: Record<string, string>): Record<string, string> =>
Object.entries(o).reduce(
(r, [k, v]) => ({
...r,
[k]: v.trim(),
}),
{},
)
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ ajv.addKeyword('modifier')

export const validateWithJSONSchema = <T extends TObject<TProperties>>(
schema: T,
): ((
value: Record<string, any>,
) =>
): ((value: Record<string, any>) =>
| {
errors: ErrorObject[]
}
Expand Down
35 changes: 0 additions & 35 deletions src/login.ts

This file was deleted.

4 changes: 2 additions & 2 deletions src/models/shipment_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export default class ShipmentExport extends Model {
downloadPath: `/shipment-exports/${this.id}`,
createdBy: {
id: this.userAccountId,
isAdmin: true,
username: this.userAccount.username,
isAdmin: this.userAccount.isAdmin,
name: this.userAccount.name,
},
createdAt: this.createdAt,
}
Expand Down
31 changes: 25 additions & 6 deletions src/models/user_account.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { Maybe } from 'graphql/jsutils/Maybe'
import {
Column,
CreatedAt,
Default,
Model,
Table,
Unique,
UpdatedAt,
} from 'sequelize-typescript'
import { Optional } from 'sequelize/types'
import { UserProfile } from '../server-internal-types'
import Group from './group'

export interface UserAccountAttributes {
id: number
username: string
passwordHash: string
token: string
isAdmin?: boolean
name: string
}

export interface UserAccountCreationAttributes
Expand All @@ -36,7 +40,11 @@ export default class UserAccount extends Model<
public passwordHash!: string

@Column
public token!: string
public name!: string

@Default(false)
@Column
public isAdmin!: boolean

@CreatedAt
@Column
Expand All @@ -46,12 +54,23 @@ export default class UserAccount extends Model<
@Column
public readonly updatedAt!: Date

public asProfile(groupId?: number, isAdmin = false): UserProfile {
public async asPublicProfile(): Promise<UserProfile> {
let groupForUser: Maybe<Group>
if (!this.isAdmin) {
// Note: this assumes that there is only 1 captain per group, where in
// reality there are no restrictions on the number of groups with the same
// captain. For now, we've agreed that this is okay, but we probably need
// to solidify some restrictions later on.
// See https://github.com/distributeaid/shipment-tracker/issues/133
groupForUser = await Group.findOne({
where: { captainId: this.id },
})
}
return {
id: this.id,
username: this.username,
isAdmin,
groupId,
isAdmin: this.isAdmin,
name: this.name,
group: groupForUser,
}
}
}
23 changes: 0 additions & 23 deletions src/registerUser.ts

This file was deleted.

Loading

0 comments on commit ee40367

Please sign in to comment.