系统安全第一步:身份验证和访问控制使用NestJS、JWT和Redis

627 阅读8分钟

引言

在现代Web应用程序中,实现有效的用户身份验证和访问控制是确保系统安全的关键一步。本文将介绍如何利用NestJS框架结合JWT和Redis,来实现安全的用户身份验证和访问控制功能。

整体思路时序图

sequenceDiagram
    participant Client
    participant Server
    participant Redis
    participant MySQL

    Note over Client,Server: 登录获取 Token 和 Redis 存储
    Client->>Server: 提交登录请求 (用户名和密码)
    Server->>MySQL: 验证用户身份
    MySQL-->>Server: 返回验证结果
    Server->>Server: 生成 JWT
    Server->>Redis: 存储 JWT
    Server-->>Client: 返回 JWT

    Note over Client,Server: 获取用户 Profile 信息
    Client->>Server: 请求获取用户 Profile (带 JWT)
    Server->>Server: 解析 JWT,获取用户 ID
    Server->>Redis: 查询缓存中的 JWT
    Redis-->>Server: 返回 JWT
    Server->>MySQL: 查询用户 Profile 信息
    MySQL-->>Server: 返回用户 Profile 信息
    Server-->>Client: 返回用户 Profile 信息

步骤

本文主要通过nestjs框架演示登录和接口验证,使用Docker快速构建:mysql,adminer,redis模拟真实开发环境下登录和接口访问控制,需要先对Docker构建mysql有一定的了解,如不了解请先阅读:《nestjs链接mysql操作数据》

1. Docker环境构建

为了方便使用,这些应用通过 Docker 进行安装,提前先把需要依赖的应用环境构建出来

  • Mysql 数据库,用于记录用户信息
  • adminer 可视化查询Mysql数据,方便查找
  • redis 记录用户登录状态下的token

配置文件docker-compose.yml

version: '3.1'
services:
  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example # password
      MYSQL_DATABASE: testdb # database name
    ports:
      - 3307:3306
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

  redis:
    image: redis:latest
    restart: always
    ports:
      - 6380:6379

应用对应链接的端口:

  • mysql:3307
  • adminer:8080
  • redis:6380

2.注册与登录

首先通过nest Cli快速创建user,auth模块和argon2密码登录

2.1 创建应用和user模块

使用nest Cli快速创建项目:auth-nest

nest new auth-nest

切换进项目安装mysql相关依赖包合创建User module模块相关代码

cd auth-nest
npm install --save @nestjs/typeorm typeorm mysql2
nest g mo user
nest g co user --no-spec
nest g s user --no-spec

还需要创建User实体类型:user.entity.ts:

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({ unique: true })
    username: string;

    @Column()
    password: string;

    @Column()
    age: number;
}

其中username设置为唯一值unique,当添加相同username记录时候, Mysql会报错误:Duplicate entry ....

2.2 链接mysql

这里使用TypeORM框架(ORM 对象关系映射)来建立应用程序与数据库之间的连接,需要Mysql有所了解,可以阅读之前文章:《nestjs链接mysql操作数据》

// src/app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3307,
      username: 'root',
      password: 'example',
      database: 'testdb',
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true,
    }),
    UserModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

2.3 启动环境

首先需要启动本地环境下的Docker应用,启动后在项目auth-nest根目录下执行脚本构建镜像和运行

 docker-compose up  --build -d 

up 为启动,--build 为构建镜像 , -d 为后台运行,启动在Docker应用可以看到容器状态:

image.png

接下来启动项目auth-nest

 npm run start:dev

2.4 添加创建Use的api

User table中有存储密码字段:password,这里使用argon2加密password字符串,加密通过:argon2

npm i argon2 --save

user.service.ts 中添加:创建user方法

// src/user/user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as argon2 from 'argon2';
import { User } from './user.entity';


@Injectable()
export class UserService {
    constructor(
        @InjectRepository(User)
        private usersRepository: Repository<User>,
    ) { }
    async create(user: User): Promise<{ success: boolean, message?: string }> {
        try {
            if (user.password) {
                user.password = await argon2.hash(user.password);
            }
            await this.usersRepository.save(user);
            return { success: true };
        } catch (error) {
            return { success: false, message: error.message };
        }
    }
}

2.5 创建用户

现在可以通过Post请求访问:localhost:3000/users,这里借用Postman来完成创建有密码的用户

image.png

创建成功后可以在adminer平台查看:http://localhost:8080/?server=db&username=root&db=testdb&select=user

image.png

可以看到密码已经创建成功

3. JWT认证登录

JWT(JSON Web Tokens)是一种用于双方之间安全传输信息的简洁的、URL安全的令牌标准。它是一个JSON对象,被编码为一个JWT,并可以作为一个令牌在HTTP环境中通过URL、POST参数或者在HTTP头部的方式安全传输

