Nestjs商城实战---基于双Token+ Redis的JWT认证方案实现无感刷新与安全防护

354 阅读4分钟

1. 实现目标

在现代Web应用中,我们需要一个既安全又用户友好的认证系统。具体来说,我们要实现:

  1. 安全的身份认证
  2. 无感知的token刷新
  3. 防范常见的安全威胁(XSS、CSRF等)
  4. 支持多端同步登录状态
  5. 支持主动登出和token失效

该文章中的完整代码在ibuy-portal-backend

2. 方案选择

2.1 基础JWT认证

我们知道JWT是无状态的,他一般会经过后端setCookie到浏览器,或者经过后端api交给前端,使用localStorage保存。并且为了保证安全,一般都会将token的过期时间设置的尽量短,也会带来用户体验问题。下面是总结的问题。

  1. 安全性问题
    • token存储在前端,容易被XSS攻击窃取
    • 一旦泄露,在过期前都是有效的
    • 无法主动使token失效, 难以实现主动踢出用户
  1. 用户体验问题
    • token过期后用户需要重新登录
    • 多个请求同时失效时会重复刷新

2.2 双Token + Redis方案的优势

通过双token + token的版本控制 + redis我们可以从下面三个方面来改善问题

  1. 提升安全性
    • Refresh Token通过httpOnly cookie存储,避免XSS攻击
    • 采用Redis白名单机制,支持token主动失效
    • 通过Token版本控制,支持强制登出
  1. 优化用户体验
    • Access Token过期时自动使用Refresh Token刷新
    • 并发请求队列,避免重复刷新

3. 图解流程

3.1. 整体架构

graph TD
    subgraph 前端
        A[用户界面] -->|1.提交账号密码| B[登录请求]
        B -->|2.存储到本地| C[[AccessToken]]
        C -->|3.携带请求| D[业务API]
        D -->|4.返回401| E[触发刷新]
        E -->|5.读取Cookie| F[[RefreshToken]]
        F -->|6.获取新Token| C
    end

    subgraph 后端服务
        G[认证模块] -->|a.验证用户| H[(数据库)]
        G -->|b.生成令牌| I[[JWT模块]]
        G -->|c.存储白名单| J[(Redis)]
        J -->|d.校验有效性| G
    end

    subgraph 安全机制
        K[HTTP-only] --> F
        L[SameSite=lax] --> F
        M[HTTPS] --> B
        N[Token版本控制] --> H
    end

    B --> G
    E --> G
    G -->|Set-Cookie| F
    D --> G
    J -.->|自动清理| G

3.2. 登录流程

sequenceDiagram
    participant 用户
    participant 前端
    participant 后端
    participant Redis
    用户->>前端: 提交账号密码
    前端->>后端: POST /auth/login
    后端->>数据库: 验证用户凭证
    数据库-->>后端: 返回用户数据
    后端->>JWT: 生成AccessToken(30m)
    后端->>JWT: 生成RefreshToken(7d)
    后端->>Redis: 存储RefreshToken
    后端->>前端: 返回AccessToken + Set-Cookie
    前端->>本地存储: 保存AccessToken

3.3. 登出流程

sequenceDiagram
    participant 用户
    participant 前端
    participant 后端
    participant Redis
    用户->>前端: 点击登出
    前端->>后端: POST /auth/logout
    后端->>Redis: 删除RefreshToken记录
    后端->>数据库: 递增tokenVersion
    后端-->>前端: 清除Cookie指令
    前端->>本地存储: 清除AccessToken

3.4. token自动刷新流程

sequenceDiagram
    participant 前端
    participant 后端
    participant Redis
    前端->>后端: 携带过期AccessToken发起请求
    后端-->>前端: 返回401状态码
    前端->>后端: 携带RefreshToken请求刷新
    后端->>Redis: 验证RefreshToken有效性
    Redis-->>后端: 返回验证结果
    后端->>JWT: 生成新AccessToken
    后端->>JWT: 生成新RefreshToken
    后端->>Redis: 更新RefreshToken
    后端->>前端: 返回新AccessToken + Set-Cookie
    前端->>本地存储: 更新AccessToken

4. 代码整体实现

4.1 后端实现

4.1.1 用户实体设计

