此篇已更新,歡迎到「【Spring Boot】第12.5課-將 Spring Security 與 JWT 結合,實作登入 API」文章繼續閱讀。
完成 REST API 的授權規則後,前端或其他 client 若想存取這些受保護的 API,勢必要出示某種證明,來表示自己有資格。實現這件事的第一步,便是製作出這個「證明」。
本文將會先介紹 token 的概念,接著實作產生 JWT 的程式。最後再加入帳號密碼認證的環節,設計出「經由登入取得 token」的 API。
讀者是否有在學校圖書館借書時要刷學生證,或是在公司搭電梯、進辦公室時要刷門禁卡的經驗?由於圖書館的書和公司的設施,不會開放給外人使用,因此需要出示這種「識別證」,來表明自己是誰。Token 就如同識別證,系統可以從中取出有關該人的基本資料。
前端在存取後端 API 時,會在「Authorization」這個 request header 攜帶 token。目的是讓後端知道現在存取 API 的人是誰。
Day 26 的文章,讀者將更能體會到為什麼後端需要知道是誰。
Token 又可再分為「access token」與「refresh token」。使用者在輸入帳密登入後,會一起拿到這兩種資料。前面提到會在 request header 攜帶 token,指的正是 access token。
但為了安全考量,我們會設定 access token 的到期時間,屆時就需要更換。這麼一來,就算被盜用,也只能使用一段時間而已。盜用者沒辦法長時間冒充他人身份。
至於 refresh token,會在更換 access token 時提供給後端,這樣使用者就不必常常被送回登入畫面重新登入。靠著 refresh token 就能長時間使用系統。當然,refresh token 本身也是會到期的,只是有效時間相對久。到期後,確實就需要請使用者重新登入了。
那麼如果 refresh token 被盜用怎麼辦?其實絕對安全是很難做到的,所以才設計出這兩種 token。Access token 在每次發送請求時都會攜帶,即便被竊取,但它的有效時間相對短。而 refresh token 的攜帶頻率相對低。透過這樣的設計,來降低被盜用的風險與影響程度。
JWT 的全名是「JSON Web Token」,它是一種將 JSON 資料進行編碼的標準。現今經常被用來當作證明使用者身份的資料,就像前面用來比喻的識別證。本文與下一篇文章,皆使用 JWT 做為 access token 與 refresh token。
JWT 是由「標頭」(header)、「內容」(payload)與「簽名」(signature)三個部份組成。因包含簽名,故又稱為 JWS(JSON Web Signature)。
標頭主要包含兩個欄位。一個是「alg」,代表加密演算法,是必要的欄位。一個是「typ」,代表 token 的種類,是非必要欄位。
{
    "alg": "HS256",
    "typ": "JWT"
}
JWT 的內容可以存放各種自定義的資料,例如使用者的 id、email、名字或權限等。
RFC 7519 的標準也舉例了一些資料種類,讓內容更齊全,但不是必需的。例如:
{
    "nbf": 1596240000000,
    "exp": 1596241800000,
    "userId": "100",
    "name": "Vincent",
    "authority": "ADMIN"
}
上述的標頭與內容,之後會經過 Base64 編碼。然而這種編碼方式是可以還原成原文的,因此請不要在裡面放置私密資料,如密碼或身份證字號等。
在 JWT 簽名是為了幫助驗證是否被竄改。簽名的產生方式,是將 JWT 的標頭與內容經過 Base64 編碼,再配合密鑰(secret key)使用加密演算法所得到的(姑且稱為 簽名1)。
產生帶簽名的 JWT(即 JWS)之虛擬碼如下。
encodedHeader = base64Encode(header)
encodedPayload = base64Encode(payload)
signature = encrypt(secretKey, encodedHeader + "." + encodedPayload)
jws = encodedHeader + "." + encodedPayload + "." + signature
而驗證的方式,則是將收到的 JWT 也產生一組簽名(稱為 簽名2)。若 簽名1 與 簽名2 比對後相同,就代表未被竄改。畢竟如果被改了,由於不知道密鑰,所以無法產生與 JWT 內容相符的簽名。正是因為如此,密鑰需妥善保管,不可洩漏。
JWT 驗證的思路,就和 Day 22 引進 Spring Security 所使用的「Password Encoder」一樣。
這節讓我們實作產生 JWT 的程式。在 JWT 官網的 library 頁面,可找到許多開源的 library。本文挑選了 Github 星星數最多的一個,叫做「JJWT」,其操作方式也相當簡單。
依據該 library 的 Github 頁面所寫,請在程式專案的 pom.xml 檔案添加依賴。
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
請建立一個叫做 TokenService 的元件類別,負責 token 相關的事務。
import java.security.Key;
@Service
public class TokenService {
    private Key secretKey;
    @PostConstruct
    private void init() {
        String key = "VincentIsParticipatingItHomeIronmanContest";
        secretKey = Keys.hmacShaKeyFor(key.getBytes());
    }
    public LoginResponse createToken(LoginRequest request) {
        // TODO
        return null;
    }
}
上面的 secretKey 是產生 JWT 簽名時所需要的密鑰,由一組自定義的字串所產生,而這也是不能外流的。
至於 createToken 方法,會接收使用者送來的帳密(包裝為 LoginRequest),並回傳 access token 與 refresh token(包裝為 LoginResponse)。下面是這兩個類別的 model。
public class LoginRequest {
    private String username;
    private String password;
    
