xmall商城学习笔记——JWT改造登录

2020/09/20 12:07
阅读数 111


前言

之前给大家许诺的给xmall 加上jwt 校验的项目终于弄好了,最近一直加班身心俱疲。


提示:以下是本篇文章正文内容,下面案例可供参考

一、无状态登录是什么?

了解JWT首先要知道什么是无状态登录,什么是有状态登录。

1.有状态登录

有状态登录:有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?

  • 服务端保存大量数据,增加服务端压力
  • 服务端保存用户状态,无法进行水平扩展
  • 客户端请求依赖服务端,多次请求必须访问同一台服务器

2.无状态登录

微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即:

  • 服务端不保存任何客户端请求者信息
  • 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份

无状态登录的优点

  • 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
  • 服务端的集群和状态对客户端透明
  • 服务端可以任意的迁移和伸缩
  • 减小服务端存储压力

3.如何实现无状态

无状态登录的流程:

  • 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
  • 认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
  • 以后每次请求,客户端都携带认证的token
  • 服务的对token进行解密,判断是否有效。

流程图:
在这里插入图片描述
整个登录过程中,最关键的点是什么?
token的安全性
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。



采用何种方式加密才是安全可靠的呢?

我们将采用JWT + RSA非对称加密

4.JWT

JWT,全称是Json Web Token,是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权;官网:https://jwt.io

JWT数据格式
JWT包含三部分数据:

  • Header:头部,通常头部有两部分信息
    声明类型,这里是JWT
    我们会对头部进行base64编码,得到第一部分数据

  • Payload:载荷,就是有效数据,一般包含下面信息:
    用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
    注册声明:如token的签发时间,过期时间,签发人等
    这部分也会采用base64编码,得到第二部分数据


  • Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性
    生成的数据格式:token==个人证件 jwt=个人身份证
    在这里插入图片描述

5.JWT交互流程

流程图:
在这里插入图片描述
步骤翻译:

  • 1、用户登录
  • 2、服务的认证,通过后根据secret生成token
  • 3、将生成的token返回给浏览器
  • 4、用户每次请求携带token
  • 5、服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息
  • 6、处理请求,返回响应结果
    因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。

二、理解原有解决方案!

1.准备util类

JWTUtil

@Component
public class JwtUtil {
   
     
    @Value(value = "60000")
    private String tokenValidTime;

    /***
     * 创建token
     * @param username
     * @param currentTimeMillis
     * @return
     */
    public String createToken(String username,String currentTimeMillis){
   
     
        try{
   
     
            //加密
            Algorithm algorithm = Algorithm.HMAC256(username);
            Date tokenExpireDate = getExpireTime();
            return JWT.create().withClaim("username",username)
                    .withClaim(RedisConstant.CURRENT_TMIE_MILLIS,currentTimeMillis)
                    .withExpiresAt(tokenExpireDate).sign(algorithm);
        } catch (Exception e){
   
     
            throw new XmallException("JWTToken验证token出现UnsupportedEncodingException异常:" + e.getMessage());
        }

    }

    /***
     * 获取token过期时间
     * @return
     */
    private Date getExpireTime(){
   
     
        long currentTimeMillis = System.currentTimeMillis();
        return new Date(currentTimeMillis+Integer.valueOf(tokenValidTime));
    }

    /**
     * 验证token
     *
     * @param username
     * @param token
     * @return
     */
    public boolean verifyToken(String username, String token) {
   
     
        try {
   
     
            Algorithm algorithm = Algorithm.HMAC256(username);
            JWTVerifier verifier = JWT.require(algorithm).withClaim("username", username).build();
            verifier.verify(token);
            return true;
        } catch (Exception e) {
   
     
            throw new XmallException("验证失败:"+e.getMessage());
        }
    }

