diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 316ddcb53..e96a577d9 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -203,7 +203,8 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyIV: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), - verifier: z.string().trim() + verifier: z.string().trim(), + password: z.string().trim() }), response: { 200: z.object({ @@ -218,7 +219,69 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { userId: token.userId }); - return { message: "Successfully updated backup private key" }; + return { message: "Successfully reset password" }; + } + }); + + server.route({ + method: "POST", + url: "/email/password-setup", + config: { + rateLimit: authRateLimit + }, + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.password.sendPasswordSetupEmail(req.permission); + + return { + message: "A password setup link has been sent" + }; + } + }); + + server.route({ + method: "POST", + url: "/password-setup", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim(), + password: z.string().trim(), + token: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req, res) => { + await server.services.password.setupPassword(req.body, req.permission); + + const appCfg = getConfig(); + void res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + return { message: "Successfully setup password" }; } }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index c0bb7dc17..d15fa4543 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -57,6 +57,12 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_PASSWORD_SETUP: { + // generate random hex + const token = crypto.randomBytes(16).toString("hex"); + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, expiresAt }; + } case TokenType.TOKEN_USER_UNLOCK: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(new Date().getTime() + 259200000); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 65d16850a..5f5843bc6 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -6,6 +6,7 @@ export enum TokenType { TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", + TOKEN_EMAIL_PASSWORD_SETUP = "passwordSetup", TOKEN_USER_UNLOCK = "userUnlock" } diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 9ed9951fe..aef8eafb9 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -4,6 +4,8 @@ import jwt from "jsonwebtoken"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; @@ -11,8 +13,13 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TAuthDALFactory } from "./auth-dal"; -import { TChangePasswordDTO, TCreateBackupPrivateKeyDTO, TResetPasswordViaBackupKeyDTO } from "./auth-password-type"; -import { AuthTokenType } from "./auth-type"; +import { + TChangePasswordDTO, + TCreateBackupPrivateKeyDTO, + TResetPasswordViaBackupKeyDTO, + TSetupPasswordViaBackupKeyDTO +} from "./auth-password-type"; +import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; @@ -169,8 +176,13 @@ export const authPaswordServiceFactory = ({ verifier, encryptedPrivateKeyIV, encryptedPrivateKeyTag, - userId + userId, + password }: TResetPasswordViaBackupKeyDTO) => { + const cfg = getConfig(); + + const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); + await userDAL.updateUserEncryptionByUserId(userId, { encryptionVersion: 2, protectedKey, @@ -180,7 +192,8 @@ export const authPaswordServiceFactory = ({ iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, salt, - verifier + verifier, + hashedPassword }); await userDAL.updateById(userId, { @@ -267,6 +280,106 @@ export const authPaswordServiceFactory = ({ return backupKey; }; + const sendPasswordSetupEmail = async (actor: OrgServiceActor) => { + if (actor.type !== ActorType.USER) + throw new BadRequestError({ message: `Actor of type ${actor.type} cannot set password` }); + + const user = await userDAL.findById(actor.id); + + if (!user) throw new BadRequestError({ message: `Could not find user with ID ${actor.id}` }); + + if (!user.isAccepted || !user.authMethods) + throw new BadRequestError({ message: `You must complete signup to set a password` }); + + const cfg = getConfig(); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_SETUP, + userId: user.id + }); + + const email = user.email ?? user.username; + + await smtpService.sendMail({ + template: SmtpTemplates.SetupPassword, + recipients: [email], + subjectLine: "Infisical Password Setup", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-setup` : "" + } + }); + }; + + const setupPassword = async ( + { + encryptedPrivateKey, + protectedKeyTag, + protectedKey, + protectedKeyIV, + salt, + verifier, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + password, + token + }: TSetupPasswordViaBackupKeyDTO, + actor: OrgServiceActor + ) => { + try { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_SETUP, + userId: actor.id, + code: token + }); + } catch (e) { + throw new BadRequestError({ message: "Expired or invalid token. Please try again." }); + } + + await userDAL.transaction(async (tx) => { + const user = await userDAL.findById(actor.id, tx); + + if (!user) throw new BadRequestError({ message: `Could not find user with ID ${actor.id}` }); + + if (!user.isAccepted || !user.authMethods) + throw new BadRequestError({ message: `You must complete signup to set a password` }); + + await userDAL.updateById( + actor.id, + { + authMethods: [...user.authMethods, AuthMethod.EMAIL] + }, + tx + ); + + const cfg = getConfig(); + + const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); + + await userDAL.updateUserEncryptionByUserId( + actor.id, + { + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + salt, + verifier, + hashedPassword, + serverPrivateKey: null, + clientPublicKey: null + }, + tx + ); + }); + + await tokenService.revokeAllMySessions(actor.id); + }; + return { generateServerPubKey, changePassword, @@ -274,6 +387,8 @@ export const authPaswordServiceFactory = ({ sendPasswordResetEmail, verifyPasswordResetEmail, createBackupPrivateKey, - getBackupPrivateKeyOfUser + getBackupPrivateKeyOfUser, + sendPasswordSetupEmail, + setupPassword }; }; diff --git a/backend/src/services/auth/auth-password-type.ts b/backend/src/services/auth/auth-password-type.ts index a52374506..7c67c0934 100644 --- a/backend/src/services/auth/auth-password-type.ts +++ b/backend/src/services/auth/auth-password-type.ts @@ -23,6 +23,20 @@ export type TResetPasswordViaBackupKeyDTO = { encryptedPrivateKeyTag: string; salt: string; verifier: string; + password: string; +}; + +export type TSetupPasswordViaBackupKeyDTO = { + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + password: string; + token: string; }; export type TCreateBackupPrivateKeyDTO = { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index d997d52f4..67168f1dd 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -30,6 +30,7 @@ export enum SmtpTemplates { NewDeviceJoin = "newDevice.handlebars", OrgInvite = "organizationInvitation.handlebars", ResetPassword = "passwordReset.handlebars", + SetupPassword = "passwordSetup.handlebars", SecretLeakIncident = "secretLeakIncident.handlebars", WorkspaceInvite = "workspaceInvitation.handlebars", ScimUserProvisioned = "scimUserProvisioned.handlebars", diff --git a/backend/src/services/smtp/templates/passwordSetup.handlebars b/backend/src/services/smtp/templates/passwordSetup.handlebars new file mode 100644 index 000000000..1d3a5f72d --- /dev/null +++ b/backend/src/services/smtp/templates/passwordSetup.handlebars @@ -0,0 +1,16 @@ + +
+ + +Someone requested to set up a password for your account. Make sure you are already logged in to Infisical in the current browser before clicking the link below.
+ Setup password +If you didn't initiate this request, please contact + {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}
+ + {{emailFooter}} + + \ No newline at end of file diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 03e077a8d..42823af4d 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -13,7 +13,8 @@ export const ROUTE_PATHS = Object.freeze({ "/_restrict-login-signup/login/provider/success" ), SignUpSsoPage: setRoute("/signup/sso", "/_restrict-login-signup/signup/sso"), - PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset") + PasswordResetPage: setRoute("/password-reset", "/_restrict-login-signup/password-reset"), + PasswordSetupPage: setRoute("/password-setup", "/_authenticate/password-setup") }, Organization: { SecretScanning: setRoute( diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 8d8ee0c3f..d7703e3b3 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -23,6 +23,7 @@ import { MfaMethod, ResetPasswordDTO, SendMfaTokenDTO, + SetupPasswordDTO, SRP1DTO, SRPR1Res, TOauthTokenExchangeDTO, @@ -286,7 +287,8 @@ export const useResetPassword = () => { encryptedPrivateKeyIV: details.encryptedPrivateKeyIV, encryptedPrivateKeyTag: details.encryptedPrivateKeyTag, salt: details.salt, - verifier: details.verifier + verifier: details.verifier, + password: details.password }, { headers: { @@ -336,3 +338,23 @@ export const checkUserTotpMfa = async () => { return data.isVerified; }; + +export const useSendPasswordSetupEmail = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/password/email/password-setup"); + + return data; + } + }); +}; + +export const useSetupPassword = () => { + return useMutation({ + mutationFn: async ({ verificationToken, ...payload }: SetupPasswordDTO) => { + const { data } = await apiRequest.post("/api/v1/password/password-setup", payload); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index d0c718e48..036897fed 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -133,6 +133,20 @@ export type ResetPasswordDTO = { salt: string; verifier: string; verificationToken: string; + password: string; +}; + +export type SetupPasswordDTO = { + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + token: string; + password: string; }; export type IssueBackupPrivateKeyDTO = { diff --git a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx index ba28c3871..3361dd961 100644 --- a/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx +++ b/frontend/src/pages/auth/PasswordResetPage/PasswordResetPage.tsx @@ -136,7 +136,8 @@ export const PasswordResetPage = () => { encryptedPrivateKeyTag, salt: result.salt, verifier: result.verifier, - verificationToken + verificationToken, + password: newPassword }); navigate({ to: "/login" }); diff --git a/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx new file mode 100644 index 000000000..311d613ef --- /dev/null +++ b/frontend/src/pages/auth/PasswordSetupPage/PasswordSetupPage.tsx @@ -0,0 +1,349 @@ +import crypto from "crypto"; + +import { FormEvent, useState } from "react"; +import { faCheck, faEye, faEyeSlash, faKey, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import jsrp from "jsrp"; + +import { createNotification } from "@app/components/notifications"; +import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck"; +import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; +import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; +import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useSetupPassword } from "@app/hooks/api/auth/queries"; + +// eslint-disable-next-line new-cap +const client = new jsrp.client(); + +export const PasswordSetupPage = () => { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [passwordsMatch, setPasswordsMatch] = useState(true); + const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(true); + const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false); + const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(true); + const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(true); + const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false); + const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false); + const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false); + const [passwordErrorBreached, setPasswordErrorBreached] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + + const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordSetupPage.id }); + + const navigate = useNavigate(); + + const setupPassword = useSetupPassword(); + + const parsedUrl = search; + const token = parsedUrl.token as string; + const email = (parsedUrl.to as string)?.replace(" ", "+").trim(); + + const handleSetPassword = async (e: FormEvent