用户登录设计及免密登录的通用思路

简介: 完整的用户登录设计及免密登录的通用思路。涉及到了SQL表单创建、Mapper接口、Service接口、Controller接口。其中还讲述了如何统一的响应体,保持前后端友好开发;以及持久化token。

流程

用户登录的基本流程如图所示:

image.png

设计

表单设计

根据实际需要去设计表单,为了规范化和逻辑化,可以也尝试着先将业务逻辑整理成流程图的形式,这样方便后续开发逻辑判断。

根据我的流程图,它是一定需要用户名密码的,这也是所有用户表必备的,所以本表的设计如下,它不仅包含了登录要用的用户名和密码,还包含了常见的:昵称、手机、头像地址、状态、创建时间、更新时间、逻辑删除标志、状态这些属性,可以根据实际情况再继续扩充。

此外为了验证,还新增了一条记录,为了后续验证。

DROP TABLE IF EXISTS `t_sys_user`;
CREATE TABLE `t_sys_user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户名',
  `password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '密码',
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称',
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机',
  `head_url` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像地址',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '描述',
  `status` tinyint(3) NULL DEFAULT 1 COMMENT '状态(1:正常 0:停用)',
  `create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
  `is_deleted` tinyint(3) NOT NULL DEFAULT 0 COMMENT '删除标记(0:不可用 1:可用)',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `idx_username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of t_sys_user
-- ----------------------------
INSERT INTO `t_sys_user` VALUES (1, 'chengyunlai', 'e10adc3949ba59abbe56e057f20f883e', NULL, NULL, NULL, NULL, 1, '2023-04-16 00:53:16', '2023-04-16 00:55:44', 0);

SET FOREIGN_KEY_CHECKS = 1;

后端设计

后端的设计思路通常分这几步:

  1. 选择一个后端框架,我的技术栈是SpringBoot,即Java-Web。
  2. 根据表单设计创建对应的bean,也称为实体类。我会使用Lombok简化一下get And set,然后顺便用一下它提供的日志类sl4j
  3. 选择一个持久层框架,负责对数据表的增删改查工作。即写Mapper。我会用mybatis-plus
  4. 写一下对应的service,业务逻辑写在这。
  5. controller对外的API,在这里调用service的方法封装响应体。整体采用RESTFul风格API,并且后端只负责数据传递,即RestController

实体类

需要的依赖:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.16</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>

配置数据源

spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/ail510?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=utf-8
    username: root
    password: root

实体类的注解分别就用到了lombokmybatis-plus,不作具体解释。一个是简化get and set,另外一个是将属性和表列名对应。

注意:你需要正确的在yml中配置数据源。

/**
 * @ClassName
 * @Description
 * @Author:chengyunlai
 * @Date
 * @Version 1.0
 **/
@Data
@TableName("t_sys_user")
public class SysUser extends BaseEntity {
    private static final long serialVersionUID = 1L;

    @TableField("username")
    private String username;

    @TableField("password")
    private String password;

    @TableField("name")
    private String name;

    @TableField("phone")
    private String phone;

    @TableField("head_url")
    private String headUrl;

    @TableField("dept_id")
    private Long deptId;

    @TableField("post_id")
    private Long postId;

    @TableField("description")
    private String description;

    @TableField("open_id")
    private String openId;

    @TableField("status")
    private Integer status;
}
小Tips:根据Mp的习惯,它可以做一些公共属性自动封装,所以我们可以将每个bean的公共部分抽出来,变成一个 BaseEntity
@Data
public class BaseEntity implements Serializable {

    @TableId(type = IdType.AUTO)
    private Long id;

    @TableField("create_time")
    private Date createTime;

    @TableField("update_time")
    private Date updateTime;

    @TableLogic
    @TableField("is_deleted")
    private Integer isDeleted;
}

Mapper

直接继承Mp的单表操作即可。

