Files
worthpath/apps/api/src/auth.ts
T

296 lines
10 KiB
TypeScript

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<string, { count: number; until: number }>();
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<UserRequest>();
if (
!['GET', 'HEAD', 'OPTIONS'].includes(req.method) &&
!allowedOrigin(
req.headers.origin,
process.env.WEB_ORIGIN,
!networkConfig().allowWildcardOrigins,
)
)
throw new ForbiddenException('请求来源不受信任');
if (this.reflector.get<boolean>('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);
}
}