03.04 如何使用開放標準 JWT 來保護你的Spring Web應用安全?

juejin.im/post/5902ca705c497d005829ed6f

關鍵詞

Spring Boot、OAuth 2.0、JWT、Spring Security、SSO、UAA

寫在前面

這幾天有人問我Spring Boot結合Spring Security實現OAuth認證的問題,寫了個Demo,順便分享下。Spring 2之後就沒再用過Java,主要是xml太麻煩,就投入了Node.js的懷抱,現在Java倒是好過之前很多,無論是執行效率還是其他什麼。感謝Pivotal團隊在Spring boot上的努力,感謝Josh Long,一個有意思的攻城獅。

我又搞Java也是為了去折騰微服務,因為目前看國內就Java程序猿最好找,雖然水平好的難找,但是至少能找到,不像其他編程語言,找個會世界上最好的編程語言PHP的人真的不易。

Spring Boot

有了Spring Boot這樣的神器,可以很簡單的使用強大的Spring框架。你需要關心的事兒只是創建應用,不必再配置了,“Just run!”,這可是Josh Long每次演講必說的,他的另一句必須說的就是“make jar not war”,這意味著,不用太關心是Tomcat還是Jetty或者Undertow了。專心解決邏輯問題,這當然是個好事兒,部署簡單了很多。

創建Spring Boot應用

有很多方法去創建Spring Boot項目,官方也推薦用:

Spring Boot在線項目創建http://start.spring.io/CLI 工具https://docs.spring.io/spring-boot/docs/current/reference/html/cli-using-the-cli.html

start.spring.io可以方便選擇你要用的組件,命令行工具當然也可以。目前Spring Boot已經到了1.53,我是懶得去更新依賴,繼續用1.52版本。雖然阿里也有了中央庫的國內版本不知道是否穩定。

如果你感興趣,可以自己嘗試下。你可以選Maven或者Gradle成為你項目的構建工具,Gradle優雅一些,使用了Groovy語言進行描述。

打開start.spring.io,創建的項目只需要一個Dependency,也就是Web,然後下載項目,用IntellJ IDEA打開。我的Java版本是1.8。

這裡看下整個項目的pom.xml文件中的依賴部分:

<code><dependencies>
    <dependency>
        <groupid>org.springframework.boot/<groupid>
        <artifactid>spring-boot-starter-web/<artifactid>
    /<dependency>

    <dependency>
        <groupid>org.springframework.boot/<groupid>
        <artifactid>spring-boot-starter-test/<artifactid>
        <scope>test/<scope>
    /<dependency>
/<dependencies>/<code>

所有Spring Boot相關的依賴都是以starter形式出現,這樣你無需關心版本和相關的依賴,所以這樣大大簡化了開發過程。

當你在pom文件中集成了spring-boot-maven-plugin插件後你可以使用Maven相關的命令來run你的應用。例如mvn spring-boot:run,這樣會啟動一個嵌入式的Tomcat,並運行在8080端口,直接訪問你當然會獲得一個Whitelabel Error Page,這說明Tomcat已經啟動了。

創建一個Web 應用

這還是一篇關於Web安全的文章,但是也得先有個簡單的HTTP請求響應。我們先弄一個可以返回JSON的Controller。修改程序的入口文件:

<code>@SpringBootApplication
@RestController
@EnableAutoConfiguration
public class DemoApplication {

    // main函數,Spring Boot程序入口
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // 根目錄映射 Get訪問方式 直接返回一個字符串
    @RequestMapping("/")
    Map<string> hello() {
      // 返回map會變成JSON key value方式
      Map<string> map=new HashMap<string>();
      map.put("content", "hello freewolf~");
      return map;
    }
}/<string>/<string>/<string>/<code>

這裡我儘量的寫清楚,讓不瞭解Spring Security的人通過這個例子可以瞭解這個東西,很多人都覺得它很複雜,而投向了Apache Shiro,其實這個並不難懂。知道主要的處理流程,和這個流程中哪些類都起了哪些作用就好了。

