Skip to content

认证 (JWT)

概述

ACE NestJS Starter 实现了基于 JWT (JSON Web Token) 的认证系统,提供安全的用户身份验证和会话管理。

核心特性

  • 双令牌机制: Access Token + Refresh Token
  • 安全存储: HttpOnly Cookie 防止 XSS 攻击
  • 自动刷新: 无感知令牌续期
  • 多设备支持: 设备级别的会话管理
  • 黑名单机制: 支持令牌撤销

认证流程

mermaid
sequenceDiagram
    participant Client
    participant Server
    participant Database
    participant Redis

    Client->>Server: POST /auth/login (credentials)
    Server->>Database: Validate user credentials
    Database-->>Server: User data
    Server->>Server: Generate tokens
    Server->>Redis: Store refresh token
    Server-->>Client: Access + Refresh tokens

    Client->>Server: GET /api/resource (Access token)
    Server->>Server: Validate access token
    Server-->>Client: Protected resource

    Client->>Server: POST /auth/refresh (Refresh token)
    Server->>Redis: Validate refresh token
    Redis-->>Server: Token valid
    Server->>Server: Generate new access token
    Server-->>Client: New access token

实现细节

JWT 策略配置

typescript
// src/auth/strategies/jwt.strategy.ts
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(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('jwt.accessSecret'),
    });
  }

  async validate(payload: any) {
    return {
      userId: payload.sub,
      email: payload.email,
      role: payload.role,
    };
  }
}

认证服务

typescript
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '@/prisma/prisma.service';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
    private configService: ConfigService,
    private redisService: RedisService,
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const isPasswordValid = await bcrypt.compare(password, user.password);

    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const { password: _, ...result } = user;
    return result;
  }

  async login(user: any, deviceInfo?: DeviceInfo) {
    const payload = {
      sub: user.id,
      email: user.email,
      role: user.role,
    };

    const sessionId = uuidv4();

    const [accessToken, refreshToken] = await Promise.all([
      this.generateAccessToken(payload),
      this.generateRefreshToken({ ...payload, sessionId }),
    ]);

    // 存储刷新令牌到 Redis
    await this.redisService.set(
      `refresh_token:${user.id}:${sessionId}`,
      refreshToken,
      this.configService.get('jwt.refreshTtl'),
    );

    // 记录设备信息
    if (deviceInfo) {
      await this.saveDeviceInfo(user.id, sessionId, deviceInfo);
    }

    return {
      accessToken,
      refreshToken,
      expiresIn: this.configService.get('jwt.accessTtl'),
      tokenType: 'Bearer',
      user: {
        id: user.id,
        email: user.email,
        username: user.username,
        role: user.role,
      },
    };
  }

  async generateAccessToken(payload: any) {
    return this.jwtService.sign(payload, {
      secret: this.configService.get('jwt.accessSecret'),
      expiresIn: this.configService.get('jwt.accessTtl'),
    });
  }

  async generateRefreshToken(payload: any) {
    return this.jwtService.sign(payload, {
      secret: this.configService.get('jwt.refreshSecret'),
      expiresIn: this.configService.get('jwt.refreshTtl'),
    });
  }

  async refreshToken(refreshToken: string) {
    try {
      const payload = this.jwtService.verify(refreshToken, {
        secret: this.configService.get('jwt.refreshSecret'),
      });

      // 验证 Redis 中的令牌
      const storedToken = await this.redisService.get(
        `refresh_token:${payload.sub}:${payload.sessionId}`,
      );

      if (!storedToken || storedToken !== refreshToken) {
        throw new UnauthorizedException('Invalid refresh token');
      }

      // 生成新的访问令牌
      const newAccessToken = await this.generateAccessToken({
        sub: payload.sub,
        email: payload.email,
        role: payload.role,
      });

      return {
        accessToken: newAccessToken,
        expiresIn: this.configService.get('jwt.accessTtl'),
        tokenType: 'Bearer',
      };
    } catch (error) {
      throw new UnauthorizedException('Invalid refresh token');
    }
  }

  async logout(userId: string, sessionId?: string) {
    if (sessionId) {
      // 登出特定设备
      await this.redisService.del(`refresh_token:${userId}:${sessionId}`);
    } else {
      // 登出所有设备
      const pattern = `refresh_token:${userId}:*`;
      const keys = await this.redisService.keys(pattern);
      if (keys.length > 0) {
        await this.redisService.del(...keys);
      }
    }

    return { message: 'Logged out successfully' };
  }

  async revokeToken(token: string) {
    // 将令牌加入黑名单
    const decoded = this.jwtService.decode(token) as any;
    const ttl = decoded.exp - Math.floor(Date.now() / 1000);

    if (ttl > 0) {
      await this.redisService.set(`blacklist:${token}`, '1', ttl);
    }
  }

  async isTokenBlacklisted(token: string): Promise<boolean> {
    const result = await this.redisService.get(`blacklist:${token}`);
    return result === '1';
  }
}

