安全认证一直都是应用非常重要的一环,并且随着移动app和SPA的不断发展,基于token的认证正不断成为主流。JSON Web Tokens是一种简单好用的认证方法,一个JWT类型的token一般由三部分组成,分别是Header、Payload和Signature,当然有时候会加一个token前缀。
以下图为例,分析一下JWT的结构:
首先,前面的“Bearer ”是token前缀,后面的内容中由两个”.”分割成为了三份。它们便分别对应Header,Payload和Signature。
-
Header:记录token算法和类型的字段
-
Payload:记录所有数据的JSON对象,真正有效的数据都包含在这里
-
Signature:Signature是签名动作发生的地方,为了得到签名,我们使用Base64URL编码头部,接着使用Base64URL编码payoad,然后把这段字符串和密钥一起使用哈希算法加密,这个计算的结果就是signature。如果token中的信息被篡改了,服务端可以根据签名校验发现这个行为。
根据刚才的分析,我们可以知道,token的有效信息其实是没有加密的。JWT的机制是保证了token的不可篡改性,但是不适合用来保存敏感信息。以上图中的token为例,它的Payload内容如下图:
关于JWT的基本介绍,就先到这里,接下来的内容是如何使用Angular2和Spring Boot实现一个简单SPA认证应用。
使用Angualr2构建SPA
Angular作为一个非常优秀的前端框架,应该很少有人没有听说过。从个人来说,我是以前玩MEAN Stack的时候真正意义上的接触Angular的(MEAN是mongoDB, Express, AngularJS和Node.js的缩写)。对Angular的第一感觉就是“这个一个野心勃勃的框架”,后来由于对JS写复杂逻辑时的问题难以忍受(其实主要还是不满它在服务端的表现),我在一段时间内没有继续对AngularJS的学习。后来Google中国开发者大会上对AngularJS大讲特讲,随后AngularJS版本大变,并且全面转移到TypeScript。我听到这个消息,其实心里就有点痒痒了,毕竟Web app才是主流。而且,我对强类型语言的依赖性非常强,TypeScript也是我的菜。
废话讲了一大段,还是说点正经的吧!首先,我们还是需要一个登陆页面,主要代码如下所示:
import { Component } from '@angular/core' import {Http, Response} from "@angular/http"; import {Router} from "@angular/router"; import { contentHeaders } from '../common/headers'; const styles = require('./login.css'); const template = require('./login.html') @Component({ selector: 'login', template: template, styles: [ styles ] }) export class Login { constructor(public router: Router, public http: Http) { } login(event, username: string, password: string): void { event.preventDefault(); let body = JSON.stringify( { username, password }); this.http.post('http://localhost:8080/login', body, { headers: contentHeaders}) .subscribe( (response: Response) => { localStorage.setItem('id_token', response.headers.get("Authorization")); console.log(response.headers.get("Authorization")); this.router.navigate(['home']); }, error => { alert(error.text()); console.log(error.text()); } ); } signup(event): void { event.preventDefault(); this.router.navigate(['signup']); } }
登陆页面的逻辑其实很简单,就是把用户名和密码以JSON格式传递给服务器,以换取JWT token。如果成功的话,把token记录在localStorage中,然后导航到home页。home页有基本的token信息,以及实现一些普通和被保护的endpoint的访问。home页面的代码如下:
import {Component} from "@angular/core"; import {Headers, Http} from "@angular/http"; import {Router} from "@angular/router"; import { JwtHelper, AuthHttp} from "angular2-jwt" const styles = require('./home.css'); const template = require('./home.html'); @Component({ selector: 'home', template: template, styles: [ styles ] }) export class Home { jwt: string; decodedJwt: string; response: string; api: string; constructor(public router: Router, public http: Http, public authHttp: AuthHttp) { this.jwt = localStorage.getItem('id_token'); this.decodedJwt = this.jwt && (new JwtHelper()).decodeToken(this.jwt); } logout() { localStorage.removeItem('id_token'); this.router.navigate(['login']); } callAnonymousApi() { this._callApi('Anonymous', 'http://localhost:8080/'); } callSecuredApi() { this._callApi('Secured', 'http://localhost:8080/users'); } _callApi(type, url) { this.response = null; if (type === 'Anonymous') { // For non-protected routes, just use Http this.http.get(url) .subscribe( response => this.response = response.text(), error => this.response = error.text() ); } if (type === 'Secured') { // For protected routes, use AuthHttp const authHeader = new Headers(); authHeader.append('Authorization', this.jwt); console.log(this.jwt); this.http.get(url, { headers: authHeader }) .subscribe( response => this.response = response.text(), error => this.response = error.text() ); } } }
其他的一些代码详见github,我会附在文章后面。
使用Spring Boot构建认证应用
我主要使用Spring security来完成token授权以及token认证。token授权(其实就是生成)主要由JWTLoginFilter完成,它的完整代码如下所示:
public class JWTLoginFilter extends AbstractAuthenticationProcessingFilter { public JWTLoginFilter(String url, AuthenticationManager authManager) { super(new AntPathRequestMatcher(url, "POST")); setAuthenticationManager(authManager); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { EnumerationparamNames = request.getParameterNames();//获取所有的参数名 while (paramNames.hasMoreElements()) { String name = paramNames.nextElement();//得到参数名 String value = request.getParameter(name);//通过参数名获取对应的值 System.out.println(MessageFormat.format("{0}={1}", name,value)); } String contentType = request.getContentType(); if(contentType.contains("application/x-www-form-urlencoded")){//web表单post System.out.println("web post"); String username = request.getParameter("username"); String password = request.getParameter("password"); return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( username, password, Collections.emptyList() ) ); }else{ //app或者ajax post,使用json解析 AccountCredentials creds = new ObjectMapper() .readValue(request.getInputStream(), AccountCredentials.class); System.out.println("json post"); response.addHeader("Access-Control-Allow-Origin", "*"); //此优先级高于@CrossOrigin配置 // Access-Control-Allow-Methods: 授权请求的方法(GET, POST, PUT, DELETE,OPTIONS等) response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); response.addHeader("Access-Control-Allow-Headers", "Content-Type"); response.addHeader("Access-Control-Expose-Headers", "Authorization"); response.addHeader("Access-Control-Max-Age", "1800");//30 min return getAuthenticationManager().authenticate( new UsernamePasswordAuthenticationToken( creds.getUsername(), creds.getPassword(), Collections.emptyList() ) ); } } @Override protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication auth) throws IOException, ServletException { TokenAuthenticationService.addAuthentication(res, auth.getName()); } }
值得一提的是,这个Filter兼容了SPA登陆以及web post登陆两种方式。一般来说SPA会使用JSON形式的body,而浏览器原生表单的post只能使用x-www-form-urlencoded这种包体。此外,另外一个filter利用TokenAuthenticationService实现了token的认证,TokenAuthenticationService的主要代码如下。
public class TokenAuthenticationService { static final long EXPIRATIONTIME = 864_000_000; static final String SECRET = "ThisIsASecret"; static final String TOKEN_PREFIX = "Bearer"; static final String HEADER_STRING = "Authorization"; static void addAuthentication(HttpServletResponse res, String username) { String JWT = Jwts.builder() .setSubject(username) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME)) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); res.addHeader(HEADER_STRING, TOKEN_PREFIX + " " + JWT); } static Authentication getAuthentication(HttpServletRequest request) { String token = request.getHeader(HEADER_STRING); if (token != null) { String user = Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token.replace(TOKEN_PREFIX, "")) .getBody() .getSubject(); return user != null ? new UsernamePasswordAuthenticationToken(user, null, emptyList()) : null; } return null; } }
事实上,这个TokenAuthenticationService也包含了token生成的逻辑。而且在这个demo里面,仅仅使用了用户名的匹配进行token的校验。这是一种非常粗略的方式,在生产环境是非常不可取的。
需要注意的问题
由于本文有不少的背景知识,很难非常系统的把所有问题都讲清楚。对前后端的介绍也只能点到为止了,接下来主要总结一下个人在实现这个demo遇到的一些问题。
- CORS的问题
CORS是Cross-Origin Resource Sharing的缩写,这本质上来自于浏览器的保护机制。当web页面的host与Ajax访问的host不同时,浏览器会使用Http的OPTIONS方法,去探测被访问的host是否允许跨域访问。我的AngularJS跑在localhost:4200,而Spring boot跑在localhost:8080。所以需要在Spring Boot侧允许这种跨域访问。具体来说就是添加Access-Control-Allow-Origin的头,如下这样的方式:
response.addHeader("Access-Control-Allow-Origin", "*");
此外,还有一个问题,JWT的token以自定义header的方式存在,还需要添加Access-Control-Expose-Headers的头,其中Authorization为token的名称。如果不添加这个header,Ajax拿不到这个token的值,更完成不了认证。
response.addHeader("Access-Control-Expose-Headers", "Authorization");
- Angular2-jwt的问题
现在Angular的版本已经到了4.0+,而Angular2-jwt的更新速度一直没有跟上,所以有一些bug。然而也不能因为Angular2-jwt的问题而不进步吧!为此,我的解决办法是,仅仅使用Angular2-jwt中JwtHelper的功能,其他的自己写。实话说来,也写不了几行代码,反而会让我们更加清楚jwt的使用原理。
- 对OPTIONS请求的过滤
和第一问题一样,浏览器会进行一个preflight的OPTIONS请求。而Spring security对非授权访问的默认行为是页面跳转(30x),这个OPTIONS是浏览器的(不会自己加JSON Web Token),如果OPTIONS跳转了,会导致接下来的正式访问也出问题。所以需要把Spring security配置为不拦截OPTIONS请求,问题随之解决。
好吧,这篇blog就先到这里了。
Angular2 SPA: https://github.com/intheworld/authorization
Spring Boot Application: https://github.com/intheworld/xforce
发表评论