mirror of
https://github.com/zhigang1992/hasura-backend-plus.git
synced 2026-01-12 17:22:59 +08:00
Cookie updates, general improvements and bug fixes. SOOOOON! (#218)
* set permission_variables cookie * use permission variables in storage from cookie * fix(security): upgrade Hasusa to v1.2.1 * refactor(auth): clear permission_variables cookie on logout * refactor(auth): clear permission_variables cookie when deleting an account * refactor: simplify generation of permission variables reuse the same code for both permission_variables cookie and jwt claims * fix(storage): change isOwner function according to the new cookie specs * test(storage): fix the tests so they work with the new way of getting auth info * style: typo * fix: send hasura claim values as string * storage polishing * updated standard storage rules * updated storage tests * remove login on test * user cookie user vars on delete * removed unused logs * specify 'user-id' to return string | number * formatting * user id is always string * use cookie to get user-id * typo * fix: clean and fix code so every test passes * ci: runInBand * refactor(auth): refactor routes Co-authored-by: Pierre-Louis Mercereau <24897252+plmercereau@users.noreply.github.com>
This commit is contained in:
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }}
|
||||
options: --restart always --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
graphql-engine:
|
||||
image: hasura/graphql-engine:v1.2.0
|
||||
image: hasura/graphql-engine:v1.2.1
|
||||
env:
|
||||
HASURA_GRAPHQL_ENABLE_TELEMETRY: 'false'
|
||||
HASURA_GRAPHQL_ADMIN_SECRET: ${{ env.HASURA_GRAPHQL_ADMIN_SECRET }}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<body>
|
||||
<h2>Hello <%= display_name %></h2>
|
||||
<p>Please confirm your email address by clicking the button below:</p>
|
||||
<a href="<%= url %>/auth/account/activate?ticket=<%= ticket %>">
|
||||
<a href="<%= url %>/auth/activate?ticket=<%= ticket %>">
|
||||
<button>Confirm email address</button></a
|
||||
>
|
||||
<p>Thanks,<br />The Team</p>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
functions:
|
||||
isAuthenticated: 'return !!request.auth'
|
||||
isOwner:
|
||||
params:
|
||||
- userId
|
||||
code: "return !!request.auth && userId === request.auth['x-hasura-user-id']"
|
||||
isOwner: "return !!request.auth && userId === request.auth['user-id']"
|
||||
validToken: 'return request.query.token === resource.Metadata.token'
|
||||
paths:
|
||||
/user/:userId/:fileId:
|
||||
read: 'isOwner(userId) || validToken()'
|
||||
write: 'isOwner(userId)'
|
||||
meta:
|
||||
read: 'isOwner(userId)'
|
||||
write: 'isOwner(userId)'
|
||||
values:
|
||||
description: 'request.query.description'
|
||||
metadata:
|
||||
description: 'request.query.description'
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-postgrespassword}'
|
||||
graphql-engine:
|
||||
image: hasura/graphql-engine:v1.2.0
|
||||
image: hasura/graphql-engine:v1.2.1
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
restart: always
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| [Authentication](#authentication) | [POST /auth/register](#registration) | Account registration |
|
||||
| ^^ | [POST /auth/login](#login) | Login |
|
||||
| ^^ | [GET /auth/jwks](#jwks) | JWK Set |
|
||||
| ^^ | [POST /auth/account/activate](#activation) | Activate account |
|
||||
| ^^ | [POST /auth/account/delete](#delete-account) | Delete account |
|
||||
| ^^ | [POST /auth/activate](#activation) | Activate account |
|
||||
| ^^ | [POST /auth/delete](#delete-account) | Delete account |
|
||||
| ^^ | [POST /auth/change-password/request](#forgotten-password) | Forgotten password |
|
||||
| ^^ | [POST /auth/change-password/change](#reset-password) | Reset password |
|
||||
| ^^ | [POST /auth/change-email/request](#) | |
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
graphql-engine:
|
||||
image: hasura/graphql-engine:v1.2.0
|
||||
image: hasura/graphql-engine:v1.2.1
|
||||
ports:
|
||||
- '8080:8080'
|
||||
depends_on:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"docs:dev": "vuepress dev docs",
|
||||
"start": "ts-node -r tsconfig-paths/register src/start.ts",
|
||||
"test": "NODE_ENV=test jest --coverage --forceExit",
|
||||
"test:ci": "NODE_ENV=ci jest --coverage --forceExit",
|
||||
"test:ci": "NODE_ENV=ci jest --coverage --forceExit --runInBand",
|
||||
"test:watch": "NODE_ENV=test jest --watch",
|
||||
"report-coverage": "codecov"
|
||||
},
|
||||
@@ -73,7 +73,7 @@
|
||||
"graphql": "15.0.0",
|
||||
"graphql-request": "1.8.2",
|
||||
"graphql-tag": "2.10.3",
|
||||
"hasura-cli": "1.2.0",
|
||||
"hasura-cli": "1.2.1",
|
||||
"helmet": "3.22.0",
|
||||
"hibp": "9.0.0",
|
||||
"jose": "1.26.0",
|
||||
@@ -170,4 +170,4 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { AUTO_ACTIVATE_NEW_USERS } from '@shared/config'
|
||||
import { Router } from 'express'
|
||||
import activateAccount from './activate'
|
||||
import deleteAccount from './delete'
|
||||
|
||||
const router = Router()
|
||||
|
||||
if (!AUTO_ACTIVATE_NEW_USERS) {
|
||||
router.get('/activate', activateAccount)
|
||||
}
|
||||
|
||||
router.post('/delete', deleteAccount)
|
||||
|
||||
export default router
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
ANONYMOUS_USERS_ENABLE
|
||||
} from '@shared/config'
|
||||
import { generateRandomString, selectAccountByEmail } from '@shared/helpers'
|
||||
import { deleteMailHogEmail, mailHogSearch, registerAccount, deleteAccount } from '@test/test-utils'
|
||||
import { deleteMailHogEmail, mailHogSearch, deleteAccount } from '@test/test-utils'
|
||||
|
||||
import { JWT } from 'jose'
|
||||
import { Token } from '@shared/types'
|
||||
import { app } from '../../server'
|
||||
import request from 'supertest'
|
||||
import { SuperTest, Test, agent } from 'supertest'
|
||||
|
||||
/**
|
||||
* Store variables in memory.
|
||||
@@ -31,18 +31,31 @@ let jwtToken: string
|
||||
const email = `${generateRandomString()}@${generateRandomString()}.com`
|
||||
const password = generateRandomString()
|
||||
|
||||
/**
|
||||
* Create agent for global state.
|
||||
*/
|
||||
let request: SuperTest<Test>
|
||||
|
||||
const server = app.listen(PORT)
|
||||
const agent = request(server)
|
||||
|
||||
beforeAll(async () => {
|
||||
request = agent(server) // * Create the SuperTest agent
|
||||
})
|
||||
|
||||
// * Code that is executed after any jest test file that imports test-utiles
|
||||
afterAll(async () => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
const pwndPasswordIt = HIBP_ENABLE ? it : it.skip
|
||||
pwndPasswordIt('should tell the password has been pwned', async () => {
|
||||
const {
|
||||
status,
|
||||
body: { message }
|
||||
} = await request.post('/auth/register').send({ email: 'test@example.com', password: '123456' })
|
||||
expect(status).toEqual(400)
|
||||
expect(message).toEqual('Password is too weak.')
|
||||
})
|
||||
|
||||
it('should create an account', async () => {
|
||||
const { status } = await agent
|
||||
const { status } = await request
|
||||
.post('/auth/register')
|
||||
.send({ email, password, user_data: { name: 'Test name' } })
|
||||
expect(status).toEqual(204)
|
||||
@@ -52,7 +65,7 @@ it('should tell the account already exists', async () => {
|
||||
const {
|
||||
status,
|
||||
body: { message }
|
||||
} = await agent.post('/auth/register').send({ email, password })
|
||||
} = await request.post('/auth/register').send({ email, password })
|
||||
expect(status).toEqual(400)
|
||||
expect(message).toEqual('Account already exists.')
|
||||
})
|
||||
@@ -61,7 +74,7 @@ it('should tell the account already exists', async () => {
|
||||
const manualActivationIt = !AUTO_ACTIVATE_NEW_USERS ? it : it.skip
|
||||
|
||||
manualActivationIt('should fail to activate an user from a wrong ticket', async () => {
|
||||
const { status, redirect, header } = await agent.get(`/auth/account/activate?ticket=${uuidv4()}`)
|
||||
const { status, redirect, header } = await request.get(`/auth/activate?ticket=${uuidv4()}`)
|
||||
expect(
|
||||
status === 500 || (status === 302 && redirect && header?.location === REDIRECT_URL_ERROR)
|
||||
).toBeTrue()
|
||||
@@ -79,12 +92,12 @@ manualActivationIt('should activate the account from a valid ticket', async () =
|
||||
} else {
|
||||
ticket = (await selectAccountByEmail(email)).ticket
|
||||
}
|
||||
const { status } = await agent.get(`/auth/account/activate?ticket=${ticket}`)
|
||||
const { status } = await request.get(`/auth/activate?ticket=${ticket}`)
|
||||
expect(status).toBeOneOf([204, 302])
|
||||
})
|
||||
|
||||
it('should sign the user in', async () => {
|
||||
const { body, status } = await agent.post('/auth/login').send({ email, password })
|
||||
const { body, status } = await request.post('/auth/login').send({ email, password })
|
||||
// Save JWT token to globally scoped varaible.
|
||||
jwtToken = body.jwt_token
|
||||
expect(status).toEqual(200)
|
||||
@@ -99,37 +112,17 @@ it('should decode a valid custom user claim', async () => {
|
||||
expect(decodedJwt[JWT_CLAIMS_NAMESPACE]['x-hasura-name']).toEqual('Test name')
|
||||
})
|
||||
|
||||
it('should delete the account', async () => {
|
||||
const { status } = await agent
|
||||
.post('/auth/account/delete')
|
||||
.set('Authorization', `Bearer ${jwtToken}`)
|
||||
expect(status).toEqual(204)
|
||||
})
|
||||
|
||||
const pwndPasswordIt = HIBP_ENABLE ? it : it.skip
|
||||
pwndPasswordIt('should tell the password has been pwned', async () => {
|
||||
const {
|
||||
status,
|
||||
body: { message }
|
||||
} = await agent.post('/auth/register').send({ email: 'test@example.com', password: '123456' })
|
||||
expect(status).toEqual(400)
|
||||
expect(message).toEqual('Password is too weak.')
|
||||
it('should logout', async () => {
|
||||
const res = await request.post('/auth/logout').send()
|
||||
expect(res.status).toBe(204)
|
||||
await request.post('/auth/login').send({ email, password })
|
||||
await deleteAccount(request, { email, password })
|
||||
})
|
||||
|
||||
const anonymousAccountIt = ANONYMOUS_USERS_ENABLE ? it : it.skip
|
||||
anonymousAccountIt('should login anonymously', async () => {
|
||||
const { body, status } = await agent.post('/auth/login').send({ anonymous: true })
|
||||
const { body, status } = await request.post('/auth/login').send({ anonymous: true })
|
||||
expect(status).toEqual(200)
|
||||
expect(body.jwt_token).toBeString()
|
||||
expect(body.jwt_expires_in).toBeNumber()
|
||||
})
|
||||
|
||||
it('should logout', async () => {
|
||||
// TODO : review this test, including cookies
|
||||
const account = await registerAccount(agent)
|
||||
const res = await agent.post('/auth/logout').send()
|
||||
expect(res.status).toBe(204)
|
||||
await deleteAccount(agent, account)
|
||||
})
|
||||
|
||||
// TODO test cookies
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Request } from 'express'
|
||||
import { selectAccountByEmail } from '@shared/helpers'
|
||||
import { selectAccountByEmail, getPermissionVariablesFromCookie } from '@shared/helpers'
|
||||
import { emailResetSchema } from '@shared/validation'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
import Boom from '@hapi/boom'
|
||||
|
||||
export const getRequestInfo = async ({
|
||||
body,
|
||||
headers
|
||||
}: Request): Promise<{ user_id: string; new_email: string }> => {
|
||||
export const getRequestInfo = async (
|
||||
req: Request
|
||||
): Promise<{ user_id: string | number; new_email: string }> => {
|
||||
// get current user_id
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
// validate new email
|
||||
const { new_email } = await emailResetSchema.validateAsync(body)
|
||||
const { new_email } = await emailResetSchema.validateAsync(req.body)
|
||||
|
||||
// make sure new_email is not attached to an account yet
|
||||
let account_exists = true
|
||||
@@ -27,5 +26,8 @@ export const getRequestInfo = async ({
|
||||
if (account_exists) {
|
||||
throw Boom.badRequest('Cannot use this email.')
|
||||
}
|
||||
return { user_id, new_email }
|
||||
return {
|
||||
user_id,
|
||||
new_email
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { asyncWrapper, checkHibp, hashPassword, selectAccountByUserId } from '@shared/helpers'
|
||||
import {
|
||||
asyncWrapper,
|
||||
checkHibp,
|
||||
hashPassword,
|
||||
selectAccountByUserId,
|
||||
getPermissionVariablesFromCookie
|
||||
} from '@shared/helpers'
|
||||
import { Request, Response } from 'express'
|
||||
import {
|
||||
resetPasswordWithOldPasswordSchema,
|
||||
@@ -10,18 +16,17 @@ import Boom from '@hapi/boom'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { request } from '@shared/request'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
import { UpdateAccountData } from '@shared/types'
|
||||
|
||||
/**
|
||||
* Reset the password, either from a valid ticket or from a valid JWT and a valid password
|
||||
*/
|
||||
async function changePassword({ body, headers }: Request, res: Response): Promise<unknown> {
|
||||
async function changePassword(req: Request, res: Response): Promise<unknown> {
|
||||
let password_hash: string
|
||||
|
||||
if (body.ticket) {
|
||||
if (req.body.ticket) {
|
||||
// Reset the password from { ticket, new_password }
|
||||
const { ticket, new_password } = await resetPasswordWithTicketSchema.validateAsync(body)
|
||||
const { ticket, new_password } = await resetPasswordWithTicketSchema.validateAsync(req.body)
|
||||
|
||||
await checkHibp(new_password)
|
||||
password_hash = await hashPassword(new_password)
|
||||
@@ -39,10 +44,11 @@ async function changePassword({ body, headers }: Request, res: Response): Promis
|
||||
}
|
||||
} else {
|
||||
// Reset the password from valid JWT and { old_password, new_password }
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
const { old_password, new_password } = await resetPasswordWithOldPasswordSchema.validateAsync(
|
||||
body
|
||||
req.body
|
||||
)
|
||||
|
||||
await checkHibp(new_password)
|
||||
|
||||
29
src/routes/auth/delete.test.ts
Normal file
29
src/routes/auth/delete.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'jest-extended'
|
||||
|
||||
import { SuperTest, Test, agent } from 'supertest'
|
||||
|
||||
import { PORT } from '@shared/config'
|
||||
import { registerAccount } from '@test/test-utils'
|
||||
|
||||
import { app } from '../../server'
|
||||
|
||||
let request: SuperTest<Test>
|
||||
|
||||
const server = app.listen(PORT)
|
||||
|
||||
// * Code that is executed before any jest test file that imports this file
|
||||
beforeAll(async () => {
|
||||
request = agent(server) // * Create the SuperTest agent
|
||||
registerAccount(request)
|
||||
})
|
||||
|
||||
// * Code that is executed after any jest test file that imports test-utiles
|
||||
afterAll(async () => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('should delete an account', async () => {
|
||||
await registerAccount(request)
|
||||
const { status } = await request.post('/auth/delete')
|
||||
expect(status).toEqual(204)
|
||||
})
|
||||
@@ -1,28 +1,24 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import Boom from '@hapi/boom'
|
||||
import { asyncWrapper } from '@shared/helpers'
|
||||
import { asyncWrapper, getPermissionVariablesFromCookie } from '@shared/helpers'
|
||||
import { deleteAccountByUserId } from '@shared/queries'
|
||||
import { request } from '@shared/request'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
import { DeleteAccountData } from '@shared/types'
|
||||
import { ALLOW_USER_SELF_DELETE } from '@shared/config'
|
||||
|
||||
async function deleteUser({ headers }: Request, res: Response): Promise<unknown> {
|
||||
if (!ALLOW_USER_SELF_DELETE) {
|
||||
throw Boom.notFound()
|
||||
}
|
||||
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
async function deleteUser(req: Request, res: Response): Promise<unknown> {
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
const hasuraData = await request<DeleteAccountData>(deleteAccountByUserId, { user_id })
|
||||
|
||||
if (!hasuraData.delete_auth_accounts.affected_rows) {
|
||||
throw Boom.unauthorized('Invalid or expired JWT token.')
|
||||
throw Boom.unauthorized('Unable to delete account')
|
||||
}
|
||||
|
||||
// clear cookie
|
||||
// clear cookies
|
||||
res.clearCookie('refresh_token')
|
||||
res.clearCookie('permission_variables')
|
||||
return res.status(204).send()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { MFA_ENABLE, CHANGE_EMAIL_ENABLE } from '@shared/config'
|
||||
import {
|
||||
MFA_ENABLE,
|
||||
CHANGE_EMAIL_ENABLE,
|
||||
AUTO_ACTIVATE_NEW_USERS,
|
||||
ALLOW_USER_SELF_DELETE
|
||||
} from '@shared/config'
|
||||
import { Router } from 'express'
|
||||
import account from './account'
|
||||
import changeEmail from './change-email'
|
||||
import getJwks from './jwks'
|
||||
import loginAccount from './login'
|
||||
@@ -10,6 +14,8 @@ import changePassword from './change-password'
|
||||
import providers from './providers'
|
||||
import registerAccount from './register'
|
||||
import token from './token'
|
||||
import activateAccount from './activate'
|
||||
import deleteAccount from './delete'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -25,13 +31,20 @@ if (CHANGE_EMAIL_ENABLE) {
|
||||
router.use('/change-email', changeEmail)
|
||||
}
|
||||
|
||||
if (!AUTO_ACTIVATE_NEW_USERS) {
|
||||
router.get('/activate', activateAccount)
|
||||
}
|
||||
|
||||
if (ALLOW_USER_SELF_DELETE) {
|
||||
router.post('/delete', deleteAccount)
|
||||
}
|
||||
|
||||
router
|
||||
.get('/jwks', getJwks)
|
||||
.post('/login', loginAccount)
|
||||
.post('/logout', logout)
|
||||
.post('/register', registerAccount)
|
||||
.use('/token', token)
|
||||
.use('/account', account)
|
||||
.use('/change-password', changePassword)
|
||||
|
||||
export default router
|
||||
|
||||
@@ -17,6 +17,7 @@ interface HasuraData {
|
||||
async function logout({ body, cookies, signedCookies }: Request, res: Response): Promise<unknown> {
|
||||
// clear cookie
|
||||
res.clearCookie('refresh_token')
|
||||
res.clearCookie('permission_variables')
|
||||
|
||||
// should we delete all refresh tokens to this user or not
|
||||
const { all } = await logoutSchema.validateAsync(body)
|
||||
@@ -35,20 +36,12 @@ async function logout({ body, cookies, signedCookies }: Request, res: Response):
|
||||
return res.status(204).send()
|
||||
}
|
||||
|
||||
if (!hasura_data) {
|
||||
const account = hasura_data?.auth_refresh_tokens?.[0]?.account
|
||||
|
||||
if (!account) {
|
||||
return res.status(204).send()
|
||||
}
|
||||
|
||||
const { auth_refresh_tokens } = hasura_data
|
||||
|
||||
// if no accounts found
|
||||
if (!auth_refresh_tokens.length) {
|
||||
return res.status(204).send()
|
||||
}
|
||||
|
||||
// get logged out account
|
||||
const { account } = auth_refresh_tokens[0]
|
||||
|
||||
// delete all refresh tokens for user
|
||||
try {
|
||||
await request(deleteAllAccountRefreshTokens, {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { asyncWrapper, selectAccountByUserId } from '@shared/helpers'
|
||||
import {
|
||||
asyncWrapper,
|
||||
selectAccountByUserId,
|
||||
getPermissionVariablesFromCookie
|
||||
} from '@shared/helpers'
|
||||
import { Request, Response } from 'express'
|
||||
import { deleteOtpSecret } from '@shared/queries'
|
||||
|
||||
@@ -6,12 +10,12 @@ import Boom from '@hapi/boom'
|
||||
import { authenticator } from 'otplib'
|
||||
import { mfaSchema } from '@shared/validation'
|
||||
import { request } from '@shared/request'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
|
||||
async function disableMfa({ headers, body }: Request, res: Response): Promise<unknown> {
|
||||
const { code } = await mfaSchema.validateAsync(body)
|
||||
async function disableMfa(req: Request, res: Response): Promise<unknown> {
|
||||
const { code } = await mfaSchema.validateAsync(req.body)
|
||||
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
const { otp_secret, mfa_enabled } = await selectAccountByUserId(user_id)
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { asyncWrapper, selectAccountByUserId } from '@shared/helpers'
|
||||
import {
|
||||
asyncWrapper,
|
||||
selectAccountByUserId,
|
||||
getPermissionVariablesFromCookie
|
||||
} from '@shared/helpers'
|
||||
import { Request, Response } from 'express'
|
||||
import { updateOtpStatus } from '@shared/queries'
|
||||
|
||||
@@ -6,12 +10,12 @@ import Boom from '@hapi/boom'
|
||||
import { authenticator } from 'otplib'
|
||||
import { mfaSchema } from '@shared/validation'
|
||||
import { request } from '@shared/request'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
|
||||
async function enableMfa({ headers, body }: Request, res: Response): Promise<unknown> {
|
||||
const { code } = await mfaSchema.validateAsync(body)
|
||||
async function enableMfa(req: Request, res: Response): Promise<unknown> {
|
||||
const { code } = await mfaSchema.validateAsync(req.body)
|
||||
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
const { otp_secret, mfa_enabled } = await selectAccountByUserId(user_id)
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { asyncWrapper, createQR } from '@shared/helpers'
|
||||
|
||||
import { asyncWrapper, createQR, getPermissionVariablesFromCookie } from '@shared/helpers'
|
||||
import { OTP_ISSUER } from '@shared/config'
|
||||
import { authenticator } from 'otplib'
|
||||
import { request } from '@shared/request'
|
||||
import { updateOtpSecret } from '@shared/queries'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
|
||||
async function generateMfa({ headers }: Request, res: Response): Promise<unknown> {
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
async function generateMfa(req: Request, res: Response): Promise<unknown> {
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
/**
|
||||
* Generate OTP secret and key URI.
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
newJwtExpiry,
|
||||
newRefreshExpiry,
|
||||
createHasuraJwt,
|
||||
setRefreshTokenAsCookie
|
||||
setCookie,
|
||||
generatePermissionVariables
|
||||
} from '@shared/jwt'
|
||||
import { request } from '@shared/request'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -54,7 +55,9 @@ async function refreshToken({ cookies, signedCookies }: Request, res: Response):
|
||||
throw Boom.badImplementation('Unable to set new refresh token')
|
||||
}
|
||||
|
||||
setRefreshTokenAsCookie(res, new_refresh_token)
|
||||
const permission_variables = JSON.stringify(generatePermissionVariables(account))
|
||||
|
||||
setCookie(res, new_refresh_token, permission_variables)
|
||||
|
||||
return res.send({
|
||||
jwt_token: createHasuraJwt(account),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { asyncWrapper } from '@shared/helpers'
|
||||
import { asyncWrapper, getPermissionVariablesFromCookie } from '@shared/helpers'
|
||||
import { deleteAllAccountRefreshTokens } from '@shared/queries'
|
||||
import { request } from '@shared/request'
|
||||
import { getClaims } from '@shared/jwt'
|
||||
|
||||
async function revokeToken({ headers }: Request, res: Response): Promise<unknown> {
|
||||
const user_id = getClaims(headers.authorization)['x-hasura-user-id']
|
||||
async function revokeToken(req: Request, res: Response): Promise<unknown> {
|
||||
const permission_variables = getPermissionVariablesFromCookie(req)
|
||||
const user_id = permission_variables['user-id']
|
||||
|
||||
await request(deleteAllAccountRefreshTokens, { user_id })
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import {
|
||||
StoragePermissions,
|
||||
PathConfig,
|
||||
createContext,
|
||||
getHeadObject,
|
||||
getKey,
|
||||
@@ -16,7 +16,7 @@ export const deleteFile = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
rules: Partial<StoragePermissions>,
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest = false
|
||||
): Promise<unknown> => {
|
||||
const headObject = await getHeadObject(req)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { StoragePermissions, createContext, getHeadObject, getKey, hasPermission } from './utils'
|
||||
import { PathConfig, createContext, getHeadObject, getKey, hasPermission } from './utils'
|
||||
|
||||
import Boom from '@hapi/boom'
|
||||
import { S3_BUCKET } from '@shared/config'
|
||||
@@ -9,7 +9,7 @@ export const getFile = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
rules: Partial<StoragePermissions>,
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest = false
|
||||
): Promise<unknown> => {
|
||||
const key = getKey(req)
|
||||
@@ -24,7 +24,7 @@ export const getFile = async (
|
||||
throw Boom.forbidden()
|
||||
}
|
||||
if (isMetadataRequest) {
|
||||
return res.status(200).send(headObject)
|
||||
return res.status(200).send({ key, ...headObject })
|
||||
} else {
|
||||
const params = {
|
||||
Bucket: S3_BUCKET as string,
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
import { META_PREFIX, STORAGE_RULES, StoragePermissions, containsSomeRule } from './utils'
|
||||
import { OBJECT_PREFIX, META_PREFIX, STORAGE_RULES, PathConfig, containsSomeRule } from './utils'
|
||||
import { NextFunction, Request, Response, Router } from 'express'
|
||||
|
||||
import { deleteFile } from './delete'
|
||||
import { getFile } from './get'
|
||||
import { listFile } from './list'
|
||||
import { getFile } from './get'
|
||||
import { uploadFile } from './upload'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const createSecureMiddleware = (
|
||||
fn: Function,
|
||||
rules: Partial<StoragePermissions>,
|
||||
isMetadataRequest: boolean,
|
||||
metadataParams: object = {}
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest: boolean
|
||||
) => (req: Request, res: Response, next: NextFunction): void =>
|
||||
fn(req, res, next, rules, isMetadataRequest, metadataParams).catch(next)
|
||||
fn(req, res, next, rules, isMetadataRequest, rules.metadata).catch(next)
|
||||
|
||||
const createRoutes = (
|
||||
path: string,
|
||||
rules: Partial<StoragePermissions>,
|
||||
isMetadataRequest = false,
|
||||
metadataParams: object = {}
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest = false
|
||||
): Router => {
|
||||
const middleware = Router()
|
||||
|
||||
// write, create, update
|
||||
if (containsSomeRule(rules, ['write', 'create', 'update'])) {
|
||||
middleware.post(
|
||||
path,
|
||||
createSecureMiddleware(uploadFile, rules, isMetadataRequest, metadataParams)
|
||||
)
|
||||
middleware.post(path, createSecureMiddleware(uploadFile, rules, isMetadataRequest))
|
||||
}
|
||||
if (containsSomeRule(rules, ['read', 'get'])) {
|
||||
middleware.get(path, createSecureMiddleware(getFile, rules, isMetadataRequest))
|
||||
}
|
||||
if (containsSomeRule(rules, ['read', 'list'])) {
|
||||
middleware.get(
|
||||
`${path.substring(0, path.lastIndexOf('/'))}`,
|
||||
createSecureMiddleware(listFile, rules, isMetadataRequest)
|
||||
)
|
||||
|
||||
// read, get, list
|
||||
if (containsSomeRule(rules, ['read', 'get', 'list'])) {
|
||||
if (path.endsWith('/')) {
|
||||
middleware.get(path, createSecureMiddleware(listFile, rules, isMetadataRequest))
|
||||
} else {
|
||||
middleware.get(path, createSecureMiddleware(getFile, rules, isMetadataRequest))
|
||||
middleware.get(
|
||||
path.substring(0, path.lastIndexOf('/')),
|
||||
createSecureMiddleware(listFile, rules, isMetadataRequest)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// write, delete
|
||||
if (containsSomeRule(rules, ['write', 'delete'])) {
|
||||
middleware.delete(
|
||||
path,
|
||||
createSecureMiddleware(deleteFile, rules, isMetadataRequest, metadataParams)
|
||||
)
|
||||
middleware.delete(path, createSecureMiddleware(deleteFile, rules, isMetadataRequest))
|
||||
}
|
||||
|
||||
return middleware
|
||||
}
|
||||
|
||||
for (const path in STORAGE_RULES.paths) {
|
||||
const rules = STORAGE_RULES.paths[path]
|
||||
router.use(createRoutes(path, rules, false, rules.meta?.values))
|
||||
if (
|
||||
containsSomeRule(rules.meta, ['read', 'write', 'get', 'create', 'update', 'delete', 'list'])
|
||||
) {
|
||||
router.use(META_PREFIX, createRoutes(path, rules.meta, true, rules.meta?.values))
|
||||
}
|
||||
|
||||
// create object data paths
|
||||
router.use(OBJECT_PREFIX, createRoutes(path, rules, false))
|
||||
|
||||
// create meta data paths
|
||||
router.use(META_PREFIX, createRoutes(path, rules, true))
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { StoragePermissions, createContext, getKey, hasPermission } from './utils'
|
||||
import { PathConfig, createContext, getKey, hasPermission } from './utils'
|
||||
|
||||
import Boom from '@hapi/boom'
|
||||
import { S3_BUCKET } from '@shared/config'
|
||||
@@ -10,25 +10,20 @@ export const listFile = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
rules: Partial<StoragePermissions>,
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest = false
|
||||
): Promise<unknown> => {
|
||||
const key = getKey(req)
|
||||
try {
|
||||
const context = createContext(req)
|
||||
if (!hasPermission([rules.list, rules.read], context)) {
|
||||
throw Boom.forbidden()
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(
|
||||
'List validation did not apply to a directory (without object context). Will anyway run validation for each object in the list.'
|
||||
)
|
||||
const context = createContext(req)
|
||||
if (!hasPermission([rules.list, rules.read], context)) {
|
||||
throw Boom.forbidden()
|
||||
}
|
||||
const params = {
|
||||
Bucket: S3_BUCKET as string,
|
||||
Prefix: key.slice(0, -1)
|
||||
}
|
||||
const list = await s3.listObjectsV2(params).promise()
|
||||
|
||||
if (list.Contents) {
|
||||
const headObjectsList = (
|
||||
await Promise.all(
|
||||
@@ -43,8 +38,16 @@ export const listFile = async (
|
||||
}))
|
||||
)
|
||||
).filter((resource) => hasPermission([rules.list, rules.read], createContext(req, resource)))
|
||||
|
||||
if (isMetadataRequest) {
|
||||
return res.status(200).send(headObjectsList.map((entry) => entry.head))
|
||||
return res.status(200).send(
|
||||
headObjectsList.map((entry) => {
|
||||
return {
|
||||
Key: entry.key,
|
||||
...entry.head
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
const archive = archiver('zip')
|
||||
headObjectsList.forEach((entry) => {
|
||||
|
||||
@@ -18,8 +18,7 @@ it('should upload a new file', async () => {
|
||||
Metadata: { token, description }
|
||||
}
|
||||
} = await request
|
||||
.post(`/storage/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
.post(`/storage/o/user/${getUserId()}/${filePath}`)
|
||||
.query({ description: initialDescription })
|
||||
.attach('file', filePath)
|
||||
expect(status).toEqual(200)
|
||||
@@ -30,49 +29,53 @@ it('should upload a new file', async () => {
|
||||
|
||||
it('should update an existing file', async () => {
|
||||
const { status } = await request
|
||||
.post(`/storage/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
.post(`/storage/o/user/${getUserId()}/${filePath}`)
|
||||
.attach('file', filePath)
|
||||
expect(status).toEqual(200)
|
||||
})
|
||||
|
||||
it('should not update an hypothetical file of another hypothetical user', async () => {
|
||||
const { status } = await request
|
||||
.post(`/storage/user/another-user/another-file`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
.post(`/storage/o/user/another-user/another-file`)
|
||||
.attach('file', filePath)
|
||||
expect(status).toEqual(403)
|
||||
})
|
||||
|
||||
it('should get file from user authentication', async () => {
|
||||
const { status, text } = await request
|
||||
.get(`/storage/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
it('should get file', async () => {
|
||||
const { status, text } = await request.get(`/storage/o/user/${getUserId()}/${filePath}`)
|
||||
const fileData = await readFile(filePath, 'utf8')
|
||||
expect(status).toEqual(200)
|
||||
expect(text).toEqual(fileData)
|
||||
})
|
||||
|
||||
it('should get file from the token stored in the file metadata', async () => {
|
||||
const { status, text } = await request
|
||||
.get(`/storage/user/${getUserId()}/${filePath}`)
|
||||
.query({ token: fileToken })
|
||||
const fileData = await readFile(filePath, 'utf8')
|
||||
expect(status).toEqual(200)
|
||||
expect(text).toEqual(fileData)
|
||||
})
|
||||
describe('Tests as an unauthenticated user', () => {
|
||||
beforeAll(async () => {
|
||||
await request.post(`/auth/logout`)
|
||||
})
|
||||
|
||||
it('should not get file without authentication nor token', async () => {
|
||||
const { status } = await request.get(`/storage/user/${getUserId()}/${filePath}`)
|
||||
expect(status).toEqual(403)
|
||||
})
|
||||
afterAll(async () => {
|
||||
await request.post('/auth/login').send({ email: account.email, password: account.password })
|
||||
})
|
||||
|
||||
// TODO attempt to get the file from another authenticated user
|
||||
it('should get file from the token stored in the file metadata while unauthenticated', async () => {
|
||||
const { status, text } = await request
|
||||
.get(`/storage/o/user/${getUserId()}/${filePath}`)
|
||||
.query({ token: fileToken })
|
||||
const fileData = await readFile(filePath, 'utf8')
|
||||
expect(status).toEqual(200)
|
||||
expect(text).toEqual(fileData)
|
||||
})
|
||||
|
||||
it('should not get file without authentication nor token', async () => {
|
||||
const { status } = await request.get(`/storage/o/user/${getUserId()}/${filePath}`)
|
||||
expect(status).toEqual(403)
|
||||
})
|
||||
// TODO attempt to get the file from another authenticated user
|
||||
})
|
||||
|
||||
it(`should update an existing file's metadata`, async () => {
|
||||
const { status } = await request
|
||||
.post(`/storage/meta/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
.post(`/storage/m/user/${getUserId()}/${filePath}`)
|
||||
.query({ description: newDescription })
|
||||
expect(status).toEqual(200)
|
||||
})
|
||||
@@ -83,44 +86,34 @@ it('should get file metadata', async () => {
|
||||
body: {
|
||||
Metadata: { filename, description }
|
||||
}
|
||||
} = await request
|
||||
.get(`/storage/meta/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
} = await request.get(`/storage/m/user/${getUserId()}/${filePath}`)
|
||||
expect(status).toEqual(200)
|
||||
expect(filename).toEqual(filePath)
|
||||
expect(description).toEqual(newDescription)
|
||||
})
|
||||
|
||||
it('should get the headers of all the user files', async () => {
|
||||
const { status, body } = await request
|
||||
.get(`/storage/meta/user/${getUserId()}/`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
const { status, body } = await request.get(`/storage/m/user/${getUserId()}/`)
|
||||
expect(status).toEqual(200)
|
||||
expect(body).toBeArrayOfSize(1)
|
||||
})
|
||||
|
||||
it('should get a zip that contains all user files', async () => {
|
||||
const { status, text } = await request
|
||||
.get(`/storage/user/${getUserId()}/`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
const { status, text } = await request.get(`/storage/m/user/${getUserId()}/`)
|
||||
expect(status).toEqual(200)
|
||||
expect(text).toBeTruthy()
|
||||
// TODO unzip and compare the file(s)
|
||||
})
|
||||
|
||||
it('should delete file metadata', async () => {
|
||||
const { status } = await request
|
||||
.delete(`/storage/meta/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
const { status } = await request.delete(`/storage/m/user/${getUserId()}/${filePath}`)
|
||||
expect(status).toEqual(204)
|
||||
const {
|
||||
status: getStatus,
|
||||
body: {
|
||||
Metadata: { filename, token, description }
|
||||
}
|
||||
} = await request
|
||||
.get(`/storage/meta/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
} = await request.get(`/storage/m/user/${getUserId()}/${filePath}`)
|
||||
expect(getStatus).toEqual(200)
|
||||
expect(filename).toBeUndefined()
|
||||
expect(token).toBeString()
|
||||
@@ -128,8 +121,6 @@ it('should delete file metadata', async () => {
|
||||
})
|
||||
|
||||
it('should delete file', async () => {
|
||||
const { status } = await request
|
||||
.delete(`/storage/user/${getUserId()}/${filePath}`)
|
||||
.set('Authorization', `Bearer ${account.token}`)
|
||||
const { status } = await request.delete(`/storage/m/user/${getUserId()}/${filePath}`)
|
||||
expect(status).toEqual(204)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
StoragePermissions,
|
||||
PathConfig,
|
||||
createContext,
|
||||
generateMetadata,
|
||||
getHeadObject,
|
||||
@@ -19,7 +19,7 @@ export const uploadFile = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
rules: Partial<StoragePermissions>,
|
||||
rules: Partial<PathConfig>,
|
||||
isMetadataRequest = false,
|
||||
metadata: object = {}
|
||||
): Promise<unknown> => {
|
||||
@@ -56,6 +56,7 @@ export const uploadFile = async (
|
||||
try {
|
||||
await s3.upload(upload_params).promise()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw Boom.badImplementation('Impossible to create or update the object.')
|
||||
}
|
||||
} else if (!isNew) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getClaims } from '@shared/jwt'
|
||||
import safeEval, { FunctionFactory } from 'notevil'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -10,10 +9,12 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { s3 } from '@shared/s3'
|
||||
import yaml from 'js-yaml'
|
||||
import { Claims } from '@shared/types'
|
||||
import { PermissionVariables } from '@shared/types'
|
||||
import { getPermissionVariablesFromCookie } from '@shared/helpers'
|
||||
|
||||
export const META_PREFIX = '/meta'
|
||||
export interface StoragePermissions {
|
||||
export const OBJECT_PREFIX = '/o'
|
||||
export const META_PREFIX = '/m'
|
||||
export interface PathConfig {
|
||||
read: string
|
||||
write: string
|
||||
get: string
|
||||
@@ -21,14 +22,13 @@ export interface StoragePermissions {
|
||||
create: string
|
||||
update: string
|
||||
delete: string
|
||||
metadata?: { [key: string]: string }
|
||||
}
|
||||
|
||||
interface StorageRules {
|
||||
functions?: { [key: string]: string | { params: string[]; code: string } }
|
||||
paths: {
|
||||
[key: string]: Partial<StoragePermissions> & {
|
||||
meta: Partial<StoragePermissions> & { values?: { [key: string]: string } }
|
||||
}
|
||||
[key: string]: Partial<PathConfig>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ interface StorageRequest {
|
||||
query: unknown
|
||||
method: string
|
||||
params?: string
|
||||
auth?: Claims
|
||||
auth?: PermissionVariables
|
||||
}
|
||||
|
||||
export const containsSomeRule = (
|
||||
rulesDefinition: Partial<StoragePermissions> = {},
|
||||
rulesDefinition: Partial<PathConfig> = {},
|
||||
rules: (string | undefined)[]
|
||||
): boolean => Object.keys(rulesDefinition).some((rule) => rules.includes(rule))
|
||||
|
||||
@@ -86,10 +86,11 @@ export const createContext = (
|
||||
): object => {
|
||||
let auth
|
||||
try {
|
||||
auth = getClaims(req.headers.authorization)
|
||||
} catch {
|
||||
auth = getPermissionVariablesFromCookie(req)
|
||||
} catch (err) {
|
||||
auth = undefined
|
||||
}
|
||||
|
||||
const variables: StorageContext = {
|
||||
request: {
|
||||
path: req.path,
|
||||
@@ -100,12 +101,15 @@ export const createContext = (
|
||||
...req.params,
|
||||
resource: s3HeadObject
|
||||
}
|
||||
|
||||
const functions = storageFunctions(variables)
|
||||
|
||||
return { ...functions, ...variables }
|
||||
}
|
||||
|
||||
export const hasPermission = (rules: (string | undefined)[], context: object): boolean =>
|
||||
rules.some((rule) => rule && !!safeEval(rule, context))
|
||||
export const hasPermission = (rules: (string | undefined)[], context: object): boolean => {
|
||||
return rules.some((rule) => rule && !!safeEval(rule, context))
|
||||
}
|
||||
|
||||
export const generateMetadata = (metadataParams: object, context: object): object =>
|
||||
Object.entries(metadataParams).reduce<{ [key: string]: unknown }>((aggr, [key, jsCode]) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HIBP_ENABLE } from './config'
|
||||
import { HIBP_ENABLE, COOKIE_SECRET } from './config'
|
||||
import { NextFunction, Request, Response } from 'express'
|
||||
import {
|
||||
rotateTicket as rotateTicketQuery,
|
||||
@@ -13,7 +13,7 @@ import bcrypt from 'bcryptjs'
|
||||
import { pwnedPassword } from 'hibp'
|
||||
import { request } from './request'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { AccountData, QueryAccountData } from './types'
|
||||
import { AccountData, QueryAccountData, PermissionVariables } from './types'
|
||||
|
||||
/**
|
||||
* Create QR code.
|
||||
@@ -112,3 +112,9 @@ export const rotateTicket = async (ticket: string): Promise<string> => {
|
||||
})
|
||||
return new_ticket
|
||||
}
|
||||
|
||||
export const getPermissionVariablesFromCookie = (req: Request): PermissionVariables => {
|
||||
const { permission_variables } = COOKIE_SECRET ? req.signedCookies : req.cookies
|
||||
if (!permission_variables) throw Boom.unauthorized()
|
||||
return JSON.parse(permission_variables)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
JWT_CLAIMS_NAMESPACE,
|
||||
JWT_REFRESH_EXPIRES_IN,
|
||||
DEFAULT_USER_ROLE,
|
||||
DEFAULT_ANONYMOUS_ROLE
|
||||
DEFAULT_ANONYMOUS_ROLE,
|
||||
JWT_CUSTOM_FIELDS
|
||||
} from './config'
|
||||
import { JWK, JWKS, JWT } from 'jose'
|
||||
|
||||
@@ -20,6 +21,12 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import kebabCase from 'lodash.kebabcase'
|
||||
import { Claims, Token, AccountData, ClaimValueType } from './types'
|
||||
|
||||
interface InsertRefreshTokenData {
|
||||
insert_auth_refresh_tokens_one: {
|
||||
account: AccountData
|
||||
}
|
||||
}
|
||||
|
||||
const RSA_TYPES = ['RS256', 'RS384', 'RS512']
|
||||
const SHA_TYPES = ['HS256', 'HS384', 'HS512']
|
||||
|
||||
@@ -58,6 +65,40 @@ if (RSA_TYPES.includes(JWT_ALGORITHM)) {
|
||||
|
||||
export const newJwtExpiry = JWT_EXPIRES_IN * 60 * 1000
|
||||
|
||||
/**
|
||||
* Create an object that contains all the permission variables of the user,
|
||||
* i.e. user-id, allowed-roles, default-role and the kebab-cased columns
|
||||
* of the public.tables columns defined in JWT_CUSTOM_FIELDS
|
||||
* @param jwt if true, add a 'x-hasura-' prefix to the property names, and stringifies the values (required by Hasura)
|
||||
*/
|
||||
export function generatePermissionVariables(
|
||||
{ default_role, account_roles = [], user }: AccountData,
|
||||
jwt = false
|
||||
): { [key: string]: ClaimValueType } {
|
||||
const prefix = jwt ? 'x-hasura-' : ''
|
||||
const role = user.is_anonymous ? DEFAULT_ANONYMOUS_ROLE : default_role || DEFAULT_USER_ROLE
|
||||
const accountRoles = account_roles.map(({ role: roleName }) => roleName)
|
||||
|
||||
if (!accountRoles.includes(role)) {
|
||||
accountRoles.push(role)
|
||||
}
|
||||
|
||||
return {
|
||||
[`${prefix}user-id`]: user.id,
|
||||
[`${prefix}allowed-roles`]: accountRoles,
|
||||
[`${prefix}default-role`]: role,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...JWT_CUSTOM_FIELDS.reduce<{ [key: string]: ClaimValueType }>((aggr: any, cursor) => {
|
||||
aggr[`${prefix}${kebabCase(cursor)}`] = jwt
|
||||
? typeof user[cursor] === 'string'
|
||||
? user[cursor]
|
||||
: JSON.stringify(user[cursor] ?? null)
|
||||
: user[cursor]
|
||||
return aggr
|
||||
}, {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* * Creates a JWKS store. Only works with RSA algorithms. Raises an error otherwise
|
||||
* @returns JWKS store
|
||||
@@ -111,7 +152,11 @@ export function newRefreshExpiry(): number {
|
||||
* @param res Express Response
|
||||
* @param refresh_token Refresh token to be set
|
||||
*/
|
||||
export const setRefreshTokenAsCookie = (res: Response, refresh_token: string): void => {
|
||||
export const setCookie = (
|
||||
res: Response,
|
||||
refresh_token: string,
|
||||
permission_variables: string
|
||||
): void => {
|
||||
// converting JWT_REFRESH_EXPIRES_IN from minutes to milliseconds
|
||||
const maxAge = JWT_REFRESH_EXPIRES_IN * 60 * 1000
|
||||
|
||||
@@ -121,6 +166,13 @@ export const setRefreshTokenAsCookie = (res: Response, refresh_token: string): v
|
||||
maxAge,
|
||||
signed: Boolean(COOKIE_SECRET)
|
||||
})
|
||||
|
||||
// set permission variables cookie
|
||||
res.cookie('permission_variables', permission_variables, {
|
||||
httpOnly: true,
|
||||
maxAge,
|
||||
signed: Boolean(COOKIE_SECRET)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,46 +189,26 @@ export const setRefreshToken = async (
|
||||
if (!refresh_token) {
|
||||
refresh_token = uuidv4()
|
||||
}
|
||||
await request(insertRefreshToken, {
|
||||
|
||||
const insert_account_data = (await request(insertRefreshToken, {
|
||||
refresh_token_data: {
|
||||
account_id: accountId,
|
||||
refresh_token,
|
||||
expires_at: new Date(newRefreshExpiry())
|
||||
}
|
||||
})
|
||||
})) as InsertRefreshTokenData
|
||||
|
||||
setRefreshTokenAsCookie(res, refresh_token)
|
||||
const { account } = insert_account_data.insert_auth_refresh_tokens_one
|
||||
|
||||
const permission_variables = JSON.stringify(generatePermissionVariables(account))
|
||||
|
||||
setCookie(res, refresh_token, permission_variables)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JWT token.
|
||||
* @param id Required v4 UUID string.
|
||||
* @param defaultRole Defaults to "user".
|
||||
* @param roles Defaults to ["user"].
|
||||
*/
|
||||
export function createHasuraJwt({ default_role, account_roles = [], user }: AccountData): string {
|
||||
const role = user.is_anonymous ? DEFAULT_ANONYMOUS_ROLE : default_role || DEFAULT_USER_ROLE
|
||||
const accountRoles = account_roles.map(({ role: roleName }) => roleName)
|
||||
|
||||
if (!accountRoles.includes(role)) {
|
||||
accountRoles.push(role)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, ...customFields } = user
|
||||
return sign({
|
||||
[JWT_CLAIMS_NAMESPACE]: {
|
||||
'x-hasura-user-id': id,
|
||||
'x-hasura-allowed-roles': accountRoles,
|
||||
'x-hasura-default-role': role,
|
||||
// Add custom fields based on the user fields fetched from the GQL query
|
||||
...Object.entries(customFields).reduce<{ [k: string]: ClaimValueType }>(
|
||||
(aggr, [key, value]) => ({
|
||||
...aggr,
|
||||
[`x-hasura-${kebabCase(key)}`]:
|
||||
typeof value === 'string' ? value : JSON.stringify(value ?? null)
|
||||
}),
|
||||
{}
|
||||
)
|
||||
}
|
||||
export const createHasuraJwt = (accountData: AccountData): string =>
|
||||
sign({
|
||||
[JWT_CLAIMS_NAMESPACE]: generatePermissionVariables(accountData, true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,10 +97,13 @@ export const selectAccountByTicket = gql`
|
||||
|
||||
export const insertRefreshToken = gql`
|
||||
mutation($refresh_token_data: auth_refresh_tokens_insert_input!) {
|
||||
insert_auth_refresh_tokens(objects: [$refresh_token_data]) {
|
||||
affected_rows
|
||||
insert_auth_refresh_tokens_one(object: $refresh_token_data) {
|
||||
account {
|
||||
...accountFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${accountFragment}
|
||||
`
|
||||
|
||||
export const selectRefreshToken = gql`
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface Claims {
|
||||
[key: string]: ClaimValueType
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionVariables interface.
|
||||
*/
|
||||
export interface PermissionVariables {
|
||||
'user-id': string
|
||||
'default-role': string
|
||||
'allowed-roles': string[]
|
||||
[key: string]: ClaimValueType
|
||||
}
|
||||
|
||||
/**
|
||||
* Token interface.
|
||||
*/
|
||||
|
||||
@@ -95,7 +95,7 @@ export const registerAccount = async (agent: SuperTest<Test>): Promise<TestAccou
|
||||
await agent.post('/auth/register').send({ email, password })
|
||||
if (!AUTO_ACTIVATE_NEW_USERS) {
|
||||
const { ticket } = await selectAccountByEmail(email)
|
||||
await agent.get(`/auth/account/activate?ticket=${ticket}`)
|
||||
await agent.get(`/auth/activate?ticket=${ticket}`)
|
||||
await deleteEmailsOfAccount(email)
|
||||
}
|
||||
const res = await agent.post('/auth/login').send({ email, password })
|
||||
@@ -112,7 +112,7 @@ export const deleteAccount = async (
|
||||
account: TestAccount
|
||||
): Promise<void> => {
|
||||
// * Delete the account
|
||||
await agent.post('/auth/account/delete').set('Authorization', `Bearer ${account.token}`)
|
||||
await agent.post('/auth/delete')
|
||||
// * Remove any message sent to this account
|
||||
await deleteEmailsOfAccount(account.email)
|
||||
}
|
||||
|
||||
@@ -5372,10 +5372,9 @@ hash.js@^1.0.0, hash.js@^1.0.3:
|
||||
inherits "^2.0.3"
|
||||
minimalistic-assert "^1.0.1"
|
||||
|
||||
hasura-cli@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/hasura-cli/-/hasura-cli-1.2.0.tgz#a1ca61266cbc57141ce4b1d18b230b24b4b32106"
|
||||
integrity sha512-WIpiELAP1rcOF7F5yIRUiWzfF3E6j6mRKxCgc97+OU/X6NiNqTdGYU6FwzEJQ45Iw0AddBPOiwgZFvynN8zUAA==
|
||||
hasura-cli@1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/hasura-cli/-/hasura-cli-1.2.1.tgz#f25d94d3c10b074ae41346b58e9fdee1529b7bfe"
|
||||
dependencies:
|
||||
axios "^0.19.0"
|
||||
chalk "^2.4.2"
|
||||
|
||||
Reference in New Issue
Block a user