xmall商城學習筆記——JWT改造登入

2020-09-19 12:04:51


前言

之前給大家許諾的給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組態檔沒貼出來。應該不難把,自己配一下檢驗一下自己。如果真有需要,就在下方留言。