恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制

登录相关业务

文章目录

  • 登录相关业务
  • 一、关于JWT token
    • 1.1 token
    • 1.2 jwt token
    • 1.3 jwt 原理
      • 1.3.1 Header(头部)
      • 1.3.2 Payload(载体)
      • 1.3.3 Signature(签名)
    • 1.4 总结
  • 二、生成验证码图片
    • 2.1 maven坐标
    • 2.1 代码编写
      • 2.1.1 config配置类
      • 2.1.2 Controller编写
  • 三、登录业务
    • 3.1 登录前准备
      • 3.1.1 存储用户登录信息的User类
      • 3.1.2 当前用户
      • 3.1.3 用户表
      • 3.1.3 响应结果封装类
      • 3.1.4 常量类
      • 3.1.5 MD5加密工具类
      • 3.1.6 token工具类
      • 3.1.7 自定义异常类
    • 3.2 登录业务
      • 3.2.1 Controller层
      • 3.2.2 Service层
      • 3.2.3 Mapper层
      • 3.2.4 结果
    • 3.3 登录限制
      • 3.3.1 回顾原生Servlet中过滤器
        • 3.3.1.1 自定义过滤器
        • 3.3.1.2 注册过滤器
    • 3.4 获取当前登录用户
    • 3.5 用户权限菜单树
      • 3.5.1 加载用户权限菜单树方式
      • 3.5.2 RBAC 用户角色权限控制
      • 3.5.3 实体类
        • 3.5.2.1 auth_info表的实体类 - Auth
      • 3.5.4 业务编写
        • 3.5.4.1 Mapper - 查询用户权限
        • 3.5.4.2 Service - 查询用户菜单树
        • 3.5.4.3 Controller
        • 3.5.4.4 效果图
    • 3.6 退出登录

一、关于JWT token

之前也有过JWT相关的介绍,下面再来看一下

过滤器与拦截器 - 登录校验与登录认证(JWT令牌技术)_我爱布朗熊的博客-CSDN博客

1.1 token

token:令牌,是一种会话技术(登陆成功之后,保证一段时间内不需要重复登录便可以直接访问系统资源)

token可以适用于分布式微服务集群项目的会话技术

它的交互流程是,用户认证成功后,服务端生成一个 token(令牌,一个字符串标识)发给客户端,客户端可以放到 cookie 或 localStorage,等存储中,每次请求时带上 token,服务端收到 token 通过验证后即可确认用户身份。这种方式主要用于分布式系统中,将 token 和用户信息存储在 Redis 中,实现会话共享。

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第1张图片

常见的会话技术:Session - 只适用于单体应用,不适用于集群

基于Session实现短信登录登录session怎么使用

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第2张图片

前端发送请求的时候会携带Cookie,并且Cookie中存储的就是sessionId

它的交互流程是,用户认证成功后,在服务端将用户信息保存在 session(当前会话)中,发给客户端的sesssion_id 存放到 cookie 中,这样用户客户端请求时带上 session id 就可以验证服务器端是否存在 session数据,以此完成用户的合法校验,当用户退出系统或 session 过期销毁时,客户端的 session id 也就无效了

1.2 jwt token

jwt token : 比较特殊的token

Json Web Token(JWT),是一种用于通信双方之闸传递信息的简洁的、安全的声明规范:作为一个开放的标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方之间以Json 对象的形式安全的传递信息。

JWT 一般被用来在身份提供者和服务提供者之间传递被认证的用户身份信息,即传递 Token,以便于从资源服务器获取资源:特别适用于分布式站点的单点登录(SSO)场景。

官网: https://jwtio

方案1

将 Token 持久化,保存到持久层: 所有服务收到请求后,都从持久层获取 Token 进行校验。这种方案的优点是架构清晰:缺点是工程量比较大,另外,持久层万一挂了,就会单点失败。

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第3张图片

方案2

数据库不再保存 Token 了,所有 Token 都保存在客户端,每次请求都将 Token 发回服务器,服务器解析校验就行了。JWT 就是这种方案的一个代表。

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第4张图片

1.3 jwt 原理

JWT 的原理是,服务端认证通过以后,会生成一个JSON 对象,发回给客户端,就像下面这样:

{
    "姓名": "张三",
    "角色": "管理员”,
    "到期时间": "2018年7月1日0点0分"
}

之后,客户端与服务端通信的时候,都要发回这个JSON 对象给服务端,服务端完全只靠这个JSON 对象认定用户身份。为了防止用户篡改数据,服务端在生成这个 JSON 对象的时候,还会加上签名。

服务端就不保存任何数据了,即服务端变成无状态了,从而比较容易实现扩展。

