項目中使token
如果項目架構采用前后端分離,並采用分布式架構,通過定義接口API,與前端進行數據交互,前端通過html前行實現。若加入移動端(Andriod,ios)實現,可直接使用API接口實現即可。由於該項目進行前后端分離,session就沒有意義了。並且移動端也是無法使用session的。那么需要使用token進行session管理,通過搭建一個認證系統負責用戶身份驗證,並進行這個系統token的維護和管理。
1.1 用戶表的設計
認證系統除了用戶的自動注冊意外,還有可能是第三方登陸(微信,qq,微博等)。如果用戶使用微信登陸成功后,這需要進行賬號合並,進行數據同步。具體的業務流程如下:對第一次使用第三方賬號登陸系統的 用戶(不注冊,直接hi用微信登陸),那么系統會給他生成一個臨時賬號(userCode)和一個臨時密碼(userPassword),並且用戶表需要記錄微信的ID(微信接口返回),以便於微信用戶下次登陸系統時繼續使用微信登陸而不綁定注冊的手機號或者郵箱。並且登陸成功后,需要把用戶信息放到token里進行統一的管理。
那么用戶表必須包含如下的字段:
1. id:主鍵ID
2. userType:用戶類型(如果時第三方登陸的話,系統為自動生成唯一的賬號密碼;自動注冊用戶這位郵箱、手機號)
3. userPassword 用戶密碼
4. flatId (自動注冊:用戶的主鍵id
第三方登陸(qqid,微信id,微博id):該字段表示第三方登陸賬號的唯一標識,token使用)
那么第二次第三方登陸(沒有進行賬號綁定),此時校驗用戶身份,需要使用第三方賬號和flatId聯合校驗(為了避免不同平台返回的平台ID(flatId)出現一致的情況),所以不管是第三方登陸,還是自動注冊登陸,token里面放的結構數據內容需要一致,並且在認證系統中需要實現自由平台的token維護和第三方賬號登陸的維護。
1.2 token的數據結構及內容
token的數據結構為key-value,具體內容如下:
1. key:token,其設計原則:必須保證整個系統中唯一存在,根據不同的客戶端(PC、移動端),為了便於同意管理和維護,token的設計生成算法如下:
token:PC/
mobile-userCode(加密)-id-date-6位隨機字符串
代碼:
/** *實體類 */ public class User { private Integer id;//主鍵id private String userCode;//若是第三方登錄,系統將自動生成唯一賬號;自注冊用戶則為郵箱或者手機號 private String userPassword;//若是第三方登錄,系統將自動生成唯一密碼;自注冊用戶則為自定義密碼 private String userType;//用戶類型(標識:0 自注冊用戶 1 微信登錄 2 QQ登錄 3 微博登錄) private String flatId;//平台ID(根據不同登錄用戶,進行相應存入:自注冊用戶主鍵ID、微信ID、QQID、微博ID) private Integer activated;//是否激活(0:否 1:是) public Integer getId() { return id; } public Integer getActivated() { return activated; } public void setActivated(Integer activated) { this.activated = activated; } public void setId(Integer id) { this.id = id; } public String getUserCode() { return userCode; } public void setUserCode(String userCode) { this.userCode = userCode; } public String getUserPassword() { return userPassword; } public void setUserPassword(String userPassword) { this.userPassword = userPassword; } public String getUserType() { return userType; } public void setUserType(String userType) { this.userType = userType; } public String getFlatId() { return flatId; } public void setFlatId(String flatId) { this.flatId = flatId; }
MD5加密:
package com.kgc.utils.common; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Random; public class MD5 { public static String getMd5(String plainText,int length) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(plainText.getBytes()); byte b[] = md.digest(); int i; StringBuffer buf = new StringBuffer(""); for (int offset = 0; offset < b.length; offset++) { i = b[offset]; if (i < 0) { i += 256; } if (i < 16) { buf.append("0"); } buf.append(Integer.toHexString(i)); } // 32位 // return buf.toString(); // 16位 // return buf.toString().substring(0, 16); return buf.toString().substring(0, length); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); return null; } } public static int getRandomCode(){ int max=9999; int min=1111; Random random = new Random(); return random.nextInt(max)%(max-min+1) + min; } public static void main(String[] args) { System.out.println(MD5.getMd5("helloadsfdsffsf",6)); System.out.println(getRandomCode()); } }
生成token的代碼:
/** * 生成token * * @param User * @param userAgent 判斷是移動端還是PC端 需要controller傳入 HttpServletRequest request String userAgent = request.getHeader("user-agent"); * @return */ public String createToken(User ser, String userAgent) throws IOException { StringBuffer token=new StringBuffer(); token.append("token:"); UserAgentInfo userAgentInfo = UserAgentUtil.getUasParser().parse(userAgent); //獲取訪問設備並拼接 if(userAgentInfo.getDeviceType().equals(UserAgentInfo.UNKNOWN)){ if(UserAgentUtil.CheckAgent(userAgent)){ token.append("MOBILE-"); }else { token.append("PC-"); } }else if(userAgentInfo.getDeviceType().equals("Personal computer")){ token.append("PC-"); }else { token.append("MOBILE-"); } token.append(MD5.getMd5(ser.getUserCode(),32)+"-"); token.append(user.getId()+"-"); token.append(new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())+"-"); token.append(MD5.getMd5(userAgent,6)); return token.toString(); }
2.value:存儲用戶登陸的信息(數據內容是json格式)
id userCode userPassword userType flatId activated
1.3 token的有效期維護
基於系統的安全性考慮,需要設置token的有效期,為了維護token的有效期,需要把token放到redis進行維護管理。對於不同的客戶端(PC端,移動端)token的有效期設置有所不同。
1.3.1 PC端
token的有效期為兩個小時,如果兩個小時內的token沒有進行置換的話,就會自動在該redis里清除token了,那么當用戶再次放送請求時,則會提示token失效,情重新登陸。此處應注意:前端需要自動掛你token的生命周期,token存在cookie,web的安全性比較差。
java代碼:
controller
@RequestMapping(value = "/api") @RestController public class LoginController { @Resource private TokenService tokenService; /** * 用戶登錄 * * @param name * @param password * @return */ @RequestMapping(value = "/dologin", method = RequestMethod.POST, produces = "application/json") public Dto dologin(@RequestParam(value = "name") String name, @RequestParam(value = "password") String password, HttpServletRequest request) { try { String userAgent = request.getHeader("user-agent"); return tokenService.dologin(name, password,userAgent); } catch (Exception e) { e.printStackTrace(); return DtoUtil.returnFail("系統異常", ErrorCode.AUTH_UNKNOWN); } }
/**
* 用戶注銷
* @param
* @return
*/
@RequestMapping(value ="/logout",method = RequestMethod.GET,produces = "application/json")
public Dto logout(HttpServletRequest request){
try {
return tokenService.logout(request.getHeader("token"));
}catch (Exception e){
e.printStackTrace();
return DtoUtil.returnFail("系統異常", ErrorCode.AUTH_UNKNOWN);
}
}
/**
* 客戶端置換token
* @param request
* @return
*/
@RequestMapping(value ="/retoken",method = RequestMethod.POST,produces = "application/json")
public Dto retoken(HttpServletRequest request){
try {
return tokenService.replacetoken(request.getHeader("token"),request.getHeader("user-agent"));
} catch (Exception e) {
e.printStackTrace();
return DtoUtil.returnFail("系統異常", ErrorCode.AUTH_UNKNOWN);
}
}
}
service代碼:
public interface TokenService { /** * 會話時間 */ public final static int SESSION_TIMEOUT=60*2*60; /** * 置換保護時間 */ public final static int REPLACETOKEN_PROTECTION_TIMEOUT=60*60; /** * 舊的token延遲時間 */ public final static int REPLACE=60*2; //用戶登錄 public Dto dologin(String userCode, String userPassword,String userAgent) throws Exception; //用戶注銷 public Dto logout(String token) throws Exception; //客戶端置換token public Dto replacetoken(String token,String userAgent) throws Exception; }
impl
@Service("LoginService") public class TokenServerImpl implements TokenService { @Resource private UserMapper UserMapper; @Resource private RedisAPI redisAPI; /** * 登錄業務 * * @param userCode * @param userPassword * @return * @throws Exception */ @Override public Dto dologin(String userCode, String userPassword, String userAgent) throws Exception { Map<String, Object> userMap = new HashMap<>(); userMap.put("userCode", userCode); user user = userMapper.getListByMap(userMap).get(0); //用戶是否存在 if (EmptyUtils.isNotEmpty(user)) { //判斷用戶密碼是否正確 if (DigestUtil.hmacSign(userPassword, "kgc").equals(user.getUserPassword())) { String tokenString = createToken(user, userAgent); //存到緩存服務器中 redisAPI.set(tokenString, JSONObject.toJSONString(user)); System.out.println("tokenString=="+tokenString); //返回給前端 TokenVO tokenVO = new TokenVO(tokenString, Calendar.getInstance().getTimeInMillis() + SESSION_TIMEOUT * 1000, Calendar.getInstance().getTimeInMillis()); return DtoUtil.returnDataSuccess(tokenVO); } else { return DtoUtil.returnFail("用戶密碼錯誤", ErrorCode.AUTH_PARAMETER_ERROR); } } else { return DtoUtil.returnFail("用戶不存在", ErrorCode.AUTH_USER_ALREADY_NOTEXISTS); } } @Override public Dto logout(String token) throws Exception { //刪除服務端 redisAPI.del(token); return DtoUtil.returnSuccess(); } /** * 客戶端置換token * @param token * @return * @throws Exception */ @Override public Dto replacetoken(String token,String userAgent) throws Exception { //判斷token是否存在 if (!redisAPI.exists(token)){ return DtoUtil.returnFail("token不存在",ErrorCode.AUTH_TOKEN_INVALID); } String [] tokens=token.split("-"); SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyyMMssHHmmss"); Date startDate=simpleDateFormat.parse(tokens[3]); String format=simpleDateFormat.format(new Date()); long logtime=simpleDateFormat.parse(format).getTime()-startDate.getTime(); if (logtime<REPLACETOKEN_PROTECTION_TIMEOUT*1000){ return DtoUtil.returnFail("token處於保護時間,禁止替換",ErrorCode.AUTH_REPLACEMENT_FAILED); } //以上情況都符合 User user=JSON.parseObject(redisAPI.get(token),User.class); //生成新的token String newtoken=createToken(user,userAgent); //覆蓋新的請求,減少過期時間 redisAPI.set(token,JSONObject.toJSONString(user),REPLACE); redisAPI.set(newtoken,JSONObject.toJSONString(user),SESSION_TIMEOUT); //返回給前端 TokenVO tokenVO = new TokenVO(newtoken, Calendar.getInstance().getTimeInMillis() + SESSION_TIMEOUT * 1000, Calendar.getInstance().getTimeInMillis()); return DtoUtil.returnDataSuccess(tokenVO); } /** * 生成token * * @param User * @param userAgent 判斷是移動端還是PC端 * @return */ public String createToken(User user, String userAgent) throws IOException { StringBuffer token=new StringBuffer(); token.append("token:"); UserAgentInfo userAgentInfo = UserAgentUtil.getUasParser().parse(userAgent); //獲取訪問設備並拼接 if(userAgentInfo.getDeviceType().equals(UserAgentInfo.UNKNOWN)){ if(UserAgentUtil.CheckAgent(userAgent)){ token.append("MOBILE-"); }else { token.append("PC-"); } }else if(userAgentInfo.getDeviceType().equals("Personal computer")){ token.append("PC-"); }else { token.append("MOBILE-"); } token.append(MD5.getMd5(user.getUserCode(),32)+"-"); token.append(user.getId()+"-"); token.append(new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())+"-"); token.append(MD5.getMd5(userAgent,6)); return token.toString(); } }
redis:
import org.springframework.stereotype.Component; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import javax.annotation.Resource; @Component public class RedisAPI { @Resource private JedisPool jedisPool; /** * 以鍵值對的方式保存數據到redis * * @param key * @param value */ public void set(String key, String value) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { String result = jedis.set(key, value); // 資源還回到連接池當中 //返還到連接池 jedisPool.returnResource(jedis); } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); } } /** * 以鍵值對的方式保存數據到redis * * @param key * @param value * @param expire 時間 單位[秒] */ public void set(String key, String value, int expire) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { String result = jedis.setex(key, expire, value); // 資源還回到連接池當中 jedisPool.returnResource(jedis); } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); } } /** * 取值 * * @param key */ public String get(String key) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { String result = jedis.get(key); // 資源還回到連接池當中 jedisPool.returnResource(jedis); return result; } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); return null; } } /** * 獲取剩余秒數 * * @param key */ public Long ttl(String key) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { Long result = jedis.ttl(key); // 資源還回到連接池當中 jedisPool.returnResource(jedis); return result; } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); return null; } } /** * 判斷key是否存在 * * @param key */ public Boolean exists(String key) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { System.out.println("key=========="+key); Boolean result = jedis.exists(key); // 資源還回到連接池當中 jedisPool.returnResource(jedis); return result; } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); return false; } } /** * 刪除 * * @param key */ public Long del(String key) { //獲取連接 Jedis jedis = jedisPool.getResource(); try { Long result = jedis.del(key); // 資源還回到連接池當中 jedisPool.returnResource(jedis); return result; } catch (Exception e) { e.printStackTrace(); //銷毀資源 jedisPool.returnBrokenResource(jedis); return null; } } }
import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; @ApiModel(value ="TokenVO",description = "用戶認證憑證信息") public class TokenVO { @ApiModelProperty("用戶認證憑據") private String token; @ApiModelProperty("過期時間,單位:毫秒") private long expTime; @ApiModelProperty("生成時間,單位:毫秒") private long genTime; public TokenVO() { } public TokenVO(String token, long expTime, long genTime) { this.token = token; this.expTime = expTime; this.genTime = genTime; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public long getExpTime() { return expTime; } public void setExpTime(long expTime) { this.expTime = expTime; } public long getGenTime() { return genTime; } public void setGenTime(long genTime) { this.genTime = genTime; } }
1.3.2 移動端
token永不失效,修改密碼后需要置換token。由於移動端的token不需要過期,只有但PC頁面進行密碼修改后,移動端才會推出重新登錄,或者當移動端進行密碼修改后,用戶也不需要進行推出登錄,知道在redis中更新該token中的密碼即可。
1.4.2 Token 置換
Token 置換規則定義:前端獲取 Token 的 1.5 時后可進行 Token 置換,若在最后的半個小時內,客戶端發出請求,則會進行 Token 置換,拿到重新生成的 Token(包括:token(key)、
生成時間、失效時間),若客戶端在最后的半個小時內沒有發送任何請求,那么兩個小時后自動過期,即:該 Token 自動從 Redis 里清除,用戶須重新登錄。 需要注意事項:
1>
不論是最后半個小時的置換時間還是 Token 的 2 個小時有效期,都是根據系統的業務需求所設計的策略方案。
2>
為了防止客戶端惡意的進行 Token 置換,需要保證生成 Token 后的 1 個小時內不允許置換。
3>
需要保證客戶端傳遞有效的 Token 進行置換。
4>
為了解決頁面的並發問題,在進行置換 Token 時,生成新 Token,但是舊 Token 不能立即失效,應設置為置換后的時間延長 2 分鍾。
token的使用
在 Controller 的處理方法中通過 request.getHeader("token")來獲取 token 字符串,為了方便進行 Token 的驗證,提供統一的 ValidationToken.java,該工具類主要負責通過傳入的 token(key) 去 Redis 里 進 行 value 的 查 找 (User currentUser =validationToken.getCurrentUser(token);),若找到相應的 value,則返回 currentUser(當前用戶),若無,則返回 null。
/** * Token驗證 * */ @Component public class ValidationToken { private Logger logger = Logger.getLogger(ValidationToken.class); private @Resource RedisAPI redisAPI; public RedisAPI getRedisAPI() { return redisAPI; } public void setRedisAPI(RedisAPI redisAPI) { this.redisAPI = redisAPI; } public ser getCurrentUser(String tokenString){ //根據token從redis中獲取用戶信息 /* test token: key : token:1qaz2wsx value : {"id":"100078","userCode":"myusercode","userPassword":"78ujsdlkfjoiiewe98r3ejrf","userType":"1","flatID":"10008989"} */ User ser = null; if(null == tokenString || "".equals(tokenString)){ return null; } try{ String userInfoJson = redisAPI.get(tokenString); ser = JSONObject.parseObject(userInfoJson,User.class); }catch(Exception e){ ser = null; logger.error("get userinfo from redis but is error : " + e.getMessage()); } return ser; } }
后端
Auth 系統需要提供 API 如下:
1>
生成 Token
該接口返回的數據內容包括:Token 的 key(注:需要對敏感信息進行加密處理)、 Token 的生成時間、Token 的失效時間(注:過期時間減去生成時間一定是兩個小時)
2>
Token 置換
該接口返回新 Token。實現過程中需要注意如下幾點:
a)
生成 Token 后的 1 個小時內不允許置換(注:主要是為了防止客戶端惡意的進行 Token 置換)
b)
由於需要保證客戶端傳遞的置換 Token 為真實存在並有效的,故需要在該
API 方法內首先判斷 Token 是否有效。
c)
在進行置換 Token,生成新 Token,舊 Token 不能立即失效,應設置為置換后的時間延長 2 分鍾。
前端
1>
登錄成功后,接收 Token 放入 cookie 中,請求的時候從 cookie 中取出放入到 header 里,如下:
$.ajax({ headers:{
Accept:"application/json;charset=utf-8",
Content-Type:"application/json;charset=utf-8",
//從 cookie 中獲取
token:"token:PC-3066014fa0b10792e4a762-23-20170531133947-4f6496"
},
type:"post",
.....
})
2>
負責服務器時間同步(根據 API 返回的 Token 生成時間、失效時間進行同步)
3>
置換 Token 需要同步處理,即:保證只有一個請求置換 Token