NextJS实现用户的注册与登录

893 阅读10分钟

快速创建ContollerServiceModule以及DTO文件的方式:


nest g resource user

图片 这样我们就快速的创建了一个REST API的模块,里面简单的CRUD代码都已经实现了。

用户注册

在注册功能中,当用户是通过用户名和密码进行注册,密码我们不能直接存明文在数据库中,所以采用bcryptjs实现加密, 然后再存入数据库。

实现注册之前,先了解一下加密方案bcryptjs,安装一下依赖包:

npm install bcryptjs

bcryptjs 是nodejs中比较好的一款加盐(salt)加密的包, 我们处理密码加密、校验要使用到的两个方法:

/**
 * 加密处理 - 同步方法
 * bcryptjs.hashSync(data, salt)
 *    - data  要加密的数据
 *    - slat  用于哈希密码的盐。如果指定为数字,则将使用指定的轮数生成盐并将其使用。推荐 10
 */
const hashPassword = bcryptjs.hashSync(password, 10)


/**
 * 校验 - 使用同步方法
 * bcryptjs.compareSync(data, encrypted)
 *    - data        要比较的数据, 使用登录时传递过来的密码
 *    - encrypted   要比较的数据, 使用从数据库中查询出来的加密过的密码
 */
const isOk = bcryptjs.compareSync(password, encryptPassword)

接下来设计用户实体:

// use/entities/user.entity.ts
import { ColumnEntityPrimaryGeneratedColumn } from 'typeorm';

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

  @Column({ length100 })
  username: string; // 用户名

  @Column({ length100 })
  nickname: string;  //昵称

  @Column()
  password: string;  // 密码

  @Column()
  avatar: string;   //头像

  @Column()
  email: string;

  @Column('simple-enum', { enum: ['root''author''visitor'] })
  role: string;   // 用户角色

  @Column({
    name'create_time',
    type'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
  })
  createTime: Date;

  @Column({
    name'update_time',
    type'timestamp',
    default: () => 'CURRENT_TIMESTAMP',
  })
  updateTime: Date;
  
  @BeforeInsert() 
  async encryptPwd() { 
    this.password = await bcrypt.hashSync(this.password); 
  } 
}
  1. 在创建User实体, 使用@PrimaryGeneratedColumn('uuid')创建一个主列id,该值将使用uuid自动生成。Uuid 是一个独特的字符串;
  2. 实现字段名驼峰转下划线命名, createTimeupdateTime字段转为下划线命名方式存入数据库, 只需要在@Column装饰器中指定name属性;
  3. 我们使用了装饰器@BeforeInsert来装饰encryptPwd方法,表示该方法在数据插入之前调用,这样就能保证插入数据库的密码都是加密后的。
  4. 给博客系统设置了三种角色rootautor和 visitorroot有所以权限,author有写文章权限,visitor只能阅读文章, 注册的用户默认是visitor,root权限的账号可以修改用户角色。

接下来实现注册用户的业务逻辑

register 注册用户

实现user.service.ts逻辑:

import { User } from './entities/user.entity';
import { InjectableHttpExceptionHttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { CreateUserDto } from './dto/create-user.dto';
import { Repository } from 'typeorm';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}
  async register(createUser: CreateUserDto) {
    const { username } = createUser;

    const existUser = await this.userRepository.findOne({
      where: { username },
    });
    if(existUser){
        throw new HttpException("用户名已存在"HttpStatus.BAD_REQUEST)
    }

    const newUser = await this.userRepository.create(createUser)
    return await this.userRepository.save(newUser);
  }
}

save方法执行插入数据。

this.userRepository.create(createUser)
// 相当于
new User(createUser)  // 只是创建了一个新的用户对象

到这里就实现了注册用户的业务逻辑, Controller比较简单, 后面登录等业务实现,不再一一呈现Controller代码:

// user.controller.ts
 @ApiOperation({ summary'注册用户' })
 @ApiResponse({ status201type: [User] })
 @Post('register')
 register(@Body() createUser: CreateUserDto) {
    return this.userService.register(createUser);
  }

执行上面代码, 返回的数据内容如下:

{
  "data": {
    "username""admin",
    "password""$2a$10$vrgqi356K00XY6Q9wrSYyuBpOIVf2E.Vu6Eu.HQcUJP.hDTuclSEW",
    "nickname"null,
    "avatar"null,
    "email"null,
    "id""5c240dcc-a9b1-4262-8212-d5ceb2815ef8",
    "createTime""2021-11-16T03:00:16.000Z",
    "updateTime""2021-11-16T03:00:16.000Z"
  },
  "code"0,
  "msg""请求成功"
}

可以发现密码也被返回了,这个接口的风险不言而喻,如何处理呢?可以思考一下~

从两方面考虑, 一个是数据层面,从数据库就不返回password字段,另一种方式是在返回数据给用户时,处理数据,不返回给前端。我们分别看一下这两种方式:

方法1

TypeORM提供的列属性select进行查询时是否默认隐藏此列。但是这只能用于查询时, 比如save方法的返回的数据就仍然会包含password

