天天看點

JWT實作單點登入(SSO)一、理論二、實作過程

目錄

一、理論

1.SSO

2.JWT

#.組成 

#.如何工作

3.Redis   RSA   MD5

4.AOP

二、實作過程

#.準備工作

#.登入

#.測試類

#.插拔式注解

#.測試

最近有機會接觸到了單點登入,寫一篇文章記錄一下整個實作的流程。

技術名詞

  1. SSO (SingleSignOn 單點登入)
  2. JWT(Json web token 一種認證協定)
  3. Redis(Remote Dictionary Server 遠端字典、非關系型資料庫、高速緩存中間件)
  4. AOP(Aspect Oriented Programming 面向切面程式設計)
  5. RSA  MD5 (加密算法)

一、理論

1.SSO

JWT實作單點登入(SSO)一、理論二、實作過程

[圖檔來源于網絡,版權歸原作者所有,如有侵權,請告知立即删除]

舉一些例子:登入了淘寶,進入之後點選天貓不用重複登入。最常用的百度,隻要登陸了,進入其他應用如百度文庫、貼吧等都是不用重複登入的。

總結:一處登入,處處登入,一處登出,處處登出。

再詳細點:用戶端請求需要攜帶TOKEN(内有使用者資訊等)才能通路一些登入後才能通路的URL,如果不傳遞TOKEN或者是過期以及被篡改都是不能通過校驗的,就會讓使用者重新登入,登入成功之後伺服器會傳回TOKEN給用戶端,用戶端隻要攜帶這個TOKEN就可以通路其他分布式的系統(這些系統都可以識别TOKEN),如果登出,TOKEN被删除,則會要求使用者重新登入。

2.JWT

#.組成 

格式:xxx.yyy.zzz

  • Header
    • 兩部分組成:token的類型("JWT")和算法名稱(如:SHA256或者RSA)
    • 用Base64對這個JSON編碼就得到JWT的第一部分
  • Payload
    • 它包含聲明(要求)聲明有三種類型: registered, public 和 private。
    • registered :預定義聲明,不是強制的,一般jwt會包含建立時間以及銷毀時間
    • public:可以定義你想存到jwt裡的資訊,如使用者的id、userName、email等,一般不存敏感資訊,除非你通過加密讓此這部分資訊變得可靠。
    • 對payload進行Base64編碼就得到JWT的第二部分
  • Signature
    • 這是一個簽名,擁有防篡改等功能,三個組成部分
    • base64UrlEncode(header)      →  x
    • base64UrlEncode(payload)     →  y
    • secret   →  z
    • 以上三部分作為參數,通過簽名算法(header中的算法名稱)對它們進行簽名(x與y通過 . 連接配接,加鹽z 組合加密),如 HMACSHA256(x+"."+y , z)

#.如何工作

在認證的時候,使用者通過憑證成功登陸之後,伺服器會傳回一個一個jwt(TOKEN)給前端,在此之後,它便是使用者的憑證了,在每一次的通路上,可以在請求的Header上攜帶這個憑證,在伺服器上受保護的路由會驗證此TOKEN。别忘了一點,jwt是可以攜帶資訊的,如使用者的賬戶類型,通過此也可以做權限管理,如果jwt攜帶足夠多的資料,可以減少不必要的資料庫通路。

3.Redis   RSA   MD5

Redis是什麼就不介紹了,主要說一下我在實作的過程中充當的作用。

  • 存儲RSA公私鑰
    • 公私鑰需要不斷變化,使使用者登入輸入的密碼在前端加密之後不會每一次都相同
  • 存儲使用者的TOKEN
    • 使用者的憑證驗證通過Redis
    • 設定TOKEN的有效期

4.AOP

面向切面程式設計,通過閱讀代碼能夠獲得更好的了解。

二、實作過程

#.準備工作

建立一個授權中心(一個普通的SpringBoot項目即可)

按照正常的建立流程建立好之後,建立LoginController用作入口。

  • 引入jedis并配置連接配接池
    • 引入相關的包:redis.client.jedis
    • 修改配置檔案
      • #帶有"#"符号的需要手動填寫
        spring:
          redis:
            database: 0
        #    host: 000.000.000.000
        #    port: 6379
            connect-timeout: 5000
            jedis:
              pool:
                max-active: 8
                max-idle: 8
                min-idle: 0
                   
    • 添加配置檔案,附配置檔案:JedisConfig.java
    • 當然還需要有一個redis的服務
  • 配置JWT
    • 引入相關的包  jjwt-api、jjwt-impl、jjwt-jackson、commons-codec
    • 修改配置檔案
      • #保持所有配置檔案相同即可
        jwt:
          secret: adsfdsfsdfdsfwetrwgfsdfsdfwsEFSEAFESF
          # 有效期,機關秒
          expire-time-in-second: 10000
                   
    • 添加配置檔案,附配置檔案:JwtOperator.java
  • DTO:LoginDTO.java
  • Result:Result.java
  • ParamNameEnum:ParamNameEnum.java

