From 852b06302cd7591d60420169510d4e6345b9730a Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Thu, 8 Jan 2026 18:20:31 +0900 Subject: [PATCH 1/9] customer_dashboard: Make email timer configurable --- apps/customer_dashboard/customer_dashboard.env.example | 1 + apps/customer_dashboard/src/constants/index.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/customer_dashboard/customer_dashboard.env.example b/apps/customer_dashboard/customer_dashboard.env.example index b4842ad9a..33e285184 100644 --- a/apps/customer_dashboard/customer_dashboard.env.example +++ b/apps/customer_dashboard/customer_dashboard.env.example @@ -5,3 +5,4 @@ NEXT_PUBLIC_OKO_DEMO_ENDPOINT=https://demo.oko.app NEXT_PUBLIC_OKO_DOCS_ENDPOINT=https://docs.oko.app NEXT_PUBLIC_OKO_FEATURE_REQUEST_ENDPOINT=https://oko-wallet.canny.io/feature-requests NEXT_PUBLIC_OKO_GET_SUPPORT_ENDPOINT=https://oko-wallet.canny.io/integration-support-inquiries +NEXT_PUBLIC_EMAIL_VERIFICATION_TIMER_SECONDS=60 diff --git a/apps/customer_dashboard/src/constants/index.ts b/apps/customer_dashboard/src/constants/index.ts index 69623fce9..e6bac2b25 100644 --- a/apps/customer_dashboard/src/constants/index.ts +++ b/apps/customer_dashboard/src/constants/index.ts @@ -3,6 +3,8 @@ export const PASSWORD_MIN_LENGTH = 8; export const SIX_DIGITS_REGEX = /^\d{6}$/; -export const EMAIL_VERIFICATION_TIMER_SECONDS = 60 * 3; +export const EMAIL_VERIFICATION_TIMER_SECONDS = Number( + process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_TIMER_SECONDS, +); export const GET_STARTED_URL = "https://form.typeform.com/to/MxrBGq9b"; From a191cc152e71ff0de3cef061afe594537ce3b66b Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Thu, 8 Jan 2026 18:44:19 +0900 Subject: [PATCH 2/9] customer_dashboard: Limit forgot password inputs to 16 chars --- apps/customer_dashboard/src/app/users/forgot_password/page.tsx | 2 ++ 1 file changed, 2 insertions(+) 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..1ce051468 100644 --- a/apps/customer_dashboard/src/app/users/forgot_password/page.tsx +++ b/apps/customer_dashboard/src/app/users/forgot_password/page.tsx @@ -317,6 +317,7 @@ export default function ForgotPasswordPage() { }} fullWidth requiredSymbol + maxLength={16} helpText={ error ? undefined @@ -344,6 +345,7 @@ export default function ForgotPasswordPage() { }} fullWidth requiredSymbol + maxLength={16} SideComponent={ + {isCodeExpired ? ( + + ) : ( + + )} diff --git a/backend/ct_dashboard_api/src/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index d2a29a18c..3f888bcd8 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 { @@ -51,10 +54,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 +291,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", 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/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", } From 17ae52adbdb99560b0f503e81314049e8519a8e8 Mon Sep 17 00:00:00 2001 From: lidarbtc Date: Wed, 14 Jan 2026 14:03:53 +0900 Subject: [PATCH 9/9] o --- .../src/app/users/forgot_password/page.tsx | 10 +++++ .../reset_password/use_reset_password_form.ts | 16 +++++++- .../customer_dashboard/src/constants/index.ts | 2 + .../ct_dashboard_api/src/constants/index.ts | 2 + .../src/routes/customer_auth.ts | 38 +++++++++++++++++++ .../openapi/src/ct_dashboard/customer_auth.ts | 24 +++++++++--- 6 files changed, 85 insertions(+), 7 deletions(-) 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 225d0386f..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"; @@ -117,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; 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 9d1b8b302..c0b4a6ca0 100644 --- a/apps/customer_dashboard/src/constants/index.ts +++ b/apps/customer_dashboard/src/constants/index.ts @@ -1,5 +1,7 @@ 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}$/; 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/routes/customer_auth.ts b/backend/ct_dashboard_api/src/routes/customer_auth.ts index 3f888bcd8..4d67fe0e8 100644 --- a/backend/ct_dashboard_api/src/routes/customer_auth.ts +++ b/backend/ct_dashboard_api/src/routes/customer_auth.ts @@ -44,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"; @@ -394,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, @@ -1019,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/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)", + }), }), );