前後端實現登入token攔截校驗

月翎魂雨發表於2018-09-03

一、場景與環境

1、我是名小白web工作者,每天都為自己的將來擔心不已。第一次記錄日常開發中的過程,如有表達不當,還請一笑而過;
2、本例項開發環境前端採用 angular框架,後端採用 springboot框架;
3、實現的目的如下:
  a、前端實現登入操作(無註冊功能);
  b、後端接收到登入資訊,生成有效期限token(後端演算法生成的一段祕鑰),作為結果返回給前端;
  c、前端在此後的每次請求,都會攜帶token與後端校驗;
  d、在token有效時間內前端的請求響應都會成功,後端實時的更新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無效,處理程式碼註釋了:

前後端實現登入token攔截校驗

原因前端angular實現的攔截器和後端會衝突,導致前端程式碼異常,後面會詳細說明。
 d、配置攔截器有兩種方法(這裡只介紹一種):

前後端實現登入token攔截校驗

直接在攔截類上新增註釋的方法,urlPatterns是你過濾的路徑,還需在服務啟動的地方配置

前後端實現登入token攔截校驗

注:這裡面過濾的路徑不包括配置檔案的根路徑,比如說前端訪問介面路徑“/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框架,目錄如下

前後端實現登入token攔截校驗
上述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攔截校驗
第二次

前後端實現登入token攔截校驗
但是如果以我上述後端攔截器token無效的情況處理程式碼(就是我註釋的那段程式碼,我註釋的程式碼重點的作用是返回401,可以回看),這個邏輯只迴圈一次,所以我將後端程式碼返回token無效的程式碼註釋,前端攔截器在後端程式碼註釋的情況下第二次返回的event結果體存在event.body=== null,以這個條件進行token是否有效判斷;
  c、攔截器使用rxjs,如果你在頁面請求中使用rxjs中Observable.forkJoin()方法進行併發請求,那麼不好意思,好像無效,如果你有辦法解決這兩個不衝突,請告訴我哈。
  d、這裡面也要剔除登陸的攔截,具體看程式碼。

3、登入效果

以上的邏輯都是實現過程,下面來看下整體的效果:
登陸邏輯中我用的是localStorage儲存token值的:

前後端實現登入token攔截校驗
點選登入會先到前端攔截器,然後直接跳到else
前後端實現登入token攔截校驗
前後端實現登入token攔截校驗
接著到後端服務攔截器
前後端實現登入token攔截校驗
過濾登陸介面,直接跳到登陸介面,建立token值並返回
前後端實現登入token攔截校驗
觀察返回的map值
前後端實現登入token攔截校驗
最後返回前端介面
前後端實現登入token攔截校驗
上面的返回結果與後端對應,登入成功後,再請求其他頁面會攜帶token值
前後端實現登入token攔截校驗

以上就是關於前後端分離登入校驗,還有一步沒有完成,就是token更新時間有效期,等抽時間再補充,上述程式碼後端用idea編輯器,後端服務搭建會涉及到很多配置。
上面實現的程式碼github地址如下:github.com/yuelinghuny… 麻煩各位給我點個贊,第一次寫記錄文件,我會堅持寫下去,會堅信越來越好,謝謝。

相關文章