diff --git a/src/admin/auth/admin-auth-guard.module.ts b/src/admin/auth/admin-auth-guard.module.ts index 0bae3b1..d53d63b 100644 --- a/src/admin/auth/admin-auth-guard.module.ts +++ b/src/admin/auth/admin-auth-guard.module.ts @@ -3,12 +3,23 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminUser } from '../user/admin-user.entity'; import { AdminAuthService } from './admin-auth.service'; +import { AdminPasswordCryptoService } from './admin-password-crypto.service'; import { AdminTokenService } from './admin-token.service'; import { JwtAuthGuard } from './jwt-auth.guard'; @Module({ imports: [ConfigModule, TypeOrmModule.forFeature([AdminUser])], - providers: [AdminAuthService, AdminTokenService, JwtAuthGuard], - exports: [AdminAuthService, AdminTokenService, JwtAuthGuard], + providers: [ + AdminAuthService, + AdminPasswordCryptoService, + AdminTokenService, + JwtAuthGuard, + ], + exports: [ + AdminAuthService, + AdminPasswordCryptoService, + AdminTokenService, + JwtAuthGuard, + ], }) export class AdminAuthGuardModule {} diff --git a/src/admin/auth/admin-auth.controller.ts b/src/admin/auth/admin-auth.controller.ts index d51f376..a472a9c 100644 --- a/src/admin/auth/admin-auth.controller.ts +++ b/src/admin/auth/admin-auth.controller.ts @@ -14,6 +14,8 @@ import { AdminMenuService } from '../menu/admin-menu.service'; import { AdminUser } from '../user/admin-user.entity'; import { AdminUserService } from '../user/admin-user.service'; import { AdminAuthService } from './admin-auth.service'; +import { AdminLoginDto } from './admin-auth.dto'; +import { AdminPasswordCryptoService } from './admin-password-crypto.service'; import { JwtAuthGuard } from './jwt-auth.guard'; import { WordpressService } from '@/wordpress/wordpress.service'; @@ -23,21 +25,32 @@ import { WordpressService } from '@/wordpress/wordpress.service'; export class AdminAuthController { constructor( private readonly authService: AdminAuthService, + private readonly passwordCryptoService: AdminPasswordCryptoService, private readonly menuService: AdminMenuService, private readonly userService: AdminUserService, private readonly wordpressService: WordpressService, ) {} + @Get('auth/password-public-key') + @ApiOperation({ summary: '获取 Admin 登录密码加密公钥' }) + @Public() + getPasswordPublicKey() { + return vbenSuccess(this.passwordCryptoService.getPublicKey()); + } + @Post('auth/login') @ApiOperation({ summary: 'Admin 用户登录' }) @Public() async login( - @Body() body: { password?: string; username?: string }, + @Body() body: AdminLoginDto, @Res({ passthrough: true }) res: Response, ) { + const password = this.passwordCryptoService.decryptPassword( + body.encryptedPassword, + ); const { accessToken, refreshToken, user } = await this.authService.login( body.username, - body.password, + password, ); const wordpressLogin = await this.wordpressService.tryLoginWithConfiguredAdmin(); diff --git a/src/admin/auth/admin-auth.dto.ts b/src/admin/auth/admin-auth.dto.ts new file mode 100644 index 0000000..4a0f350 --- /dev/null +++ b/src/admin/auth/admin-auth.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AdminLoginDto { + @ApiProperty({ description: 'RSA-OAEP 加密后的登录密码' }) + encryptedPassword?: string; + + @ApiProperty({ description: '用户名' }) + username?: string; +} + +export class AdminPasswordPublicKeyDto { + @ApiProperty({ description: '加密算法', example: 'RSA-OAEP' }) + algorithm: 'RSA-OAEP'; + + @ApiProperty({ description: '摘要算法', example: 'SHA-256' }) + hash: 'SHA-256'; + + @ApiProperty({ description: 'PEM 格式 RSA 公钥' }) + publicKey: string; +} diff --git a/src/admin/auth/admin-password-crypto.service.ts b/src/admin/auth/admin-password-crypto.service.ts new file mode 100644 index 0000000..1e242e7 --- /dev/null +++ b/src/admin/auth/admin-password-crypto.service.ts @@ -0,0 +1,67 @@ +import { + constants, + generateKeyPairSync, + privateDecrypt, +} from 'node:crypto'; + +import { HttpStatus, Injectable } from '@nestjs/common'; +import { throwVbenError } from '@/common'; + +@Injectable() +export class AdminPasswordCryptoService { + private readonly privateKey: string; + + private readonly publicKey: string; + + constructor() { + const keyPair = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { + format: 'pem', + type: 'pkcs8', + }, + publicKeyEncoding: { + format: 'pem', + type: 'spki', + }, + }); + + this.privateKey = keyPair.privateKey; + this.publicKey = keyPair.publicKey; + } + + getPublicKey() { + return { + algorithm: 'RSA-OAEP' as const, + hash: 'SHA-256' as const, + publicKey: this.publicKey, + }; + } + + decryptPassword(encryptedPassword?: string) { + if (!encryptedPassword) { + throwVbenError( + 'Encrypted password is required', + HttpStatus.BAD_REQUEST, + 'BadRequestException', + ); + } + + try { + return privateDecrypt( + { + key: this.privateKey, + oaepHash: 'sha256', + padding: constants.RSA_PKCS1_OAEP_PADDING, + }, + Buffer.from(encryptedPassword, 'base64'), + ).toString('utf8'); + } catch { + throwVbenError( + 'Password decrypt failed', + HttpStatus.BAD_REQUEST, + 'BadRequestException', + ); + } + } +} diff --git a/test/admin/auth/admin-password-crypto.service.spec.ts b/test/admin/auth/admin-password-crypto.service.spec.ts new file mode 100644 index 0000000..bb96e5a --- /dev/null +++ b/test/admin/auth/admin-password-crypto.service.spec.ts @@ -0,0 +1,38 @@ +import { constants, publicEncrypt } from 'node:crypto'; + +import { HttpException } from '@nestjs/common'; +import { AdminPasswordCryptoService } from '../../../src/admin/auth/admin-password-crypto.service'; + +describe('AdminPasswordCryptoService', () => { + it('decrypts password encrypted by the exported public key', () => { + const service = new AdminPasswordCryptoService(); + const { hash, publicKey } = service.getPublicKey(); + const encryptedPassword = publicEncrypt( + { + key: publicKey, + oaepHash: hash.toLowerCase().replace('-', ''), + padding: constants.RSA_PKCS1_OAEP_PADDING, + }, + Buffer.from('123456', 'utf8'), + ).toString('base64'); + + expect(service.decryptPassword(encryptedPassword)).toBe('123456'); + }); + + it('rejects invalid encrypted password payloads', () => { + const service = new AdminPasswordCryptoService(); + + try { + service.decryptPassword('not-base64'); + throw new Error('decryptPassword should fail'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getResponse()).toEqual( + expect.objectContaining({ + err: 'BadRequestException', + msg: 'Password decrypt failed', + }), + ); + } + }); +});