feat(api): 开发auth、login等接口,规范格式化
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user