// src/mall-service/mall-service-system/users/entitys/sys-user.entity.ts
@Entity('ibuy_admin')
export class SysUsersEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ name: 'login_name', unique: true })
  loginName: string;

  @Column()
  password: string;

  @Column()
  status: string;

  @Column({ default: 0 })
  tokenVersion: number; // token版本号,用于使token失效
}

4.1.2 认证服务核心实现

// src/mall-service/mall-service-system/auth/auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
    @InjectRedis() private readonly redis: Redis,
    private configService: ConfigService,
  ) {
    this.accessTokenExpiresIn = this.configService.get('JWT_ACCESS_EXPIRES_IN');
    this.refreshTokenExpiresIn = this.configService.get(
      'JWT_REFRESH_EXPIRES_IN',
    );
  }

  // Redis白名单管理
  private async storeRefreshToken(
    userId: number,
    refreshToken: string,
  ): Promise<void> {
    const key = `refresh_token:${userId}`;
    await this.redis.set(
      key,
      refreshToken,
      'EX',
      parseTimeToSeconds(this.refreshTokenExpiresIn),
    );
  }

  private async isRefreshTokenValid(
    userId: number,
    refreshToken: string,
  ): Promise<boolean> {
    const storedToken = await this.redis.get(`refresh_token:${userId}`);
    return storedToken === refreshToken;
  }

  // Cookie安全配置
  private setCookies(res: Response, refreshToken: string): void {
    res.cookie('refresh_token', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'development' ? false : true,
      sameSite: 'lax',
      maxAge: parseTimeToSeconds(this.refreshTokenExpiresIn) * 1000,
    });
  }

  // 登录实现
  async signIn(
    loginName: string,
    pass: string,
    res: Response,
  ): Promise<Result<{ access_token: string; userInfo: any }>> {
    // 验证用户
    const { data: user } = await this.usersService.findByLoginName(loginName);
    const isMatch = await bcrypt.compare(pass, user?.password);
    if (!isMatch) throw new UnauthorizedException();

    // 生成token
    const payload = {
      user_id: user.id,
      loginName: user.loginName,
      tokenVersion: user.tokenVersion,
    };

    const access_token = await this.jwtService.signAsync(payload, {
      expiresIn: this.accessTokenExpiresIn,
    });
    const refresh_token = await this.jwtService.sign(payload, {
      expiresIn: this.refreshTokenExpiresIn,
    });

    // 存储refresh token
    await this.storeRefreshToken(user.id, refresh_token);
    this.setCookies(res, refresh_token);

    return new Result(
      {
        access_token,
        userInfo: {
          id: user.id,
          loginName: user.loginName,
          roles: userRoles,
        },
      },
      '登录成功',
    );
  }

  // 刷新token
  async refreshToken(refreshToken: string, res: Response) {
    try {
      // 验证token
      const decoded = this.jwtService.verify(refreshToken);
      const userId = decoded.user_id;

      // 验证白名单和版本号
      const isValid = await this.isRefreshTokenValid(userId, refreshToken);
      if (!isValid) {
        throw new UnauthorizedException('Refresh token不在白名单中');
      }

      const { data: user } = await this.usersService.findById(userId);
      if (decoded.tokenVersion !== user.tokenVersion) {
        throw new UnauthorizedException('Token版本已失效');
      }

      // 生成新token
      const payload = {
        user_id: user.id,
        loginName: user.loginName,
        tokenVersion: user.tokenVersion,
      };

      const access_token = this.jwtService.sign(payload, {
        expiresIn: this.accessTokenExpiresIn,
      });
      const new_refresh_token = this.jwtService.sign(payload, {
        expiresIn: this.refreshTokenExpiresIn,
      });

      // 更新Redis和Cookie
      await this.invalidateRefreshToken(userId);
      await this.storeRefreshToken(userId, new_refresh_token);
      this.setCookies(res, new_refresh_token);

      return new Result({ access_token }, 'Token刷新成功');
    } catch (e) {
      throw new UnauthorizedException(
        e instanceof TokenExpiredError ? 'token已过期' : e,
      );
    }
  }

  // 登出实现
  async logout(userId: number): Promise<void> {
    await this.invalidateRefreshToken(userId);
    await this.usersService.incrementTokenVersion(userId);
  }
}