    // getter, setter ...
}
public class LoginResponse {
    private String accessToken;
    private String refreshToken;
    // getter, setter ...
}
接著使用 library 提供的方法,逐步將 JWS 產生出來。此處暫時只取用 LoginRequest 的帳號(username),而密碼(password)在第五節才會用到。
import java.time.Instant;
@Service
public class TokenService {
    // ...
    public LoginResponse createToken(LoginRequest request) {
        String accessToken = createAccessToken(request.getUsername());
        LoginResponse res = new LoginResponse();
        res.setAccessToken(accessToken);
        return res;
    }
    private String createAccessToken(String username) {
        // 有效時間(毫秒)
        long expirationMillis = Instant.now()
                .plusSeconds(90)
                .getEpochSecond()
                * 1000;
        // 設置標準內容與自定義內容
        Claims claims = Jwts.claims();
        claims.setSubject("Access Token");
        claims.setIssuedAt(new Date());
        claims.setExpiration(new Date(expirationMillis));
        claims.put("username", username);
        // 簽名後產生 token
        return Jwts.builder()
                .setClaims(claims)
                .signWith(secretKey)
                .compact();
    }
}
在範例程式中,首先計算出到期時間為 90 秒之後。再提供幾個 JWT 的標準欄位,包含主旨、核發時間、到期時間。最後將帳號(username)做為自定義內容放入 JWT。簽名之後就產生出來了。
此段用來產生
secretKey的字串,與過期時間的相關參數,可放置於application.properties檔案中,便於抽換。
讓我們來測試一下效果。請在 controller 建立一個 REST API,以 LoginRequest 接收帳密後,呼叫 TokenService 產生 token。
@RestController
public class DemoController {
    // ...
    
    @Autowired
    private TokenService tokenService;
    
    @PostMapping("/auth/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        LoginResponse res = tokenService.createToken(request);
        return ResponseEntity.ok(res);
    }
}
附帶一提,如果在 Spring Security 的配置程式中(SecurityConfig),沒有將此 API 開放給任何人存取,別忘了去調整配置。筆者為了方便後續幾篇文章的示範,故直接將其餘 API 設為「permitAll」(允許任何人)比較快。
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .authorizeHttpRequests(registry ->
                    registry
                            ...
                            .anyRequest().permitAll()
            )
            ...
    return http.build();
}
另外,此處使用
formLogin方法啟用的預設登入畫面,已經可以停用了。
透過 Postman 發送請求到該 API 的結果如下圖。
實作完產生 JWT 的程式後,這節讓我們去解析它。
請再利用剛剛建立的 secretKey 密鑰,建立出 JwtParser 物件。它的用途是將 JWT 解析成原本的內容。同時也會驗證 JWT 是否被竄改。
@Service
public class TokenService {
    private Key secretKey;
    private JwtParser jwtParser;
    