JWT是一个很长的字符串,中间用点(.)分隔成三个部分

1> Header(头部)

2>Payload(载体)

3> Signature(签名)

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第5张图片

1.3.1 Header(头部)

第一部分:Header(头),记录令牌类型、签名算法等。

Header 部分是一个JSON 对象,描述了JWT 的元数据,通常是下面的样子:

{
    "alg":"HS256",
    "typ":"JWT"
}

alg 属性表示签名用的算法(algorithm),默认是 HMAC SHA256(写成 HS256);

typ 属性表示这个令牌(token)的类型(type),JWT 统一写为JWT;

最后,将上面的 JSON 对象使用 Base64URL 编码转成字符串。

头部的信息一般就是固定的

1.3.2 Payload(载体)

载体一般放置用户信息

第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。

Payload 部分也是一个JSON 对象,用来存放实际需要传递的数据。JWT 规定了 7个官方字段供选用:

sub(subject): 主题

iat(issuedAt): 签发时间

exp(expiresAt): 过期时间

iss(issuer): 签发人

aud(audience):受众

nbf(notBefore): 生效时间

jti(jwtId):编号

除了官方字段,还可以在这个部分定义私有字段,例如:

{
    "sub":"1234567890",
    "name":"John Doe",
    "admin": true
}

JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息(密码,手机号等)放在这个部分:但也是可以加密的,生成原始 Token 以后,可以用密钥再加密一次。

这个JSON 对象也要使用 Base64URL编码转成字符串

1.3.3 Signature(签名)

第三部分: Signature(签名),防止Token被篡改、确保安全性。

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户然后,使用 Header 里面指定的算法(HMAC SHA256),按照下面的公式产生签名:

HMACSHA256(
base64UrIEncode(header) +“.” +

base64UrlEncode(payload),

secret

)

secret是一个秘钥

最后,算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用点(.)分隔,就可以返回给用户了。

JWT token的形式

base64UrIEncode(header) . base64UrlEncode(payload).HMACSHA256(base64UrIEncode(header) +“.” + base64UrlEncode(payload), secret)

比如下图:

红色部分就是Header头部,就是头部JSON串以Base64做的编码处理

紫色部分就是载体,就是载体JSON串以Base64做的编码处理

绿色部分就是签名,就是把Base64编码处理的头部+“.”+Base64编码处理的载体,再传入一个秘钥secret,通过HMACSHA256生成一个编码

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第6张图片

1.4 总结

JWT 是一种用于传递 Token 的解决方案,而且可以无需持久化 Token 实现跨域认证

通俗来进,JWT 是一个含签名并携带用户相关信息的加密串,客户端请求服务端时,请求中携带JWT 串到服务端,服务端通过签名加密串匹配校验,保证信息未被篡改,校验通过则认为是可靠的请求,将正常返可数据

二、生成验证码图片

2.1 maven坐标


<dependency>
    <groupId>com.github.pengglegroupId>
    <artifactId>kaptchaartifactId>
    <version>2.3.2version>
dependency>

2.1 代码编写

2.1.1 config配置类

配置类通过Bean注解的方式配置了一个bean对象captchaProducer

import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;

/**
 * 验证码工具kaptcha的配置类
 */
@Configuration
public class CaptchaConfig {

    /**
     * 配置Producer接口的实现类DefaultKaptcha的bean对象,该对象用于生成验证码图片;
     * 并给其指定生成的验证码图片的设置项;
     * bean对象的id引用名为captchaProducer;
     * 如果不指定@Bean注解的name,那这个bean的Name默认就是方法名
     */
    @Bean(name = "captchaProducer")
    public DefaultKaptcha getKaptchaBean() {

        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
//      属性集对象,存储内容时采用键值对的方式 - 对properties文件的封装
        Properties properties = new Properties();
        //是否有边框 默认为true 我们可以自己设置yes,no
        properties.setProperty("kaptcha.border", "yes");
        //边框颜色 默认为Color.BLACK
        properties.setProperty("kaptcha.border.color", "105,179,90");
        //验证码文本字符颜色 默认为Color.BLACK
        properties.setProperty("kaptcha.textproducer.font.color", "blue");
        //验证码图片宽度 默认为200
        properties.setProperty("kaptcha.image.width", "120");
        //验证码图片高度 默认为50
        properties.setProperty("kaptcha.image.height", "40");
        //验证码文本字符大小 默认为40
        properties.setProperty("kaptcha.textproducer.font.size", "32");
        //KAPTCHA_SESSION_KEY
        properties.setProperty("kaptcha.session.key", "kaptchaCode");
        //验证码文本字符间距 默认为2
        properties.setProperty("kaptcha.textproducer.char.space", "4");
        //验证码文本字符长度 默认为5
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        //验证码文本字体样式 默认为new Font("Arial", 1, fontSize), new Font("Courier", 1, fontSize)
        properties.setProperty("kaptcha.textproducer.font.names", "Arial,Courier");
        //验证码噪点颜色 默认为Color.BLACK
        properties.setProperty("kaptcha.noise.color", "gray");

//      创建一个配置对象,把数据集对象封装给她
        Config config = new Config(properties);
//      再把配置对象封装给defaultKaptcha
        defaultKaptcha.setConfig(config);

//      返回后加入到IOC容器
        return defaultKaptcha;
    }
}