// user.entity.ts
 @Column({ select: false})    // 表示隐藏此列
 password: string;  // 密码

使用这种方式,我们user.service.ts中的代码可以做如下修改:

// user.service.ts
 async register(createUser: CreateUserDto) {
  ...
  await this.userRepository.save(newUser);
  return await this.userRepository.findOne({where:{username}})
 }

方法2

使用class-transformer提供的Exclude来序列化,对返回的数据实现过滤掉password字段的效果。首先在user.entity.ts中使用@Exclude装饰:

// user.entity.ts
...
import { Exclude } from 'class-transformer';

@Exclude()
@Column() 
password: string;  // 密码

接着在对应请求的地方标记使用ClassSerializerInterceptor,此时,POST /api/user/register这个请求返回的数据中,就不会包含password这个字段。

  @UseInterceptors(ClassSerializerInterceptor)
  @Post('register')
  register(@Body() createUser: CreateUserDto) {...}

此时可以不用像方法1那样,修改user.service.ts中的逻辑。如果你想让该Controller中所有的请求都不包含password字段, 那可以直接用ClassSerializerInterceptor标记类。

其实这两种方式结合使用也完全可以的。

用户登录

用户登录这块,前面也提到了打算使用两种方式,一种是本地身份验证(用户名&密码),另一种是使用微信扫码登录。先来看一下本地身份验证登录如何实现。

passport.js

首先介绍有个专门做身份认证的Nodejs中间件:Passport.js,它功能单一,只能做登录验证,但非常强大,支持本地账号验证和第三方账号登录验证(OAuth和OpenID等),支持大多数Web网站和服务。

passport中最重要的概念是策略,passport模块本身不能做认证,所有的认证方法都以策略模式封装为插件,需要某种认证时将其添加到package.json即可, 这里我不会详细去讲passport实现原理这些, 如果感兴趣可以留言,我单独准备一篇文章来分享登录认证相关的一些内容(Nodejs不止可以用passport,还有其他不错的包)。

local 本地认证

首先安装一下依赖包,前面说了passport本身不做认证, 所以我们至少要安装一个passport策略, 这里先实现本地身份验证,所以先安装passport-local:

npm install @nestjs/passport passport passport-local
npm install @types/passport @types/passport-local

我们还安装了一个类型提示,因为passport是纯js的包,不装也不会影响程序运行,只是写的过程中没有代码提示。

创建一个auth模块,用于处理认证相关的代码,Controllerservice等这些文件夹创建方式就不重复了。我们还需要创建一个local.strategy.ts文件来写本地验证策略代码:

// local.strategy.ts
...
import { compareSync } from 'bcryptjs';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptionsStrategy } from 'passport-local';
import { User } from 'src/user/entities/user.entity';

export class LocalStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {
   
    super({
      usernameField'username',
      passwordField'password',
    } as IStrategyOptions);
  }

  async validate(username: string, password: string) {
    const user = await this.userRepository
      .createQueryBuilder('user')
      .addSelect('user.password')
      .where('user.username=:username', { username })
      .getOne();

    if (!user) {
      throw new BadRequestException('用户名不正确!');
    }

    if (!compareSync(password, user.password)) {
      throw new BadRequestException('密码错误!');
    }

    return user;
  }
}

我们从上至下的分析一下代码实现:

  • 首先定义了一个LocalStorage继承至@nestjs/passport提供的PassportStrategy类, 接受两个参数
    • 第一个参数: Strategy,你要用的策略,这里是passport-local
    • 第二个参数:是策略别名,上面是passport-local,默认就是local
  • 接着调用super传递策略参数, 这里如果传入的就是usernamepassword,可以不用写,使用默认的参数就是,比如我们是用邮箱进行验证,传入的参数是email, 那usernameField对应的value就是email
  • validateLocalStrategy的内置方法, 主要实现了用户查询以及密码对比,因为存的密码是加密后的,没办法直接对比用户名密码,只能先根据用户名查出用户,再比对密码。
    • 这里还有一个注意点, 通过addSelect添加password查询, 否则无法做密码对比。

有了这个策略,我们现在就可以实现一个简单的 /auth/login 路由,并应用Nest.js内置的守卫AuthGuard来进行验证。打开 app.controller.ts 文件,并将其内容替换为以下内容:

...
import { AuthGuard } from '@nestjs/passport';

@ApiTags('验证')
@Controller('auth')
export class AuthController {
  @UseGuards(AuthGuard('local'))
  @UseInterceptors(ClassSerializerInterceptor)
  @Post('login')
  async login(@Body() user: LoginDto, @Req() req) {
    return req.user;
  }
}

同时不要忘记在auth.module.ts导入PassportModule和实体User,并且将LocalStorage注入,提供给其模块内共享使用。

// auth.module.ts
... 
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/entities/user.entity';
import { LocalStorage } from './local.strategy';

@Module({
  imports: [TypeOrmModule.forFeature([User]), PassportModule],
  controllers: [AuthController],
  providers: [AuthService, LocalStorage],
})

接口返回的数据如下,这是我们所需要的吗?

图片

