From d287c3e1529e0d9b8e225886f944752a0d33ce42 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Sat, 9 Mar 2024 09:01:13 +0100 Subject: [PATCH] Feat: Org Scoped JWT Tokens --- backend/src/server/routes/v3/login-router.ts | 64 +++++++++++++++++++ .../services/auth-token/auth-token-service.ts | 19 +++++- backend/src/services/auth/auth-fns.ts | 4 +- .../src/services/auth/auth-login-service.ts | 41 +++++++----- .../src/services/auth/auth-signup-service.ts | 2 + backend/src/services/auth/auth-type.ts | 6 ++ .../service-token/service-token-service.ts | 10 ++- .../permissions/PermissionDeniedBanner.tsx | 9 +-- 8 files changed, 129 insertions(+), 26 deletions(-) diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 240aa21b1..62002b775 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -1,7 +1,10 @@ +import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; import { authRateLimit } from "@app/server/config/rateLimiter"; +import { AuthModeJwtTokenPayload } from "@app/services/auth/auth-type"; export const registerLoginRouter = async (server: FastifyZodProvider) => { server.route({ @@ -34,6 +37,67 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/select-organization", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + token: z.string() + }) + } + }, + handler: async (req, res) => { + const cfg = getConfig(); + + if (!req.headers.authorization) throw new UnauthorizedError({ name: "Authorization header is required" }); + if (!req.headers["user-agent"]) throw new UnauthorizedError({ name: "user agent header is required" }); + + const userAgent = req.headers["user-agent"]; + const authToken = req.headers.authorization.slice(7); // slice of after Bearer + + // The decoded JWT token, which contains the auth method. + const decodedToken = jwt.verify(authToken, cfg.AUTH_SECRET) as AuthModeJwtTokenPayload; + + if (decodedToken.organizationId) { + throw new UnauthorizedError({ message: "You have already selected an organization" }); + } + + const user = await server.services.user.getMe(decodedToken.userId); + + // Check if the user actually has access to the specified organization. + const userOrgs = await server.services.org.findAllOrganizationOfUser(user.id); + + if (!userOrgs.some((org) => org.id === req.body.organizationId)) { + throw new UnauthorizedError({ message: "User does not have access to the organization" }); + } + + await server.services.authToken.clearTokenSessionById(decodedToken.userId, decodedToken.tokenVersionId); + const tokens = await server.services.login.generateUserTokens({ + authMethod: decodedToken.authMethod, + user, + userAgent, + ip: req.realIp, + organizationId: req.body.organizationId + }); + + void res.setCookie("jid", tokens.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED + }); + + return { token: tokens.access }; + } + }); + server.route({ method: "POST", url: "/login2", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a..a5c93e3aa 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -6,7 +6,8 @@ import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; -import { AuthModeJwtTokenPayload } from "../auth/auth-type"; +import { AuthMethod, AuthModeJwtTokenPayload } from "../auth/auth-type"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TTokenDALFactory } from "./auth-token-dal"; import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types"; @@ -14,6 +15,7 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; userDAL: Pick; + orgDAL: TOrgDALFactory; }; export type TAuthTokenServiceFactory = ReturnType; @@ -54,7 +56,7 @@ export const getTokenConfig = (tokenType: TokenType) => { } }; -export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFactoryDep) => { +export const tokenServiceFactory = ({ tokenDAL, userDAL, orgDAL }: TAuthTokenServiceFactoryDep) => { const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); @@ -135,12 +137,25 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFact id: token.tokenVersionId, userId: token.userId }); + if (!session) throw new UnauthorizedError({ name: "Session not found" }); if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" }); const user = await userDAL.findById(session.userId); if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" }); + if (token.organizationId) { + const organization = await orgDAL.findById(token.organizationId); + + if (organization.authEnforced) { + const tokenAuthMode = token.authMethod; + + if (![AuthMethod.AZURE_SAML, AuthMethod.OKTA_SAML, AuthMethod.JUMPCLOUD_SAML].includes(tokenAuthMode)) { + throw new UnauthorizedError({ name: "Organization enforces SAML" }); + } + } + } + return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId }; }; diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 0b78ab438..80fb0b325 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -15,10 +15,10 @@ export const validateProviderAuthToken = (providerToken: string, username?: stri if (decodedToken.username !== username) throw new Error("Invalid auth credentials"); if (decodedToken.organizationId) { - return { orgId: decodedToken.organizationId }; + return { orgId: decodedToken.organizationId, authMethod: decodedToken.authMethod }; } - return {}; + return { authMethod: decodedToken.authMethod, orgId: null }; }; export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 786e69d73..967160f08 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -17,7 +17,7 @@ import { TOauthLoginDTO, TVerifyMfaTokenDTO } from "./auth-login-type"; -import { AuthMethod, AuthTokenType } from "./auth-type"; +import { AuthMethod, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type"; type TAuthLoginServiceFactoryDep = { userDAL: TUserDALFactory; @@ -83,12 +83,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: user, ip, userAgent, - organizationId + organizationId, + authMethod }: { user: TUsers; ip: string; userAgent: string; organizationId?: string; + authMethod: AuthMethod; }) => { const cfg = getConfig(); await updateUserDeviceSession(user, ip, userAgent); @@ -98,8 +100,10 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: userId: user.id }); if (!tokenSession) throw new Error("Failed to create token"); + const accessToken = jwt.sign( { + authMethod, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: user.id, tokenVersionId: tokenSession.id, @@ -112,6 +116,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: const refreshToken = jwt.sign( { + authMethod, authTokenType: AuthTokenType.REFRESH_TOKEN, userId: user.id, tokenVersionId: tokenSession.id, @@ -158,9 +163,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: const loginExchangeClientProof = async ({ email, clientProof, - providerAuthToken, ip, - userAgent + userAgent, + providerAuthToken }: TLoginClientProofDTO) => { const userEnc = await userDAL.findUserEncKeyByUsername({ username: email @@ -168,14 +173,14 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: if (!userEnc) throw new Error("Failed to find user"); const cfg = getConfig(); - let organizationId; - if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { - const { orgId } = validateProviderAuthToken(providerAuthToken as string, email); - organizationId = orgId; - } else if (providerAuthToken) { - // SAML SSO - const { orgId } = validateProviderAuthToken(providerAuthToken, email); - organizationId = orgId; + // let organizationId; + + // let authMethod = (providerAuthToken as AuthMethod) || AuthMethod.EMAIL; + + let authMethod = AuthMethod.EMAIL; + + if (providerAuthToken) { + authMethod = validateProviderAuthToken(providerAuthToken, email).authMethod; } if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); @@ -196,9 +201,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: if (userEnc.isMfaEnabled && userEnc.email) { const mfaToken = jwt.sign( { + authMethod, authTokenType: AuthTokenType.MFA_TOKEN, - userId: userEnc.userId, - organizationId + userId: userEnc.userId }, cfg.AUTH_SECRET, { @@ -221,7 +226,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: }, ip, userAgent, - organizationId + authMethod }); return { token, isMfaEnabled: false, user: userEnc } as const; @@ -250,6 +255,9 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: userId, code: mfaToken }); + + const decodedToken = jwt.verify(mfaToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload; + const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to authenticate user"); @@ -260,7 +268,8 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: }, ip, userAgent, - organizationId: orgId + organizationId: orgId, + authMethod: decodedToken.authMethod }); return { token, user: userEnc }; diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 39bfec8b1..4c14cfb81 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -174,6 +174,7 @@ export const authSignupServiceFactory = ({ const accessToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, @@ -277,6 +278,7 @@ export const authSignupServiceFactory = ({ const accessToken = jwt.sign( { + authMethod: AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 57c86158f..6ce8de1bf 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -40,6 +40,7 @@ export enum ActorType { // would extend to AWS, Azure, ... export type AuthModeJwtTokenPayload = { authTokenType: AuthTokenType.ACCESS_TOKEN; + authMethod: AuthMethod; userId: string; tokenVersionId: string; accessVersion: number; @@ -48,12 +49,15 @@ export type AuthModeJwtTokenPayload = { export type AuthModeMfaJwtTokenPayload = { authTokenType: AuthTokenType.MFA_TOKEN; + authMethod: AuthMethod; userId: string; organizationId?: string; }; export type AuthModeRefreshJwtTokenPayload = { + // authMode authTokenType: AuthTokenType.REFRESH_TOKEN; + authMethod: AuthMethod; userId: string; tokenVersionId: string; refreshVersion: number; @@ -63,6 +67,8 @@ export type AuthModeRefreshJwtTokenPayload = { export type AuthModeProviderJwtTokenPayload = { authTokenType: AuthTokenType.PROVIDER_TOKEN; username: string; + authMethod: AuthMethod; + email: string; organizationId?: string; }; diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 3410aacad..798d2ca0c 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -9,6 +9,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; import { ActorType } from "../auth/auth-type"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TServiceTokenDALFactory } from "./service-token-dal"; @@ -24,6 +25,7 @@ type TServiceTokenServiceFactoryDep = { userDAL: TUserDALFactory; permissionService: Pick; projectEnvDAL: Pick; + projectDAL: Pick; }; export type TServiceTokenServiceFactory = ReturnType; @@ -32,7 +34,8 @@ export const serviceTokenServiceFactory = ({ serviceTokenDAL, userDAL, permissionService, - projectEnvDAL + projectEnvDAL, + projectDAL }: TServiceTokenServiceFactoryDep) => { const createServiceToken = async ({ iv, @@ -132,6 +135,9 @@ export const serviceTokenServiceFactory = ({ const serviceToken = await serviceTokenDAL.findById(TOKEN_IDENTIFIER); if (!serviceToken) throw new UnauthorizedError(); + const project = await projectDAL.findById(serviceToken.projectId); + + if (!project) throw new UnauthorizedError({ message: "Service token project not found" }); if (serviceToken.expiresAt && new Date(serviceToken.expiresAt) < new Date()) { await serviceTokenDAL.deleteById(serviceToken.id); @@ -144,7 +150,7 @@ export const serviceTokenServiceFactory = ({ lastUsed: new Date() }); - return { ...serviceToken, lastUsed: updatedToken.lastUsed }; + return { ...serviceToken, lastUsed: updatedToken.lastUsed, orgId: project.orgId }; }; return { diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index 40e17577f..067ee9c4a 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -13,13 +13,13 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children return (
@@ -27,10 +27,11 @@ export const PermissionDeniedBanner = ({ containerClassName, className, children
-
Access Restricted
+
Access Restricted
{children || (
- Your role has limited permissions, please
contact your administrator to gain access + Your role has limited permissions, please
contact your administrator to gain + access
)}