Spring Boot對於開發人員最大的好處在於可以對Spring應用進行自動配置。Spring Boot會根據應用中聲明的第三方依賴來自動配置Spring框架,而不需要進行顯式的聲明。

Spring Boot推薦採用基於Java註解的配置方式,而不是傳統的XML。只需要在主配置 Java 類上添加@EnableAutoConfiguration註解就可以啟用自動配置。Spring Boot的自動配置功能是沒有侵入性的,只是作為一種基本的默認實現。

這個入口類我們添加@RestController和@EnableAutoConfiguration兩個註解。@RestController註解相當於@ResponseBody和@Controller合在一起的作用。

run整個項目。訪問http://localhost:8080/就能看到這個JSON的輸出。使用Chrome瀏覽器可以裝JSON Formatter這個插件,顯示更PL一些。

<code>{
  "content": "hello freewolf~"
}/<code>

為了顯示統一的JSON返回,這裡建立一個JSONResult類進行,簡單的處理。首先修改pom.xml,加入org.json相關依賴。

<code><dependency>
    <groupid>org.json/<groupid>
    <artifactid>json/<artifactid>
/<dependency>/<code>

然後在我們的代碼中加入一個新的類,裡面只有一個結果集處理方法,因為只是個Demo,所有這裡都放在一個文件中。這個類只是讓返回的JSON結果變為三部分:

  • status - 返回狀態碼 0 代表正常返回,其他都是錯誤
  • message - 一般顯示錯誤信息
  • result - 結果集


<code>class JSONResult{
    public static String fillResultString(Integer status, String message, Object result){
        JSONObject jsonObject = new JSONObject(){{
            put("status", status);
            put("message", message);
            put("result", result);
        }};
        return jsonObject.toString();
    }
}/<code>

然後我們引入一個新的@RestController並返回一些簡單的結果,後面我們將對這些內容進行訪問控制,這裡用到了上面的結果集處理類。這裡多放兩個方法,後面我們來測試權限和角色的驗證用。

<code>@RestController
class UserController {

    // 路由映射到/users
    @RequestMapping(value = "/users", produces="application/json;charset=UTF-8")
    public String usersList() {

        ArrayList<string> users =  new ArrayList<string>(){{
            add("freewolf");
            add("tom");
            add("jerry");
        }};

        return JSONResult.fillResultString(0, "", users);
    }


    @RequestMapping(value = "/hello", produces="application/json;charset=UTF-8")
    public String hello() {
        ArrayList<string> users =  new ArrayList<string>(){{ add("hello"); }};
        return JSONResult.fillResultString(0, "", users);
    }

    @RequestMapping(value = "/world", produces="application/json;charset=UTF-8")
    public String world() {
        ArrayList<string> users =  new ArrayList<string>(){{ add("world"); }};
        return JSONResult.fillResultString(0, "", users);
    }
}/<string>/<string>/<string>/<string>/<string>/<string>/<code>

重新run這個文件,訪問http://localhost:8080/users就看到了下面的結果:

<code>{
  "result": [
    "freewolf",
    "tom",
    "jerry"
  ],
  "message": "",
  "status": 0
}/<code>

如果你細心,你會發現這裡的JSON返回時,Chrome的格式化插件好像並沒有識別?這是為什麼呢?我們藉助curl分別看一下我們寫的兩個方法的Header信息.

<code>curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:8080/users/<code>

可以看到第一個方法hello,由於返回值是Map,Spring已經有相關的機制自動處理成JSON:

<code>Content-Type: application/json;charset=UTF-8/<code>

第二個方法usersList由於返回時String,由於是@RestControler已經含有了@ResponseBody也就是直接返回內容,並不模板。

所以就是:

<code>Content-Type: text/plain;charset=UTF-8/<code>

那怎麼才能讓它變成JSON呢,其實也很簡單隻需要補充一下相關注解:

<code>@RequestMapping(value = "/users", produces="application/json;charset=UTF-8")/<code>

這樣就好了。

使用JWT保護你的Spring Boot應用

