还在好奇 node 后端登录流程怎么做?进来聊聊吧

218 阅读8分钟

前言

哈喽大家好!我是 嘟老板。今天我们来梳理下 zimu-admin 的登录全流程,看看 nodejs 如何实现用户会话控制。

阅读本文您将收获:

  1. 了解登录流程设计及实现。
  2. 了解授权验证逻辑及实现。
  3. 后续优化点。

流程设计

以下是登录流程简图:

image.png

客户端首次向服务端发送请求时,由于没有登录,服务端在验证后会响应 401,提示没有授权;

前端接收到 401 状态,发现需要先登录才行,就重定向到登录页,要求用户输入用户名、密码进行登录;

服务端接收到客户端的登录请求,会先验证用户名和密码是否正确,验证通过后,则会生成加密的 token,返回给客户端,后续服务端会通过该 token 进行会话验证。

客户端接收到 token 后,需要将其缓存到浏览器(localStoragesessionStoragecookie 都可,按实际需求而定),后续每次向服务端发送请求时,都要在请求头 Authorization 标头中携带该 token

服务端后续收到客户端的请求时,发现 Authorization 标头携带了 token,很好,然后对该 token 进行有效性验证,若验证通过,则可以执行后续的业务逻辑。

实现过程

相关技术

zimu-admin 服务端基于以下技术栈构建:

用户模块

要做登录,自然离不开用户,我们先来定义下用户模块。

实体类 UserEntity

根据系统实际需求,定义 User 实体类及用户属性:

/**
 * 用户实体
 */
import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from 'typeorm'
import { encryptPassword } from '../utils/pwd'

@Entity('user')
export class User {
  // rowId
  @PrimaryGeneratedColumn()
  id!: number

  // 用户账号/工号
  @PrimaryColumn({ name: 'user_name' })
  username!: string

  // 用户密码
  @Column({
    transformer: {
      to(entityValue) {
        return encryptPassword(entityValue)
      },
      from(dbValue) {
        return dbValue
      }
    }
  })
  password!: string

  // 姓名
  @Column()
  name!: string

  // 性别
  @Column()
  sex!: string

  // 电话
  @Column()
  tel!: string

  // 电子邮箱
  @Column()
  email!: string

  // 住址
  @Column()
  address!: string

  // 创建人
  @Column({ name: 'created_by' })
  createdBy!: string

  // 创建时间
  @Column({ type: 'date', name: 'created_at' })
  createdAt!: string

  // 更新人
  @Column({ name: 'updated_by' })
  updatedBy!: string

  // 更新时间
  @Column({ type: 'date', name: 'updated_at' })
  updatedAt!: string
}

UserEntity 类属性中,除了 id 是数据库数据主键外,其他都是用户相关信息,还算全面,包括如 账号密码姓名性别电话 等,可根据项目实际场景适当增减。

服务类 UserService

UserService 主要用来定义用户服务的具体业务逻辑,我们在登录时需要根据用户账号获取用户信息,先定义个 queryUserByUsername 函数。

/**
 * 用户服务类
 */
import db from '../data-source'
import { User } from '../entities/user.entity'
import { BaseService } from './base.service'

export class UserService extends BaseService {
  userRepository = db.getRepository(User)

  // 根据用户名查询用户详情
  async queryByUsername(username: string) {
    return await this.userRepository.findOneBy({
      username
    })
  }
}

queryUserByUsername 函数通过传递的 username,查询数据库中的用户数据,返回用户详细信息

登录服务

登录流程图如下:

image.png

登录请求处理步骤如下:

  1. 服务端接收到客户端发送的登录请求,验证用户是否存在,若不存在,则报错“用户不存在”;
  2. 若用户存在,则比较客户端传递的密码和数据库中存储的用户密码是否匹配,若不匹配,则报错“密码不正确”;
  3. 若密码匹配,则生成一串 token 字符串,返回给客户端,作为后续鉴权的依据。

LoginController

我们在 LoginController 中定义 login 接口。

import { Body, Controller, Post, QueryParam } from 'routing-controllers'
import jwt from 'jsonwebtoken'
import { comparePassword } from '../utils/pwd'
import { error } from '../utils/r'
import { JWT_SECRET } from '../constants/secrets'
import { UserService } from '../services/user.service'

/**
 * 登录 controller
 */
@Controller()
export class LoginController {
  // 用户服务
  userService = new UserService()

  /**
   * 登录接口
   * @param body 请求体
   * @param href 登录成功后,浏览器重定向的地址
   */
  @Post('/login')
  async login(@Body() body: any) {
    const { username, password } = body
    if (!username || !password) {
      return error('用户名和密码不允许为空')
    }

    // 校验用户是否存在
    const user = await this.userService.queryByUsername(username)
    if (!user) {
      return error('用户不存在')
    }

    // 验证用户名和密码是否正确
    const isPwdSame = await comparePassword(password, user.password)
    if (isPwdSame) {
      // 生成 jwt
      const token = jwt.sign({ username }, JWT_SECRET, {
        expiresIn: 60 * 60
      })

      return success({ token })
    } else {
      return error('密码错误,请检查后重试')
    }
  }
}