2.1.2 Controller编写

@RestController
@RequestMapping()
public class LoginController {

    //  Producer是一个接口,注入的是在CaptchaConfig配置类中注入的对象 - 生成验证码图片的对象
    @Resource(name = "captchaProducer")//按照名字进行注入
    private Producer producer;

    @Autowired
    private StringRedisTemplate redisTemplate;

//  要通过响应对象把生成的验证码图片返回给前端
    @RequestMapping("/captcha/captchaImage")
    public void captchaImage(HttpServletResponse response) throws IOException {
//      生成验证码图片的文本
        String text = producer.createText();

//      使用验证码文本生成验证码图片 - BufferedImage缓冲图片,在内存里
        BufferedImage image = producer.createImage(text);

//      把验证码图片的文本作为键保存到Redis中
        redisTemplate.opsForValue().set(text,"",30, TimeUnit.MINUTES);

//      将验证码图片响应给前端
//      设置响应正文image/jpeg - Content-Type头字段用于告诉浏览器或客户端服务器返回的数据的MIME类型(Multipurpose Internet Mail Extensions)
        response.setContentType("image/jpeg");
//      将验证码图片写给前端
        ServletOutputStream outputStream = response.getOutputStream();
//      write方法的参数中
//          参数一:ReaderImage代表内存中图片,参数二:格式名,参数三:所用到的字节输出流
        ImageIO.write(image,"jpg",outputStream);
        
        outputStream.flush();
        
        if (outputStream!=null){
            outputStream.close();
        }

    }
}

三、登录业务

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第7张图片

前端Vue向服务器发送的地址- 访问后台的login接口: http://localhost:3000/api/login

前端Vue项目的访问地址: http://localhost:3000/

前端Vue里面的一个代理接口: /api,其中代理的是后台项目的访问路劲

代理的是哪个地址

代理的目标是 target: env.VITE WAREHOUSE CONTEXT PATH变量,这个变量就是http://localhost:9999/warehouse

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第8张图片

所以只要是前端Vue发起的请求地址是http://localhost:3000/api/xxx ,都是发给后台项目的

3.1 登录前准备

3.1.1 存储用户登录信息的User类

用户登录表单信息

/**
 * 存储用户登录信息的User类:
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class LoginUser {

    private String userCode;//用户名

    private String userPwd;//密码

    private String userState;//用户状态

    private String verificationCode;//验证码
}

3.1.2 当前用户

存储当前登录用户的信息

/**
 * 此User类只封装了用户的用户id、用户名和真实姓名
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class CurrentUser {

    private int userId;//用户id

    private String userCode;//用户名

    private String userName;//真实姓名
}

3.1.3 用户表

/**
 * user_info表的实体类:
 */
@Data
@ToString
public class User {

   private int userId;//用户id

   private String userCode;//账号

   private String userName;//用户名

   private String userPwd;//用户密码

   private String userType;//用户类型

   private String userState;//用户状态

   private String isDelete;//删除状态

   private int createBy;//创建人

   //返回前端时,自动将Date转换成指定格式的json字符串
   @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")
   private Date createTime;//创建时间

   private int updateBy;//修改人

   private Date updateTime;//修改时间

   private String getCode;

   public User() {

   }

   public User(int userId, String userCode, String userName, String userPwd,
         String userType, String userState, String isDelete, int createBy,
         Date createTime, int updateBy, Date updateTime) {
      this.userId = userId;
      this.userCode = userCode;
      this.userName = userName;
      this.userPwd = userPwd;
      this.userType = userType;
      this.userState = userState;
      this.isDelete = isDelete;
      this.createBy = createBy;
      this.createTime = createTime;
      this.updateBy = updateBy;
      this.updateTime = updateTime;
   }
}

3.1.3 响应结果封装类