終於我們開始介紹正題,這裡我們會對/users進行訪問控制,先通過申請一個JWT(JSON Web Token讀jot),然後通過這個訪問/users,才能拿到數據。

關於JWT(Json Web Tokens) ,這裡簡單說下,它是一個開放標準(RFC 7519),它定義了一種簡潔,自包含,JSON 對象形式的安全傳遞信息的方法。JWT常用在 Web 應用或者移動應用上,Token是令牌的意思,表示只有拿著令牌才具有一些權限。JWT的聲明(Claim)一般被用來在身份提供者和服務提供者間傳遞身份驗證信息,也可以增加一些額外的其它業務邏輯所必須的聲明信息。更多信息,請出門奔向以下內容,這些不在本文討論範圍內:

https://tools.ietf.org/html/rfc7519https://jwt.io/

JWT很大程度上還是個新技術,通過使用HMAC(Hash-based Message Authentication Code)計算信息摘要,也可以用RSA公私鑰中的私鑰進行簽名。這個根據業務場景進行選擇。更多:SpringBoot內容聚合

添加Spring Security

根據上文我們說過我們要對/users進行訪問控制,讓用戶在/login進行登錄並獲得Token。這裡我們需要將spring-boot-starter-security加入pom.xml。加入後,我們的Spring Boot項目將需要提供身份驗證,相關的pom.xml如下:

<code><dependency>
    <groupid>org.springframework.boot/<groupid>
    <artifactid>spring-boot-starter-security/<artifactid>
/<dependency>
<dependency>
    <groupid>io.jsonwebtoken/<groupid>
    <artifactid>jjwt/<artifactid>
    <version>0.7.0/<version>
/<dependency>/<code>

至此我們之前所有的路由都需要身份驗證。我們將引入一個安全設置類WebSecurityConfig,這個類需要從WebSecurityConfigurerAdapter類繼承。

<code>@Configuration
@EnableWebSecurity
class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // 設置 HTTP 驗證規則
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 關閉csrf驗證
        http.csrf().disable()
                // 對請求進行認證
                .authorizeRequests()
                // 所有 / 的所有請求 都放行
                .antMatchers("/").permitAll()
                // 所有 /login 的POST請求 都放行
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                // 權限檢查
                .antMatchers("/hello").hasAuthority("AUTH_WRITE")
                // 角色檢查

                .antMatchers("/world").hasRole("ADMIN")
                // 所有請求需要身份認證
                .anyRequest().authenticated()
            .and()
                // 添加一個過濾器 所有訪問 /login 的請求交給 JWTLoginFilter 來處理 這個類處理所有的JWT相關內容
                .addFilterBefore(new JWTLoginFilter("/login", authenticationManager()),
                        UsernamePasswordAuthenticationFilter.class)
                // 添加一個過濾器驗證其他請求的Token是否合法
                .addFilterBefore(new JWTAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定義身份驗證組件
        auth.authenticationProvider(new CustomAuthenticationProvider());

    }
}/<code>

先放兩個基本類,一個負責存儲用戶名密碼,另一個是一個權限類型,負責存儲權限和角色。