认证控制器

typescript
// src/auth/auth.controller.ts
import {
  Controller,
  Post,
  Body,
  UseGuards,
  Request,
  HttpCode,
  HttpStatus,
  Headers,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginDto, RegisterDto, RefreshTokenDto } from './dto';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { Public } from './decorators/public.decorator';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Public()
  @Post('register')
  async register(@Body() dto: RegisterDto) {
    return this.authService.register(dto);
  }

  @Public()
  @UseGuards(LocalAuthGuard)
  @Post('login')
  @HttpCode(HttpStatus.OK)
  async login(
    @Request() req,
    @Headers('user-agent') userAgent: string,
    @Headers('x-device-id') deviceId: string,
  ) {
    const deviceInfo = {
      userAgent,
      deviceId: deviceId || 'default',
      ip: req.ip,
    };

    return this.authService.login(req.user, deviceInfo);
  }

  @Public()
  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  async refresh(@Body() dto: RefreshTokenDto) {
    return this.authService.refreshToken(dto.refreshToken);
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout')
  @HttpCode(HttpStatus.OK)
  async logout(@Request() req, @Headers('x-session-id') sessionId: string) {
    return this.authService.logout(req.user.userId, sessionId);
  }

  @UseGuards(JwtAuthGuard)
  @Post('logout-all')
  @HttpCode(HttpStatus.OK)
  async logoutAll(@Request() req) {
    return this.authService.logout(req.user.userId);
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  async getProfile(@Request() req) {
    return this.authService.getProfile(req.user.userId);
  }

  @UseGuards(JwtAuthGuard)
  @Post('change-password')
  @HttpCode(HttpStatus.OK)
  async changePassword(@Request() req, @Body() dto: ChangePasswordDto) {
    return this.authService.changePassword(req.user.userId, dto);
  }
}

守卫实现

typescript
// src/auth/guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }

  handleRequest(err, user, info, context) {
    if (err || !user) {
      throw err || new UnauthorizedException('Unauthorized');
    }

    // 检查令牌是否在黑名单中
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (token) {
      const authService = request.app.get(AuthService);
      if (authService.isTokenBlacklisted(token)) {
        throw new UnauthorizedException('Token has been revoked');
      }
    }

    return user;
  }

  private extractTokenFromHeader(request: any): string | null {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : null;
  }
}

高级功能

多因素认证 (MFA)

typescript
// src/auth/mfa/mfa.service.ts
import { Injectable } from '@nestjs/common';
import * as speakeasy from 'speakeasy';
import * as qrcode from 'qrcode';

@Injectable()
export class MfaService {
  generateSecret(user: User) {
    const secret = speakeasy.generateSecret({
      name: `ACE App (${user.email})`,
      issuer: 'ACE NestJS',
    });

    return {
      secret: secret.base32,
      otpauthUrl: secret.otpauth_url,
    };
  }

  async generateQRCode(otpauthUrl: string) {
    return qrcode.toDataURL(otpauthUrl);
  }

  verifyToken(secret: string, token: string) {
    return speakeasy.totp.verify({
      secret,
      encoding: 'base32',
      token,
      window: 2,
    });
  }

  async enableMfa(userId: string, secret: string, token: string) {
    const isValid = this.verifyToken(secret, token);

    if (!isValid) {
      throw new BadRequestException('Invalid verification code');
    }

    await this.prisma.user.update({
      where: { id: userId },
      data: {
        mfaSecret: secret,
        mfaEnabled: true,
      },
    });

    // 生成备用码
    const backupCodes = this.generateBackupCodes();
    await this.saveBackupCodes(userId, backupCodes);

    return { backupCodes };
  }

  private generateBackupCodes(count = 10): string[] {
    return Array.from({ length: count }, () =>
      Math.random().toString(36).substr(2, 10).toUpperCase(),
    );
  }
}

OAuth 社交登录

