Skip to content

这节我们实现下登录、修改密码。

在 UserController 添加一个 login 的路由:

javascript
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    console.log(loginUser);
    return 'success';
}

创建 src/dto/login-user.dto.ts:

javascript
import { IsNotEmpty } from "class-validator";

export class LoginUserDto {

    @IsNotEmpty({
        message: "用户名不能为空"
    })
    username: string;
    
    @IsNotEmpty({
        message: '密码不能为空'
    })
    password: string;    
}

测试下:

服务端打印了接收的参数。

ValidationPipe 开启 transform: true

再次访问:

这样会把参数转为 dto 的实例。

然后在 UserService 实现 login 方法:

javascript
async login(loginUserDto: LoginUserDto) {
  const foundUser = await this.prismaService.user.findUnique({
    where: {
      username: loginUserDto.username
    }
  });

  if(!foundUser) {
      throw new HttpException('用户不存在', HttpStatus.BAD_REQUEST);
  }

  if(foundUser.password !== loginUserDto.password) {
      throw new HttpException('密码错误', HttpStatus.BAD_REQUEST);
  }

  delete foundUser.password;
  return foundUser;
}

为了开发方便,我们注册的时候没有对密码做加密,登录的时候也就不用加密了。

在 UserController 里调用下:

javascript
@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    const user = await this.userService.login(loginUser);

    return user;
}

测试下:

当用户名不存在时:

当密码错误时:

登录成功:

登录成功之后我们要返回 jwt。

引入下 jwt 的包:

npm install --save @nestjs/jwt

在 UserModule 里引入:

javascript
JwtModule.registerAsync({
  global: true,
  useFactory() {
    return {
      secret: 'guang',
      signOptions: {
        expiresIn: '30m' // 默认 30 分钟
      }
    }
  }
}),

然后登录成功之后返回 token:

javascript
@Inject(JwtService)
private jwtService: JwtService;

@Post('login')
async userLogin(@Body() loginUser: LoginUserDto) {
    const user = await this.userService.login(loginUser);

    return {
      user,
      token: this.jwtService.sign({
        userId: user.id,
        username: user.username
      }, {
        expiresIn: '7d'
      })
    };
}

token 过期时间是 7 天。

测试下:

我们这里就不用双 token 的方式来刷新了,而是用单 token 无限续期来做。

也就是当访问接口的时候,就返回一个新的 token。

这样只要它在 token 过期之前,也就是 7 天内访问了一次系统,那就会刷新换成新 token。

超过 7 天没访问,那就需要重新登录了。

然后我们加上 AuthGuard 来做登录鉴权:

创建一个 common 的 lib:

nest g lib common

进入 common 目录,生成 Guard:

nest g guard auth --flat --no-spec

AuthGuard 的实现代码如下:

javascript
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Observable } from 'rxjs';

interface JwtUserData {
  userId: number;
  username: string;
}

declare module 'express' {
  interface Request {
    user: JwtUserData
  }
}

@Injectable()
export class AuthGuard implements CanActivate {
  
  @Inject()
  private reflector: Reflector;

  @Inject(JwtService)
  private jwtService: JwtService;
  
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    
    const requireLogin = this.reflector.getAllAndOverride('require-login', [
      context.getClass(),
      context.getHandler()
    ]);

    if(!requireLogin) {
      return true;
    }

    const authorization = request.headers.authorization;

    if(!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try{
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify<JwtUserData>(token);

      request.user = {
        userId: data.userId,
        username: data.username,
      }
      return true;
    } catch(e) {
      throw new UnauthorizedException('token 失效,请重新登录');
    }
  }
}

用 reflector 从目标 controller 和 handler 上拿到 require-login 的 metadata。

如果没有 metadata,就是不需要登录,返回 true 放行。

否则从 authorization 的 header 取出 jwt 来,把用户信息设置到 request,然后放行。

如果 jwt 无效,返回 401 响应,提示 token 失效,请重新登录。

导出下:

然后全局启用这个 Guard,在 UserModule 里添加这个 provider:

javascript
{
  provide: APP_GUARD,
  useClass: AuthGuard
}

在 UserController 添加 aaa、bbb 两个接口:

javascript
@Get('aaa')
aaa() {
    return 'aaa';
}

@Get('bbb')
bbb() {
    return 'bbb';
}

访问下:

然后在 aaa 加上 require-login 的 matadata

javascript
@Get('aaa')
@SetMetadata('require-login', true)
aaa() {
    return 'aaa';
}

会提示用户未登录:

而 bbb 还是可以直接访问的:

登录下,拿到 token:

添加到 authorization 的 header 里,就可以访问了:

我们把这个 @SetMetadata 封装成自定义装饰器

放在 libs/common 下

新建 src/custom.decorator.ts

javascript
import { SetMetadata } from "@nestjs/common";

export const  RequireLogin = () => SetMetadata('require-login', true);

然后就可以通过在 controller 或者 handler 上的 @RequiredLogin 来声明接口需要登录了:

再实现个自定义参数装饰器来取 request.user

javascript
import { SetMetadata } from "@nestjs/common";
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from "express";

export const  RequireLogin = () => SetMetadata('require-login', true);

export const UserInfo = createParamDecorator(
  (data: string, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest<Request>();

    if(!request.user) {
        return null;
    }
    return data ? request.user[data] : request.user;
  },
)

在 aaa 方法里测试下:

javascript
@Get('aaa')
@RequireLogin()
// @SetMetadata('require-login', true)
aaa(@UserInfo() userInfo, @UserInfo('username') username) {
    console.log(userInfo, username);
    return 'aaa';
}

这样,就完成了登录和鉴权。

还有 token 自动续期没有做,这个就是访问接口之后,在 header 或者 body 里额外返回新 token。

javascript
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';

interface JwtUserData {
  userId: number;
  username: string;
}

declare module 'express' {
  interface Request {
    user: JwtUserData
  }
}

@Injectable()
export class AuthGuard implements CanActivate {
  
  @Inject()
  private reflector: Reflector;

  @Inject(JwtService)
  private jwtService: JwtService;
  
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    const response: Response = context.switchToHttp().getResponse();

    const requireLogin = this.reflector.getAllAndOverride('require-login', [
      context.getClass(),
      context.getHandler()
    ]);

    if(!requireLogin) {
      return true;
    }

    const authorization = request.headers.authorization;

    if(!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try{
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify<JwtUserData>(token);

      request.user = {
        userId: data.userId,
        username: data.username,
      }

      response.header('token', this.jwtService.sign({
        userId: data.userId,
        username: data.username
      }, {
        expiresIn: '7d'
      }))

      return true;
    } catch(e) {
      console.log(e);
      throw new UnauthorizedException('token 失效,请重新登录');
    }
  }
}

再访问下 aaa 接口:

可以看到返回了新 token。

这样只要访问需要登录的接口,就会刷新 token。

比双token 的方案简单多了,很多公司就是这样做的。

然后实现修改密码的接口:

javascript
@Post('update_password')
async updatePassword(@Body() passwordDto: UpdateUserPasswordDto) {
  console.log(passwordDto);
  return 'success';
}

创建 src/dto/update-user-password.dto.ts

javascript
import { IsEmail, IsNotEmpty, MinLength } from "class-validator";

export class UpdateUserPasswordDto {    
    @IsNotEmpty({
        message: '密码不能为空'
    })
    @MinLength(6, {
        message: '密码不能少于 6 位'
    })
    password: string;
    
    @IsNotEmpty({
        message: '邮箱不能为空'
    })
    @IsEmail({}, {
        message: '不是合法的邮箱格式'
    })
    email: string;
    
    @IsNotEmpty({
        message: '用户名不能为空'
    })
    username: string;
    
    @IsNotEmpty({
        message: '验证码不能为空'
    })
    captcha: string;
}

需要传的是用户名、邮箱、密码、验证码。

确认密码在前端和密码对比就行,不需要传到后端。

测试下:

然后实现下具体的更新密码的逻辑:

在 UserController 里调用 UserService 的方法:

javascript
@Post('update_password')
async updatePassword(@Body() passwordDto: UpdateUserPasswordDto) {
    return this.userService.updatePassword(passwordDto);
}

UserService 实现具体的逻辑:

javascript
async updatePassword(passwordDto: UpdateUserPasswordDto) {
  const captcha = await this.redisService.get(`update_password_captcha_${passwordDto.email}`);

  if(!captcha) {
      throw new HttpException('验证码已失效', HttpStatus.BAD_REQUEST);
  }

  if(passwordDto.captcha !== captcha) {
      throw new HttpException('验证码不正确', HttpStatus.BAD_REQUEST);
  }

  const foundUser = await this.prismaService.user.findUnique({
    where: {
        username: passwordDto.username
    }
  });

  foundUser.password = passwordDto.password;

  try {
    await this.prismaService.user.update({
      where: {
        id: foundUser.id
      },
      data: foundUser
    });
    return '密码修改成功';
  } catch(e) {
    this.logger.error(e, UserService);
    return '密码修改失败';
  }
}

先查询 redis 中相对应的验证码,检查通过之后根据 email 查询用户信息,修改密码之后 save。

测试下:

在 redis 里手动添加 update_password_captcha_邮箱 的 key,值为 111111

半小时过期。

然后再试下:

修改成功。

我们为了开发方便没对密码做加密,可以直观看出来密码修改成功了:

然后再加上发送邮箱验证码的接口:

javascript
@Get('update_password/captcha')
async updatePasswordCaptcha(@Query('address') address: string) {
    if(!address) {
      throw new BadRequestException('邮箱地址不能为空');
    }
    const code = Math.random().toString().slice(2,8);

    await this.redisService.set(`update_password_captcha_${address}`, code, 10 * 60);

    await this.emailService.sendMail({
      to: address,
      subject: '更改密码验证码',
      html: `<p>你的更改密码验证码是 ${code}</p>`
    });
    return '发送成功';
}

测试下:

用这个验证码修改下密码:

修改成功。

代码在小册仓库

总结

这节我们实现了登录、鉴权、修改密码。

添加了 /user/login 接口来实现登录,登录后返回 jwt token。

实现了 /user/update_password 用于修改密码,/user/update_password/captcha 用于发送验证码。

访问的时候在 Authorization 的 header 带上 jwt 的 token 就能通过 AuthGuard 的鉴权。

我们做了 token 的自动续期,也就是访问接口后在 header 返回新 token,这样比双 token 的方案简单。

然后封装了 @RequireLogin 和 @UserInfo 两个自定义装饰器。

登录之后,就可以访问一些需要 user 信息的接口了。

至此,用户微服务的三个接口就开发完了。