前言

在自学java的过程中,每次做项目都绕不开登录这个茬,虽然不难,且没啥技术含量,但登录的实现综合了许多知识,我认为实现一个完整可用的登录逻辑对于复习和巩固java基础基础是分合适,所以写下这篇笔记。

下面是文章使用的一些依赖以及版本信息

  • 数据库: mysql,mybatis
  • 框架: springboot 3.0.5
  • 数据库相关: mybatis,druid 1.2.22,mysql 8.0.33,redis
  • 工具:lombok
  • 验证码工具:EasyCaptcha

思路梳理

  1. 在注册接口中:
    • 对前端传过来的密码需要进行加密存储,要验证用户名的唯一性
  2. 验证码接口中:
    • 指定一个验证码Vo类,包含字段imagekey
    • 生成的图形验证码的文本为code生成一个唯一key(如UUID),然后以键值对形式存入redis中,并设置过期时间
    • 验证码toBase64()的值为image,以及key封装对象传回前端。这样前端根据image显示的每个验证码图片都有唯一的key
  3. 登录接口中:
    • 指定一个登录Vo类,字段包含用户的username,password,以及用户输入的验证码code,以验证码图片对应的key
    • 根据key从redis中查询并验证码文本,情况有:用户未输入验证码,验证码已过期,验证码错误
    • 验证账号,情况有:用户未输入username,无该账号,密码错误
    • 登录成功,生成token并返回
  4. 关于token:
    • 创建一个工具类,用于生成token解析token
    • token中设置密钥,密钥不能过简单SecretKey secretKey = Keys.hmacShaKeyFor("密钥".getBytes());
    • 在token中claim()自定义字段中存入能标识当前登录用户的信息字段
    • 解析token中,从返回结果中获取我们在body部分自定义的用户信息即可
  5. 异常处理:
    • 可设置全局异常处理器@RestControllerAdvice
    • 可自定义异常来返回预期消息public class MyAdviceException extends RuntimeException
  6. 拦截器
    • 指定一个拦截器类实现HandlerInterceptor接口中的preHandle方法
    • 在拦截器中获取token,解析token,执行拦截或放行
    • 在MvcConfig类中注册自定义的拦截器
  7. ThreadLocal
    • 指定一个工具类,用于在Threadlocal中存入移除token中解析的自定义当前登录用户信息
    • 在拦截器中成功解析token时存入
    • 在执行完所有方法回到拦截器中执行afterCompletion时移除

