init
This commit is contained in:
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal 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 ?? '');
|
||||
}
|
||||
}
|
||||
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal 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 {}
|
||||
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user