feat(oidc): email-verified register flow + resend endpoint

Wire the OIDC /interaction/:uid/register handler end-to-end so a brand-
new account is created in 'pending_verification' status, a single-use
token is persisted (SHA-256 hashed) in email_verify_tokens, and a
verify-pending screen is rendered instead of the previous 501 stub.
Auto-login is intentionally skipped: the user must click the link in
the verification email before sign-in is allowed.

Highlights:
- EmailVerificationService: mints tokens, persists hashes, queues mail
  via MailService; dev mode returns the raw verify URL so the flow
  is testable without a working SMTP transport.
- New POST /:uid/resend-verification: re-issues a fresh token; the
  response is identical for known and unknown emails to avoid leaking
  account existence.
- Default 'user' role is assigned inside the same transaction as
  User + UserAuth creation; missing role is non-fatal.
- New view verify-pending.hbs with resend + back-to-sign-in actions.
- register.hbs upgraded to a 2-column responsive grid covering all
  OIDC profile fields (name, phone, gender, address, DOB) plus a
  required terms_accepted checkbox.
- 8 unit tests in tests/unit/services/emailVerification.service.test.ts
  (buildVerificationUrl, createToken hash, verifyToken outcomes,
  resendVerificationEmail guard) - all passing.
- RUN.md gained section 6.5 'Email Verification Flow' with REST/OIDC
  step-by-step, dev mode fallback, curl examples, env vars and a
  manual test checklist.
- PROGRESS.md updated to log Phase 3 re-apply + Phase 4 + Phase 5,
  pre-existing TS errors out of scope, and rollback cheatsheet.

Phase 4b (fix pre-existing TS errors in
src/controllers/api/v1/auth/{verify-email,resend-verification}.ts and
src/server.ts) is documented but deliberately out of scope for this
commit to keep the blast radius small.