3.1 简单登录

这里我们先实现一个正常登录的接口和service逻辑,创建auth.dto.ts文件和定义接收登录参数的映射类:SignInDto

// src/auth/auth.dto.ts

export class SignInDto {
    username: string;
    password: string;
}

创建登录api接口:signIn

// src/auth/auth.controller.ts

import {
    Controller, Post, Body
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInDto } from './auth.dto'

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

    @Post('/signIn')
    async signIn(@Body() signInDto: SignInDto) {
        return this.authService.signIn(signInDto)
    }
}

AuthService 中创建 signIn方法:

// src/auth/auth.service.ts

import { Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
import { UserService } from 'src/user/user.service';
import { SignInDto } from './auth.dto'

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

    async signIn(user: SignInDto): Promise<{ success: boolean, message?: string }> {
        try {
            const existingUser = await this.userService.findOneByUsername(user.username);

            if (!existingUser) {
                return { success: false, message: 'User not found' };
            }

            const isPasswordValid = await argon2.verify(existingUser.password, user.password);

            if (!isPasswordValid) {
                return { success: false, message: 'Invalid password' };
            }

            return { success: true };
        } catch (error) {
            return { success: false, message: error.message };
        }
    }

}

在 user.service.ts 中需要添加个通过用户名查找用户的方法:findOneByUsername

// src/user/user.service.ts
+  async findOneByUsername(username: string): Promise<User> {
+       return this.usersRepository.findOne({ where: { username } });
+   }

测试登录:POST 请求,URL:localhost:3000/auth/signIn,返回:success:true登录成功

image.png

3.2 JWT生成登录令牌

首先我们需要安装适配Nestjs的JWT依赖包,

npm install --save @nestjs/jwt

在 auth.module 中引入 jwt Module

// src/auth/auth.module.ts

+ import { JwtModule } from '@nestjs/jwt';
+ import { jwtConstants } from './constants';
...
  imports: [
    TypeOrmModule.forFeature([User]),
+    JwtModule.register({
+      global: true,
+      secret: jwtConstants.secret,
+      signOptions: { expiresIn: '7d' },
+    })
    ],

其中 secret 为 src/auth/constants.ts中固定的密钥(需要保密存放)

// src/auth/constants.ts