/**
 * 响应结果封装类:
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Result {

    /**
     * 状态码常量:
     */
    //成功
    public static final int CODE_OK = 200;
    //业务错误
    public static final int CODE_ERR_BUSINESS = 501;
    //用户未登录
    public static final int CODE_ERR_UNLOGINED = 502;
    //系统错误
    public static final int CODE_ERR_SYS = 503;

    //成员属性
    private int code;//状态码

    private boolean success;//成功响应为true,失败响应为false

    private String message;//响应信息

    private Object data;//响应数据

    //成功响应的方法 -- 返回的Result中只封装了成功状态码
    public static Result ok(){
        return new Result(CODE_OK,true,null, null);
    }
    
    //成功响应的方法 -- 返回的Result中封装了成功状态码和响应信息
    public static Result ok(String message){
        return new Result(CODE_OK,true,message, null);
    }
    
    //成功响应的方法 -- 返回的Result中封装了成功状态码和响应数据
    public static Result ok(Object data){
        return new Result(CODE_OK,true,null, data);
    }
    
    //成功响应的方法 -- 返回的Result中封装了成功状态码和响应信息和响应数据
    public static Result ok(String message, Object data){
        return new Result(CODE_OK,true,message, data);
    }
    
    //失败响应的方法 -- 返回的Result中封装了失败状态码和响应信息
    public static Result err(int errCode, String message){
        return new Result(errCode,false, message, null);
    }
    
    //失败响应的方法 -- 返回的Result中封装了失败状态码和响应信息和响应数据
    public static Result err(int errCode,String message,Object data){
        return new Result(errCode,false,message, data);
    }
}

3.1.4 常量类

这是一个接口,为什么是一个常量类?

常量类:一个类里面定义的全是全局常量的接口

/**
 * 常量类:
 */
public interface WarehouseConstants {

    //用户未审核
    public String USER_STATE_NOT_PASS = "0";

    //用户已审核
    public String USER_STATE_PASS = "1";

    //传递token的请求头名称
    public String HEADER_TOKEN_NAME = "Token";
}

3.1.5 MD5加密工具类

要使用MD5对密码进行加密,但是不能解密

/**
 * 加密工具类 -- 提供了MD5加密算法
 */
public class DigestUtil {

    private static String encodingCharset = "UTF-8";

    //对参数数据进行MD5加密的算法
    public static String hmacSign(String aValue) {
        return hmacSign(aValue, "warehouse");
    }

    public static String hmacSign(String aValue, String aKey) {
        byte k_ipad[] = new byte[64];
        byte k_opad[] = new byte[64];
        byte keyb[];
        byte value[];
        try {
            keyb = aKey.getBytes(encodingCharset);
            value = aValue.getBytes(encodingCharset);
        } catch (UnsupportedEncodingException e) {
            keyb = aKey.getBytes();
            value = aValue.getBytes();
        }

        Arrays.fill(k_ipad, keyb.length, 64, (byte) 54);
        Arrays.fill(k_opad, keyb.length, 64, (byte) 92);
        for (int i = 0; i < keyb.length; i++) {
            k_ipad[i] = (byte) (keyb[i] ^ 0x36);
            k_opad[i] = (byte) (keyb[i] ^ 0x5c);
        }

        MessageDigest md = null;
        try {
            md = MessageDigest.getInstance("MD5");
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
        md.update(k_ipad);
        md.update(value);
        byte dg[] = md.digest();
        md.reset();
        md.update(k_opad);
        md.update(dg, 0, 16);
        dg = md.digest();
        return toHex(dg);
    }

    public static String toHex(byte input[]) {
        if (input == null)
            return null;
        StringBuffer output = new StringBuffer(input.length * 2);
        for (int i = 0; i < input.length; i++) {
            int current = input[i] & 0xff;
            if (current < 16)
                output.append("0");
            output.append(Integer.toString(current, 16));
        }
        return output.toString();
    }
}

3.1.6 token工具类

之前生成jwt token可以看下面这个文章:

过滤器与拦截器 - 登录校验与登录认证(JWT令牌技术)_jwt过滤器_我爱布朗熊的博客-CSDN博客

我们这个地方使用的坐标


<dependency>
    <groupId>com.auth0groupId>
    <artifactId>java-jwtartifactId>
    <version>3.18.3version>
dependency>
/**
 * token工具类
 */
@Component
public class TokenUtils {

    //注入redis模板
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    //注入配置文件中的warehouse.expire-time属性 -- token的过期时间
    @Value("${warehouse.expire-time}")
    private int expireTime;