<code>class AccountCredentials {

    private String username;
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

class GrantedAuthorityImpl implements GrantedAuthority{
    private String authority;

    public GrantedAuthorityImpl(String authority) {
        this.authority = authority;
    }

    public void setAuthority(String authority) {
        this.authority = authority;
    }

    @Override
    public String getAuthority() {
        return this.authority;
    }
}/<code>

在上面的安全設置類中,我們設置所有人都能訪問/和POST方式訪問/login,其他的任何路由都需要進行認證。然後將所有訪問/login的請求,都交給JWTLoginFilter過濾器來處理。

稍後我們會創建這個過濾器和其他這裡需要的JWTAuthenticationFilter和CustomAuthenticationProvider兩個類。

先建立一個JWT生成,和驗籤的類

<code>class TokenAuthenticationService {
    static final long EXPIRATIONTIME = 432_000_000;     // 5天
    static final String SECRET = "P@ssw02d";            // JWT密碼
    static final String TOKEN_PREFIX = "Bearer";        // Token前綴
    static final String HEADER_STRING = "Authorization";// 存放Token的Header Key

  // JWT生成方法
    static void addAuthentication(HttpServletResponse response, String username) {

    // 生成JWT
        String JWT = Jwts.builder()

                // 保存權限(角色)
                .claim("authorities", "ROLE_ADMIN,AUTH_WRITE")
                // 用戶名寫入標題
                .setSubject(username)
                // 有效期設置
                        .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
                // 簽名設置
                        .signWith(SignatureAlgorithm.HS512, SECRET)
                        .compact();

        // 將 JWT 寫入 body
        try {
            response.setContentType("application/json");
            response.setStatus(HttpServletResponse.SC_OK);
            response.getOutputStream().println(JSONResult.fillResultString(0, "", JWT));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

  // JWT驗證方法
    static Authentication getAuthentication(HttpServletRequest request) {
        // 從Header中拿到token
        String token = request.getHeader(HEADER_STRING);

        if (token != null) {
            // 解析 Token
            Claims claims = Jwts.parser()
                    // 驗籤
                    .setSigningKey(SECRET)
                    // 去掉 Bearer
                    .parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
                    .getBody();

            // 拿用戶名
            String user = claims.getSubject();

            // 得到 權限(角色)
            List<grantedauthority> authorities =  AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));

            // 返回驗證令牌
            return user != null ?
                    new UsernamePasswordAuthenticationToken(user, null, authorities) :
                    null;
        }

        return null;
    }
}/<grantedauthority>/<code>

這個類就兩個static方法,一個負責生成JWT,一個負責認證JWT最後生成驗證令牌。註釋已經寫得很清楚了,這裡不多說了。

下面來看自定義驗證組件,這裡簡單寫了,這個類就是提供密碼驗證功能,在實際使用時換成自己相應的驗證邏輯,從數據庫中取出、比對、賦予用戶相應權限。

<code>// 自定義身份認證驗證組件
class CustomAuthenticationProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 獲取認證的用戶名 & 密碼
        String name = authentication.getName();
        String password = authentication.getCredentials().toString();

        // 認證邏輯
        if (name.equals("admin") && password.equals("123456")) {

            // 這裡設置權限和角色
            ArrayList<grantedauthority> authorities = new ArrayList<>();
            authorities.add( new GrantedAuthorityImpl("ROLE_ADMIN") );
            authorities.add( new GrantedAuthorityImpl("AUTH_WRITE") );
            // 生成令牌
            Authentication auth = new UsernamePasswordAuthenticationToken(name, password, authorities);
            return auth;
        }else {
            throw new BadCredentialsException("密碼錯誤~");
        }
    }

    // 是否可以提供輸入類型的認證服務

    @Override
    public boolean supports(Class> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}/<grantedauthority>/<code>

下面實現JWTLoginFilter 這個Filter比較簡單,除了構造函數需要重寫三個方法。

  • attemptAuthentication - 登錄時需要驗證時候調用
  • successfulAuthentication - 驗證成功後調用
  • unsuccessfulAuthentication - 驗證失敗後調用,這裡直接灌入500錯誤返回,由於同一JSON返回,HTTP就都返回200了
<code>class JWTLoginFilter extends AbstractAuthenticationProcessingFilter {

    public JWTLoginFilter(String url, AuthenticationManager authManager) {
        super(new AntPathRequestMatcher(url));
        setAuthenticationManager(authManager);
    }

    @Override
    public Authentication attemptAuthentication(
            HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException, IOException, ServletException {

        // JSON反序列化成 AccountCredentials
        AccountCredentials creds = new ObjectMapper().readValue(req.getInputStream(), AccountCredentials.class);

        // 返回一個驗證令牌
        return getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(
                        creds.getUsername(),
                        creds.getPassword()

                )
        );
    }

    @Override
    protected void successfulAuthentication(
            HttpServletRequest req,
            HttpServletResponse res, FilterChain chain,
            Authentication auth) throws IOException, ServletException {

        TokenAuthenticationService.addAuthentication(res, auth.getName());
    }


    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {

        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_OK);
        response.getOutputStream().println(JSONResult.fillResultString(500, "Internal Server Error!!!", JSONObject.NULL));
    }
}/<code>