export const jwtConstants = {
    secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

expiresIn为生成令牌的的时效性,这里设置7天后过期

现在需要添加登录成功后返回登录令牌:accessToken

// src/auth/auth.service.ts
+ import { JwtService } from '@nestjs/jwt';
...
export class AuthService {
    constructor(
        private userService: UserService,
+        private jwtService: JwtService
    ) { }
    
...
  if (!isPasswordValid) {
     return { success: false, message: 'Invalid password' };
  }

+  const payload = { sub: existingUser.id, username: existingUser.username };
+  return {
+   success: true,
+    data: {
+       accessToken: await this.jwtService.signAsync(payload)
+    }
+  };

通过登录用户的id,username生成令牌,利于认证接口解析令牌获取当前的用户信息

再次登录查看返回的accessToken:

image.png

3.3 添加AuthGuard

当前端成功登录并获取登录凭证后,就可以访问需认证的接口,服务认证与登录凭证使用nestjs 提供的守卫:Guard进行验证,这些需要先添加个src/guards/auth.guard.ts

// src/guards/auth.guard.ts

import {
    CanActivate,
    ExecutionContext,
    Injectable,
    UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from '../auth/constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
    constructor(private jwtService: JwtService) { }

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest();
        const token = this.extractTokenFromHeader(request);


        if (!token) {
            throw new UnauthorizedException();
        }
        try {
            const payload = await this.jwtService.verifyAsync(
                token,
                {
                    secret: jwtConstants.secret
                }
            );
            request['user'] = payload;
        } catch {
            throw new UnauthorizedException();
        }
        return true;
    }

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

其中 request['user'] = payload 把从token解析出来的用户信息回塞给request请求中,方便后续接口拿到当前访问人的信息

3.4 创建profile接口

结合JWT 生成的token 请求当前访问用户的profile,流程如下:

sequenceDiagram
    title 返回当前访问用户的profile

    participant 前端
    participant Guard
    participant Controller
    participant Service

    前端 ->> Guard: 带有 JWT token
    Guard ->> Controller: 解析出token中的user信息
    Controller ->> Service: 查询当前user profile
    Service -->> Controller: 返回当前 user profile
    Controller -->> 前端: 返回当前 user profile

创建profile接口

// src/auth/auth.controller.ts

import {
    Controller,
    Post,
    Body, 
    Request, 
+    Get,
+   UseGuards
} from '@nestjs/common';

+ import { AuthGuard } from '../guards/auth.guard';
...
+ @UseGuards(AuthGuard)
+    @Get('profile')
+    getProfile(@Request() req) {
+        const username = req.user?.username
+        return this.authService.getProfile(username)
+    }

这里通过UseGuards使用AuthGurad对profile接口进行请求拦截,其中AuthGuard主要作用:

  • request header 中拦截无效的token
  • 当token有效时,解析token中的用户信息并塞到request,提供给controller调用

接下来是在authService 中创建获取当前用户信息的:getProfile方法

// src/auth/auth.service.ts
...
+ async getProfile(username: string): Promise<{ success: boolean, data?: User, message?: string }> {
+        try {
+            const existingUser = await this.userService.findOneByUsername(username);
+            if (!existingUser) {
+                return { success: false, message: 'User not found' };
+            }
+            return {
+                success: true,
+                data: existingUser
+            };
+        } catch (error) {
+            return { success: false, message: error.message };
+       }
+    }

3.5 访问profile接口

这里使用postman进行访问,复制3.2步骤中登录返回的accessToken,创建:GET请求 localhost:3000/auth/profile,其中需要在Authorization 中 Bearer Token 中添加token

image.png

访问后可得到当前用户的详情

image.png

当把token去除后,AuthGuard将拒绝访问返回:

{
    "message": "Unauthorized",
    "statusCode": 401
}

4. Redis 持久化token

用户生成的token一般都会在前端存储,有被窃取的风险,一旦被窃取的token在未失效状态下可以请求认证接口而不被拦截,这时候可以使用Redis配合JWT做双重验证,实现思路:

  1. 当用户登录时:Redis存储key为username,value为token的记录,
  2. 当用户访问认证接口时:AuthGuard验证token通过后还需要对比 ,当前用户在Redis 对应的token值

4.1 项目配置Redis

首先安装 redis 相关依赖包

npm install @nestjs-modules/ioredis ioredis --save

创建 redis module模块

// src/redis/redis.module.ts

import { Module, Global } from '@nestjs/common';
import { RedisModule as NestRedisModule } from '@nestjs-modules/ioredis';

@Global()
@Module({
    imports: [
        NestRedisModule.forRootAsync({
            useFactory: () => {
                return {
                    config: {
                        url: `redis://localhost:6380`,
                    },
                };
            },
        }),
    ],
    exports: [NestRedisModule],
})
export class RedisModule { }

其中6380为docker中redis宿主机端口,在根module中引入 RedisModule

// src/app.module.ts
+ import { RedisModule } from './redis/redis.module';

 imports: [
+    RedisModule,
    ...
  ],

4.2 Redis记录accessToken

// src/auth/auth.service.ts
...
+ import { InjectRedis, Redis } from '@nestjs-modules/ioredis';
...

@Injectable()
export class AuthService {
    constructor(
        private userService: UserService,
        private jwtService: JwtService,
+        @InjectRedis() private readonly redisClient: Redis,
    ) { }
    ....
    
       async signIn(user: SignInDto): Promise<{ success: boolean, data?: { accessToken: string }, message?: string }> {
       ....
           const accessToken = await this.jwtService.signAsync(payload)
+          await this.redisClient.set(existingUser.username, accessToken);
       } 

重新通过postman登录用户成功后在Docker redis 查看登录产生的数据,步骤:

image.png

点击Terminal输入指令:

# redis-cli
KEYS *
GET NeoLuo

可以查看到对应的token

image.png

4.2 验证Redis中的token

在AuthGuard中当token验证通过后现在需要验证当前用户的token和Redis记录的token是否一致

// src/guards/auth.guard.ts
...
+ import { InjectRedis, Redis } from '@nestjs-modules/ioredis';

export class AuthGuard implements CanActivate {
    constructor(
        private jwtService: JwtService,
+        @InjectRedis() private readonly redis: Redis,
    ) { }
    
    async canActivate(context: ExecutionContext): Promise<boolean> {
    ...
+            const tokenCache = await this.redis.get(payload.username)
+            if (tokenCache !== token) {
+                throw new UnauthorizedException('Invalid Token!!!');
+            }
    ...
    }
}    

至此完成了JWT和Redis双重校验token

结论

在这篇博客中,我们详细讲解了如何使用 NestJS、JWT 和 Redis 实现用户认证和访问控制。通过配置 NestJS 项目,集成 JWT 进行身份验证,并使用 Redis 缓存用户数据,我们构建了一个高效、安全的认证系统。

本教程涵盖了用户登录、JWT 生成与验证、Redis 缓存使用,以及 Docker 容器配置等方面的内容。希望这些步骤能帮助你在实际项目中实现安全可靠的用户认证和访问控制。

后续优化

在实现基本的用户认证和访问控制之后,后续将介绍扩展用户权限控制:实现基于角色的访问控制(RBAC),以细化不同用户的权限。