    /**
     * 常量:
     */
    //token中存放用户id对应的名字
    private static final String CLAIM_NAME_USERID = "CLAIM_NAME_USERID";
    //token中存放用户名对应的名字
    private static final String CLAIM_NAME_USERCODE = "CLAIM_NAME_USERCODE";
    //token中存放用户真实姓名对应的名字
    private static final String CLAIM_NAME_USERNAME = "CLAIM_NAME_USERNAME";

    private String sign(CurrentUser currentUser, String securityKey){
        String token = JWT.create()
                .withClaim(CLAIM_NAME_USERID, currentUser.getUserId())
                .withClaim(CLAIM_NAME_USERCODE, currentUser.getUserCode())
                .withClaim(CLAIM_NAME_USERNAME, currentUser.getUserName())
                .withIssuedAt(new Date())//发行时间
                .withExpiresAt(new Date(System.currentTimeMillis() + expireTime *1000))//有效时间
                .sign(Algorithm.HMAC256(securityKey));
        return token;
    }

    /**
     * 将当前用户信息以用户密码为密钥生成token的方法
     */
    public String loginSign(CurrentUser currentUser, String password){
        //生成token
        String token = sign(currentUser, password);
        //将token保存到redis中,并设置token在redis中的过期时间
        stringRedisTemplate.opsForValue().set(token, token, expireTime *2, TimeUnit.SECONDS);
        return token;
    }

    /**
     * 从客户端归还的token中获取用户信息的方法
     */
    public CurrentUser getCurrentUser(String token) {
        if(StringUtils.isEmpty(token)){
            throw new BusinessException("令牌为空,请登录!");
        }
        //对token进行解码,获取解码后的token
        DecodedJWT decodedJWT = null;
        try {
            decodedJWT = JWT.decode(token);
        } catch (JWTDecodeException e) {
            throw new BusinessException("令牌格式错误,请登录!");
        }
        //从解码后的token中获取用户信息并封装到CurrentUser对象中返回
        int userId = decodedJWT.getClaim(CLAIM_NAME_USERID).asInt();//用户账号id
        String userCode = decodedJWT.getClaim(CLAIM_NAME_USERCODE).asString();//用户账号
        String userName = decodedJWT.getClaim(CLAIM_NAME_USERNAME).asString();//用户姓名
        if(StringUtils.isEmpty(userCode) || StringUtils.isEmpty(userName)){
            throw new BusinessException("令牌缺失用户信息,请登录!");
        }
        return new CurrentUser(userId, userCode, userName);
    }

}

3.1.7 自定义异常类

运行时异常

/**
 * 用户操作不当导致的异常
 */
public class BusinessException extends RuntimeException{

//  创建异常对象
    public BusinessException() {
//      访问父类构造器
        super();
    }

//  创建异常对象并同时指定异常信息
    public BusinessException(String message) {
//      访问父类构造器
        super(message);
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
    }

    public BusinessException(Throwable cause) {
        super(cause);
    }

    public BusinessException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

3.2 登录业务

请求中三个参数:用户名、密码、用户输入的验证码恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第9张图片

3.2.1 Controller层

 @Autowired
    private UserService userService;
    @Autowired
    private TokenUtils tokenUtils;

    //  登录URL接口
    @RequestMapping("/login")
    public Result login(@RequestBody LoginUser loginUser) {
//      验证验证码是否正确
        String verificationCode = loginUser.getVerificationCode();
//      与Redis存储的验证码进行比较
        if (!redisTemplate.hasKey(verificationCode)){
//          Redis中没有对应的验证码
            return Result.err(Result.CODE_ERR_BUSINESS, "验证码输入不正确");
        }

//      根据账号查询用户
        User user = userService.queryUserByCode(loginUser.getUserCode());
//      判断账号是否存在
        if (user != null) {
//          账号存在
//          user表里有个字段user_state,表示用户是否已经被审核,0未审核,1已审核
            if (user.getUserState().equals(WarehouseConstants.USER_STATE_PASS)) {
//              代表是1,表示用户已经被审核,之后校验密码
//              数据库中的密码是MD5加密的(MD5加密的数据不能解密),请求携带的密码是明文
//              所以只能将明文密码加密与数据库中加密的数据对比
                String userPwd = loginUser.getUserPwd();
                userPwd = DigestUtil.hmacSign(userPwd);
                if (userPwd.equals(user.getUserPwd())) {
//                  密码正确,生成JWTToken,颁发给浏览器
                    CurrentUser currentUser = new CurrentUser();
                    currentUser.setUserId(user.getUserId());
                    currentUser.setUserName(user.getUserName());
                    currentUser.setUserCode(user.getUserName());
//                  生成JWT Token并存储到Redis
                    String token = tokenUtils.loginSign(currentUser, user.getUserPwd());
//                  将token颁发给浏览器
                    return Result.ok("登录成功",token);
                } else {
//                  密码错误
                    return Result.err(Result.CODE_ERR_BUSINESS, "密码错误");
                }

            } else {
//              用户未审核
                return Result.err(Result.CODE_ERR_BUSINESS, "用户未审核");
            }
        } else {
//           账号不存在
            return Result.err(Result.CODE_ERR_BUSINESS, "账号不存在");
        }

    }

3.2.2 Service层

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserMapper userMapper;

