SpringBoot系列之前後端介面安全技術JWT

smileNicky發表於2020-07-10

@

1. 什麼是JWT?

JWT的全稱為Json Web Token (JWT),是目前最流行的跨域認證解決方案,是在網路應用環境間傳遞宣告而執行的一種基於JSON的開放標準((RFC 7519),JWT 是一種JSON風格的輕量級的授權和身份認證規範,可實現無狀態、分散式的Web應用授權

引用官方的說法是:

JSON Web令牌(JWT)是一個開放標準(RFC 7519),它定義了一種緊湊且自包含的方式,用於在各方之間安全地將資訊作為JSON物件傳輸。由於此資訊是經過數字簽名的,因此可以進行驗證和信任。可以使用祕密(使用HMAC演算法)或使用RSA或ECDSA的公鑰/私鑰對對JWT進行簽名。

引用官網圖片,JWT生成的token格式如圖:
在這裡插入圖片描述

2. JWT令牌結構怎麼樣?

JSON Web令牌以緊湊的形式由三部分組成,這些部分由點(.)分隔,分別是:

  • 標頭(Header)
  • 有效載荷(Playload)
  • 簽名(Signature)
    因此,JWT通常如下所示。
    xxxxx.yyyyy.zzzzz
    在這裡插入圖片描述

ok,詳細介紹一下這3部分組成

2.1 標頭(Header)

標頭通常由兩部分組成:令牌的型別(即JWT)和所使用的簽名演算法,例如HMAC SHA256或RSA。
* 宣告型別,這裡是JWT
* 加密演算法,自定義

{
  "alg": "HS256",
  "typ": "JWT"
}

然後進行Base64Url編碼得到jwt的第1部分

Base64是一種基於64個可列印字元來表示二進位制資料的表示方法。由於2
的6次方等於64,所以每6個位元為一個單元,對應某個可列印字元。三個位元組有24
個位元,對應於4個Base64單元,即3個位元組需要用4個可列印字元來表示。JDK 中 提
供了非常方便的 B BA AS SE E6 64 4E En nc co od de er r和B BA AS SE E6 64 4D De ec co od de er r,用它們可以非常方便的完
成基於 BASE64 的編碼和解碼

2.2 有效載荷(Playload)

載荷就是存放有效資訊的地方。這個名字像是特指飛機上承載的貨品,這些有效資訊包
含三個部分:

  • (1)標準中註冊的宣告

    • iss (issuer):表示簽發人
    • exp (expiration time):表示token過期時間
    • sub (subject):主題
    • aud (audience):受眾
    • nbf (Not Before):生效時間
    • iat (Issued At):簽發時間
    • jti (JWT ID):編號
  • (2)公共的宣告
    公共的宣告可以新增任何的資訊,一般新增使用者的相關資訊或其他業務需要的必要資訊

  • (3)私有的宣告
    私有宣告是提供者和消費者所共同定義的宣告,一般不建議存放敏感資訊,因為base64是對稱解密的,意味著該部分資訊可以歸類為明文資訊。這些私有的宣告其實一般就是指自定義Claim

定義一個payload:

{
    "user_id":1,
    "user_name":"nicky",
    "scope":[
        "ROLE_ADMIN"
    ],
    "non_expired":false,
    "exp":1594352348,
    "iat":1594348748,
    "enabled":true,
    "non_locked":false
}

對其進行base64加密,得到payload:

eyJ1c2VyX2lkIjoxLCJ1c2VyX25hbWUiOiJuaWNreSIsInNjb3BlIjpbIlJPTEVfQURNSU4iXSwibm9uX2V4cGlyZWQiOmZhbHNlLCJleHAiOjE1OTQzNTIzNDgsImlhdCI6MTU5NDM0ODc0OCwiZW5hYmxlZCI6dHJ1ZSwibm9uX2xvY2tlZCI6ZmFsc2V9

2.3 簽名(Signature)

jwt的第三部分是一個簽證資訊,這個簽證資訊由三部分組成:

  • header (base64後的)
  • payload (base64後的)
  • secret
    簽名,是整個資料的認證資訊。一般根據前兩步的資料,然後通過header中宣告的加密方式進行加鹽secret組合加密,然後就構成了jwt的第3部分

ok,一個jwt令牌的組成就介紹好咯,令牌是三個由點分隔的Base64-URL字串,可以在HTML和HTTP環境中輕鬆傳遞這些字串,與基於XML的標準(例如SAML)相比,它更緊湊。
下圖顯示了一個JWT,它已對先前的標頭和有效負載進行了編碼,並用一個祕密secret進行了簽名編碼的JWT:
在這裡插入圖片描述

JWT官網提供的線上除錯工具:
https://jwt.io/#debugger-io
在這裡插入圖片描述
開源中國提供的base64線上加解密:
https://tool.oschina.net/encrypt?type=3
在這裡插入圖片描述

3. JWT原理簡單介紹

引用官網的圖,用於顯示如何獲取JWT,並將其用於訪問API或資源:

在這裡插入圖片描述

  • 1、客戶端(包括瀏覽器、APP等)向授權伺服器請求授權
  • 2、授權伺服器驗證通過,授權伺服器會嚮應用程式返回訪問令牌
  • 3、該應用程式使用訪問令牌來訪問受保護的資源(例如API)

4. JWT的應用場景

JWT 使用於比較小型的業務驗證,對於比較複雜的可以用OAuth2.0實現

引用官方的說法:

  • 授權:這是使用JWT的最常見方案。一旦使用者登入,每個後續請求將包括JWT,從而允許使用者訪問該令牌允許的路由,服務和資源。單一登入是當今廣泛使用JWT的一項功能,因為它的開銷很小並且可以在不同的域中輕鬆使用。
  • 資訊交換:JSON Web令牌是在各方之間安全地傳輸資訊的好方法。因為可以對JWT進行簽名(例如,使用公鑰/私鑰對),所以您可以確保發件人是他們所說的人。此外,由於簽名是使用標頭和有效負載計算的,因此您還可以驗證內容是否遭到篡改。

5. 與Cookie-Session對比

瞭解JWT之前先要了解傳統的Cookie-Session認證機制,這是單體應用最常用的,其大概流程:

  • 1、使用者訪問客戶端(瀏覽器),伺服器通過session校驗使用者是否登入
  • 2、 使用者沒登入返回登入頁面,輸入賬號密碼等驗證
  • 3、 驗證通過建立session,返回sessionId給客戶端儲存到cookie
  • 4、接著,使用者訪問其它同域連結,都會校驗sessionId,符合就允許訪問

ok,簡單介紹這套cookie-session機制,之前設計者開發這套機制是為了相容http的無狀態,這套機制有其優點,當然也有一些缺陷:

  • 只適用於B/S架構的軟體,對於安卓app等客戶端不帶cookie的,不能和服務端進行對接
  • 不支援跨域,因為Cookie為了保證安全性,只能允許同域訪問,不支援跨域
  • CSRF攻擊,Cookie沒做好安全保證,有時候容易被竊取,受到跨站請求偽造的攻擊

ok,簡單介紹了cookie-session機制後,可以介紹一下jwt的認證

  • 1、使用者訪問客戶端(瀏覽器、APP等等),伺服器通過token校驗
  • 2、 使用者沒登入返回登入頁面,輸入賬號密碼等驗證
  • 3、 驗證通過建立已簽名token,返回token給客戶端儲存,最常見的是儲存在localStorage中,但是也可以存在Session Storage和Cookie中
  • 4、接著,使用者訪問其它連結,都會帶上token,伺服器解碼JWT,如果Token是有效的則處理這個請求

網上對於cookie-session機制和jwt的討論很多,可以自行網上找資料,我覺得這兩套機制各有優點,應該根據場景進行選用,JWT最明顯優點就是小巧輕便,安全性也比較好,但是也有其缺點。

  • 比如對於業務繁雜的功能,如果一些資訊也丟在jwt的token裡,cookie有可能不能儲存。
  • 續簽問題,jwt不能支援,傳統的cookie+session的方案天然的支援續簽,但是jwt由於服務端不儲存使用者狀態,因此很難完美解決續簽問題
  • 密碼重置等問題,jwt因為資料不儲存於服務端,如果使用者修改密碼,不過token還沒過期,這種情況,原來的token還是可以訪問系統的,這種肯定是不允許的,不過這種情況或許可以通過修改secret實現

6. Java的JJWT實現JWT

6.1 什麼是JJWT?

JJWT是一個提供端到端的JWT建立和驗證的Java庫。永遠免費和開源(Apache
License,版本2.0),JJWT很容易使用和理解。它被設計成一個以建築為中心的流暢界
面,隱藏了它的大部分複雜性。

6.2 實驗環境準備

環境準備:

  • Maven 3.0+
  • IntelliJ IDEA

技術棧:

  • SpringBoot2.2.1
  • Spring Security

新建一個SpringBoot專案,maven加入JJWT相關配置

<dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${java.jwt.version}</version>
        </dependency>

pom.xml:


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example.springboot</groupId>
    <artifactId>springboot-jwt</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>springboot-jwt</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
        <jjwt.version>0.9.0</jjwt.version>
        <java.jwt.version>3.4.0</java.jwt.version>
        <mybatis.springboot.version>2.1.1</mybatis.springboot.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${java.jwt.version}</version>
        </dependency>

        <!-- springboot mybatis-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>${mybatis.springboot.version}</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.27</version>
            <scope>runtime</scope>
        </dependency>

        <!-- SpringBoot thymeleaf-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

application.yml:

spring:
  datasource:
    url: jdbc:mysql://192.168.0.152:33306/jeeplatform?autoReconnect=true&useUnicode=true&characterEncoding=utf8&characterSetResults=utf8&useSSL=false
    username: root
    password: minstone
    driver-class-name: com.mysql.jdbc.Driver
  #新增Thymeleaf配置,除了cache在專案沒上線前建議關了,其它配置都可以不用配的,本部落格只是列舉一下有這些配置
  thymeleaf:
    # cache預設開啟的,這裡可以關了,專案上線之前,專案上線後可以開啟
    cache: false
    # 這個prefix可以註釋,因為預設就是templates的,您可以改成其它的自定義路徑
    prefix: classpath:/templates/
    suffix: .html
    mode: HTML5
    # 指定一下編碼為utf8
    encoding: UTF-8
    # context-type為text/html,也可以不指定,因為boot可以自動識別
    servlet:
      content-type: text/html
  messages:
    basename: i18n.messages
    #    cache-duration:
    encoding: UTF-8


logging:
  level:
    org:
      springframework:
        security: DEBUG
    com:
      example:
        springboot:
          jwt:
            mapper: DEBUG

專案工程:
在這裡插入圖片描述

6.3 jwt配置屬性讀取

新建jwt.yml:

# jwt configuration
jwt:
  # 存放Token的Header key值
  token-key: Authorization
  # 自定義金鑰,加鹽
  secret: mySecret
  # 超時時間 單位秒
  expiration: 3600
  # 自定義token 字首字元
  token-prefix: Bearer-
  # accessToken超時時間 單位秒
  access-token: 3600
  # 重新整理token時間 單位秒
  refresh-token: 3600
  # 允許訪問的uri
  permit-all: /oauth/**,/login/**,/logout/**
  # 需要校驗的uri
  authenticate-uri: /api/**

JWTProperties .java

package com.example.springboot.jwt.configuration;

import com.example.springboot.jwt.core.io.support.YamlPropertyResourceFactory;
import lombok.Data;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

import java.time.Duration;

/**
 * <pre>
 *  JWT配置類
 * </pre>
 *
 * <pre>
 * @author nicky.ma
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/06 11:37  修改內容:
 * </pre>
 */
@Component
@PropertySource(value = "classpath:jwt.yml",encoding = "utf-8",factory = YamlPropertyResourceFactory.class)
@ConfigurationProperties(prefix = "jwt")
@Data
@ToString
public class JWTProperties {

    /**
     * 存放Token的Header key值
     */
    private String tokenKey;

    /*
     * 自定義金鑰,加鹽
     */
    private String secret;

    /*
     * 超時時間 單位秒
     */
    private Duration expiration =Duration.ofMinutes(3600);

    /*
     * 自定義token 字首字元
     */
    private String tokenPrefix;

    /*
     * accessToken超時時間 單位秒
     */
    private Duration accessToken =Duration.ofMinutes(3600);

    /*
     * 重新整理token時間 單位秒
     */
    private Duration refreshToken =Duration.ofMinutes(3600);

    /*
     * 允許訪問的uri
     */
    private String permitAll;

    /*
     * 需要校驗的uri
     */
    private String authenticateUri;
}

SpringBoot2.2.1版本使用@ConfigurationProperties註解是不能讀取yaml檔案的,只能讀取properties,所以自定義PropertySourceFactory

package com.example.springboot.jwt.core.io.support;

import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.support.DefaultPropertySourceFactory;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertySourceFactory;
import org.springframework.lang.Nullable;

import java.io.IOException;
import java.util.List;
import java.util.Optional;

/**
 * <pre>
 *  YAML配置檔案讀取工廠類
 * </pre>
 * <p>
 * <pre>
 * @author nicky.ma
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2019/11/13 15:44  修改內容:
 * </pre>
 */
public class YamlPropertyResourceFactory implements PropertySourceFactory {

    /**
     * Create a {@link PropertySource} that wraps the given resource.
     *
     * @param name     the name of the property source
     * @param encodedResource the resource (potentially encoded) to wrap
     * @return the new {@link PropertySource} (never {@code null})
     * @throws IOException if resource resolution failed
     */
    @Override
    public PropertySource<?> createPropertySource(@Nullable String name, EncodedResource encodedResource) throws IOException {
        String resourceName = Optional.ofNullable(name).orElse(encodedResource.getResource().getFilename());
        if (resourceName.endsWith(".yml") || resourceName.endsWith(".yaml")) {
            //yaml資原始檔
            List<PropertySource<?>> yamlSources = new YamlPropertySourceLoader().load(resourceName, encodedResource.getResource());
            return yamlSources.get(0);
        } else {
            //返回預設的PropertySourceFactory
            return new DefaultPropertySourceFactory().createPropertySource(name, encodedResource);
        }
    }
}

6.4 JWT Token工具類

package com.example.springboot.jwt.core.jwt.util;

import com.alibaba.fastjson.JSON;
import com.example.springboot.jwt.configuration.JWTProperties;
import com.example.springboot.jwt.core.jwt.userdetails.JWTUserDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.*;


/**
 * <pre>
 *   JWT工具類
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/06 13:57  修改內容:
 * </pre>
 */
@Component
@Slf4j
public class JWTTokenUtil {

    private static final String CLAIM_KEY_USER_ID = "user_id";
    private static final String CLAIM_KEY_USER_NAME ="user_name";
    private static final String CLAIM_KEY_ACCOUNT_ENABLED = "enabled";
    private static final String CLAIM_KEY_ACCOUNT_NON_LOCKED = "non_locked";
    private static final String CLAIM_KEY_ACCOUNT_NON_EXPIRED = "non_expired";
    private static final String CLAIM_KEY_AUTHORITIES = "scope";
    //簽名方式
    private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;


    @Autowired
    JWTProperties jwtProperties;

    /**
     * 生成acceptToken
     * @param userDetails
     * @return
     */
    public String generateToken(UserDetails userDetails) {
        JWTUserDetails user = (JWTUserDetails) userDetails;
        Map<String, Object> claims = generateClaims(user);
        return generateToken(user.getUsername(),claims);
    }

    /**
     * 生成acceptToken
     * @param username
     * @param claims
     * @return
     */
    public String generateToken(String username, Map<String, Object> claims) {
        return Jwts.builder()
                .setId(UUID.randomUUID().toString())
                .setSubject(username)
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(generateExpirationDate(jwtProperties.getExpiration().toMillis()))
                .signWith(SIGNATURE_ALGORITHM, jwtProperties.getSecret())
                .compact();
    }

    /**
     * 校驗acceptToken
     * @param token
     * @param userDetails
     * @return
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        JWTUserDetails user = (JWTUserDetails) userDetails;
       return validateToken(token, user.getUsername());
    }

    /**
     * 校驗acceptToken
     * @param token
     * @param  username
     * @return
     */
    public boolean validateToken(String token, String username) {
        try {
            final String userId = getUserIdFromClaims(token);
            return getClaimsFromToken(token) != null
                    && userId.equals(username)
                    && !isTokenExpired(token);
        } catch (Exception e) {
            throw new IllegalStateException("Invalid Token!"+e);
        }
    }

    /**
     * 校驗acceptToken
     * @param token
     * @return
     */
    public boolean validateToken(String token) {
        try {
            return getClaimsFromToken(token) != null
                    && !isTokenExpired(token);
        } catch (Exception e) {
            throw new IllegalStateException("Invalid Token!"+e);
        }
    }

    /**
     * 解析token 資訊
     * @param token
     * @return
     */
    public Claims  getClaimsFromToken(String token){
        Claims claims = Jwts.parser()
                    .setSigningKey(jwtProperties.getSecret())
                    .parseClaimsJws(token)
                    .getBody();
        return claims;
    }

    /**
     * 從token獲取userId
     * @param token
     * @return
     */
    public String getUserIdFromClaims(String token) {
        String userId = getClaimsFromToken(token).getId();
        return userId;
    }

    /**
     * 從token獲取ExpirationDate
     * @param token
     * @return
     */
    public Date getExpirationDateFromClaims(String token) {
        Date expiration = getClaimsFromToken(token).getExpiration();
        return expiration;
    }

    /**
     * 從token獲取username
     * @param token
     * @return
     */
    public String getUsernameFromClaims(String token) {
        return  getClaimsFromToken(token).get(CLAIM_KEY_USER_NAME).toString();
    }

    /**
     * token 是否過期
     * @param token
     * @return
     */
    public boolean isTokenExpired(String token) {
        final Date expirationDate = getExpirationDateFromClaims(token);
        return expirationDate.before(new Date());
    }

    /**
     * 生成失效時間
     * @param expiration
     * @return
     */
    public Date generateExpirationDate(long expiration) {
        return new Date(System.currentTimeMillis() + expiration * 1000);
    }

    /**
     * 生成Claims
     * @Param user
     * @return
     */
    public Map<String, Object> generateClaims(JWTUserDetails user) {
        Map<String, Object> claims = new HashMap<>(16);
        claims.put(CLAIM_KEY_USER_ID, user.getUserId());
        claims.put(CLAIM_KEY_USER_NAME, user.getUsername());
        claims.put(CLAIM_KEY_ACCOUNT_ENABLED, user.isEnabled());
        claims.put(CLAIM_KEY_ACCOUNT_NON_LOCKED, user.isAccountNonLocked());
        claims.put(CLAIM_KEY_ACCOUNT_NON_EXPIRED, user.isAccountNonExpired());
        if (!CollectionUtils.isEmpty(user.getAuthorities())) {
            claims.put(CLAIM_KEY_AUTHORITIES , JSON.toJSON(getAuthorities(user.getAuthorities())));
        }
        return claims;
    }

    /**
     * 獲取角色許可權
     * @param authorities
     * @return
     */
    public List<String> getAuthorities(Collection<? extends GrantedAuthority> authorities){
        List<String> list = new ArrayList<>();
        for (GrantedAuthority ga : authorities) {
            list.add(ga.getAuthority());
        }
        return list;
    }

}

6.5 Spring Security引入

自定義UserDetails:

package com.example.springboot.jwt.core.jwt.userdetails;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.Instant;
import java.util.Collection;
import java.util.List;

/**
 * <pre>
 *  JWTUserDetails
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/06 14:45  修改內容:
 * </pre>
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class JWTUserDetails implements UserDetails {

    /**
     * 使用者ID
     */
    private Long userId;
    /**
     * 使用者密碼
     */
    private String password;
    /**
     * 使用者名稱
     */
    private String username;
    /**
     * 使用者角色許可權
     */
    private Collection<? extends GrantedAuthority> authorities;
    /**
     * 賬號是否過期
     */
    private  Boolean isAccountNonExpired = false;
    /**
     * 賬戶是否鎖定
     */
    private  Boolean isAccountNonLocked = false;
    /**
     * 密碼是否過期
     */
    private  Boolean isCredentialsNonExpired = false;
    /**
     * 賬號是否啟用
     */
    private  Boolean isEnabled = true;
    /**
     * 上次密碼重置時間
     */
    private  Instant lastPasswordResetDate;

    public JWTUserDetails(Long id, String username, String password, List<GrantedAuthority> mapToGrantedAuthorities) {
        this.userId = id;
        this.username = username;
        this.password = password;
        this.authorities = mapToGrantedAuthorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonExpired() {
        return isAccountNonExpired;
    }

    @JsonIgnore
    @Override
    public boolean isAccountNonLocked() {
        return isAccountNonLocked;
    }

    @JsonIgnore
    @Override
    public boolean isCredentialsNonExpired() {
        return isCredentialsNonExpired;
    }

    @JsonIgnore
    @Override
    public boolean isEnabled() {
        return isEnabled;
    }


}

UserDetailsServiceImpl.java業務介面

package com.example.springboot.jwt.service;

import com.example.springboot.jwt.core.jwt.userdetails.JWTUserDetails;
import com.example.springboot.jwt.mapper.UserMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.List;

/**
 * <pre>
 *  UserDetailsServiceImpl
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/06 18:10  修改內容:
 * </pre>
 */
@Service("jwtUserService")
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    @Qualifier("userMapper")
    UserMapper userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        JWTUserDetails user = userRepository.findByUsername(username);
        if(user == null){
            log.info("登入使用者[{}]沒註冊!",username);
            throw new UsernameNotFoundException("登入使用者["+username + "]沒註冊!");
        }
        return new JWTUserDetails(1L,user.getUsername(), user.getPassword(), getAuthority());
    }

    private List<GrantedAuthority> getAuthority() {
        return Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"));
    }
}

