一、場景與環境
最近需要寫一下前后端分離下的登錄解決方案,目前大多數都采用請求頭攜帶 Token 的形式
1、我是名小白web工作者,每天都為自己的將來擔心不已。第一次記錄日常開發中的過程,如有表達不當,還請一笑而過;
2、本實例開發環境前端采用 angular框架,后端采用 springboot框架;
3、實現的目的如下:
a、前端實現登錄操作(無注冊功能);
b、后端接收到登錄信息,生成有效期限token(后端算法生成的一段秘鑰),作為結果返回給前端;
c、前端在此后的每次請求,都會攜帶token與后端校驗;
d、在token有效時間內前端的請求響應都會成功,后端實時的更新token有效時間(暫無實現),如果token失效則返回登錄頁。
二、后端實現邏輯
注:部分代碼參考網上各個大神的資料
整個服務端項目結構如下(登錄token攔截只是在此工程下的一部分,文章結尾會貼上工程地址):
1、新增AccessToken 類 model
在model文件下新增AccessToken.java,此model 類保存校驗token的信息:
/** * @param access_token token字段; * @param token_type token類型字段; * @param expires_in token 有效期字段; */ public class AccessToken { private String access_token; private String token_type; private long expires_in; public String getAccess_token() { return access_token; } public void setAccess_token(String access_token) { this.access_token = access_token; } public String getToken_type() { return token_type; } public void setToken_type(String token_type) { this.token_type = token_type; } public long getExpires_in() { return expires_in; } public void setExpires_in(long expires_in) { this.expires_in = expires_in; } }
2、新增Audience 類 model
@ConfigurationProperties(prefix = "audience") public class Audience { private String clientId; private String base64Secret; private String name; private int expiresSecond; public String getClientId() { return clientId; } public void setClientId(String clientId) { this.clientId = clientId; } public String getBase64Secret() { return base64Secret; } public void setBase64Secret(String base64Secret) { this.base64Secret = base64Secret; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getExpiresSecond() { return expiresSecond; } public void setExpiresSecond(int expiresSecond) { this.expiresSecond = expiresSecond; } }
@ConfigurationProperties(prefix = "audience")獲取配置文件的信息(application.properties),如下:
server.port=8888 spring.profiles.active=dev server.servlet.context-path=/movies audience.clientId=098f6bcd4621d373cade4e832627b4f6 audience.base64Secret=MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjYyN2I0ZjY= audience.name=xxx audience.expiresSecond=1800
配置文件定義了端口號、根路徑和audience相關字段的信息,(audience也是根據網上資料命名的),audience的功能主要在第一次登錄時,生成有效token,然后將token的信息存入上述AccessToken類model中,方便登錄成功后校驗前端攜帶的token信息是否正確。
3、生成以jwt包的CreateTokenUtils 工具類
下面對這個工具類的生成、功能進行說明:
a、首先在pom.xml文件中引用依賴(這和前端在package.json安裝npm包性質相似)
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.6.0</version> </dependency>
b、然后再uitls文件夾下新增工具類CreateTokenUtils,代碼如下 :
public class CreateTokenUtils { private static Logger logger = LoggerFactory.getLogger(CreateTokenUtils.class); /** * * @param request * @return s; * @throws Exception */ public static ReturnModel checkJWT(HttpServletRequest request,String base64Secret)throws Exception{ Boolean b = null; String auth = request.getHeader("Authorization"); if((auth != null) && (auth.length() > 4)){ String HeadStr = auth.substring(0,3).toLowerCase(); if(HeadStr.compareTo("mso") == 0){ auth = auth.substring(4,auth.length()); logger.info("claims:"+parseJWT(auth,base64Secret)); Claims claims = parseJWT(auth,base64Secret); b = claims==null?false:true; } } if(b == false){ logger.error("getUserInfoByRequest:"+ auth); return new ReturnModel(-1,b); } return new ReturnModel(0,b); } public static Claims parseJWT(String jsonWebToken, String base64Security){ try { Claims claims = Jwts.parser() .setSigningKey(DatatypeConverter.parseBase64Binary(base64Security)) .parseClaimsJws(jsonWebToken).getBody(); return claims; } catch(Exception ex) { return null; } } public static String createJWT(String name,String audience, String issuer, long TTLMillis, String base64Security) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(base64Security); Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName()); JwtBuilder builder = Jwts.builder().setHeaderParam("typ", "JWT") .claim("unique_name", name) .setIssuer(issuer) .setAudience(audience) .signWith(signatureAlgorithm, signingKey); if (TTLMillis >= 0) { long expMillis = nowMillis + TTLMillis; Date exp = new Date(expMillis); builder.setExpiration(exp).setNotBefore(now); } return builder.compact(); } }
此工具類有三個 靜態方法:
checkJWT—— 此方法在后端攔截器中使用,檢測前端發來的請求是否帶有token值
createJWT——此方法在登陸接口中調用,首次登陸生成token值
parseJWT——此方法在checkJWT中調用,解析token值,將jwt類型的token值分解成audience模塊
可以在parseJWT方法中打斷點,查看Claims 對象,發現其字段存儲的值與audience對象值一一對應。
注:Claims對象直接會將token的有效期進行判斷是否過期,所以不需要再另寫相關時間比對邏輯,前端的帶來的時間與后臺的配置文件audience的audience.expiresSecond=1800 Claims對象會直接解析
4、攔截器的實現HTTPBasicAuthorizeHandler類的實現
在typesHandlers文件夾中新建HTTPBasicAuthorizeHandler類,代碼如下:
@WebFilter(filterName = "basicFilter",urlPatterns = "/*") public class HTTPBasicAuthorizeHandler implements Filter { private static Logger logger = LoggerFactory.getLogger(HTTPBasicAuthorizeHandler.class); private static final Set<String> ALLOWED_PATHS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("/person/exsit"))); @Autowired private Audience audience; @Override public void init(FilterConfig filterConfig) throws ServletException { logger.info("filter is init"); } @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { logger.info("filter is start"); try { logger.info("audience:"+audience.getBase64Secret()); HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; String path = request.getRequestURI().substring(request.getContextPath().length()).replaceAll("[/]+$", ""); logger.info("url:"+path); Boolean allowedPath = ALLOWED_PATHS.contains(path); if(allowedPath){ filterChain.doFilter(servletRequest,servletResponse); }else { ReturnModel returnModel = CreateTokenUtils.checkJWT((HttpServletRequest)servletRequest,audience.getBase64Secret()); if(returnModel.getCode() == 0){ filterChain.doFilter(servletRequest,servletResponse); }else { // response.setCharacterEncoding("UTF-8"); // response.setContentType("application/json; charset=utf-8"); // response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // ReturnModel rm = new ReturnModel(); // response.getWriter().print(rm); } } } catch (Exception e) { e.printStackTrace(); } } @Override public void destroy() { logger.info("filter is destroy"); } }
此類繼承Filter類,所以重寫的三個方法init、doFitler、destory,重點攔截的功能在doFitler方法中:
a、前端發來請求都會到這個方法,那么顯而易見,第一登陸請求肯定不能攔截,因為它不帶有token值,所以剔除登錄攔截這種情況:
private static final Set<String> ALLOWED_PATHS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("/person/exsit")));
這里面的我的登錄接口路徑是“/person/exsit”,所以在將前端請求路徑分解:
String path = request.getRequestURI().substring(request.getContextPath().length()).replaceAll("[/]+$", "");
兩者進行如下比對:
Boolean allowedPath = ALLOWED_PATHS.contains(path);
根據allowedPath 的值進行判斷是否攔截;
b、攔截的時候調用上述工具類的checkJWT方法,判斷token是否有效:
ReturnModel returnModel = CreateTokenUtils.checkJWT((HttpServletRequest)servletRequest,audience.getBase64Secret());
ReturnModel 是我定義的返回類型結構,在model文件下;
c、如果token無效,處理代碼注釋了:
原因前端angular實現的攔截器和后端會沖突,導致前端代碼異常,后面會詳細說明。
d、配置攔截器有兩種方法(這里只介紹一種):
直接在攔截類上添加注釋的方法,urlPatterns是你過濾的路徑,還需在服務啟動的地方配置
注:這里面過濾的路徑不包括配置文件的根路徑,比如說前端訪問接口路徑“/movies/people/exist”,這里面的movies是根路徑,在配置文件中配置,如果你想攔截這個路徑,則urlPatterns=”/people/exist“即可。
5、登錄類的實現
在controller文件夾中新建PersonController類,代碼如下
/** * Created by jdj on 2018/4/23. */ @RestController @RequestMapping("/person") public class PersonController { private final static Logger logger = LoggerFactory.getLogger(PersonController.class); @Autowired private PersonBll personBll; @Autowired private Audience audience; /** * @content:根據id對應的person * @param id=1; * @return returnModel */ @RequestMapping(value = "/exsit",method = RequestMethod.POST) public ReturnModel exsit( @RequestParam(value = "userName") String userName, @RequestParam(value = "passWord") String passWord ){ String md5PassWord = Md5Utils.getMD5(passWord); String id = personBll.getPersonExist(userName,md5PassWord); if(id == null||id.length()<0){ return new ReturnModel(-1,null); }else { Map<String,Object> map = new HashMap<>(); Person person = personBll.getPerson(id); map.put("person",person); String accessToken = CreateTokenUtils .createJWT(userName,audience.getClientId(), audience.getName(),audience.getExpiresSecond() * 1000, audience.getBase64Secret()); AccessToken accessTokenEntity = new AccessToken(); accessTokenEntity.setAccess_token(accessToken); accessTokenEntity.setExpires_in(audience.getExpiresSecond()); accessTokenEntity.setToken_type("bearer"); map.put("accessToken",accessTokenEntity); return new ReturnModel(0,map); } } /** * @content:list * @param null; * @return returnModel */ @RequestMapping(value = "/list",method = RequestMethod.GET) public ReturnModel list(){ List<Person> list = personBll.selectAll(); if(list.size()==0){ return new ReturnModel(-1,null); }else { return new ReturnModel(0,list); } } @RequestMapping(value = "/item",method = RequestMethod.GET) public ReturnModel getItem( @RequestParam(value = "id") String id ){ Person person = personBll.getPerson(id); if(person != null){ return new ReturnModel(0,person); }else { return new ReturnModel(-1,"無此用戶"); } } }
前端調用這個類的接口路徑:“/movies/people/exist”
首先它會查詢數據庫
String id = personBll.getPersonExist(userName,md5PassWord);
如果查詢存在,創建accessToken
String accessToken = CreateTokenUtils .createJWT(userName,audience.getClientId(), audience.getName(),audience.getExpiresSecond() * 1000, audience.getBase64Secret());
最后整合返回到前端model
AccessToken accessTokenEntity = new AccessToken(); accessTokenEntity.setAccess_token(accessToken); accessTokenEntity.setExpires_in(audience.getExpiresSecond()); accessTokenEntity.setToken_type("bearer"); map.put("accessToken",accessTokenEntity); return new ReturnModel(0,map);
這個controller類中還有兩個接口供前端登陸成功后調用。
以上都是服務端的實現邏輯,接下來說明前端的實現邏輯,我本身是前端小碼農,后端只是大多是不會的,如有錯誤,請一笑而過哈~_~哈
三、前端實現邏輯
前端使用angular框架,目錄如下
上述app文件下common 存一些共同組建(分頁、彈框)、component存一些整體布局框架、
page是各個頁面組件,service是請求接口聚集地,shared是表單自定義校驗;所以這里面都有相關的angular2+表單校驗、http請求、分頁、angular動畫等各種實現邏輯。
1、前端http請求(確切的說httpClient請求)
所有的請求都在service文件夾service.service.ts文件中,代碼如下:
import { Injectable } from '@angular/core'; import { HttpClient,HttpHeaders } from "@angular/common/http"; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/operator/map'; import 'rxjs/add/observable/forkJoin'; @Injectable() export class ServiceService { movies:string; httpOptions:Object; constructor(public http:HttpClient) { this.movies = "/movies"; this.httpOptions = { headers:new HttpHeaders({ 'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8', }), } } /**登錄模塊開始*/ loginMovies(body){ const url = this.movies+"/person/exsit"; const param = 'userName='+body.userName+"&passWord="+body.password; return this.http.post(url,param,this.httpOptions); } /**登錄模塊結束*/ //首頁; getPersonItem(param){ const url = this.movies+"/person/item"; return this.http.get(url,{params:param}); } //個人中心 getPersonList(){ const url = this.movies+"/person/list"; return this.http.get(url); /**首頁模塊結束 */ }
上述有三個請求與后端personController類中三個接口方法一一對應,這里面的請求方式官網有,這里不做贅述,this.httpOptions是設置請求頭。然后再app.modules.ts中添加到provides,所謂的依賴注入,這樣就可以在各個頁面調用servcie方法了
providers: [ServiceService,httpInterceptorProviders]
httpInterceptorProviders 是前端攔截器,前端每次請求結果都會出現成功或者錯誤,所以在攔截器中統一處理返回結果使代碼更簡潔。
2、前端攔截器的實現
在app文件在新建InterceptorService.ts文件,代碼如下:
import { Injectable } from '@angular/core'; import { HttpEvent,HttpInterceptor,HttpHandler,HttpRequest,HttpResponse} from "@angular/common/http"; import {Observable} from "rxjs/Observable"; import { ErrorObservable } from 'rxjs/observable/ErrorObservable'; import { mergeMap } from 'rxjs/operators'; import {Router} from '@angular/router'; @Injectable() export class InterceptorService implements HttpInterceptor{ constructor( private router:Router, ){ } authorization:string = ""; authReq:any; intercept(req:HttpRequest<any>,next:HttpHandler):Observable<HttpEvent<any>>{ this.authorization = "mso " + localStorage.getItem("accessToken"); if (req.url.indexOf('/person/exsit') === -1) { this.authReq = req.clone({ url:req.url, headers:req.headers.set("Authorization",this.authorization) }); }else{ this.authReq = req.clone({ url:req.url, }); } return next.handle(this.authReq).pipe(mergeMap((event:any) => { if(event instanceof HttpResponse && event.body === null){ return this.handleData(event); } return Observable.create(observer => observer.next(event)); })); } private handleData(event: HttpResponse<any>): Observable<any> { // 業務處理:一些通用操作 switch (event.status) { case 200: if (event instanceof HttpResponse) { const body: any = event.body; if (body === null) { this.backForLoginOut(); } } break; case 401: // 未登錄狀態碼 this.backForLoginOut(); break; case 404: case 500: break; default: return ErrorObservable.create(event); } } private backForLoginOut(){ if(localStorage.getItem("accessToken") !== null || localStorage.getItem("person")!== null){ localStorage.removeItem("accessToken"); localStorage.removeItem("person"); } if(localStorage.getItem("accessToken") === null && localStorage.getItem("person") === null){ this.router.navigateByUrl('/login'); } } }
攔截器的實現官網也詳細說明了,但是攔截器有幾大坑:
a、如果用的是angular2,你請求是采用的是import { Http } from "@angular/http"
包http,那么攔截器無效,你可能需要另一種寫法了,angular4、5、6都是采用import { HttpClient,HttpHeaders } from "@angular/common/http"
包下HttpClient和請求頭HttpHeaders ;
b、攔截器返回結果的方法中:
return next.handle(this.authReq).pipe(mergeMap((event:any) => { if(event instanceof HttpResponse && event.body === null){ return this.handleData(event); } return Observable.create(observer => observer.next(event)); }));
打斷點查看這個方法一次請求會循環兩次,第一次event:{type:0}
,第二次才會返回對象,截圖如下:
第一次
第二次
但是如果以我上述后端攔截器token無效的情況處理代碼(就是我注釋的那段代碼,我注釋的代碼重點的作用是返回401,可以回看),這個邏輯只循環一次,所以我將后端代碼返回token無效的代碼注釋,前端攔截器在后端代碼注釋的情況下第二次返回的event結果體存在event.body=== null
,以這個條件進行token是否有效判斷;
c、攔截器使用rxjs,如果你在頁面請求中使用rxjs中Observable.forkJoin()
方法進行并發請求,那么不好意思,好像無效,如果你有辦法解決這兩個不沖突,請告訴我哈。
d、這里面也要剔除登陸的攔截,具體看代碼。
3、登錄效果
以上的邏輯都是實現過程,下面來看下整體的效果:
登陸邏輯中我用的是localStorage存儲token值的:
點擊登錄會先到前端攔截器,然后直接跳到else
接著到后端服務攔截器
過濾登陸接口,直接跳到登陸接口,創建token值并返回
觀察返回的map值
最后返回前端界面
上面的返回結果與后端對應,登錄成功后,再請求其他頁面會攜帶token值
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。TEL:177 7030 7066 E-MAIL:11247931@qq.com