    @Override
    public User queryUserByCode(String userCode) {
        return userMapper.findUserByCode(userCode);
    }
}

3.2.3 Mapper层

/**
 * user_info表的Mapper接口
 */
@Mapper
public interface UserMapper {
//    根据账号查询用户信息的方法啊
    public User findUserByCode(String userCode);
}
<mapper namespace="com.pn.mapper.UserMapper">
    
    <select id="findUserByCode" resultType="com.pn.entity.User">
        select  *
        from user_info
        where user_code = #{userCode} and is_delete = '0'
    select>

mapper>

3.2.4 结果

Redis中存放了一个token

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第10张图片

3.3 登录限制

用户在未登录时禁止访问某些页面

具体实现:在后台项目中配置一个过滤器,会拦截前端发出的所有请求,拦截之后判断用户是否已经登录来决定是否允许访问系统资源(url接口)

过滤器与拦截器 - 登录校验与登录认证(JWT令牌技术)_jwt过滤器_我爱布朗熊的博客-CSDN博客

3.3.1 回顾原生Servlet中过滤器

3.3.1.1 自定义过滤器

定义一个Filter接口的实现类并重写doFilter()方法,doFilter()方法中就是过滤器拦截到请求执行的内容;

//自定义的登录限制过滤器
@Slf4j
public class LoginCheckFilter implements Filter {

    private StringRedisTemplate redisTemplate;

    public LoginCheckFilter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    //  过滤器拦截到请求执行的方法
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

//      TODO 1.白名单请求直接放行
        List<String> urlList = new ArrayList<>();
        urlList.add("/captcha/captchaImage");
        urlList.add("/login");

//      过滤器拦截到的当前请求的资源路径
        String url = request.getServletPath();
        log.info("资源路径 - " + url);//也就是“/captcha/captchaImage”形式
        if (urlList.contains(url)) {
//          放行
            filterChain.doFilter(request, response);
//          不需要执行后面的代码
            return;
        }
//      项目路径
        String contextPath = request.getContextPath();
        log.info("项目路径 - " + contextPath);// 也就是“/warehouse”形式

//      TODO 2.其他请求都校验是否携带token,携带了后判断Redis中是否存在token的键
        String token = request.getHeader("Token");
//      1>有,说明已经登录,请求放行
        if (StringUtils.hasText(token)&& Boolean.TRUE.equals(redisTemplate.hasKey(token))){
            //          放行
            filterChain.doFilter(request, response);
//          不需要执行后面的代码
            return;
        }
//      2>没有,说明未登录或token过期,请求不放行,给前端做出响应

        String jsonString = JSON.toJSONString(Result.err(401,"您尚未登录!"));
//      响应正文,application/json表示响应的为JSON,charset=UTF-8表示响应编码
        response.setContentType("application/json;charset=UTF-8");
//      拿到响应对象的字符输出流
        PrintWriter out = response.getWriter();
        out.write(jsonString);
        out.flush();
        out.close();
    }
}

在过滤器当中为什么要把ServletRequest类型强转成HttpServletRequest类型

在Java中,ServletRequest是一个接口,它是由Servlet容器提供的。

HttpServletRequest接口则是ServletRequest接口的子接口,它包含了一些用于HTTP协议的方法和属性。

在Java Web应用程序中,Servlet容器实现了ServletRequest和HttpServletRequest接口,并使用HttpServletRequest实现了HTTP协议相关的逻辑

在开发Web应用程序时,Servlet容器将在每个客户端请求到达时创建一个ServletRequest对象并将其传递给请求处理器。

由于具体的实现是由Servlet容器提供的并且通常是HttpServletRequest,因此在编写Servlet处理器时,我们通常将ServletRequest对象强制转换成HttpServletRequest对象,以便能够调用提供的HTTP协议相关方法

因此,在过滤器中,如果我们需要使用HttpServletRequest接口中特定的HTTP协议相关方法,我们需要将ServletRequest对象强制转换成HttpServletRequest对象。这样我们才能够在处理ServletRequest对象时,使用HttpServletRequest中更多的方法和属性

3.3.1.2 注册过滤器

