Files
plp/apps/api/src/auth/auth.controller.ts
T
2026-09-14 18:43:45 +08:00

142 lines
4.0 KiB
TypeScript

import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { parse as parseCookie } from "cookie";
import { ErrorCode } from "@drift/contracts";
import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js";
import { AuthGuard } from "./auth.guard.js";
import { AuthService, type TokenPair } from "./auth.service.js";
import { CurrentUser } from "./current-user.decorator.js";
import type { AccessClaims } from "./token.service.js";
import { LoginDto, SendSmsDto } from "./dto.js";
const COOKIE = "refresh_token";
@Controller()
export class AuthController {
constructor(
@Inject(AuthService) private readonly auth: AuthService,
@Inject(PrismaService) private readonly prisma: PrismaService,
) {}
@Post("auth/sms/send") send(@Body() dto: SendSmsDto, @Req() req: Request) {
return this.auth.sendSms(dto.phone, dto.deviceId, this.ip(req));
}
@Post("auth/sms/login") async login(
@Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const pair = await this.auth.login(
dto.phone,
dto.code,
dto.deviceId,
this.ip(req),
);
this.cookie(res, pair.refreshToken);
return this.publicPair(pair);
}
@Post("auth/token/refresh") @HttpCode(HttpStatus.OK) async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
this.origin(req);
const pair = await this.auth.refresh(this.readCookie(req));
this.cookie(res, pair.refreshToken);
return this.publicPair(pair);
}
@Post("auth/logout") @HttpCode(HttpStatus.OK) async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
this.origin(req);
await this.auth.logout(this.readCookieOptional(req));
res.clearCookie(COOKIE, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/api/v1/auth",
});
return { loggedOut: true };
}
@Get("me") @UseGuards(AuthGuard) async me(@CurrentUser() user: AccessClaims) {
const account = await this.prisma.account.findUniqueOrThrow({
where: { id: user.sub },
include: { anonymousProfile: true },
});
return {
accountId: account.id,
publicId: account.anonymousProfile?.publicId ?? null,
nickname: account.anonymousProfile?.nickname ?? null,
};
}
private publicPair(pair: TokenPair) {
return {
accessToken: pair.accessToken,
expiresIn: pair.expiresIn,
tokenType: "Bearer",
};
}
private cookie(res: Response, value: string) {
res.cookie(COOKIE, value, {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/api/v1/auth",
maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000,
});
}
private origin(req: Request) {
const origin = req.headers.origin;
if (origin !== process.env.WEB_ORIGIN)
throw new DomainException(
ErrorCode.AUTH_ORIGIN_FORBIDDEN,
"Forbidden origin",
HttpStatus.FORBIDDEN,
);
}
private readCookieOptional(req: Request) {
const raw = req.headers.cookie;
if (!raw) return undefined;
try {
return parseCookie(raw)[COOKIE];
} catch {
return undefined;
}
}
private readCookie(req: Request) {
const value = this.readCookieOptional(req);
if (!value)
throw new DomainException(
ErrorCode.AUTH_INVALID_CREDENTIALS,
"Invalid credentials",
HttpStatus.UNAUTHORIZED,
);
return value;
}
private ip(req: Request) {
return req.ip || req.socket.remoteAddress || "unknown";
}
}
Reflect.defineMetadata(
"design:paramtypes",
[SendSmsDto, Object],
AuthController.prototype,
"send",
);
Reflect.defineMetadata(
"design:paramtypes",
[LoginDto, Object, Object],
AuthController.prototype,
"login",
);