前言
- 本章節是做一個小型的前後端項目的驗證授權控制簡單Demo實作方案
- 重點在于後端API的權限認證
- 這種實作方式比較low,不容易開發維護,此部落格還寫的很醜陋,建議自己看項目代碼分析就好(重點在于Interceptor的邏輯),很快就明白的。
- 項目位址:github.com/Tonciy/perm…
1. 描述
1.1 實作思路
- 總體流程如下:
- 登入邏輯如下:
- 請求權限認證如下
1.2 使用者通路API劃分
- 如下圖所示
2. 環境搭建
2.1 資料庫
- 基于RBAC模型建立的,比較簡單
- 在這裡要特别描述下權限表的各個字段含義:
- 比如做SaaS如果還要細分的話,權限表實際上還可以拆,這裡隻是一個權限認證的小Demo,就簡單化了
- 本Demo中權限表中的具體資料如下圖所示(特别注意每行記錄中的api_identify值,代表某個接口的唯一辨別符,後面在Controller中會有對應标明")
2.2 項目
- 項目位址:github.com/Tonciy/perm…
- 由于總體上比較簡單,這裡隻描述一些重要的點,其餘的自行檢視釋出到GitHub的項目
- 登入接口
- @RestController @RequestMapping("/login") public class LoginController { @Resource private UserService userService; @Resource private JwtUtils jwtUtils; @Resource private RedisTemplate<String, Object> redisTemplate; @Value("${redis.user.prefix}") private String redisKeyPrefix; @Value("${jwt.config.ttl}") private Long time = 1800L; @PostMapping public Result login(String username, String password) throws CommonException { if(StringUtils.isEmpty(username) || StringUtils.isEmpty(password)){ throw new CommonException(ResultCode.REQUEST_PARARMETER_MISS); } User user = userService.findByUsername(username); if(user == null){ // 不存在此使用者,登入失敗 return new Result(ResultCode.USERNAME_PASSWORD_ERROR); }else{ // 比對密碼 if(password.equals(user.getPassword())){ // 登入成功,存儲目前使用者到Redis裡(設定存活時間) 簽發token redisTemplate.opsForValue().set(redisKeyPrefix + user.getId(), user, time, TimeUnit.SECONDS); String token = jwtUtils.createJwt(user.getId(), user.getUsername(), null); return Result.SUCCESS(token); }else{ // 密碼錯誤 return new Result(ResultCode.USERNAME_PASSWORD_ERROR); } } } } 複制代碼
- 這裡的登入邏輯是按照上述的邏輯圖來實作的
- 特别注意使用者資訊存放到Redis中的key,是通過配置的字首 + 使用者id拼接成的
- 有效時間也是通過配置來設定的,否則有個預設時間
- 兩個Controller(注意每個接口上的請求映射注解name屬性上,都标明了此接口對應的唯一辨別符)
- 統一狀态碼封裝
- public enum ResultCode { SUCCESS(true, 10000, "操作成功!"), //---系統錯誤傳回碼----- FAIL(false, 10001, "操作失敗"), UNAUTHENTICATED(false, 10002, "您還未登入"), TOKEN_LOSE_EFFICACY(false, 10003, "登入憑證已失效!"), UNAUTHORISE(false, 10004, "權限不足"), /** * 登入失敗異常 */ USERNAME_PASSWORD_ERROR(false, 20001, "使用者名或者密碼錯誤"), REQUEST_PARARMETER_MISS(false, 30000, "請求參數缺失"), /** * 請求類型不支援 */ REQUEST_METHOD_NOT_SUPPORT(false, 40000, "不支援的請求類型"), SERVER_ERROR(false, 99999, "抱歉,系統繁忙,請稍後重試!"); //---其他操作傳回碼---- //操作是否成功 boolean success; //操作代碼 int code; //提示資訊 String message; ResultCode(boolean success, int code, String message) { this.success = success; this.code = code; this.message = message; } public boolean success() { return success; } public int code() { return code; } public String message() { return message; } } 複制代碼
- 自定義異常
- 異常統一處理
- /** * @author: Zero * @time: 2022/12/28 * @description: 統一異常處理 */ @RestControllerAdvice public class BaseExceptionHandler { /** * 通用自定義異常捕獲(登入狀态/權限驗證) * * @return */ @ExceptionHandler(value = CommonException.class) public Result commonException(CommonException exception) { if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.REQUEST_PARARMETER_MISS.message())) { // 請求參數缺失 return new Result(ResultCode.REQUEST_PARARMETER_MISS); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.UNAUTHENTICATED.message())) { // 未登入/token非法 return new Result(ResultCode.UNAUTHENTICATED); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.TOKEN_LOSE_EFFICACY.message())) { // 登入憑證token已經失效 return new Result(ResultCode.TOKEN_LOSE_EFFICACY); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.UNAUTHORISE.message())) { // 通路權限不足 return new Result(ResultCode.UNAUTHORISE); } if (exception.getMessage() != null && exception.getMessage().equals(ResultCode.REQUEST_METHOD_NOT_SUPPORT.message())) { // 不支援的請求方法類型 return new Result(ResultCode.REQUEST_METHOD_NOT_SUPPORT); } if (exception.getMessage() != null) { // 給定異常資訊 return new Result(10001, exception.getMessage(), false); } // 請求失敗 return new Result(ResultCode.FAIL); } /** * 伺服器異常統一傳回 * * @return */ @ExceptionHandler(value = Exception.class) public Result error() { return new Result(ResultCode.SERVER_ERROR); } } 複制代碼
- 攔截器實作
- /** * @author: Zero * @time: 2022/12/28 * @description: */ public class RequestInterceptor implements HandlerInterceptor { @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private JwtUtils jwtUtils; @Value("${redis.user.prefix}") private String redisKeyPrefix; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 擷取token String authorization = request.getHeader("Authorization"); // 2. 驗證token (不為null 且 開頭為"Bearer ",簽發的時候是以"Bearer "開頭,後面再接token實際值-業界統一這樣做,也不知道為啥) if (!StringUtils.isEmpty(authorization) && authorization.startsWith("Bearer ")) { String token = authorization.replace("Bearer ", ""); Claims claims = null; try { claims = jwtUtils.parseJwt(token); } catch (ExpiredJwtException e) { e.printStackTrace(); throw new CommonException(ResultCode.TOKEN_LOSE_EFFICACY); // token失效 } catch (UnsupportedJwtException e) { e.printStackTrace(); throw new CommonException("不支援的token"); } catch (MalformedJwtException e) { e.printStackTrace(); throw new CommonException("token解析失敗"); } catch (SignatureException e) { e.printStackTrace(); throw new CommonException("token簽名驗證失敗"); } catch (IllegalArgumentException e) { e.printStackTrace(); throw new CommonException("token非法參數"); } if (claims != null) { // 已登入 // 從Redis中擷取使用者,進而擷取權限資訊 User user = (User) redisTemplate.opsForValue().get(redisKeyPrefix + claims.getId()); List<Permission> permissions = null; if (user != null) { permissions = user.getPermissions(); } else { // Redis出問題,導緻儲存的已經登入的使用者資訊沒了(注意不是登入時間失效了) throw new CommonException(ResultCode.SERVER_ERROR); } // 通過注解反射擷取每個API接口的唯一辨別符 // --在這裡的是唯一辨別符是在Controller的方法上的@RequestMapping的name屬性标明的,資料庫的API也有 // --可以自己自定義注解接口來實作(這樣擷取時比較容易),使用Restful風格時推薦使用, // -- 使用了Restful風格但是沒有統一使用@RequestMapping的話那就根據請求類型來擷取注解 HandlerMethod h = (HandlerMethod) handler; // 擷取接口上的@RequestMapping注解 Object annotation = null; // 擷取請求類型 String method = request.getMethod().toUpperCase(); String name = null; // 表示目标接口處的唯一辨別符 boolean pass = false; // 表示最終是否有權限通路此接口 switch (method) { case "GET": annotation = h.getMethodAnnotation(GetMapping.class); name = ((GetMapping) annotation).name(); break; case "POST": annotation = h.getMethodAnnotation(PostMapping.class); name = ((PostMapping) annotation).name(); break; case "DELETE": annotation = h.getMethodAnnotation(DeleteMapping.class); name = ((DeleteMapping) annotation).name(); break; case "PUT": annotation = h.getMethodAnnotation(PutMapping.class); name = ((PutMapping) annotation).name(); break; default: throw new CommonException(ResultCode.REQUEST_METHOD_NOT_SUPPORT); } if (permissions != null && !StringUtils.isEmpty(name)) { //如需權限限定時使用開放此句即可 for (Permission permission : permissions) { if (permission.getApiIdentify() != null && permission.getApiIdentify().equals(name)) { // 具有通路權限 pass = true; break; } } } if (pass) { // // 表示具有通路權限 return true; } else { // 無通路權限 throw new CommonException(ResultCode.UNAUTHORISE); } } } // 未登入/token格式不對 throw new CommonException(ResultCode.UNAUTHENTICATED); } } 複制代碼
- 配置檔案
3. 實踐測試
3.1 Admin
- 登入張三使用者
- 帶着token通路OneController的各個接口
- Get
- Post
- Put
- Delete
- 帶着token通路TwoController的各個接口
- Get
- Post
- Put
- Delete
- 可以看到張三這個Admin使用者正如我們所願,可以通路到OneController和TwoController中的接口
3.2 Common
- 登入李四使用者
- 帶着token通路OneController的各個接口
- Get
- Post
- Put
- Delete
- 帶着token通路OneController的各個接口
- Get
- Post
- Put
- Delete
- 可以看到李四這個Common使用者,按照我們之前的規劃,隻能通路OneController的接口,通路不到TwoController的接口
3.3 Another
- 對于其他情況,比如說token過期,未登入,還是說token非法等情況
- 在攔截器中均有對應的情況解決
- 也就是直接抛出對應的裝載了自定義狀态碼的異常
- 然後統一解決異常處理
- 在這裡就不再一一示範了
4. 總結
- 總的來說,邏輯上是沒啥問題的
- 基本上能夠實作登入驗權的基本功能
- 隻要寫Controller接口時,在請求映射注解上通過name屬性标明此接口的唯一辨別符(因為現在大多數使用的是Restful風格,這裡推薦通過自定義注解上的屬性來辨別每個接口的唯一辨別符,這樣在Interceptor中便于擷取每個接口的唯一辨別符,不用再枚舉使用的是哪個映射注解)
- 然後再到權限表中插入此接口資訊,最後在角色-權限表中設定不同角色對應的權限映射關系即可
- 但也帶來很多問題
- 得手動插入接口資訊入表,得手動設定角色-權限表中的映射關系,很麻煩
- 最好是當接口寫的差不多後,再統一弄這個,會節省很多時間,但是還是很麻煩,或者看能不能自己寫個工具類出來,自動完成這一工作,目前這個正在考慮怎麼寫ing
- token本身可能帶來的問題
- 比如說
- token釋出後,比如說設定了有效時間為半個鐘,那麼這半個鐘内此token都有效,無法主動登出此token(玩點極端的,可以重新開機Redis伺服器,付出讓所有線上使用者掉線一次的代價銷毀此token的作用,然後趕緊跑路)
- 還是假設token釋出,其有效時間為半個鐘,原本此使用者是無法A接口的,在這半個鐘内,超級管理者設定了此使用者可以通路A接口,但是使用者對應權限資訊隻是實時更新到了資料庫中,而Redis中還是存的是老舊的使用者資訊,也就代表着使用剛剛釋出的token來通路時,從Redis中擷取到的使用者資訊,是不具有通路A接口的權限的,很沖突。(讓使用者退出重新登入即可)
- 總而言之:僅供參考