RESTful登錄設計(基於Spring及Redis的Token鑑權)

RESTful登錄設計(基於Spring及Redis的Token鑑權)

轉載:http://blog.csdn.net/gebitan505/article/details/51614805

 

什麼是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用於表示該操作需要登錄後才能進行:

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

這裏使用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等。

源碼發佈

本文的完整示例程序已發佈在我的Github上,可以下載並按照readme.md的流程進行操作。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章