【尚庭公寓springboot + vue的项目实战】- 后台登录模块

129 阅读8分钟

【尚庭公寓springboot + vue的项目实战】- 后台登录模块

一、 技术栈要求

1.JWT(json web token)

什么是token?

(Token)令牌是一个代表用户身份和权限的字符串,用于在客户端和服务器之间进行身份验证和授权。令牌可以是任何形式的字符串,通常由服务器生成并在客户端存储。令牌可以包含有关用户身份、访问权限、过期时间等信息。

我们所说的Token,通常指JWT(JSON Web TOKEN)。JWT是一种轻量级的安全传输方式,用于在两个实体之间传递信息,通常用于身份验证和信息传递。

JWT是一个字符串,如下图所示,该字符串由三部分组成,三部分由.分隔。三个部分分别被称为

  • header(头部)
  • payload(负载)
  • signature(签名)

例如如下就是一个完整的token,用小数点来分开三个部分

Snipaste_2025-02-14_18-49-18.png

各部分的作用如下:

  • Header(头部)

    Header部分是由一个Json对象经过经过base64url编码得到的,这个JSON对象用于保存JWT 的类型(type)、签名算法(alg)等元信息,例如

    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  • Payload(负载)

也称为 Claims(声明),也是由一个JSON对象经过base64url编码得到的,用于保存要传递的具体信息。JWT规范定义了7个官方字段,如下:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除此之外,我们还可以自定义任何字段,例如将我们要存储的信息写进

{
  "sub": "userInfo",
  "name": "John Doe",
  "iat": 1516239022
}
  • Signature(签名)**

    由头部、负载和秘钥一起经过(header中指定的签名算法)计算得到的一个字符串,用于防止消息被篡改。

2.图形验证码 ->easy-captcha工具**

3.单节点redis 保存验证码

4.ThreadLocal 保存token中的负载数据(登录用户信息)

二、 登录具体实现

1. 后台客户端登录模块

1.1 实现获取图形验证码
1.1.1引入相关依赖

在common模块对应的pom.xml文件下引入依赖(具体内容可以参考官方文档EasyCaptcha: Java图形验证码,支持gif、中文、算术等类型,可用于Java Web、JavaSE等项目。)

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

并在application.yml中增加如下配置

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

注意:上述hostnameport需根据实际情况进行修改

1.1.2 编写相关代码
  • com.atguigu.lease.web.admin.controller.login.LoginController类下
@Operation(summary = "获取图形验证码")
@GetMapping("login/captcha")
public Result<CaptchaVo> getCaptcha() {
    CaptchaVo captchaVo = loginService.getCaptcha();
    return Result.ok(captchaVo);
}
  • LoginService中增加如下内容:
CaptchaVo getCaptcha();
  • 进入LoginServiceImpl类下,编写getCaptcha()方法
    @Override
    public CaptchaVo getCaptcha() {
        //图片对象specCaptcha (设置图片的样式)
        SpecCaptcha specCaptcha = new SpecCaptcha(130, 48, 4);
        //验证码
        String verCode = specCaptcha.text().toLowerCase();
        //redis保存verCode验证码的key 
        String key = RedisConstant.ADMIN_LOGIN_PREFIX + UUID.randomUUID();
        //将验证码和指定前缀的随机key存入redis并设置过期时间
        stringRedisTemplate.opsForValue().set(key,verCode,RedisConstant.ADMIN_LOGIN_CAPTCHA_TTL_SEC, TimeUnit.SECONDS);

        //把图形验证码的Base64编码,和redis中对应验证码的key进行封装返回
        CaptchaVo captchaVo = new CaptchaVo(specCaptcha.toBase64(), key);
        return captchaVo;
    }
  • 为方便管理,可以将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;
}
1.2 登录接口
1.2.1 登录校验逻辑

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

  • 前端发送usernamepasswordcaptchaKeycaptchaCode请求登录。
  • 判断captchaCode是否为空,若为空,则直接响应验证码为空;若不为空进行下一步判断。
  • 根据captchaKey从Redis中查询之前保存的code,若查询出来的code为空,则直接响应验证码已过期;若不为空进行下一步判断。
  • 比较captchaCodecode,若不相同,则直接响应验证码不正确;若相同则进行下一步判断。
  • 根据username查询数据库,若查询结果为空,则直接响应账号不存在;若不为空则进行下一步判断。
  • 查看用户状态,判断是否被禁用,若禁用,则直接响应账号被禁;若未被禁用,则进行下一步判断。
  • 比对password和数据库中查询的密码,若不一致,则直接响应账号或密码错误,若一致则进行入最后一步。
  • 创建JWT,并响应给浏览器。
