自己在前後端分離上的實踐
要想實現完整的前後端分離,安全這塊是繞不開的,這個系統主要功能就是動態restful api管理,這次實踐包含兩個模塊,基於springBoot + shiro
搭建的權限管理系統後臺bootshiro, angular5 + typeScript
編寫的前端管理usthe。(ps:考慮到我幼小的心靈和水平,大神誤噴啊^_^~)
項目的基礎框架設計:
總的長這樣:
前端usthe
基於angular5 + angular-cli + typeScript + rxjs + bootstrap + adminLTE
,踐行angular最佳實踐。
過程中node,webpack等有用到過,但我不熟。。。
後端bootshiro
基於springboot + apache shiro + mybatis
框架,restful風格api,自定義狀態碼,json-web-token,druid數據庫連接池,swagger文檔生成,redis存儲refreshtoken和動態祕鑰,maven,MD5單向加密和AES雙向等。。。
gate -nginx
這個nginx作爲反向代理服務器,解決了跨域請求的問題。另一個nginx作爲angular應用服務器,tomcat作爲bootshiro的服務器。
反向代理的nginx.conf見: conf
持續集成
流程長這樣~:
詳細實現技術見另一篇: docker學習
一些實現細節方案
對加密認證簽發,api動態權限,token過期刷新,前後端交互等等實現的細節,慢慢更。
傳輸密碼動態加密解密
在用戶密碼登錄認證中,明文傳輸用戶輸入的密碼是不可取的。在沒有用https的情況下,這裏需要對用戶密碼加密傳輸,保證即使密碼泄露也不影響。
這裏的前後端加密解密下圖:
由於介紹的是動態加密解密傳輸信息方案,這裏並不會涉及之後的JWT簽發等。
下面是實現細節:
angular 前端發送get動態祕鑰請求後會對對象進行監聽,在回調函數裏獲取後端返回的祕鑰後再進行加密處理,之後再發送登錄請求。在angular我把請求服務化了,下面的代碼片段會有點凌亂。
// 調用獲取tokenKey祕鑰服務
this.loginService.getTokenKey().subscribe(
data => {
this.responseData = data;
if (this.responseData.data.tokenKey !== undefined) {
const tokenKey = this.responseData.data.tokenKey;
// 調用服務,發送認證請求
this.loginService.login(this.appId, this.password, tokenKey).subscribe(
data2 => {
// 認證成功返回jwt
this.responseData = data2;
if (this.responseData.meta.code === 1003 && this.responseData.data.jwt != null) {
this.authService.updateAuthorizationToken(this.responseData.data.jwt);
this.authService.updateUid(this.appId);
this.authService.updateUser(this.responseData.data.user);
this.router.navigateByUrl('/index');
} else {
this.msg = '用戶名密碼錯誤';
this.isDisabled = true;
}
},
error => {
console.error(error);
this.msg = error;
this.isDisabled = true;
}
);
}
}
);
@Injectable()
export class LoginService {
constructor(private httpUtil: HttpUtil) {
}
getTokenKey() {
const url = 'account/login?tokenKey=get';
// 先向後臺申請加密tokenKey tokenKey=get
// const getKeyParam = new HttpParams().set('tokenKey', 'get');
return this.httpUtil.get(url);
}
login(appId: string, password: string, tokenKey: string) {
const url = 'account/login';
tokenKey = CryptoJS.enc.Utf8.parse(tokenKey);
password = CryptoJS.enc.Utf8.parse(password);
password = CryptoJS.AES.encrypt(password, tokenKey, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}).toString();
console.log(password);
const param = new HttpParams().append('appId', appId)
.append('password', password)
.append('methodName', 'login')
.append('timestamp', new Date().toUTCString());
return this.httpUtil.post(url, param);
}
}
後端是在一個filter中對登錄註冊請求進行攔截,判斷其是正常登錄註冊還是獲取動態加密祕鑰請求,正常認證就走shiro,判斷爲獲取祕鑰則生成16隨機碼默認AES加密祕鑰爲約定16位,小於16位會報錯
,將祕鑰以<遠程IP,祕鑰>的
// 判斷若爲獲取登錄註冊加密動態祕鑰請求
if (isPasswordTokenGet(request)) {
//動態生成祕鑰,redis存儲祕鑰供之後祕鑰驗證使用,設置有效期5秒用完即丟棄
String tokenKey = CommonUtil.getRandomString(16);
try {
redisTemplate.opsForValue().set("PASSWORD_TOKEN_KEY_"+request.getRemoteAddr().toUpperCase(),tokenKey,5, TimeUnit.SECONDS);
// 動態祕鑰response返回給前端
Message message = new Message();
message.ok(1000,"issued tokenKey success")
.addData("tokenKey",tokenKey);
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
}catch (Exception e) {
LOGGER.warn(e.getMessage(),e);
// 動態祕鑰response返回給前端
Message message = new Message();
message.ok(1000,"issued tokenKey fail");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
}
return false;
}
// 創建認證信息,其中就有包括獲取redis中對應IP的動態祕鑰
private AuthenticationToken createPasswordToken(ServletRequest request) {
Map<String ,String> map = RequestResponseUtil.getRequestParameters(request);
String appId = map.get("appId");
String timestamp = map.get("timestamp");
String password = map.get("password");
String host = request.getRemoteAddr();
String tokenKey = redisTemplate.opsForValue().get("PASSWORD_TOKEN_KEY_"+host.toUpperCase());
return new PasswordToken(appId,password,timestamp,host,tokenKey);
}
jwt令牌(json web token)
jwt是自包含的令牌,自包含即整個令牌已經包含自己的角色,權限,用戶信息等各種認證一個用戶的必要信息,這樣就不用後端根據用戶標識再去數據庫查詢對應用戶的角色權限等。
jwt包含頭信息,載荷信息,簽名信息三個部分:
Header //頭信息
{
"alg": "HS256", //摘要算法
"typ": "JWT" //token類型
}
payload //載荷信息
{
"sub": "1234567890", //用戶標識,subject
"name": "John Doe", //用戶名
"exp": "Mon Nov 13 15:28:41 CST 2018" //有效期
}
verify signature //簽名信息
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
詳細到官網jwt試一波吧,輸入對應信息可以生成JWT
jwt簽發解析使用的是jjwt,maven導入如下:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
jwt簽發解析工具類:
/* *
* @Author tomsun28
* @Description
* @Date 16:29 2018/3/8
*/
public class JsonWebTokenUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(JsonWebTokenUtil.class);
public static final String SECRET_KEY = "?::4343fdf4fdf6cvf):";
private static final ObjectMapper MAPPER = new ObjectMapper();
private static CompressionCodecResolver CODECRESOLVER = new DefaultCompressionCodecResolver();
/* *
* @Description json web token 簽發
* @param id 令牌ID
* @param subject 用戶ID
* @param issuer 簽發人
* @param period 有效時間(毫秒)
* @param roles 訪問主張-角色
* @param permissions 訪問主張-權限
* @param algorithm 加密算法
* @Return java.lang.String
*/
public static String issueJWT(String id,String subject, String issuer, Long period, String roles, String permissions, SignatureAlgorithm algorithm) {
// 當前時間戳
Long currentTimeMillis = System.currentTimeMillis();
// 祕鑰
byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
JwtBuilder jwtBuilder = Jwts.builder();
if (!StringUtils.isEmpty(id)) {
jwtBuilder.setId(id);
}
if (!StringUtils.isEmpty(subject)) {
jwtBuilder.setSubject(subject);
}
if (!StringUtils.isEmpty(issuer)) {
jwtBuilder.setIssuer(issuer);
}
// 設置簽發時間
jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
// 設置到期時間
if (null != period) {
jwtBuilder.setExpiration(new Date(currentTimeMillis+period*1000));
}
if (!StringUtils.isEmpty(roles)) {
jwtBuilder.claim("roles",roles);
}
if (!StringUtils.isEmpty(permissions)) {
jwtBuilder.claim("perms",permissions);
}
// 壓縮,可選GZIP
jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
// 加密設置
jwtBuilder.signWith(algorithm,secreKeyBytes);
return jwtBuilder.compact();
}
/**
* 解析JWT的Payload
*/
public static String parseJwtPayload(String jwt){
Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
String base64UrlEncodedDigest = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
for (char c : jwt.toCharArray()) {
if (c == '.') {
CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
String token = tokenSeq!=null?tokenSeq.toString():null;
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
delimiterCount++;
sb.setLength(0);
} else {
sb.append(c);
}
}
if (delimiterCount != 2) {
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
}
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
if (base64UrlEncodedPayload == null) {
throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
}
// =============== Header =================
Header header = null;
CompressionCodec compressionCodec = null;
if (base64UrlEncodedHeader != null) {
String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
Map<String, Object> m = readValue(origValue);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = CODECRESOLVER.resolveCompressionCodec(header);
}
// =============== Body =================
String payload;
if (compressionCodec != null) {
byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
} else {
payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
}
return payload;
}
/**
* 驗籤JWT
*
* @param jwt json web token
*/
public static JwtAccount parseJwt(String jwt, String appKey) {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
.parseClaimsJws(jwt)
.getBody();
JwtAccount jwtAccount = new JwtAccount();
jwtAccount.setTokenId(claims.getId());// 令牌ID
jwtAccount.setAppId(claims.getSubject());// 客戶標識
jwtAccount.setIssuer(claims.getIssuer());// 簽發者
jwtAccount.setIssuedAt(claims.getIssuedAt());// 簽發時間
jwtAccount.setAudience(claims.getAudience());// 接收方
jwtAccount.setRoles(claims.get("roles", String.class));// 訪問主張-角色
jwtAccount.setPerms(claims.get("perms", String.class));// 訪問主張-權限
return jwtAccount;
}
基於shiro的改造集成真正支持restful請求
首先說明設計的這個安全體系是是RBAC(基於角色的權限訪問控制)授權模型,即用戶--角色--資源
,用戶不直接和權限打交道,角色擁有資源,用戶擁有這個角色就有權使用角色所用戶的資源。所有這裏沒有權限一說,簽發jwt裏面也就只有用戶所擁有的角色而沒有權限。
爲啥說是真正的restful風格集成,雖說shiro對rest不友好但他本身是有支持rest集成的filter–HttpMethodPermissionFilter
,這個shiro rest的 風格攔截器,會自動根據請求方法構建權限字符串( GET=read,POST=create,PUT=update,DELETE=delete)
構建權限字符串;eg: /users=rest[user]
, 會 自動拼接出user:read,user:create,user:update,user:delete
”權限字符串進行權限匹配(所有都得匹配,isPermittedAll)。
但是這樣感覺不利於基於jwt的角色的權限控制,在細粒度上驗權url(即支持get,post,delete鑑別)就更沒法了(個人見解)。打個比方:我們對一個用戶簽發的jwt寫入角色列(role_admin,role_customer)。對不同request請求:url="api/resource/",httpMethod="GET"
,url="api/resource",httpMethod="POST"
,在基於角色-資源的授權模型中,這兩個url相同的請求對HttpMethodPermissionFilter是一種請求,用戶對應的角色擁有的資源url=”api/resource”,只要請求的url是”api/resource”,不論它的請求方式是什麼,都會判定通過這個請求,這在restful風格的api中肯定是不可取的,對同一資源有些角色可能只要查詢的權限而沒有修改增加的權限。
可能會說在jwt中再增加權限列就好了嘛,但是在基於用戶-資源的授權模型中,雖然能判別是不同的請求,但是太麻煩了,對每個資源我們都要設計對應的權限列然後再塞入到jwt中,對每個用戶都要單獨授權資源這也是不可取的。
對shiro的改造這裏自定義了一些規則:
shiro過濾器鏈的url=url+"=="+httpMethod
eg:對於url="api/resource/",httpMethod="GET"
的資源,其拼接出來的過濾器鏈匹配url=api/resource==GET
這樣對相同的url而不同的訪問方式,會判定爲不同的資源,即資源不再簡單是url,而是url和httpMethod的組合。基於角色的授權模型中,角色所擁有的資源形式爲url+"=="+httpMethod
。
這裏改變了過濾器的過濾匹配url規則,重寫PathMatchingFilterChainResolver的getChain方法,增加對上述規則的url的支持。
/* *
* @Author tomsun28
* @Description
* @Date 21:12 2018/4/20
*/
public class RestPathMatchingFilterChainResolver extends PathMatchingFilterChainResolver {
private static final Logger LOGGER = LoggerFactory.getLogger(RestPathMatchingFilterChainResolver.class);
public RestPathMatchingFilterChainResolver() {
super();
}
public RestPathMatchingFilterChainResolver(FilterConfig filterConfig) {
super(filterConfig);
}
/* *
* @Description 重寫filterChain匹配
* @Param [request, response, originalChain]
* @Return javax.servlet.FilterChain
*/
@Override
public FilterChain getChain(ServletRequest request, ServletResponse response, FilterChain originalChain) {
FilterChainManager filterChainManager = this.getFilterChainManager();
if (!filterChainManager.hasChains()) {
return null;
} else {
String requestURI = this.getPathWithinApplication(request);
Iterator var6 = filterChainManager.getChainNames().iterator();
String pathPattern;
boolean flag = true;
String[] strings = null;
do {
if (!var6.hasNext()) {
return null;
}
pathPattern = (String)var6.next();
strings = pathPattern.split("==");
if (strings.length == 2) {
// 分割出url+httpMethod,判斷httpMethod和request請求的method是否一致,不一致直接false
if (WebUtils.toHttp(request).getMethod().toUpperCase().equals(strings[1].toUpperCase())) {
flag = false;
} else {
flag = true;
}
} else {
flag = false;
}
pathPattern = strings[0];
} while(!this.pathMatches(pathPattern, requestURI) || flag);
if (LOGGER.isTraceEnabled()) {
LOGGER.trace("Matched path pattern [" + pathPattern + "] for requestURI [" + requestURI + "]. Utilizing corresponding filter chain...");
}
if (strings.length == 2) {
pathPattern = pathPattern.concat("==").concat(WebUtils.toHttp(request).getMethod().toUpperCase());
}
return filterChainManager.proxy(originalChain, pathPattern);
}
}
}
重寫PathMatchingFilter的路徑匹配方法pathsMatch(),加入httpMethod支持。
/* *
* @Author tomsun28
* @Description 重寫過濾鏈路徑匹配規則,增加REST風格post,get.delete,put..支持
* @Date 23:37 2018/4/19
*/
public abstract class BPathMatchingFilter extends PathMatchingFilter {
public BPathMatchingFilter() {
}
/* *
* @Description 重寫URL匹配 加入httpMethod支持
* @Param [path, request]
* @Return boolean
*/
@Override
protected boolean pathsMatch(String path, ServletRequest request) {
String requestURI = this.getPathWithinApplication(request);
// path: url==method eg: http://api/menu==GET 需要解析出path中的url和httpMethod
String[] strings = path.split("==");
if (strings.length <= 1) {
// 分割出來只有URL
return this.pathsMatch(strings[0], requestURI);
} else {
// 分割出url+httpMethod,判斷httpMethod和request請求的method是否一致,不一致直接false
String httpMethod = WebUtils.toHttp(request).getMethod().toUpperCase();
return httpMethod.equals(strings[1].toUpperCase()) && this.pathsMatch(strings[0], requestURI);
}
}
}
這樣增加httpMethod的改造就完成了,重寫ShiroFilterFactoryBean使其使用改造後的chainResolver:RestPathMatchingFilterChainResolver
/* *
* @Author tomsun28
* @Description rest支持的shiroFilterFactoryBean
* @Date 21:35 2018/4/20
*/
public class RestShiroFilterFactoryBean extends ShiroFilterFactoryBean {
private static final Logger LOGGER = LoggerFactory.getLogger(RestShiroFilterFactoryBean.class);
public RestShiroFilterFactoryBean() {
super();
}
@Override
protected AbstractShiroFilter createInstance() throws Exception {
LOGGER.debug("Creating Shiro Filter instance.");
SecurityManager securityManager = this.getSecurityManager();
String msg;
if (securityManager == null) {
msg = "SecurityManager property must be set.";
throw new BeanInitializationException(msg);
} else if (!(securityManager instanceof WebSecurityManager)) {
msg = "The security manager does not implement the WebSecurityManager interface.";
throw new BeanInitializationException(msg);
} else {
FilterChainManager manager = this.createFilterChainManager();
RestPathMatchingFilterChainResolver chainResolver = new RestPathMatchingFilterChainResolver();
chainResolver.setFilterChainManager(manager);
return new RestShiroFilterFactoryBean.SpringShiroFilter((WebSecurityManager)securityManager, chainResolver);
}
}
private static final class SpringShiroFilter extends AbstractShiroFilter {
protected SpringShiroFilter(WebSecurityManager webSecurityManager, FilterChainResolver resolver) {
if (webSecurityManager == null) {
throw new IllegalArgumentException("WebSecurityManager property cannot be null.");
} else {
this.setSecurityManager(webSecurityManager);
if (resolver != null) {
this.setFilterChainResolver(resolver);
}
}
}
}
}
上面是一些核心的代碼片段,更多請看項目代碼。
對用戶賬戶登錄註冊的過濾filter:PasswordFilter
/* *
* @Author tomsun28
* @Description 基於 用戶名密碼 的認證過濾器
* @Date 20:18 2018/2/10
*/
public class PasswordFilter extends AccessControlFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(PasswordFilter.class);
private StringRedisTemplate redisTemplate;
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
Subject subject = getSubject(request,response);
// 如果其已經登錄,再此發送登錄請求
if(null != subject && subject.isAuthenticated()){
return true;
}
// 拒絕,統一交給 onAccessDenied 處理
return false;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 判斷若爲獲取登錄註冊加密動態祕鑰請求
if (isPasswordTokenGet(request)) {
//動態生成祕鑰,redis存儲祕鑰供之後祕鑰驗證使用,設置有效期5秒用完即丟棄
String tokenKey = CommonUtil.getRandomString(16);
try {
redisTemplate.opsForValue().set("PASSWORD_TOKEN_KEY_"+request.getRemoteAddr().toUpperCase(),tokenKey,5, TimeUnit.SECONDS);
// 動態祕鑰response返回給前端
Message message = new Message();
message.ok(1000,"issued tokenKey success")
.addData("tokenKey",tokenKey);
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
}catch (Exception e) {
LOGGER.warn(e.getMessage(),e);
// 動態祕鑰response返回給前端
Message message = new Message();
message.ok(1000,"issued tokenKey fail");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
}
return false;
}
// 判斷是否是登錄請求
if(isPasswordLoginPost(request)){
AuthenticationToken authenticationToken = createPasswordToken(request);
Subject subject = getSubject(request,response);
try {
subject.login(authenticationToken);
//登錄認證成功,進入請求派發json web token url資源內
return true;
}catch (AuthenticationException e) {
LOGGER.warn(authenticationToken.getPrincipal()+"::"+e.getMessage(),e);
// 返回response告訴客戶端認證失敗
Message message = new Message().error(1002,"login fail");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
return false;
}catch (Exception e) {
LOGGER.error(e.getMessage(),e);
// 返回response告訴客戶端認證失敗
Message message = new Message().error(1002,"login fail");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
return false;
}
}
// 判斷是否爲註冊請求,若是通過過濾鏈進入controller註冊
if (isAccountRegisterPost(request)) {
return true;
}
// 之後添加對賬戶的找回等
// response 告知無效請求
Message message = new Message().error(1111,"error request");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),response);
return false;
}
private boolean isPasswordTokenGet(ServletRequest request) {
// String tokenKey = request.getParameter("tokenKey");
String tokenKey = RequestResponseUtil.getParameter(request,"tokenKey");
return (request instanceof HttpServletRequest)
&& ((HttpServletRequest) request).getMethod().toUpperCase().equals("GET")
&& null != tokenKey && "get".equals(tokenKey);
}
private boolean isPasswordLoginPost(ServletRequest request) {
// String password = request.getParameter("password");
// String timestamp = request.getParameter("timestamp");
// String methodName = request.getParameter("methodName");
// String appId = request.getParameter("appId");
Map<String ,String> map = RequestResponseUtil.getRequestParameters(request);
String password = map.get("password");
String timestamp = map.get("timestamp");
String methodName = map.get("methodName");
String appId = map.get("appId");
return (request instanceof HttpServletRequest)
&& ((HttpServletRequest) request).getMethod().toUpperCase().equals("POST")
&& null != password
&& null != timestamp
&& null != methodName
&& null != appId
&& methodName.equals("login");
}
private boolean isAccountRegisterPost(ServletRequest request) {
// String uid = request.getParameter("uid");
// String methodName = request.getParameter("methodName");
// String username = request.getParameter("username");
// String password = request.getParameter("password");
Map<String ,String> map = RequestResponseUtil.getRequestParameters(request);
String uid = map.get("uid");
String username = map.get("username");
String methodName = map.get("methodName");
String password = map.get("password");
return (request instanceof HttpServletRequest)
&& ((HttpServletRequest) request).getMethod().toUpperCase().equals("POST")
&& null != username
&& null != password
&& null != methodName
&& null != uid
&& methodName.equals("register");
}
private AuthenticationToken createPasswordToken(ServletRequest request) {
// String appId = request.getParameter("appId");
// String password = request.getParameter("password");
// String timestamp = request.getParameter("timestamp");
Map<String ,String> map = RequestResponseUtil.getRequestParameters(request);
String appId = map.get("appId");
String timestamp = map.get("timestamp");
String password = map.get("password");
String host = request.getRemoteAddr();
String tokenKey = redisTemplate.opsForValue().get("PASSWORD_TOKEN_KEY_"+host.toUpperCase());
return new PasswordToken(appId,password,timestamp,host,tokenKey);
}
public void setRedisTemplate(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
}
支持restful風格的jwt鑑權filter:BJwtFilter
/* *
* @Author tomsun28
* @Description 支持restful url 的過濾鏈 JWT json web token 過濾器,無狀態驗證
* @Date 0:04 2018/4/20
*/
public class BJwtFilter extends BPathMatchingFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(BJwtFilter.class);
private StringRedisTemplate redisTemplate;
private AccountService accountService;
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
Subject subject = getSubject(servletRequest,servletResponse);
// 判斷是否爲JWT認證請求
if ((null == subject || !subject.isAuthenticated()) && isJwtSubmission(servletRequest)) {
AuthenticationToken token = createJwtToken(servletRequest);
try {
subject.login(token);
// return this.checkRoles(subject,mappedValue) && this.checkPerms(subject,mappedValue);
return this.checkRoles(subject,mappedValue);
}catch (AuthenticationException e) {
LOGGER.info(e.getMessage(),e);
// 如果是JWT過期
if (e.getMessage().equals("expiredJwt")) {
// 這裏初始方案先拋出令牌過期,之後設計爲在Redis中查詢當前appId對應令牌,其設置的過期時間是JWT的兩倍,此作爲JWT的refresh時間
// 當JWT的有效時間過期後,查詢其refresh時間,refresh時間有效即重新派發新的JWT給客戶端,
// refresh也過期則告知客戶端JWT時間過期重新認證
// 當存儲在redis的JWT沒有過期,即refresh time 沒有過期
String appId = WebUtils.toHttp(servletRequest).getHeader("appId");
String jwt = WebUtils.toHttp(servletRequest).getHeader("authorization");
String refreshJwt = redisTemplate.opsForValue().get("JWT-SESSION-"+appId);
if (null != refreshJwt && refreshJwt.equals(jwt)) {
// 重新申請新的JWT
// 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源)
String roles = accountService.loadAccountRole(appId);
long refreshPeriodTime = 36000L; //seconds爲單位,10 hours
String newJwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId,
"token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512);
// 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt}
redisTemplate.opsForValue().set("JWT-SESSION-"+appId,newJwt,refreshPeriodTime, TimeUnit.SECONDS);
Message message = new Message().ok(1005,"new jwt").addData("jwt",newJwt);
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}else {
// jwt時間失效過期,jwt refresh time失效 返回jwt過期客戶端重新登錄
Message message = new Message().error(1006,"expired jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}
// 其他的判斷爲JWT錯誤無效
Message message = new Message().error(1007,"error Jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}catch (Exception e) {
// 其他錯誤
LOGGER.warn(servletRequest.getRemoteAddr()+"JWT認證"+e.getMessage(),e);
// 告知客戶端JWT錯誤1005,需重新登錄申請jwt
Message message = new Message().error(1007,"error jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}else {
// 請求未攜帶jwt 判斷爲無效請求
Message message = new Message().error(1111,"error request");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
Subject subject = getSubject(servletRequest,servletResponse);
// 未認證的情況
if (null == subject || !subject.isAuthenticated()) {
// 告知客戶端JWT認證失敗需跳轉到登錄頁面
Message message = new Message().error(1006,"error jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
}else {
// 已經認證但未授權的情況
// 告知客戶端JWT沒有權限訪問此資源
Message message = new Message().error(1008,"no permission");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
}
// 過濾鏈終止
return false;
}
private boolean isJwtSubmission(ServletRequest request) {
String jwt = RequestResponseUtil.getHeader(request,"authorization");
String appId = RequestResponseUtil.getHeader(request,"appId");
return (request instanceof HttpServletRequest)
&& !StringUtils.isEmpty(jwt)
&& !StringUtils.isEmpty(appId);
}
private AuthenticationToken createJwtToken(ServletRequest request) {
Map<String,String> maps = RequestResponseUtil.getRequestHeaders(request);
String appId = maps.get("appId");
String ipHost = request.getRemoteAddr();
String jwt = maps.get("authorization");
String deviceInfo = maps.get("deviceInfo");
return new JwtToken(ipHost,deviceInfo,jwt,appId);
}
// 驗證當前用戶是否屬於mappedValue任意一個角色
private boolean checkRoles(Subject subject, Object mappedValue){
String[] rolesArray = (String[]) mappedValue;
return rolesArray == null || rolesArray.length == 0 || Stream.of(rolesArray).anyMatch(role -> subject.hasRole(role.trim()));
}
// 驗證當前用戶是否擁有mappedValue任意一個權限
private boolean checkPerms(Subject subject, Object mappedValue){
String[] perms = (String[]) mappedValue;
boolean isPermitted = true;
if (perms != null && perms.length > 0) {
if (perms.length == 1) {
if (!subject.isPermitted(perms[0])) {
isPermitted = false;
}
} else {
if (!subject.isPermittedAll(perms)) {
isPermitted = false;
}
}
}
return isPermitted;
}
public void setRedisTemplate(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void setAccountService(AccountService accountService) {
this.accountService = accountService;
}
}
realm數據源,數據提供service,匹配matchs,自定義token,spring集成shiro配置等其他詳見項目代碼。
最後項目實現了基於jwt的動態restful api權限認證。
簽發的用戶認證token超時刷新策略
對於登錄的用戶簽發其對應的jwt,我們在jwt設置他的固定有效期時間,在有效期內用戶攜帶jwt訪問沒問題,當過有效期後jwt失效,用戶需要重新登錄獲取新的jwt。這個體驗不太好,好的體驗應該是:活躍的用戶應該在無感知的情況下在jwt失效後獲取到新的jwt,攜帶這個新的jwt進行訪問,而長時間不活躍的用戶應該在jwt失效後需要進行重新的登錄認證。
這裏就涉及到了token的超時刷新問題,解決方案看圖:
在簽發有效期爲 t 時間的jwt後,把jwt用(”JWT-SESSION-“+appId,jwt)的key-value形式存儲到redis中,有效期設置爲2倍的 t 。這樣jwt在有效期過後的 t 時間段內可以申請刷新token。
還有個問題是用戶攜帶過期的jwt對後臺請求,在可刷新時間段內返回了新的jwt,應該在用戶無感知的情況下返回請求的內容,而不是接收一個刷新的jwt。我們是不是可以在每次request請求回調的時候判斷返回的是不是刷新jwt,但是判斷是之後我們是否放棄之前的用戶請求,如果不放棄,那是不是應該在最開始的用戶request請求前先保存這個請求,在之後的回調中如果是返回刷新jwt,我們再攜帶這個新的jwt再請求一次保存好的request請求?但對於前端這麼大量的不同請求,這樣是不是太麻煩了?
這困擾了我很久哎,直到我用到了angualr的HttpInterceptor
哈哈哈哈哈哈哈哈哈哈哈哈哈哈。
angualr的HttpInterceptor
就是前端的攔截過濾器,發起請求會攔截處理,接收請求也會攔截處理。最大的好處對每次的原始request他都會完整的保存下來,我們向後臺發生的request是他的clone。next.handle(request.clone)
繼承HttpInterceptor的AuthInterceptor,攔截response判斷是否爲refresh token,是則攜帶新token再次發起保存的request:
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const authToken = this.authService.getAuthorizationToken();
const uid = this.authService.getUid();
let authReq: any;
if (authToken != null && uid != null) {
authReq = req.clone({
setHeaders: {
'authorization': authToken,
'appId': uid
}
});
} else {
authReq = req.clone();
}
console.log(authReq);
return next.handle(authReq).pipe(
mergeMap(event => {
// 返回response
if (event instanceof HttpResponse) {
if (event.status === 200) {
// 若返回JWT過期但refresh token未過期,返回新的JWT 狀態碼爲1005
if (event.body.meta.code === 1005) {
const jwt = event.body.data.jwt;
// 更新AuthorizationToken
this.authService.updateAuthorizationToken(jwt);
// clone request 重新發起請求
// retry(1);
authReq = req.clone({
setHeaders: {
'authorization': jwt,
'appId': uid
}
});
return next.handle(authReq);
}
}
if (event.status === 404) {
// go to 404 html
this.router.navigateByUrl('/404');
}
if (event.status === 500) {
// go to 500 html
this.router.navigateByUrl('/500');
}
}
console.log(event);
// 返回正常情況的可觀察對象
return of(event);
}),
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse) {
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
console.error('An error occurred:', error.error.message);
} else {
console.error( `Backend returned code ${error.status}, ` +
`body was: ${error.error}`);
}
repeat(1);
return new ErrorObservable('親請檢查網絡');
}
}
後端簽發jwt時所做的:
/* *
* @Description 這裏已經在 passwordFilter 進行了登錄認證
* @Param [] 登錄簽發 JWT
* @Return java.lang.String
*/
@ApiOperation(value = "用戶登錄",notes = "POST用戶登錄簽發JWT")
@PostMapping("/login")
public Message accountLogin(HttpServletRequest request, HttpServletResponse response) {
Map<String,String> params = RequestResponseUtil.getRequestParameters(request);
String appId = params.get("appId");
// 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源)
String roles = accountService.loadAccountRole(appId);
// 時間以秒計算,token有效刷新時間是token有效過期時間的2倍
long refreshPeriodTime = 36000L;
String jwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId,
"token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512);
// 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt}
redisTemplate.opsForValue().set("JWT-SESSION-"+appId,jwt,refreshPeriodTime, TimeUnit.SECONDS);
AuthUser authUser = userService.getUserByAppId(appId);
return new Message().ok(1003,"issue jwt success").addData("jwt",jwt).addData("user",authUser);
}
後端refresh token時所做的:
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
Subject subject = getSubject(servletRequest,servletResponse);
// 判斷是否爲JWT認證請求
if ((null == subject || !subject.isAuthenticated()) && isJwtSubmission(servletRequest)) {
AuthenticationToken token = createJwtToken(servletRequest);
try {
subject.login(token);
// return this.checkRoles(subject,mappedValue) && this.checkPerms(subject,mappedValue);
return this.checkRoles(subject,mappedValue);
}catch (AuthenticationException e) {
LOGGER.info(e.getMessage(),e);
// 如果是JWT過期
if (e.getMessage().equals("expiredJwt")) {
// 這裏初始方案先拋出令牌過期,之後設計爲在Redis中查詢當前appId對應令牌,其設置的過期時間是JWT的兩倍,此作爲JWT的refresh時間
// 當JWT的有效時間過期後,查詢其refresh時間,refresh時間有效即重新派發新的JWT給客戶端,
// refresh也過期則告知客戶端JWT時間過期重新認證
// 當存儲在redis的JWT沒有過期,即refresh time 沒有過期
String appId = WebUtils.toHttp(servletRequest).getHeader("appId");
String jwt = WebUtils.toHttp(servletRequest).getHeader("authorization");
String refreshJwt = redisTemplate.opsForValue().get("JWT-SESSION-"+appId);
if (null != refreshJwt && refreshJwt.equals(jwt)) {
// 重新申請新的JWT
// 根據appId獲取其對應所擁有的角色(這裏設計爲角色對應資源,沒有權限對應資源)
String roles = accountService.loadAccountRole(appId);
long refreshPeriodTime = 36000L; //seconds爲單位,10 hours
String newJwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(),appId,
"token-server",refreshPeriodTime >> 2,roles,null, SignatureAlgorithm.HS512);
// 將簽發的JWT存儲到Redis: {JWT-SESSION-{appID} , jwt}
redisTemplate.opsForValue().set("JWT-SESSION-"+appId,newJwt,refreshPeriodTime, TimeUnit.SECONDS);
Message message = new Message().ok(1005,"new jwt").addData("jwt",newJwt);
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}else {
// jwt時間失效過期,jwt refresh time失效 返回jwt過期客戶端重新登錄
Message message = new Message().error(1006,"expired jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}
// 其他的判斷爲JWT錯誤無效
Message message = new Message().error(1007,"error Jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}catch (Exception e) {
// 其他錯誤
LOGGER.warn(servletRequest.getRemoteAddr()+"JWT認證"+e.getMessage(),e);
// 告知客戶端JWT錯誤1005,需重新登錄申請jwt
Message message = new Message().error(1007,"error jwt");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}else {
// 請求未攜帶jwt 判斷爲無效請求
Message message = new Message().error(1111,"error request");
RequestResponseUtil.responseWrite(JSON.toJSONString(message),servletResponse);
return false;
}
}
。。。。。持續更新中。。。。
效果展示
持續更新。。。。。。
分享一波阿里雲代金券快速上雲
轉載請註明 from tomsun28