用户密码使用 bcrypt 加密,匹配密码时,需要使用 bcrypt 提供的 compare api,代码中 comparePassword 函数的内部逻辑就是如此,只是做了简单封装。

token 使用 jsonwebtoken 生成,通过 秘钥(常量 JWT_SECRET)加密用户信息,后续可通过解析 token 获取用户信息。

授权验证

于系统来说,完成登录不是终点,登录后的授权验证同样重要,能不能把一切妖魔鬼怪隔离在系统之外,就看验证逻辑的缜密性。

前文提到,token 是授权验证的依据,那到底需要验证哪些呢?以下是大致内容:

  1. 客户端请求是否携带 token
  2. token 是否有效,即 token 是否通过系统逻辑生成、是否在有效期内等。
  3. token 中携带的用户信息是否真实,即数据库中是否存在真实的用户数据。

我们将验证逻辑封装为 authChecker 工具函数,以实现统一验证。

实现 authChecker 函数

/**
 * 接口授权验证逻辑
 */
import jwt from 'jsonwebtoken'
import { JWT_SECRET } from '../constants/secrets'
import { UserService } from '../services/user.service'
import type { Action } from 'routing-controllers'

export default function authChecker(action: Action, roles: string[]) {
  // 请求头 Authorization 获取 token
  const token = action.request.headers.authorization?.split(' ')[1]
  // 若 token 不存在,不通过
  if (!token) return false

  // jwt 验证 token 是否有效
  jwt.verify(token, JWT_SECRET, (err: any, payload: any) => {
    // 若 error 存在,不通过
    if (err) {
      return false
    }

    // 验证用户名是否有效
    const username = payload.username
    const user = new UserService().queryByUsername(username)
    // 若不存在对应用户,不通过
    if (!user) return false
  })
  return true
}

核心逻辑分为以下几个部分:

  • 从请求头 Authorization 标头中获取 token,若不存在,则未授权;
  • jsonwebtoken 验证 token 是否有效,若报错,则未授权;
  • token 中的 username 信息,匹配数据库用户,若不存在,则未授权;
  • 若全部检查通过,则已授权。

应用 authChecker 函数

使用 routing-controllers 应用 authChecker

若不了解 routing-controllers,可查看 《Express 项目集成 routing-controllers,快速搞定 controller 层》

useExpressServer 第二个参数中添加 authorizationChecker 配置项,即完成 authChecker 配置:

useExpressServer(app, {
  // ...
  authorizationChecker: authChecker
})

后续在需要授权验证的请求上,添加 @Authorized 装饰器即可。

比如菜单模块 MenuController:

/**
 * 菜单 controller
 */
import { Authorized, Controller, Get } from 'routing-controllers'
import { MenuService } from '../services/menu.service'
import { INTERFACE_PATH } from '../constants/path'

@Controller('/menu')
export class MenuController {
  menuService = new MenuService()

  @Get('/list')
  @Authorized()
  async queryList() {
    const [rows, count] = await this.menuService.queryList()
    return {
      rows,
      count
    }
  }
}

客户端请求 @Authorized() 装饰的接口时,会先走 authChecker 授权验证,若 authChecker 返回 false,则验证未通过,会将响将应码设置为 401(未授权),返回给客户端。

image.png

后续优化

无感刷新 token

按照现在的逻辑,若当前 token 过期后,则会要求用户输入用户名、密码重新登录。事实上,为保证 token 安全,有效期一般不会设置太长时间,这可能导致用户频繁登录。

无感刷新 token 的设计恰恰可以解决这个问题,若 token 过期,我们可以从应用层面做到自动刷新,对用户来说无感。

token 黑名单

token 不是过期失效,而是用户主动注销,则需要主动失效 token

可以通过前端清空浏览器 token 缓存方式实现,然而前端控制安全性低,难免有心之人拿到 token 存起来,通过其他手段向服务端发送请求。

可以将用户注销但仍然可用的 token 加入到黑名单中,从服务端控制,提升安全性。

登录错误次数限制

目前登录时用户可以无限次的输入、试错,存在一定的安全隐患,攻击者可以通过不断尝试暴力破解。

限定错误次数,比如 5 次,错误 5 次后锁定账号,不允许持续性试错,可以一定程度避免系统被暴力攻破。

结语

本文重点介绍了 zimu-admin 中登录流程及授权验证的整体实现,核心逻辑多是按照项目当前情况设计,后续会持续迭代优化。望本文对您有所帮助。相关代码已上传至 GitHub,欢迎 star

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期推荐