mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -1,11 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
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 { useEmailProviders, type EmailProvider, type EmailProviderForm } from '../composables/useEmailProviders'
|
||||||
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
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 showForm = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
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) {
|
async function test(p: EmailProvider) {
|
||||||
testing.value = p.id
|
testing.value = p.id
|
||||||
try {
|
try {
|
||||||
@@ -260,11 +286,26 @@ async function test(p: EmailProvider) {
|
|||||||
<span class="text-sm font-medium text-foreground">{{ p.name }}</span>
|
<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.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.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>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p>
|
<p class="text-xs text-muted-foreground mt-0.5">{{ p.host }}:{{ p.port }} · {{ p.fromAddress || p.username || 'no from address' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<button
|
<button
|
||||||
@@ -329,7 +370,8 @@ async function test(p: EmailProvider) {
|
|||||||
|
|
||||||
<div class="border border-border rounded-lg p-4 bg-card/50">
|
<div class="border border-border rounded-lg p-4 bg-card/50">
|
||||||
<p class="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface EmailProvider {
|
|||||||
startTls: boolean
|
startTls: boolean
|
||||||
isDefault: boolean
|
isDefault: boolean
|
||||||
isShared: boolean
|
isShared: boolean
|
||||||
|
isSystemProvider: boolean
|
||||||
hasPassword: boolean
|
hasPassword: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
@@ -108,5 +109,35 @@ export function useEmailProviders() {
|
|||||||
return res.json()
|
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,6 +4,8 @@ export enum AuditAction {
|
|||||||
AuthLoginFailed = 'auth.login.failed',
|
AuthLoginFailed = 'auth.login.failed',
|
||||||
AuthLogout = 'auth.logout',
|
AuthLogout = 'auth.logout',
|
||||||
AuthPasswordChange = 'auth.password.change',
|
AuthPasswordChange = 'auth.password.change',
|
||||||
|
AuthPasswordResetRequest = 'auth.password.reset_request',
|
||||||
|
AuthPasswordReset = 'auth.password.reset',
|
||||||
AuthPasswordAdminReset = 'auth.password.admin_reset',
|
AuthPasswordAdminReset = 'auth.password.admin_reset',
|
||||||
AuthSessionRevoke = 'auth.session.revoke',
|
AuthSessionRevoke = 'auth.session.revoke',
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ export enum AuditAction {
|
|||||||
EmailProviderUpdate = 'email.provider.update',
|
EmailProviderUpdate = 'email.provider.update',
|
||||||
EmailProviderDelete = 'email.provider.delete',
|
EmailProviderDelete = 'email.provider.delete',
|
||||||
EmailProviderSetDefault = 'email.provider.set_default',
|
EmailProviderSetDefault = 'email.provider.set_default',
|
||||||
|
EmailProviderSetSystem = 'email.provider.set_system',
|
||||||
|
EmailProviderClearSystem = 'email.provider.clear_system',
|
||||||
|
|
||||||
EmailTemplateCreate = 'email.template.create',
|
EmailTemplateCreate = 'email.template.create',
|
||||||
EmailTemplateUpdate = 'email.template.update',
|
EmailTemplateUpdate = 'email.template.update',
|
||||||
|
|||||||
+8
-7
@@ -26,11 +26,12 @@ BOOKS_PATH=../local/data
|
|||||||
# OIDC (optional — configure via admin settings UI; these are just for reference)
|
# 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)
|
# OIDC config is stored in the database (app_settings table, key: oidc_config)
|
||||||
|
|
||||||
# SMTP (optional — if unset, self-service password reset is disabled)
|
# App URL (used for building password reset links in emails)
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
SMTP_FROM=noreply@yourdomain.com
|
|
||||||
APP_URL=http://localhost:5173
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
|||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
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 { validateEnv } from './config/env.validation';
|
||||||
import { loggerConfig } from './common/logger.config';
|
import { loggerConfig } from './common/logger.config';
|
||||||
import { CommonModule } from './common/common.module';
|
import { CommonModule } from './common/common.module';
|
||||||
@@ -57,7 +57,7 @@ import { AuditModule } from './modules/audit/audit.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
load: [appConfig, dbConfig, authConfig, storageConfig, mailerConfig, externalApiConfig, emailConfig],
|
load: [appConfig, dbConfig, authConfig, storageConfig, externalApiConfig, emailConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
DbModule,
|
DbModule,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { MailerService } from './services/mailer.service';
|
|
||||||
import { PermissionService } from './services/permission.service';
|
import { PermissionService } from './services/permission.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PermissionService, MailerService],
|
providers: [PermissionService],
|
||||||
exports: [PermissionService, MailerService],
|
exports: [PermissionService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ export const appConfig = registerAs('app', () => ({
|
|||||||
port: parseInt(process.env.PORT ?? '3000', 10),
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
logLevel: process.env.LOG_LEVEL,
|
logLevel: process.env.LOG_LEVEL,
|
||||||
|
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const dbConfig = registerAs('db', () => ({
|
export const dbConfig = registerAs('db', () => ({
|
||||||
@@ -29,13 +30,3 @@ export const externalApiConfig = registerAs('externalApi', () => ({
|
|||||||
export const emailConfig = registerAs('email', () => ({
|
export const emailConfig = registerAs('email', () => ({
|
||||||
encryptionKey: process.env.EMAIL_ENCRYPTION_KEY ?? '',
|
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',
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ const envSchema = z.object({
|
|||||||
STAGING_PATH: z.string().optional(),
|
STAGING_PATH: z.string().optional(),
|
||||||
CLIENT_URL: z.string().url().optional(),
|
CLIENT_URL: z.string().url().optional(),
|
||||||
GOOGLE_BOOKS_API_KEY: z.string().default(''),
|
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'),
|
APP_URL: z.string().url().default('http://localhost:5173'),
|
||||||
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).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,
|
"start_tls" boolean DEFAULT true NOT NULL,
|
||||||
"is_default" boolean DEFAULT false NOT NULL,
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
"is_shared" 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,
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
CONSTRAINT "email_providers_user_id_name_unique" UNIQUE("user_id","name")
|
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",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -4161,6 +4161,13 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"is_system_provider": {
|
||||||
|
"name": "is_system_provider",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "timestamp",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1774047704263,
|
"when": 1774130193988,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const emailProviders = pgTable(
|
|||||||
startTls: boolean('start_tls').notNull().default(true),
|
startTls: boolean('start_tls').notNull().default(true),
|
||||||
isDefault: boolean('is_default').notNull().default(false),
|
isDefault: boolean('is_default').notNull().default(false),
|
||||||
isShared: boolean('is_shared').notNull().default(false),
|
isShared: boolean('is_shared').notNull().default(false),
|
||||||
|
isSystemProvider: boolean('is_system_provider').notNull().default(false),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at')
|
updatedAt: timestamp('updated_at')
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
|
|||||||
@@ -88,15 +88,15 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('forgot-password')
|
@Post('forgot-password')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
forgotPassword(@Body() dto: ForgotPasswordDto) {
|
forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: FastifyRequest) {
|
||||||
return this.authService.forgotPassword(dto);
|
return this.authService.forgotPassword(dto, req.ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('reset-password')
|
@Post('reset-password')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
resetPassword(@Body() dto: ResetPasswordDto) {
|
resetPassword(@Body() dto: ResetPasswordDto, @Req() req: FastifyRequest) {
|
||||||
return this.authService.resetPassword(dto);
|
return this.authService.resetPassword(dto, req.ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('change-password')
|
@Post('change-password')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { StringValue } from 'ms';
|
|||||||
import { CommonModule } from '../../common/common.module';
|
import { CommonModule } from '../../common/common.module';
|
||||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { EmailModule } from '../email/email.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
@@ -35,6 +36,7 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
AppSettingsModule,
|
AppSettingsModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
EmailModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const mailerService = {
|
const systemMailService = {
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
isConfigured: vi.fn().mockResolvedValue(true),
|
||||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const appSettings = {
|
const appSettings = {
|
||||||
@@ -95,7 +95,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
|||||||
userService as never,
|
userService as never,
|
||||||
jwtService as never,
|
jwtService as never,
|
||||||
config as never,
|
config as never,
|
||||||
mailerService as never,
|
systemMailService as never,
|
||||||
appSettings as never,
|
appSettings as never,
|
||||||
oidcSessionRepo as never,
|
oidcSessionRepo as never,
|
||||||
oidcDiscovery as never,
|
oidcDiscovery as never,
|
||||||
@@ -103,7 +103,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
|||||||
db,
|
db,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { service, db, userService, jwtService, config, mailerService, appSettings, oidcSessionRepo, oidcDiscovery };
|
return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@@ -382,9 +382,9 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('forgotPassword', () => {
|
describe('forgotPassword', () => {
|
||||||
it('throws ServiceUnavailableException when mailer is not configured', async () => {
|
it('throws ServiceUnavailableException when system mail is not configured', async () => {
|
||||||
const { service, mailerService } = makeService();
|
const { service, systemMailService } = makeService();
|
||||||
mailerService.isConfigured.mockReturnValue(false);
|
systemMailService.isConfigured.mockResolvedValue(false);
|
||||||
|
|
||||||
await expect(service.forgotPassword({ email: 'u@example.com' })).rejects.toThrow(ServiceUnavailableException);
|
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 () => {
|
it('sends reset email when user exists', async () => {
|
||||||
const { service, userService, mailerService } = makeService();
|
const { service, userService, systemMailService } = makeService();
|
||||||
userService.findByEmail.mockResolvedValue({ id: 1, email: 'u@example.com', name: 'User' });
|
userService.findByEmail.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
email: 'u@example.com',
|
||||||
|
name: 'User',
|
||||||
|
active: true,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
username: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
await service.forgotPassword({ email: 'u@example.com' });
|
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);
|
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', () => {
|
describe('logout', () => {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { DB } from '../../db/db.module';
|
|||||||
import * as schema from '../../db/schema';
|
import * as schema from '../../db/schema';
|
||||||
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
|
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
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 { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
@@ -57,7 +57,7 @@ export class AuthService {
|
|||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly mailerService: MailerService,
|
private readonly systemMailService: SystemMailService,
|
||||||
private readonly appSettings: AppSettingsService,
|
private readonly appSettings: AppSettingsService,
|
||||||
private readonly oidcSessionRepo: OidcSessionRepository,
|
private readonly oidcSessionRepo: OidcSessionRepository,
|
||||||
private readonly oidcDiscovery: OidcDiscoveryService,
|
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));
|
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
|
async forgotPassword(dto: ForgotPasswordDto, ip?: string): Promise<void> {
|
||||||
if (!this.mailerService.isConfigured()) {
|
if (!(await this.systemMailService.isConfigured())) {
|
||||||
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
|
throw new ServiceUnavailableException('Self-service password reset is not configured. Contact your administrator.');
|
||||||
}
|
}
|
||||||
|
void this.processPasswordResetAsync(dto.email, ip).catch((err) => this.logger.error('Unhandled error during password reset processing', err));
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 tokenHash = sha256(dto.token);
|
||||||
|
|
||||||
const row = await this.db.query.passwordResetTokens.findFirst({
|
const row = await this.db.query.passwordResetTokens.findFirst({
|
||||||
@@ -361,6 +384,11 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Invalid or expired reset token');
|
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);
|
const passwordHash = await hash(dto.newPassword, 12);
|
||||||
|
|
||||||
await this.db.transaction(async (tx) => {
|
await this.db.transaction(async (tx) => {
|
||||||
@@ -376,6 +404,14 @@ export class AuthService {
|
|||||||
.set({ revokedAt: new Date() })
|
.set({ revokedAt: new Date() })
|
||||||
.where(and(eq(schema.refreshTokens.userId, row.userId), isNull(schema.refreshTokens.revokedAt)));
|
.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) {
|
async changePassword(userId: number, dto: ChangePasswordDto, reply: FastifyReply, ip?: string) {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ export class EmailProviderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/share')
|
@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) {
|
toggleShared(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||||
return this.service.toggleShared(id, user);
|
return this.service.toggleShared(id, user);
|
||||||
}
|
}
|
||||||
@@ -79,4 +85,26 @@ export class EmailProviderController {
|
|||||||
testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
testConnection(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser) {
|
||||||
return this.service.testConnection(id, user);
|
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 { 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 { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
|
|
||||||
import { DB } from '../../db';
|
import { DB } from '../../db';
|
||||||
@@ -73,4 +73,27 @@ export class EmailProviderRepository {
|
|||||||
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
|
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
|
||||||
.returning();
|
.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 type { RequestUser } from '../../common/types/request-user';
|
||||||
import { EmailEncryptionService } from './email-encryption.service';
|
import { EmailEncryptionService } from './email-encryption.service';
|
||||||
@@ -59,7 +59,10 @@ export class EmailProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number, user: RequestUser) {
|
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);
|
await this.repo.delete(id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,13 +75,26 @@ export class EmailProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async toggleShared(id: number, user: RequestUser) {
|
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 provider = await this.getOwned(id, user);
|
||||||
const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared);
|
const [updated] = await this.repo.setSharedByOwner(id, user.id, !provider.isShared);
|
||||||
if (!updated) throw new NotFoundException('Provider not found');
|
if (!updated) throw new NotFoundException('Provider not found');
|
||||||
return this.sanitize(updated);
|
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 }> {
|
async testConnection(id: number, user: RequestUser): Promise<{ success: boolean; error?: string }> {
|
||||||
const provider = await this.getOwnedOrShared(id, user);
|
const provider = await this.getOwnedOrShared(id, user);
|
||||||
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
const password = provider.passwordEnc ? this.encryption.decrypt(provider.passwordEnc) : undefined;
|
||||||
@@ -128,8 +144,4 @@ export class EmailProviderService {
|
|||||||
const { passwordEnc, ...rest } = provider;
|
const { passwordEnc, ...rest } = provider;
|
||||||
return { ...rest, hasPassword: !!provider.passwordEnc };
|
return { ...rest, hasPassword: !!provider.passwordEnc };
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSuperuser(user: RequestUser): boolean {
|
|
||||||
return user.isSuperuser;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class EmailTemplateContextService {
|
|||||||
const [meta] = metaResult;
|
const [meta] = metaResult;
|
||||||
const file: typeof bookFiles.$inferSelect | undefined = fileResult[0];
|
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` : '';
|
const coverUrl = meta ? `${appUrl}/api/books/${bookId}/cover` : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { EmailTemplateController } from './email-template.controller';
|
|||||||
import { EmailTemplateRepository } from './email-template.repository';
|
import { EmailTemplateRepository } from './email-template.repository';
|
||||||
import { EmailTemplateService } from './email-template.service';
|
import { EmailTemplateService } from './email-template.service';
|
||||||
import { EmailTransportService } from './email-transport.service';
|
import { EmailTransportService } from './email-transport.service';
|
||||||
|
import { SystemMailService } from './system-mail.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
@@ -58,7 +59,8 @@ import { EmailTransportService } from './email-transport.service';
|
|||||||
EmailSendLogRepository,
|
EmailSendLogRepository,
|
||||||
EmailSendLogService,
|
EmailSendLogService,
|
||||||
EmailSendOrchestrator,
|
EmailSendOrchestrator,
|
||||||
|
SystemMailService,
|
||||||
],
|
],
|
||||||
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService],
|
exports: [EmailSendOrchestrator, EmailTransportService, EmailEncryptionService, SystemMailService],
|
||||||
})
|
})
|
||||||
export class EmailModule {}
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const escapedUrl = resetUrl.replace(/&/g, '&').replace(/"/g, '"');
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 rawToken = await this.userRepo.generateResetToken(user.id);
|
||||||
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ export class UserService {
|
|||||||
if (target.isSuperuser && !requestingUser.isSuperuser) {
|
if (target.isSuperuser && !requestingUser.isSuperuser) {
|
||||||
throw new ForbiddenException('Only administrators can reset administrator passwords');
|
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);
|
const rawToken = await this.userRepo.generateResetToken(targetUserId);
|
||||||
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
|
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user