Skip to content

Commit

Permalink
feat: added AUTH_REQUIRE_ELEVATED_CLAIM to require elevated permissio…
Browse files Browse the repository at this point in the history
…ns for certain actions (#462)
  • Loading branch information
dbarrosop authored Feb 13, 2024
1 parent b865c27 commit 0c52594
Show file tree
Hide file tree
Showing 11 changed files with 84 additions and 16 deletions.
5 changes: 5 additions & 0 deletions .changeset/eight-brooms-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'hasura-auth': minor
---

feat: added AUTH_REQUIRE_ELEVATED_CLAIM to require elevated permissions for certain action
1 change: 1 addition & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 40 additions & 5 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -11,17 +13,50 @@ 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;
}
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;
};
2 changes: 1 addition & 1 deletion src/routes/mfa/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const router = Router();
*/
router.get(
'/mfa/totp/generate',
authenticationGate,
authenticationGate(false),
aw(mfatotpGenerateHandler)
);

Expand Down
8 changes: 7 additions & 1 deletion src/routes/pat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 };
4 changes: 0 additions & 4 deletions src/routes/pat/pat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
19 changes: 14 additions & 5 deletions src/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
);

Expand Down Expand Up @@ -97,7 +102,7 @@ router.post(
router.post(
'/user/email/change',
bodyValidator(userEmailChangeSchema),
authenticationGate,
authenticationGate(true),
aw(userEmailChange)
);

Expand All @@ -114,7 +119,7 @@ router.post(
router.post(
'/user/mfa',
bodyValidator(userMfaSchema),
authenticationGate,
authenticationGate(true),
aw(userMFAHandler)
);

Expand All @@ -131,7 +136,7 @@ router.post(
router.post(
'/user/deanonymize',
bodyValidator(userDeanonymizeSchema),
authenticationGate,
authenticationGate(false),
aw(userDeanonymizeHandler)
);

Expand Down Expand Up @@ -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
/**
Expand Down
7 changes: 7 additions & 0 deletions src/routes/user/password.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions types/express-request.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ interface RequestAuth {
userId: string;
defaultRole: string;
isAnonymous: boolean;
elevated: boolean;
}

declare namespace Express {
Expand Down

0 comments on commit 0c52594

Please sign in to comment.