演示项目

  1. 数据库创建
    这里只创建一个用于演示的用户表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    create database if not exists loginImplementation;

    use loginImplementation;

    create table tb_user(
    id bigint auto_increment primary key comment '主键id',
    username varchar(30) unique null comment '用户名',
    password varchar(100) null comment '密码',
    name varchar(50) null comment '姓名',
    phone varchar(11) null comment '电话号码'
    )
  2. 创建一个简单的maven项目,并继承spring-boot-starter-parent依赖,引入如下依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.xnj</groupId>
    <artifactId>AboutLoginImplementation</artifactId>
    <version>1.0-SNAPSHOT</version>


    <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.0.5</version>
    </parent>

    <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>


    <dependencies>
    <!--web启动器-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 数据库相关配置启动器 jdbctemplate 事务相关-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <!--mybatis启动器-->
    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>3.0.3</version>
    </dependency>

    <!-- druid启动器的依赖 -->
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-3-starter</artifactId>
    <version>1.2.22</version>
    </dependency>

    <!-- mysql驱动-->
    <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
    </dependency>

    <!--EasyCaptcha依赖-->
    <dependency>
    <groupId>com.github.whvcse</groupId>
    <artifactId>easy-captcha</artifactId>
    <version>1.6.2</version>
    </dependency>

    <!--redis-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!--用于加密密码-->
    <dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    </dependency>

    <!--lombok-->
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    </dependency>

    <!--JWT登录认证相关-->
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
    </dependency>

    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
    </dependency>

    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
    </dependency>

    </dependencies>

    </project>

    com.xnj下创建启动类:

    1
    2
    3
    4
    5
    6
    @SpringBootApplication
    public class App {
    public static void main(String[] args) {
    SpringApplication.run(App.class, args);
    }
    }
  3. 配置信息
    配置端口号,mysql数据库连接以及redis连接

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    server:
    port: 8080

    spring:
    datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    druid:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.40.101:3306/loginImplementation?useUnicode=true&characterEncoding=utf-8&useSSL=false& allowPublicKeyRetrieval=true&serverTimezone=GMT%2b8
    username: root
    password: Xnj.123456
    data:
    redis:
    host: 192.168.40.101
    password: 123456
    port: 6379
    database: 10
  4. 创建实体类

    • 包名:com.xnj.pojo
    • User: 代表用户,包括id,username,password,name,phone。
    • CaptchaVo:代表图形验证码,包括验证码的图片信息image和key。
    • LoginVo:用于接收登录信息,包括username和password,以及验证码的key和用户填的值code。
    • LoginUser:用于在ThreadLocal中存入的当前登录用户基础信息。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      //用户
      @Data
      public class User {

      private Long id;
      private String username;
      private String password;
      private String name;
      private String phone;
      }


      //"图像验证码"
      @Data
      @AllArgsConstructor
      public class CaptchaVo {

      //验证码图片信息,这里并不指图片的url,而是图片本身
      private String image;

      //验证码key
      private String key;
      }


      //接收登录信息
      @Data
      public class LoginVo {
      //用户名
      private String username;
      //密码
      private String password;
      //验证码
      private String code;
      //验证码的key
      private String key;
      }


      //ThreadLocal中存入的当前登录用户信息
      @Data
      @AllArgsConstructor
      public class LoginUser {

      private Long userId;
      private String username;
      }
  5. Mapper接口
    演示项目中用于获取用户信息的相关接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Mapper
    public interface UserMapper {

    //获取所有用户信息
    @Select("select * from tb_user")
    List<User> getAll();

    //通过id查询用户信息
    @Select("select * from tb_user where id=#{id}")
    User selectById(Long id);

    //通过用户名查找用户数
    @Select("select count(*) from tb_user where username=#{username}")
    long countByUsername(String username);

    //保存用户信息
    @Insert("insert into tb_user(username,password) values(#{username},#{password})")
    void insert(User user);

    //通过用户名获取用户信息
    @Select("select * from tb_user where username=#{username}")
    User selectByUsername(String username);
    }
  6. 统一结果相应result

    • 包路径:com.xnj.result
    • Result: 用于统一返回结果
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      //全局统一返回结果类
      @Data
      public class Result<T> {

      //返回码
      private Integer code;

      //返回消息
      private String message;

      //返回数据
      private T data;

      public Result() {
      }

      private static <T> Result<T> build(T data) {
      Result<T> result = new Result<>();
      if (data != null)
      result.setData(data);
      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> ok(T data) {
      return build(data, ResultCodeEnum.SUCCESS);
      }

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

      public static <T> Result<T> fail() {
      return build(null, ResultCodeEnum.FAIL);
      }

      public static <T> Result<T> fail(Integer code, String message) {
      Result<T> result = build(null);
      result.setCode(code);
      result.setMessage(message);
      return result;
      }
      }
    • ResultCodeEnum:用于统一返回结果状态信息枚举类
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      /**
      * 统一返回结果状态信息类
      */
      @Getter
      public enum ResultCodeEnum {
      USER_IS_EXIST_ERROR(301,"用户已存在"),
      USER_NOT_EXIST_ERROR(301, "账号不存在"),
      USER_LOGIN_CODE_EMPTY(503,"验证码为空"),
      USER_CAPTCHA_CODE_EXPIRED(303, "验证码已过期"),
      USER_LOGIN_CODE_ERROR(302,"验证码错误"),
      USER_LOGIN_PASSWORD_ERROR(307, "密码错误"),
      USER_LOGIN_AUTH(600, "用户未登录"),
      TOKEN_EXPIRED(601, "token过期"),
      TOKEN_INVALID(602, "token非法"),
      SUCCESS(200,"success"),
      FAIL(500,"fail");

      private final Integer code;

      private final String message;

      ResultCodeEnum(Integer code, String message) {
      this.code = code;
      this.message = message;
      }
      }
  7. 全局异常以及自定义异常

    • 包路径:com.xnj.exception
    • MyAdviceException: 继承RuntimeException,自定义异常返回信息
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Data
      public class MyAdviceException extends RuntimeException{

      private Integer code;

      public MyAdviceException(Integer code, String message) {
      super(message);
      this.code = code;
      }

      public MyAdviceException(ResultCodeEnum resultCodeEnum) {
      super(resultCodeEnum.getMessage());
      this.code = resultCodeEnum.getCode();
      }
      }
    • GlobalExceptionHandler: 定义全局异常@RestControllerAdvice,在里面使用我们自定义的异常
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      //全局异常处理类
      @RestControllerAdvice
      public class GlobalExceptionHandler {

      @ResponseBody
      @ExceptionHandler(MyAdviceException.class)
      public Result handlerException(MyAdviceException e){
      e.printStackTrace();
      return Result.fail(e.getCode(),e.getMessage());
      }
      }
  8. 工具类

    • 包路径:com.xnj.utils
    • JWTUtil:用于提供创建token,解析token的工具类
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      public class JWTUtil {

      // 密钥
      private static SecretKey secretKey = Keys.hmacShaKeyFor("UG8A6u22CD0MivcGUvtl1IFkMDV2xJae".getBytes());

      // 生成token
      public static String createToken(Long userId, String username){
      String token = Jwts.builder()
      .setExpiration(new Date(System.currentTimeMillis()+3600000)) //过期时间1小时
      .setSubject("LOGIN_USER") //主题
      .claim("userId",userId) //用户id
      .claim("userName",username) //用户名
      .signWith(secretKey, SignatureAlgorithm.HS256)
      .compact();

      return token;
      }


      //解析token
      public static Claims parseToken(String token){
      if(token==null){
      // 抛出异常,未登录
      throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_AUTH);
      }

      try {
      // 解析token
      JwtParser jwtParser = Jwts.parserBuilder()
      .setSigningKey(secretKey)
      .build();
      Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
      return claimsJws.getBody();
      } catch (ExpiredJwtException e) {
      throw new MyAdviceException(ResultCodeEnum.TOKEN_EXPIRED);
      } catch (JwtException e) {
      throw new MyAdviceException(ResultCodeEnum.TOKEN_INVALID);
      }
      }

      //测试
      public static void main(String[] args) {
      System.out.println(createToken(1l, "admin"));
      }
      }
    • LoginUserThreadLocal:创建ThreadLocal对象,存储当前登录用户信息的工具类
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Component
      public class LoginUserThreadLocal {

      public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

      public static void setLoginUser(LoginUser loginUser) {
      threadLocal.set(loginUser);
      }

      public static LoginUser getLoginUser() {
      return threadLocal.get();
      }

      public static void clear() {
      threadLocal.remove();
      }
      }
  9. 拦截器

    • 包路径:com.xnj.interceptor
      • LoginInterceptor: 实现HandlerInterceptor来实现拦截功能
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        @Component
        public class LoginInterceptor implements HandlerInterceptor {

        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取token
        String token = request.getHeader("authorization");
        //2. 解析token
        Claims claims = JWTUtil.parseToken(token);

        //3.没有抛异常说明解析成功
        //3.1将token中解析的用户信息存入LocalThread中
        Long userId = claims.get("userId", Long.class);
        String userName = claims.get("userName", String.class);
        LoginUserThreadLocal.setLoginUser(new LoginUser(userId,userName));
        //放行
        return true;
        }

        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //4. 移除LocalThread中的用户信息
        LoginUserThreadLocal.clear();
        }
        }
    • 包路径:com.xnj.config
      • MvcConfig: 实现WebMvcConfigurer注册拦截器
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        @Configuration
        public class MvcConfig implements WebMvcConfigurer {

        @Autowired
        private LoginInterceptor loginInterceptor;

        @Override
        public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(this.loginInterceptor)
        .addPathPatterns("/user/service/**")
        .excludePathPatterns("/user/login/**");
        }
        }
  10. controller接口

    • 包路径:com.xnj.controller
    • ServiceController: 代表一般项目中除登录逻辑以外的功能
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      /**
      * user/service/**用于代表user的各种服务
      */
      @RestController
      @RequestMapping("user/service")
      public class ServiceController {

      @Autowired
      private UserMapper userMapper;

      //获取所有用户信息
      @GetMapping("all")
      public Result<List<User>> findAllUser(){
      List<User> all = userMapper.getAll();
      return Result.ok(all);
      }

      //通过id获取用户信息
      @GetMapping("id/{id}")
      public Result<User> findById(@PathVariable("id") Long id){
      User user =userMapper.selectById(id);
      return Result.ok(user);
      }

      //获取当前登录用户信息
      @GetMapping("info")
      public Result<User> findInfo(){
      User user = userMapper.selectById(LoginUserThreadLocal.getLoginUser().getUserId());
      return Result.ok(user);
      }
      }
    • LoginController:代表登录相关逻辑
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      @RestController
      @RequestMapping("user/login")
      public class LoginController {

      @Autowired
      private UserMapper userMapper;


      @Autowired
      private StringRedisTemplate stringRedisTemplate;

      //获取图形验证码
      @GetMapping("captcha")
      public Result<CaptchaVo> getCode(){
      //设置图形验证码的参数:长,宽,验证码位数
      SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);

      //生成二进制验证码图片,并将其代表的验证码设置为小写
      String code = specCaptcha.text().toLowerCase();

      //设置验证码在redis中的key
      String key = "user:login:"+ UUID.randomUUID();

      //将验证码存入redis,并设置过期时间,3分钟
      stringRedisTemplate.opsForValue().set(key,code,3, TimeUnit.MINUTES);

      CaptchaVo captchaVo = new CaptchaVo(specCaptcha.toBase64(),key);

      return Result.ok(captchaVo);
      }


      //注册
      @PostMapping("register")
      public Result register(@RequestBody User user){
      //1.判断用户名是否已经存在
      long count =userMapper.countByUsername(user.getUsername());
      if(count>0){
      throw new MyAdviceException(ResultCodeEnum.USER_IS_EXIST_ERROR);
      }

      //2.将密码加密
      user.setPassword(md5Hex(user.getPassword()));

      //3.添加用户
      userMapper.insert(user);

      return Result.ok();
      }



      //登录
      @PostMapping()
      public Result login(@RequestBody LoginVo loginVo){
      //1. 判断前端传的验证码是否为空
      if(loginVo.getCode()==null){
      //抛出异常,验证码为空
      throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_CODE_EMPTY);
      }

      //2. 从redis中获取验证码,并判断验证码是否过期
      String code = stringRedisTemplate.opsForValue().get(loginVo.getKey());
      if(code==null){
      //抛出异常,验证码国企
      throw new MyAdviceException(ResultCodeEnum.USER_CAPTCHA_CODE_EXPIRED);
      }

      //3. 验证验证码,判断验证码是否正确
      if(!loginVo.getCode().equals(code)){
      //抛出异常,验证码错误
      throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_CODE_ERROR);
      }

      //2. 判断用户是否存在
      User user = userMapper.selectByUsername(loginVo.getUsername());
      if(user==null){
      //抛出异常,用户不存在
      throw new MyAdviceException(ResultCodeEnum.USER_NOT_EXIST_ERROR);
      }

      //3. 判断密码是否正确
      if(!user.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))){
      //抛出异常,密码错误
      throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_PASSWORD_ERROR);
      }

      //4. 登陆成功生成token
      String token = JWTUtil.createToken(user.getId(), user.getUsername());

      return Result.ok(token);
      }

      }

