diff --git a/.changeset/eight-brooms-explain.md b/.changeset/eight-brooms-explain.md new file mode 100644 index 000000000..75441fbf8 --- /dev/null +++ b/.changeset/eight-brooms-explain.md @@ -0,0 +1,5 @@ +--- +'hasura-auth': minor +--- + +feat: added AUTH_REQUIRE_ELEVATED_CLAIM to require elevated permissions for certain action diff --git a/docs/environment-variables.md b/docs/environment-variables.md index c40cbc7c8..345e25c11 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -56,6 +56,7 @@ | AUTH_WEBAUTHN_RP_ID | Relying party id. If not set `AUTH_CLIENT_URL` will be used as a default. | | | AUTH_WEBAUTHN_RP_ORIGINS | Array of URLs where the registration is permitted and should have occurred on. `AUTH_CLIENT_URL` will be automatically added to the list of origins if is set. | | | AUTH_WEBAUTHN_ATTESTATION_TIMEOUT | How long (in ms) the user can take to complete authentication. | `60000` (1 minute) | +| AUTH_REQUIRE_ELEVATED_CLAIM | Require x-hasura-auth-elevated claim to perform certain actions: create PATs, change email and/or password, enable/disable MFA and add security keys. If set to `recommended` the claim check is only performed if the user has a security key attached. If set to `required` the only action that won't require the claim is setting a security key for the first time. | `disabled` | # OAuth environment variables diff --git a/src/errors.ts b/src/errors.ts index 15188f3f0..a88e71569 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -131,6 +131,10 @@ export const ERRORS = asErrors({ status: StatusCodes.UNAUTHORIZED, message: 'User is not logged in', }, + 'elevated-claim-required': { + status: StatusCodes.FORBIDDEN, + message: 'Elevated claim is required', + }, 'forbidden-endpoint-in-production': { status: StatusCodes.BAD_REQUEST, message: 'This endpoint is only available on test environments', diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index bb765862d..707666b49 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,8 @@ import { RequestHandler } from 'express'; import { getPermissionVariables } from '@/utils'; import { sendError } from '@/errors'; +import { ENV } from '../utils/env'; +import { gqlSdk } from '@/utils'; export const authMiddleware: RequestHandler = async (req, _, next) => { try { @@ -11,6 +13,7 @@ export const authMiddleware: RequestHandler = async (req, _, next) => { userId: permissionVariables['user-id'], defaultRole: permissionVariables['default-role'], isAnonymous: permissionVariables['is-anonymous'] === true, + elevated: permissionVariables['auth-elevated'] === permissionVariables['user-id'], }; } catch (e) { req.auth = null; @@ -18,10 +21,42 @@ export const authMiddleware: RequestHandler = async (req, _, next) => { next(); }; -export const authenticationGate: RequestHandler = (req, res, next) => { - if (!req.auth) { - return sendError(res, 'unauthenticated-user'); - } else { - next(); +export const authenticationGate = ( + checkElevatedPermissions: boolean, + bypassIfNoKeys = false, +): RequestHandler => { + return async (req, res, next) => { + if (!req.auth) { + return sendError(res, 'unauthenticated-user'); + } + + if (checkElevatedPermissions) { + const auth = req.auth as RequestAuth; + if (await failsElevatedCheck(auth, bypassIfNoKeys)) { + return sendError(res, 'elevated-claim-required'); + } + } + + return next(); + }; +} + +export const failsElevatedCheck = async (auth: RequestAuth, bypassIfNoKeys = false) => { + if (ENV.AUTH_REQUIRE_ELEVATED_CLAIM === 'disabled' || !ENV.AUTH_WEBAUTHN_ENABLED || auth.elevated) { + return false; } + + const response = await gqlSdk.getUserSecurityKeys({ + id: auth.userId, + }); + + if (response.authUserSecurityKeys.length === 0 && ENV.AUTH_REQUIRE_ELEVATED_CLAIM === 'recommended') { + return false; + } + + if (response.authUserSecurityKeys.length === 0 && bypassIfNoKeys) { + return false; + } + + return true; }; diff --git a/src/routes/mfa/index.ts b/src/routes/mfa/index.ts index 48f2bc6cc..4bc8f3110 100644 --- a/src/routes/mfa/index.ts +++ b/src/routes/mfa/index.ts @@ -17,7 +17,7 @@ const router = Router(); */ router.get( '/mfa/totp/generate', - authenticationGate, + authenticationGate(false), aw(mfatotpGenerateHandler) ); diff --git a/src/routes/pat/index.ts b/src/routes/pat/index.ts index 6528eb4ac..ffbd16f47 100644 --- a/src/routes/pat/index.ts +++ b/src/routes/pat/index.ts @@ -2,6 +2,7 @@ import { asyncWrapper as aw } from '@/utils'; import { bodyValidator } from '@/validation'; import { Router } from 'express'; import { createPATHandler, createPATSchema } from './pat'; +import { authenticationGate } from '@/middleware/auth'; const router = Router(); @@ -14,7 +15,12 @@ const router = Router(); * @return {UnauthorizedError} 401 - Unauthenticated user or invalid token - application/json * @tags General */ -router.post('/pat', bodyValidator(createPATSchema), aw(createPATHandler)); +router.post( + '/pat', + authenticationGate(true), + bodyValidator(createPATSchema), + aw(createPATHandler), +); const patRouter = router; export { patRouter }; diff --git a/src/routes/pat/pat.ts b/src/routes/pat/pat.ts index 7be09bbde..a133d7e5c 100644 --- a/src/routes/pat/pat.ts +++ b/src/routes/pat/pat.ts @@ -16,10 +16,6 @@ export const createPATHandler: RequestHandler< {}, { metadata: object; expiresAt: Date } > = async (req, res) => { - if (!req.auth) { - return sendError(res, 'unauthenticated-user'); - } - try { const { userId } = req.auth as RequestAuth; diff --git a/src/routes/user/index.ts b/src/routes/user/index.ts index 248165089..3054d0913 100644 --- a/src/routes/user/index.ts +++ b/src/routes/user/index.ts @@ -38,7 +38,11 @@ const router = Router(); * @security BearerAuth * @tags User management */ -router.get('/user', authenticationGate, aw(userHandler)); +router.get( + '/user', + authenticationGate(false), + aw(userHandler), +); /** * POST /user/password/reset @@ -67,6 +71,7 @@ router.post( router.post( '/user/password', bodyValidator(userPasswordSchema), + // authenticationGate(true, false, (req) => req.body.ticket !== undefined), // this is done in the handler because the handler has an auhtenticated and unauthenticated mode............. aw(userPasswordHandler) ); @@ -97,7 +102,7 @@ router.post( router.post( '/user/email/change', bodyValidator(userEmailChangeSchema), - authenticationGate, + authenticationGate(true), aw(userEmailChange) ); @@ -114,7 +119,7 @@ router.post( router.post( '/user/mfa', bodyValidator(userMfaSchema), - authenticationGate, + authenticationGate(true), aw(userMFAHandler) ); @@ -131,7 +136,7 @@ router.post( router.post( '/user/deanonymize', bodyValidator(userDeanonymizeSchema), - authenticationGate, + authenticationGate(false), aw(userDeanonymizeHandler) ); @@ -161,7 +166,11 @@ router.post( * @return {DisabledEndpointError} 404 - The feature is not activated - application/json * @tags User management */ -router.post('/user/webauthn/add', aw(addSecurityKeyHandler)); +router.post( + '/user/webauthn/add', + authenticationGate(true, true), + aw(addSecurityKeyHandler), +); // TODO add @return payload on success /** diff --git a/src/routes/user/password.ts b/src/routes/user/password.ts index 661257325..529d7f58c 100644 --- a/src/routes/user/password.ts +++ b/src/routes/user/password.ts @@ -1,6 +1,8 @@ import { RequestHandler } from 'express'; import { ReasonPhrases } from 'http-status-codes'; +import { failsElevatedCheck } from '@/middleware/auth'; + import { gqlSdk, hashPassword, getUserByTicket } from '@/utils'; import { sendError } from '@/errors'; import { Joi, password } from '@/validation'; @@ -27,6 +29,11 @@ export const userPasswordHandler: RequestHandler< if (!req.auth?.userId) { return sendError(res, 'unauthenticated-user'); } + + if (await failsElevatedCheck(req.auth)) { + return sendError(res, 'elevated-claim-required'); + } + user = (await gqlSdk.user({ id: req.auth?.userId })).user; } diff --git a/src/utils/env.ts b/src/utils/env.ts index 9f76627a9..70a8e0054 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -236,6 +236,10 @@ export const ENV = { return castBooleanEnv('AUTH_DISABLE_SIGNUP', false); }, + get AUTH_REQUIRE_ELEVATED_CLAIM() { + return castStringEnv('AUTH_REQUIRE_ELEVATED_CLAIM', 'disabled'); + } + // * See ../server.ts // get AUTH_SKIP_INIT() { // return castBooleanEnv('AUTH_SKIP_INIT', false); diff --git a/types/express-request.d.ts b/types/express-request.d.ts index 5c67f278b..372a7665f 100644 --- a/types/express-request.d.ts +++ b/types/express-request.d.ts @@ -2,6 +2,7 @@ interface RequestAuth { userId: string; defaultRole: string; isAnonymous: boolean; + elevated: boolean; } declare namespace Express {