Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added AUTH_REQUIRE_ELEVATED_CLAIM to require elevated permissions for certain actions #462

Merged
merged 7 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
}

dbarrosop marked this conversation as resolved.
Show resolved Hide resolved
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.............
dbarrosop marked this conversation as resolved.
Show resolved Hide resolved
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
Loading