forked from wangziqi/gongxue-base
feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
44
apps/server/src/auth/auth.controller.ts
Normal file
44
apps/server/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Post, Body, Get, Request, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService, private logService: OperationLogsService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||
async login(@Body() dto: LoginDto, @Req() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
try {
|
||||
const result = await this.authService.login(dto, ipAddress);
|
||||
await this.logService.log({
|
||||
userId: result.user.id, username: result.user.username,
|
||||
module: '认证', action: '登录成功',
|
||||
ipAddress, userAgent, status: 'success',
|
||||
});
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
await this.logService.log({
|
||||
username: dto.username,
|
||||
module: '认证', action: '登录失败',
|
||||
detail: e.message || '密码错误',
|
||||
ipAddress, userAgent, status: 'fail',
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('profile')
|
||||
getProfile(@Request() req: any) {
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
30
apps/server/src/auth/auth.module.ts
Normal file
30
apps/server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { RbacModule } from '../rbac/rbac.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
|
||||
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') },
|
||||
}),
|
||||
}),
|
||||
forwardRef(() => RbacModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
91
apps/server/src/auth/auth.service.ts
Normal file
91
apps/server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Injectable, UnauthorizedException, forwardRef, Inject } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { User } from '../entities/user.entity';
|
||||
import { LoginDto } from './dto/auth.dto';
|
||||
import { RbacService } from '../rbac/rbac.service';
|
||||
|
||||
// 内存中的登录失败计数器(按IP+用户名)
|
||||
const loginAttempts = new Map<string, { count: number; lockedUntil?: Date }>();
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@InjectRepository(User) private userRepo: Repository<User>,
|
||||
private jwtService: JwtService,
|
||||
@Inject(forwardRef(() => RbacService)) private rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto, ip?: string) {
|
||||
const attemptKey = `${ip || 'unknown'}:${dto.username}`;
|
||||
const attempt = loginAttempts.get(attemptKey);
|
||||
|
||||
// 检查是否在锁定期
|
||||
if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) {
|
||||
const remaining = Math.ceil((attempt.lockedUntil.getTime() - Date.now()) / 60000);
|
||||
throw new UnauthorizedException(`账号已被临时锁定,请 ${remaining} 分钟后重试`);
|
||||
}
|
||||
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { username: dto.username },
|
||||
relations: ['roles'],
|
||||
});
|
||||
if (!user) {
|
||||
this.recordFailedAttempt(attemptKey);
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
if (!user.isActive) throw new UnauthorizedException('账号已被禁用,请联系管理员');
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
this.recordFailedAttempt(attemptKey);
|
||||
const att = loginAttempts.get(attemptKey);
|
||||
const remaining = MAX_ATTEMPTS - (att?.count || 0);
|
||||
if (remaining > 0) {
|
||||
throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`);
|
||||
}
|
||||
throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
|
||||
}
|
||||
|
||||
// 登录成功,清除失败计数
|
||||
loginAttempts.delete(attemptKey);
|
||||
|
||||
// 记录登录时间
|
||||
user.lastLoginAt = new Date();
|
||||
await this.userRepo.save(user);
|
||||
|
||||
// 获取用户权限
|
||||
const permissions = await this.rbacService.getUserPermissions(user.id);
|
||||
const payload = { sub: user.id, username: user.username, permissions };
|
||||
|
||||
// 获取角色名称列表
|
||||
const roleNames = user.roles ? user.roles.filter(r => r.status === 1).map(r => r.name) : [];
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
roles: roleNames,
|
||||
permissions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private recordFailedAttempt(key: string) {
|
||||
const attempt = loginAttempts.get(key) || { count: 0 };
|
||||
attempt.count++;
|
||||
if (attempt.count >= MAX_ATTEMPTS) {
|
||||
attempt.lockedUntil = new Date(Date.now() + LOCK_MINUTES * 60 * 1000);
|
||||
}
|
||||
loginAttempts.set(key, attempt);
|
||||
}
|
||||
|
||||
async validateUser(payload: any) {
|
||||
return this.userRepo.findOne({ where: { id: payload.sub } });
|
||||
}
|
||||
}
|
||||
11
apps/server/src/auth/decorators/permission.decorator.ts
Normal file
11
apps/server/src/auth/decorators/permission.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSION_KEY = 'permissions';
|
||||
|
||||
/**
|
||||
* 声明接口所需权限。
|
||||
* 多个参数之间为 OR 关系(用户拥有其中任一权限即可通过)。
|
||||
* 不支持 AND 语义:多次调用装饰器会被全局 PermissionGuard 合并为扁平数组,效果等同于单次多参数调用。
|
||||
*/
|
||||
export const RequirePermission = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSION_KEY, permissions);
|
||||
4
apps/server/src/auth/decorators/public.decorator.ts
Normal file
4
apps/server/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
22
apps/server/src/auth/dto/auth.dto.ts
Normal file
22
apps/server/src/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
}
|
||||
5
apps/server/src/auth/guards/jwt-auth.guard.ts
Normal file
5
apps/server/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
34
apps/server/src/auth/guards/permission.guard.ts
Normal file
34
apps/server/src/auth/guards/permission.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// 1. @Public() 豁免
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
// 2. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(
|
||||
PERMISSION_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
// 无装饰器 = 默认拒绝
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return false;
|
||||
|
||||
// 3. 从 JWT payload 获取用户权限
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
|
||||
|
||||
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
return requiredPermissions.some(p => user.permissions.includes(p));
|
||||
}
|
||||
}
|
||||
23
apps/server/src/auth/strategies/jwt.strategy.ts
Normal file
23
apps/server/src/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user