graph TD
    A[前端提交登录信息] --> B{验证码校验}
    B -->|失败| C[返回错误码]
    B -->|成功| D{账号状态校验}
    D -->|异常| C
    D -->|正常| E{密码校验}
    E -->|错误| C
    E -->|正确| F[生成JWT]
1.2.2 配置相关依赖

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

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>
</dependen

创建JWT工具类

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

public class JwtUtil {

    private static final Long expire = 1000 * 60 * 60L; //过期时间为1个小时,可按要求自行设定
    private static final SecretKey secretKey = Keys.hmacShaKeyFor("b946ccc987465afcda7e45b1715219711a13518d1f1663b8c53b848cb0143441".getBytes());

    /**
     * 创建token
     * 包含三部分: 头,负载,签名
     *  负载:在token中保存的登录用户的相关信息(userName 账号 userId 唯一标识)
     */
    public static String createToken(String userName,Long userId){
        String token = Jwts.builder()
                .setSubject("login-token") //token的主题
                .setExpiration(new Date(System.currentTimeMillis() + expire)) //token的过期时间
                .claim("userId", userId)
                .claim("userName", userName)
                .signWith(secretKey) //token的签名
                .compressWith(CompressionCodecs.GZIP) //将token进行压缩,提高传输效率
                .compact(); //将jwt的各个部分组合成一个完整的、可传输的字符串
        return token;
    }

    //解析token
    public static Claims parseToken(String token){
        Jws<Claims> claimsJws = null;
        try {
            claimsJws = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            throw new LeaseException(ResultCodeEnum.TOKEN_EXPIRED); //token过期
        } catch (JwtException e){
            throw new LeaseException(ResultCodeEnum.TOKEN_INVALID); //token非法
        }
        return claimsJws.getBody();
    }
}
1.2.3 接口逻辑实现
  • 查看请求数据结构

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

package com.atguigu.lease.web.admin.vo.login;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@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;
}

  • com.atguigu.lease.web.admin.controller.login.LoginController类下
    @Operation(summary = "登录")
    @PostMapping("login")
    public Result<String> login(@RequestBody LoginVo loginVo) {
        //登录成功 生成token返回给客户端
        String token = loginService.login(loginVo);
        return Result.ok(token);
    }
  • LoginService中增加如下内容:
    String login(LoginVo loginVo);
  • 进入LoginServiceImpl类下,编写getCaptcha()方法
/**
 *实现用户登录
 */
@Override
public String login(LoginVo loginVo) {
    //1.先判断验证码

    String captchaCode = loginVo.getCaptchaCode();
    if(!StringUtils.hasText(captchaCode)){ //未输入验证码
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_NOT_FOUND);
    }
    //获取redis中的验证码
    String redisCode = stringRedisTemplate.opsForValue().get(loginVo.getCaptchaKey());、
    //判断redis中验证码是否存在
    if(redisCode==null){
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_EXPIRED);//验证码过期
    }
    //比较验证码是否相等
    if(!redisCode.equals(captchaCode)){
        throw new LeaseException(ResultCodeEnum.ADMIN_CAPTCHA_CODE_ERROR);//验证码输入错误
    }

    //2.再判断用户登录信息

    //根据用户名查询数据库用户的信息
    SystemUser systemUser = systemUserMapper.selectOne(new LambdaQueryWrapper<SystemUser>().eq(SystemUser::getUsername, loginVo.getUsername()));
    if(systemUser==null){
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_NOT_EXIST_ERROR); //账号不存在
    }
    //如果账号存在,判断账号是否禁用
    if(systemUser.getStatus()==BaseStatus.DISABLE){
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_DISABLED_ERROR);//该用户已被禁用
    }
    //如果账号状态正常,判断密码
    if(!systemUser.getPassword().equals(DigestUtils.md5Hex(loginVo.getPassword()))){
        throw new LeaseException(ResultCodeEnum.ADMIN_ACCOUNT_ERROR); //密码错误
    }

    //登录成功,生成token并返回
    String  token = JwtUtil.createToken(systemUser.getUsername(),systemUser.getId());
    return token;
}
1.3 登录简化

