This commit is contained in:
dlandy
2026-03-27 10:18:26 +08:00
commit 40be11adbf
116 changed files with 26138 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { AdminAuthService } from './admin-auth.service';
@ApiTags('admin-auth')
@Controller('admin/auth')
export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@Post('login')
@ApiOperation({ summary: 'Admin login' })
login(@Body() body: LoginDto) {
return this.adminAuthService.login(body);
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh admin token' })
refresh(@Body() body: RefreshTokenDto) {
return this.adminAuthService.refresh(body.refreshToken);
}
@Post('logout')
@UseGuards(AdminJwtGuard)
@ApiBearerAuth('admin-access-token')
@ApiOperation({ summary: 'Admin logout' })
logout() {
return this.adminAuthService.logout();
}
@Get('me')
@UseGuards(AdminJwtGuard)
@ApiBearerAuth('admin-access-token')
@ApiOperation({ summary: 'Get current admin profile' })
me(@Req() request: RequestWithContext) {
return this.adminAuthService.getMe(request.user?.sub ?? '');
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
}),
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, AdminJwtStrategy],
exports: [AdminAuthService],
})
export class AdminAuthModule {}

View File

@@ -0,0 +1,189 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AdminUserStatus } from '@prisma/client';
import argon2 from 'argon2';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service';
import type { LoginDto } from './dto/login.dto';
import type { JwtPayload } from './interfaces/jwt-payload.interface';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AdminAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async login(body: LoginDto) {
const user = await this.prisma.adminUser.findUnique({
where: {
username: body.username,
},
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.INVALID_CREDENTIALS,
'invalid username or password',
HttpStatus.UNAUTHORIZED,
);
}
const isValidPassword = await argon2.verify(user.passwordHash, body.password);
if (!isValidPassword) {
throw new AppException(
ERROR_CODES.INVALID_CREDENTIALS,
'invalid username or password',
HttpStatus.UNAUTHORIZED,
);
}
await this.prisma.adminUser.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const tokens = await this.issueTokens({
sub: user.id,
username: user.username,
type: 'access',
});
return {
...tokens,
profile: {
id: user.id,
username: user.username,
displayName: user.displayName ?? user.username,
},
};
}
async refresh(refreshToken: string) {
const payload = await this.verifyRefreshToken(refreshToken);
const user = await this.prisma.adminUser.findUnique({
where: { id: payload.sub },
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.TOKEN_INVALID,
'token invalid or expired',
HttpStatus.UNAUTHORIZED,
);
}
return this.issueTokens({
sub: user.id,
username: user.username,
type: 'access',
});
}
async getMe(userId: string) {
const user = await this.prisma.adminUser.findUnique({
where: { id: userId },
});
if (!user || user.status !== AdminUserStatus.active) {
throw new AppException(
ERROR_CODES.UNAUTHORIZED,
'admin user not available',
HttpStatus.UNAUTHORIZED,
);
}
return {
id: user.id,
username: user.username,
displayName: user.displayName ?? user.username,
status: user.status,
lastLoginAt: user.lastLoginAt,
};
}
async logout() {
return {
success: true,
};
}
private async issueTokens(payload: JwtPayload) {
const accessSecret = this.configService.get<string>(
'JWT_ACCESS_SECRET',
'change_this_access_secret',
);
const refreshSecret = this.configService.get<string>(
'JWT_REFRESH_SECRET',
'change_this_refresh_secret',
);
const accessExpiresInRaw = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h');
const refreshExpiresInRaw = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const accessExpiresIn = this.parseExpiresInSeconds(accessExpiresInRaw);
const refreshExpiresIn = this.parseExpiresInSeconds(refreshExpiresInRaw);
const accessToken = await this.jwtService.signAsync(payload, {
secret: accessSecret,
expiresIn: accessExpiresIn,
});
const refreshToken = await this.jwtService.signAsync(
{
...payload,
type: 'refresh' as const,
},
{
secret: refreshSecret,
expiresIn: refreshExpiresIn,
},
);
return {
accessToken,
refreshToken,
expiresIn: accessExpiresIn,
};
}
private async verifyRefreshToken(token: string): Promise<JwtPayload> {
try {
const refreshSecret = this.configService.get<string>(
'JWT_REFRESH_SECRET',
'change_this_refresh_secret',
);
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: refreshSecret,
});
if (payload.type !== 'refresh') {
throw new Error('invalid refresh token type');
}
return payload;
} catch {
throw new AppException(
ERROR_CODES.TOKEN_INVALID,
'token invalid or expired',
HttpStatus.UNAUTHORIZED,
);
}
}
private parseExpiresInSeconds(expiresIn: string): number {
if (/^\d+$/.test(expiresIn)) {
return Number(expiresIn);
}
if (expiresIn.endsWith('h')) {
return Number(expiresIn.replace('h', '')) * 3600;
}
if (expiresIn.endsWith('m')) {
return Number(expiresIn.replace('m', '')) * 60;
}
if (expiresIn.endsWith('d')) {
return Number(expiresIn.replace('d', '')) * 86400;
}
return 7200;
}
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty({ example: 'admin' })
@IsString()
@MinLength(3)
@MaxLength(64)
username!: string;
@ApiProperty({ example: 'admin123456' })
@IsString()
@MinLength(6)
@MaxLength(128)
password!: string;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken!: string;
}

View File

@@ -0,0 +1,5 @@
export interface JwtPayload {
sub: string;
username: string;
type: 'access' | 'refresh';
}

View File

@@ -0,0 +1,23 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { JwtPayload } from '../interfaces/jwt-payload.interface';
@Injectable()
export class AdminJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
if (payload.type !== 'access') {
throw new UnauthorizedException('invalid access token');
}
return payload;
}
}