【尚庭公寓SpringBoot + Vue 项目实战】登录管理(十八)

【尚庭公寓SpringBoot + Vue 项目实战】登录管理(十八)


1、登录业务介绍

登录管理共需三个接口,分别是获取图形验证码登录获取登录用户个人信息,除此之外,我们还需为所有受保护的接口增加验证JWT合法性的逻辑,这一功能可通过HandlerInterceptor来实现。后台管理系统的登录流程如下图所示

image-20240619221355636

2、接口开发
2.1、获取图形验证码

查看接口

image-20240619221628521

代码开发

  • 查看响应的数据结构

    查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.CaptchaVo,内容如下

    @Data
    @Schema(description = "图像验证码")
    @AllArgsConstructor
    public class CaptchaVo {
    
        @Schema(description="验证码图片信息")
        private String image;
    
        @Schema(description="验证码key")
        private String key;
    }
    
  • 配置所需依赖

    • 验证码生成工具

      本项目使用开源的验证码生成工具EasyCaptcha,其支持多种类型的验证码,例如gif、中文、算术等,并且简单易用,具体内容可参考其官方文档

      common模块的pom.xml文件中增加如下内容

      <dependency>
          <groupId>com.github.whvcse</groupId>
          <artifactId>easy-captcha</artifactId>
      </dependency>
      
    • Redis

      common模块的pom.xml中增加如下内容

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

      application.yml中增加如下配置

      spring:
        data:
          redis:
            host: <hostname>
            port: <port>
            password:<password>
            database: 0
      

      注意:上述hostnamepasswordport需根据实际情况进行修改,,如果你redis没有密码,可以省略

  • 编写Controller层逻辑

    LoginController中增加如下内容

    @Operation(summary = "获取图形验证码")
    @GetMapping("login/captcha")
    public Result<CaptchaVo> getCaptcha() {
        CaptchaVo captcha = service.getCaptcha();
        return Result.ok(captcha);
    }
    
  • 编写Service层逻辑

    • LoginService中增加如下内容

      CaptchaVo getCaptcha();
      
    • LoginServiceImpl中增加如下内容

      @Autowired
      private StringRedisTemplate redisTemplate;
      
      @Override
      public CaptchaVo getCaptcha() {
          SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
          specCaptcha.setCharType(Captcha.TYPE_DEFAULT);
      
          String code = specCaptcha.text().toLowerCase();
          String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
          String image = specCaptcha.toBase64();
          redisTemplate.opsForValue().set(key, code, RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);
      
          return new CaptchaVo(image, key);
      }
      

      知识点

      • 本项目Reids中的key需遵循以下命名规范:项目名:功能模块名:其他,例如admin:login:123456

      • spring-boot-starter-data-redis已经完成了StringRedisTemplate的自动配置,我们直接注入即可。

      • 为方便管理,可以将Reids相关的一些值定义为常量,例如key的前缀、TTL时长,内容如下。大家可将这些常量统一定义在common模块下的com.atguigu.lease.common.constant.RedisConstant类中

        public class RedisConstant {
            public static final String ADMIN_LOGIN_PREFIX = "admin:login:";
            public static final Integer ADMIN_LOGIN_CAPTCHA_TTL_SEC = 60;
            public static final String APP_LOGIN_PREFIX = "app:login:";
            public static final Integer APP_LOGIN_CODE_RESEND_TIME_SEC = 60;
            public static final Integer APP_LOGIN_CODE_TTL_SEC = 60 * 10;
            public static final String APP_ROOM_PREFIX = "app:room:";
        }
        
2.2、登录接口

查看接口

image-20240619221948522

登录校验逻辑

用户登录的校验逻辑分为三个主要步骤,分别是校验验证码校验用户状态校验密码,具体逻辑如下

  • 前端发送usernamepasswordcaptchaKeycaptchaCode请求登录。
  • 判断captchaCode是否为空,若为空,则直接响应验证码为空;若不为空进行下一步判断。
  • 根据captchaKey从Redis中查询之前保存的code,若查询出来的code为空,则直接响应验证码已过期;若不为空进行下一步判断。
  • 比较captchaCodecode,若不相同,则直接响应验证码不正确;若相同则进行下一步判断。
  • 根据username查询数据库,若查询结果为空,则直接响应账号不存在;若不为空则进行下一步判断。
  • 查看用户状态,判断是否被禁用,若禁用,则直接响应账号被禁;若未被禁用,则进行下一步判断。
  • 比对password和数据库中查询的密码,若不一致,则直接响应账号或密码错误,若一致则进行入最后一步。
  • 创建JWT,并响应给浏览器。