再完成最後一個類JWTAuthenticationFilter,這也是個攔截器,它攔截所有需要JWT的請求,然後調用TokenAuthenticationService類的靜態方法去做JWT驗證。

<code>class JWTAuthenticationFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain filterChain)
            throws IOException, ServletException {
        Authentication authentication = TokenAuthenticationService
                .getAuthentication((HttpServletRequest)request);

        SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        filterChain.doFilter(request,response);
    }
}/<code>

現在代碼就寫完了,整個Spring Security結合JWT基本就差不多了,下面我們來測試下,並說下整體流程。擴展:圖解JWT如何用於單點登錄

開始測試,先運行整個項目,這裡介紹下過程:

  • 先程序啟動 - main函數
  • 註冊驗證組件 - WebSecurityConfig 類 configure(AuthenticationManagerBuilder auth)方法,這裡我們註冊了自定義驗證組件
  • 設置驗證規則 - WebSecurityConfig 類 configure(HttpSecurity http)方法,這裡設置了各種路由訪問規則
  • 初始化過濾組件 - JWTLoginFilter 和 JWTAuthenticationFilter 類會初始化

首先測試獲取Token,這裡使用CURL命令行工具來測試。

<code>curl -H "Content-Type: application/json" -X POST -d '{"username":"admin","password":"123456"}'  http://127.0.0.1:8080/login/<code>

結果:

<code>{
  "result": "eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ",
  "message": "",
  "status": 0
}/<code>

這裡我們得到了相關的JWT,反Base64之後,就是下面的內容,標準JWT。

<code>{"alg":"HS512"}{"authorities":"ROLE_ADMIN,AUTH_WRITE","sub":"admin","exp":1493782240}ͽ]BS`pS6~hCVH%
ܬ)֝ଖoE5р/<code>

整個過程如下:

  • 拿到傳入JSON,解析用戶名密碼 - JWTLoginFilter 類 attemptAuthentication 方法
  • 自定義身份認證驗證組件,進行身份認證 - CustomAuthenticationProvider 類 authenticate 方法
  • 鹽城成功 - JWTLoginFilter 類 successfulAuthentication 方法
  • 生成JWT - TokenAuthenticationService 類 addAuthentication方法

再測試一個訪問資源的:

<code>curl -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ"  http://127.0.0.1:8080/users/<code>

結果:

<code>{
  "result":["freewolf","tom","jerry"],
  "message":"",
  "status":0
}/<code>

說明我們的Token生效可以正常訪問。其他的結果您可以自己去測試。再回到處理流程:

  • 接到請求進行攔截 - JWTAuthenticationFilter 中的方法
  • 驗證JWT - TokenAuthenticationService 類 getAuthentication 方法
  • 訪問Controller

這樣本文的主要流程就結束了,本文主要介紹了,如何用Spring Security結合JWT保護你的Spring Boot應用。

如何使用Role和Authority,這裡多說一句其實在Spring Security中,對於GrantedAuthority接口實現類來說是不區分是Role還是Authority,二者區別就是如果是hasAuthority判斷,就是判斷整個字符串,判斷hasRole時,系統自動加上ROLE_到判斷的Role字符串上,也就是說hasRole("CREATE")和hasAuthority('ROLE_CREATE')是相同的。利用這些可以搭建完整的RBAC體系。

本文到此,你應該已經會用了本文介紹的知識點。

代碼

https://github.com/freew01f/securing-spring-boot-with-jwts

參考

https://docs.spring.io/spring-security/site/docs/current/reference/html/jc.htmlhttp://ryanjbaxter.com/2015/01/06/securing-rest-apis-with-spring-boot/http://www.ekiras.com/2015/01/spring-security-create-custom-authentication-filter-in-grails.htmlhttps://auth0.com/blog/securing-spring-boot-with-jwts/http://www.jwt.io/


分享到:


相關文章: