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:
Johan Eliasson
2020-05-08 07:08:21 +02:00
committed by GitHub
parent 1435c87422
commit ba9ea54880
34 changed files with 352 additions and 281 deletions

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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

View File

@@ -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](#) | |

View File

@@ -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:

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)

View 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)
})

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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, {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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),

View File

@@ -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 })

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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)
})

View File

@@ -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) {

View File

@@ -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]) => {

View File

@@ -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)
}

View File

@@ -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)
})
}

View File

@@ -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`

View File

@@ -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.
*/

View File

@@ -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)
}

View File

@@ -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"