feat: implement password reset via UI-configured system mail provider

- replace env-var SMTP config with DB-backed system mail provider
- add isSystemProvider flag to emailProviders table
- add SystemMailService for sending password reset emails
- add PATCH :id/system and DELETE system endpoints on email providers
- add forgotPassword/resetPassword audit logging with IP tracking
- block OIDC and inactive users from password reset flow
- fire-and-forget async work to prevent email enumeration timing oracle
- add HTML email template for password reset
- remove mailer.service and all SMTP env vars
This commit is contained in:
neon
2026-03-21 04:12:55 -06:00
parent 2df7e093c5
commit 9bcde5b0c8
25 changed files with 469 additions and 271 deletions
@@ -1,11 +1,23 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { toast } from 'vue-sonner'
import { Plus, Pencil, Trash2, Star, Share2, Wifi } from 'lucide-vue-next'
import { Plus, Pencil, Trash2, Star, Share2, Wifi, Server } from 'lucide-vue-next'
import { useEmailProviders, type EmailProvider, type EmailProviderForm } from '../composables/useEmailProviders'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const { providers, createProvider, updateProvider, deleteProvider, setDefaultProvider, toggleSharedProvider, testProvider } = useEmailProviders()
const {
providers,
createProvider,
updateProvider,
deleteProvider,
setDefaultProvider,
toggleSharedProvider,
testProvider,
setSystemProvider,
clearSystemProvider,
} = useEmailProviders()
const { isSuperuser } = usePermissions()
const showForm = ref(false)
const editingId = ref<number | null>(null)
@@ -112,6 +124,20 @@ async function toggleShare(p: EmailProvider) {
}
}
async function setSystem(p: EmailProvider) {
try {
if (p.isSystemProvider) {
await clearSystemProvider()
toast.success(`"${p.name}" removed as system mail provider`)
} else {
await setSystemProvider(p.id)
toast.success(`"${p.name}" set as system mail provider`)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update system provider')
}
}
async function test(p: EmailProvider) {
testing.value = p.id
try {
@@ -260,11 +286,26 @@ async function test(p: EmailProvider) {
<span class="text-sm font-medium text-foreground">{{ p.name }}</span>
<span v-if="p.isDefault" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-primary/15 text-primary">Default</span>
<span v-if="p.isShared" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-muted text-muted-foreground">Shared</span>
<span v-if="p.isSystemProvider" class="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400"
>System</span
>
</div>
<p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<Tooltip v-if="isSuperuser">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
:class="p.isSystemProvider ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500 hover:bg-muted'"
@click="setSystem(p)"
>
<Server :size="13" />
</button>
</TooltipTrigger>
<TooltipContent>{{ p.isSystemProvider ? 'Remove as system mail provider' : 'Set as system mail provider' }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
@@ -329,7 +370,8 @@ async function test(p: EmailProvider) {
<div class="border border-border rounded-lg p-4 bg-card/50">
<p class="text-xs text-muted-foreground">
Providers with "Shared" enabled are available to all users. The "Default" provider is used when no provider is specified at send time.
The <strong>System</strong> provider (superuser only) is used for password reset emails. The <strong>Default</strong> provider is used when
sending books with no explicit provider selected. Providers marked <strong>Shared</strong> are available to all users.
</p>
</div>
</div>
@@ -15,6 +15,7 @@ export interface EmailProvider {
startTls: boolean
isDefault: boolean
isShared: boolean
isSystemProvider: boolean
hasPassword: boolean
createdAt: string
}
@@ -108,5 +109,35 @@ export function useEmailProviders() {
return res.json()
}
return { providers, fetchProviders, createProvider, updateProvider, deleteProvider, setDefaultProvider, toggleSharedProvider, testProvider }
async function setSystemProvider(id: number): Promise<void> {
const res = await api(`/api/v1/email/providers/${id}/system`, { method: 'PATCH' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error((err as { message?: string }).message ?? 'Failed to set system provider')
}
const updated: EmailProvider = await res.json()
providers.value = providers.value.map((p) => ({ ...p, isSystemProvider: p.id === updated.id }))
}
async function clearSystemProvider(): Promise<void> {
const res = await api('/api/v1/email/providers/system', { method: 'DELETE' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error((err as { message?: string }).message ?? 'Failed to clear system provider')
}
providers.value = providers.value.map((p) => ({ ...p, isSystemProvider: false }))
}
return {
providers,
fetchProviders,
createProvider,
updateProvider,
deleteProvider,
setDefaultProvider,
toggleSharedProvider,
testProvider,
setSystemProvider,
clearSystemProvider,
}
}
+4
View File
@@ -4,6 +4,8 @@ export enum AuditAction {
AuthLoginFailed = 'auth.login.failed',
AuthLogout = 'auth.logout',
AuthPasswordChange = 'auth.password.change',
AuthPasswordResetRequest = 'auth.password.reset_request',
AuthPasswordReset = 'auth.password.reset',
AuthPasswordAdminReset = 'auth.password.admin_reset',
AuthSessionRevoke = 'auth.session.revoke',
@@ -62,6 +64,8 @@ export enum AuditAction {
EmailProviderUpdate = 'email.provider.update',
EmailProviderDelete = 'email.provider.delete',
EmailProviderSetDefault = 'email.provider.set_default',
EmailProviderSetSystem = 'email.provider.set_system',
EmailProviderClearSystem = 'email.provider.clear_system',
EmailTemplateCreate = 'email.template.create',
EmailTemplateUpdate = 'email.template.update',
+8 -7
View File
@@ -26,11 +26,12 @@ BOOKS_PATH=../local/data
# OIDC (optional — configure via admin settings UI; these are just for reference)
# OIDC config is stored in the database (app_settings table, key: oidc_config)
# SMTP (optional — if unset, self-service password reset is disabled)
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@yourdomain.com
# App URL (used for building password reset links in emails)
APP_URL=http://localhost:5173
# Email encryption key (optional but recommended — 64-char hex string for AES-256)
# Generate with: openssl rand -hex 32
# EMAIL_ENCRYPTION_KEY=
# SMTP for system emails (password reset) is configured via the admin UI,
# not via environment variables.
+2 -2
View File
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino';
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, mailerConfig, storageConfig } from './config/config';
import { appConfig, authConfig, dbConfig, emailConfig, externalApiConfig, storageConfig } from './config/config';
import { validateEnv } from './config/env.validation';
import { loggerConfig } from './common/logger.config';
import { CommonModule } from './common/common.module';
@@ -57,7 +57,7 @@ import { AuditModule } from './modules/audit/audit.module';
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig, emailConfig],
load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig],
}),
ScheduleModule.forRoot(),
DbModule,
+2 -3
View File
@@ -1,10 +1,9 @@
import { Module } from '@nestjs/common';
import { MailerService } from './services/mailer.service';
import { PermissionService } from './services/permission.service';
@Module({
providers: [PermissionService, MailerService],
exports: [PermissionService, MailerService],
providers: [PermissionService],
exports: [PermissionService],
})
export class CommonModule {}
@@ -1,145 +0,0 @@
import type { ConfigService } from '@nestjs/config';
import type { MockedFunction } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailerService } from './mailer.service';
vi.mock('nodemailer', () => ({
createTransport: vi.fn(),
}));
type ConfigValues = Record<string, unknown>;
const DEFAULT_CONFIG: ConfigValues = {
'mailer.host': 'smtp.example.com',
'mailer.port': 587,
'mailer.secure': false,
'mailer.user': 'mailer-user',
'mailer.pass': 'mailer-pass',
'mailer.from': 'noreply@example.com',
'mailer.appUrl': 'http://localhost:5173',
'app.nodeEnv': 'development',
};
function makeConfig(overrides: ConfigValues = {}): ConfigService {
const values = { ...DEFAULT_CONFIG, ...overrides };
return {
get: vi.fn((key: string) => values[key]),
} as unknown as ConfigService;
}
function makeService(overrides: ConfigValues = {}) {
const config = makeConfig(overrides);
const service = new MailerService(config);
const logger = {
log: vi.fn(),
error: vi.fn(),
};
(service as unknown as { logger: typeof logger }).logger = logger;
return { service, config, logger };
}
describe('MailerService', () => {
const createTransportMock = nodemailer.createTransport as MockedFunction<typeof nodemailer.createTransport>;
beforeEach(() => {
vi.clearAllMocks();
});
it('isConfigured returns true only when SMTP host exists', () => {
const configured = makeService();
const notConfigured = makeService({ 'mailer.host': '' });
expect(configured.service.isConfigured()).toBe(true);
expect(notConfigured.service.isConfigured()).toBe(false);
});
it('does not send email when mailer is not configured and still logs reset URL in non-production', async () => {
const { service, logger } = makeService({
'mailer.host': undefined,
'mailer.appUrl': 'http://app.local/',
'app.nodeEnv': 'development',
});
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'a+b&c/d==')).resolves.toBeUndefined();
expect(createTransportMock).not.toHaveBeenCalled();
expect(logger.log).toHaveBeenCalledWith('[DEV] Password reset URL for Alice: http://app.local/reset-password?token=a%2Bb%26c%2Fd%3D%3D');
});
it('sends password reset email with expected payload and no DEV log in production', async () => {
const sendMail = vi.fn().mockResolvedValue(undefined);
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
const { service, logger } = makeService({
'app.nodeEnv': 'production',
'mailer.appUrl': 'https://app.example.com/',
});
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token+slash/value')).resolves.toBeUndefined();
expect(logger.log).not.toHaveBeenCalled();
expect(createTransportMock).toHaveBeenCalledWith({
host: 'smtp.example.com',
port: 587,
secure: false,
auth: {
user: 'mailer-user',
pass: 'mailer-pass',
},
});
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: 'noreply@example.com',
to: 'user@example.com',
subject: 'Reset your password',
}),
);
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining('https://app.example.com/reset-password?token=token%2Bslash%2Fvalue'),
html: expect.stringContaining('https://app.example.com/reset-password?token=token%2Bslash%2Fvalue'),
}),
);
});
it('reuses the same transporter across multiple sends', async () => {
const sendMail = vi.fn().mockResolvedValue(undefined);
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
const { service } = makeService();
await service.sendPasswordReset('u1@example.com', 'User1', 'token-1');
await service.sendPasswordReset('u2@example.com', 'User2', 'token-2');
expect(createTransportMock).toHaveBeenCalledTimes(1);
expect(sendMail).toHaveBeenCalledTimes(2);
});
it('swallows transport creation errors and logs them', async () => {
const error = new Error('invalid SMTP configuration');
createTransportMock.mockImplementation(() => {
throw error;
});
const { service, logger } = makeService();
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token')).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith('Failed to send password reset email', error);
});
it('swallows provider-specific non-Error failures from sendMail and logs them', async () => {
const providerFailure = { code: 'EAUTH', response: 'Invalid login' };
const sendMail = vi.fn().mockRejectedValue(providerFailure);
createTransportMock.mockReturnValue({ sendMail } as unknown as nodemailer.Transporter);
const { service, logger } = makeService();
await expect(service.sendPasswordReset('user@example.com', 'Alice', 'token')).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledWith('Failed to send password reset email', providerFailure);
});
});
@@ -1,54 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
@Injectable()
export class MailerService {
private readonly logger = new Logger(MailerService.name);
private transporter: Transporter | null = null;
constructor(private readonly config: ConfigService) {}
isConfigured(): boolean {
return !!this.config.get<string>('mailer.host');
}
private getTransporter(): Transporter {
if (!this.transporter) {
this.transporter = nodemailer.createTransport({
host: this.config.get<string>('mailer.host'),
port: this.config.get<number>('mailer.port'),
secure: this.config.get<boolean>('mailer.secure'),
auth: {
user: this.config.get<string>('mailer.user'),
pass: this.config.get<string>('mailer.pass'),
},
});
}
return this.transporter;
}
async sendPasswordReset(to: string, username: string, rawToken: string): Promise<void> {
const appUrl = (this.config.get<string>('mailer.appUrl') ?? '').replace(/\/+$/, '');
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
if (this.config.get('app.nodeEnv') !== 'production') {
this.logger.log(`[DEV] Password reset URL for ${username}: ${resetUrl}`);
}
if (!this.isConfigured()) return;
try {
await this.getTransporter().sendMail({
from: this.config.get<string>('mailer.from'),
to,
subject: 'Reset your password',
text: `Hi ${username},\n\nClick the link below to reset your password (expires in 15 minutes):\n\n${resetUrl}\n\nIf you did not request this, ignore this email.`,
html: `<p>Hi ${username},</p><p>Click the link below to reset your password (expires in 15 minutes):</p><p><a href="${resetUrl}">${resetUrl}</a></p><p>If you did not request this, ignore this email.</p>`,
});
} catch (err) {
this.logger.error('Failed to send password reset email', err);
}
}
}
+1 -10
View File
@@ -4,6 +4,7 @@ export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
logLevel: process.env.LOG_LEVEL,
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
}));
export const dbConfig = registerAs('db', () => ({
@@ -29,13 +30,3 @@ export const externalApiConfig = registerAs('externalApi', () => ({
export const emailConfig = registerAs('email', () => ({
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
}));
export const mailerConfig = registerAs('mailer', () => ({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT ?? '587', 10),
secure: process.env.SMTP_SECURE === 'true',
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM ?? 'noreply@projectx.local',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
}));
-6
View File
@@ -18,12 +18,6 @@ const envSchema = z.object({
STAGING_PATH: z.string().optional(),
CLIENT_URL: z.string().url().optional(),
GOOGLE_BOOKS_API_KEY: z.string().default(''),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().default(587),
SMTP_SECURE: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('noreply@projectx.local'),
APP_URL: z.string().url().default('http://localhost:5173'),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(),
@@ -511,6 +511,7 @@ CREATE TABLE "email_providers" (
"start_tls" boolean DEFAULT true NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"is_shared" boolean DEFAULT false NOT NULL,
"is_system_provider" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name")
@@ -1,5 +1,5 @@
{
"id": "34de1491-9bf5-4cfe-b899-32360bc9b18a",
"id": "a5647da5-6734-4203-8698-c4192405229a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -4161,6 +4161,13 @@
"notNull": true,
"default": false
},
"is_system_provider": {
"name": "is_system_provider",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1774047704263,
"when": 1774130193988,
"tag": "0000_baseline",
"breakpoints": true
}
+1
View File
@@ -19,6 +19,7 @@ export const emailProviders = pgTable(
startTls: boolean('start_tls').notNull().default(true),
isDefault: boolean('is_default').notNull().default(false),
isShared: boolean('is_shared').notNull().default(false),
isSystemProvider: boolean('is_system_provider').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
+4 -4
View File
@@ -88,15 +88,15 @@ export class AuthController {
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
forgotPassword(@Body() dto: ForgotPasswordDto) {
return this.authService.forgotPassword(dto);
forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: FastifyRequest) {
return this.authService.forgotPassword(dto, req.ip);
}
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.NO_CONTENT)
resetPassword(@Body() dto: ResetPasswordDto) {
return this.authService.resetPassword(dto);
resetPassword(@Body() dto: ResetPasswordDto, @Req() req: FastifyRequest) {
return this.authService.resetPassword(dto, req.ip);
}
@Post('change-password')
+2
View File
@@ -7,6 +7,7 @@ import type { StringValue } from 'ms';
import { CommonModule } from '../../common/common.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AuditModule } from '../audit/audit.module';
import { EmailModule } from '../email/email.module';
import { UserModule } from '../user/user.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@@ -35,6 +36,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
CommonModule,
AppSettingsModule,
AuditModule,
EmailModule,
],
controllers: [AuthController],
providers: [
+106 -10
View File
@@ -76,8 +76,8 @@ function makeService(dbOverrides?: Record<string, unknown>) {
return undefined;
}),
};
const mailerService = {
isConfigured: vi.fn().mockReturnValue(true),
const systemMailService = {
isConfigured: vi.fn().mockResolvedValue(true),
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
};
const appSettings = {
@@ -95,7 +95,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
userService as never,
jwtService as never,
config as never,
mailerService as never,
systemMailService as never,
appSettings as never,
oidcSessionRepo as never,
oidcDiscovery as never,
@@ -103,7 +103,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
db,
);
return { service, db, userService, jwtService, config, mailerService, appSettings, oidcSessionRepo, oidcDiscovery };
return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery };
}
describe('AuthService', () => {
@@ -382,9 +382,9 @@ describe('AuthService', () => {
});
describe('forgotPassword', () => {
it('throws ServiceUnavailableException when mailer is not configured', async () => {
const { service, mailerService } = makeService();
mailerService.isConfigured.mockReturnValue(false);
it('throws ServiceUnavailableException when system mail is not configured', async () => {
const { service, systemMailService } = makeService();
systemMailService.isConfigured.mockResolvedValue(false);
await expect(service.forgotPassword({ email: 'u@example.com' })).rejects.toThrow(ServiceUnavailableException);
});
@@ -397,11 +397,52 @@ describe('AuthService', () => {
});
it('sends reset email when user exists', async () => {
const { service, userService, mailerService } = makeService();
userService.findByEmail.mockResolvedValue({ id: 1, email: 'u@example.com', name: 'User' });
const { service, userService, systemMailService } = makeService();
userService.findByEmail.mockResolvedValue({
id: 1,
email: 'u@example.com',
name: 'User',
active: true,
provisioningMethod: 'local',
username: 'user',
});
await service.forgotPassword({ email: 'u@example.com' });
expect(mailerService.sendPasswordReset).toHaveBeenCalledWith('u@example.com', 'User', 'raw-reset-token');
// fire-and-forget — flush microtasks
await new Promise((r) => setImmediate(r));
expect(systemMailService.sendPasswordReset).toHaveBeenCalledWith('u@example.com', 'User', 'raw-reset-token');
});
it('silently returns without sending email for inactive user', async () => {
const { service, userService, systemMailService } = makeService();
userService.findByEmail.mockResolvedValue({
id: 1,
email: 'u@example.com',
name: 'User',
active: false,
provisioningMethod: 'local',
username: 'user',
});
await service.forgotPassword({ email: 'u@example.com' });
await new Promise((r) => setImmediate(r));
expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled();
});
it('silently returns without sending email for OIDC user', async () => {
const { service, userService, systemMailService } = makeService();
userService.findByEmail.mockResolvedValue({
id: 1,
email: 'u@example.com',
name: 'User',
active: true,
provisioningMethod: 'oidc',
username: 'user',
});
await service.forgotPassword({ email: 'u@example.com' });
await new Promise((r) => setImmediate(r));
expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled();
});
});
@@ -490,6 +531,61 @@ describe('AuthService', () => {
await expect(service.resetPassword({ token: 'used', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException for inactive user', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
id: 1,
userId: 1,
expiresAt: new Date(Date.now() + 10_000),
usedAt: null,
});
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
id: 1,
username: 'jdoe',
active: false,
provisioningMethod: 'local',
});
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException for OIDC user', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
id: 1,
userId: 1,
expiresAt: new Date(Date.now() + 10_000),
usedAt: null,
});
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
provisioningMethod: 'oidc',
});
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException);
});
it('resets password, marks token used, and revokes sessions on success', async () => {
const { service, db } = makeService();
(db.query as never as Record<string, Record<string, vi.Mock>>).passwordResetTokens.findFirst.mockResolvedValue({
id: 5,
userId: 1,
expiresAt: new Date(Date.now() + 10_000),
usedAt: null,
});
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValue({
id: 1,
username: 'jdoe',
active: true,
provisioningMethod: 'local',
});
await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1!' })).resolves.toBeUndefined();
expect(db.transaction).toHaveBeenCalled();
});
});
describe('logout', () => {
+48 -12
View File
@@ -23,7 +23,7 @@ import { DB } from '../../db/db.module';
import * as schema from '../../db/schema';
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
import type { RequestUser } from '../../common/types/request-user';
import { MailerService } from '../../common/services/mailer.service';
import { SystemMailService } from '../email/system-mail.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { UserService } from '../user/user.service';
import { ChangePasswordDto } from './dto/change-password.dto';
@@ -57,7 +57,7 @@ export class AuthService {
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly mailerService: MailerService,
private readonly systemMailService: SystemMailService,
private readonly appSettings: AppSettingsService,
private readonly oidcSessionRepo: OidcSessionRepository,
private readonly oidcDiscovery: OidcDiscoveryService,
@@ -337,20 +337,43 @@ export class AuthService {
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
}
async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
if (!this.mailerService.isConfigured()) {
async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
if (!(await this.systemMailService.isConfigured())) {
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
}
// Always return without revealing whether email exists
const user = await this.userService.findByEmail(dto.email);
if (!user || !user.email) return;
const rawToken = await this.userService.generatePasswordResetToken(user.id);
await this.mailerService.sendPasswordReset(user.email, user.name, rawToken);
void this.processPasswordResetAsync(dto.email, ip).catch((err) => this.logger.error('Unhandled error during password reset processing', err));
}
async resetPassword(dto: ResetPasswordDto): Promise<void> {
private async processPasswordResetAsync(email: string, ip?: string): Promise<void> {
const user = await this.userService.findByEmail(email);
if (!user || !user.email) {
this.logger.log(`Password reset requested for unknown email: ${email}`);
return;
}
if (!user.active) {
this.logger.log(`Password reset requested for inactive account: ${email}`);
return;
}
if (user.provisioningMethod === 'oidc') {
this.logger.log(`Password reset requested for OIDC account: ${email}`);
return;
}
const rawToken = await this.userService.generatePasswordResetToken(user.id);
await this.systemMailService.sendPasswordReset(user.email, user.name, rawToken);
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthPasswordResetRequest,
description: `Password reset email sent to ${user.email}`,
ip,
});
}
async resetPassword(dto: ResetPasswordDto, ip?: string): Promise<void> {
const tokenHash = sha256(dto.token);
const row = await this.db.query.passwordResetTokens.findFirst({
@@ -361,6 +384,11 @@ export class AuthService {
throw new BadRequestException('Invalid or expired reset token');
}
const user = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
if (!user || !user.active || user.provisioningMethod === 'oidc') {
throw new BadRequestException('Invalid or expired reset token');
}
const passwordHash = await hash(dto.newPassword, 12);
await this.db.transaction(async (tx) => {
@@ -376,6 +404,14 @@ export class AuthService {
.set({ revokedAt: new Date() })
.where(and(eq(schema.refreshTokens.userId, row.userId), isNull(schema.refreshTokens.revokedAt)));
});
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.AuthPasswordReset,
description: `Password reset completed for user '${user.username}'`,
ip,
});
}
async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
@@ -70,6 +70,12 @@ export class EmailProviderController {
}
@Patch(':id/share')
@Auditable({
action: AuditAction.EmailProviderUpdate,
resource: AuditResource.EmailProvider,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Toggled sharing for email provider #${req.params['id']}`,
})
toggleShared(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.toggleShared(id, user);
}
@@ -79,4 +85,26 @@ export class EmailProviderController {
testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.testConnection(id, user);
}
@Patch(':id/system')
@Auditable({
action: AuditAction.EmailProviderSetSystem,
resource: AuditResource.EmailProvider,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Set email provider #${req.params['id']} as system mail provider`,
})
setSystemProvider(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
return this.service.setSystemProvider(id, user);
}
@Delete('system')
@HttpCode(HttpStatus.NO_CONTENT)
@Auditable({
action: AuditAction.EmailProviderClearSystem,
resource: AuditResource.EmailProvider,
description: () => 'Cleared system mail provider',
})
clearSystemProvider(@CurrentUser() user: RequestUser) {
return this.service.clearSystemProvider(user);
}
}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, isNull, or, sql } from 'drizzle-orm';
import { and, eq, isNull, ne, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -73,4 +73,27 @@ export class EmailProviderRepository {
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
.returning();
}
findSystemProvider() {
return this.db.select().from(emailProviders).where(eq(emailProviders.isSystemProvider, true)).limit(1);
}
clearSystemProvider() {
return this.db.update(emailProviders).set({ isSystemProvider: false }).where(eq(emailProviders.isSystemProvider, true));
}
setSystemProvider(id: number) {
return this.db.transaction(async (tx) => {
await tx
.update(emailProviders)
.set({ isSystemProvider: false })
.where(and(eq(emailProviders.isSystemProvider, true), ne(emailProviders.id, id)));
const [updated] = await tx
.update(emailProviders)
.set({ isSystemProvider: true, updatedAt: sql`now()` })
.where(eq(emailProviders.id, id))
.returning();
return updated;
});
}
}
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { RequestUser } from '../../common/types/request-user';
import { EmailEncryptionService } from './email-encryption.service';
@@ -59,7 +59,10 @@ export class EmailProviderService {
}
async remove(id: number, user: RequestUser) {
await this.getOwned(id, user);
const provider = await this.getOwned(id, user);
if (provider.isSystemProvider) {
throw new BadRequestException('Cannot delete the system mail provider. Remove the system designation first.');
}
await this.repo.delete(id, user.id);
}
@@ -72,13 +75,26 @@ export class EmailProviderService {
}
async toggleShared(id: number, user: RequestUser) {
if (!this.isSuperuser(user)) throw new ForbiddenException('Only superusers can share providers');
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can share providers');
const provider = await this.getOwned(id, user);
const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared);
if (!updated) throw new NotFoundException('Provider not found');
return this.sanitize(updated);
}
async setSystemProvider(id: number, user: RequestUser) {
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can configure the system mail provider');
await this.getOwnedOrShared(id, user);
const updated = await this.repo.setSystemProvider(id);
if (!updated) throw new NotFoundException('Provider not found');
return this.sanitize(updated);
}
async clearSystemProvider(user: RequestUser) {
if (!user.isSuperuser) throw new ForbiddenException('Only superusers can configure the system mail provider');
await this.repo.clearSystemProvider();
}
async testConnection(id: number, user: RequestUser): Promise<{ success: boolean; error?: string }> {
const provider = await this.getOwnedOrShared(id, user);
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
@@ -128,8 +144,4 @@ export class EmailProviderService {
const { passwordEnc, ...rest } = provider;
return { ...rest, hasPassword: !!provider.passwordEnc };
}
private isSuperuser(user: RequestUser): boolean {
return user.isSuperuser;
}
}
@@ -38,7 +38,7 @@ export class EmailTemplateContextService {
const [meta] = metaResult;
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0];
const appUrl = this.config.get<string>('mailer.appUrl') ?? '';
const appUrl = this.config.get<string>('app.appUrl') ?? '';
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
return {
+3 -1
View File
@@ -26,6 +26,7 @@ import { EmailTemplateController } from './email-template.controller';
import { EmailTemplateRepository } from './email-template.repository';
import { EmailTemplateService } from './email-template.service';
import { EmailTransportService } from './email-transport.service';
import { SystemMailService } from './system-mail.service';
@Module({
imports: [],
@@ -58,7 +59,8 @@ import { EmailTransportService } from './email-transport.service';
EmailSendLogRepository,
EmailSendLogService,
EmailSendOrchestrator,
SystemMailService,
],
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService],
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService],
})
export class EmailModule {}
@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EmailEncryptionService } from './email-encryption.service';
import { EmailProviderRepository } from './email-provider.repository';
import { EmailTransportService } from './email-transport.service';
@Injectable()
export class SystemMailService {
private readonly logger = new Logger(SystemMailService.name);
constructor(
private readonly repo: EmailProviderRepository,
private readonly encryption: EmailEncryptionService,
private readonly transport: EmailTransportService,
private readonly config: ConfigService,
) {}
async isConfigured(): Promise<boolean> {
const [provider] = await this.repo.findSystemProvider();
return !!provider;
}
async sendPasswordReset(to: string, name: string, rawToken: string): Promise<void> {
const appUrl = (this.config.get<string>('app.appUrl') ?? '').replace(/\/+$/, '');
const resetUrl = `${appUrl}/reset-password?token=${encodeURIComponent(rawToken)}`;
if (this.config.get('app.nodeEnv') !== 'production') {
this.logger.log(`[DEV] Password reset URL for ${name}: ${resetUrl}`);
}
const [provider] = await this.repo.findSystemProvider();
if (!provider) {
this.logger.warn('System mail provider not found when sending password reset - provider may have been removed');
return;
}
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
const transporter = this.transport.buildTransporter({
host: provider.host,
port: provider.port,
username: provider.username,
password: password ?? null,
auth: provider.auth,
ssl: provider.ssl,
startTls: provider.startTls,
});
const from = provider.fromAddress ? (provider.fromName ? `${provider.fromName} <${provider.fromAddress}>` : provider.fromAddress) : undefined;
try {
await transporter.sendMail({
from,
to,
subject: 'Reset your password',
text: this.buildResetText(name, resetUrl),
html: this.buildResetHtml(name, resetUrl),
});
} catch (err) {
this.logger.error('Failed to send password reset email', err);
}
}
private buildResetText(name: string, resetUrl: string): string {
return [
`Hi ${name},`,
'',
'We received a request to reset the password for your account.',
'',
'Click the link below to set a new password. This link expires in 15 minutes.',
'',
resetUrl,
'',
'If you did not request a password reset, you can safely ignore this email.',
'Your password will not change unless you click the link above.',
].join('\n');
}
private buildResetHtml(name: string, resetUrl: string): string {
const escapedName = name.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedUrl = resetUrl.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reset your password</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 16px;">
<tr>
<td align="center">
<table width="100%" style="max-width:520px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
<tr>
<td style="background:#18181b;padding:24px 32px;">
<p style="margin:0;color:#ffffff;font-size:18px;font-weight:600;letter-spacing:-0.3px;">Project X</p>
</td>
</tr>
<tr>
<td style="padding:32px 32px 24px;">
<p style="margin:0 0 8px;font-size:20px;font-weight:600;color:#18181b;">Reset your password</p>
<p style="margin:0 0 24px;font-size:14px;color:#71717a;line-height:1.6;">
Hi ${escapedName}, we received a request to reset the password for your account.
This link expires in <strong style="color:#18181b;">15 minutes</strong>.
</p>
<a href="${escapedUrl}"
style="display:inline-block;padding:11px 24px;background:#18181b;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none;border-radius:6px;">
Reset password
</a>
</td>
</tr>
<tr>
<td style="padding:0 32px 32px;">
<p style="margin:24px 0 0;padding-top:20px;border-top:1px solid #e4e4e7;font-size:12px;color:#a1a1aa;line-height:1.6;">
If you did not request a password reset, you can safely ignore this email.
Your password will not change unless you click the button above.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
}
+2 -2
View File
@@ -91,7 +91,7 @@ export class UserService {
}
}
const appUrl = this.config.get<string>('mailer.appUrl') ?? 'http://localhost:5173';
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
const rawToken = await this.userRepo.generateResetToken(user.id);
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
@@ -180,7 +180,7 @@ export class UserService {
if (target.isSuperuser && !requestingUser.isSuperuser) {
throw new ForbiddenException('Only administrators can reset administrator passwords');
}
const appUrl = this.config.get<string>('mailer.appUrl') ?? 'http://localhost:5173';
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
const rawToken = await this.userRepo.generateResetToken(targetUserId);
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
}