上一篇的進度是使用第三方 library 產生 JWT。而本文將會在使用者的 model 類別中,額外添加一些代表帳號狀態的欄位。接著搭配自定義的 UserDetails
類別,達到「受限制的帳號無法通過認證」的目的。
最後再參考筆者工作經驗,於登入的 REST API,回傳更多資料給使用者,並實作 refresh token。
此篇亦轉載到個人部落格。
在上一篇實作的登入 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(); }
}
上面使用 AppUser
的 enabled
與 trailExpiration
欄位,覆寫了 UserDetails
的 isEnabled
與 isAccountNonExpired
方法。並另外提供 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;
}
關於 JWT 的介紹與實作,以及帳密認證,就到上一節為止。本文的最後主要是想補上未完成的 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 秒。
以下的範例程式,用途是接收 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": "..."
}
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教