public interface SysUserMapper extends BaseMapper<SysUser> {
}

Service

Service层需要处理逻辑,底层的增删改查都使用Mp的即可,我们通过对现有的sql进行整合。

思考:

后端会以一个登录类,对用户名和密码进行封装。由于用户名是唯一的,所以我们可以通过用户名去查询数据库对应的记录并返回。而Mp是没有根据用户名去查询数据的接口,所以我们需要创建该方法。

接口如下,extends IService<SysUser>是对操作类的固定写法。

public interface SysUserService extends IService<SysUser> {
    SysUser getByUsername(String username);
}

实现类如下:

主要是用到了getOne方法,构建了一个LambdaQuery去封装对应的查询条件。

@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
    @Override
    public SysUser getByUsername(String username) {
        LambdaQueryWrapper<SysUser> lambdaQueryWrapper = new LambdaQueryWrapper();
        lambdaQueryWrapper.eq(SysUser::getUsername,username);
        return this.getOne(lambdaQueryWrapper);
    }
}

Controller

我先直接带出代码,一般的API开发我就不多介绍了,而调用Service的方法也不难。这里我主要再说一下Token的思路,以及为什么。

/**
 * @ClassName
 * @Description
 * @Author:chengyunlai
 * @Date
 * @Version 1.0
 **/
@RestController
@Slf4j
public class IndexController {
    @Autowired
    SysUserService sysUserService;
    
    @PostMapping("/login")
    @CrossOrigin(value = "http://localhost:3000")
    public Result login(@RequestBody LoginVo loginUser, HttpServletResponse response){
        SysUser sysUser = sysUserService.getByUsername(loginUser.getUsername());
        if(null == sysUser) {
            return Result.fail().message("用户不存在");
        }
        if(!MD5.encrypt(loginUser.getPassword()).equals(sysUser.getPassword())) {
            return Result.fail().message("密码错误");
        }
        if(sysUser.getStatus() == 0) {
            return Result.fail().message("用户被禁用");
        }
        Map<String, Object> map = new HashMap<>();
        map.put("token", JwtHelper.createToken(sysUser.getId(), sysUser.getUsername()));
        return Result.success(map).message("登录成功");
    }
}
token的作用
了解什么是token,参考 JSON Web Token 入门教程 - 阮一峰的网络日志 (ruanyifeng.com)

代码中调用了一个JwtHelper类来生成token。token的作用其实很简单,即验证用户身份。

image.png

后续前端发送相关请求API只需要带着这个token即可,这个token就是用户的标识。

那么token就可以简单理解为用户的名片,所以token应该具备:

  1. 可以拿到用户的基本信息(不能查询到私密信息,例如密码)。
  2. 应该需要有过期时间,判断token的有效期,会自动过期。

token的优点是,服务器不必再存储它了,减轻服务器的压力,token只在客户端存储。

缺点是:作为消息体返回以及客户端存储,安全压力上来了,毕竟有这玩意就表示是用户本身。

保障安全性这里我简单说几点:

  1. 网站通讯应该采取https协议
  2. token中不能存放隐私内容
  3. token应该被非对称算法加密,且服务器要做好保护私钥安全
  4. 客户端可以尝试用安全的存储方式(必应一下,我还没涉及到)
  5. ...(没有绝对的安全)

应用点:

  1. 免密登录
  2. 根据token操作用户相关的API操作
生成token

这里我使用一个工具包:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

工具类:

//jwt工具类
public class JwtHelper {
    //天 24 小时 60 分 60秒 1000毫秒
    private static long tokenExpiration = 3 * 1000;
    private static String tokenSignKey = "123456";

    //根据用户id和用户名称生成token字符串
    public static String createToken(Long userId, String username) {
        String token = Jwts.builder()
                //分类
                .setSubject("AUTH-USER")
                //设置token有效时长
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
                //设置主体部分
                .claim("userId", userId)
                .claim("username", username)
                //签名部分
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                .compressWith(CompressionCodecs.GZIP)
                .compact();
        return token;
    }