密码处理

  1. 形式:
    用户的密码不会以明文的形式保存到数据库中,而是先经过处理,然后将处理之后的密文保存到数据库,这样能降低数据泄露导致的用户安全问题

  2. 处理:
    密码通常会使用单向函数进行处理:

    • 【前端】->【明文:123456】->【单向函数】->【密文:e10adc3949ba59abbe56e057f20f883e】->【数据库】
  3. 依赖:
    常用的处理密码的单向函数(算法)有MD5SHA-256等,Apache Commons提供了一个工具类DigestUtils,其中就包含上述算法的实现。
    引入maven依赖如下:

    1
    2
    3
    4
    5
    <dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.15</version>
    </dependency>
  4. 基本用法

    • Base64编码和解码:
      • 用途:将二进制数据编码为可打印的ASCII字符串,或将Base64编码的字符串解码回原始二进制数据。
      • 适用场景:常用于在网络传输中传递二进制数据,或将二进制数据存储在文本文件中。
    • URL编码和解码:
      • 用途:将字符串进行URL编码,以便在URL中传递参数或数据。URL编码将特殊字符转换为%xx格式,其中xx是字符的十六进制ASCII码。
      • 适用场景:常用于构建URL参数、解析URL参数,以及在网络中传递URL编码的数据。
    • MD5哈希生成:
      • 用途:通过对任意长度的数据进行哈希计算,生成固定长度(通常是128位)的哈希值。MD5哈希算法不可逆,相同的输入将始终生成相同的哈希值。
      • 适用场景:常用于验证数据的完整性、存储密码的摘要,或用于简单的数据唯一性标识。
    • SHA哈希生成:
      • 用途:通过对任意长度的数据进行哈希计算,生成固定长度(通常是160位或256位)的哈希值。SHA哈希算法是安全的,用于验证数据的完整性和加密。
      • 适用场景:常用于数据完整性验证、数字签名、密码存储和加密等安全领域的应用。
  5. 使用实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    @SpringBootTest
    public class CommonsCodecTest {
    //1. md哈希生成
    @Test
    public void test01(){
    // 原始文本
    String originalText = "Hello, World!";
    // 计算MD5哈希值
    String md5Hash = DigestUtils.md5Hex(originalText);
    System.out.println("MD5 Hash: " + md5Hash);//MD5 Hash: 65a8e27d8879283831b664bd8b7f0ad4
    }

    //2. SHA哈希生成
    @Test
    public void test02(){
    // 原始文本
    String originalText = "Hello, World!";
    // 计算sha哈希值
    String sha1Hash = DigestUtils.sha1Hex(originalText);
    System.out.println("SHA-1 Hash: " + sha1Hash);//SHA-1 Hash: 0a0a9f2a6772942557ab5355d76af442f8f65e01
    }

    //3. Base64编码和解码
    @Test
    public void test03(){
    // 原始文本
    String originalText = "Hello, World!";
    // 计算SHA-256哈希值
    byte[] encodedBytes = Base64.encodeBase64(originalText.getBytes());
    // 编码为字符串
    String encodedText = new String(encodedBytes);
    System.out.println("Encoded Text: " + encodedText);//Encoded Text: SGVsbG8sIFdvcmxkIQ==
    // 解码
    byte[] decodedBytes = Base64.decodeBase64(encodedText.getBytes());
    // 解码为字符串
    String decodedText = new String(decodedBytes);
    System.out.println("Decoded Text: " + decodedText);//Decoded Text: Hello, World!
    }

    //4. URL编码和解码
    @Test
    public void test04() throws EncoderException, DecoderException {
    // 原始文本
    String originalText = "Hello, World!";
    // 编码和解码
    URLCodec urlCodec = new URLCodec();
    // 编码
    String encodedText = urlCodec.encode(originalText);
    System.out.println("Encoded Text: " + encodedText);//Encoded Text: Hello%2C+World%21
    // 解码
    String decodedText = urlCodec.decode(encodedText);
    System.out.println("Decoded Text: " + decodedText);//Decoded Text: Hello, World!
    }

    }
  6. 总结:
    这些方法在实际应用中经常被使用,根据具体的需求选择合适的方法进行编码和解码操作。请注意,MD5和SHA哈希算法虽然常用,但并不适合用于密码存储,因为它们容易受到暴力破解和碰撞攻击。对于密码存储,请使用专门设计的密码哈希函数(如bcrypt、scrypt或PBKDF2)。
    上面的演示项目使用的MD5加密:

    1
    user.setPassword(DigestUtils.md5Hex(user.getPassword()));