4.1.3 控制器实现

// src/mall-service/mall-service-system/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Public()
  @HttpCode(HttpStatus.OK)
  @Post('login')
  async signIn(
    @Body() user: Record<string, any>,
    @Res({ passthrough: true }) res: Response,
  ) {
    return this.authService.signIn(user.loginName, user.password, res);
  }

  @Public()
  @Get('refresh')
  async refresh(@Request() req, @Res({ passthrough: true }) res: Response) {
    const refreshToken = req.cookies.refresh_token;
    if (!refreshToken) {
      throw new UnauthorizedException('No refresh token provided');
    }
    return this.authService.refreshToken(refreshToken, res);
  }

  @Post('logout')
  async logout(@Request() req, @Res({ passthrough: true }) res: Response) {
    const userId = req.user.user_id;
    await this.authService.logout(userId);
    res.clearCookie('refresh_token');
    return new Result(null, '退出登录成功');
  }
}

4.1.4 工具类

export default function parseTimeToSeconds(time) {
  const units = {
    s: 1,
    m: 60,
    h: 60 * 60,
    d: 24 * 60 * 60,
  };

  const unit = time.slice(-1);
  const value = parseInt(time.slice(0, -1), 10);

  if (isNaN(value) || !units[unit]) {
    throw new Error('Invalid time format');
  }

  return value * units[unit];
}

4.2. 前端实现

4.2.1 请求拦截器

// 请求拦截器:自动添加token
const authHeaderInterceptor = (url: string, options: RequestConfig) => {
  const Token = localStorage.getItem('token');
  const authHeader = { Authorization: `Bearer ${Token}` };
  return {
    url: `${url}`,
    options: { ...options, interceptors: true, headers: authHeader },
  };
};

// 响应拦截器配置
export const request: RequestConfig = {
  timeout: 1000 * 30,
  errorConfig: {
    errorHandler: async (error: any) => {
      if (error.response?.status === 401) {
        return handleRefreshToken(error);
      }
      throw error;
    },
  },
  requestInterceptors: [authHeaderInterceptor],
};

4.2.2 Token刷新处理

let isRefreshing = false;
let requests: any[] = [];

const handleRefreshToken = async (error: any) => {
  const { response } = error;

  if (!isRefreshing) {
    isRefreshing = true;
    try {
      // 刷新token
      const result = await refreshToken();
      if (result?.data?.access_token) {
        localStorage.setItem('token', result.data.access_token);

        // 重试当前请求
        const originalRequest = response.config;
        originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
        const retryResponse = await axios(originalRequest.url, originalRequest);

        // 重试队列中的请求
        requests.forEach((cb) => cb());
        requests = [];

        return retryResponse;
      }
    } catch (refreshError) {
      history.push(loginPath);
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  }

  // 将请求加入队列
  return new Promise((resolve) => {
    requests.push(async () => {
      try {
        const originalRequest = response.config;
        originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`;
        const retryResponse = await axios(originalRequest.url, originalRequest);
        resolve(retryResponse);
      } catch (retryError) {
        resolve(Promise.reject(retryError));
      }
    });
  });
};

4.2.3 路由守卫

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  return {
    onPageChange: () => {
      const { location } = history;
      // 检查登录状态
      if (!localStorage.getItem('token') && location.pathname !== loginPath) {
        history.push(loginPath);
      }
    },
    // ... 其他配置
  };
};

4.3. 相关配置

4.3.1 环境变量

# JWT配置
JWT_SECRET=your-secret-key
JWT_ACCESS_EXPIRES_IN=30m
JWT_REFRESH_EXPIRES_IN=7d

# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379

4.3.2 NestJS模块配置

// src/mall-service/mall-service-system/auth/auth.module.ts
@Module({
  imports: [
    SysUserModule,
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        global: true,
        secret: configService.get('JWT_SECRET'),
      }),
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

5. 后续优化方向

  1. 功能扩展

    • 支持多设备登录管理
    • 添加登录设备限制
    • 实现登录日志追踪
  2. 性能优化

    • 引入token缓存机制
    • 优化并发请求处理
    • 添加限流保护
  3. 安全加强

    • 添加设备指纹验证
    • 支持2FA认证