开发中登录完,不是应该返回一个可以识别用户token这样的吗?

是的,客户端使用用户名和密码进行身份验证,服务器验证成功后应该签发一个身份标识的东西给客户端,这样以后客户端就拿着这个标识来证明自己的身份。而标识用户身份的方式有多种,这里我们采用jwt方式。

jwt 生成token

接着我们要实现的就是,验证成功后,生成一个token字符串返回去。而jwt是一种成熟的生成token字符串的方案,它生成的token内容是这种形式:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NjMzNjUsImV4cCI6MTYzNzU3Nzc2NX0.NZl4qLA2B4C9qsjMjaXmZoFUyNjt2FH4C-zGSlviiXA

这种东西怎么生成的呢?

图片通过上图可以看出JWT token由三个部分组成,头部(header)、有效载荷(payload)、签名(signature)。实践一下

npm install @nestjs/jwt

首先注册一下JwtModule, 在auth.module.ts中实现:

...
import { JwtModule } from '@nestjs/jwt';

const jwtModule = JwtModule.register({
    secret:"test123456",
    signOptions: { expiresIn'4h' },
})

@Module({
  imports: [
    ...
    jwtModule,
  ],
  exports: [jwtModule],
})

上面代码中,是通过将secret写死在代码中实现的,这种方案实际开发中是不推荐的,secret这种私密的配置,应该像数据库配置那样,从环境变量中获取,不然secret泄露了,别人一样可以生成相应的的token,随意获取你的数据, 我们采用下面这种异步获取方式:

...
const jwtModule = JwtModule.registerAsync({
  inject: [ConfigService],
  useFactoryasync (configService: ConfigService) => {
    return {
      secret: configService.get('SECRET''test123456'),
      signOptions: { expiresIn'4h' },
    };
  },
});
...

注意不要忘记在.env文件中设置SECRET配置信息。

最后我们在auth.service.ts中实现业务逻辑:

//auth.service.ts
...
import { JwtService } from '@nestjs/jwt';

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

 // 生成token
  createToken(user: Partial<User>) {
    return this.jwtService.sign(user);
  }

  async login(user: Partial<User>) {
    const token = this.createToken({
      id: user.id,
      username: user.username,
      role: user.role,
    });

    return { token };
  }
}

到目前为止, 我们已经通过passport-local结合jwt实现了给用户返回一个token, 接下来就是用户携带token请求数据时,我们要验证携带的token是否正确,比如获取用户信息接口。

获取用户信息接口实现

实现token认证,passport也给我们提供了对应的passport-jwt策略,实现起来也是非常的方便,废话不多,直接Q代码:

首先安装:

npm install passport-jwt @types/passport-jwt

其实jwt 策略主要实现分两步

  • 第一步: 如何取出token
  • 第二步: 根据token拿到用户信息

我们看一下实现:

//jwt.strategy.ts
...
import { ConfigService } from '@nestjs/config';
import { UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StrategyOptionsStrategyExtractJwt } from 'passport-jwt';

export class JwtStorage extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      jwtFromRequestExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('SECRET'),
    } as StrategyOptions);
  }

  async validate(user: User) {
    const existUser = await this.authService.getUser(user);
    if (!existUser) {
      throw new UnauthorizedException('token不正确');
    }
    return existUser;
  }
}

在上面策略中的ExtractJwt提供多种方式从请求中提取JWT,常见的方式有以下几种:

  • fromHeader:在Http 请求头中查找JWT
  • fromBodyField: 在请求的Body字段中查找JWT
  • fromAuthHeaderAsBearerToken:在授权标头带有Bearer方案中查找JWT我们采用的是fromAuthHeaderAsBearerToken,后面请求操作演示中可以看到,发送的请求头中需要带上,这种方案也是现在很多后端比较青睐的:
'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImQyZTZkNjRlLWU1YTAtNDhhYi05ZjU2LWMyMjY3ZjRkZGMyNyIsInVzZXJuYW1lIjoiYWRtaW4xIiwicm9sZSI6InZpc2l0b3IiLCJpYXQiOjE2Mzc1NzUxMzMsImV4cCI6MTYzNzU4OTUzM30._-v8V2YG8hZWpL1Jq3puxBlETeSuWg8DBEPCL2X-h5c'

不要忘记在auth.module.ts中注入JwtStorage

...
import { JwtStorage } from './jwt.strategy';

@Module({
  ...
  providers: [AuthService, LocalStorage, JwtStorage],
  ...
})

最后只需要在Controller中使用绑定jwt授权守卫:

// user.controller.ts

@ApiOperation({ summary'获取用户信息' })
@ApiBearerAuth() // swagger文档设置token
@UseGuards(AuthGuard('jwt'))
@Get()
getUserInfo(@Req() req) {
    return req.user;
}

到这里获取用户信息接口就告一段落, 最后为了可以顺畅的使用Swagger来测试传递bearer token接口,需要添加一个addBearerAuth:

// main.ts
...
  const config = new DocumentBuilder()
    .setTitle('管理后台')
    .setDescription('管理后台接口文档')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document);
  await app.listen(9080);
  ...