使用JWT实现安全认证——基于Angular2和Spring

本文出自:【InTheWorld的博客】

json_web_token

全认证一直都是应用非常重要的一环,并且随着移动app和SPA的不断发展,基于token的认证正不断成为主流。JSON Web Tokens是一种简单好用的认证方法,一个JWT类型的token一般由三部分组成,分别是Header、Payload和Signature,当然有时候会加一个token前缀。

以下图为例,分析一下JWT的结构:

image

首先,前面的“Bearer ”是token前缀,后面的内容中由两个”.”分割成为了三份。它们便分别对应Header,Payload和Signature。

  • Header记录token算法和类型的字段
  • Payload:记录所有数据的JSON对象,真正有效的数据都包含在这里
  • Signature:Signature是签名动作发生的地方,为了得到签名,我们使用Base64URL编码头部,接着使用Base64URL编码payoad,然后把这段字符串和密钥一起使用哈希算法加密,这个计算的结果就是signature。如果token中的信息被篡改了,服务端可以根据签名校验发现这个行为。

根据刚才的分析,我们可以知道,token的有效信息其实是没有加密的。JWT的机制是保证了token的不可篡改性,但是不适合用来保存敏感信息。以上图中的token为例,它的Payload内容如下图:

image

关于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 {
        Enumeration paramNames = 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

发表评论