    //从生成token字符串获取用户id
    public static Long getUserId(String token) {
        try {
            if (StringUtils.isEmpty(token)) return null;
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            Integer userId = (Integer) claims.get("userId");
            return userId.longValue();
        } catch (Exception e) {
            throw new CusException(ResultCodeEnum.FAIL.getCode(),"用户token失效");
        }
    }

    //从生成token字符串获取用户名称
    public static String getUsername(String token) {
        try {
            if (StringUtils.isEmpty(token)) return "";

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            return (String) claims.get("username");
        } catch (Exception e) {
            throw new CusException(ResultCodeEnum.FAIL.getCode(),"用户token失效");
        }
    }
}

通用返回类

前端后端不要打架,统一一下数据返回格式和处理格式,非常有必要。

  1. 后端返回的内容是json,都以响应体返回。
  2. 数据格式要包含一下响应状态码,了解接口请求状态。
  3. 提供一下提示信息。
  4. 最后将相应内容封装在data中。

一般来说类似这种格式:

{ "code":"200", "message":"操作成功", "data":"..." }
Result
/**
 * @ClassName
 * @Description
 * @Author:chengyunlai
 * @Date
 * @Version 1.0
 **/
@Data
public class Result<T> {

    //返回码
    private Integer code;

    //返回消息
    private String message;

    //返回数据
    private T data;

    public Result(){}

    // 返回数据
    protected static <T> Result<T> build(T data) {
        Result<T> result = new Result<T>();
        if (data != null)
            result.setData(data);
        return result;
    }

    public static <T> Result<T> build(T body, Integer code, String message) {
        Result<T> result = build(body);
        result.setCode(code);
        result.setMessage(message);
        return result;
    }

    public static <T> Result<T> build(T body, ResultCodeEnum resultCodeEnum) {
        Result<T> result = build(body);
        result.setCode(resultCodeEnum.getCode());
        result.setMessage(resultCodeEnum.getMessage());
        return result;
    }

    public static<T> Result<T> success(){
        return Result.success(null);
    }

    /**
     * 操作成功
     * @param data  baseCategory1List
     * @param <T>
     * @return
     */
    public static<T> Result<T> success(T data){
        Result<T> result = build(data);
        return build(data, ResultCodeEnum.SUCCESS);
    }

    public static<T> Result<T> fail(){
        return Result.fail(null);
    }

    /**
     * 操作失败
     * @param data
     * @param <T>
     * @return
     */
    public static<T> Result<T> fail(T data){
        Result<T> result = build(data);
        return build(data, ResultCodeEnum.FAIL);
    }

    public Result<T> message(String msg){
        this.setMessage(msg);
        return this;
    }

    public Result<T> code(Integer code){
        this.setCode(code);
        return this;
    }
}
响应状态码
package top.chengyunlai.utils.R;

import lombok.Getter;

@Getter
public enum ResultCodeEnum {
    SUCCESS(200,"成功"),
    FAIL(201, "失败"),
    SERVICE_ERROR(2012, "服务异常"),
    DATA_ERROR(204, "数据异常"),
    LOGIN_AUTH(208, "未登陆"),
    PERMISSION(209, "没有权限");

    private Integer code;

    private String message;

