diff --git a/apps/customer_dashboard/src/app/users/forgot_password/page.tsx b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx index 8444f6541..d1ed3d053 100644 --- a/apps/customer_dashboard/src/app/users/forgot_password/page.tsx +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx @@ -20,6 +20,8 @@ import { EMAIL_REGEX, EMAIL_VERIFICATION_TIMER_SECONDS, PASSWORD_MIN_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_CONTAINS_NUMBER_REGEX, SIX_DIGITS_REGEX, } from "@oko-wallet-ct-dashboard/constants"; import { ExpiryTimer } from "@oko-wallet-ct-dashboard/components/expiry_timer/expiry_timer"; @@ -49,6 +51,7 @@ export default function ForgotPasswordPage() { const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [isCodeExpired, setIsCodeExpired] = useState(false); const codeValue = useMemo(() => codeDigits.join(""), [codeDigits]); @@ -116,6 +119,14 @@ export default function ForgotPasswordPage() { setError(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`); return; } + if (password.length > PASSWORD_MAX_LENGTH) { + setError(`Password must be at most ${PASSWORD_MAX_LENGTH} characters`); + return; + } + if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(password)) { + setError("Password must include at least one number"); + return; + } if (password !== confirmPassword) { setError("Passwords do not match"); return; @@ -123,6 +134,7 @@ export default function ForgotPasswordPage() { setIsLoading(true); resetError(); + setIsCodeExpired(false); try { const res = await requestResetPasswordConfirm( email, @@ -132,7 +144,38 @@ export default function ForgotPasswordPage() { if (res.success) { router.push(paths.home); } else { - setError(res.msg || "Failed to reset password"); + if (res.code === "INVALID_VERIFICATION_CODE") { + setIsCodeExpired(true); + setError("Verification code has expired. Please request a new code."); + } else { + setError(res.msg || "Failed to reset password"); + } + } + } catch (err) { + setError("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + }; + + const handleRequestNewCode = async () => { + if (!email || isLoading) { + return; + } + + setIsLoading(true); + resetError(); + setIsCodeExpired(false); + try { + const res = await requestForgotPassword(email); + if (res.success) { + setCodeDigits(EMPTY_CODE); + setVerifiedCode(""); + setPassword(""); + setConfirmPassword(""); + goToStep(Step.CODE); + } else { + setError(res.msg || "Failed to send code"); } } catch (err) { setError("An unexpected error occurred"); @@ -317,6 +360,7 @@ export default function ForgotPasswordPage() { }} fullWidth requiredSymbol + maxLength={16} helpText={ error ? undefined @@ -344,6 +388,7 @@ export default function ForgotPasswordPage() { }} fullWidth requiredSymbol + maxLength={16} SideComponent={ + {isCodeExpired ? ( + + ) : ( + + )} diff --git a/apps/customer_dashboard/src/components/reset_password/use_reset_password_form.ts b/apps/customer_dashboard/src/components/reset_password/use_reset_password_form.ts index 3729644e0..9fcb6bfb3 100644 --- a/apps/customer_dashboard/src/components/reset_password/use_reset_password_form.ts +++ b/apps/customer_dashboard/src/components/reset_password/use_reset_password_form.ts @@ -3,7 +3,11 @@ import { useForm, type SubmitHandler } from "react-hook-form"; import { useRouter } from "next/navigation"; import { paths } from "@oko-wallet-ct-dashboard/paths"; -import { PASSWORD_MIN_LENGTH } from "@oko-wallet-ct-dashboard/constants"; +import { + PASSWORD_MIN_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_CONTAINS_NUMBER_REGEX, +} from "@oko-wallet-ct-dashboard/constants"; import { requestChangePassword } from "@oko-wallet-ct-dashboard/fetch/users"; import { useAppState } from "@oko-wallet-ct-dashboard/state"; @@ -91,6 +95,16 @@ function resetPasswordResolver(values: ResetPasswordInputs) { type: "minLength", message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters`, }; + } else if (values.newPassword.length > PASSWORD_MAX_LENGTH) { + errors.newPassword = { + type: "maxLength", + message: `Password must be at most ${PASSWORD_MAX_LENGTH} characters`, + }; + } else if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(values.newPassword)) { + errors.newPassword = { + type: "pattern", + message: "Password must include at least one number", + }; } if (!values.confirmPassword) { diff --git a/apps/customer_dashboard/src/constants/index.ts b/apps/customer_dashboard/src/constants/index.ts index 69623fce9..c0b4a6ca0 100644 --- a/apps/customer_dashboard/src/constants/index.ts +++ b/apps/customer_dashboard/src/constants/index.ts @@ -1,8 +1,10 @@ export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const PASSWORD_MIN_LENGTH = 8; +export const PASSWORD_MAX_LENGTH = 16; +export const PASSWORD_CONTAINS_NUMBER_REGEX = /\d/; export const SIX_DIGITS_REGEX = /^\d{6}$/; -export const EMAIL_VERIFICATION_TIMER_SECONDS = 60 * 3; +export const EMAIL_VERIFICATION_TIMER_SECONDS = 60; export const GET_STARTED_URL = "https://form.typeform.com/to/MxrBGq9b"; diff --git a/apps/email_template_2/src/app/reset_pw_code/page.tsx b/apps/email_template_2/src/app/reset_pw_code/page.tsx index 62736b22c..a03f4eee7 100644 --- a/apps/email_template_2/src/app/reset_pw_code/page.tsx +++ b/apps/email_template_2/src/app/reset_pw_code/page.tsx @@ -72,7 +72,7 @@ export default function ResetPwCodePage() { password.
The code is valid for{" "} - {"${email_verification_expiration_minutes}"} minutes + {"${email_verification_expiration_minutes}"} minute for your security. diff --git a/backend/ct_dashboard_api/src/constants/index.ts b/backend/ct_dashboard_api/src/constants/index.ts index 3ffd04649..3ee504128 100644 --- a/backend/ct_dashboard_api/src/constants/index.ts +++ b/backend/ct_dashboard_api/src/constants/index.ts @@ -1,6 +1,8 @@ export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export const PASSWORD_MIN_LENGTH = 4; export const CHANGED_PASSWORD_MIN_LENGTH = 8; +export const CHANGED_PASSWORD_MAX_LENGTH = 16; +export const PASSWORD_CONTAINS_NUMBER_REGEX = /\d/; export const SIX_DIGITS_REGEX = /^\d{6}$/; diff --git a/backend/ct_dashboard_api/src/email/password_reset.ts b/backend/ct_dashboard_api/src/email/password_reset.ts index 543911fd1..b30518921 100644 --- a/backend/ct_dashboard_api/src/email/password_reset.ts +++ b/backend/ct_dashboard_api/src/email/password_reset.ts @@ -12,7 +12,7 @@ export async function sendPasswordResetEmail( const subject = `Reset Password Verification Code for ${customer_label}`; const html = ` - Oko Email Template
Oko password reset code header

Enter this code in your Oko Dashboard to reset your password.
The code is valid for ${email_verification_expiration_minutes} minutes for your security.

 

Your 6-digit code
for changing your password

 

${verification_code}

 
 

If you didn't make this request, you can safely delete this email.

 

Oko Team

 
Gray Oko logo
+ Oko Email Template
Oko password reset code header

Enter this code in your Oko Dashboard to reset your password.
The code is valid for ${email_verification_expiration_minutes} minute for your security.

 

Your 6-digit code
for changing your password

 

${verification_code}

 
 

If you didn't make this request, you can safely delete this email.

 

Oko Team

 
Gray Oko logo
`; console.info( diff --git a/backend/ct_dashboard_api/src/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index d2a29a18c..4d67fe0e8 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.ts @@ -17,7 +17,10 @@ import { getCTDUserWithCustomerByEmail, } from "@oko-wallet/oko-pg-interface/customer_dashboard_users"; import { hashPassword, comparePassword } from "@oko-wallet/crypto-js"; -import { verifyEmailCode } from "@oko-wallet/oko-pg-interface/email_verifications"; +import { + verifyEmailCode, + markCodeVerified, +} from "@oko-wallet/oko-pg-interface/email_verifications"; import { registry } from "@oko-wallet/oko-api-openapi"; import { ErrorResponseSchema } from "@oko-wallet/oko-api-openapi/common"; import { @@ -41,6 +44,8 @@ import { generateCustomerToken } from "@oko-wallet-ctd-api/auth"; import { sendEmailVerificationCode } from "@oko-wallet-ctd-api/email/send"; import { CHANGED_PASSWORD_MIN_LENGTH, + CHANGED_PASSWORD_MAX_LENGTH, + PASSWORD_CONTAINS_NUMBER_REGEX, EMAIL_REGEX, SIX_DIGITS_REGEX, } from "@oko-wallet-ctd-api/constants"; @@ -51,10 +56,7 @@ import { import { rateLimitMiddleware } from "@oko-wallet-ctd-api/middleware/rate_limit"; import { generateVerificationCode } from "@oko-wallet-ctd-api/email/verification"; import { sendPasswordResetEmail } from "@oko-wallet-ctd-api/email/password_reset"; -import { - createEmailVerification, - getLatestPendingVerification, -} from "@oko-wallet/oko-pg-interface/email_verifications"; +import { createEmailVerification } from "@oko-wallet/oko-pg-interface/email_verifications"; export function setCustomerAuthRoutes(router: Router) { registry.registerPath({ @@ -291,16 +293,8 @@ export function setCustomerAuthRoutes(router: Router) { return; } - const pendingRes = await getLatestPendingVerification(state.db, email); - if (!pendingRes.success) { - res - .status(500) - .json({ success: false, code: "UNKNOWN_ERROR", msg: "DB Error" }); - return; - } - - const pending = pendingRes.data; - if (!pending || pending.verification_code !== code) { + const result = await markCodeVerified(state.db, email, code, 5); + if (!result.success) { res.status(400).json({ success: false, code: "INVALID_VERIFICATION_CODE", @@ -402,6 +396,24 @@ export function setCustomerAuthRoutes(router: Router) { return; } + if (newPassword.length > CHANGED_PASSWORD_MAX_LENGTH) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Password too long", + }); + return; + } + + if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(newPassword)) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Password must include at least one number", + }); + return; + } + const verificationResult = await verifyEmailCode(state.db, { email, verification_code: code, @@ -1027,6 +1039,24 @@ export function setCustomerAuthRoutes(router: Router) { return; } + if (request.new_password.length > CHANGED_PASSWORD_MAX_LENGTH) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Password must be at most 16 characters long", + }); + return; + } + + if (!PASSWORD_CONTAINS_NUMBER_REGEX.test(request.new_password)) { + res.status(400).json({ + success: false, + code: "INVALID_EMAIL_OR_PASSWORD", + msg: "Password must include at least one number", + }); + return; + } + // Inline changePassword logic const customerAccountResult = await getCTDUserWithCustomerAndPasswordHashByEmail( diff --git a/backend/oko_pg_interface/src/email_verifications/index.ts b/backend/oko_pg_interface/src/email_verifications/index.ts index ec9267b9e..dd5451ea7 100644 --- a/backend/oko_pg_interface/src/email_verifications/index.ts +++ b/backend/oko_pg_interface/src/email_verifications/index.ts @@ -15,11 +15,11 @@ export async function createEmailVerification( ): Promise> { const query = ` INSERT INTO email_verifications ( - email_verification_id, email, verification_code, + email_verification_id, email, verification_code, status, expires_at ) VALUES ( - $1, $2, $3, + $1, $2, $3, $4, $5 ) RETURNING * @@ -61,14 +61,14 @@ export async function verifyEmailCode( ): Promise> { try { const updateQuery = ` -UPDATE email_verifications +UPDATE email_verifications SET status = '${EmailVerificationStatus.VERIFIED}', updated_at = NOW() WHERE email_verification_id = ( SELECT email_verification_id FROM email_verifications WHERE email = $1 AND verification_code = $2 - AND status = '${EmailVerificationStatus.PENDING}' + AND status = '${EmailVerificationStatus.CODE_VERIFIED}' AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1 @@ -110,14 +110,65 @@ RETURNING status } } +export async function markCodeVerified( + db: Pool, + email: string, + code: string, + extendMinutes: number = 5, +): Promise> { + const query = ` +UPDATE email_verifications +SET status = '${EmailVerificationStatus.CODE_VERIFIED}', + expires_at = NOW() + INTERVAL '1 minute' * $3, + updated_at = NOW() +WHERE email_verification_id = ( + SELECT email_verification_id + FROM email_verifications + WHERE email = $1 + AND verification_code = $2 + AND status = '${EmailVerificationStatus.PENDING}' + AND expires_at > NOW() + ORDER BY created_at DESC + LIMIT 1 +) +RETURNING * +`; + + try { + const result = await db.query(query, [ + email, + code, + extendMinutes, + ]); + + const row = result.rows[0]; + if (!row) { + return { + success: false, + err: "Invalid or expired verification code", + }; + } + + return { + success: true, + data: row, + }; + } catch (error) { + return { + success: false, + err: String(error), + }; + } +} + export async function getLatestPendingVerification( db: Pool, email: string, ): Promise> { const query = ` -SELECT * +SELECT * FROM email_verifications -WHERE email = $1 +WHERE email = $1 AND status = '${EmailVerificationStatus.PENDING}' AND expires_at > NOW() ORDER BY created_at DESC diff --git a/backend/openapi/src/ct_dashboard/customer_auth.ts b/backend/openapi/src/ct_dashboard/customer_auth.ts index b37458237..2c1698679 100644 --- a/backend/openapi/src/ct_dashboard/customer_auth.ts +++ b/backend/openapi/src/ct_dashboard/customer_auth.ts @@ -118,9 +118,15 @@ export const ChangePasswordRequestSchema = registry.register( email: z.email().openapi({ description: "Email address of the account", }), - new_password: z.string().min(8).openapi({ - description: "New password to set", - }), + new_password: z + .string() + .min(8) + .max(16) + .regex(/\d/, "Password must include at least one number") + .openapi({ + description: + "New password to set (8-16 characters, must include at least one number)", + }), original_password: z.string().optional().openapi({ description: "Current password for verification", }), @@ -219,9 +225,15 @@ export const ResetPasswordConfirmRequestSchema = registry.register( code: z.string().length(6).openapi({ description: "The 6-digit verification code", }), - newPassword: z.string().min(8).openapi({ - description: "The new password", - }), + newPassword: z + .string() + .min(8) + .max(16) + .regex(/\d/, "Password must include at least one number") + .openapi({ + description: + "The new password (8-16 characters, must include at least one number)", + }), }), ); diff --git a/common/oko_types/src/ct_dashboard/email_verification.ts b/common/oko_types/src/ct_dashboard/email_verification.ts index 2145516fa..84f657589 100644 --- a/common/oko_types/src/ct_dashboard/email_verification.ts +++ b/common/oko_types/src/ct_dashboard/email_verification.ts @@ -2,6 +2,7 @@ import type { SMTPConfig } from "../admin"; export enum EmailVerificationStatus { PENDING = "PENDING", + CODE_VERIFIED = "CODE_VERIFIED", VERIFIED = "VERIFIED", EXPIRED = "EXPIRED", } diff --git a/crypto/teddsa/teddsa_interface/tsconfig.json b/crypto/teddsa/teddsa_interface/tsconfig.json index 8d81c3fe1..e2b46401a 100644 --- a/crypto/teddsa/teddsa_interface/tsconfig.json +++ b/crypto/teddsa/teddsa_interface/tsconfig.json @@ -10,8 +10,8 @@ "declaration": true, "declarationMap": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/tests"], } diff --git a/ui/oko_common_ui/src/checkbox/checkbox.module.scss b/ui/oko_common_ui/src/checkbox/checkbox.module.scss index 9dd1f2903..7e3d8c821 100644 --- a/ui/oko_common_ui/src/checkbox/checkbox.module.scss +++ b/ui/oko_common_ui/src/checkbox/checkbox.module.scss @@ -16,17 +16,14 @@ align-items: center; } -[data-theme="light"] { - .checkboxInput { - color: var(--white); - } -} [data-theme="dark"] { .checkboxInput { color: var(--gray-700); } } + .checkboxInput { + color: var(--white); display: flex; justify-content: center;