    /**
     * 根据key获取token中携带key对应的信息
     *
     * @param token token
     * @param key   关键词
     * @return 该关键词携带的值
     */
    public String getValueFromTokenByKey(String token, String key) {
   
     
        try {
   
     
            DecodedJWT decodedJWT = JWT.decode(token);
            return decodedJWT.getClaim(key).asString();
        } catch (JWTDecodeException e) {
   
     
            return null;
        }
    }

    /**
     * 获取token中username对应的值
     *
     * @param token
     * @return
     */
    public String getUsernameFromToken(String token) {
   
     
        return getValueFromTokenByKey(token, "username");
    }

    /**
     * 获取token中的创建的时间戳
     *
     * @param token
     * @return
     */
    public String getCurrentTmieMillisFromToken(String token) {
   
     
        return getValueFromTokenByKey(token, RedisConstant.CURRENT_TMIE_MILLIS);
    }

在这里插入图片描述
JedisClientPool 里添加

/**
	 * 设置key ,value 并且设置其过期时间
	 *
	 * @param key
	 * @param value
	 * @param expireTime
	 * @return
	 */
	 @Override
	public String set(String key,String value,int expireTime){
   
     
		Jedis jedis = jedisPool.getResource();
		String result = jedis.set(key, value);
		if ("OK".equals(result)) {
   
     
			jedis.expire(key, expireTime);
		}
		jedis.close();
		return result;
	}

还需要在JedisClient 接口里写个方法

在这里插入图片描述

2.xmall-manager-web 重点

先理一下他原来的解决方法
首先我看找到登录的地方
在这里插入图片描述
我们可以看到原来的项目里直接把 password 进行了MD5加密
再和username 组装成token
调用 subject.login(token)
调用这个之后会到MyRealm类里执行doGetAuthenticationInfo方法
在这里插入图片描述
这里可以看到从token中获取username,去数据库里查询,查到 就把数据放到
SimpleAuthenticationInfo类对象里返回,这里我们注意new 实例化 传的参数








SimpleAuthenticationInfo(Object principal, Object credentials, String realmName)

第一个是主键,第二个是证书,第三个随便只要不为null

到这儿只是部分登录的流程,还有几个重点现在开始讲完整的请求过程
先看一下shiro的配置
在这里插入图片描述
加入一个查询的请求过来
首先被我们MyPermissionFilter拦截器拦截
在这里插入图片描述
subject.getPrincipal() 就是我们刚刚说的SimpleAuthenticationInfo 里的第一个参数
如果没有值就是没有登录,如果登录里就判断 parms 是否被允许
subject.isPermitted(perms[0])
perms 这个值是哪里来的呢?和shiro框架里的比较,框架里是拿来的的?
第一个问题:
perms的值isAccessAllowed(ServletRequest request, ServletResponse response, Object o) 来之Object o 这个值就是来之配置文件的filterChainDefinitions
但是xmall项目把校验的配置放到数据库中了tb_shiro_filter
配置文件里可以看到项目自己实现了MyShiroFilterFactoryBean,这里面可以看到是读取数据库中的配置的。一个什么请求后面就带了需要校验的权限或者角色
在这里插入图片描述
第二个问题
还是看回我们的MyRealm
doGetAuthorizationInfo方法获取了当前账号所有的角色和权限路径
在这里插入图片描述

















三.改造项目

在controller 中

@ResponseBody
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    public CommonResult login(String username, String password) {
   
     
        String token = userService.login(username, password);
        if (token != null) {
   
     
            return CommonResult.success(token);
        }
        return CommonResult.failed("传入账号或密码错误", null);
    }

在userService中

/**
     * 用户登录
     *
     * @param username
     * @param password
     * @return
     */
    public String login(String username, String password) {
   
     
        User user = getUserByUsername(username);
        if (user != null && user.getPassword().equals(password)) {
   
     
            // redis存储的时间戳
            String currentTimeMillis = String.valueOf(System.currentTimeMillis());
            // redis 设置
            redisUtil.set(username, currentTimeMillis);
            return jwtUtil.createToken(username, currentTimeMillis);
        } else {
   
     
            return null;
        }
    }

主要工作:1、把username 和password 和数据里比较如果存在
2、存到redis中设置过期时间
3、使用JWT 生成token
然后新建一个MyShiroRealm


public class MyShiroRealm extends AuthorizingRealm {
   
     
    private static final Logger log= LoggerFactory.getLogger(MyRealm.class);

