feat: harden sessions and track downstream acknowledgements
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
|
||||
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 { 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';
|
||||
|
||||
type CookieResponse = {
|
||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||
clearCookie(name: string, options: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
@@ -15,8 +24,8 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
adminLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'admin');
|
||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
@@ -25,15 +34,101 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
clientLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'client');
|
||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
||||
}
|
||||
|
||||
@Get('auth/session')
|
||||
currentSession(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
return this.sessions.publicSession(request.authSession!);
|
||||
}
|
||||
|
||||
@Post('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('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('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.token);
|
||||
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('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('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);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('auth/password')
|
||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
}
|
||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||
}
|
||||
|
||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||
this.setCookie(response, result.sessionToken);
|
||||
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 writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||
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, token: string) {
|
||||
response.cookie(this.sessions.cookieName, token, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
private clearCookie(response: CookieResponse) {
|
||||
response.clearCookie(this.sessions.cookieName, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user