向IOC容器中配置FilterRegistrationBean的Bean对象

Filter是javaweb三大组件之一,不是Spring提供的,如果想要使用三大组件,需要配置或者添加注解

如果是配置,需要将我们自定义的过滤器给FilterRegistrationBean它

@Configuration
public class ServletConfig {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Bean
    public FilterRegistrationBean filterRegistrationBean(){
//      创建FilterRegistrationBean的Bean对象
        FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>();
//      创建自定义的过滤器
        LoginCheckFilter loginCheckFilter = new LoginCheckFilter(stringRedisTemplate);
//      将自定义的过滤器注册到FilterRegistrationBean
        filterRegistrationBean.setFilter(loginCheckFilter);
//      给过滤器指定拦截的请求
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }
}

3.4 获取当前登录用户

用户登录成功后发起了两个请求,如下所示,

image-20230810203619746

目的就是获取当前用户的信息以及左侧菜单栏的用户权限信息

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第11张图片

我们在登录时将用户的信息存放到了token当中,比如用户的id,用户名,真实姓名

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第12张图片

之后我们可以解析token,从里面获取用户信息并封装到CurrentUser对象,之后响应给前端

解析token的方法

/**
 * 从客户端归还的token中获取用户信息的方法
 */
public CurrentUser getCurrentUser(String token) {
    if(StringUtils.isEmpty(token)){
        throw new BusinessException("令牌为空,请登录!");
    }
    //对token进行解码,获取解码后的token
    DecodedJWT decodedJWT = null;
    try {
        decodedJWT = JWT.decode(token);
    } catch (JWTDecodeException e) {
        throw new BusinessException("令牌格式错误,请登录!");
    }
    //从解码后的token中获取用户信息并封装到CurrentUser对象中返回
    int userId = decodedJWT.getClaim(CLAIM_NAME_USERID).asInt();//用户账号id
    String userCode = decodedJWT.getClaim(CLAIM_NAME_USERCODE).asString();//用户账号
    String userName = decodedJWT.getClaim(CLAIM_NAME_USERNAME).asString();//用户姓名
    if(StringUtils.isEmpty(userCode) || StringUtils.isEmpty(userName)){
        throw new BusinessException("令牌缺失用户信息,请登录!");
    }
    return new CurrentUser(userId, userCode, userName);
}

Controller层代码

@Autowired
private TokenUtils tokenUtils;

//我们需要获取请求头,或者可以直接使用@RequestHeader("token") String token
@RequestMapping("/curr-user")
public Result currentUser(HttpServletRequest request){
    String token = request.getHeader("token");
    log.info("token - "+token);
    CurrentUser currentUser = tokenUtils.getCurrentUser(token);

    return Result.ok(currentUser);
}

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第13张图片

3.5 用户权限菜单树

登录之后会访问user/auth-list接口,获取用户权限菜单树

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第14张图片

3.5.1 加载用户权限菜单树方式

①前端生成菜单树

后台系统中是查询出用户权限下的所有菜单List,然后将用户的所有菜单的List响应给前端,前端使用菜单树组件并通过用户所有菜单的List中auth_id与parent_id确定关系并生成菜单树

涉及表auth_info

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第15张图片

②后端生成菜单树

后台系统中是查询出用户权限下的所有菜单List,然后将用户的所有菜单的List转成菜单树List,最后将菜单树响应给前端,前端只需要循环迭代展示菜单树即可

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第16张图片

我们项目选择第二种方式

3.5.2 RBAC 用户角色权限控制

我们可以先了解一下RBAC:用户角色权限控制

给用户分配不同的角色,再给角色分配对应的权限

在我们这个项目中,指的就是用户可以操作什么菜单,进而实现给用户分配不同的菜单权限

要想实现这个效果,至少涉及五张表

①用户表user_info - 存放用户信息

核心字段:user_id、user_name…

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第17张图片

②角色表role - 存放角色信息

核心字段:role_id、role_name

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第18张图片

③用户角色中间表user_role - 用户关联角色

体验给用户分配的角色,用户与角色之间值多对多的关系

一般多对多的关系都会用到第三张表

核心字段:role_id、user_id

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第19张图片

④菜单权限表auth_info - 存放菜单信息

核心字段:id、pid、auth_name

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第20张图片

⑤角色菜单权限中间表role_auth - 给角色分配权限

存放的是角色菜单关系,体现了给角色分配的菜单权限,而且菜单和角色是多对多的关系

核心字段:role_id、auth_id

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第21张图片

总结

image-20230810230408634

3.5.3 实体类

3.5.2.1 auth_info表的实体类 - Auth