    @Autowired
    private UserService userService;

    @Autowired
    JwtUtil jwtUtil;

    @Autowired
    JedisClient redisUtil;

    /**
     * 返回权限信息
     * @param principal
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal) {
   
     
        // 从token中获取username
        String username = jwtUtil.getUsernameFromToken(principal.toString());
        TbUser tbUser = userService.getUserByUsername(username);
        SimpleAuthorizationInfo authorizationInfo=new SimpleAuthorizationInfo();
        //获得授权角色
        authorizationInfo.setRoles(userService.getRoles(username));
        //获得授权权限
        authorizationInfo.setStringPermissions(userService.getPermissions(username));
        return authorizationInfo;
    }

    /**
     * 先执行登录验证
     * @param auth
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
   
     
        // 在shiro中获得用户(token)
        String token = (String) auth.getCredentials();
        // 验证token 首先查看token是否包含username,其次查看其中的username是否在数据库里,最后,校验token的正确性
        String username = jwtUtil.getUsernameFromToken(token);
        if (username == null) {
   
     
            throw new AuthenticationException("无效token");
        }
        // 验证用户是否存在
        TbUser tbUser = userService.getUserByUsername(username);
        if(tbUser == null){
   
     
            throw new AuthenticationException("无效token");
        }

        String redisUsername = String.format("%s%s", RedisConstant.REDIS_USERNAME_PREFIX, username);
        //数据库里取的username 和token和redis里的比较是否一致
        if(jwtUtil.verifyToken(username,token) && redisUtil.exists(redisUsername)){
   
     
            // redis中存储的token
            String currentTimeMillisRedis = redisUtil.get(redisUsername); //根据key可以获取存储的时间戳
            //从参数token中获取时间戳 和redis里存的比较是否一致
            if(jwtUtil.getCurrentTmieMillisFromToken(token).equals(currentTimeMillisRedis)){
   
     
                //得到用户账号(token)和密码(token)存放到authenticationInfo中用于Controller层的权限判断 第三个参数随意不能为null
                return new SimpleAuthenticationInfo(token,token,"MyShiroRealm");
            }
        }
        throw new AuthenticationException("无效token");
    }
}

这里主要变化就是doGetAuthenticationInfo登录的方法
主要是JWT 解码token 获得参数 去数据库查找比较,在和redis 里比较是否一致
一致就把subject.login(token) 传过来的JWT加密过的token 放到
SimpleAuthenticationInfo(token,token,“MyShiroRealm”) 实例化对象里返回


我们接着看自定义的MyLoginFilter

public class MyLoginFilter extends BasicHttpAuthenticationFilter {
   
     
    private static final Logger log= LoggerFactory.getLogger(MyPermissionFilter.class);

    @Value(value="60000")
    private String tokenValidTime;

    @Value(value="80000")
    private String inValidTokenLiveSaveTime;

    @Autowired
    JwtUtil jwtUtil;

    @Autowired
    JedisClient redisUtil;

    /**
     * 判断请求头中是否含有token
     *
     * @param request
     * @param response
     * @return
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
   
     
        return !StringUtils.isEmpty(this.getAuthzHeader(request));
    }


    /**
     * 正常情况下返回true,如果遇到Token过期的话,这里调用刷新token。遇到其他的异常,这里返回401。
     *
     * @param request
     * @param response
     * @param mappedValue
     * @return
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
   
     
        String token = this.getAuthzHeader(request);
        if (isLoginAttempt(request, response) == true) {
   
     
            try {
   
     
                this.executeLogin(request, response);
            } catch (Exception e) {
   
     
                Throwable throwable = e.getCause();
                if (throwable instanceof TokenExpiredException) {
   
     
                    // 执行刷新token
                    String newToken = this.refreshToken(token);
                    if (!StringUtils.isEmpty(newToken)) {
   
     
                        // 将刷新之后的token放在响应的头上 前端下次取出很本地的比较,如果不一样的话做token的替换。
                        ((HttpServletResponse) response).setHeader(AUTHORIZATION_HEADER, newToken);
                        ((HttpServletResponse) response).setHeader("Access-Control-Expose-Headers", AUTHORIZATION_HEADER);
                        return true;
                    }
                }
            }
            this.response401(response);
            return false;
        }
        return true;

    }


    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
   
     
        String token = this.getAuthzHeader(request);
        JwtToken jwtToken = new JwtToken(token);
        Subject subject = getSubject(request, response);
        subject.login(jwtToken);
        return true;
    }


    /**
     * 刷新过期的token
     *
     * @param needRefreshToken
     * @return
     */
    private String refreshToken(String needRefreshToken) {
   
     
        // 过期token的username
        String needRefreshTokenUsername = jwtUtil.getUsernameFromToken(needRefreshToken);

        if (redisUtil.exists(needRefreshTokenUsername)) {
   
     
            // 如果redis中存在过期token的key 则获取存储的时间戳
            String redisTokenTimeMillis = redisUtil.get(needRefreshTokenUsername);
            // 需要被刷新的token中获取时间戳
            String needRefreshTokenTimeMillis = jwtUtil.getCurrentTmieMillisFromToken(needRefreshToken);

            // 相等执行刷新token的步骤
            if (redisTokenTimeMillis.equals(needRefreshTokenTimeMillis)) {
   
     
                String currentTimeMillis = String.valueOf(System.currentTimeMillis());
                redisUtil.set(RedisConstant.REDIS_USERNAME_PREFIX + needRefreshTokenUsername, currentTimeMillis);

                String newToken = jwtUtil.createToken(needRefreshTokenUsername, currentTimeMillis);

                // 这里的目的是为了防止 前端同时多请求 所带来刷新token多次的问题(当第二个请求带着老得token来的时候,在myRealm中增加此判断 这样的请求不会认为其过期)
                // 而这里的过期时间是按照前端设置了请求8秒没有返回则默认为请求超时。 这里给老得token16秒的存在时间。
                redisUtil.set(RedisConstant.REDIS_EXPIRE_TOKEN_PREFIX + needRefreshTokenUsername, needRefreshToken, Integer.valueOf(inValidTokenLiveSaveTime));
                return newToken;
            }
        }
        return "";
    }


    /**
     * 将非法请求跳转到 /401
     */
    private void response401(ServletResponse resp) {
   
     
        try {
   
     
            HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
            httpServletResponse.sendRedirect("/401");
        } catch (IOException e) {
   
     
            log.error(e.getMessage());
        }
    }
}

这边我们看到了subject.login()。这里就是请求中是不是带token,如果有就是登录
还有一个是刷新redis里过期时间。
我就说一些刷新的思路
首先如果MyShiroRealm 中登录失败
我们拿着这个token 去redis中找是否存在,再看它的时间戳是否和redis中的一致
生成新的token设置到redis中并设置过期时间
这边说一下redis中存的数据
redis里存两种数据
一个是 “XXX”+username,时间戳
一个是 “XXX”+username,token,并设置过期时间








总结

由于篇幅的问题我就不在说了,基本上到这边就介绍了。
思考一下使用JWT 登录的时候真的登录了么?
其实没有,shiro没有登录,因为login 请求过来的时候url里没有token,只是生成了token返回。下次发送请求的时候shiro才会登录。
还有一个shiro配置文件没贴出来。应该不难把,自己配一下检验一下自己。如果真有需要,就在下方留言。


展开阅读全文
打赏
1
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
1
分享
返回顶部
顶部