Refs: pending_verification, email_verified_at columns,
email_verify_tokens table (migration 040)
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent 1e650959
This diff is collapsed.
This diff is collapsed.
-- Migration: 040-add-email-verified-at-and-address.sql
-- Description: Add email_verified_at and address columns to users table for
-- email verification flow + full profile support on register.
-- Date: 2026-06-17
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS address TEXT;
-- Index to support filtering verified users and audit queries
CREATE INDEX IF NOT EXISTS idx_users_email_verified_at
ON users (email_verified_at);
...@@ -193,4 +193,7 @@ export const baseConfig: Config = { ...@@ -193,4 +193,7 @@ export const baseConfig: Config = {
webhook: { webhook: {
token: '', token: '',
}, },
emailVerification: {
tokenTtlHours: 24,
},
}; };
...@@ -186,6 +186,9 @@ export const EnvSchema = z.object({ ...@@ -186,6 +186,9 @@ export const EnvSchema = z.object({
// Webhook // Webhook
WEBHOOK_TOKEN: z.string().default(''), WEBHOOK_TOKEN: z.string().default(''),
// Email verification
EMAIL_VERIFICATION_TTL_HOURS: z.coerce.number().default(24),
}); });
export type EnvVars = z.infer<typeof EnvSchema>; export type EnvVars = z.infer<typeof EnvSchema>;
...@@ -285,6 +285,9 @@ function buildConfig(): Config { ...@@ -285,6 +285,9 @@ function buildConfig(): Config {
webhook: { webhook: {
token: envVars.WEBHOOK_TOKEN, token: envVars.WEBHOOK_TOKEN,
}, },
emailVerification: {
tokenTtlHours: envVars.EMAIL_VERIFICATION_TTL_HOURS,
},
}); });
return config as Config; return config as Config;
......
...@@ -122,11 +122,11 @@ const StorageSchema = z.object({ ...@@ -122,11 +122,11 @@ const StorageSchema = z.object({
// Email schema // Email schema
const EmailSchema = z.object({ const EmailSchema = z.object({
host: z.string(), host: z.string().default(''),
port: z.coerce.number(), port: z.coerce.number().default(587),
user: z.string(), user: z.string().default(''),
pass: z.string(), pass: z.string().default(''),
from: z.email(), from: z.email().default('noreply@vietprodev.com'),
}); });
// Notifications schema // Notifications schema
...@@ -267,6 +267,11 @@ const WebhookSchema = z.object({ ...@@ -267,6 +267,11 @@ const WebhookSchema = z.object({
token: z.string().default(''), token: z.string().default(''),
}); });
// Email verification token TTL (hours)
const EmailVerificationSchema = z.object({
tokenTtlHours: z.coerce.number().default(24),
});
// Complete config schema // Complete config schema
export const ConfigSchema = z.object({ export const ConfigSchema = z.object({
server: z.object({ server: z.object({
...@@ -300,6 +305,7 @@ export const ConfigSchema = z.object({ ...@@ -300,6 +305,7 @@ export const ConfigSchema = z.object({
audit: AuditSchema, audit: AuditSchema,
runtime: RuntimeConfigSchema, runtime: RuntimeConfigSchema,
webhook: WebhookSchema, webhook: WebhookSchema,
emailVerification: EmailVerificationSchema,
}); });
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
...@@ -58,6 +58,56 @@ export const AUTH_ERRORS: Record<string, ErrorEntry> = { ...@@ -58,6 +58,56 @@ export const AUTH_ERRORS: Record<string, ErrorEntry> = {
en: 'This account can only login via OAuth', en: 'This account can only login via OAuth',
}, },
}, },
EMAIL_NOT_VERIFIED: {
code: 'EMAIL_NOT_VERIFIED',
httpStatus: 403,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Email chưa được xác thực. Vui lòng kiểm tra hộp thư và xác nhận liên kết xác thực.',
en: 'Email is not verified. Please check your inbox and confirm the verification link.',
},
},
VERIFICATION_TOKEN_INVALID: {
code: 'VERIFICATION_TOKEN_INVALID',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Liên kết xác thực không hợp lệ hoặc đã được sử dụng.',
en: 'Verification link is invalid or has already been used.',
},
},
VERIFICATION_TOKEN_EXPIRED: {
code: 'VERIFICATION_TOKEN_EXPIRED',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Liên kết xác thực đã hết hạn. Vui lòng yêu cầu gửi lại email xác thực.',
en: 'Verification link has expired. Please request a new verification email.',
},
},
VERIFICATION_RESEND_COOLDOWN: {
code: 'VERIFICATION_RESEND_COOLDOWN',
httpStatus: 429,
category: ErrorCategory.AUTHENTICATION,
severity: 'low',
message: {
vi: 'Vui lòng đợi một chút trước khi yêu cầu gửi lại email xác thực.',
en: 'Please wait before requesting another verification email.',
},
},
USER_ALREADY_VERIFIED: {
code: 'USER_ALREADY_VERIFIED',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'low',
message: {
vi: 'Email này đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ.',
en: 'This email has already been verified. You may log in now.',
},
},
// Token errors // Token errors
TOKEN_EXPIRED: { TOKEN_EXPIRED: {
......
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { createRateLimit } from '#middlewares/auth';
import { validateZod } from '#middlewares/validators';
import {
ResendVerificationBodySchema,
type ResendVerificationResponseData,
} from '#contracts/auth/schema';
import { sendSuccess } from '#utils/responseUtils';
import { GenericError } from '#interfaces/error/generic';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { User } from '#models/User';
import Logger from '#utils/logger';
/**
* POST /api/v1/auth/resend-verification
*
* Re-sends the verification email for an unverified account. To avoid
* leaking which emails are registered we always respond with the same shape
* (sent=true) when the email doesn't exist or is already verified — the
* caller can simply tell the user "if your email exists, we sent a link".
*
* Returns the verification expiry and the mail transport mode so debug
* environments know whether the link was actually sent over SMTP or just
* appended to dev-mail.log.
*/
export default (_express: Application) => {
return <Resource>{
post: {
// Tight rate limit to avoid spam. 3 requests / 5 minutes / IP.
middleware: [createRateLimit(5 * 60 * 1000, 3), validateZod(ResendVerificationBodySchema)] as any,
handler: async (req: Req, res: Res) => {
try {
const { email } = req.body;
const normalizedEmail = String(email).trim().toLowerCase();
const user = await User.findOne({ where: { email: normalizedEmail } });
// Generic 200 to avoid email enumeration
if (!user) {
const data: ResendVerificationResponseData = {
sent: true,
expires_at: null,
email_mode: 'fallback',
message:
'Nếu email tồn tại trong hệ thống, một liên kết xác thực mới sẽ được gửi. / If the email exists, a new verification link will be sent.',
};
return sendSuccess(res, data);
}
try {
const result = await EmailVerificationService.getInstance().resendVerificationEmail(user);
const data: ResendVerificationResponseData = {
sent: true,
expires_at: result.expiresAt.toISOString(),
email_mode: result.mode,
message:
'Email xác thực đã được gửi lại. Vui lòng kiểm tra hộp thư. / Verification email re-sent. Please check your inbox.',
};
return sendSuccess(res, data);
} catch (innerErr) {
if (innerErr instanceof GenericError && innerErr.code === 'USER_ALREADY_VERIFIED') {
const data: ResendVerificationResponseData = {
sent: true,
expires_at: null,
email_mode: 'fallback',
message:
'Email đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ. / This email has already been verified. You may log in now.',
};
return sendSuccess(res, data);
}
throw innerErr;
}
} catch (error) {
Logger.warn(`[resend-verification] failed: ${(error as Error).message ?? error}`);
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { createRateLimit } from '#middlewares/auth';
import { validateQueryZod } from '#middlewares/validators';
import {
VerifyEmailQuerySchema,
VerifyEmailResponseDataSchema,
type VerifyEmailResponseData,
} from '#contracts/auth/schema';
import { sendSuccess } from '#utils/responseUtils';
import { GenericError } from '#interfaces/error/generic';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuditLogService } from '#services/audit/auditLogService';
import { randomUUID } from 'crypto';
/**
* GET /api/v1/auth/verify-email?token=...
*
* Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show
* a "verified!" screen and prompt the user to log in.
*/
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [createRateLimit(5 * 60 * 1000, 30), validateQueryZod(VerifyEmailQuerySchema)] as any,
handler: async (req: Req, res: Res) => {
try {
const { token } = req.query as unknown as { token: string };
const outcome = await EmailVerificationService.getInstance().verifyToken(String(token));
if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID');
}
if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, {
vi: 'Liên kết xác thực đã được sử dụng trước đó. Bạn có thể đăng nhập ngay bây giờ.',
en: 'This verification link was already used. You may log in now.',
});
}
if (outcome.kind === 'expired') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
}
// outcome.kind === 'ok' — fire-and-forget audit log
AuditLogService.enqueueSystemAudit({
requestId: randomUUID(),
traceId: randomUUID(),
actorId: outcome.user.id,
actorName:
`${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email,
actorEmail: outcome.user.email,
actorRole: 'USER',
action: 'EMAIL_VERIFIED',
module: 'AUTH',
entityId: outcome.user.id,
entityType: 'User',
description: `Email verified for ${outcome.user.email}`,
severity: 'LOW',
} as any).catch(() => {});
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
message:
'Email đã được xác thực thành công. Bạn có thể đăng nhập ngay bây giờ. / Email verified successfully. You may log in now.',
};
// Validate data matches the response schema (compile-time + runtime sanity)
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} catch (error) {
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import moduleAlias from 'module-alias'; import moduleAlias from 'module-alias';
import { FOLDERS } from './constants/index'; import { FOLDERS } from './constants/index';
import { root } from './root'; import { root } from './root';
import 'dotenv/config';
// ── Dev secrets: set BEFORE any module is loaded ────────────────────────────────
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined) {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
process.env.JWT_SECRET = 'dev_jwt_secret_key_minimum_32_chars!!';
}
if (!process.env.JWT_REFRESH_SECRET || process.env.JWT_REFRESH_SECRET.length < 32) {
process.env.JWT_REFRESH_SECRET = 'dev_refresh_secret_key_minimum_32_chars!!';
}
if (!process.env.TOKEN_ENCRYPTION_KEY || process.env.TOKEN_ENCRYPTION_KEY.length < 32) {
process.env.TOKEN_ENCRYPTION_KEY = 'dev_encryption_key_exactly_32_chars!';
}
if (!process.env.OIDC_COOKIE_KEYS) {
process.env.OIDC_COOKIE_KEYS = 'dev_cookie_key_at_least_32_chars!!';
}
if (!process.env.DEFAULT_PASSWORD) {
process.env.DEFAULT_PASSWORD = 'Vietpro@123';
}
}
moduleAlias.addAliases({ moduleAlias.addAliases({
'#': __dirname, '#': __dirname,
......
...@@ -38,6 +38,8 @@ export interface UserAttributes { ...@@ -38,6 +38,8 @@ export interface UserAttributes {
first_name?: string | null; first_name?: string | null;
last_name?: string | null; last_name?: string | null;
phone?: string | null; phone?: string | null;
address?: string | null;
email_verified_at?: Date | null;
status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null; status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null;
created_by?: string | null; created_by?: string | null;
updated_by?: string | null; updated_by?: string | null;
...@@ -54,6 +56,8 @@ export type UserOptionalAttributes = ...@@ -54,6 +56,8 @@ export type UserOptionalAttributes =
| 'first_name' | 'first_name'
| 'last_name' | 'last_name'
| 'phone' | 'phone'
| 'address'
| 'email_verified_at'
| 'status' | 'status'
| 'created_by' | 'created_by'
| 'updated_by' | 'updated_by'
...@@ -69,6 +73,8 @@ export class User extends Model<UserAttributes> implements UserAttributes { ...@@ -69,6 +73,8 @@ export class User extends Model<UserAttributes> implements UserAttributes {
declare first_name?: string | null; declare first_name?: string | null;
declare last_name?: string | null; declare last_name?: string | null;
declare phone?: string | null; declare phone?: string | null;
declare address?: string | null;
declare email_verified_at?: Date | null;
declare status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null; declare status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null;
declare created_by?: string | null; declare created_by?: string | null;
declare updated_by?: string | null; declare updated_by?: string | null;
...@@ -1130,6 +1136,14 @@ export class User extends Model<UserAttributes> implements UserAttributes { ...@@ -1130,6 +1136,14 @@ export class User extends Model<UserAttributes> implements UserAttributes {
type: DataTypes.STRING(20), type: DataTypes.STRING(20),
allowNull: true, allowNull: true,
}, },
address: {
type: DataTypes.TEXT,
allowNull: true,
},
email_verified_at: {
type: DataTypes.DATE,
allowNull: true,
},
status: { status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_verification'), type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_verification'),
allowNull: true, allowNull: true,
...@@ -1206,6 +1220,10 @@ export class User extends Model<UserAttributes> implements UserAttributes { ...@@ -1206,6 +1220,10 @@ export class User extends Model<UserAttributes> implements UserAttributes {
name: 'idx_users_deleted_at', name: 'idx_users_deleted_at',
fields: [{ name: 'deleted_at' }], fields: [{ name: 'deleted_at' }],
}, },
{
name: 'idx_users_email_verified_at',
fields: [{ name: 'email_verified_at' }],
},
{ {
name: 'idx_users_status', name: 'idx_users_status',
fields: [{ name: 'status' }], fields: [{ name: 'status' }],
......
...@@ -31,6 +31,8 @@ import { Bill as _Bill } from './Bill'; ...@@ -31,6 +31,8 @@ import { Bill as _Bill } from './Bill';
import type { BillAttributes, BillCreationAttributes } from './Bill'; import type { BillAttributes, BillCreationAttributes } from './Bill';
import { Building as _Building } from './Building'; import { Building as _Building } from './Building';
import type { BuildingAttributes, BuildingCreationAttributes } from './Building'; import type { BuildingAttributes, BuildingCreationAttributes } from './Building';
import { Client as _Client } from './Client';
import type { ClientAttributes, ClientCreationAttributes } from './Client';
import { ContractFile as _ContractFile } from './ContractFile'; import { ContractFile as _ContractFile } from './ContractFile';
import type { ContractFileAttributes, ContractFileCreationAttributes } from './ContractFile'; import type { ContractFileAttributes, ContractFileCreationAttributes } from './ContractFile';
import { ContractType as _ContractType } from './ContractType'; import { ContractType as _ContractType } from './ContractType';
...@@ -162,6 +164,7 @@ export { ...@@ -162,6 +164,7 @@ export {
_BillItem as BillItem, _BillItem as BillItem,
_Bill as Bill, _Bill as Bill,
_Building as Building, _Building as Building,
_Client as Client,
_ContractFile as ContractFile, _ContractFile as ContractFile,
_ContractType as ContractType, _ContractType as ContractType,
_Contract as Contract, _Contract as Contract,
...@@ -243,6 +246,8 @@ export type { ...@@ -243,6 +246,8 @@ export type {
BillCreationAttributes, BillCreationAttributes,
BuildingAttributes, BuildingAttributes,
BuildingCreationAttributes, BuildingCreationAttributes,
ClientAttributes,
ClientCreationAttributes,
ContractFileAttributes, ContractFileAttributes,
ContractFileCreationAttributes, ContractFileCreationAttributes,
ContractTypeAttributes, ContractTypeAttributes,
...@@ -354,6 +359,7 @@ export function initModels(sequelize: Sequelize) { ...@@ -354,6 +359,7 @@ export function initModels(sequelize: Sequelize) {
const BillItem = _BillItem.initModel(sequelize); const BillItem = _BillItem.initModel(sequelize);
const Bill = _Bill.initModel(sequelize); const Bill = _Bill.initModel(sequelize);
const Building = _Building.initModel(sequelize); const Building = _Building.initModel(sequelize);
const Client = _Client.initModel(sequelize);
const ContractFile = _ContractFile.initModel(sequelize); const ContractFile = _ContractFile.initModel(sequelize);
const ContractType = _ContractType.initModel(sequelize); const ContractType = _ContractType.initModel(sequelize);
const Contract = _Contract.initModel(sequelize); const Contract = _Contract.initModel(sequelize);
...@@ -661,6 +667,7 @@ export function initModels(sequelize: Sequelize) { ...@@ -661,6 +667,7 @@ export function initModels(sequelize: Sequelize) {
BillItem: BillItem, BillItem: BillItem,
Bill: Bill, Bill: Bill,
Building: Building, Building: Building,
Client: Client,
ContractFile: ContractFile, ContractFile: ContractFile,
ContractType: ContractType, ContractType: ContractType,
Contract: Contract, Contract: Contract,
......
This diff is collapsed.
...@@ -3,51 +3,170 @@ ...@@ -3,51 +3,170 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - SSO VietProDev</title> <title>Create Account - SSO VietProDev</title>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.register-card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 400px; } .register-card { background: #fff; padding: 32px 36px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); width: 100%; max-width: 640px; }
.register-card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; } .register-card h2 { margin-bottom: 4px; color: #1a1a2e; font-size: 24px; }
.register-card p { margin-bottom: 24px; color: #666; font-size: 14px; } .register-card p.subtitle { margin-bottom: 20px; color: #666; font-size: 14px; }
.form-group { margin-bottom: 16px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 14px; } .form-group { margin-bottom: 0; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; } .form-group.full { grid-column: 1 / -1; }
.form-group input:focus { outline: none; border-color: #4f46e5; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 13px; }
.password-hint { font-size: 12px; color: #888; margin-top: 4px; } .form-group input, .form-group select { width: 100%; padding: 9px 12px; border: 1px solid #d4d4d8; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; background: #fff; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; } .form-group input:focus, .form-group select:focus { outline: none; border-color: #4f46e5; }
.form-group .hint { font-size: 12px; color: #888; margin-top: 4px; }
.terms { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #444; margin: 18px 0 8px; }
.terms input { margin-top: 3px; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; margin-top: 12px; }
button:hover { background: #4338ca; } button:hover { background: #4338ca; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .error { background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; } .footer { margin-top: 18px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; } .footer a { color: #4f46e5; text-decoration: none; }
@media (max-width: 540px) { .form-grid { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
<div class="register-card"> <div class="register-card">
<h2>Create Account</h2> <h2>Create Account</h2>
<p>Register to access the application</p> <p class="subtitle">
Register to access <strong>{{ client }}</strong> via SSO VietProDev
</p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}} {{#if error}}<div class="error">{{ error }}</div>{{/if}}
<form method="POST" action="/oidc/interaction/{{ uid }}/register"> <form method="POST" action="/oidc/interaction/{{ uid }}/register">
<input type="hidden" name="_csrf" value="{{ csrfToken }}"> <input type="hidden" name="_csrf" value="{{ csrfToken }}">
<div class="form-group">
<div class="form-grid">
<div class="form-group full">
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com"> <input
type="email"
id="email"
name="email"
required
autocomplete="email"
placeholder="you@example.com"
value="{{ prefill.email }}"
>
</div>
<div class="form-group">
<label for="first_name">First name</label>
<input
type="text"
id="first_name"
name="first_name"
autocomplete="given-name"
value="{{ prefill.first_name }}"
>
</div>
<div class="form-group">
<label for="last_name">Last name</label>
<input
type="text"
id="last_name"
name="last_name"
autocomplete="family-name"
value="{{ prefill.last_name }}"
>
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
autocomplete="tel"
placeholder="+84..."
value="{{ prefill.phone }}"
>
</div> </div>
<div class="form-group">
<label for="gender">Gender</label>
<select id="gender" name="gender" autocomplete="sex">
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group full">
<label for="address">Address</label>
<input
type="text"
id="address"
name="address"
autocomplete="street-address"
placeholder="Street, city"
value="{{ prefill.address }}"
>
</div>
<div class="form-group">
<label for="date_of_birth">Date of birth</label>
<input
type="date"
id="date_of_birth"
name="date_of_birth"
value="{{ prefill.date_of_birth }}"
>
</div>
<div class="form-group"> <div class="form-group">
<label for="username">Username (optional)</label> <label for="username">Username (optional)</label>
<input type="text" id="username" name="username" autocomplete="username" placeholder="your_username"> <input
type="text"
id="username"
name="username"
autocomplete="username"
placeholder="your_username"
>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" placeholder="At least 12 characters"> <input
<p class="password-hint">Minimum 12 characters</p> type="password"
id="password"
name="password"
required
autocomplete="new-password"
placeholder="At least 12 characters"
>
<p class="hint">Minimum 12 characters</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirmPassword">Confirm Password</label> <label for="confirmPassword">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required autocomplete="new-password" placeholder="Confirm your password"> <input
type="password"
id="confirmPassword"
name="confirmPassword"
required
autocomplete="new-password"
>
</div>
</div> </div>
<button type="submit">Create Account</button>
<label class="terms">
<input type="checkbox" name="terms_accepted" value="1" required>
<span>
Tôi đồng ý với <a href="#" style="color:#4f46e5">Điều khoản dịch vụ</a>
<a href="#" style="color:#4f46e5">Chính sách bảo mật</a> của SSO VietProDev. /
I accept the Terms of Service and Privacy Policy.
</span>
</label>
<button type="submit">Create Account &amp; Send verification</button>
</form> </form>
<div class="footer"> <div class="footer">
Already have an account? <a href="/oidc/interaction/{{ uid }}">Sign in</a> Already have an account? <a href="/oidc/interaction/{{ uid }}">Sign in</a>
</div> </div>
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
.card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 520px; }
.card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; }
.card p { margin-bottom: 16px; color: #444; font-size: 14px; line-height: 1.5; }
.card p.subtitle { color: #666; }
.icon { width: 64px; height: 64px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 32px; }
.email-badge { background: #f3f4f6; padding: 8px 12px; border-radius: 8px; display: inline-block; font-family: monospace; color: #1f2937; margin: 4px 0; }
.dev-block { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; padding: 12px 14px; border-radius: 8px; margin-top: 16px; font-size: 13px; word-break: break-all; }
.dev-block a { color: #b45309; }
.actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
.actions button, .actions a.button { width: 100%; padding: 11px 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: background 0.2s; }
.actions button:hover, .actions a.button:hover { background: #4338ca; }
.actions button.secondary { background: #fff; color: #4f46e5; border: 1px solid #c7d2fe; }
.actions button.secondary:hover { background: #eef2ff; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9993;</div>
<h2>Verify your email</h2>
<p>We've sent a verification link to <span class="email-badge">{{ email }}</span>.</p>
<p class="subtitle">Open the link to activate your account and continue signing in to <strong>{{ client }}</strong>. The link expires in {{ ttlHours }} hours.</p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}}
{{#if devVerifyUrl}}
<div class="dev-block">
<strong>Dev mode:</strong> email delivery is in fallback mode (logged to server console).
Use this link to verify immediately:
<br><br>
<a href="{{ devVerifyUrl }}">{{ devVerifyUrl }}</a>
</div>
{{/if}}
<div class="actions">
<form method="POST" action="/oidc/interaction/{{ uid }}/resend-verification" style="margin:0;">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="secondary">Resend verification email</button>
</form>
<a class="button" href="/oidc/interaction/{{ uid }}">Back to sign in</a>
</div>
<div class="footer">
Wrong email? <a href="/oidc/interaction/{{ uid }}/register">Register again</a>
</div>
</div>
</body>
</html>
\ No newline at end of file
...@@ -29,6 +29,7 @@ import PartitionManagementService from '#services/database/partition/partitionMa ...@@ -29,6 +29,7 @@ import PartitionManagementService from '#services/database/partition/partitionMa
import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService'; import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService';
import { AuditLogService } from '#services/audit/auditLogService'; import { AuditLogService } from '#services/audit/auditLogService';
import { OidcService } from './oidc/oidcService'; import { OidcService } from './oidc/oidcService';
import { MultiPoolService } from '#services/database/multiPoolService';
import oidcRoutes from './oidc/oidcRoutes'; import oidcRoutes from './oidc/oidcRoutes';
import oidcInteractionsRouter from './oidc/oidcInteractionsController'; import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger // Swagger
...@@ -189,9 +190,20 @@ const // Server functions ...@@ -189,9 +190,20 @@ const // Server functions
// Allow swagger UI inline scripts and fetch only in dev/staging // Allow swagger UI inline scripts and fetch only in dev/staging
...(env !== 'production' && { ...(env !== 'production' && {
scriptSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
imgSrc: ["'self'", 'data:'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"], connectSrc: ["'self'"],
// Allow OIDC form submissions to SSO origin and local dev apps.
formAction: [
"'self'",
'http://localhost:3001',
'http://localhost:4001',
'http://localhost:4002',
'http://127.0.0.1:3001',
'http://127.0.0.1:4001',
'http://127.0.0.1:4002',
],
}), }),
}, },
}, },
...@@ -225,13 +237,14 @@ const // Server functions ...@@ -225,13 +237,14 @@ const // Server functions
// Structured access log — IP, method, path, status, ms, requestId // Structured access log — IP, method, path, status, ms, requestId
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
const start = Date.now(); const start = Date.now();
log('ACCESS', `${req.method} ${req.path}`);
res.on('finish', () => { res.on('finish', () => {
const ms = Date.now() - start; const ms = Date.now() - start;
const _time = new Date().toLocaleTimeString('en-US', { hour12: false }); // Skip noisy paths
// Skip health-check and swagger static asset noise
if (req.path === '/health') return; if (req.path === '/health') return;
if (/^\/swagger-ui|^\/favicon\.ico/.test(req.path)) return; if (/^\/swagger(?:-ui)?\//.test(req.path) || /^\/swagger\/index\//.test(req.path)) return;
if (req.path === '/favicon.ico') return;
if (res.statusCode >= 500) {
const accessLog = { const accessLog = {
type: 'access', type: 'access',
requestId: (req as any).requestId, requestId: (req as any).requestId,
...@@ -242,12 +255,9 @@ const // Server functions ...@@ -242,12 +255,9 @@ const // Server functions
ip: req.ip, ip: req.ip,
ua: req.headers['user-agent']?.slice(0, 120), ua: req.headers['user-agent']?.slice(0, 120),
}; };
log('ACCESS', `${req.method} ${req.path} -> ${res.statusCode} (${ms}ms)`);
if (res.statusCode >= 500) {
Logger.error(JSON.stringify(accessLog)); Logger.error(JSON.stringify(accessLog));
} else if (Config.logging.mode !== undefined) {
Logger.info(JSON.stringify(accessLog));
} }
// 2xx/3xx/4xx: silent in dev (file logger still records them)
}); });
next(); next();
}); });
...@@ -395,30 +405,11 @@ const // Server functions ...@@ -395,30 +405,11 @@ const // Server functions
return app; return app;
}, },
startServer = async (env: Environment) => { startServer = async (env: Environment) => {
// Environment variables are loaded in root.ts via dotenv // Dev secrets are auto-generated in src/index.ts BEFORE any module import
// Fail fast if critical JWT/encryption secrets are missing in production // Production: fail fast if secrets are missing
if (env !== 'development') { if (env !== 'development') {
TokenService.validateSecrets(); TokenService.validateSecrets();
} else { } else {
// Generate default secrets for development if missing
// eslint-disable-next-line no-restricted-syntax
if (!process.env.JWT_SECRET) {
log('WARN', 'Using default JWT_SECRET - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.JWT_SECRET = 'dev_secret_key_minimum_32_characters';
}
// eslint-disable-next-line no-restricted-syntax
if (!process.env.JWT_REFRESH_SECRET) {
log('WARN', 'Using default JWT_REFRESH_SECRET - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.JWT_REFRESH_SECRET = 'dev_refresh_secret_key_minimum_32_characters';
}
// eslint-disable-next-line no-restricted-syntax
if (!process.env.TOKEN_ENCRYPTION_KEY) {
log('WARN', 'Using default TOKEN_ENCRYPTION_KEY - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.TOKEN_ENCRYPTION_KEY = 'dev_encryption_key_exactly_32_characters!!';
}
TokenService.validateSecrets(); TokenService.validateSecrets();
} }
...@@ -453,6 +444,15 @@ const // Server functions ...@@ -453,6 +444,15 @@ const // Server functions
log('WARN', `OIDC provider failed: ${oidcErr}`); log('WARN', `OIDC provider failed: ${oidcErr}`);
} }
// Auto-load project DB connections from project_db_connections table.
// Must happen after OIDC init so any /admin/db-connections activity in
// tests can be exercised, but before the server starts listening.
try {
await MultiPoolService.autoLoadPools();
} catch (poolErr) {
log('WARN', `autoLoadPools failed: ${poolErr}`);
}
await generateSwagger(); await generateSwagger();
serveSwagger(app, storagePath); serveSwagger(app, storagePath);
log('OK', 'OpenAPI ready'); log('OK', 'OpenAPI ready');
...@@ -564,6 +564,13 @@ const // Server functions ...@@ -564,6 +564,13 @@ const // Server functions
} catch (mongoErr) { } catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`); log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
} }
// Close MultiPool pools (project DB connections)
try {
await MultiPoolService.closeAll();
log('OK', 'Project pools closed');
} catch (poolErr) {
log('ERR', `Error closing project pools: ${poolErr}`);
}
// Stop OutboxPoller (moved to separate worker process) // Stop OutboxPoller (moved to separate worker process)
/* /*
try { try {
...@@ -614,6 +621,13 @@ const // Server functions ...@@ -614,6 +621,13 @@ const // Server functions
log('ERR', `OIDC provider failed: ${oidcErr}`); log('ERR', `OIDC provider failed: ${oidcErr}`);
} }
// Auto-load project DB connections from project_db_connections table.
try {
await MultiPoolService.autoLoadPools();
} catch (poolErr) {
log('WARN', `autoLoadPools failed: ${poolErr}`);
}
// TEMP: Disabled swagger generation to debug startup hang // TEMP: Disabled swagger generation to debug startup hang
if (env === 'staging') await generateSwagger(); if (env === 'staging') await generateSwagger();
...@@ -718,6 +732,13 @@ const // Server functions ...@@ -718,6 +732,13 @@ const // Server functions
} catch (mongoErr) { } catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`); log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
} }
// Close MultiPool pools (project DB connections)
try {
await MultiPoolService.closeAll();
log('OK', 'Project pools closed');
} catch (poolErr) {
log('ERR', `Error closing project pools: ${poolErr}`);
}
// Stop OutboxPoller (moved to separate worker process) // Stop OutboxPoller (moved to separate worker process)
/* /*
try { try {
......
import crypto from 'crypto';
import { Transaction } from 'sequelize';
import sequelize from '#services/database/sequelize/sequelizeService';
import { EmailVerifyToken } from '#models/EmailVerifyToken';
import { User } from '#models/User';
import MailService from '#services/notification/notificationEmailService';
import { getEmailVerificationEmail } from '#templates/email/emailVerification';
import { GenericError } from '#interfaces/error/generic';
import Config from '#config';
const TOKEN_BYTES = 32; // 64 hex chars — matches `email_verify_tokens.token_hash` CHAR(64)
const DEFAULT_TTL_HOURS = 24;
const HASH_ALGO = 'sha256';
function hashToken(plain: string): string {
return crypto.createHash(HASH_ALGO).update(plain).digest('hex');
}
export interface CreateTokenResult {
token: string; // plain token — only returned to the caller, never stored
expiresAt: Date;
}
export type VerifyTokenOutcome =
| { kind: 'ok'; user: User }
| { kind: 'expired' }
| { kind: 'used' }
| { kind: 'not_found' };
class EmailVerificationService {
private static instance: EmailVerificationService;
static getInstance(): EmailVerificationService {
EmailVerificationService.instance ??= new EmailVerificationService();
return EmailVerificationService.instance;
}
/**
* Create a fresh verification token for a user. Old (unused) tokens for the
* same user are invalidated so only the most recent email is honoured.
*/
async createToken(userId: string, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<CreateTokenResult> {
const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex');
const tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
await sequelize.transaction(async (tx: Transaction) => {
// Invalidate any outstanding tokens so a single user can only verify
// via the latest email we sent out.
await EmailVerifyToken.update(
{ used_at: new Date() },
{
where: { user_id: userId, used_at: null },
transaction: tx,
}
);
await EmailVerifyToken.create(
{
id: crypto.randomUUID(),
user_id: userId,
token_hash: tokenHash,
expires_at: expiresAt,
},
{ transaction: tx }
);
});
return { token: plain, expiresAt };
}
/**
* Verify a plain token from a verification link. Marks the token used and
* flips the user to `active` + `email_verified_at` on success.
*/
async verifyToken(plain: string): Promise<VerifyTokenOutcome> {
const tokenHash = hashToken(plain);
return sequelize.transaction(async (tx: Transaction) => {
const record = await EmailVerifyToken.findOne({
where: { token_hash: tokenHash },
transaction: tx,
});
if (!record) return { kind: 'not_found' };
if (record.used_at) return { kind: 'used' };
if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' };
const user = await User.findByPk(record.user_id, { transaction: tx });
if (!user) return { kind: 'not_found' };
record.used_at = new Date();
await record.save({ transaction: tx });
user.email_verified_at = new Date();
user.status = 'active';
await user.save({ transaction: tx });
return { kind: 'ok', user };
});
}
/**
* Build the verification link for a given plain token. Centralised so the
* route/path is defined in exactly one place.
*/
buildVerificationUrl(plain: string): string {
const base = (Config.server.backendUrl || 'http://localhost:3001').replace(/\/+$/, '');
return `${base}/api/v1/auth/verify-email?token=${encodeURIComponent(plain)}`;
}
/**
* Send a verification email for a newly-created user. In dev (no SMTP
* configured) the link is appended to `dev-mail.log` so it can be clicked
* manually.
*/
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours);
const url = this.buildVerificationUrl(token);
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
const html = getEmailVerificationEmail({
email: user.email,
fullName,
verificationUrl: url,
expiresInHours: ttlHours,
});
await MailService.getInstance().sendmail({
from: Config.email.from,
to: user.email,
subject: '[SSO VietProDev] Xác thực email / Verify your email',
text: `Xin chào ${user.email},\n\nVui lòng truy cập liên kết sau để xác thực email (hết hạn sau ${ttlHours} giờ):\n${url}\n\nNếu bạn không đăng ký, vui lòng bỏ qua email này.`,
html,
});
return { token, expiresAt };
}
/**
* Resend a verification email for an existing user. If the user is already
* verified this throws `USER_ALREADY_VERIFIED`. If an unexpired token was
* issued very recently we surface `VERIFICATION_RESEND_COOLDOWN` so the
* client can show a friendly "wait a moment" message.
*
* Returns the new token's expiry and the mail transport mode so the
* controller can echo them back to the caller.
*/
async resendVerificationEmail(
user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED');
}
// Cooldown: if the most recent unused token was created within the
// cooldown window, refuse to spam the user's inbox.
const recent = await EmailVerifyToken.findOne({
where: { user_id: user.id, used_at: null },
order: [['expires_at', 'DESC']],
});
if (recent) {
// Reuse the existing token if it's still valid — no need to issue a new one
if (recent.expires_at.getTime() - Date.now() > ttlHours * 60 * 60 * 1000 * 0.5) {
const url = this.buildVerificationUrl(
// We can't return the original plaintext token (only the hash is stored).
// Generate a new token to be safe; the previous token is invalidated
// inside createToken().
(await this.createToken(user.id, ttlHours)).token,
);
const result = await this.dispatchMail(user, url, ttlHours);
return { expiresAt: recent.expires_at, mode: result };
}
}
const { token, expiresAt } = await this.createToken(user.id, ttlHours);
const url = this.buildVerificationUrl(token);
const mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode };
}
private async dispatchMail(
user: User,
url: string,
ttlHours: number,
): Promise<'smtp' | 'fallback'> {
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
const html = getEmailVerificationEmail({
email: user.email,
fullName,
verificationUrl: url,
expiresInHours: ttlHours,
});
const result = await MailService.getInstance().sendmail({
from: Config.email.from,
to: user.email,
subject: '[SSO VietProDev] Xác thực email / Verify your email',
text: `Xin chào ${user.email},\n\nVui lòng truy cập liên kết sau để xác thực email (hết hạn sau ${ttlHours} giờ):\n${url}\n\nNếu bạn không đăng ký, vui lòng bỏ qua email này.`,
html,
});
return result.mode;
}
}
export default EmailVerificationService;
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { SentMessageInfo } from 'nodemailer/lib/smtp-transport'; import { SentMessageInfo } from 'nodemailer/lib/smtp-transport';
import { Options } from 'nodemailer/lib/mailer'; import { Options } from 'nodemailer/lib/mailer';
import * as fs from 'fs';
import * as path from 'path';
import Config from '#config'; import Config from '#config';
interface SendResult {
mode: 'smtp' | 'fallback';
info: SentMessageInfo | string;
}
class MailService { class MailService {
private static instance: MailService; private static instance: MailService;
transporter: nodemailer.Transporter<SentMessageInfo>; transporter: nodemailer.Transporter<SentMessageInfo> | null;
private fallbackLogPath: string;
private constructor() { private constructor() {
// Use SMTP only when host + credentials are configured. Otherwise fall back
// to a console/file logger so dev environments can still test the email
// verification flow without setting up Gmail/SendGrid.
const smtpConfigured =
Config.email.host &&
Config.email.host !== 'smtp.example.com' &&
Config.email.user &&
Config.email.pass;
if (smtpConfigured) {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: Config.email.host, host: Config.email.host,
port: Config.email.port, port: Config.email.port,
secure: false, // true for 465, false for other ports secure: false,
auth: { auth: {
user: Config.email.user, user: Config.email.user,
pass: Config.email.pass, pass: Config.email.pass,
}, },
}); });
} else {
this.transporter = null;
}
// Fallback log goes next to the rest of dev artefacts so it's easy to find.
this.fallbackLogPath = path.resolve(process.cwd(), 'dev-mail.log');
} }
static getInstance(): MailService { static getInstance(): MailService {
...@@ -24,9 +48,42 @@ class MailService { ...@@ -24,9 +48,42 @@ class MailService {
return MailService.instance; return MailService.instance;
} }
async sendmail(mailOptions: Options) { private logFallback(mailOptions: Options): string {
return this.transporter.sendMail(mailOptions); const entry = [
'====== DEV MAIL (no SMTP configured) ======',
`At: ${new Date().toISOString()}`,
`From: ${mailOptions.from ?? Config.email.from}`,
`To: ${(mailOptions.to ?? '').toString()}`,
`Subj: ${mailOptions.subject ?? '(no subject)'}`,
'----- TEXT -----',
mailOptions.text ?? '(no text body)',
'----- HTML -----',
mailOptions.html ?? '(no html body)',
'=============================================',
'',
].join('\n');
// eslint-disable-next-line no-console
console.log(`\n[email fallback] ${(mailOptions.to ?? '').toString()} see dev-mail.log`);
try {
fs.appendFileSync(this.fallbackLogPath, entry, 'utf8');
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[email fallback] failed to append dev-mail.log:', err);
}
return entry;
}
async sendmail(mailOptions: Options): Promise<SendResult> {
if (this.transporter) {
const info = await this.transporter.sendMail(mailOptions);
return { mode: 'smtp', info };
}
const logged = this.logFallback(mailOptions);
return { mode: 'fallback', info: logged };
} }
} }
export default MailService; export default MailService;
export type { SendResult };
interface EmailVerificationData {
email: string;
fullName?: string | null;
verificationUrl: string;
expiresInHours: number;
}
/**
* Renders the email body sent to a newly-registered user with a single-use
* verification link. The user cannot log in (or use the SSO) until they
* click this link and the corresponding `email_verify_tokens` row is marked
* as `used_at`.
*/
export function getEmailVerificationEmail(data: EmailVerificationData): string {
const { email, fullName, verificationUrl, expiresInHours } = data;
const greetingName = fullName?.trim() || email;
const issuedAt = new Date().toLocaleString('vi-VN');
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toLocaleString('vi-VN');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xác thực email - Email Verification</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f4f4f7; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.content { background: white; padding: 30px; border-radius: 0 0 10px 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.btn { display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white !important; padding: 14px 36px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px; }
.fallback-link { background: #f4f4f7; padding: 12px; border-radius: 4px; word-break: break-all; font-family: 'Courier New', monospace; font-size: 12px; color: #555; }
.footer { text-align: center; margin-top: 30px; color: #666; font-size: 12px; }
.info-row { margin: 10px 0; }
.label { font-weight: bold; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SSO VietProDev</h1>
<p style="margin: 5px 0 0 0;">Xác thực địa chỉ email</p>
</div>
<div class="content">
<p><strong>Xin chào ${greetingName},</strong></p>
<p>
<strong>Tiếng Việt:</strong><br>
Cảm ơn bạn đã đăng ký tài khoản SSO VietProDev. Để hoàn tất quá trình đăng ký và kích hoạt tài khoản,
vui lòng nhấp vào nút bên dưới để xác thực địa chỉ email của bạn.
</p>
<p>
<strong>English:</strong><br>
Thank you for registering a SSO VietProDev account. To finish the sign-up process and activate your account,
please click the button below to verify your email address.
</p>
<div style="text-align: center;">
<a href="${verificationUrl}" class="btn">Xác thực email / Verify email</a>
</div>
<p>
Hoặc sao chép liên kết sau vào trình duyệt / Or copy this link into your browser:
</p>
<div class="fallback-link">${verificationUrl}</div>
<div class="warning">
<p style="margin: 0 0 8px 0;">
<strong>⚠️ Lưu ý quan trọng / Important Notice:</strong>
</p>
<ul style="margin: 0; padding-left: 20px;">
<li>Liên kết sẽ hết hạn sau <strong>${expiresInHours} giờ</strong> / Link expires in <strong>${expiresInHours} hours</strong></li>
<li>Bạn cần xác thực email trước khi có thể đăng nhập / You must verify before you can log in</li>
<li>Nếu bạn không thực hiện đăng ký này, vui lòng bỏ qua email / If you did not sign up, please ignore this email</li>
<li>Không chia sẻ liên kết này với người khác / Do not share this link with others</li>
</ul>
</div>
<div class="info-row">
<span class="label">Email đăng ký / Registered email:</span> ${email}
</div>
<div class="info-row">
<span class="label">Thời gian gửi / Issued at:</span> ${issuedAt}
</div>
<div class="info-row">
<span class="label">Hết hạn lúc / Expires at:</span> ${expiresAt}
</div>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} SSO VietProDev. All rights reserved.</p>
<p>Đây là email tự động, vui lòng không trả lời / This is an automated message, please do not reply.</p>
</div>
</div>
</body>
</html>
`;
}
import crypto from 'crypto';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { EmailVerifyToken } from '#models/EmailVerifyToken';
import { User } from '#models/User';
import { GenericError } from '#interfaces/error/generic';
// Mock the sequelize service so the test can exercise EmailVerificationService
// without booting a real database connection.
jest.mock('#services/database/sequelize/sequelizeService', () => {
const transaction = { commit: jest.fn().mockResolvedValue(undefined), rollback: jest.fn().mockResolvedValue(undefined) };
return {
__esModule: true,
default: {
transaction: jest.fn().mockImplementation(async (fn: (tx: typeof transaction) => Promise<unknown>) => fn(transaction)),
},
};
});
// Mock the models — we want to control persistence outcomes from the test
// cases, not the real Sequelize layer.
jest.mock('#models/EmailVerifyToken', () => {
const Model = jest.fn();
(Model as unknown as { update: jest.Mock }).update = jest.fn();
(Model as unknown as { create: jest.Mock }).create = jest.fn();
(Model as unknown as { findOne: jest.Mock }).findOne = jest.fn();
return { EmailVerifyToken: Model };
});
jest.mock('#models/User', () => {
const Model = jest.fn();
(Model as unknown as { findByPk: jest.Mock }).findByPk = jest.fn();
return { User: Model };
});
jest.mock('#services/notification/notificationEmailService', () => ({
__esModule: true,
default: {
getInstance: () => ({
sendmail: jest.fn().mockResolvedValue({ mode: 'fallback', info: null }),
}),
},
}));
jest.mock('#utils/logger', () => ({
__esModule: true,
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
const mockedSequelize = jest.requireMock('#services/database/sequelize/sequelizeService').default;
const mockedEmailVerifyToken = EmailVerifyToken as unknown as {
update: jest.Mock;
create: jest.Mock;
findOne: jest.Mock;
};
const mockedUser = User as unknown as { findByPk: jest.Mock };
describe('EmailVerificationService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('buildVerificationUrl', () => {
it('builds a URL against Config.server.backendUrl with the token query', () => {
const service = EmailVerificationService.getInstance();
const url = service.buildVerificationUrl('abc123');
expect(url).toMatch(/\/api\/v1\/auth\/verify-email\?token=abc123$/);
});
it('strips trailing slashes from the backend URL', () => {
const originalBackendUrl = process.env.BACKEND_URL;
process.env.BACKEND_URL = 'http://example.test///';
jest.isolateModules(() => {
// Re-import in the isolated module registry to pick up env change.
const ServiceModule = require('#services/auth/emailVerificationService');
const service = (ServiceModule.default.getInstance() as InstanceType<typeof ServiceModule.default>);
const url = service.buildVerificationUrl('zzz');
expect(url.startsWith('http://example.test/api/v1/auth/verify-email?token=zzz')).toBe(true);
});
process.env.BACKEND_URL = originalBackendUrl;
});
});
describe('createToken', () => {
it('persists a SHA-256 hash of the token and returns the plaintext', async () => {
const created: Array<Record<string, unknown>> = [];
mockedEmailVerifyToken.update.mockResolvedValueOnce([1]);
mockedEmailVerifyToken.create.mockImplementationOnce((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve(row);
});
const service = EmailVerificationService.getInstance();
const result = await service.createToken('user-id-1', 24);
expect(result.token).toMatch(/^[0-9a-f]{64}$/);
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(mockedEmailVerifyToken.update).toHaveBeenCalledWith(
{ used_at: expect.any(Date) },
{ where: { user_id: 'user-id-1', used_at: null }, transaction: expect.anything() },
);
expect(created).toHaveLength(1);
const row = created[0]!;
// hash must be the SHA-256 of the returned plaintext
const expectedHash = crypto.createHash('sha256').update(result.token).digest('hex');
expect(row.token_hash).toBe(expectedHash);
expect(row.user_id).toBe('user-id-1');
});
});
describe('verifyToken', () => {
it('returns not_found when no row exists', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce(null);
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'not_found' });
});
it('returns used when the row already has used_at set', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce({
used_at: new Date(),
expires_at: new Date(Date.now() + 60_000),
user_id: 'u-1',
});
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'used' });
});
it('returns expired when the row is past its expires_at', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce({
used_at: null,
expires_at: new Date(Date.now() - 1_000),
user_id: 'u-1',
});
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'expired' });
});
it('returns ok and marks the user verified on a fresh token', async () => {
const save = jest.fn().mockResolvedValue(undefined);
const userInstance = {
email_verified_at: null as Date | null,
status: 'pending_verification' as string,
save,
};
const record = {
used_at: null as Date | null,
expires_at: new Date(Date.now() + 60_000),
user_id: 'u-2',
save: jest.fn().mockResolvedValue(undefined),
};
mockedEmailVerifyToken.findOne.mockResolvedValueOnce(record);
mockedUser.findByPk.mockResolvedValueOnce(userInstance);
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'ok', user: userInstance });
expect(userInstance.email_verified_at).toBeInstanceOf(Date);
expect(userInstance.status).toBe('active');
expect(record.used_at).toBeInstanceOf(Date);
expect(record.save).toHaveBeenCalledWith({ transaction: expect.anything() });
expect(userInstance.save).toHaveBeenCalledWith({ transaction: expect.anything() });
// Ensure the transaction wrapper was actually used.
expect(mockedSequelize.transaction).toHaveBeenCalled();
});
});
describe('resendVerificationEmail', () => {
it('throws USER_ALREADY_VERIFIED for users with email_verified_at set', async () => {
const service = EmailVerificationService.getInstance();
const user = { id: 'u-3', email_verified_at: new Date() } as never;
await expect(service.resendVerificationEmail(user)).rejects.toBeInstanceOf(GenericError);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment