Skip to content

🔐 Node.js 技术选型(六):认证与授权 — JWT · OAuth2 · RBAC 权限体系

系列导读:安全是后端的底线。 本篇从 Session vs JWT 的认证方案对比出发,深入 OAuth2 流程、RBAC 权限设计, 搭建一个完整的企业级认证授权体系。


🔑 1. 认证 vs 授权

认证(Authentication):你是谁?
   → 登录验证 → 身份确认

授权(Authorization):你能干什么?
   → 权限检查 → 访问控制

完整流程:
用户登录 → 认证成功 → 颁发令牌 → 携带令牌请求 → 验证令牌 → 检查权限 → 返回数据

🆚 2. Session vs JWT

维度SessionJWT
存储位置服务端(内存/Redis)客户端(Cookie/LocalStorage)
服务端状态有状态(Stateful)无状态(Stateless)
扩展性🟡 需要共享 Session(Redis)🟢 天然支持分布式
安全性🟢 服务端可随时吊销🟡 无法主动吊销(过期前有效)
性能🟡 每次查 Redis🟢 本地验证签名即可
跨域🟡 需要 CORS 配置🟢 Header 传递,跨域友好
移动端🟡 Cookie 支持不佳🟢 完美支持
适用场景传统 Web 应用SPA / 移动端 / 微服务

JWT 结构解析

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.sH1k9gZ6k5tJE7G7T2YN3Q
│                     │                     │
│     Header          │     Payload         │    Signature
│  {"alg":"HS256"}    │  {"sub":"123"...}   │  HMAC(header.payload, secret)
typescript
// JWT Payload 建议结构
{
  sub: '1234567890',     // 用户 ID
  email: 'user@test.com',
  role: 'admin',
  iat: 1709000000,       // 签发时间
  exp: 1709604800,       // 过期时间
}

🛡 3. JWT 认证实战(NestJS)

3.1 注册与登录

typescript
// auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import * as bcrypt from 'bcrypt'

@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  // 注册
  async register(dto: RegisterDto) {
    const existingUser = await this.userService.findByEmail(dto.email)
    if (existingUser) throw new UnauthorizedException('邮箱已注册')

    const hashedPassword = await bcrypt.hash(dto.password, 12)
    const user = await this.userService.create({
      ...dto,
      password: hashedPassword,
    })

    return this.generateTokens(user)
  }

  // 登录
  async login(dto: LoginDto) {
    const user = await this.userService.findByEmail(dto.email)
    if (!user) throw new UnauthorizedException('邮箱或密码错误')

    const isPasswordValid = await bcrypt.compare(dto.password, user.password)
    if (!isPasswordValid) throw new UnauthorizedException('邮箱或密码错误')

    return this.generateTokens(user)
  }

  // 生成双 Token
  private async generateTokens(user: User) {
    const payload = { sub: user.id, email: user.email, role: user.role }

    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(payload, {
        secret: process.env.JWT_ACCESS_SECRET,
        expiresIn: '15m',          // Access Token 短期有效
      }),
      this.jwtService.signAsync(payload, {
        secret: process.env.JWT_REFRESH_SECRET,
        expiresIn: '7d',           // Refresh Token 长期有效
      }),
    ])

    return { accessToken, refreshToken }
  }

  // 刷新 Token
  async refreshTokens(refreshToken: string) {
    try {
      const payload = await this.jwtService.verifyAsync(refreshToken, {
        secret: process.env.JWT_REFRESH_SECRET,
      })
      const user = await this.userService.findById(payload.sub)
      return this.generateTokens(user)
    } catch {
      throw new UnauthorizedException('Refresh Token 无效或已过期')
    }
  }
}

3.2 JWT 守卫

typescript
// jwt.strategy.ts
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { ExtractJwt, Strategy } from 'passport-jwt'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_ACCESS_SECRET,
    })
  }

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

// 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)
  }
}

// public.decorator.ts
import { SetMetadata } from '@nestjs/common'
export const IS_PUBLIC_KEY = 'isPublic'
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true)

3.3 使用

typescript
// 全局启用 JWT 守卫
// main.ts 或 app.module.ts
app.useGlobalGuards(new JwtAuthGuard());

// controller
@Controller('users')
export class UserController {
  @Public()                       // 公开接口,不需要登录
  @Post('register')
  register(@Body() dto: RegisterDto) {}

  @Public()
  @Post('login')
  login(@Body() dto: LoginDto) {}

  @Get('profile')                 // 需要登录
  getProfile(@CurrentUser() user: User) {
    return user
  }
}

🌍 4. OAuth2 第三方登录

4.1 OAuth2 授权码流程

用户                    你的应用               第三方(GitHub/微信)
 │                        │                        │
 │─── 1. 点击登录 ──────→│                        │
 │                        │── 2. 重定向到授权页 ──→│
 │←── 3. 用户授权 ─────────────────────────────→│
 │                        │←─ 4. 返回授权码 code ──│
 │                        │── 5. 用 code 换 token →│
 │                        │←─ 6. 返回 access_token │
 │                        │── 7. 用 token 获取用户 →│
 │                        │←─ 8. 返回用户信息 ──────│
 │←── 9. 登录成功 ────────│                        │

4.2 GitHub OAuth 实战

typescript
// auth.controller.ts
@Controller('auth')
export class AuthController {
  @Get('github')
  @Public()
  githubLogin(@Res() res: Response) {
    const githubAuthUrl = `https://github.com/login/oauth/authorize?` +
      `client_id=${process.env.GITHUB_CLIENT_ID}` +
      `&redirect_uri=${process.env.GITHUB_CALLBACK_URL}` +
      `&scope=user:email`
    res.redirect(githubAuthUrl)
  }

  @Get('github/callback')
  @Public()
  async githubCallback(@Query('code') code: string) {
    // 1. 用 code 换 access_token
    const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
      method: 'POST',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: process.env.GITHUB_CLIENT_ID,
        client_secret: process.env.GITHUB_CLIENT_SECRET,
        code,
      }),
    })
    const { access_token } = await tokenRes.json()

    // 2. 用 access_token 获取用户信息
    const userRes = await fetch('https://api.github.com/user', {
      headers: { Authorization: `Bearer ${access_token}` },
    })
    const githubUser = await userRes.json()

    // 3. 查找或创建本地用户
    let user = await this.userService.findByGithubId(githubUser.id)
    if (!user) {
      user = await this.userService.create({
        githubId: githubUser.id,
        name: githubUser.name,
        email: githubUser.email,
        avatar: githubUser.avatar_url,
      })
    }

    // 4. 生成 JWT
    return this.authService.generateTokens(user)
  }
}

👥 5. RBAC 权限体系设计

5.1 权限模型

RBAC(Role-Based Access Control):基于角色的访问控制

用户 ──→ 角色 ──→ 权限

User          Role           Permission
┌──────┐     ┌──────┐      ┌──────────────┐
│ Alice │──→  │ Admin │──→  │ user:create  │
└──────┘     └──────┘      │ user:read    │
┌──────┐     ┌──────┐      │ user:update  │
│  Bob  │──→  │ Editor│──→  │ user:delete  │
└──────┘     └──────┘      │ post:create  │
                            │ post:read    │
                            │ post:update  │
                            │ post:delete  │
                            └──────────────┘

5.2 数据模型

prisma
// schema.prisma
model User {
  id       Int    @id @default(autoincrement())
  email    String @unique
  roles    UserRole[]
}

model Role {
  id          Int          @id @default(autoincrement())
  name        String       @unique   // admin / editor / viewer
  description String?
  permissions RolePermission[]
  users       UserRole[]
}

model Permission {
  id          Int              @id @default(autoincrement())
  resource    String           // user / post / order
  action      String           // create / read / update / delete
  roles       RolePermission[]

  @@unique([resource, action])
}

model UserRole {
  user   User @relation(fields: [userId], references: [id])
  userId Int
  role   Role @relation(fields: [roleId], references: [id])
  roleId Int

  @@id([userId, roleId])
}

model RolePermission {
  role         Role       @relation(fields: [roleId], references: [id])
  roleId       Int
  permission   Permission @relation(fields: [permissionId], references: [id])
  permissionId Int

  @@id([roleId, permissionId])
}

5.3 权限守卫实现

typescript
// permissions.decorator.ts
import { SetMetadata } from '@nestjs/common'

export const PERMISSIONS_KEY = 'permissions'
export const RequirePermissions = (...permissions: string[]) =>
  SetMetadata(PERMISSIONS_KEY, permissions)

// permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private userService: UserService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredPermissions = this.reflector.get<string[]>(
      PERMISSIONS_KEY,
      context.getHandler(),
    )
    if (!requiredPermissions) return true

    const { user } = context.switchToHttp().getRequest()
    const userWithRoles = await this.userService.findWithPermissions(user.id)

    // 收集用户所有权限
    const userPermissions = new Set<string>()
    for (const userRole of userWithRoles.roles) {
      for (const rolePermission of userRole.role.permissions) {
        const { resource, action } = rolePermission.permission
        userPermissions.add(`${resource}:${action}`)
      }
    }

    // 检查是否拥有所有必需权限
    return requiredPermissions.every((p) => userPermissions.has(p))
  }
}

// 使用
@Controller('users')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class UserController {
  @Get()
  @RequirePermissions('user:read')
  findAll() {}

  @Post()
  @RequirePermissions('user:create')
  create() {}

  @Delete(':id')
  @RequirePermissions('user:delete')
  delete() {}
}

🔒 6. 安全最佳实践清单

维度做法
密码存储bcrypt 哈希,salt rounds ≥ 12
JWT Secret至少 256 位随机字符串,环境变量存储
Access Token短有效期(15-30 分钟)
Refresh Token长有效期(7-30 天),可吊销
HTTPS生产环境必须全站 HTTPS
限频登录接口限制每 IP 每分钟 5 次
敏感操作二次验证(邮箱/短信验证码)
日志记录登录、权限变更等安全事件

✅ 本篇重点 Checklist


安全不是可选项,是必选项。 下一篇我们聊 缓存与消息队列 — Redis · Bull · RabbitMQ


📝 作者:NIHoa | 系列:Node.js技术选型与架构系列 | 更新日期:2025-02-06