#.登入

package com.shixin.security.controller;
/**
 * @Description
 * @Author shixin
 * @Date 2021/4/23 23:38
 */
@Slf4j
@RestController
@RequestMapping("/login")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class LoginController {
    private final PcUserMapper pcUserMapper;
    private final JedisPool jedisPool;
    private final JwtOperator jwtOperator;
    private static String RSA_PUBLIC_KEY = "RSA_PUBLIC_KEY";
    private static String RSA_PRIVATE_KEY = "RSA_PRIVATE_KEY";
    @Value("${jwt.expire-time-in-second}")
    private Integer EXPIRATION_TIME_IN_SECOND;
    /**
      * @Description: 建立RSA秘鑰對 傳回公鑰給前端 redis儲存密鑰對,主要作用為不斷更改使用者使用
      *               相同資料加密後的結果,使得資料難以僞造
      * @Date: 2021/4/24 11:52
      */
    @PostMapping("/refreshKey")
    public Result<String> getRSA() throws NoSuchAlgorithmException {
        Jedis jedis = jedisPool.getResource();
        Map<String, String> keyMap = RSAUtil.genKeyPair(RSA_PUBLIC_KEY,RSA_PRIVATE_KEY);
        String publicKey = keyMap.get(RSA_PUBLIC_KEY);
        String privateKey = keyMap.get(RSA_PRIVATE_KEY);
        jedis.set(RSA_PUBLIC_KEY,publicKey);
        jedis.set(RSA_PRIVATE_KEY, privateKey);
        return Result.success("請求成功",publicKey);
    }
    /**
      * @Description: 傳遞公鑰加密過的密碼,後端解密後用MD5J加密和資料庫面對比
      * @Date: 2021/4/24 11:48
      */
    @PostMapping("/check")
    public Result<LoginDTO> login(@RequestBody PcUser param) throws Exception {
        Jedis jedis = jedisPool.getResource();
        //校驗空值
        if (!StringUtils.isBlank(param.getUserId()) && !StringUtils.isBlank(param.getPassword())){
            //擷取資訊,沒有連接配接資料庫可以自行僞造資料
            PcUser pcUser = pcUserMapper.selectByUserId(param.getUserId());
            //通過redis擷取的私鑰對前端傳過來的用公鑰加密過的密碼進行解密
            String password = RSAUtil.decrypt(param.getPassword(), jedis.get(RSA_PRIVATE_KEY));
            //通過getSaltverifyMD5這個方法可以校驗資料庫密文的明文是否是password,如果是,就可以開始頒發TOKEN了
            if (pcUser != null && MD5Util.getSaltverifyMD5(password,pcUser.getPassword())){
                //構件jwt第二部分的public部分,如果不知道什麼意思的可以看理論-JWT部分的介紹
                Map<String,Object> userInfo = new HashMap<>(5);
                userInfo.put("id",pcUser.getId());
                userInfo.put("type",pcUser.getType());
                userInfo.put("userName",pcUser.getUserName());
                userInfo.put("email",pcUser.getEmail() == null ? "":pcUser.getEmail());
                userInfo.put("avatar",pcUser.getAvatar() == null ? "":pcUser.getAvatar());
                //生成TOKEN
                String token = jwtOperator.generateToken(userInfo);
                //将TOKEN存到Redis裡面,格式:TOKEN_[userId] : TOKEN
                jedis.setex(ParamNameEnum.TOKEN_ +pcUser.getUserId(),EXPIRATION_TIME_IN_SECOND,token);
                //建構傳回給前端的資料,在此之後,前端的請求都會攜帶token通路,隻要token驗證通過,便會放行
                return Result.success(LoginDTO.builder()
                        .token(token)
                        .userInfo(LoginDTO.UserInfo.builder()
                                .id(pcUser.getId())
                                .type(pcUser.getType())
                                .userId(pcUser.getUserId())
                                .userName(pcUser.getUserName())
                                .avatar(pcUser.getAvatar() == null ? "" : pcUser.getAvatar())
                                .email(pcUser.getEmail() == null ? "" : param.getEmail())
                                .build())
                        .build()
                );
            }else {
                return Result.fail("賬号或密碼錯誤");
            }
        }else {
            return Result.fail("使用者或密碼為空");
        }
    }
}
           

#.測試類

package com.shixin.pawcode.admin.controller;
/**
 * @Description
 * @Author shixin
 * @Date 2021/4/24 22:15
 */