代码开发

  • 查看请求数据结构

    查看web-admin模块下的com.atguigu.lease.web.admin.vo.login.LoginVo,具体内容如下

    @Data
    @Schema(description = "后台管理系统登录信息")
    public class LoginVo {
    
        @Schema(description="用户名")
        private String username;
    
        @Schema(description="密码")
        private String password;
    
        @Schema(description="验证码key")
        private String captchaKey;
    
        @Schema(description="验证码code")
        private String captchaCode;
    }
    
  • 配置所需依赖

    登录接口需要为登录成功的用户创建并返回JWT,本项目使用开源的JWT工具Java-JWT,配置如下,具体内容可参考官方文档

    • 引入Maven依赖

      common模块的pom.xml文件中增加如下内容

      <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-api</artifactId>
      </dependency>
      
      <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-impl</artifactId>
          <scope>runtime</scope>
      </dependency>
      
      <dependency>
          <groupId>io.jsonwebtoken</groupId>
          <artifactId>jjwt-jackson</artifactId>
          <scope>runtime</scope>
      </dependency>
      
    • 创建JWT工具类

      common模块下创建com.atguigu.lease.common.utils.JwtUtil工具类,内容如下

      public class JwtUtil {
      
          private static long tokenExpiration = 60 * 60 * 1000L;
          private static SecretKey tokenSignKey = Keys.hmacShaKeyFor("M0PKKI6pYGVWWfDZw90a0lTpGYX1d4AQ".getBytes());
      
          public static String createToken(Long userId, String username) {
              String token = Jwts.builder().
                      setSubject("USER_INFO").
                      setExpiration(new Date(System.currentTimeMillis() + tokenExpiration)).
                      claim("userId", userId).
                      claim("username", username).
                      signWith(tokenSignKey).
                      compact();
              return token;
          }
      }
      
  • 编写Controller层逻辑

    LoginController中增加如下内容

    @Operation(summary = "登录")
    @PostMapping("login")
    public Result<String> login(@RequestBody LoginVo loginVo) {
        String token = service.login(loginVo);
        return Result.ok(token);
    }
    
  • 编写Service层逻辑

    • LoginService中增加如下内容

      String login(LoginVo loginVo);
      
    • LoginServiceImpl中增加如下内容

      @Override
      public String login(LoginVo loginVo) {
          //1.判断是否输入了验证码
          if (!StringUtils.hasText(loginVo.getCaptchaCode())) {
              throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
          }
      
          //2.校验验证码
          String code = redisTemplate.opsForValue().get(loginVo.getCaptchaKey());
          if (code == null) {
              throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);
          }
      
          if (!code.equals(loginVo.getCaptchaCode().toLowerCase())) {
              throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);
          }
      
          //3.校验用户是否存在
          SystemUser systemUser = systemUserMapper.selectOneByUsername(loginVo.getUsername());
      
          if (systemUser == null) {
              throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR);
          }
      
          //4.校验用户是否被禁
          if (systemUser.getStatus() == BaseStatus.DISABLE) {
              throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);
          }
      
          //5.校验用户密码
          if (!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))) {
              throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR);
          }
      
          //6.创建并返回TOKEN
          return JwtUtil.createToken(systemUser.getId(), systemUser.getUsername());
      }
      
  • 编写Mapper层逻辑

    • LoginMapper中增加如下内容

      SystemUser selectOneByUsername(String username);
      
    • LoginMapper.xml中增加如下内容

      <select id="selectOneByUsername" resultType="com.atguigu.lease.model.entity.SystemUser">
          select id,
                 username,
                 password,
                 name,
                 type,
                 phone,
                 avatar_url,
                 additional_info,
                 post_id,
                 status
          from system_user
          where is_deleted = 0
            and username = #{username}
      </select>
      
  • 编写HandlerInterceptor

    我们需要为所有受保护的接口增加校验JWT合法性的逻辑。具体实现如下

    • JwtUtil中增加parseToken方法,内容如下

      public static Claims parseToken(String token){
      
          if (token==null){
              throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
          }
      
          try{
              JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
              return jwtParser.parseClaimsJws(token).getBody();
          }catch (ExpiredJwtException e){
              throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
          }catch (JwtException e){
              throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
          }
      }
      
    • 编写HandlerInterceptor

      web-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下,有关HanderInterceptor的相关内容,可参考官方文档

      @Component
      public class AuthenticationInterceptor implements HandlerInterceptor {
      
          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
              String token = request.getHeader("access-token");
              JwtUtil.parseToken(token);
              return true;
          }
      }
      

      注意

      我们约定,前端登录后,后续请求都将JWT,放置于HTTP请求的Header中,其Header的key为access-token

    • 注册HandlerInterceptor

      web-admin模块com.atguigu.lease.web.admin.custom.config.WebMvcConfiguration中增加如下内容

      @Autowired
      private AuthenticationInterceptor authenticationInterceptor;
      
      @Override
      public void addInterceptors(InterceptorRegistry registry) {
          registry.addInterceptor(this.authenticationInterceptor).addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
      }
      
  • Knife4j配置

    在增加上述拦截器后,为方便继续调试其他接口,可以获取一个长期有效的Token,将其配置到Knife4j的全局参数中,如下图所示。

    image-20240619222213976

    注意:每个接口分组需要单独配置,刷新页面,任选一个接口进行调试,会发现发送请求时会自动携带该header

