iT邦幫忙

2023 iThome 鐵人賽

DAY 24
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 24

【Spring Security】核發 JWT 並結合帳密認證(上)

  • 分享至 

  • xImage
  •  

完成 REST API 的授權規則後,前端或其他 client 若想存取這些受保護的 API,勢必要出示某種證明,來表示自己有資格。實現這件事的第一步,便是製作出這個「證明」。

本文將會先介紹 token 的概念,接著實作產生 JWT 的程式。最後再加入帳號密碼認證的環節,設計出「經由登入取得 token」的 API。

此篇在 2024 年於「【Spring Boot】第17.5課-將 Spring Security 與 JWT 結合,實作登入 API」文章更新。


一、Token 的用途

讀者是否有在學校圖書館借書時要刷學生證,或是在公司搭電梯、進辦公室時要刷門禁卡的經驗?由於圖書館的書和公司的設施,不會開放給外人使用,因此需要出示這種「識別證」,來表明自己是誰。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 的構成

(一)介紹

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 的標準也舉例了一些資料種類,讓內容更齊全,但不是必需的。例如:

  • iss:issuer,核發者
  • nbf:not before,生效時間
  • exp:expiration time,到期時間
  • sub:subject,主旨
{
    "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 的程式。在 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 的結果如下圖。
https://ithelp.ithome.com.tw/upload/images/20231004/20131107SNCAoM0crE.jpg

四、實作解析 JWT

實作完產生 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 的結果如下圖。
https://ithelp.ithome.com.tw/upload/images/20231004/20131107BbzGgcdU4F.jpg

五、結合帳密認證產生 Token

第三節已經實作了產生 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());

        // ...
    }

    // ...
}

範例程式的流程如下:

  1. 將帳號與密碼(明文)包裝為 Authentication 介面的物件,實作類別為 UsernamePasswordAuthenticationToken
  2. Authentication 物件傳遞給 AuthenticationProvider 做認證。
  3. 認證時,AuthenticationProvider(實作類別為 DaoAuthenticationProvider)底層會使用 UserDetailsServicePasswordEncoder,做帳密的比對。
  4. 若帳密無誤,可拿到新的 UsernamePasswordAuthenticationToken,但其內容會換成 UserDetails
  5. 取出 UserDetails,做後續運用。

最後讀者可繼續透過 Postman 測試。若發送請求到 POST /auth/login 時,提供 UserRepository 不存在的帳密,會得到 HTTP 403(Forbidden)的狀態碼。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch18-1


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Spring Security】實作身份認證與 API 存取授權
下一篇
【Spring Security】核發 JWT 並結合帳密認證(下)
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
thenkyle
iT邦新手 5 級 ‧ 2024-01-22 11:49:21

想詢問一下~
原先在Security內已經有將PasswordEncoder做成Bean了
AuthenticationProvider 建構式內的 BCryptPasswordEncoder passwordEncoder 是不是就不用了,然後改使用註冊完的那個Bean?~

Chikuwa iT邦新手 2 級 ‧ 2024-01-22 23:27:36 檢舉

嗨嗨!
AuthenticationProvider 的建構式中,雖然 code 上的型態寫的是 BCryptPasswordEncoder,但其實注入進來的,依然是自己做成的 PasswordEncoder 這個 bean 喔

其實文中的範例程式,這樣寫會比較清楚

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

@Bean
public AuthenticationProvider authenticationProvider(
        UserDetailsService userDetailsService,
        PasswordEncoder passwordEncoder
) {
    // ...
}
thenkyle iT邦新手 5 級 ‧ 2024-01-23 11:00:34 檢舉

原來如此!感謝感謝!!!

注入的方式除了可用@Autowired也能用建構子的方式注入喔

0
凱文大叔
iT邦新手 4 級 ‧ 2024-01-26 16:41:21

之前都在抄網路範例,一直搞不太懂 AuthenticationManagerAuthenticationProvider都有 authenticate,兩者拿來作帳密的驗證都是一樣的效果嗎?另外什麼時候會用到 AuthenticationManager?

我要留言

立即登入留言