import { networkConfig, isNetworkOriginAllowed } from './network'; import { Injectable, Controller, Get, Post, Patch, BadRequestException, Body, Req, Res, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException, HttpException, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request, Response } from 'express'; import { randomBytes, createHash } from 'node:crypto'; import { hash, compare } from 'bcryptjs'; import { Database } from './database'; import { credentials, credentialChange, defaultOverviewCards } from './validation'; import { Prisma } from '@prisma/client'; export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean; agent?: boolean; agentGrantId?: string; }; const Public = () => SetMetadata('public', true); const digest = (s: string) => createHash('sha256').update(s).digest('hex'); export function allowedOrigin( origin: string | undefined, configured: string | undefined, production = false, ) { return isNetworkOriginAllowed(origin, configured, !production); } @Injectable() export class AuthService { private attempts = new Map(); constructor(private db: Database) {} limit(req: Request) { const network = networkConfig(); if (!network.rateLimitEnabled) return; const key = (req.ip || 'local') + ('userId' in req && typeof req.userId === 'string' ? ':' + req.userId : ''), now = Date.now(); let v = this.attempts.get(key); if (!v || v.until < now) { v = { count: 0, until: now + network.rateLimitWindowMs }; this.attempts.set(key, v); } if (++v.count > network.authRateLimitMax) throw new HttpException('尝试过于频繁,请稍后重试', 429); if (this.attempts.size > 10000) { for (const [k, v] of this.attempts) if (v.until < now) this.attempts.delete(k); if (this.attempts.size > 10000) throw new HttpException('服务繁忙,请稍后重试', 429); } } async issue(userId: string, res: Response) { const user = await this.db.user.findUniqueOrThrow({ where: { id: userId } }); const token = randomBytes(32).toString('hex'), expiresAt = new Date(Date.now() + user.sessionHours * 3600000); await this.db.session.create({ data: { id: digest(token), userId, expiresAt } }); this.cookie(token, expiresAt, res); } cookie(token: string, expiresAt: Date, res: Response) { res.cookie('wp_session', token, { httpOnly: true, sameSite: networkConfig().sameSite, secure: networkConfig().cookieSecure, expires: expiresAt, path: '/api', }); } async user(token: unknown) { if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) return null; const s = await this.db.session.findUnique({ where: { id: digest(token) } }); if (!s || s.expiresAt <= new Date()) return null; const u = await this.db.user.findUniqueOrThrow({ where: { id: s.userId }, select: { idleMinutes: true }, }); if (u.idleMinutes && Date.now() - +s.lastActivity >= u.idleMinutes * 60000) { await this.db.session.deleteMany({ where: { id: s.id } }); throw new UnauthorizedException('长时间无操作,已自动退出登录'); } return s; } async logout(req: Request, res: Response) { if (typeof req.cookies?.wp_session === 'string') await this.db.session.deleteMany({ where: { id: digest(req.cookies.wp_session) } }); res.clearCookie('wp_session', { path: '/api', sameSite: networkConfig().sameSite, secure: networkConfig().cookieSecure, httpOnly: true, }); } } @Injectable() export class AuthGuard implements CanActivate { constructor( private auth: AuthService, private reflector: Reflector, ) {} async canActivate(ctx: ExecutionContext) { const req = ctx.switchToHttp().getRequest(); if ( !['GET', 'HEAD', 'OPTIONS'].includes(req.method) && !allowedOrigin( req.headers.origin, process.env.WEB_ORIGIN, !networkConfig().allowWildcardOrigins, ) ) throw new ForbiddenException('请求来源不受信任'); if (this.reflector.get('public', ctx.getHandler())) return true; const id = await this.auth.user(req.cookies?.wp_session); if (!id) throw new UnauthorizedException('请先登录'); req.userId = id.userId; req.sessionId = id.id; req.revealed = !!id.revealUntil && +id.revealUntil > Date.now(); return true; } } @Injectable() export class AuthBusinessService { constructor( private db: Database, private auth: AuthService, ) {} health() { return { status: 'ok' }; } async register(body: unknown, req: Request, res: Response) { this.auth.limit(req); const v = credentials.parse(body), user = await this.db.user.create({ data: { username: v.username, passwordHash: await hash(v.password, 12) }, }); await this.auth.issue(user.id, res); return { username: user.username, baseCurrency: user.baseCurrency }; } async login(body: unknown, req: Request, res: Response) { this.auth.limit(req); const v = credentials.parse(body), user = await this.db.user.findUnique({ where: { username: v.username } }); const ok = await compare( v.password, user?.passwordHash || '$2b$12$JZKvzAzfqM3obKxMQTFBjOzqIIKG97kn96/xUMbgTuWUnlMAT2bSi', ); if (!user || !ok) throw new UnauthorizedException('账号或密码错误'); await this.auth.issue(user.id, res); return { username: user.username, baseCurrency: user.baseCurrency }; } async me(req: UserRequest) { const user = await this.db.user.findUniqueOrThrow({ where: { id: req.userId }, select: { username: true, baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true, accountGroupOrder: true, sessionHours: true, requireHiddenPassword: true, overviewCards: true, includeIndependentAssets: true, }, }); const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } }); return { ...user, accountGroupOrder: user.accountGroupOrder || [], overviewCards: user.overviewCards ?? [...defaultOverviewCards], includeIndependentAssets: user.includeIndependentAssets, sessionExpiresAt: session.expiresAt, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean), revealed: req.revealed, revealUntil: session.revealUntil, lastActivity: session.lastActivity, }; } async changeCredentials(r: UserRequest, body: unknown, res: Response) { this.auth.limit(r); const v = credentialChange.parse(body); const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); if (!(await compare(v.currentPassword, user.passwordHash))) throw new ForbiddenException('当前密码错误'); if ((!v.username || v.username === user.username) && !v.newPassword) throw new BadRequestException('请填写新的账号或密码'); const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash; const token = randomBytes(32).toString('hex'), expiresAt = new Date(Date.now() + user.sessionHours * 3600000); await this.db.serial(async (tx) => { await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`); const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); if (current.passwordHash !== user.passwordHash || current.username !== user.username) throw new ForbiddenException('账号已变更,请重新登录后操作'); await tx.user.update({ where: { id: r.userId }, data: { username: v.username, passwordHash }, }); await tx.session.deleteMany({ where: { userId: r.userId } }); await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } }); }); this.auth.cookie(token, expiresAt, res); return { ok: true }; } async activity(r: UserRequest) { await this.db.session.update({ where: { id: r.sessionId }, data: { lastActivity: new Date() }, }); return { ok: true }; } async reveal(r: UserRequest, b: unknown) { this.auth.limit(r); const revealUntil = await this.db.serial(async (tx) => { await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`); const u = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); if (u.requireHiddenPassword) { const { password } = credentials.pick({ password: true }).parse(b); if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误'); } const until = new Date(Date.now() + 5 * 60000); await tx.session.update({ where: { id: r.sessionId }, data: { revealUntil: until } }); return until; }); return { revealUntil }; } async lock(r: UserRequest) { await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } }); return { ok: true }; } async logout(req: Request, res: Response) { await this.auth.logout(req, res); return { ok: true }; } } @Controller('api') export class AuthController { constructor(private service: AuthBusinessService) {} @Public() @Get('health') health() { return this.service.health(); } @Public() @Post('auth/register') async register( @Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { return this.service.register(body, req, res); } @Public() @Post('auth/login') async login( @Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { return this.service.login(body, req, res); } @Get('auth/me') async me(@Req() req: UserRequest) { return this.service.me(req); } @Patch('auth/credentials') async changeCredentials( @Req() r: UserRequest, @Body() body: unknown, @Res({ passthrough: true }) res: Response, ) { return this.service.changeCredentials(r, body, res); } @Post('auth/activity') async activity(@Req() r: UserRequest) { return this.service.activity(r); } @Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) { return this.service.reveal(r, b); } @Post('auth/lock') async lock(@Req() r: UserRequest) { return this.service.lock(r); } @Post('auth/logout') async logout( @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { return this.service.logout(req, res); } }