207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiBody,
|
|
ApiNoContentResponse,
|
|
ApiOkResponse,
|
|
ApiOperation,
|
|
ApiTags,
|
|
ApiUnauthorizedResponse,
|
|
} from '@nestjs/swagger';
|
|
import { createHash } from 'node:crypto';
|
|
import { loginRequestSchema, registerRequestSchema } from '@atlas/contracts';
|
|
import { ApiError } from '../common/api-error.js';
|
|
import { AccessTokenGuard, type AuthenticatedRequest } from './auth.guard.js';
|
|
import { AuthService, type RequestMetadata } from './auth.service.js';
|
|
|
|
const refreshCookie = 'atlas_refresh';
|
|
type HttpRequest = {
|
|
header: (name: string) => string | undefined;
|
|
ip?: string;
|
|
};
|
|
type HttpResponse = {
|
|
cookie: (
|
|
name: string,
|
|
value: string,
|
|
options: Record<string, unknown>,
|
|
) => void;
|
|
clearCookie: (name: string, options: Record<string, unknown>) => void;
|
|
};
|
|
|
|
@ApiTags('Authentication')
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
public constructor(private readonly authService: AuthService) {}
|
|
|
|
@Post('register')
|
|
@ApiOperation({ summary: '注册账户并创建个人工作空间' })
|
|
@ApiBody({
|
|
schema: {
|
|
example: {
|
|
email: 'owner@example.com',
|
|
password: 'a-strong-password',
|
|
password_confirmation: 'a-strong-password',
|
|
display_name: 'Joey',
|
|
},
|
|
},
|
|
})
|
|
public async register(
|
|
@Body() body: unknown,
|
|
@Req() request: HttpRequest,
|
|
@Res({ passthrough: true }) response: HttpResponse,
|
|
) {
|
|
const result = await this.authService.register(
|
|
parse(registerRequestSchema, body),
|
|
metadata(request),
|
|
);
|
|
setRefreshCookie(response, result.refreshToken);
|
|
return { data: result.payload };
|
|
}
|
|
|
|
@Post('login')
|
|
@ApiOperation({ summary: '使用邮箱和密码登录' })
|
|
@ApiBody({
|
|
schema: {
|
|
example: {
|
|
email: 'owner@example.com',
|
|
password: 'a-strong-password',
|
|
},
|
|
},
|
|
})
|
|
public async login(
|
|
@Body() body: unknown,
|
|
@Req() request: HttpRequest,
|
|
@Res({ passthrough: true }) response: HttpResponse,
|
|
) {
|
|
const result = await this.authService.login(
|
|
parse(loginRequestSchema, body),
|
|
metadata(request),
|
|
);
|
|
setRefreshCookie(response, result.refreshToken);
|
|
return { data: result.payload };
|
|
}
|
|
|
|
@Post('refresh')
|
|
@ApiOperation({ summary: '轮换 refresh cookie 并获取新的 access token' })
|
|
public async refresh(
|
|
@Req() request: HttpRequest,
|
|
@Res({ passthrough: true }) response: HttpResponse,
|
|
) {
|
|
try {
|
|
const result = await this.authService.refresh(
|
|
readCookie(request.header('cookie'), refreshCookie),
|
|
metadata(request),
|
|
);
|
|
setRefreshCookie(response, result.refreshToken);
|
|
return {
|
|
data: {
|
|
access_token: result.payload.access_token,
|
|
access_token_expires_in:
|
|
result.payload.access_token_expires_in,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
clearRefreshCookie(response);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@Post('logout')
|
|
@HttpCode(204)
|
|
@UseGuards(AccessTokenGuard)
|
|
@ApiBearerAuth('access-token')
|
|
@ApiOperation({ summary: '撤销当前登录会话' })
|
|
@ApiNoContentResponse({ description: '会话已撤销' })
|
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
|
public async logout(
|
|
@Req() request: AuthenticatedRequest,
|
|
@Res({ passthrough: true }) response: HttpResponse,
|
|
) {
|
|
await this.authService.logout(request.auth.sessionId);
|
|
clearRefreshCookie(response);
|
|
}
|
|
|
|
@Get('me')
|
|
@UseGuards(AccessTokenGuard)
|
|
@ApiBearerAuth('access-token')
|
|
@ApiOperation({ summary: '获取当前用户、工作空间和页头初始上下文' })
|
|
@ApiOkResponse({ description: '当前身份和工作空间' })
|
|
@ApiUnauthorizedResponse({ description: 'access token 无效或已过期' })
|
|
public async me(@Req() request: AuthenticatedRequest) {
|
|
return { data: await this.authService.me(request.auth.userId) };
|
|
}
|
|
}
|
|
|
|
function parse<
|
|
T extends {
|
|
safeParse: (value: unknown) => {
|
|
success: boolean;
|
|
data?: unknown;
|
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
|
};
|
|
},
|
|
>(schema: T, value: unknown) {
|
|
const result = schema.safeParse(value);
|
|
if (result.success)
|
|
return result.data as ReturnType<T['safeParse']> extends {
|
|
data: infer Data;
|
|
}
|
|
? Data
|
|
: never;
|
|
throw new ApiError(
|
|
422,
|
|
'VALIDATION_FAILED',
|
|
'请求参数不符合要求',
|
|
result.error?.issues.map((issue) => ({
|
|
field: issue.path.join('.'),
|
|
code: 'INVALID_FIELD',
|
|
message: issue.message,
|
|
})),
|
|
);
|
|
}
|
|
function metadata(request: HttpRequest): RequestMetadata {
|
|
return {
|
|
ip: request.ip ?? null,
|
|
ipHash: request.ip
|
|
? createHash('sha256').update(request.ip).digest('base64url')
|
|
: null,
|
|
userAgent: request.header('user-agent') ?? null,
|
|
};
|
|
}
|
|
function cookieOptions() {
|
|
return {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax' as const,
|
|
path: '/api/auth',
|
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
};
|
|
}
|
|
function setRefreshCookie(response: HttpResponse, token: string) {
|
|
response.cookie(refreshCookie, token, cookieOptions());
|
|
}
|
|
function clearRefreshCookie(response: HttpResponse) {
|
|
response.clearCookie(refreshCookie, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
path: '/api/auth',
|
|
});
|
|
}
|
|
function readCookie(header: string | undefined, name: string) {
|
|
return header
|
|
?.split(';')
|
|
.map((part) => part.trim())
|
|
.find((part) => part.startsWith(`${name}=`))
|
|
?.slice(name.length + 1);
|
|
}
|