图形验证码处理

  1. 说明
    这里使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档
    为了方便发送和验证图形验证码,以及完成验证码失效机制,往往利用redis的键值对的存储形式,创建一个类,包含keycode,并在存入redis中时设置有效时间

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //"图像验证码"
    @Data
    @AllArgsConstructor
    public class CaptchaVo {

    //验证码图片信息,这里并不指图片的url,而是图片本身
    private String image;

    //验证码key
    private String key;
    }
  2. 引入maven依赖

    1
    2
    3
    4
    5
    6
    <!--EasyCaptcha依赖-->
    <dependency>
    <groupId>com.github.whvcse</groupId>
    <artifactId>easy-captcha</artifactId>
    <version>1.6.2</version>
    </dependency>
  3. 使用

    • 在创建SpecCaptcha对象时指定验证码图片的长宽验证码的位数
    • specCaptcha.setCharType();来设置验证码的样式
    • specCaptcha.text();获取验证码文本
    • specCaptcha.toBase64();将生成的验证码图片转换为Base64字符串
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @SpringBootTest
    public class CaPtchaTest {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //生成验证码
    @Test
    public void test(){
    // 创建SpecCaptcha实例,定义验证码图片:长,宽,验证码位数
    SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);

    // 设置验证码样式参数 TYPE_DEFAULT=>数字字母混合
    specCaptcha.setCharType(SpecCaptcha.TYPE_DEFAULT);

    //获取验证码文本,并将其代表的验证码设置为小写
    String code = specCaptcha.text().toLowerCase();
    System.out.println("验证码文本: " + code);//验证码文本: kdh7

    //将生成的验证码图片转换为Base64字符串
    String codeImage = specCaptcha.toBase64();
    System.out.println(codeImage);
    }
    }

