為什么用 Token
一般來說都是用 session 來存儲(chǔ)登錄信息的,但是移動(dòng)端使用 session 不太方便,所以一般都用 token 。另外現(xiàn)在前后端分離,一般都用 token 來鑒權(quán)。用 token 也更加符合 RESTful 中無狀態(tài)的定義。
交互流程
- 客戶端通過登錄請(qǐng)求提交用戶名和密碼,服務(wù)端驗(yàn)證通過后生成一個(gè) Token 與該用戶進(jìn)行關(guān)聯(lián),并將 Token 返回給客戶端。
- 客戶端在接下來的請(qǐng)求中都會(huì)攜帶 Token,服務(wù)端通過解析 Token 檢查登錄狀態(tài)。
- 當(dāng)用戶退出登錄、其他終端登錄同一賬號(hào)(被頂號(hào))、長時(shí)間未進(jìn)行操作時(shí) Token 會(huì)失效,這時(shí)用戶需要重新登錄。
程序示例
Token的生成算法
服務(wù)端生成的 Token 一般為隨機(jī)的非重復(fù)字符串,根據(jù)應(yīng)用對(duì)安全性的不同要求,會(huì)將其添加時(shí)間戳(通過時(shí)間判斷 Token 是否被盜用)或 url 簽名(通過請(qǐng)求地址判斷 Token 是否被盜用)后加密進(jìn)行傳輸。因?yàn)橹皇莻€(gè) demo,所以這里簡單寫了
public class TokenUtil {
private static final String SEPARATOR = "-";
/**
* Token格式:時(shí)間戳-userId-隨機(jī)字符串
*/
public static String createToken(long userId) {
return new Date().getTime() + SEPARATOR + userId + SEPARATOR + RandomStringUtils.random(10, true, true);
}
/**
* 解析Token,從中取得userId
*/
public static Long getUserIdFromToken(String token) {
if (StringUtils.isEmpty(token)) {
return null;
}
String[] param = token.split(SEPARATOR);
if (param.length != 3) {
return null;
}
try {
return NumberUtils.createLong(param[1]);
} catch (NumberFormatException e) {
return null;
}
}
}
Token的CRUD操作
Redis 是一個(gè) Key-Value 結(jié)構(gòu)的內(nèi)存數(shù)據(jù)庫,用它維護(hù) userId 和 Token 的映射表會(huì)比傳統(tǒng)數(shù)據(jù)庫速度更快,這里使用 Spring-Data-Redis 封裝的 RedisTokenManager 對(duì) Token 進(jìn)行基礎(chǔ)操作:
首先定義一個(gè) DAO 接口
package com.owen.favorite.repository;
import com.owen.favorite.domain.Token;
public interface TokenRepository {
/**
* 創(chuàng)建一個(gè) token 并關(guān)聯(lián)上指定用戶
*/
Token createToken(long userId);
/**
* 檢查 token 是否有效
*/
boolean checkToken(Token token);
/**
* 清除 token
*/
void deleteToken (long userId);
}
然后是實(shí)現(xiàn)類
package com.owen.favorite.repository.impl;
import com.owen.favorite.constant.ApiConstant;
import com.owen.favorite.domain.Token;
import com.owen.favorite.repository.TokenDao;
import com.owen.favorite.util.TokenUtil;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.concurrent.TimeUnit;
/**
* 通過 Redis 管理 token 的實(shí)現(xiàn)類
*/
@Repository
public class RedisTokenDaoImpl implements TokenDao {
private RedisTemplate<Long, String> redisTemplate;
@Override
public Token createToken(long userId) {
String token = TokenUtil.createToken(userId);
// 存儲(chǔ)到 redis 并設(shè)置過期時(shí)間
redisTemplate.boundValueOps(userId).set(token, ApiConstant.Token.EXPIRE_DAYS, TimeUnit.DAYS);
return new Token(userId, token);
}
@Override
public boolean checkToken(String tokenFromClient) {
if (StringUtils.isEmpty(tokenFromClient)) {
return false;
}
Long userId = TokenUtil.getUserIdFromToken(tokenFromClient);
if (userId == null) {
return false;
}
String tokenInRedis = redisTemplate.boundValueOps(userId).get();
if (tokenFromClient.equals(tokenInRedis)) {
// 如果驗(yàn)證成功,說明此用戶進(jìn)行了一次有效操作,延長 token 的過期時(shí)間
redisTemplate.boundValueOps(userId).expire(ApiConstant.Token.EXPIRE_DAYS, TimeUnit.DAYS);
return true;
} else {
return false;
}
}
@Override
public void deleteToken(long userId) {
redisTemplate.delete(userId);
}
@Autowired
public void setRedisTemplate(RedisTemplate<Long, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
}
登錄與注冊(cè)
登錄與注冊(cè)的 Controller
package com.owen.favorite.controller;
import com.owen.favorite.domain.APIResult;
import com.owen.favorite.domain.Token;
import com.owen.favorite.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
@Autowired
private TokenService tokenService;
@PostMapping("/login")
public APIResult login(@RequestParam String username, @RequestParam String password) {
User user = userService.findByUsername(username);
if (user == null /* 未注冊(cè) */ || !user.getPassword().equals(password) /* 密碼錯(cuò)誤 */) {
return APIResult.createNg("用戶名或密碼錯(cuò)誤");
}
//生成一個(gè)token,保存用戶登錄狀態(tài)
Token token = tokenService.createToken(user.getId());
return APIResult.createOk(token);
}
@PostMapping("/logout")
public APIResult logout(@RequestParam String token) {
Long userId = TokenUtil.getUserIdFromToken(token);
if (userId == null) {
return APIResult.createNg("退出失敗");
}
tokenService.deleteToken(userId);
return APIResult.createOKMessage("退出成功");
}
}
token驗(yàn)證
客戶端訪問一些需要用戶登錄之后才能調(diào)用的接口,比如在數(shù)據(jù)庫中插入一條記錄,那么就需要判斷 token 的合法性。而這樣的接口又有很多,那么豈不是每一次都需要及你想那個(gè)判斷,代碼要重復(fù)寫很多遍。這時(shí)候可以使用自定義注解和攔截器來實(shí)現(xiàn)。
首先定義一個(gè)注解
package com.owen.favorite.anno;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 在 Controller 的方法上使用此注解,該方法在映射時(shí)會(huì)檢查用戶是否登錄,未登錄返回 401 錯(cuò)誤
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}
攔截器的實(shí)現(xiàn)
package com.owen.favorite.interceptor;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.owen.favorite.anno.Authorization;
import com.owen.favorite.constant.ApiConstant;
import com.owen.favorite.domain.APIResult;
import com.owen.favorite.service.TokenService;
import com.owen.favorite.util.TokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
/**
* 自定義攔截器,判斷此次請(qǐng)求的用戶是否已登錄
*/
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
@Autowired
private TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
// 如果不是映射到方法直接通過
return true;
}
//從header中得到token
String token = request.getHeader(ApiConstant.RequestParam.TOKEN);
// 驗(yàn)證 token
if (tokenService.checkToken(token)) {
//如果token驗(yàn)證成功,將token對(duì)應(yīng)的用戶id存在request中,便于之后注入
request.setAttribute(ApiConstant.RequestParam.USER_ID, TokenUtil.getUserIdFromToken(token));
return true;
} else {
// 如果驗(yàn)證token失敗,并且方法注明了Authorization,就告訴客戶端token不對(duì)
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (handlerMethod.getMethodAnnotation(Authorization.class) != null) {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=utf-8");
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
PrintWriter writer = response.getWriter();
writer.write(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(APIResult.createNg("請(qǐng)登錄")));
writer.close();
return false;
} else {
return true;
}
}
}
}
一些細(xì)節(jié)
- 登錄請(qǐng)求一定要使用 HTTPS,否則無論 Token 做的安全性多好密碼泄露了也是白搭
- Token 的生成方式有很多種,例如比較熱門的有 JWT(JSON Web Tokens)、OAuth 等。