自定義AuthenticationEntryPoint進行統一異常處理:

package com.example.springboot.jwt.web.handler;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.Serializable;

/**
 * <pre>
 *  JWTAuthenticationEntryPoint
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/09 14:46  修改內容:
 * </pre>
 */
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        // 出錯時候
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

6.6 JWT授權過濾器

package com.example.springboot.jwt.web.filter;

import com.example.springboot.jwt.configuration.JWTProperties;
import com.example.springboot.jwt.core.jwt.userdetails.JWTUserDetails;
import com.example.springboot.jwt.core.jwt.util.JWTTokenUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * <pre>
 *  JWTAuthenticationTokenFilter
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/06 16:04  修改內容:
 * </pre>
 */
@Slf4j
public class JWTAuthenticationTokenFilter extends OncePerRequestFilter {

    private static final ConcurrentMap<String,Boolean> URI_CACHE_MAP = new ConcurrentHashMap<String,Boolean>();
    private final List<String> permitAllUris;
    private final List<String> authenticateUris;

    @Autowired
    JWTProperties jwtProperties;
    @Autowired
    JWTTokenUtil jwtTokenUtil;
    @Autowired
    @Qualifier("jwtUserService")
    UserDetailsService userDetailsService;

    public JWTAuthenticationTokenFilter(JWTProperties jwtProperties) {
        this.permitAllUris = Arrays.asList(jwtProperties.getPermitAll().split(","));
        this.authenticateUris = Arrays.asList(jwtProperties.getAuthenticateUri().split(","));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (!isAllowUri(httpServletRequest)) {
            final String _authHeader = httpServletRequest.getHeader(jwtProperties.getTokenKey());
            log.info("Authorization:[{}]",_authHeader);
            if (StringUtils.isEmpty(_authHeader) || ! _authHeader.startsWith(jwtProperties.getTokenPrefix())) {
                throw new RuntimeException("Unable to get JWT Token");
            }
            final String token = _authHeader.substring(7);
            log.info("acceptToken:[{}]",token);
            if (!jwtTokenUtil.validateToken(token)) {
                throw new RuntimeException("Invalid token");
            }
            if (jwtTokenUtil.validateToken(token)) {
                String username = jwtTokenUtil.getUsernameFromClaims(token);
                JWTUserDetails userDetails = (JWTUserDetails)userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }

    private Boolean isAllowUri(HttpServletRequest request) {
        String uri = request.getServletPath();
        if (URI_CACHE_MAP.containsKey(uri)) {
            // 快取有資料,直接從快取讀取
            return URI_CACHE_MAP.get(uri);
        }
        boolean flag = checkRequestUri(uri);
        // 資料丟到快取裡
        URI_CACHE_MAP.putIfAbsent(uri, flag);
        return flag;
    }

    private Boolean checkRequestUri(String requestUri) {
        boolean filter = true;
        final PathMatcher pathMatcher = new AntPathMatcher();
        for (String permitUri : permitAllUris) {
            if (pathMatcher.match(permitUri, requestUri)) {
                // permit all的連結直接放過
                filter = true;
            }
        }
        for (String authUri : authenticateUris) {
            if (pathMatcher.match(authUri, requestUri)) {
                filter = false;
            }
        }
        return filter;
    }
}

WebMvcConfigurer類註冊過濾器:

package com.example.springboot.jwt.configuration;

import com.example.springboot.jwt.web.filter.JWTAuthenticationTokenFilter;
import com.example.springboot.jwt.web.handler.SecurityHandlerInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * <pre>
 *  MyWebMvcConfigurer
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/07 13:52  修改內容:
 * </pre>
 */
@Configuration

public class MyWebMvcConfigurer implements WebMvcConfigurer {

    @Autowired
    private JWTProperties jwtProperties;
   

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SecurityHandlerInterceptor())
                .addPathPatterns("/**");
    }

    @Bean
    public JWTAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
        return new JWTAuthenticationTokenFilter(jwtProperties);
    }

    @Bean
    public FilterRegistrationBean jwtFilter() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(jwtAuthenticationTokenFilter());
        return registrationBean;
    }


   

}

6.7 Spring Security配置類

package com.example.springboot.jwt.configuration;


import com.example.springboot.jwt.core.encode.CustomPasswordEncoder;
import com.example.springboot.jwt.web.filter.JWTAuthenticationTokenFilter;
import com.example.springboot.jwt.web.handler.JWTAuthenticationEntryPoint;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * <pre>
 *  SecurityConfiguration
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/04/30 15:58  修改內容:
 * </pre>
 */
@Configuration
@EnableWebSecurity
@Order(1)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("jwtUserService")
    private UserDetailsService userDetailsService;
    @Autowired
    private JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    @Autowired
    private JWTAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(new CustomPasswordEncoder());
        auth.parentAuthenticationManager(authenticationManagerBean());

    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //解決靜態資源被攔截的問題
        web.ignoring().antMatchers("/asserts/**");
        web.ignoring().antMatchers("/favicon.ico");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http   // 配置登入頁並允許訪問
                .formLogin().loginPage("/login").permitAll()
                // 登入成功被呼叫
                //.successHandler(new MyAuthenticationSuccessHandler())
                // 配置登出頁面
                .and().logout().logoutUrl("/logout").logoutSuccessUrl("/")
                .and().authorizeRequests().antMatchers("/oauth/**", "/login/**", "/logout/**","/authenticate/**").permitAll()
                // 其餘所有請求全部需要鑑權認證
                .anyRequest().authenticated()
                // 自定義authenticationEntryPoint
                .and().exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint )
                // 不使用Session
                .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                // 關閉跨域保護;
                .and().csrf().disable();
        // JWT 過濾器
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }



    @Bean
    public PasswordEncoder bcryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }


}