/**
 * auth_info表的实体类:
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Auth {

   private int authId;//权限(菜单)id

   private int parentId;//父权限(菜单)id

   private String authName;//权限(菜单)名称

   private String authDesc;//权限(菜单)描述

   private int authGrade;//权限(菜单)层级

   private String authType;//权限(菜单)类型,1模块,2列表,3按钮

   private String authUrl;//权限(菜单)访问的url接口

   private String authCode;//权限(菜单)标识

   private int authOrder;//权限(菜单)的优先级

   private String authState;//权限(菜单)状态(1.启用,0.禁用)

   private int createBy;//创建权限(菜单)的用户id

   private Date createTime;//权限(菜单)的创建时间

   private int updateBy;//修改权限(菜单)的用户id

   private Date updateTime;//权限(菜单)的修改时间

   //追加的List集合属性 -- 用于存储当前权限(菜单)的子级权限(菜单)
   private List<Auth> childAuth;
}

3.5.4 业务编写

3.5.4.1 Mapper - 查询用户权限

@Mapper
public interface AuthMapper {
//  根据userId查询用户权限下的所有菜单的方法
    public List<Auth> findAuthByUid(Integer userId);
}
<mapper namespace="com.pn.mapper.AuthMapper">
    
    
    
    <select id="findAuthByUid" resultType="com.pn.entity.Auth">
        select t3.*
        from user_role t1,
             role_auth t2,
             auth_info t3
        where t1.role_id = t2.role_id
          and t2.auth_id = t3.auth_id
          and t1.user_id = #{userId}
          and t3.auth_state = 1
          and t3.auth_type!=3
    select>

mapper>

3.5.4.2 Service - 查询用户菜单树

@Service
public class AuthServiceImpl implements AuthService {
    @Autowired
    private AuthMapper authMapper;

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 查询用户菜单树的业务方法
     * 向Redis中缓存 - 键authTree:userId,值菜单树List转的JSON字符串
     */
    @Override
    public List<Auth> queryAuthTreeByUid(Integer userId) {

//      先从Redis中查找缓存中的用户菜单树
        String authTreeJson = redisTemplate.opsForValue().get("authTree:" + userId);
        if (StringUtils.hasText(authTreeJson)) {
//          说明Redis中有用户中有用户菜单树的缓存
//          将JSON串转成集合对象并返回
            return JSON.parseArray(authTreeJson, Auth.class);
        }
//      说明Redis中没有用户中有用户菜单树的缓存
//      查询用户权限下的所有菜单
        List<Auth> allAuthList = authMapper.findAuthByUid(userId);
//      将所有菜单List转成菜单树List
        List<Auth> authTreeList = allAuthToAuthTree(allAuthList, 0);
//      向Redis中缓存一份
        redisTemplate.opsForValue().set("authTree:" + userId,JSON.toJSONString(authTreeList));
        return authTreeList;
    }

//  将所有菜单List转成菜单树List
//  第一次的话pid是0
    private  List<Auth> allAuthToAuthTree( List<Auth> allAuthList,Integer pid){
        List<Auth> firstLevelAuthList = new ArrayList<>();

//      查询出所有n级菜单(比如说一级菜单)
        for (Auth auth:allAuthList){
//          pid=0,说明就是1级菜单
           if ( auth.getParentId().equals(pid)){
               firstLevelAuthList.add(auth);
           }
        }
//      拿到每一个n级菜单的(n+1)级菜单
        for (Auth firstAuth: firstLevelAuthList){
//          递归,获取(n+1)级菜单
            List<Auth> secondLevelAuthList = allAuthToAuthTree(allAuthList,firstAuth.getAuthId());
            firstAuth.setChildAuth(secondLevelAuthList);
        }

        return firstLevelAuthList;
    }


}

3.5.4.3 Controller

@Autowired
private AuthService authService;

@RequestMapping("/user/auth-list")
public Result loadAuthTree(@RequestHeader("token") String token){
    CurrentUser currentUser = tokenUtils.getCurrentUser(token);

    return Result.ok(authService.queryAuthTreeByUid(currentUser.getUserId()));
}

3.5.4.4 效果图

恒合仓库 - 登录相关业务、JWT Token、生成验证码图片、登录限制_第22张图片

3.6 退出登录

怎么退出登录?

从Redis中删除当前登录的用户的token键

//退出登录
@RequestMapping("/logout")
public Result logout(@RequestHeader("token") String token){
     //从redis中删除token的键
     redisTemplate.delete(token);
     //响应
    return Result.ok("成功退出系统!");
}

你可能感兴趣的:(恒合仓库,java,spring,maven,spring,boot)