为了所有受保护的接口增加检验token合法性的逻辑,否则登录功能将没有任何意义,所以我们要添加拦截器拦截前端的对应请求

**注意:**我们的token在登录时返回给前端,token就会被保存在浏览器,前端每次发送请求都会在请求头中携带token

如图

  • 编写HandlerInterceptor**

Snipaste_2025-02-14_18-49-46.pngweb-admin模块中创建com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor类,内容如下

```java
package com.atguigu.lease.web.admin.custom.interceptor;

import com.atguigu.lease.common.context.LoginUser;
import com.atguigu.lease.common.context.LoginUserContext;
import com.atguigu.lease.common.exception.LeaseException;
import com.atguigu.lease.common.result.ResultCodeEnum;
import com.atguigu.lease.common.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;


/**
 * 验证token的拦截器(检查是否登录)
 */
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("access_token");
        if(token==null){
            throw new LeaseException(ResultCodeEnum.ADMIN_LOGIN_AUTH);//未登录
        }else{
            //解析token
            Claims claims = JwtUtil.parseToken(token);
            Long userId = claims.get("userId", Long.class);
            String userName = claims.get("userName", String.class);

            //将token中的负载信息保存到ThreadLocal中
            LoginUser loginUser = new LoginUser(userId,userName);
            LoginUserContext.set(loginUser);
        }

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

        //将保存到ThreadLocal中的token的负载信息删除
        LoginUserContext.remove();
    }
}

```
  • 注册HandlerInterceptor

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

package com.atguigu.lease.web.admin.custom.config;

import com.atguigu.lease.web.admin.custom.converter.StringToBaseEnumConverterFactory;
import com.atguigu.lease.web.admin.custom.converter.StringToItemTypeConverter;
import com.atguigu.lease.web.admin.custom.interceptor.AuthenticationInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * ClassName: WebMvcConfiguration
 * Description:springmvc的配置类(配置文件)
 *
 * @Author linz
 * @Creat 2025/2/6 20:09
 * @Version 1.00
 */

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
    @Autowired
    AuthenticationInterceptor authenticationInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor)
            .addPathPatterns("/admin/**").excludePathPatterns("/admin/login/**");
    }
}
  • 使用ThreadLocal保存当前登录用户的信息

由于配置了AuthenticationInterceptor,所以类似的接口被调用时,JWT都会被重复的解析两次,一次是在拦截器中,一次是在Controller中。

为了避免重复解析,也为了方便使用当前登录用户的信息。我们可以修改一下AuthenticationInterceptor的逻辑,在解析完JWT后,将得到的用户信息保存到线程本地变量ThreadLocal中,由于Spring MVC中每个请求的处理流程都是在单个线程中完成的,所以将登陆用户的信息放置于ThreadLocal中后,我们在ControllerService中都可以十分方便的获取到。

具体实现如下:

  • 定义登陆用户信息实体

    查看LoginUser类

    package com.atguigu.lease.common.context;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    /**
     * ClassName: LoginUser
     * Description:     对应token负载数据的类
     */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class LoginUser {
        private Long userId;
        private String userName;
    }
    

    common模块中创建com.atguigu.lease.common.context.LoginUserContext类,内容如下

    package com.atguigu.lease.common.context;
    
    /**
     * 维护ThreadLocal本地线程对象
     */
    public class LoginUserContext {
    
        private static final ThreadLocal<LoginUser> userThreadLocal = new ThreadLocal<>();
    
        //向threadlocal对象保存token负载信息
        public static void set(LoginUser loginUser) {
            userThreadLocal.set(loginUser);
        }
    
        //从threadlocal对象获取token负载信息
        public static LoginUser get() {
            return userThreadLocal.get();
        }
    
        //删除threadlocal对象保存的负载信息
        public static void remove() {
            userThreadLocal.remove();
        }
    }
    

后端完整源码在此处 欢迎交流学习