import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { CurrentSessionUserId } from './current-session-user.decorator'; import { AuthService, LoginDto } from './auth.service'; import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service'; import type { SessionRequest } from './session-validation.middleware'; import { UsersService } from '../users/users.service'; import { PrismaService } from '../prisma/prisma.service'; import { Prisma } from '@prisma/client'; import { requestContext } from '../common/request-context'; import { SecurityDetectionService } from '../security-detection/security-detection.service'; type CookieResponse = { cookie(name: string, value: string, options: Record): void; clearCookie(name: string, options: Record): void; }; @ApiTags('auth') @Controller() export class AuthController { constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {} @Get('admin/auth/captcha') adminCaptcha() { return this.auth.createCaptcha(); } @Post('admin/auth/login') async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { let result: Awaited>; try { result = await this.auth.login(body, 'admin'); } catch (error) { await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined); throw error; } return this.finishLogin(result, request, response); } @Get('client/auth/captcha') clientCaptcha() { return this.auth.createCaptcha(); } @Post('client/auth/login') async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { let result: Awaited>; try { result = await this.auth.login(body, 'client'); } catch (error) { await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined); throw error; } return this.finishLogin(result, request, response); } @Get(['admin/auth/session', 'client/auth/session']) async currentSession(@Req() request: SessionRequest) { this.assertSession(request); const user = await this.prisma.user.findUniqueOrThrow({ where: { id: request.sessionUserId! }, include: { tenant: true, roles: { include: { role: true } } }, }); return { portal: request.authSession!.portal, locked: Boolean(request.authSession!.lockedAt), user: { id: user.id, tenantId: user.tenantId, tenantName: user.tenant?.name, username: user.username, email: user.email, phone: user.phone, displayName: user.displayName, roles: user.roles.map((item) => item.role.code), }, ...this.sessions.publicSession(request.authSession!), }; } @Post(['admin/auth/session/touch', 'client/auth/session/touch']) async touch(@Req() request: SessionRequest) { this.assertSession(request); const result = await this.sessions.touch(request.sessionToken!); if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' }); return this.sessions.publicSession(result.record); } @Post(['admin/auth/session/lock', 'client/auth/session/lock']) async lock(@Req() request: SessionRequest) { this.assertSession(request); const record = await this.sessions.lock(request.sessionToken!); if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' }); return { locked: Boolean(record) }; } @Post(['admin/auth/session/unlock', 'client/auth/session/unlock']) async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) { this.assertSession(request); const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password); if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' }); this.setCookie(response, result.record.portal, result.token); await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal }); return this.sessions.publicSession(result.record); } @Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate']) async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) { this.assertSession(request); const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password); if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' }); await this.writeLog(request, 'auth.session_reauthenticated', { portal: result.record.portal }); return this.sessions.publicSession(result.record); } @Post(['admin/auth/logout', 'client/auth/logout']) async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { if (request.sessionToken) await this.sessions.remove(request.sessionToken); if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal }); this.clearCookie(response, request.authSession?.portal); return { success: true }; } @Post(['admin/auth/password', 'client/auth/password']) changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) { if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录'); return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? ''); } private async finishLogin(result: Awaited>, request: SessionRequest, response: CookieResponse) { this.setCookie(response, result.portal, result.sessionToken); this.clearLegacyCookie(response); await this.prisma.operationLog.create({ data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } }, }); const { sessionToken: _, ...publicResult } = result; return publicResult; } private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) { return this.security.recordEvent({ ruleCode, sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1', account, protocol: 'http', path: ruleCode === 'admin_login_failure' ? '/admin/auth/login' : '/client/auth/login', evidence: { userAgent: request.header('user-agent')?.slice(0, 256) }, }); } private writeLog(request: SessionRequest, action: string, detail: Record) { return this.prisma.operationLog.create({ data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue }, }); } private assertSession(request: SessionRequest) { if (!request.sessionToken || !request.sessionUserId || !request.authSession) { throw new UnauthorizedException({ code: 'SESSION_INVALID', message: '登录会话无效,请重新登录' }); } } private setCookie(response: CookieResponse, portal: SessionPortal, token: string) { response.cookie(this.sessions.cookieName(portal), token, { httpOnly: true, secure: this.sessions.cookieSecure, sameSite: 'lax', path: '/', }); } private clearCookie(response: CookieResponse, portal?: SessionPortal) { if (!portal) return; response.clearCookie(this.sessions.cookieName(portal), { httpOnly: true, secure: this.sessions.cookieSecure, sameSite: 'lax', path: '/', }); } private clearLegacyCookie(response: CookieResponse) { response.clearCookie(this.sessions.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME, { httpOnly: true, secure: this.sessions.cookieSecure, sameSite: 'lax', path: '/', }); } }