@Slf4j
@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
    private final JedisPool jedisPool;
    @Value("${jwt.expire-time-in-second}")
    private Integer EXPIRATION_TIME_IN_SECOND;
    @GetMapping("/checkAspect")
    //這個注解的編寫後面會寫到,這就是上面提到過的AOP程式設計,進入此方法的請求都會被攔截
    @CheckLogin
    public String checkAspect(HttpServletRequest request, HttpServletResponse response){
        //處理業務邏輯

        //擷取經過注解處理之後在request添加的TOKEN,怎麼處理,下面有介紹
        String token = (String) request.getAttribute(ParamNameEnum.L_TOKEN.name());
        if (!StringUtils.isBlank(token)){
            Jedis jedis = jedisPool.getResource();
            //在業務邏輯處理完畢并且不出錯的情況下才重新整理TOKEN,否則還是使用原來的TOKEN
            response.setHeader(ParamNameEnum.L_TOKEN.name(),token);
            jedis.setex(ParamNameEnum.TOKEN_.name() + request.getAttribute("userId"),EXPIRATION_TIME_IN_SECOND,token);
        }
        return token;
    }
}
           

#.插拔式注解

CheckLogin.java

package com.shixin.common.auth;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckLogin {
}
           

CheckLoginAspect.java

package com.shixin.common.auth;
/**
 * @Description 隻要添加了CheckLogin注解的都會經過此方法
 * @Author shixin
 * @Date 2021/4/24 21:46
 */
@Aspect
@Component
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class CheckLoginAspect {
    private final JwtOperator jwtOperator;
    private final JedisPool jedisPool;
    @Value("${jwt.expire-time-in-second}")
    private Integer EXPIRATION_TIME_IN_SECOND;
    //如果不了解此注解的作用可以自行百度關于AOP切面程式設計,注意裡的參數要填寫注解的包名全路徑
    @Around("@annotation(com.shixin.common.auth.CheckLogin)")
    public Object checkLogin(ProceedingJoinPoint point) throws Throwable {
        //1.從header裡擷取token
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
        HttpServletRequest request = attributes.getRequest();
        //這裡要去redis校驗 并且在業務完成後重新整理過期時間 token會變
        //在前端傳輸的過程中,我設定多傳了一個ID,用于建立Redis的key和擷取,可以直接通過id和token來确定是否登入
        String token = request.getHeader(ParamNameEnum.L_TOKEN.name());
        String userId = request.getHeader(ParamNameEnum.U_ID.name());
        //校驗token 是否和過期
        Jedis jedis = jedisPool.getResource();
        String redisToken = jedis.get(ParamNameEnum.TOKEN_.name()+ userId);
        if (!StringUtils.isBlank(redisToken) || !StringUtils.isBlank(token)) {
            if (!redisToken.equals(token) ) {  // || !jwtOperator.validateToken(token) 這個是校驗是否合法,redis裡存的jwt本身就是合法的了,而且有效期也有,是以可以省略這個校驗
                //自定義異常類,自己随意建立
                throw new SecurityException("Token不合法");
            } else {
                //添加使用者資訊到attribute
                Claims claims = jwtOperator.getClaimsFromToken(token);
                Map<String,Object> userInfo = new HashMap<>(5);
                Object id = claims.get("id");
                request.setAttribute("id", id);
                userInfo.put("id",id);
                Object type = claims.get("type");
                request.setAttribute("type", type);
                userInfo.put("type",type);
                Object userName = claims.get("userName");
                request.setAttribute("userName", userName);
                userInfo.put("userName",userName);
                Object email = claims.get("email") == null ? "" : claims.get("email");
                request.setAttribute("email", email);
                userInfo.put("email",email);
                Object avatar = claims.get("avatar") == null ? "" : claims.get("avatar");
                request.setAttribute("avatar", avatar);
                userInfo.put("avatar",avatar);
                //生成新的TOKEN
                String newToken = jwtOperator.generateToken(userInfo);
                //通過request将TOKEN傳遞給方法
                request.setAttribute(ParamNameEnum.L_TOKEN.name(), newToken);
                //執行被注解的方法
                return point.proceed();
            }
        } else {
            throw new SecurityException("使用者未登入");
        }
    }
}
           

#.測試

假定:本地測試,端口8083

  • 通路localhost:8083/login/refreshKey
    • 生成一對公私鑰
  • 通路localhost:8083/login/check
    • 也就是登入接口
    • 通過postman測試的要讓密碼先用第一步生成的RSA公鑰加密再傳輸
    • 此方法的傳回結果會包含一個token
    • JWT實作單點登入(SSO)一、理論二、實作過程
  • 攜帶上一步傳回的token以及userId通路用來測試的路由localhost:8083/checkAspect
    • JWT實作單點登入(SSO)一、理論二、實作過程
    • 複制傳回值(②)中的TOKEN到請求體的TOKEN(①)中,重複這個操作
    • 實作每次通路本站都可以免登陸所設定的最長時長

繼續閱讀