    @PostConstruct
    private void init() {
        // ...
        jwtParser = Jwts.parserBuilder().setSigningKey(secretKey).build();
    }
    
    public Map<String, Object> parseToken(String token) {
        Claims claims = jwtParser.parseClaimsJws(token).getBody();
        return new HashMap<>(claims);
    }
}
特別的是,放置 JWT 內容的 Claims 物件,本身有實作 Map。故筆者也直接以 Map 回傳,使用上較為熟悉。
若解析 token 時已經過期了,
JwtParser會拋出io.jsonwebtoken.ExpiredJwtException的例外。但我們仍可呼叫該例外物件的getClaims方法取出 JWT 的內容。
請於 controller 再建立一個 REST API,以「Authorization」這個 request header 接收 token,並呼叫 TokenService 進行解析。
public class DemoController {
    // ...
    @Autowired
    private TokenService tokenService;
    @GetMapping("/parse-token")
    public ResponseEntity<Map<String, Object>> parseToken(@RequestHeader(HttpHeaders.AUTHORIZATION) String authorization) {
        Map<String, Object> jwtPayload = tokenService.parseToken(authorization);
        return ResponseEntity.ok(jwtPayload);
    }
}
透過 Postman 進行測試時,要記得在 header 攜帶 token。發送請求到該 API 的結果如下圖。
第三節已經實作了產生 JWT 的程式,而本節要加入帳號密碼認證的環節,預期登入時帳密需正確,才能產生 token。
上一篇文章有在 Spring Security 的登入畫面,輸入帳密來登入。當時底層便是倚賴 AuthenticationProvider 元件來做身份認證。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // ...
    @Bean
    public AuthenticationProvider authenticationProvider(
            UserDetailsService userDetailsService,
            BCryptPasswordEncoder passwordEncoder
    ) {
        var provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder);
        return provider;
    }
}
接下來請將 AuthenticationProvider 注入到 TokenService,讓它幫助我們在 createToken 方法中實現身份認證。
@Service
public class TokenService {
    @Autowired
    private AuthenticationProvider authenticationProvider;
    
    public LoginResponse createToken(LoginRequest request) {
        // 封裝帳密
        Authentication authToken = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
        
        // 執行帳密認證
        authToken = authenticationProvider.authenticate(authToken);
        
        // 認證成功後取得結果
        UserDetails userDetails = (UserDetails) authToken.getPrincipal();
        // 產生 token
        String accessToken = createAccessToken(userDetails.getUsername());
        // ...
    }
    // ...
}
範例程式的流程如下:
Authentication 介面的物件,實作類別為 UsernamePasswordAuthenticationToken。Authentication 物件傳遞給 AuthenticationProvider 做認證。AuthenticationProvider(實作類別為 DaoAuthenticationProvider)底層會使用 UserDetailsService 與 PasswordEncoder,做帳密的比對。UsernamePasswordAuthenticationToken,但其內容會換成 UserDetails。UserDetails,做後續運用。最後讀者可繼續透過 Postman 測試。若發送請求到 POST /auth/login 時,提供 UserRepository 不存在的帳密,會得到 HTTP 403(Forbidden)的狀態碼。
本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch18-1
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教
想詢問一下~
原先在Security內已經有將PasswordEncoder做成Bean了
AuthenticationProvider 建構式內的 BCryptPasswordEncoder passwordEncoder 是不是就不用了,然後改使用註冊完的那個Bean?~
嗨嗨!AuthenticationProvider 的建構式中,雖然 code 上的型態寫的是 BCryptPasswordEncoder,但其實注入進來的,依然是自己做成的 PasswordEncoder 這個 bean 喔
其實文中的範例程式,這樣寫會比較清楚
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationProvider authenticationProvider(
        UserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder
) {
    // ...
}
原來如此!感謝感謝!!!
注入的方式除了可用@Autowired也能用建構子的方式注入喔
之前都在抄網路範例,一直搞不太懂 AuthenticationManager 跟 AuthenticationProvider都有 authenticate,兩者拿來作帳密的驗證都是一樣的效果嗎?另外什麼時候會用到 AuthenticationManager?