Java編程架構-「RESTful登錄設計基於Spring及Redis的Token鑒權」

Java編程架構-「RESTful登錄設計基於Spring及Redis的Token鑑權」

什麼是 REST

REST (Representational State Transfer) 是一種軟件架構風格。它將服務端的信息和功能等所有事物統稱為資源,客戶端的請求實際就是對資源進行操作,它的主要特點有: - 每一個資源都會對應一個獨一無二的 url - 客戶端通過 HTTP 的 GET、POST、PUT、DELETE 請求方法對資源進行查詢、創建、修改、刪除操作 - 客戶端與服務端的交互必須是無狀態的

關於 RESTful 的詳細介紹可以參考 這篇文章,在此就不浪費時間直接進入正題了。

使用 Token 進行身份鑑權

網站應用一般使用 Session 進行登錄用戶信息的存儲及驗證,而在移動端使用 Token 則更加普遍。它們之間並沒有太大區別,Token 比較像是一個更加精簡的自定義的 Session。Session 的主要功能是保持會話信息,而 Token 則只用於登錄用戶的身份鑑權。所以在移動端使用 Token 會比使用 Session 更加簡易並且有更高的安全性,同時也更加符合 RESTful 中無狀態的定義。

交互流程

  1. 客戶端通過登錄請求提交用戶名和密碼,服務端驗證通過後生成一個 Token 與該用戶進行關聯,並將 Token 返回給客戶端。
  2. 客戶端在接下來的請求中都會攜帶 Token,服務端通過解析 Token 檢查登錄狀態。
  3. 當用戶退出登錄、其他終端登錄同一賬號(被頂號)、長時間未進行操作時 Token 會失效,這時用戶需要重新登錄。

程序示例

服務端生成的 Token 一般為隨機的非重複字符串,根據應用對安全性的不同要求,會將其添加時間戳(通過時間判斷 Token 是否被盜用)或 url 簽名(通過請求地址判斷 Token 是否被盜用)後加密進行傳輸。在本文中為了演示方便,僅是將 User Id 與 Token 以”\_”進行拼接。

/**

* Token 的 Model 類,可以增加字段提高安全性,例如時間戳、url 簽名

* @author ScienJus

* @date 2015/7/31.

*/

public class TokenModel {

// 用戶 id

private long userId;

// 隨機生成的 uuid

private String token;

public TokenModel (long userId, String token) {

this.userId = userId;

this.token = token;

}

public long getUserId () {

return userId;

}

public void setUserId (long userId) {

this.userId = userId;

}

public String getToken () {

return token;

}

public void setToken (String token) {

this.token = token;

}

}

Redis 是一個 Key-Value 結構的內存數據庫,用它維護 User Id 和 Token 的映射表會比傳統數據庫速度更快,這裡使用 Spring-Data-Redis 封裝的 TokenManager 對 Token 進行基礎操作:

/**

* 對 token 進行操作的接口

* @author ScienJus

* @date 2015/7/31.

*/

public interface TokenManager {

/**

* 創建一個 token 關聯上指定用戶

* @param userId 指定用戶的 id

* @return 生成的 token

*/

public TokenModel createToken (long userId);

/**

* 檢查 token 是否有效

* @param model token

* @return 是否有效

*/

public boolean checkToken (TokenModel model);

/**

* 從字符串中解析 token

* @param authentication 加密後的字符串

* @return

*/

public TokenModel getToken (String authentication);

/**

* 清除 token

* @param userId 登錄用戶的 id

*/

public void deleteToken (long userId);

}

/**

* 通過 Redis 存儲和驗證 token 的實現類

* @author ScienJus

* @date 2015/7/31.

*/

@Component

public class RedisTokenManager implements TokenManager {

private RedisTemplate redis;

@Autowired

public void setRedis (RedisTemplate redis) {

this.redis = redis;

// 泛型設置成 Long 後必須更改對應的序列化方案

redis.setKeySerializer (new JdkSerializationRedisSerializer ());

}

public TokenModel createToken (long userId) {

// 使用 uuid 作為源 token

String token = UUID.randomUUID ().toString ().replace ("-", "");

TokenModel model = new TokenModel (userId, token);

// 存儲到 redis 並設置過期時間

redis.boundValueOps (userId).set (token, Constants.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS);

return model;

}

public TokenModel getToken (String authentication) {

if (authentication == null || authentication.length () == 0) {

return null;

}

String [] param = authentication.split ("_");

if (param.length != 2) {

return null;

}

// 使用 userId 和源 token 簡單拼接成的 token,可以增加加密措施

long userId = Long.parseLong (param [0]);

String token = param [1];

return new TokenModel (userId, token);

}

public boolean checkToken (TokenModel model) {

if (model == null) {

return false;

}

String token = redis.boundValueOps (model.getUserId ()).get ();

if (token == null || !token.equals (model.getToken ())) {

return false;

}

// 如果驗證成功,說明此用戶進行了一次有效操作,延長 token 的過期時間

redis.boundValueOps (model.getUserId ()).expire (Constants.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS);

return true;

}

public void deleteToken (long userId) {

redis.delete (userId);

}

}

RESTful 中所有請求的本質都是對資源進行 CRUD 操作,所以登錄和退出登錄也可以抽象為對一個 Token 資源的創建和刪除,根據該想法創建 Controller:

/**

* 獲取和刪除 token 的請求地址,在 Restful 設計中其實就對應著登錄和退出登錄的資源映射

* @author ScienJus

* @date 2015/7/30.

*/