    private ResultCodeEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

前端设计

最后我们来构思一下前端该大致怎么做,注意这并不完善,你还需要根据实际业务进行完善,这对你自己有所提升,毕竟没有什么东西都是现成的,思想这东西计算机也好,人也好。(内涵一下GPT)

我使用的是 Nuxt工程,很方便。大致思路是不变的,只是请求方式的工具可能不一样。

接口

我的接口还没做统一的封装,因为工程还不大,方便记录学习。看一下里面的思路跟着注解还是很容易理解的。

async function login() {
  // 封装表单数据
  const userJson = JSON.stringify(user.value)
  // 发送请求
  const {data: res, pending, error, refresh} = await useFetch(() => '/login', {
    baseURL: config.public.baseUrl,
    method: 'post',
    body: userJson,
    server: false
  })
  
  if (pending) {
    // 请求还在进行中,可以显示加载中的提示
    message.success('正在验证中',{
      duration:1000
    })
  }
  // 请求成功
  if (res && res.value.code==200) {
    message.success(res.value.message, {
      duration: 5000
    })
    // 存储token
    token.value  = res.value.data.token
    // 页面跳转
    router.push({ path: "/room" });
  } else if (res && res.value.code==201) {
    message.error(res.value.message, {
      duration: 5000
    })
  }else{
    message.error("网络问题请稍后重试", {
      duration: 5000
    })
  }
}

存储Token

我使用两个工具来实现这个存储

  1. pinia(用vuex也行,或者浏览器本地也行):做的事就是存储。
  2. pinia-plugin-persistedstate:做的事就是解决页面刷新状态丢失的问题。

安装:

yarn add -D @pinia-plugin-persistedstate/nuxt

yarn add @pinia/nuxt

配置:

modules:[
  '@pinia-plugin-persistedstate/nuxt',
  // 引入 Pinia
  [
    "@pinia/nuxt",
    {
      autoImports: [
        // 自动引入 `defineStore(), storeToRefs()`
        "defineStore",
        "storeToRefs"
      ],
    },
  ]
],

写存储:

工程根路径/composables/auth.ts中写入存储代码,persist: true即持久化Token。

export const is_Login = defineStore("user", {
    state: () => ({
        token: null
    }),
    persist: true
});

免密登录的思路

已经将token存储在客户端了,所以客户端可以在需要登录的地方或者页面先加上判断,如果token存在,先验证token的合法性和时效性,如果一切正常,直接放行。如果token不存在或者token认证失败(错误或过期),则用户还是要去重新登录。

其他思路

后端验证token时如果出现token过期或者错误的情况,需要提示前端要做相应处理。包括退出登录时的举措,需要指定token为过期,或者提醒前端删除token操作。

其他补充

整体思路如果你有问题的话可以在评论区中留言。这里我推荐一下学习的途径

  1. Nuxt中文站 - 易懂的Web框架 Nuxt3文档,主要看配置文件、composables文件的说明、useFetch的使用。
  2. 在 Nuxt 3 中使用 | pinia-plugin-persistedstate (prazdevs.github.io)
目录
相关文章
|
8月前
|
SQL 关系型数据库 MySQL
MySQL数据库基础练习系列13、用户注册与登录系统
MySQL数据库基础练习系列13、用户注册与登录系统
62 1
|
存储 NoSQL Redis
【面试题】:说一下登录模块的思路以及登录的优化
说一下登录模块的思路以及登录的优化
224 1
|
安全 Java 数据安全/隐私保护
案例之密码模式测试|学习笔记
快速学习案例之密码模式测试
案例之密码模式测试|学习笔记
|
PHP
laravel-admin 自定义登陆逻辑,补充原有账号密码登录
laravel-admin 自定义登陆逻辑,补充原有账号密码登录
382 0
【测试基础】一、你真的会测试“用户登录”吗?
【测试基础】一、你真的会测试“用户登录”吗?
【测试基础】一、你真的会测试“用户登录”吗?
|
存储 SQL 编解码
一个简单的登录功能,你真的会测试吗?
一个简单的登录功能,你真的会测试吗?
137 0
|
数据安全/隐私保护 开发者 Python
登录功能的实现| 学习笔记
快速学习登录功能的实现
|
Java 数据库 数据安全/隐私保护
用户模块之登录功能 | 学习笔记
快速学习用户模块之登录功能
241 0
|
存储 缓存 NoSQL
通用登陆(下)
登陆代理接入