全局异常与自定义异常

  1. 说明
    在登录逻辑中,无论是业务逻辑判断还是生成token和解析token时,出现错误信息时如,没有token,密码错误等,我们都可以自定义异常,再在全局异常中捕获来返回我们需要的异常信息

  2. 定义自定义异常

    • 指定一个类,继承需要的异常类如RuntimeException
    • 在该类中创建我们需要传给前端的信息如code,message
      • 常见的状态码如下:
        • 200 : 请求成功
        • 404 : 请求资源不存在
        • 500 : 服务器错误
        • 更多信息:响应状态码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //自定义异常
    @Data
    public class MyAdviceException extends RuntimeException{

    private Integer code;

    public MyAdviceException(Integer code, String message) {
    super(message);
    this.code = code;
    }

    }
  3. 定义全局异常处理器

    • 指定一个类,加上相应注解@RestControllerAdvice@ControllerAdvice
    • 在该类下面定义方法,方法上加上对应注解@ExceptionHandler()
    • 更多信息:springboo3/springboo3整合Mvc/全局异常
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //全局异常处理类
    @RestControllerAdvice
    public class GlobalExceptionHandler {

    @ResponseBody
    @ExceptionHandler(MyAdviceException.class)
    public Result handlerException(MyAdviceException e){
    e.printStackTrace();
    return Result.fail(e.getCode(),e.getMessage());
    }
    }

