diff --git a/APIReference/Dockerfile.tpl b/APIReference/Dockerfile.tpl index 9508e0592a..758064fac8 100644 --- a/APIReference/Dockerfile.tpl +++ b/APIReference/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/APIReference/nodemon.json b/APIReference/nodemon.json index b8fb4314a4..7676e20960 100644 --- a/APIReference/nodemon.json +++ b/APIReference/nodemon.json @@ -10,5 +10,5 @@ ], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Accounts/Dockerfile.tpl b/Accounts/Dockerfile.tpl index ebc84bb2b1..5aa618ab1f 100644 --- a/Accounts/Dockerfile.tpl +++ b/Accounts/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Accounts/src/Pages/Login.tsx b/Accounts/src/Pages/Login.tsx index dac9753f07..e73ef717d1 100644 --- a/Accounts/src/Pages/Login.tsx +++ b/Accounts/src/Pages/Login.tsx @@ -1,6 +1,8 @@ import { LOGIN_API_URL, - VERIFY_TWO_FACTOR_AUTH_API_URL, + VERIFY_TOTP_AUTH_API_URL, + GENERATE_WEBAUTHN_AUTH_OPTIONS_API_URL, + VERIFY_WEBAUTHN_AUTH_API_URL, } from "../Utils/ApiPaths"; import Route from "Common/Types/API/Route"; import URL from "Common/Types/API/URL"; @@ -12,17 +14,20 @@ import { DASHBOARD_URL } from "Common/UI/Config"; import OneUptimeLogo from "Common/UI/Images/logos/OneUptimeSVG/3-transparent.svg"; import UiAnalytics from "Common/UI/Utils/Analytics"; import LoginUtil from "Common/UI/Utils/Login"; -import UserTwoFactorAuth from "Common/Models/DatabaseModels/UserTwoFactorAuth"; +import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth"; +import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn"; import Navigation from "Common/UI/Utils/Navigation"; import UserUtil from "Common/UI/Utils/User"; import User from "Common/Models/DatabaseModels/User"; import React from "react"; import useAsyncEffect from "use-async-effect"; -import StaticModelList from "Common/UI/Components/ModelList/StaticModelList"; import BasicForm from "Common/UI/Components/Forms/BasicForm"; import API from "Common/UI/Utils/API/API"; import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse"; import HTTPResponse from "Common/Types/API/HTTPResponse"; +import Base64 from "Common/Utils/Base64"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader"; const LoginPage: () => JSX.Element = () => { const apiUrl: URL = LOGIN_API_URL; @@ -36,14 +41,32 @@ const LoginPage: () => JSX.Element = () => { const [showTwoFactorAuth, setShowTwoFactorAuth] = React.useState(false); - const [twoFactorAuthList, setTwoFactorAuthList] = React.useState< - UserTwoFactorAuth[] - >([]); + const [totpAuthList, setTotpAuthList] = React.useState([]); - const [selectedTwoFactorAuth, setSelectedTwoFactorAuth] = React.useState< - UserTwoFactorAuth | undefined + const [webAuthnList, setWebAuthnList] = React.useState([]); + + const [selectedTotpAuth, setSelectedTotpAuth] = React.useState< + UserTotpAuth | undefined >(undefined); + const [selectedWebAuthn, setSelectedWebAuthn] = React.useState< + UserWebAuthn | undefined + >(undefined); + + type TwoFactorMethod = { + type: "totp" | "webauthn"; + item: UserTotpAuth | UserWebAuthn; + }; + + const twoFactorMethods: TwoFactorMethod[] = [ + ...totpAuthList.map((item: UserTotpAuth) => { + return { type: "totp" as const, item }; + }), + ...webAuthnList.map((item: UserWebAuthn) => { + return { type: "webauthn" as const, item }; + }), + ]; + const [isTwoFactorAuthLoading, setIsTwoFactorAuthLoading] = React.useState(false); const [twofactorAuthError, setTwoFactorAuthError] = @@ -57,6 +80,96 @@ const LoginPage: () => JSX.Element = () => { } }, []); + useAsyncEffect(async () => { + if (selectedWebAuthn) { + setIsTwoFactorAuthLoading(true); + try { + const result: HTTPResponse = await API.post({ + url: GENERATE_WEBAUTHN_AUTH_OPTIONS_API_URL, + data: { + data: { + email: initialValues["email"], + }, + }, + }); + + if (result instanceof HTTPErrorResponse) { + throw result; + } + + const data: any = result.data as any; + + // Convert base64url strings back to Uint8Array + data.options.challenge = Base64.base64UrlToUint8Array( + data.options.challenge, + ); + if (data.options.allowCredentials) { + data.options.allowCredentials.forEach((cred: any) => { + cred.id = Base64.base64UrlToUint8Array(cred.id); + }); + } + + // Use WebAuthn API + const credential: PublicKeyCredential = + (await navigator.credentials.get({ + publicKey: data.options, + })) as PublicKeyCredential; + + const assertionResponse: AuthenticatorAssertionResponse = + credential.response as AuthenticatorAssertionResponse; + + // Verify + const verifyResult: HTTPResponse = await API.post({ + url: VERIFY_WEBAUTHN_AUTH_API_URL, + data: { + data: { + ...initialValues, + challenge: data.challenge, + credential: { + id: credential.id, + rawId: Base64.uint8ArrayToBase64Url( + new Uint8Array(credential.rawId), + ), + response: { + authenticatorData: Base64.uint8ArrayToBase64Url( + new Uint8Array(assertionResponse.authenticatorData), + ), + clientDataJSON: Base64.uint8ArrayToBase64Url( + new Uint8Array(assertionResponse.clientDataJSON), + ), + signature: Base64.uint8ArrayToBase64Url( + new Uint8Array(assertionResponse.signature), + ), + userHandle: assertionResponse.userHandle + ? Base64.uint8ArrayToBase64Url( + new Uint8Array(assertionResponse.userHandle), + ) + : null, + }, + type: credential.type, + }, + }, + }, + }); + + if (verifyResult instanceof HTTPErrorResponse) { + throw verifyResult; + } + + const user: User = User.fromJSON( + verifyResult.data as JSONObject, + User, + ) as User; + const miscData: JSONObject = {}; + + login(user as User, miscData); + } catch (error) { + setTwoFactorAuthError(API.getFriendlyErrorMessage(error as Error)); + } + setIsTwoFactorAuthLoading(false); + } + }, [selectedWebAuthn]); + type LoginFunction = (user: User, miscData: JSONObject) => void; const login: LoginFunction = (user: User, miscData: JSONObject): void => { @@ -155,17 +268,24 @@ const LoginPage: () => JSX.Element = () => { miscData: JSONObject | undefined, ) => { if ( - miscData && - (miscData as JSONObject)["twoFactorAuth"] === true + (miscData && + (((miscData as JSONObject)["totpAuthList"] as JSONArray) + ?.length || 0) > 0) || + (((miscData as JSONObject)["webAuthnList"] as JSONArray) + ?.length || 0) > 0 ) { - const twoFactorAuthList: Array = - UserTwoFactorAuth.fromJSONArray( - (miscData as JSONObject)[ - "twoFactorAuthList" - ] as JSONArray, - UserTwoFactorAuth, + const totpAuthList: Array = + UserTotpAuth.fromJSONArray( + (miscData as JSONObject)["totpAuthList"] as JSONArray, + UserTotpAuth, ); - setTwoFactorAuthList(twoFactorAuthList); + const webAuthnList: Array = + UserWebAuthn.fromJSONArray( + (miscData as JSONObject)["webAuthnList"] as JSONArray, + UserWebAuthn, + ); + setTotpAuthList(totpAuthList); + setWebAuthnList(webAuthnList); setShowTwoFactorAuth(true); return; } @@ -187,19 +307,53 @@ const LoginPage: () => JSX.Element = () => { /> )} - {showTwoFactorAuth && !selectedTwoFactorAuth && ( - - titleField="name" - descriptionField="" - selectedItems={[]} - list={twoFactorAuthList} - onClick={(item: UserTwoFactorAuth) => { - setSelectedTwoFactorAuth(item); - }} - /> + {showTwoFactorAuth && !selectedTotpAuth && !selectedWebAuthn && ( +
+ {twoFactorMethods.map( + (method: TwoFactorMethod, index: number) => { + return ( +
{ + if (method.type === "totp") { + setSelectedTotpAuth(method.item as UserTotpAuth); + } else { + setSelectedWebAuthn(method.item as UserWebAuthn); + } + }} + > +
+ {(method.item as any).name} +
+
+ {method.type === "totp" + ? "Authenticator App" + : "Security Key"} +
+
+ ); + }, + )} +
)} - {showTwoFactorAuth && selectedTwoFactorAuth && ( + {showTwoFactorAuth && selectedWebAuthn && ( +
+
+ Authenticating with Security Key +
+
+ Please follow the instructions on your security key device. +
+ {isTwoFactorAuthLoading && } + {twofactorAuthError && ( + + )} +
+ )} + + {showTwoFactorAuth && selectedTotpAuth && ( JSX.Element = () => { try { const code: string = data["code"] as string; const twoFactorAuthId: string = - selectedTwoFactorAuth.id?.toString() as string; + selectedTotpAuth!.id?.toString() as string; const result: HTTPErrorResponse | HTTPResponse = await API.post({ - url: VERIFY_TWO_FACTOR_AUTH_API_URL, + url: VERIFY_TOTP_AUTH_API_URL, data: { - ...initialValues, - code: code, - twoFactorAuthId: twoFactorAuthId, + data: { + ...initialValues, + code: code, + twoFactorAuthId: twoFactorAuthId, + }, }, }); @@ -262,7 +418,7 @@ const LoginPage: () => JSX.Element = () => { )}
- {!selectedTwoFactorAuth && ( + {!selectedTotpAuth && !selectedWebAuthn && (
Don't have an account?{" "} JSX.Element = () => {
)} - {selectedTwoFactorAuth ? ( + {selectedTotpAuth || selectedWebAuthn ? (
{ - setSelectedTwoFactorAuth(undefined); + setSelectedTotpAuth(undefined); + setSelectedWebAuthn(undefined); }} className="text-indigo-500 hover:text-indigo-900 cursor-pointer" > diff --git a/Accounts/src/Utils/ApiPaths.ts b/Accounts/src/Utils/ApiPaths.ts index 6b31508338..3ec371eaf4 100644 --- a/Accounts/src/Utils/ApiPaths.ts +++ b/Accounts/src/Utils/ApiPaths.ts @@ -1,6 +1,6 @@ import Route from "Common/Types/API/Route"; import URL from "Common/Types/API/URL"; -import { IDENTITY_URL } from "Common/UI/Config"; +import { IDENTITY_URL, APP_API_URL } from "Common/UI/Config"; export const SIGNUP_API_URL: URL = URL.fromURL(IDENTITY_URL).addRoute( new Route("/signup"), @@ -9,9 +9,17 @@ export const LOGIN_API_URL: URL = URL.fromURL(IDENTITY_URL).addRoute( new Route("/login"), ); -export const VERIFY_TWO_FACTOR_AUTH_API_URL: URL = URL.fromURL( +export const VERIFY_TOTP_AUTH_API_URL: URL = URL.fromURL(IDENTITY_URL).addRoute( + new Route("/verify-totp-auth"), +); + +export const GENERATE_WEBAUTHN_AUTH_OPTIONS_API_URL: URL = URL.fromURL( + APP_API_URL, +).addRoute(new Route("/user-webauthn/generate-authentication-options")); + +export const VERIFY_WEBAUTHN_AUTH_API_URL: URL = URL.fromURL( IDENTITY_URL, -).addRoute(new Route("/verify-two-factor-auth")); +).addRoute(new Route("/verify-webauthn-auth")); export const SERVICE_PROVIDER_LOGIN_URL: URL = URL.fromURL( IDENTITY_URL, diff --git a/AdminDashboard/Dockerfile.tpl b/AdminDashboard/Dockerfile.tpl index 3c83316c5d..bcc9bd0c7b 100644 --- a/AdminDashboard/Dockerfile.tpl +++ b/AdminDashboard/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/App/Dockerfile.tpl b/App/Dockerfile.tpl index 3b618c01de..16d2161c5b 100644 --- a/App/Dockerfile.tpl +++ b/App/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index 00287169af..8d73f5dc09 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -24,7 +24,8 @@ import WorkspaceNotificationRuleAPI from "Common/Server/API/WorkspaceNotificatio import StatusPageDomainAPI from "Common/Server/API/StatusPageDomainAPI"; import StatusPageSubscriberAPI from "Common/Server/API/StatusPageSubscriberAPI"; import UserCallAPI from "Common/Server/API/UserCallAPI"; -import UserTwoFactorAuthAPI from "Common/Server/API/UserTwoFactorAuthAPI"; +import UserTotpAuthAPI from "Common/Server/API/UserTotpAuthAPI"; +import UserWebAuthnAPI from "Common/Server/API/UserWebAuthnAPI"; import MonitorTest from "Common/Models/DatabaseModels/MonitorTest"; // User Notification methods. import UserEmailAPI from "Common/Server/API/UserEmailAPI"; @@ -1666,7 +1667,11 @@ const BaseAPIFeatureSet: FeatureSet = { app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserCallAPI().getRouter()); app.use( `/${APP_NAME.toLocaleLowerCase()}`, - new UserTwoFactorAuthAPI().getRouter(), + new UserTotpAuthAPI().getRouter(), + ); + app.use( + `/${APP_NAME.toLocaleLowerCase()}`, + new UserWebAuthnAPI().getRouter(), ); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserEmailAPI().getRouter()); app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserSMSAPI().getRouter()); diff --git a/App/FeatureSet/Identity/API/Authentication.ts b/App/FeatureSet/Identity/API/Authentication.ts index b6f1e78b53..3729911363 100644 --- a/App/FeatureSet/Identity/API/Authentication.ts +++ b/App/FeatureSet/Identity/API/Authentication.ts @@ -24,7 +24,7 @@ import AccessTokenService from "Common/Server/Services/AccessTokenService"; import EmailVerificationTokenService from "Common/Server/Services/EmailVerificationTokenService"; import MailService from "Common/Server/Services/MailService"; import UserService from "Common/Server/Services/UserService"; -import UserTwoFactorAuthService from "Common/Server/Services/UserTwoFactorAuthService"; +import UserTotpAuthService from "Common/Server/Services/UserTotpAuthService"; import CookieUtil from "Common/Server/Utils/Cookie"; import Express, { ExpressRequest, @@ -34,10 +34,12 @@ import Express, { } from "Common/Server/Utils/Express"; import logger from "Common/Server/Utils/Logger"; import Response from "Common/Server/Utils/Response"; -import TwoFactorAuth from "Common/Server/Utils/TwoFactorAuth"; +import TotpAuth from "Common/Server/Utils/TotpAuth"; import EmailVerificationToken from "Common/Models/DatabaseModels/EmailVerificationToken"; import User from "Common/Models/DatabaseModels/User"; -import UserTwoFactorAuth from "Common/Models/DatabaseModels/UserTwoFactorAuth"; +import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth"; +import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn"; +import UserWebAuthnService from "Common/Server/Services/UserWebAuthnService"; const router: ExpressRouter = Express.getRouter(); @@ -503,7 +505,7 @@ router.post( ); router.post( - "/verify-two-factor-auth", + "/verify-totp-auth", async ( req: ExpressRequest, res: ExpressResponse, @@ -513,7 +515,25 @@ router.post( req: req, res: res, next: next, - verifyTwoFactorAuth: true, + verifyTotpAuth: true, + verifyWebAuthn: false, + }); + }, +); + +router.post( + "/verify-webauthn-auth", + async ( + req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, + ): Promise => { + return login({ + req: req, + res: res, + next: next, + verifyTotpAuth: false, + verifyWebAuthn: true, }); }, ); @@ -529,62 +549,99 @@ router.post( req: req, res: res, next: next, - verifyTwoFactorAuth: false, + verifyTotpAuth: false, + verifyWebAuthn: false, }); }, ); -type FetchTwoFactorAuthListFunction = ( - userId: ObjectID, -) => Promise>; +type FetchTotpAuthListFunction = (userId: ObjectID) => Promise<{ + totpAuthList: Array; + webAuthnList: Array; +}>; -const fetchTwoFactorAuthList: FetchTwoFactorAuthListFunction = async ( +const fetchTotpAuthList: FetchTotpAuthListFunction = async ( userId: ObjectID, -): Promise> => { - const twoFactorAuthList: Array = - await UserTwoFactorAuthService.findBy({ - query: { - userId: userId, - isVerified: true, - }, - select: { - _id: true, - userId: true, - name: true, - }, - limit: LIMIT_PER_PROJECT, - skip: 0, - props: { - isRoot: true, - }, - }); +): Promise<{ + totpAuthList: Array; + webAuthnList: Array; +}> => { + const totpAuthList: Array = await UserTotpAuthService.findBy({ + query: { + userId: userId, + isVerified: true, + }, + select: { + _id: true, + userId: true, + name: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + props: { + isRoot: true, + }, + }); - return twoFactorAuthList; + const webAuthnList: Array = await UserWebAuthnService.findBy({ + query: { + userId: userId, + isVerified: true, + }, + select: { + _id: true, + userId: true, + name: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + props: { + isRoot: true, + }, + }); + + return { + totpAuthList: totpAuthList || [], + webAuthnList: webAuthnList || [], + }; }; type LoginFunction = (options: { req: ExpressRequest; res: ExpressResponse; next: NextFunction; - verifyTwoFactorAuth: boolean; + verifyTotpAuth: boolean; + verifyWebAuthn: boolean; }) => Promise; const login: LoginFunction = async (options: { req: ExpressRequest; res: ExpressResponse; next: NextFunction; - verifyTwoFactorAuth: boolean; + verifyTotpAuth: boolean; + verifyWebAuthn: boolean; }): Promise => { const req: ExpressRequest = options.req; const res: ExpressResponse = options.res; const next: NextFunction = options.next; - const verifyTwoFactorAuth: boolean = options.verifyTwoFactorAuth; + const verifyTotpAuth: boolean = options.verifyTotpAuth; + const verifyWebAuthn: boolean = options.verifyWebAuthn; try { const data: JSONObject = req.body["data"]; + logger.debug("Login request data: " + JSON.stringify(req.body, null, 2)); + const user: User = BaseModel.fromJSON(data as JSONObject, User) as User; + if (!user.email || !user.password) { + return Response.sendErrorResponse( + req, + res, + new BadDataException("Email and password are required."), + ); + } + await user.password?.hashValue(EncryptionSecret); const alreadySavedUser: User | null = await UserService.findOneBy({ @@ -638,13 +695,21 @@ const login: LoginFunction = async (options: { ); } - if (alreadySavedUser.enableTwoFactorAuth && !verifyTwoFactorAuth) { + if ( + alreadySavedUser.enableTwoFactorAuth && + !verifyTotpAuth && + !verifyWebAuthn + ) { // If two factor auth is enabled then we will send the user to the two factor auth page. - const twoFactorAuthList: Array = - await fetchTwoFactorAuthList(alreadySavedUser.id!); + const { totpAuthList, webAuthnList } = await fetchTotpAuthList( + alreadySavedUser.id!, + ); - if (!twoFactorAuthList || twoFactorAuthList.length === 0) { + if ( + (!totpAuthList || totpAuthList.length === 0) && + (!webAuthnList || webAuthnList.length === 0) + ) { const errorMessage: string = IsBillingEnabled ? "Two Factor Authentication is enabled but no two factor auth is setup. Please contact OneUptime support for help." : "Two Factor Authentication is enabled but no two factor auth is setup. Please contact your server admin to disable two factor auth for this account."; @@ -656,62 +721,68 @@ const login: LoginFunction = async (options: { ); } - return Response.sendEntityResponse(req, res, user, User, { + return Response.sendEntityResponse(req, res, alreadySavedUser, User, { miscData: { - twoFactorAuthList: UserTwoFactorAuth.toJSONArray( - twoFactorAuthList, - UserTwoFactorAuth, - ), - twoFactorAuth: true, + totpAuthList: UserTotpAuth.toJSONArray(totpAuthList, UserTotpAuth), + webAuthnList: UserWebAuthn.toJSONArray(webAuthnList, UserWebAuthn), }, }); } - if (verifyTwoFactorAuth) { - // code from req - const code: string = data["code"] as string; - const twoFactorAuthId: string = data["twoFactorAuthId"] as string; + if (verifyTotpAuth || verifyWebAuthn) { + if (verifyTotpAuth) { + // code from req + const code: string = data["code"] as string; + const twoFactorAuthId: string = data["twoFactorAuthId"] as string; - const twoFactorAuth: UserTwoFactorAuth | null = - await UserTwoFactorAuthService.findOneBy({ - query: { - _id: twoFactorAuthId, - userId: alreadySavedUser.id!, - isVerified: true, - }, - select: { - _id: true, - twoFactorSecret: true, - }, - props: { - isRoot: true, - }, + const totpAuth: UserTotpAuth | null = + await UserTotpAuthService.findOneBy({ + query: { + _id: twoFactorAuthId, + userId: alreadySavedUser.id!, + isVerified: true, + }, + select: { + _id: true, + twoFactorSecret: true, + }, + props: { + isRoot: true, + }, + }); + + if (!totpAuth) { + return Response.sendErrorResponse( + req, + res, + new BadDataException("Invalid two factor auth id."), + ); + } + + const isVerified: boolean = TotpAuth.verifyToken({ + token: code, + secret: totpAuth.twoFactorSecret!, + email: alreadySavedUser.email!, }); - if (!twoFactorAuth) { - return Response.sendErrorResponse( - req, - res, - new BadDataException("Invalid two factor auth id."), - ); + if (!isVerified) { + return Response.sendErrorResponse( + req, + res, + new BadDataException("Invalid code."), + ); + } + } else if (verifyWebAuthn) { + const expectedChallenge: string = data["challenge"] as string; + const credential: any = data["credential"]; + + await UserWebAuthnService.verifyAuthentication({ + userId: alreadySavedUser.id!.toString(), + challenge: expectedChallenge, + credential: credential, + }); } - - const isVerified: boolean = TwoFactorAuth.verifyToken({ - token: code, - secret: twoFactorAuth.twoFactorSecret!, - email: alreadySavedUser.email!, - }); - - if (!isVerified) { - return Response.sendErrorResponse( - req, - res, - new BadDataException("Invalid code."), - ); - } - } - - // Refresh Permissions for this user here. + } // Refresh Permissions for this user here. await AccessTokenService.refreshUserAllPermissions(alreadySavedUser.id!); if (alreadySavedUser.password.toString() === user.password!.toString()) { diff --git a/App/FeatureSet/Identity/Utils/AuthenticationEmail.ts b/App/FeatureSet/Identity/Utils/AuthenticationEmail.ts index 5cfde0917b..9b67ae2ee4 100644 --- a/App/FeatureSet/Identity/Utils/AuthenticationEmail.ts +++ b/App/FeatureSet/Identity/Utils/AuthenticationEmail.ts @@ -35,6 +35,8 @@ export default class AuthenticationEmail { const host: Hostname = await DatabaseConfig.getHost(); const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); + logger.debug("Sending verification email"); + MailService.sendMail({ toEmail: user.email!, subject: "Please verify email.", @@ -50,8 +52,13 @@ export default class AuthenticationEmail { ).toString(), homeUrl: new URL(httpProtocol, host).toString(), }, - }).catch((err: Error) => { - logger.error(err); - }); + }) + .then(() => { + logger.debug("Verification email sent"); + }) + .catch((err: Error) => { + logger.debug("Error sending verification email"); + logger.error(err); + }); } } diff --git a/App/nodemon.json b/App/nodemon.json index 97e0473e95..c8df815972 100644 --- a/App/nodemon.json +++ b/App/nodemon.json @@ -16,5 +16,5 @@ "TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false" }, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Common/Models/DatabaseModels/Index.ts b/Common/Models/DatabaseModels/Index.ts index 2d351f42ca..4e165e155c 100644 --- a/Common/Models/DatabaseModels/Index.ts +++ b/Common/Models/DatabaseModels/Index.ts @@ -146,7 +146,8 @@ import ServiceCatalogDependency from "./ServiceCatalogDependency"; import ServiceCatalogMonitor from "./ServiceCatalogMonitor"; import ServiceCatalogTelemetryService from "./ServiceCatalogTelemetryService"; -import UserTwoFactorAuth from "./UserTwoFactorAuth"; +import UserTotpAuth from "./UserTotpAuth"; +import UserWebAuthn from "./UserWebAuthn"; import TelemetryIngestionKey from "./TelemetryIngestionKey"; @@ -366,7 +367,8 @@ const AllModelTypes: Array<{ ProbeOwnerTeam, ProbeOwnerUser, - UserTwoFactorAuth, + UserTotpAuth, + UserWebAuthn, TelemetryIngestionKey, diff --git a/Common/Models/DatabaseModels/User.ts b/Common/Models/DatabaseModels/User.ts index 8cf546f536..4c8a6b0aad 100644 --- a/Common/Models/DatabaseModels/User.ts +++ b/Common/Models/DatabaseModels/User.ts @@ -1,7 +1,6 @@ import File from "./File"; import UserModel from "../../Models/DatabaseModels/DatabaseBaseModel/UserModel"; import Route from "../../Types/API/Route"; -import URL from "../../Types/API/URL"; import CompanySize from "../../Types/Company/CompanySize"; import JobRole from "../../Types/Company/JobRole"; import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid"; @@ -301,51 +300,6 @@ class User extends UserModel { }) public twoFactorAuthEnabled?: boolean = undefined; - @ColumnAccessControl({ - create: [], - read: [], - - update: [], - }) - @TableColumn({ type: TableColumnType.ShortText }) - @Column({ - type: ColumnType.ShortText, - length: ColumnLength.ShortText, - nullable: true, - unique: false, - }) - public twoFactorSecretCode?: string = undefined; - - @ColumnAccessControl({ - create: [], - read: [], - - update: [], - }) - @TableColumn({ type: TableColumnType.ShortURL }) - @Column({ - type: ColumnType.ShortURL, - length: ColumnLength.ShortURL, - nullable: true, - unique: false, - transformer: URL.getDatabaseTransformer(), - }) - public twoFactorAuthUrl?: URL = undefined; - - @ColumnAccessControl({ - create: [], - read: [Permission.CurrentUser], - - update: [], - }) - @TableColumn({ type: TableColumnType.Array }) - @Column({ - type: ColumnType.Array, - nullable: true, - unique: false, - }) - public backupCodes?: Array = undefined; - @ColumnAccessControl({ create: [], read: [], diff --git a/Common/Models/DatabaseModels/UserTwoFactorAuth.ts b/Common/Models/DatabaseModels/UserTotpAuth.ts similarity index 86% rename from Common/Models/DatabaseModels/UserTwoFactorAuth.ts rename to Common/Models/DatabaseModels/UserTotpAuth.ts index 369a23f743..296489c626 100644 --- a/Common/Models/DatabaseModels/UserTwoFactorAuth.ts +++ b/Common/Models/DatabaseModels/UserTotpAuth.ts @@ -27,19 +27,19 @@ import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; delete: [Permission.CurrentUser], update: [Permission.CurrentUser], }) -@CrudApiEndpoint(new Route("/user-two-factor-auth")) +@CrudApiEndpoint(new Route("/user-totp-auth")) @Entity({ - name: "UserTwoFactorAuth", + name: "UserTotpAuth", }) @TableMetadata({ - tableName: "UserTwoFactorAuth", - singularName: "Two Factor Auth", - pluralName: "Two Factor Auth", + tableName: "UserTotpAuth", + singularName: "TOTP Auth", + pluralName: "TOTP Auth", icon: IconProp.ShieldCheck, - tableDescription: "Two Factor Authentication for users", + tableDescription: "TOTP Authentication for users", }) @CurrentUserCanAccessRecordBy("userId") -class UserTwoFactorAuth extends BaseModel { +class UserTotpAuth extends BaseModel { @ColumnAccessControl({ create: [Permission.CurrentUser], read: [Permission.CurrentUser], @@ -48,8 +48,8 @@ class UserTwoFactorAuth extends BaseModel { @TableColumn({ type: TableColumnType.ShortText, canReadOnRelationQuery: true, - title: "Two Factor Auth Name", - description: "Name of the two factor authentication", + title: "TOTP Auth Name", + description: "Name of the TOTP authentication", }) @Column({ type: ColumnType.ShortText, @@ -67,8 +67,8 @@ class UserTwoFactorAuth extends BaseModel { @TableColumn({ type: TableColumnType.VeryLongText, canReadOnRelationQuery: false, - title: "Two Factor Auth Secret", - description: "Secret of the two factor authentication", + title: "TOTP Auth Secret", + description: "Secret of the TOTP authentication", }) @Column({ type: ColumnType.VeryLongText, @@ -85,8 +85,8 @@ class UserTwoFactorAuth extends BaseModel { @TableColumn({ type: TableColumnType.VeryLongText, canReadOnRelationQuery: false, - title: "Two Factor Auth OTP URL", - description: "OTP URL of the two factor authentication", + title: "TOTP Auth OTP URL", + description: "OTP URL of the TOTP authentication", }) @Column({ type: ColumnType.VeryLongText, @@ -106,7 +106,7 @@ class UserTwoFactorAuth extends BaseModel { title: "Is Verified", isDefaultValueColumn: true, description: - "Is this two factor authentication verified and validated (has user entered the tokent to verify it)", + "Is this TOTP authentication verified and validated (has user entered the token to verify it)", defaultValue: false, }) @Column({ @@ -171,7 +171,7 @@ class UserTwoFactorAuth extends BaseModel { manyToOneRelationColumn: "userId", type: TableColumnType.Entity, title: "User", - description: "Relation to User who owns this two factor authentication", + description: "Relation to User who owns this TOTP authentication", }) @ManyToOne( () => { @@ -207,4 +207,4 @@ class UserTwoFactorAuth extends BaseModel { public userId?: ObjectID = undefined; } -export default UserTwoFactorAuth; +export default UserTotpAuth; diff --git a/Common/Models/DatabaseModels/UserWebAuthn.ts b/Common/Models/DatabaseModels/UserWebAuthn.ts new file mode 100644 index 0000000000..4cc7ebd6b6 --- /dev/null +++ b/Common/Models/DatabaseModels/UserWebAuthn.ts @@ -0,0 +1,244 @@ +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import User from "./User"; +import Route from "../../Types/API/Route"; +import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +@EnableDocumentation({ + isMasterAdminApiDocs: true, +}) +@AllowAccessIfSubscriptionIsUnpaid() +@TableAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + delete: [Permission.CurrentUser], + update: [Permission.CurrentUser], +}) +@CrudApiEndpoint(new Route("/user-webauthn")) +@Entity({ + name: "UserWebAuthn", +}) +@TableMetadata({ + tableName: "UserWebAuthn", + singularName: "WebAuthn Credential", + pluralName: "WebAuthn Credentials", + icon: IconProp.ShieldCheck, + tableDescription: "WebAuthn credentials for users (security keys)", +}) +@CurrentUserCanAccessRecordBy("userId") +class UserWebAuthn extends BaseModel { + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Credential Name", + description: "Name of the WebAuthn credential", + }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public name?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Credential ID", + description: "Unique identifier for the WebAuthn credential", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: true, + }) + public credentialId?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Public Key", + description: "Public key of the WebAuthn credential", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public publicKey?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Counter", + description: "Counter for the WebAuthn credential", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public counter?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.VeryLongText, + canReadOnRelationQuery: false, + title: "Transports", + description: "Transports supported by the WebAuthn credential", + }) + @Column({ + type: ColumnType.VeryLongText, + nullable: true, + unique: false, + }) + public transports?: string = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + type: TableColumnType.Boolean, + canReadOnRelationQuery: true, + title: "Is Verified", + isDefaultValueColumn: true, + description: "Is this WebAuthn credential verified and validated", + defaultValue: false, + }) + @Column({ + type: ColumnType.Boolean, + nullable: false, + default: false, + }) + public isVerified?: boolean = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + manyToOneRelationColumn: "userId", + type: TableColumnType.Entity, + title: "User", + description: "Relation to User who owns this WebAuthn credential", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "userId" }) + public user?: User = undefined; + + @ColumnAccessControl({ + create: [Permission.CurrentUser], + read: [Permission.CurrentUser], + update: [Permission.CurrentUser], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "User ID", + description: "User ID who owns this WebAuthn credential", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public userId?: ObjectID = undefined; +} + +export default UserWebAuthn; diff --git a/Common/Server/API/UserTwoFactorAuthAPI.ts b/Common/Server/API/UserTotpAuthAPI.ts similarity index 68% rename from Common/Server/API/UserTwoFactorAuthAPI.ts rename to Common/Server/API/UserTotpAuthAPI.ts index 224e70c3b1..ae209499ab 100644 --- a/Common/Server/API/UserTwoFactorAuthAPI.ts +++ b/Common/Server/API/UserTotpAuthAPI.ts @@ -1,8 +1,8 @@ import ObjectID from "../../Types/ObjectID"; import UserMiddleware from "../Middleware/UserAuthorization"; -import UserTwoFactorAuthService, { - Service as UserTwoFactorAuthServiceType, -} from "../Services/UserTwoFactorAuthService"; +import UserTotpAuthService, { + Service as UserTotpAuthServiceType, +} from "../Services/UserTotpAuthService"; import { ExpressRequest, ExpressResponse, @@ -10,27 +10,27 @@ import { OneUptimeRequest, } from "../Utils/Express"; import BaseAPI from "./BaseAPI"; -import UserTwoFactorAuth from "../../Models/DatabaseModels/UserTwoFactorAuth"; +import UserTotpAuth from "../../Models/DatabaseModels/UserTotpAuth"; import BadDataException from "../../Types/Exception/BadDataException"; -import TwoFactorAuth from "../Utils/TwoFactorAuth"; +import TotpAuth from "../Utils/TotpAuth"; import Response from "../Utils/Response"; import User from "../../Models/DatabaseModels/User"; import UserService from "../Services/UserService"; -export default class UserTwoFactorAuthAPI extends BaseAPI< - UserTwoFactorAuth, - UserTwoFactorAuthServiceType +export default class UserTotpAuthAPI extends BaseAPI< + UserTotpAuth, + UserTotpAuthServiceType > { public constructor() { - super(UserTwoFactorAuth, UserTwoFactorAuthService); + super(UserTotpAuth, UserTotpAuthService); this.router.post( `${new this.entityType().getCrudApiPath()?.toString()}/validate`, UserMiddleware.getUserMiddleware, async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { try { - const userTwoFactorAuth: UserTwoFactorAuth | null = - await UserTwoFactorAuthService.findOneById({ + const userTotpAuth: UserTotpAuth | null = + await UserTotpAuthService.findOneById({ id: new ObjectID(req.body["id"]), select: { twoFactorSecret: true, @@ -41,24 +41,24 @@ export default class UserTwoFactorAuthAPI extends BaseAPI< }, }); - if (!userTwoFactorAuth) { - throw new BadDataException("Two factor auth not found"); + if (!userTotpAuth) { + throw new BadDataException("TOTP auth not found"); } if ( - userTwoFactorAuth.userId?.toString() !== + userTotpAuth.userId?.toString() !== (req as OneUptimeRequest).userAuthorization?.userId.toString() ) { throw new BadDataException("Two factor auth not found"); } - if (!userTwoFactorAuth.userId) { + if (!userTotpAuth.userId) { throw new BadDataException("User not found"); } // get user email. const user: User | null = await UserService.findOneById({ - id: userTwoFactorAuth.userId!, + id: userTotpAuth.userId!, select: { email: true, }, @@ -75,8 +75,8 @@ export default class UserTwoFactorAuthAPI extends BaseAPI< throw new BadDataException("User email not found"); } - const isValid: boolean = TwoFactorAuth.verifyToken({ - secret: userTwoFactorAuth.twoFactorSecret || "", + const isValid: boolean = TotpAuth.verifyToken({ + secret: userTotpAuth.twoFactorSecret || "", token: req.body["code"] || "", email: user.email!, }); @@ -87,8 +87,8 @@ export default class UserTwoFactorAuthAPI extends BaseAPI< // update this 2fa code as verified - await UserTwoFactorAuthService.updateOneById({ - id: userTwoFactorAuth.id!, + await UserTotpAuthService.updateOneById({ + id: userTotpAuth.id!, data: { isVerified: true, }, diff --git a/Common/Server/API/UserWebAuthnAPI.ts b/Common/Server/API/UserWebAuthnAPI.ts new file mode 100644 index 0000000000..822aca5c76 --- /dev/null +++ b/Common/Server/API/UserWebAuthnAPI.ts @@ -0,0 +1,103 @@ +import ObjectID from "../../Types/ObjectID"; +import UserMiddleware from "../Middleware/UserAuthorization"; +import UserWebAuthnService, { + Service as UserWebAuthnServiceType, +} from "../Services/UserWebAuthnService"; +import { + ExpressRequest, + ExpressResponse, + NextFunction, + OneUptimeRequest, +} from "../Utils/Express"; +import BaseAPI from "./BaseAPI"; +import UserWebAuthn from "../../Models/DatabaseModels/UserWebAuthn"; +import BadDataException from "../../Types/Exception/BadDataException"; +import Response from "../Utils/Response"; +import { JSONObject } from "../../Types/JSON"; +import CommonAPI from "./CommonAPI"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; + +export default class UserWebAuthnAPI extends BaseAPI< + UserWebAuthn, + UserWebAuthnServiceType +> { + public constructor() { + super(UserWebAuthn, UserWebAuthnService); + + this.router.post( + `${new this.entityType().getCrudApiPath()?.toString()}/generate-registration-options`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + const userId: ObjectID = (req as OneUptimeRequest).userAuthorization! + .userId; + + const result: { options: any; challenge: string } = + await UserWebAuthnService.generateRegistrationOptions({ + userId: userId, + }); + + return Response.sendJsonObjectResponse(req, res, result); + } catch (err) { + next(err); + } + }, + ); + + this.router.post( + `${new this.entityType().getCrudApiPath()?.toString()}/verify-registration`, + UserMiddleware.getUserMiddleware, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + const data: JSONObject = req.body; + + const databaseProps: DatabaseCommonInteractionProps = + await CommonAPI.getDatabaseCommonInteractionProps(req); + + const expectedChallenge: string = data["challenge"] as string; + const credential: any = data["credential"]; + const name: string = data["name"] as string; + + await UserWebAuthnService.verifyRegistration({ + challenge: expectedChallenge, + credential: credential, + name: name, + props: databaseProps, + }); + + return Response.sendEmptySuccessResponse(req, res); + } catch (err) { + next(err); + } + }, + ); + + this.router.post( + `${new this.entityType().getCrudApiPath()?.toString()}/generate-authentication-options`, + async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { + try { + const data: JSONObject = req.body["data"] as JSONObject; + + if (!data) { + throw new BadDataException("Data is required"); + } + + const email: string | undefined = data["email"] as string | undefined; + + if (!email) { + throw new BadDataException("Email is required"); + } + + const result: { options: any; challenge: string; userId: string } = + await UserWebAuthnService.generateAuthenticationOptions({ + email: email, + }); + + return Response.sendJsonObjectResponse(req, res, result); + } catch (err) { + next(err); + } + }, + ); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759175457008-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759175457008-MigrationName.ts new file mode 100644 index 0000000000..b738d70625 --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759175457008-MigrationName.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1759175457008 implements MigrationInterface { + public name = "MigrationName1759175457008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "UserWebAuthn" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "name" character varying(100) NOT NULL, "credentialId" text NOT NULL, "publicKey" text NOT NULL, "counter" text NOT NULL, "transports" text, "isVerified" boolean NOT NULL DEFAULT false, "deletedByUserId" uuid, "userId" uuid, CONSTRAINT "UQ_ed9d287cb27cc360b9c3a4542e9" UNIQUE ("credentialId"), CONSTRAINT "PK_76a58e093d632ac5a9036bfac57" PRIMARY KEY ("_id"))`, + ); + await queryRunner.query( + `ALTER TABLE "UserWebAuthn" ADD CONSTRAINT "FK_e14966d27e4991f5f53ef54cad5" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "UserWebAuthn" ADD CONSTRAINT "FK_e7a7d2869a90899c5f76ec997c0" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "UserWebAuthn" DROP CONSTRAINT "FK_e7a7d2869a90899c5f76ec997c0"`, + ); + await queryRunner.query( + `ALTER TABLE "UserWebAuthn" DROP CONSTRAINT "FK_e14966d27e4991f5f53ef54cad5"`, + ); + await queryRunner.query(`DROP TABLE "UserWebAuthn"`); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759232954703-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759232954703-MigrationName.ts new file mode 100644 index 0000000000..cdcec6a674 --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759232954703-MigrationName.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MigrationName1759232954703 implements MigrationInterface { + public name = "MigrationName1759232954703"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "User" DROP COLUMN "twoFactorSecretCode"`, + ); + await queryRunner.query( + `ALTER TABLE "User" DROP COLUMN "twoFactorAuthUrl"`, + ); + await queryRunner.query(`ALTER TABLE "User" DROP COLUMN "backupCodes"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "User" ADD "backupCodes" text`); + await queryRunner.query( + `ALTER TABLE "User" ADD "twoFactorAuthUrl" character varying(100)`, + ); + await queryRunner.query( + `ALTER TABLE "User" ADD "twoFactorSecretCode" character varying(100)`, + ); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759234532998-MigrationName.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759234532998-MigrationName.ts new file mode 100644 index 0000000000..f38f95c306 --- /dev/null +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/1759234532998-MigrationName.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameUserTwoFactorAuthToUserTotpAuth1759234532998 + implements MigrationInterface +{ + public name = "RenameUserTwoFactorAuthToUserTotpAuth1759234532998"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.renameTable("UserTwoFactorAuth", "UserTotpAuth"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.renameTable("UserTotpAuth", "UserTwoFactorAuth"); + } +} diff --git a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts index 1c232b1491..8a3359c8f1 100644 --- a/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +++ b/Common/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts @@ -171,6 +171,9 @@ import { MigrationName1758313975491 } from "./1758313975491-MigrationName"; import { MigrationName1758626094132 } from "./1758626094132-MigrationName"; import { MigrationName1758629540993 } from "./1758629540993-MigrationName"; import { MigrationName1758798730753 } from "./1758798730753-MigrationName"; +import { MigrationName1759175457008 } from "./1759175457008-MigrationName"; +import { MigrationName1759232954703 } from "./1759232954703-MigrationName"; +import { RenameUserTwoFactorAuthToUserTotpAuth1759234532998 } from "./1759234532998-MigrationName"; export default [ InitialMigration, @@ -346,4 +349,7 @@ export default [ MigrationName1758626094132, MigrationName1758629540993, MigrationName1758798730753, + MigrationName1759175457008, + MigrationName1759232954703, + RenameUserTwoFactorAuthToUserTotpAuth1759234532998, ]; diff --git a/Common/Server/Services/Index.ts b/Common/Server/Services/Index.ts index a0c4a3268b..38be27ef02 100644 --- a/Common/Server/Services/Index.ts +++ b/Common/Server/Services/Index.ts @@ -123,7 +123,8 @@ import UserNotificationSettingService from "./UserNotificationSettingService"; import UserOnCallLogService from "./UserOnCallLogService"; import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService"; import UserService from "./UserService"; -import UserTwoFactorAuthService from "./UserTwoFactorAuthService"; +import UserTotpAuthService from "./UserTotpAuthService"; +import UserWebAuthnService from "./UserWebAuthnService"; import UserSmsService from "./UserSmsService"; import WorkflowLogService from "./WorkflowLogService"; // Workflows. @@ -280,7 +281,8 @@ const services: Array = [ UserOnCallLogService, UserOnCallLogTimelineService, UserSmsService, - UserTwoFactorAuthService, + UserTotpAuthService, + UserWebAuthnService, WorkflowLogService, WorkflowService, diff --git a/Common/Server/Services/UserService.ts b/Common/Server/Services/UserService.ts index ef3d7fd73e..9c6745ddb1 100755 --- a/Common/Server/Services/UserService.ts +++ b/Common/Server/Services/UserService.ts @@ -29,8 +29,10 @@ import EmailVerificationToken from "../../Models/DatabaseModels/EmailVerificatio import TeamMember from "../../Models/DatabaseModels/TeamMember"; import Model from "../../Models/DatabaseModels/User"; import SlackUtil from "../Utils/Workspace/Slack/Slack"; -import UserTwoFactorAuth from "../../Models/DatabaseModels/UserTwoFactorAuth"; -import UserTwoFactorAuthService from "./UserTwoFactorAuthService"; +import UserTotpAuth from "../../Models/DatabaseModels/UserTotpAuth"; +import UserTotpAuthService from "./UserTotpAuthService"; +import UserWebAuthn from "../../Models/DatabaseModels/UserWebAuthn"; +import UserWebAuthnService from "./UserWebAuthnService"; import BadDataException from "../../Types/Exception/BadDataException"; import Name from "../../Types/Name"; import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; @@ -157,8 +159,8 @@ export class Service extends DatabaseService { }); for (const user of users) { - const twoFactorAuth: UserTwoFactorAuth | null = - await UserTwoFactorAuthService.findOneBy({ + const totpAuth: UserTotpAuth | null = + await UserTotpAuthService.findOneBy({ query: { userId: user.id!, isVerified: true, @@ -171,7 +173,21 @@ export class Service extends DatabaseService { }, }); - if (!twoFactorAuth) { + const webAuthn: UserWebAuthn | null = + await UserWebAuthnService.findOneBy({ + query: { + userId: user.id!, + isVerified: true, + }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!totpAuth && !webAuthn) { throw new BadDataException( "Please verify two factor authentication method before you enable two factor authentication.", ); diff --git a/Common/Server/Services/UserTwoFactorAuthService.ts b/Common/Server/Services/UserTotpAuthService.ts similarity index 92% rename from Common/Server/Services/UserTwoFactorAuthService.ts rename to Common/Server/Services/UserTotpAuthService.ts index 2b04a7bdac..d4945eacd8 100644 --- a/Common/Server/Services/UserTwoFactorAuthService.ts +++ b/Common/Server/Services/UserTotpAuthService.ts @@ -1,8 +1,8 @@ import CreateBy from "../Types/Database/CreateBy"; import { OnCreate, OnDelete } from "../Types/Database/Hooks"; import DatabaseService from "./DatabaseService"; -import Model from "../../Models/DatabaseModels/UserTwoFactorAuth"; -import TwoFactorAuth from "../Utils/TwoFactorAuth"; +import Model from "../../Models/DatabaseModels/UserTotpAuth"; +import TotpAuth from "../Utils/TotpAuth"; import UserService from "./UserService"; import BadDataException from "../../Types/Exception/BadDataException"; import User from "../../Models/DatabaseModels/User"; @@ -43,8 +43,8 @@ export class Service extends DatabaseService { throw new BadDataException("User email is required"); } - createBy.data.twoFactorSecret = TwoFactorAuth.generateSecret(); - createBy.data.twoFactorOtpUrl = TwoFactorAuth.generateUri({ + createBy.data.twoFactorSecret = TotpAuth.generateSecret(); + createBy.data.twoFactorOtpUrl = TotpAuth.generateUri({ secret: createBy.data.twoFactorSecret, userEmail: user.email, }); diff --git a/Common/Server/Services/UserWebAuthnService.ts b/Common/Server/Services/UserWebAuthnService.ts new file mode 100644 index 0000000000..9cab4e8f3f --- /dev/null +++ b/Common/Server/Services/UserWebAuthnService.ts @@ -0,0 +1,400 @@ +import CreateBy from "../Types/Database/CreateBy"; +import { OnCreate, OnDelete } from "../Types/Database/Hooks"; +import DatabaseService from "./DatabaseService"; +import Model from "../../Models/DatabaseModels/UserWebAuthn"; +import UserService from "./UserService"; +import BadDataException from "../../Types/Exception/BadDataException"; +import User from "../../Models/DatabaseModels/User"; +import DeleteBy from "../Types/Database/DeleteBy"; +import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax"; +import CaptureSpan from "../Utils/Telemetry/CaptureSpan"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import { Host, HttpProtocol } from "../EnvironmentConfig"; +import ObjectID from "../../Types/ObjectID"; +import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps"; + +export class Service extends DatabaseService { + public constructor() { + super(Model); + } + + @CaptureSpan() + public async generateRegistrationOptions(data: { + userId: ObjectID; + }): Promise<{ options: any; challenge: string }> { + const user: User | null = await UserService.findOneById({ + id: data.userId, + select: { + email: true, + name: true, + }, + props: { + isRoot: true, + }, + }); + + if (!user) { + throw new BadDataException("User not found"); + } + + if (!user.email) { + throw new BadDataException("User email not found"); + } + + // Get existing credentials for this user + const existingCredentials: Array = await this.findBy({ + query: { + userId: data.userId, + }, + select: { + credentialId: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + props: { + isRoot: true, + }, + }); + + const options: any = await generateRegistrationOptions({ + rpName: "OneUptime", + rpID: Host.toString(), + userID: new Uint8Array(Buffer.from(data.userId.toString())), + userName: user.email.toString(), + userDisplayName: user.name ? user.name.toString() : user.email.toString(), + attestationType: "none", + excludeCredentials: existingCredentials + .filter((cred: Model) => { + return cred.credentialId; + }) + .map((cred: Model) => { + return { + id: cred.credentialId!, + type: "public-key", + }; + }), + authenticatorSelection: { + residentKey: "discouraged", + userVerification: "preferred", + }, + }); + + // Convert to JSON serializable format + options.challenge = Buffer.from(options.challenge).toString("base64url"); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map( + (cred: any) => { + return { + ...cred, + id: + typeof cred.id === "string" + ? cred.id + : Buffer.from(cred.id).toString("base64url"), + }; + }, + ); + } + + return { + options: options as any, + challenge: options.challenge, + }; + } + + @CaptureSpan() + public async verifyRegistration(data: { + challenge: string; + credential: any; + name: string; + props: DatabaseCommonInteractionProps; + }): Promise { + const expectedOrigin: string = `${HttpProtocol}${Host.toString()}`; + + const verification: any = await verifyRegistrationResponse({ + response: data.credential, + expectedChallenge: data.challenge, + expectedOrigin: expectedOrigin, + expectedRPID: Host.toString(), + }); + + if (!verification.verified) { + throw new BadDataException("Registration verification failed"); + } + + const { registrationInfo } = verification; + + if (!registrationInfo) { + throw new BadDataException("Registration info not found"); + } + + if (!data.props.userId) { + throw new BadDataException("User ID not found in request"); + } + + // Save the credential + const userWebAuthn: Model = Model.fromJSON( + { + name: data.name, + credentialId: registrationInfo.credential.id, + publicKey: Buffer.from(registrationInfo.credential.publicKey).toString( + "base64", + ), + counter: "0", + transports: JSON.stringify([]), + isVerified: true, + userId: data.props.userId, + }, + Model, + ) as Model; + + await this.create({ + data: userWebAuthn, + props: data.props, + }); + } + + @CaptureSpan() + public async generateAuthenticationOptions(data: { + email: string; + }): Promise<{ options: any; challenge: string; userId: string }> { + const user: User | null = await UserService.findOneBy({ + query: { email: data.email }, + select: { + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!user) { + throw new BadDataException("User not found"); + } + + // Get user's WebAuthn credentials + const credentials: Array = await this.findBy({ + query: { + userId: user.id!, + isVerified: true, + }, + select: { + credentialId: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + props: { + isRoot: true, + }, + }); + + if (credentials.length === 0) { + throw new BadDataException("No WebAuthn credentials found for this user"); + } + + const options: any = await generateAuthenticationOptions({ + rpID: Host.toString(), + allowCredentials: credentials.map((cred: Model) => { + return { + id: cred.credentialId!, + type: "public-key", + }; + }), + userVerification: "preferred", + }); + + // Convert to JSON serializable format + options.challenge = Buffer.from(options.challenge).toString("base64url"); + // allowCredentials id is already base64url string + + return { + options: options as any, + challenge: options.challenge, + userId: user.id!.toString(), + }; + } + + @CaptureSpan() + public async verifyAuthentication(data: { + userId: string; + challenge: string; + credential: any; + }): Promise { + const user: User | null = await UserService.findOneById({ + id: new ObjectID(data.userId), + select: { + _id: true, + email: true, + }, + props: { + isRoot: true, + }, + }); + + if (!user) { + throw new BadDataException("User not found"); + } + + // Get the credential from database + const dbCredential: Model | null = await this.findOneBy({ + query: { + credentialId: data.credential.id, + userId: new ObjectID(data.userId), + isVerified: true, + }, + select: { + credentialId: true, + publicKey: true, + counter: true, + _id: true, + }, + props: { + isRoot: true, + }, + }); + + if (!dbCredential) { + throw new BadDataException("Credential not found"); + } + + const expectedOrigin: string = `${HttpProtocol}${Host.toString()}`; + + const verification: any = await verifyAuthenticationResponse({ + response: data.credential, + expectedChallenge: data.challenge, + expectedOrigin: expectedOrigin, + expectedRPID: Host.toString(), + credential: { + id: dbCredential.credentialId!, + publicKey: Buffer.from(dbCredential.publicKey!, "base64"), + counter: parseInt(dbCredential.counter!), + } as any, + }); + + if (!verification.verified) { + throw new BadDataException("Authentication verification failed"); + } + + // Update counter + await this.updateOneById({ + id: dbCredential.id!, + data: { + counter: verification.authenticationInfo.newCounter.toString(), + }, + props: { + isRoot: true, + }, + }); + + return user; + } + + @CaptureSpan() + protected override async onBeforeCreate( + createBy: CreateBy, + ): Promise> { + if (!createBy.props.userId) { + throw new BadDataException("User id is required"); + } + + createBy.data.userId = createBy.props.userId; + + const user: User | null = await UserService.findOneById({ + id: createBy.data.userId, + props: { + isRoot: true, + }, + select: { + email: true, + }, + }); + + if (!user) { + throw new BadDataException("User not found"); + } + + if (!user.email) { + throw new BadDataException("User email is required"); + } + + // by default secuirty keys are always verified. You can't add an unverified security key. + + createBy.data.isVerified = true; + + return { + createBy: createBy, + carryForward: {}, + }; + } + + @CaptureSpan() + protected override async onBeforeDelete( + deleteBy: DeleteBy, + ): Promise> { + const itemsToBeDeleted: Array = await this.findBy({ + query: deleteBy.query, + select: { + userId: true, + _id: true, + isVerified: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: deleteBy.props, + }); + + for (const item of itemsToBeDeleted) { + if (item.isVerified) { + // check if user two auth is enabled. + + const user: User | null = await UserService.findOneById({ + id: item.userId!, + props: { + isRoot: true, + }, + select: { + enableTwoFactorAuth: true, + }, + }); + + if (!user) { + throw new BadDataException("User not found"); + } + + if (user.enableTwoFactorAuth) { + // if enabled then check if this is the only verified item for this user. + + const verifiedItems: Array = await this.findBy({ + query: { + userId: item.userId!, + isVerified: true, + }, + select: { + _id: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: deleteBy.props, + }); + + if (verifiedItems.length === 1) { + throw new BadDataException( + "You must have atleast one verified two factor auth. Please disable two factor auth before deleting this item.", + ); + } + } + } + } + + return { + deleteBy: deleteBy, + carryForward: {}, + }; + } +} + +export default new Service(); diff --git a/Common/Server/Utils/TwoFactorAuth.ts b/Common/Server/Utils/TotpAuth.ts similarity index 95% rename from Common/Server/Utils/TwoFactorAuth.ts rename to Common/Server/Utils/TotpAuth.ts index 6db0db0481..1015a89418 100644 --- a/Common/Server/Utils/TwoFactorAuth.ts +++ b/Common/Server/Utils/TotpAuth.ts @@ -3,9 +3,9 @@ import * as OTPAuth from "otpauth"; import CaptureSpan from "./Telemetry/CaptureSpan"; /** - * Utility class for handling two-factor authentication. + * Utility class for handling TOTP authentication. */ -export default class TwoFactorAuth { +export default class TotpAuth { /** * Generates a random secret key for two-factor authentication. * @returns The generated secret key. diff --git a/Common/UI/Utils/Login.ts b/Common/UI/Utils/Login.ts index af8090dc69..c0ce49e1a5 100644 --- a/Common/UI/Utils/Login.ts +++ b/Common/UI/Utils/Login.ts @@ -22,7 +22,12 @@ export default abstract class LoginUtil { if (user.timezone) { UserUtil.setSavedUserTimezone(user.timezone); } - UserUtil.setIsMasterAdmin(user.isMasterAdmin as boolean); + + if (user.isMasterAdmin) { + UserUtil.setIsMasterAdmin(user.isMasterAdmin as boolean); + } else { + UserUtil.setIsMasterAdmin(false); + } if (user.profilePictureId) { UserUtil.setProfilePicId(user.profilePictureId); diff --git a/Common/Utils/Base64.ts b/Common/Utils/Base64.ts new file mode 100644 index 0000000000..5fef347514 --- /dev/null +++ b/Common/Utils/Base64.ts @@ -0,0 +1,13 @@ +class Base64 { + public static base64UrlToUint8Array(base64Url: string): Uint8Array { + const base64: string = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + return Buffer.from(base64, "base64") as Uint8Array; + } + + public static uint8ArrayToBase64Url(uint8Array: Uint8Array): string { + const base64: string = Buffer.from(uint8Array).toString("base64"); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, ""); + } +} + +export default Base64; diff --git a/Common/package-lock.json b/Common/package-lock.json index 04817c5ce0..d94c29c330 100644 --- a/Common/package-lock.json +++ b/Common/package-lock.json @@ -33,6 +33,7 @@ "@opentelemetry/sdk-trace-web": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.26.0", "@remixicon/react": "^4.2.0", + "@simplewebauthn/server": "^13.2.1", "@tippyjs/react": "^4.2.6", "@types/archiver": "^6.0.3", "@types/crypto-js": "^4.2.2", @@ -1553,6 +1554,12 @@ "node": ">=6" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@icons/material": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", @@ -2078,6 +2085,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@monaco-editor/loader": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", @@ -3621,150 +3634,160 @@ "node": ">=0.10" } }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.13.tgz", - "integrity": "sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==", + "node_modules/@peculiar/asn1-android": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.5.0.tgz", + "integrity": "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "@peculiar/asn1-x509-attr": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.5.0.tgz", + "integrity": "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-x509-attr": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.13.tgz", - "integrity": "sha512-+JtFsOUWCw4zDpxp1LbeTYBnZLlGVOWmHHEhoFdjM5yn4wCn+JiYQ8mghOi36M2f6TPQ17PmhNL6/JfNh7/jCA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz", + "integrity": "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.14.tgz", - "integrity": "sha512-zWPyI7QZto6rnLv6zPniTqbGaLh6zBpJyI46r1yS/bVHJXT2amdMHCRRnbV5yst2H8+ppXG6uXu/M6lKakiQ8w==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz", + "integrity": "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.13.tgz", - "integrity": "sha512-fypYxjn16BW+5XbFoY11Rm8LhZf6euqX/C7BTYpqVvLem1GvRl7A+Ro1bO/UPwJL0z+1mbvXEnkG0YOwbwz2LA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz", + "integrity": "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.13", - "@peculiar/asn1-pkcs8": "^2.3.13", - "@peculiar/asn1-rsa": "^2.3.13", - "@peculiar/asn1-schema": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-pkcs8": "^2.5.0", + "@peculiar/asn1-rsa": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.13.tgz", - "integrity": "sha512-VP3PQzbeSSjPjKET5K37pxyf2qCdM0dz3DJ56ZCsol3FqAXGekb4sDcpoL9uTLGxAh975WcdvUms9UcdZTuGyQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz", + "integrity": "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.13.tgz", - "integrity": "sha512-rIwQXmHpTo/dgPiWqUgby8Fnq6p1xTJbRMxCiMCk833kQCeZrC5lbSKg6NDnJTnX2kC6IbXBB9yCS2C73U2gJg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz", + "integrity": "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.13", - "@peculiar/asn1-pfx": "^2.3.13", - "@peculiar/asn1-pkcs8": "^2.3.13", - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "@peculiar/asn1-x509-attr": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-pfx": "^2.5.0", + "@peculiar/asn1-pkcs8": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "@peculiar/asn1-x509-attr": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.13.tgz", - "integrity": "sha512-wBNQqCyRtmqvXkGkL4DR3WxZhHy8fDiYtOjTeCd7SFE5F6GBeafw3EJ94PX/V0OJJrjQ40SkRY2IZu3ZSyBqcg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz", + "integrity": "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", - "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz", + "integrity": "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==", "license": "MIT", "dependencies": { - "asn1js": "^3.0.5", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.13.tgz", - "integrity": "sha512-PfeLQl2skXmxX2/AFFCVaWU8U6FKW1Db43mgBhShCOFS1bVxqtvusq1hVjfuEcuSQGedrLdCSvTgabluwN/M9A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz", + "integrity": "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "asn1js": "^3.0.5", - "ipaddr.js": "^2.1.0", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.3.13", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.13.tgz", - "integrity": "sha512-WpEos6CcnUzJ6o2Qb68Z7Dz5rSjRGv/DtXITCNBtjZIRWRV12yFVci76SVfOX8sisL61QWMhpLKQibrG8pi2Pw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz", + "integrity": "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "asn1js": "^3.0.5", - "tslib": "^2.6.2" + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, "node_modules/@peculiar/x509": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.12.3.tgz", - "integrity": "sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.0.tgz", + "integrity": "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.3.13", - "@peculiar/asn1-csr": "^2.3.13", - "@peculiar/asn1-ecc": "^2.3.14", - "@peculiar/asn1-pkcs9": "^2.3.13", - "@peculiar/asn1-rsa": "^2.3.13", - "@peculiar/asn1-schema": "^2.3.13", - "@peculiar/asn1-x509": "^2.3.13", - "pvtsutils": "^1.3.5", + "@peculiar/asn1-cms": "^2.5.0", + "@peculiar/asn1-csr": "^2.5.0", + "@peculiar/asn1-ecc": "^2.5.0", + "@peculiar/asn1-pkcs9": "^2.5.0", + "@peculiar/asn1-rsa": "^2.5.0", + "@peculiar/asn1-schema": "^2.5.0", + "@peculiar/asn1-x509": "^2.5.0", + "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", - "tslib": "^2.7.0", - "tsyringe": "^4.8.0" + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" } }, "node_modules/@pkgjs/parseargs": { @@ -3983,6 +4006,25 @@ "react": ">=16.8.0" } }, + "node_modules/@simplewebauthn/server": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.1.tgz", + "integrity": "sha512-Inmfye5opZXe3HI0GaksqBnQiM7glcNySoG6DH1GgkO1Lh9dvuV4XSV9DK02DReUVX39HpcDob9nxHELjECoQw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/x509": "^1.13.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -4032,9 +4074,9 @@ } }, "node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", "peer": true, @@ -4043,9 +4085,9 @@ "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", - "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { @@ -5021,6 +5063,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", + "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -5506,14 +5555,14 @@ } }, "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", + "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", "license": "BSD-3-Clause", "dependencies": { - "pvtsutils": "^1.3.2", + "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=12.0.0" @@ -9026,15 +9075,6 @@ "url": "https://opencollective.com/ioredis" } }, - "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/is-alphabetical": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", @@ -12300,11 +12340,14 @@ } }, "node_modules/monaco-editor": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", - "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", + "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", "license": "MIT", - "peer": true + "peer": true, + "dependencies": { + "@types/trusted-types": "^1.0.6" + } }, "node_modules/mri": { "version": "1.2.0", @@ -13340,12 +13383,12 @@ } }, "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", "license": "MIT", "dependencies": { - "tslib": "^2.6.1" + "tslib": "^2.8.1" } }, "node_modules/pvutils": { @@ -15919,9 +15962,9 @@ "license": "0BSD" }, "node_modules/tsyringe": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", - "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", "license": "MIT", "dependencies": { "tslib": "^1.9.3" @@ -16185,9 +16228,9 @@ } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "peer": true, diff --git a/Common/package.json b/Common/package.json index 464bd2f1c6..6dc40e2ba4 100644 --- a/Common/package.json +++ b/Common/package.json @@ -68,6 +68,7 @@ "@opentelemetry/sdk-trace-web": "^1.25.1", "@opentelemetry/semantic-conventions": "^1.26.0", "@remixicon/react": "^4.2.0", + "@simplewebauthn/server": "^13.2.1", "@tippyjs/react": "^4.2.6", "@types/archiver": "^6.0.3", "@types/crypto-js": "^4.2.2", diff --git a/Copilot/nodemon.json b/Copilot/nodemon.json index e36a6d8457..1435b8066d 100644 --- a/Copilot/nodemon.json +++ b/Copilot/nodemon.json @@ -7,5 +7,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Dashboard/Dockerfile.tpl b/Dashboard/Dockerfile.tpl index ccc3717003..5a6867be45 100644 --- a/Dashboard/Dockerfile.tpl +++ b/Dashboard/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx b/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx index c0060f8f65..4387bccacd 100644 --- a/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx +++ b/Dashboard/src/Pages/Global/UserProfile/TwoFactorAuth.tsx @@ -7,8 +7,9 @@ import Route from "Common/Types/API/Route"; import Page from "Common/UI/Components/Page/Page"; import React, { FunctionComponent, ReactElement } from "react"; import UserUtil from "Common/UI/Utils/User"; -import UserTwoFactorAuth from "Common/Models/DatabaseModels/UserTwoFactorAuth"; -import { ButtonStyleType } from "Common/UI/Components/Button/Button"; +import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth"; +import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn"; +import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button"; import IconProp from "Common/Types/Icon/IconProp"; import FieldType from "Common/UI/Components/Types/FieldType"; import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; @@ -26,10 +27,11 @@ import { CustomElementProps } from "Common/UI/Components/Forms/Types/Field"; import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; import User from "Common/Models/DatabaseModels/User"; import OneUptimeDate from "Common/Types/Date"; +import Base64 from "Common/Utils/Base64"; const Home: FunctionComponent = (): ReactElement => { - const [selectedTwoFactorAuth, setSelectedTwoFactorAuth] = - React.useState(null); + const [selectedTotpAuth, setSelectedTotpAuth] = + React.useState(null); const [showVerificationModal, setShowVerificationModal] = React.useState(false); const [verificationError, setVerificationError] = React.useState< @@ -42,6 +44,13 @@ const Home: FunctionComponent = (): ReactElement => { OneUptimeDate.getCurrentDate().toString(), ); + const [showWebAuthnRegistrationModal, setShowWebAuthnRegistrationModal] = + React.useState(false); + const [webAuthnRegistrationError, setWebAuthnRegistrationError] = + React.useState(null); + const [webAuthnRegistrationLoading, setWebAuthnRegistrationLoading] = + React.useState(false); + return ( = (): ReactElement => { sideMenu={} >
- - modelType={UserTwoFactorAuth} - name="Two Factor Authentication" - id="two-factor-auth-table" - userPreferencesKey="user-two-factor-auth-table" + + modelType={UserTotpAuth} + name="Authenticator Based TOTP Authentication" + id="totp-auth-table" + userPreferencesKey="user-totp-auth-table" isDeleteable={true} refreshToggle={tableRefreshToggle} filters={[]} @@ -82,25 +91,28 @@ const Home: FunctionComponent = (): ReactElement => { isCreateable={true} isViewable={false} cardProps={{ - title: "Two Factor Authentication", - description: "Manage your two factor authentication settings here.", + title: "Authenticator Based Two Factor Authentication", + description: + "Manage your authenticator based two factor authentication settings here.", }} - noItemsMessage={"No two factor authentication found."} - singularName="Two Factor Authentication" - pluralName="Two Factor Authentications" + noItemsMessage={ + "No authenticator based two factor authentication found." + } + singularName="Authenticator Based Two Factor Authentication" + pluralName="Authenticator Based Two Factor Authentications" actionButtons={[ { title: "Verify", buttonStyleType: ButtonStyleType.NORMAL, icon: IconProp.Check, - isVisible: (item: UserTwoFactorAuth) => { + isVisible: (item: UserTotpAuth) => { return !item.isVerified; }, onClick: async ( - item: UserTwoFactorAuth, + item: UserTotpAuth, onCompleteAction: VoidFunction, ) => { - setSelectedTwoFactorAuth(item); + setSelectedTotpAuth(item); setShowVerificationModal(true); onCompleteAction(); }, @@ -137,9 +149,67 @@ const Home: FunctionComponent = (): ReactElement => { }, ]} /> - {showVerificationModal && selectedTwoFactorAuth ? ( + +
+ + modelType={UserWebAuthn} + name="Security Key-Based Two-Factor Authentication" + id="webauthn-table" + userPreferencesKey="user-webauthn-table" + isDeleteable={true} + refreshToggle={tableRefreshToggle} + filters={[]} + query={{ + userId: UserUtil.getUserId(), + }} + isEditable={false} + showRefreshButton={true} + isCreateable={false} + isViewable={false} + cardProps={{ + title: "Security Key-Based Two-Factor Authentication", + description: + "Manage your security keys for two-factor authentication.", + rightElement: ( +
+
+ ), + }} + noItemsMessage={"No security keys found."} + singularName="Security Key" + pluralName="Security Keys" + columns={[ + { + field: { + name: true, + }, + title: "Name", + type: FieldType.Text, + }, + { + field: { + isVerified: true, + }, + title: "Is Verified?", + type: FieldType.Boolean, + }, + ]} + /> +
+ + {showVerificationModal && selectedTotpAuth ? ( = (): ReactElement => { } return ( ); }, @@ -183,7 +253,7 @@ const Home: FunctionComponent = (): ReactElement => { onClose={() => { setShowVerificationModal(false); setVerificationError(null); - setSelectedTwoFactorAuth(null); + setSelectedTotpAuth(null); }} isLoading={verificationLoading} onSubmit={async (values: JSONObject) => { @@ -195,17 +265,17 @@ const Home: FunctionComponent = (): ReactElement => { | HTTPResponse | HTTPErrorResponse = await API.post({ url: URL.fromString(APP_API_URL.toString()).addRoute( - `/user-two-factor-auth/validate`, + `/user-totp-auth/validate`, ), data: { code: values["code"], - id: selectedTwoFactorAuth.id?.toString(), + id: selectedTotpAuth.id?.toString(), }, }); if (response.isSuccess()) { setShowVerificationModal(false); setVerificationError(null); - setSelectedTwoFactorAuth(null); + setSelectedTotpAuth(null); setVerificationLoading(false); } @@ -227,6 +297,124 @@ const Home: FunctionComponent = (): ReactElement => { ) : ( <> )} + + {showWebAuthnRegistrationModal ? ( + { + setShowWebAuthnRegistrationModal(false); + setWebAuthnRegistrationError(null); + setWebAuthnRegistrationLoading(false); + }} + isLoading={webAuthnRegistrationLoading} + onSubmit={async (values: JSONObject) => { + try { + setWebAuthnRegistrationLoading(true); + setWebAuthnRegistrationError(""); + + // Generate registration options + const response: HTTPResponse | HTTPErrorResponse = + await API.post({ + url: URL.fromString(APP_API_URL.toString()).addRoute( + `/user-webauthn/generate-registration-options`, + ), + data: {}, + }); + + if (response instanceof HTTPErrorResponse) { + throw response; + } + + const data: any = response.data as any; + + // Convert base64url strings back to Uint8Array + data.options.challenge = Base64.base64UrlToUint8Array( + data.options.challenge, + ); + if (data.options.excludeCredentials) { + data.options.excludeCredentials.forEach((cred: any) => { + cred.id = Base64.base64UrlToUint8Array(cred.id); + }); + } + if (data.options.user && data.options.user.id) { + data.options.user.id = Base64.base64UrlToUint8Array( + data.options.user.id, + ); + } + + // Use WebAuthn API + const credential: PublicKeyCredential = + (await navigator.credentials.create({ + publicKey: data.options, + })) as PublicKeyCredential; + + const attestationResponse: AuthenticatorAttestationResponse = + credential.response as AuthenticatorAttestationResponse; + + // Verify registration + const verifyResponse: + | HTTPResponse + | HTTPErrorResponse = await API.post({ + url: URL.fromString(APP_API_URL.toString()).addRoute( + `/user-webauthn/verify-registration`, + ), + data: { + challenge: data.challenge, + name: values["name"], + credential: { + id: credential.id, + rawId: Base64.uint8ArrayToBase64Url( + new Uint8Array(credential.rawId), + ), + response: { + attestationObject: Base64.uint8ArrayToBase64Url( + new Uint8Array(attestationResponse.attestationObject), + ), + clientDataJSON: Base64.uint8ArrayToBase64Url( + new Uint8Array(attestationResponse.clientDataJSON), + ), + }, + type: credential.type, + }, + }, + }); + + if (verifyResponse instanceof HTTPErrorResponse) { + throw verifyResponse; + } + + setShowWebAuthnRegistrationModal(false); + setWebAuthnRegistrationError(null); + setTableRefreshToggle( + OneUptimeDate.getCurrentDate().toString(), + ); + setWebAuthnRegistrationLoading(false); + } catch (err) { + setWebAuthnRegistrationError(API.getFriendlyMessage(err)); + setWebAuthnRegistrationLoading(false); + } + }} + /> + ) : ( + <> + )}
cardProps={{ diff --git a/Docs/Dockerfile.tpl b/Docs/Dockerfile.tpl index 324eed28aa..799047a17b 100644 --- a/Docs/Dockerfile.tpl +++ b/Docs/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Docs/nodemon.json b/Docs/nodemon.json index a06d947c9b..87543bab46 100644 --- a/Docs/nodemon.json +++ b/Docs/nodemon.json @@ -8,5 +8,5 @@ "./build/**", "greenlock.d/*" ], - "exec": "node --inspect=0.0.0.0:9229 --require ts-node/register Index.ts" + "exec": "node --require ts-node/register Index.ts" } \ No newline at end of file diff --git a/FluentIngest/nodemon.json b/FluentIngest/nodemon.json index a89bf622b3..4c66f4c997 100644 --- a/FluentIngest/nodemon.json +++ b/FluentIngest/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Home/Dockerfile.tpl b/Home/Dockerfile.tpl index 68e0645024..1772703ba6 100644 --- a/Home/Dockerfile.tpl +++ b/Home/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Home/nodemon.json b/Home/nodemon.json index b8fb4314a4..7676e20960 100644 --- a/Home/nodemon.json +++ b/Home/nodemon.json @@ -10,5 +10,5 @@ ], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/IncomingRequestIngest/nodemon.json b/IncomingRequestIngest/nodemon.json index a89bf622b3..4c66f4c997 100644 --- a/IncomingRequestIngest/nodemon.json +++ b/IncomingRequestIngest/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/IsolatedVM/nodemon.json b/IsolatedVM/nodemon.json index 0d3a12b42a..79c72c4828 100644 --- a/IsolatedVM/nodemon.json +++ b/IsolatedVM/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/OpenTelemetryIngest/nodemon.json b/OpenTelemetryIngest/nodemon.json index a89bf622b3..4c66f4c997 100644 --- a/OpenTelemetryIngest/nodemon.json +++ b/OpenTelemetryIngest/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Probe/nodemon.json b/Probe/nodemon.json index e36a6d8457..1435b8066d 100644 --- a/Probe/nodemon.json +++ b/Probe/nodemon.json @@ -7,5 +7,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/ProbeIngest/nodemon.json b/ProbeIngest/nodemon.json index a89bf622b3..4c66f4c997 100644 --- a/ProbeIngest/nodemon.json +++ b/ProbeIngest/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/ServerMonitorIngest/nodemon.json b/ServerMonitorIngest/nodemon.json index a89bf622b3..4c66f4c997 100644 --- a/ServerMonitorIngest/nodemon.json +++ b/ServerMonitorIngest/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/StatusPage/Dockerfile.tpl b/StatusPage/Dockerfile.tpl index fa87a9ecb6..759cbf7975 100644 --- a/StatusPage/Dockerfile.tpl +++ b/StatusPage/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/TestServer/Dockerfile.tpl b/TestServer/Dockerfile.tpl index 123f22eb04..f8387dce92 100644 --- a/TestServer/Dockerfile.tpl +++ b/TestServer/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/TestServer/nodemon.json b/TestServer/nodemon.json index 0d3a12b42a..79c72c4828 100644 --- a/TestServer/nodemon.json +++ b/TestServer/nodemon.json @@ -4,5 +4,5 @@ "ignore": ["./node_modules/**", "./public/**", "./bin/**", "./build/**"], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Tests/Dockerfile.tpl b/Tests/Dockerfile.tpl index 1feec3ff3a..d242f16065 100644 --- a/Tests/Dockerfile.tpl +++ b/Tests/Dockerfile.tpl @@ -1,4 +1,4 @@ -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Worker/Dockerfile.tpl b/Worker/Dockerfile.tpl index f7aed764f5..7f97095d40 100644 --- a/Worker/Dockerfile.tpl +++ b/Worker/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Worker/nodemon.json b/Worker/nodemon.json index 97e0473e95..c8df815972 100644 --- a/Worker/nodemon.json +++ b/Worker/nodemon.json @@ -16,5 +16,5 @@ "TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false" }, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file diff --git a/Workflow/Dockerfile.tpl b/Workflow/Dockerfile.tpl index 8b91b619db..21d3e48d7f 100644 --- a/Workflow/Dockerfile.tpl +++ b/Workflow/Dockerfile.tpl @@ -3,7 +3,7 @@ # # Pull base image nodejs image. -FROM public.ecr.aws/docker/library/node:23.8-alpine3.21 +FROM public.ecr.aws/docker/library/node:24.9-alpine3.21 RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global RUN npm config set fetch-retries 5 diff --git a/Workflow/nodemon.json b/Workflow/nodemon.json index b8fb4314a4..7676e20960 100644 --- a/Workflow/nodemon.json +++ b/Workflow/nodemon.json @@ -10,5 +10,5 @@ ], "watchOptions": {"useFsEvents": false, "interval": 500}, "env": {"TS_NODE_TRANSPILE_ONLY": "1", "TS_NODE_FILES": "false"}, - "exec": "node --inspect=0.0.0.0:9229 -r ts-node/register/transpile-only Index.ts" + "exec": "node -r ts-node/register/transpile-only Index.ts" } \ No newline at end of file