iT邦幫忙

2023 iThome 鐵人賽

DAY 25
0
Software Development

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

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

  • 分享至 

  • xImage
  •  

上一篇的進度是使用第三方 library 產生 JWT。而本文將會在使用者的 model 類別中,額外添加一些代表帳號狀態的欄位。接著搭配自定義的 UserDetails 類別,達到「受限制的帳號無法通過認證」的目的。

最後再參考筆者工作經驗,於登入的 REST API,回傳更多資料給使用者,並實作 refresh token。

此篇亦轉載到個人部落格


六、自定義 UserDetails

(一)背景

在上一篇實作的登入 API(POST /auth/login),我們呼叫了 TokenService.createToken 方法。裡面透過 AuthenticationProvider.authenticate 方法進行帳密認證。

@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);

        // ...
    }
}

有些產品,可能會有「停用使用者」或「試用帳號到期」之類的帳號管理設計。那麼符合這些情況的帳號,應該視為「受限制的帳號」,照理說不該繼續取得 access token 並存取 API 才對。

本節的目的,是要在呼叫登入 API 時進行這方面的管控,使得 AuthenticationProvider 能夠判定認證失敗。

(二)程式實作

上述的 AuthenticationProvider(實作類別為 DaoAuthenticationProvider)會在做帳密認證時,呼叫 UserDetailsService.loadUserByUsername 方法,其回傳值是實作 UserDetails 介面的 User 物件。

還記得 Day 23 提到的 UserDetails 介面,提供了 4 個回傳帳號狀態的方法嗎?只要其中一個回傳 false,則認證就會失敗。

雖然在建立 User 物件時,可透過另一個 overloading 的建構子傳入這些狀態,但這樣有些麻煩。因此本段的目標是在使用者的 model 類別(AppUser),添加一些有關帳號狀態的欄位,並封裝到自定義的 UserDetails 物件中。

下面的範例是在 AppUser 額外添加「是否啟用」、「是否為高級會員」與「試用期結束日」三個欄位。

public class AppUser {
    // ...
    private boolean enabled = true;
    private boolean premium = false;
    private LocalDate trailExpiration;
    
    // getter, setter ...
}

接著建立一個名為 AppUserDetails 的類別,並實作 UserDetails 介面,將 AppUser 封裝在其中。

public class AppUserDetails implements UserDetails {
    private final AppUser appUser;

    public AppUserDetails(AppUser user) {
        this.appUser = user;
    }

    // 必須覆寫的方法
    public String getUsername() { return appUser.getEmail(); }
    public String getPassword() { return appUser.getPassword(); }
    public boolean isEnabled() { return appUser.isEnabled(); }
    public boolean isAccountNonLocked() { return true; }
    public boolean isCredentialsNonExpired() { return true; }

    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(appUser.getAuthority());
    }

    public boolean isAccountNonExpired() {
        if (appUser.getTrailExpiration() == null) {
            return true;
        }

        return LocalDate.now().isBefore(appUser.getTrailExpiration());
    }
    
    // 自定義的 public 方法
    public String getId() { return appUser.getId(); }
    public UserAuthority getUserAuthority() { return appUser.getAuthority(); }
    public boolean isPremium() { return appUser.isPremium(); }
    public LocalDate getTrailExpiration() { return appUser.getTrailExpiration(); }
}

上面使用 AppUserenabledtrailExpiration 欄位,覆寫了 UserDetailsisEnabledisAccountNonExpired 方法。並另外提供 4 個新方法,回傳 AppUser 的其他欄位,於本文第七節使用。

最後別忘了讓 UserDetailsService 回傳。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    // ...

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // ...
        return new AppUserDetails(appUser);
    }
}

(三)測試

接下來可以將測試用的使用者,設為「停用」或「試用期結束」。

public class AppUser {
    // ...

    public static AppUser getTestGuestUser() {
        var user = new AppUser();
        // ...

        user.enabled = false;
        user.trailExpiration = LocalDate.of(2022, 12, 31);

        return user;
    }
}

並對登入的 API 發出請求。

POST http://localhost:8080/auth/login
{
    "username": "ivy@gmail.com",
    "password": "000000"
}

結果會得到 HTTP 403(Forbidden)的狀態碼,證明帳密雖正確,卻因帳號狀態而認證失敗。

(四)自定義的好處

以筆者前公司的產品情境為例,每個使用者都會隸屬於某家公司。那麼一個使用者的狀態與權限,可能取決於:

  • 帳號是否被停用
  • 帳號是否已完成重設密碼
  • 公司是否已付費升級且尚未過期
  • 公司類型為賣家或買家

而「使用者」與「公司」,在 DB 中是不同種類的 entity。因此自定義 UserDetails 類別的好處,便是將使用者與公司的程式物件,以及帳號狀態的判斷邏輯,都封裝到裡面。同時也讓 UserDetailsService 的實作類別能夠少一些 code,專注自己職責。

若沿用內建的 User 類別,其建構子會有 7 個參數。光帳號狀態就佔了 4 個,code 寫起來並不簡潔。

還有另一個好處,那就是在登入 API 回傳更多資料時,實作上會更方便,請見第七節。