拦截器

  1. 说明

    • 创建一个类实现HandlerInterceptor接口实现登录拦截功能
    • 重写preHandleafterCompletion方法
    • preHandle:在每次请求到来时,进行拦截,解析验证token的合法性
      若需要,可将token中解析出的信息存入ThreadLocal
    • afterCompletion: 结束所有请求后,销毁ThreadLocal中存储的信息
  2. 实现

    • 定义拦截器
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Component
      public class MyInterceptor implements HandlerInterceptor {

      @Override
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      return true;
      }

      @Override
      public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
      }
      }
    • 注册拦截器
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Configuration
      public class MvcConfig implements WebMvcConfigurer {

      @Autowired
      private MyInterceptor myInterceptor;

      @Override
      public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(new MyInterceptor())
      .addPathPatterns("拦截的请求路径")
      .excludePathPatterns("排除的路径");
      }
      }

Java-JWT

  1. 登录接口需要为登录成功的用户创建并返回JWT

  2. 引入maven依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!--JWT登录认证相关-->
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.2</version>
    </dependency>

    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
    </dependency>

    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.2</version>
    <scope>runtime</scope>
    </dependency>
  3. 说明
    创建一个JwtUtil工具类,里面提供创建token和解析token的方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    public class JWTUtil {
    // 密钥 密钥可以去搜索随机密码
    private static SecretKey secretKey = Keys.hmacShaKeyFor("密钥".getBytes());

    // 生成token
    public static String createToken(Long userId, String username){
    String token = Jwts.builder()
    .setExpiration(new Date(System.currentTimeMillis()+3600000)) //过期时间1小时
    .setSubject("LOGIN_USER") //主题
    .claim("userId",userId) //用户id
    .claim("userName",username) //用户名
    .signWith(secretKey, SignatureAlgorithm.HS256)
    .compact();

    return token;
    }

    //解析token
    public static Claims parseToken(String token){
    if(token==null){
    // 抛出异常,未登录
    throw new MyAdviceException(ResultCodeEnum.USER_LOGIN_AUTH);
    }

    try {
    // 解析token
    JwtParser jwtParser = Jwts.parserBuilder()
    .setSigningKey(secretKey)
    .build();
    Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
    return claimsJws.getBody();
    } catch (ExpiredJwtException e) {
    throw new MyAdviceException(ResultCodeEnum.TOKEN_EXPIRED);
    } catch (JwtException e) {
    throw new MyAdviceException(ResultCodeEnum.TOKEN_INVALID);
    }

    }

    //测试
    public static void main(String[] args) {
    System.out.println(createToken(1l, "admin"));
    }
    }
  4. 使用

    • 在登录的接口中,当用户账号各校验工作后,返回token给前端
      1
      2
      3
      //登陆成功生成token
      String token = JWTUtil.createToken(user.getId(), user.getUsername());
      return Result.ok(token);
    • 在拦截器中,需要对token进行解析
      1
      2
      3
      4
      //1. 获取token
      String token = request.getHeader("authorization");
      //2. 解析token
      Claims claims = JWTUtil.parseToken(token);