6.8 自定義登入頁面

<!DOCTYPE html>
<html lang="zh" xmlns:th="http://www.thymeleaf.org">
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
		<meta name="description" content="" />
		<meta name="author" content="" />
		<title>Signin Template for Bootstrap</title>
		<!-- Bootstrap core CSS -->
		<link href="../static/asserts/css/bootstrap.min.css" th:href="@{asserts/css/bootstrap.min.css}" rel="stylesheet" />
		<!-- Custom styles for this template -->
		<link href="../static/asserts/css/signin.css" th:href="@{asserts/css/signin.css}" rel="stylesheet"/>
	</head>

	<body class="text-center">
		<form class="form-signin" th:action="@{/authenticate}" method="post">
			<img class="mb-4" th:src="@{asserts/img/bootstrap-solid.svg}" alt="" width="72" height="72" />
			<h1 class="h3 mb-3 font-weight-normal" th:text="#{messages.tip}">Oauth2.0 Login</h1>
			<label class="sr-only" th:text="#{messages.username}">Username</label>
			<input type="text" class="form-control" name="username" id="username" th:placeholder="#{messages.username}" required="" autofocus="" value="nicky" />
			<label class="sr-only" th:text="#{messages.password} ">Password</label>
			<input type="password" class="form-control" name="password" id="password" th:placeholder="#{messages.password}" required="" value="123" />
			<div class="checkbox mb-3">
				<label>
          <input type="checkbox" value="remember-me"  /> remember me
        </label>
			</div>
			<button class="btn btn-lg btn-primary btn-block" id="btnSave" type="submit" th:text="#{messages.loginBtnName}">Sign in</button>
			<p class="mt-5 mb-3 text-muted">© 2019</p>
			<a class="btn btn-sm" th:href="@{/login(lang='zh_CN')} ">中文</a>
			<a class="btn btn-sm" th:href="@{/login(lang='en_US')} ">English</a>
		</form>
		<script src="https://cdn.bootcss.com/jquery/1.11.3/jquery.js"></script>
		<script>
            $(function() {
                $("#btnSave").click(function () {
                    var username=$("#username").val();
                    var password=$("#password").val();
                    $.ajax({
                        cache: false,
                        type: "POST",
                        url: "/authenticate",
                        contentType:"application/x-www-form-urlencoded; charset=UTF-8",
                        data:{"username":username ,"password" : password},
                        dataType: "json",
                        async: false,
                        error: function (request) {
                            console.log("Connection error");
                        },
                        success: function (data) {
                            //save token
                            localStorage.setItem("token",data);
                        }
                    });
                });
            });
		</script>

	</body>