七、回傳更多資料給登入方

(一)背景

在前面的 TokenService.createToken 方法,透過 AuthenticationProvider 進行帳密認證後,得到了 UserDetails。接著又取出它的 username,產生 access token 後回傳給登入方。

根據筆者在前公司的經驗,登入 API 的 response body,還會附上其他關於使用者的資訊。例如使用者 id、email、權限等。這種做法,將有利於讓前端控制是否要顯示某些畫面,或者將其攜帶於 query string,有各種用途。

而本文的登入 API,其 response body 對應的類別為 LoginResponse,本節就讓我們在裡面多攜帶一點資訊。

(二)程式實作

以下是登入 API 的 response body。我們添加「使用者 id」、「Email」、「權限」、「是否為高級會員」與「試用期結束日」共 5 個欄位。

public class LoginResponse {
    // accessToken, refreshToken
    
    private String userId;
    private String email;
    private UserAuthority userAuthority;
    private boolean premium;
    private LocalDate trailExpiration;
    
    // getter, setter ...
}

TokenService.createToken 方法中,已經有得到 UserDetails 介面的物件了。且該物件的實作類別,也換成上一節自定義的 AppUserDetails

下面將進行 code 的調整,以 AppUserDetails 型態來接收認證成功的結果。這麼一來,我們就能從當中各個自定義的 public 方法,取得各種需要的資訊,放進 LoginResponse 了。

public LoginResponse createToken(LoginRequest request) {
    // 帳密認證
    Authentication authToken = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
    authToken = authenticationProvider.authenticate(authToken);
    
    // 以自定義的 UserDetails 接收已認證的使用者
    AppUserDetails userDetails = (AppUserDetails) authToken.getPrincipal();
        
    // 產生 token
    String accessToken = createAccessToken(userDetails.getUsername());

    // 添加更多資料給登入方
    LoginResponse res = new LoginResponse();
    res.setAccessToken(accessToken);
    res.setUserId(userDetails.getId());
    res.setEmail(userDetails.getUsername());
    res.setUserAuthority(userDetails.getUserAuthority());
    res.setPremium(userDetails.isPremium());
    res.setTrailExpiration(userDetails.getTrailExpiration());

    return res;
}

八、實作 Refresh Token

關於 JWT 的介紹與實作,以及帳密認證,就到上一節為止。本文的最後主要是想補上未完成的 refresh token。

(一)產生 Refresh Token

以下的範例程式是在 TokenService 宣告一個方法,它會接收帳號名稱,再產生一組 refresh token。並用於登入時所呼叫的 createToken 方法。

@Service
public class TokenService {
    // ...

    public LoginResponse createToken(LoginRequest request) {
        // ...

        String refreshToken = createRefreshToken(userDetails.getUsername());

        LoginResponse res = new LoginResponse();
        res.setRefreshToken(refreshToken);
        
        // ...
    }

    private String createRefreshToken(String username) {
        long expirationMillis = Instant.now()
                .plusSeconds(600)
                .getEpochSecond()
                * 1000;

        Claims claims = Jwts.claims();
        claims.setSubject("Refresh Token");
        claims.setIssuedAt(new Date());
        claims.setExpiration(new Date(expirationMillis));
        claims.put("username", username);

        return Jwts.builder()
                .setClaims(claims)
                .signWith(secretKey)
                .compact();
    }
}

Refresh token 的內容(payload),會包含產生 access token 時所需要的資料。由於只需要帳號名稱,所以上面的範例程式就只有放入 username。此外,refresh token 的有效時間相對較長,此處設為 600 秒。

(二)換發 Access Token

以下的範例程式,用途是接收 refresh token,並回傳新產生的 access token。

@Service
public class TokenService {
    // ...

    public String refreshAccessToken(String refreshToken) {
        Map<String, Object> payload = parseToken(refreshToken);
        String username = (String) payload.get("username");
        
        return createAccessToken(username);
    }
}

首先會解析 refresh token,還原成原本的內容。接著再取出 username 的資料,用以產生 access token。

最後再準備一個 API,讓他人呼叫即可。

@RestController
public class DemoController {

    @Autowired
    private TokenService tokenService;
    
    // ...

    @PostMapping("/auth/refresh-token")
    public ResponseEntity<Map<String, String>> refreshAccessToken(@RequestBody Map<String, String> request) {
        String refreshToken = request.get("refreshToken");
        String accessToken = tokenService.refreshAccessToken(refreshToken);
        Map<String, String> res = Map.of("accessToken", accessToken);

        return ResponseEntity.ok(res);
    }
}

為了方便,此處的 request 與 response body 並未設計的 model 類別,僅僅用 Map 代替。以下是 API request 的示意內容。

POST http://localhost:8080/auth/refresh-token
{
    "refreshToken": "..."
}

而 response 內容如下。

{
    "accessToken": "..."
}

本文的完成專案:
https://chikuwa-tech-study.blogspot.com/2023/11/httpschikuwa-tech-study.blogspot.com202311spring-boot-username-password-authentication-and-jwt-part2.html.html


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


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

尚未有邦友留言

立即登入留言