ThreadLocal

  1. 说明
    ThreadLocal是Java中提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据

    • ThreadMap下存一个个实体entry
      • entry:包含keyvalue部分
        • key:存放threadlocal
        • value: 存放obbject
    • 实际就是操作当前线程上面的ThreadLocalMap,将threadLocal放在key上面,再将threadlocal存储的值放在map的value当中。
    • 如果你在当前实体上使用了多个threadlocal,它会存储多个entry放在Map当中
    • ThreadLocal只是用来操作当前线程的ThreadLocalMap的工具类。
      • set就是往ThreadLocalMap中存放,Map的key就是threadLoacl对象,value为需要缓存的值
  2. 关于Threadlocal内存泄漏问题
    在使用了ThreadLocal对象后,手动调用ThreadLocal的remove方法,手动清除Entry对象

  3. 使用场景
    当一个共享变量是共享的,但是需要每个线程互不影响,相互隔离,就可以使用ThreadLocal

  4. 应用实例

    • 如我们上面的案例,我们需要往Thread中存储当前登录用户LoginUserVo对象,定义如下工具类
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Component
      public class LoginUserThreadLocal {

      public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();

      public static void setLoginUser(LoginUser loginUser) {
      threadLocal.set(loginUser);
      }

      public static LoginUser getLoginUser() {
      return threadLocal.get();
      }

      public static void clear() {
      threadLocal.remove();
      }
      }
    • 存:当我们在拦截器中验证token完成,即将放行时,将信息存入
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      //1. 获取token
      String token = request.getHeader("authorization");

      //2. 解析token
      Claims claims = JWTUtil.parseToken(token);

      //3.没有抛异常说明解析成功
      //3.1将token中解析的用户信息存入LocalThread中
      Long userId = claims.get("userId", Long.class);
      String userName = claims.get("userName", String.class);
      LoginUserThreadLocal.setLoginUser(new LoginUser(userId,userName));
      //3.2 放行
      return true;
    • 取:查询当前登录用户信息时,从thread取出关键信息,再用该信息去查询用户详细信息
      1
      2
      3
      4
      5
        User user = userMapper.selectById(LoginUserThreadLocal.getLoginUser().getUserId());
      ```
      - 销毁:为防止内存泄漏,在使用完后需要销毁
      ```java
      LoginUserThreadLocal.clear();

短信验证码实现

  1. 说明

    • 当用户在前端登录时,输入电话号码,点击发送验证码,即可获取短信验证码
    • 这里使用阿里的短信发送服务
  2. 开通阿里云短信服务

    • 阿里云官网,注册阿里云账号,并按照指引,完成实名认证(不认证,无法购买服务)
    • 找到短信服务,选择免费开通
    • 进入短信服务控制台,选择快速学习和测试
    • 找到发送测试下的API发送测试,绑定测试用的手机号(只有绑定的手机号码才能收到测试短信),然后配置短信签名和短信模版,这里选择[专用]测试签名/模版
    • 创建AccessKey
      云账号AccessKey是访问阿里云API的密钥,没有AccessKey无法调用短信服务。
      在阿里云首页点击右上角头像,选择AccessKey,然后创建Accesskey
      AccessSecretKey只会在创建时显示一次,需及时保存记住
  3. 实现流程

    • 引入如下依赖,具体可参考官方文档
      1
      2
      3
      4
      5
      <dependency>
      <groupId>com.aliyun</groupId>
      <artifactId>dysmsapi20170525</artifactId>
      <version>3.1.0</version>
      </dependency>
    • 配置环境
      1
      2
      3
      4
      5
      6
      # 短信验证码配置
      aliyun:
      sms:
      access-key-id:
      access-key-secret:
      endpoint: dysmsapi.aliyuncs.com
    • 创建一个属性类用于获取配置属性
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      package com.xnj.sms;

      @Data
      @ConfigurationProperties(prefix = "aliyun.sms")
      public class AliyunSMSProperties {

      private String accessKeyId;

      private String accessKeySecret;

      private String endpoint;
      }
    • 创建一个配置类,用于获取Client对象
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      package com.xnj.sms;

      import com.aliyun.dysmsapi20170525.Client;
      import com.aliyun.teaopenapi.models.Config;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
      import org.springframework.boot.context.properties.EnableConfigurationProperties;
      import org.springframework.context.annotation.Bean;
      import org.springframework.context.annotation.Configuration;

      @Configuration
      @EnableConfigurationProperties(AliyunSMSProperties.class)
      @ConditionalOnProperty(name = "aliyun.sms.endpoint")
      public class AliyunSmsConfiguration {
      @Autowired
      private AliyunSMSProperties properties;
      @Bean
      public Client createClient() {
      // 创建Client对象
      Config config = new Config();
      // 配置AccessKeyId和AccessKeySecret
      config.setAccessKeyId(properties.getAccessKeyId());
      config.setAccessKeySecret(properties.getAccessKeySecret());
      // 配置Endpoint
      config.setEndpoint(properties.getEndpoint());
      try {
      // 实例化Client对象
      return new Client(config);
      } catch (Exception e) {
      throw new RuntimeException(e);
      }
      }
      }
      一定要注意别引错包了,不确定可以看官网,写得很清楚
    • 写一个工具类,用于生成我们想要的验证码格式,下面的仅作示例参考
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Component
      public class CodeUtil {

      public static String getRandomCode(Integer length){
      StringBuilder builder = new StringBuilder();
      Random random = new Random();
      for (int i = 0; i < length; i++) {
      int number = random.nextInt(10);
      builder.append(number);
      }
      return builder.toString();
      }
      }
    • 测试发送
      注意,因为为测试使用,所以电话号码只能填你在阿里云里填的测试电话号码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      @SpringBootTest
      public class PhoneCodeTest {

      @Autowired
      private Client client;

      @Test
      public void test01() throws Exception {
      // 手机号
      String phone = "";
      // 验证码
      String code = CodeUtil.getRandomCode(4);
      //创建一个发送短信的请求对象
      SendSmsRequest request = new SendSmsRequest();
      //短信接收号码
      request.setPhoneNumbers(phone);
      //短信签名
      request.setSignName("阿里云短信测试");
      //短信模板code
      request.setTemplateCode("SMS_154950909");
      // 短信模板变量 参数必需以json字符串的形式
      request.setTemplateParam("{\"code\":\"" + code + "\"}");
      // 发送短信
      client.sendSms(request);
      }
      }
  4. 业务中实现

    1. 引入依赖,参考上面3.实现流程
    2. 配置文件中创建属性(access-key-id,access-key-secret,endpoint),参考上面3.实现流程
    3. 创建配置属性类和配置类,以及生成验证码的工具类,参考上面3实现流程
    4. 创建发送短信服务接口
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      //接口类
      public interface CodeService {

      void sendCode(String phone,String code);
      }

      //实现类
      @Service
      public class CodeServiceImpl implements CodeService {

      @Autowired
      private Client client;

      @Override
      public void sendCode(String phone, String code) {
      // 发送验证码
      SendSmsRequest request = new SendSmsRequest();
      //短信接收号码
      request.setPhoneNumbers(phone);
      //短信签名
      request.setSignName("阿里云短信测试");
      //短信模板code
      request.setTemplateCode("SMS_154950909");
      // 短信模板变量 参数必需以json字符串的形式
      request.setTemplateParam("{\"code\":\"" + code + "\"}");
      // 发送短信
      try {
      client.sendSms(request);
      } catch (Exception e) {
      throw new RuntimeException(e);
      }
      }
      }
    5. 引入redis依赖
      1
      2
      3
      4
      5
      <!--redis-->
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
    6. 在LoginService中实现具体发送验证码登录逻辑
      • 前端传入用户输入的手机号,点击发送验证码后
      • 我们生成验证码和验证码的key(key能标识当前用户手机号)
      • 判断redis中是否已经存在该用户请求发送的验证码
        • 存在,获取旧验证码的有效时间
        • 用我们预先设置的验证码有效时间 减去 旧验证码的剩余有效时间 满足我们设置的最低间隔时间
        • 满足,发送新验证码;不满足,返回请求频繁信息,终止这次发送
      • 发送新验证码,将新验证码以及key存入redis中,用于后续校验
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        //接口
        public interface LoginService {

        void sentCode(String phone);
        }

        //实现类
        @Service
        public class LoginServiceImpl implements LoginService {

        @Autowired
        private CodeService codeService;

        @Autowired
        private StringRedisTemplate redisTemplate;

        //发送验证码
        @Override
        public void sentCode(String phone) {
        //生成验证码
        String code = CodeUtil.getRandomCode(4);
        //验证码key
        String key="user:code"+phone;

        //防刷
        //或redis中查找该key是否存在
        Boolean haskey = redisTemplate.hasKey(key);
        //如果存在,说明该手机号在5分钟内已发送过验证码
        if(haskey){
        //获取旧验证码的剩余有效时间
        Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
        //如果该用户的间隔时间小于1分钟
        if(60*5- ttl < 60){
        throw new MyAdviceException(403,"验证码发送过于频繁,请稍后再试");
        }
        }

        //发送验证码
        codeService.sendCode(phone,code);

        //保存验证码到redis,并设置有效时间5分钟
        redisTemplate.opsForValue().set(key,code,60*5, TimeUnit.SECONDS);
        }
        }
        1
        2
        3
        4
        5
        6
        7
        @GetMapping("code")
        public Result sentCode(@RequestParam String phone){

        loginService.sentCode(phone);

        return Result.ok();
        }