什麼是 REST
REST (Representational State Transfer) 是一種軟件架構風格。它將服務端的信息和功能等所有事物統稱為資源,客戶端的請求實際就是對資源進行操作,它的主要特點有: - 每一個資源都會對應一個獨一無二的 url - 客戶端通過 HTTP 的 GET、POST、PUT、DELETE 請求方法對資源進行查詢、創建、修改、刪除操作 - 客戶端與服務端的交互必須是無狀態的
關於 RESTful 的詳細介紹可以參考 這篇文章,在此就不浪費時間直接進入正題了。
使用 Token 進行身份鑑權
網站應用一般使用 Session 進行登錄用戶信息的存儲及驗證,而在移動端使用 Token 則更加普遍。它們之間並沒有太大區別,Token 比較像是一個更加精簡的自定義的 Session。Session 的主要功能是保持會話信息,而 Token 則只用於登錄用戶的身份鑑權。所以在移動端使用 Token 會比使用 Session 更加簡易並且有更高的安全性,同時也更加符合 RESTful 中無狀態的定義。
交互流程
- 客戶端通過登錄請求提交用戶名和密碼,服務端驗證通過後生成一個 Token 與該用戶進行關聯,並將 Token 返回給客戶端。
- 客戶端在接下來的請求中都會攜帶 Token,服務端通過解析 Token 檢查登錄狀態。
- 當用戶退出登錄、其他終端登錄同一賬號(被頂號)、長時間未進行操作時 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 等。
對本文感興趣歡迎關注筆者,如有不同意見歡迎留言。
閱讀更多 JavaSpring高級進階 的文章