typescript
// src/auth/strategies/google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
  ) {
    super({
      clientID: configService.get('oauth.google.clientId'),
      clientSecret: configService.get('oauth.google.clientSecret'),
      callbackURL: configService.get('oauth.google.callbackUrl'),
      scope: ['email', 'profile'],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: any,
    done: VerifyCallback,
  ): Promise<any> {
    const { emails, photos, displayName } = profile;

    const user = await this.authService.validateOAuthUser({
      email: emails[0].value,
      username: displayName,
      avatar: photos[0].value,
      provider: 'google',
      providerId: profile.id,
    });

    done(null, user);
  }
}

会话管理

typescript
// src/auth/session/session.service.ts
@Injectable()
export class SessionService {
  constructor(
    private redisService: RedisService,
    private prisma: PrismaService,
  ) {}

  async getUserSessions(userId: string) {
    const pattern = `session:${userId}:*`;
    const keys = await this.redisService.keys(pattern);

    const sessions = await Promise.all(
      keys.map(async (key) => {
        const data = await this.redisService.get(key);
        return JSON.parse(data);
      }),
    );

    return sessions.map((session) => ({
      id: session.id,
      deviceName: session.deviceName,
      lastActivity: session.lastActivity,
      ip: session.ip,
      userAgent: session.userAgent,
      current: session.current,
    }));
  }

  async terminateSession(userId: string, sessionId: string) {
    await this.redisService.del(`session:${userId}:${sessionId}`);
    await this.redisService.del(`refresh_token:${userId}:${sessionId}`);
  }

  async updateSessionActivity(userId: string, sessionId: string) {
    const key = `session:${userId}:${sessionId}`;
    const session = await this.redisService.get(key);

    if (session) {
      const data = JSON.parse(session);
      data.lastActivity = new Date();
      await this.redisService.set(key, JSON.stringify(data));
    }
  }
}

安全最佳实践

1. 密码策略

typescript
// src/auth/validators/password.validator.ts
export class PasswordValidator {
  static validate(password: string): ValidationResult {
    const errors = [];

    if (password.length < 8) {
      errors.push('Password must be at least 8 characters');
    }

    if (!/[A-Z]/.test(password)) {
      errors.push('Password must contain uppercase letter');
    }

    if (!/[a-z]/.test(password)) {
      errors.push('Password must contain lowercase letter');
    }

    if (!/[0-9]/.test(password)) {
      errors.push('Password must contain number');
    }

    if (!/[!@#$%^&*]/.test(password)) {
      errors.push('Password must contain special character');
    }

    return {
      isValid: errors.length === 0,
      errors,
    };
  }

  static generateSecurePassword(): string {
    const chars =
      'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
    let password = '';

    for (let i = 0; i < 16; i++) {
      password += chars.charAt(Math.floor(Math.random() * chars.length));
    }

    return password;
  }
}

2. 速率限制

typescript
// src/auth/decorators/throttle.decorator.ts
export function AuthThrottle() {
  return applyDecorators(
    Throttle({
      default: {
        limit: 5,
        ttl: 60000, // 1 分钟内最多 5 次尝试
      },
    }),
  );
}

// 使用
@AuthThrottle()
@Post('login')
async login() {
  // ...
}

3. 账户锁定

typescript
async handleFailedLogin(email: string) {
  const key = `failed_login:${email}`;
  const attempts = await this.redisService.incr(key);

  if (attempts === 1) {
    await this.redisService.expire(key, 900); // 15 分钟
  }

  if (attempts >= 5) {
    await this.lockAccount(email);
    throw new ForbiddenException('Account locked due to multiple failed attempts');
  }

  throw new UnauthorizedException(`Invalid credentials. ${5 - attempts} attempts remaining`);
}

async lockAccount(email: string) {
  await this.prisma.user.update({
    where: { email },
    data: {
      lockedUntil: new Date(Date.now() + 30 * 60 * 1000), // 锁定 30 分钟
    },
  });

  // 发送邮件通知
  await this.emailService.sendAccountLockNotification(email);
}

测试认证功能

typescript
describe('AuthController (e2e)', () => {
  let app: INestApplication;
  let accessToken: string;
  let refreshToken: string;

  beforeAll(async () => {
    // 初始化测试应用
  });

  describe('/auth/register', () => {
    it('should register a new user', () => {
      return request(app.getHttpServer())
        .post('/auth/register')
        .send({
          email: 'test@example.com',
          username: 'testuser',
          password: 'Test123!@#',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body).toHaveProperty('id');
          expect(res.body.email).toBe('test@example.com');
        });
    });
  });

  describe('/auth/login', () => {
    it('should login successfully', () => {
      return request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: 'test@example.com',
          password: 'Test123!@#',
        })
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('accessToken');
          expect(res.body).toHaveProperty('refreshToken');
          accessToken = res.body.accessToken;
          refreshToken = res.body.refreshToken;
        });
    });
  });

  describe('/auth/refresh', () => {
    it('should refresh access token', () => {
      return request(app.getHttpServer())
        .post('/auth/refresh')
        .send({ refreshToken })
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('accessToken');
          expect(res.body.accessToken).not.toBe(accessToken);
        });
    });
  });

  describe('/auth/profile', () => {
    it('should get user profile', () => {
      return request(app.getHttpServer())
        .get('/auth/profile')
        .set('Authorization', `Bearer ${accessToken}`)
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('email');
          expect(res.body.email).toBe('test@example.com');
        });
    });
  });
});

下一步

Released under the MIT License.