</html>

LoginController.java:



    @GetMapping(value = {"/login"})
    public ModelAndView toLogin(){
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("login");
        return modelAndView;
    }

    @PostMapping(value = "/authenticate")
    @ResponseBody
    public ResponseEntity<?> authenticate( UserDto userDto, HttpServletRequest request,
                                           HttpServletResponse response) throws Exception {
        // ... 省略使用者登入校驗程式碼
        UserDetails userDetails = userDetailsService.loadUserByUsername(userDto.getUsername());
        String token = jwtTokenUtil.generateToken(userDetails);
        response.setHeader(jwtProperties.getTokenKey(),jwtProperties.getTokenPrefix()+token);
        return ResponseEntity.ok(token);
    }
	

在這裡插入圖片描述
輸入賬號密碼,校驗通過,返回jwt的令牌token

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VyX25hbWUiOiJuaWNreSIsInNjb3BlIjpbIlJPTEVfQURNSU4iXSwibm9uX2V4cGlyZWQiOmZhbHNlLCJleHAiOjE1OTQyODgyMzksImlhdCI6MTU5NDI4NDYzOCwiZW5hYmxlZCI6dHJ1ZSwibm9uX2xvY2tlZCI6ZmFsc2V9.bxGCCBSQE5cgVSl9Lve-vyDtITw1gL5i2-O-B5uEgno

測試令牌,官方測試連結:https://jwt.io/#debugger-io
在這裡插入圖片描述
base64:
在這裡插入圖片描述

package com.example.springboot.jwt.web.controller;

import com.example.springboot.jwt.configuration.JWTProperties;
import com.example.springboot.jwt.core.jwt.util.JWTTokenUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

/**
 * <pre>
 *  UserController
 * </pre>
 *
 * <pre>
 * @author mazq
 * 修改記錄
 *    修改後版本:     修改人:  修改日期: 2020/07/07 14:14  修改內容:
 * </pre>
 */
@RestController
@RequestMapping(value = "api/user")
public class UserController {

    @Autowired
    JWTProperties jwtProperties;
    @Autowired
    JWTTokenUtil jwtTokenUtil;

    @GetMapping("/auth-info")
    public ResponseEntity authInfo(HttpServletRequest request) {
        String authHeader = request.getHeader(jwtProperties.getTokenKey());
        String token = authHeader.substring(7);
        return ResponseEntity.ok(jwtTokenUtil.getUsernameFromClaims(token));
    }
}

複製生成的jwt令牌,設定Request Header

在這裡插入圖片描述

程式碼例子下載:下載

相關文章