2.3、获取登录用户个人信息

查看接口

image-20240619222327267

代码开发

  • 查看请求和响应的数据结构

    • 响应的数据结构

      查看web-admin模块下的com.atguigu.lease.web.admin.vo.system.user.SystemUserInfoVo,内容如下

      @Schema(description = "员工基本信息")
      @Data
      public class SystemUserInfoVo {
      
          @Schema(description = "用户姓名")
          private String name;
      
          @Schema(description = "用户头像")
          private String avatarUrl;
      }
      
    • 请求的数据结构

      按理说,前端若想获取当前登录用户的个人信息,需要传递当前用户的id到后端进行查询。但是由于请求中携带的JWT中就包含了当前登录用户的id,故请求个人信息时,就无需再传递id

  • 修改JwtUtil中的parseToken方法

    由于需要从Jwt中获取用户id,因此需要为parseToken 方法增加返回值,如下

    public static Claims parseToken(String token){
    
        if (token==null){
            throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);
        }
    
        try{
            JwtParser jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
            return jwtParser.parseClaimsJws(token).getBody();
        }catch (ExpiredJwtException e){
            throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED);
        }catch (JwtException e){
            throw new LeaseException(ResultCodeEnum.TOKEN_INVALID);
        }
    }
    
  • 编写ThreadLocal工具类

    理论上我们可以在Controller方法中,使用@RequestHeader获取JWT,然后在进行解析,如下

    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info(@RequestHeader("access-token") String token) {
        Claims claims = JwtUtil.parseToken(token);
        Long userId = claims.get("userId", Long.class);
        SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
        return Result.ok(userInfo);
    }
    

    上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。

    ThreadLocal概述

    ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,使每个线程都可以操作自己的变量,而不会互相干扰,其用法如下图所示。

    common模块中创建com.atguigu.lease.common.login.LoginUserHolder工具类

    public class LoginUserHolder {
        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();
        }
    }
    

    同时在common模块中创建com.atguigu.lease.common.login.LoginUser

    @Data
    @AllArgsConstructor
    public class LoginUser {
    
        private Long userId;
        private String username;
    }
    
  • 修改AuthenticationInterceptor拦截器

    @Component
    public class AuthenticationInterceptor implements HandlerInterceptor {
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
            String token = request.getHeader("access-token");
    
            Claims claims = JwtUtil.parseToken(token);
            Long userId = claims.get("userId", Long.class);
            String username = claims.get("username", String.class);
            LoginUserHolder.setLoginUser(new LoginUser(userId, username));
    
            return true;
    
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            LoginUserHolder.clear();
        }
    }
    
  • 编写Controller层逻辑

    LoginController中增加如下内容

    @Operation(summary = "获取登陆用户个人信息")
    @GetMapping("info")
    public Result<SystemUserInfoVo> info() {
        SystemUserInfoVo userInfo = service.getLoginUserInfo(LoginUserHolder.getLoginUser().getUserId());
        return Result.ok(userInfo);
    }
    
  • 编写Service层逻辑

    LoginService中增加如下内容

    @Override
    public SystemUserInfoVo getLoginUserInfo(Long userId) {
        SystemUser systemUser = systemUserMapper.selectById(userId);
        SystemUserInfoVo systemUserInfoVo = new SystemUserInfoVo();
        systemUserInfoVo.setName(systemUser.getName());
        systemUserInfoVo.setAvatarUrl(systemUser.getAvatarUrl());
        return systemUserInfoVo;
    }
    
  • 25
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小林学习编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值