246 lines
9.3 KiB
TypeScript
246 lines
9.3 KiB
TypeScript
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
|
|
import { ApiTags } from '@nestjs/swagger';
|
|
import { CurrentSessionUserId } from './current-session-user.decorator';
|
|
import { AuthService } from './auth.service';
|
|
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
|
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
|
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<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,
|
|
private readonly sessions: SessionService,
|
|
private readonly prisma: PrismaService,
|
|
private readonly security: SecurityDetectionService,
|
|
) {}
|
|
|
|
@Get('admin/auth/captcha')
|
|
adminCaptcha(@Req() request: SessionRequest) {
|
|
return this.auth.createCaptcha(this.sourceIp(request));
|
|
}
|
|
|
|
@Post('admin/auth/login')
|
|
@UsePipes(strictValidationPipe)
|
|
async adminLogin(
|
|
@Body() body: LoginDto,
|
|
@Req() request: SessionRequest,
|
|
@Res({ passthrough: true }) response: CookieResponse,
|
|
) {
|
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
|
try {
|
|
result = await this.auth.login(body, 'admin', this.sourceIp(request));
|
|
} 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(@Req() request: SessionRequest) {
|
|
return this.auth.createCaptcha(this.sourceIp(request));
|
|
}
|
|
|
|
@Post('client/auth/login')
|
|
@UsePipes(strictValidationPipe)
|
|
async clientLogin(
|
|
@Body() body: LoginDto,
|
|
@Req() request: SessionRequest,
|
|
@Res({ passthrough: true }) response: CookieResponse,
|
|
) {
|
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
|
try {
|
|
result = await this.auth.login(body, 'client', this.sourceIp(request));
|
|
} 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'])
|
|
@UsePipes(strictValidationPipe)
|
|
async unlock(
|
|
@Req() request: SessionRequest,
|
|
@Body() body: PasswordVerificationDto,
|
|
@Res({ passthrough: true }) response: CookieResponse,
|
|
) {
|
|
const { password } = body;
|
|
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'])
|
|
@UsePipes(strictValidationPipe)
|
|
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
|
|
const { password } = body;
|
|
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'])
|
|
@UsePipes(strictValidationPipe)
|
|
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
|
|
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.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 sourceIp(request: SessionRequest) {
|
|
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
|
|
}
|
|
|
|
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, 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: '/',
|
|
});
|
|
}
|
|
}
|