@RestController

@RequestMapping ("/tokens")

public class TokenController {

@Autowired

private UserRepository userRepository;

@Autowired

private TokenManager tokenManager;

@RequestMapping (method = RequestMethod.POST)

public ResponseEntity login (@RequestParam String username, @RequestParam String password) {

Assert.notNull (username, "username can not be empty");

Assert.notNull (password, "password can not be empty");

User user = userRepository.findByUsername (username);

if (user == null || // 未註冊

!user.getPassword ().equals (password)) { // 密碼錯誤

// 提示用戶名或密碼錯誤

return new ResponseEntity (ResultModel.error (ResultStatus.USERNAME_OR_PASSWORD_ERROR), HttpStatus.NOT_FOUND);

}

// 生成一個 token,保存用戶登錄狀態

TokenModel model = tokenManager.createToken (user.getId ());

return new ResponseEntity (ResultModel.ok (model), HttpStatus.OK);

}

@RequestMapping (method = RequestMethod.DELETE)

@Authorization

public ResponseEntity logout (@CurrentUser User user) {

tokenManager.deleteToken (user.getId ());

return new ResponseEntity (ResultModel.ok (), HttpStatus.OK);

}

}

這個 Controller 中有兩個自定義的註解分別是@Authorization和@CurrentUser,其中@Authorization用於表示該操作需要登錄後才能進行:

/** * 在 Controller 的方法上使用此註解,該方法在映射時會檢查用戶是否登錄,未登錄返回 401 錯誤 * @author ScienJus * @date 2015/7/31. */ @Target (ElementType.METHOD) @Retention (RetentionPolicy.RUNTIME) public @interface Authorization { }/**

* 在 Controller 的方法上使用此註解,該方法在映射時會檢查用戶是否登錄,未登錄返回 401 錯誤

* @author ScienJus

* @date 2015/7/31.

*/

@Target (ElementType.METHOD)

@Retention (RetentionPolicy.RUNTIME)

public @interface Authorization {

}

這裡使用 Spring 的攔截器完成這個功能,該攔截器會檢查每一個請求映射的方法是否有@Authorization註解,並使用 TokenManager 驗證 Token,如果驗證失敗直接返回 401 狀態碼(未授權):

/**

* 自定義攔截器,判斷此次請求是否有權限

* @author ScienJus

* @date 2015/7/30.

*/

@Component

public class AuthorizationInterceptor extends HandlerInterceptorAdapter {

@Autowired

private TokenManager manager;

public boolean preHandle (HttpServletRequest request,

HttpServletResponse response, Object handler) throws Exception {

// 如果不是映射到方法直接通過

if (!(handler instanceof HandlerMethod)) {

return true;

}

HandlerMethod handlerMethod = (HandlerMethod) handler;

Method method = handlerMethod.getMethod ();

// 從 header 中得到 token

String authorization = request.getHeader (Constants.AUTHORIZATION);

// 驗證 token

TokenModel model = manager.getToken (authorization);

if (manager.checkToken (model)) {

// 如果 token 驗證成功,將 token 對應的用戶 id 存在 request 中,便於之後注入

request.setAttribute (Constants.CURRENT_USER_ID, model.getUserId ());

return true;

}

// 如果驗證 token 失敗,並且方法註明了 Authorization,返回 401 錯誤

if (method.getAnnotation (Authorization.class) != null) {

response.setStatus (HttpServletResponse.SC_UNAUTHORIZED);

return false;

}

return true;

}

}

@CurrentUser註解定義在方法的參數中,表示該參數是登錄用戶對象。這裡同樣使用了 Spring 的解析器完成參數注入:

/**

* 在 Controller 的方法參數中使用此註解,該方法在映射時會注入當前登錄的 User 對象

* @author ScienJus

* @date 2015/7/31.

*/

@Target (ElementType.PARAMETER)

@Retention (RetentionPolicy.RUNTIME)

public @interface CurrentUser {

}

/**

* 增加方法注入,將含有 CurrentUser 註解的方法參數注入當前登錄用戶

* @author ScienJus

* @date 2015/7/31.

*/

@Component

public class CurrentUserMethodArgumentResolver implements HandlerMethodArgumentResolver {

@Autowired

private UserRepository userRepository;

@Override

public boolean supportsParameter (MethodParameter parameter) {

// 如果參數類型是 User 並且有 CurrentUser 註解則支持

if (parameter.getParameterType ().isAssignableFrom (User.class) &&

parameter.hasParameterAnnotation (CurrentUser.class)) {

return true;

}

return false;

}

@Override

public Object resolveArgument (MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

// 取出鑑權時存入的登錄用戶 Id

Long currentUserId = (Long) webRequest.getAttribute (Constants.CURRENT_USER_ID, RequestAttributes.SCOPE_REQUEST);

if (currentUserId != null) {

// 從數據庫中查詢並返回

return userRepository.findOne (currentUserId);

}

throw new MissingServletRequestPartException (Constants.CURRENT_USER_ID);

}

}

一些細節

  • 登錄請求一定要使用 HTTPS,否則無論 Token 做的安全性多好密碼洩露了也是白搭
  • Token 的生成方式有很多種,例如比較熱門的有 JWT(JSON Web Tokens)、OAuth 等。

對本文感興趣歡迎關注筆者,如有不同意見歡迎留言。


分享到:


相關文章: