接續應用系統專案(8-1) - Spring Boot Security 設定與認證的-基礎準備 (URL: https://ithelp.ithome.com.tw/articles/10398800 ,可能因為內容太長,存檔時跳出廣告及垃圾訊息,以及後續部分程式碼會截掉,所以不得已分配在不同文件, 這裡介紹 Spring boot Srcurity 的 JWT 及 OAuth2 認證設定與認證範例,同樣以程式內的註解說明該程式的功能。
主要目錄與程式
├── jwttoken/
│ ├── JwtUtils (JWT 產生/驗證工具)
│ ├── JwtAuthenticationFilter (JWT Token認證過濾器,確認Token並取得授權相關資料)
│ ├── JwtAuthenticationLoginApi (JWT 登入認證與簽發Token的API)
│ ├── JwtSystemApiTestController (測試驗證 JWT API的功能,包含@PreAuthorize測試用 API)
│ └── JwtAuthRolesExceptionHandler (Method授權Role異常處理)
├── oauth2/
│ ├── OAuth2AuthenticationClient (OAuth2.0 認證)
│ ├── OAuth2UserService (將 Google 與 GitHub的 email 作為 principal)
│ ├── OAuth2JwtConverter (OAuth2.0 簽發Token對應至系統內的使用者)
│ ├── OAuth2FailureHandler (OAuth2.0 失敗處理)
│ └── OAuth2SuccessHandler (OAuth2.0 成功處理)
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtUtils.java
package tw.lewishome.webapp.base.security.jwttoken;
import static java.util.stream.Collectors.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import javax.crypto.SecretKey;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecureDigestAlgorithm;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.GlobalConstants;
/**
* JwtUtils 負責處理 JWT Token 的產生、解析與驗證。
*
* 此類別主要提供以下功能:
* <ul>
* <li>根據使用者認證資訊產生 JWT Token。</li>
* <li>解析 JWT Token 並取得授權資訊。</li>
* <li>驗證 JWT Token 是否有效。</li>
* <li>從 HttpServletRequest 取得 Bearer JWT Token。</li>
* </ul>
*
* 主要欄位說明:
* <ul>
* <li>{@code AUTHORITIES_KEY}:JWT 權限欄位名稱。</li>
* <li>{@code USERNAME}:JWT 使用者名稱欄位名稱。</li>
* <li>{@code JWT_ISSUE}:JWT 發行者。</li>
* <li>{@code SUBJECT}:JWT 主題。</li>
* <li>{@code ACCESS_EXPIRED}:JWT 有效時間(秒)。</li>
* <li>{@code SECRET}:JWT 金鑰字串。</li>
* <li>{@code KEY}:JWT 簽章用金鑰。</li>
* <li>{@code ALGORITHM}:JWT 簽章演算法。</li>
* </ul>
*
*
* 方法說明:
* <ul>
* <li>{@link #createJwtToken(Authentication)} 根據認證資訊產生 JWT Token。</li>
* <li>{@link #getJwtAuthentication(String)} 解析 JWT Token 並取得授權資訊。</li>
* <li>{@link #validateJwtToken(String)} 驗證 JWT Token 是否有效。</li>
* <li>{@link #getBearJWTToken(HttpServletRequest)} 從 HttpServletRequest 取得
* Bearer JWT Token。</li>
* </ul>
*
*
* 注意事項:
* <ul>
* <li>每次啟動程式時會重新產生金鑰 {@code SECRET},請確保金鑰一致性以避免 Token 無法驗證。</li>
* <li>JWT Token 內含使用者角色資訊,並自動加入 {@code ROLE_API_USER} 權限。</li>
* <li>Token 驗證時會自動檢查過期時間。</li>
* </ul>
*
* @author Lewis
*/
@Slf4j
public class JwtUtils {
/** Private constructor to prevent instantiation */
JwtUtils() {
throw new IllegalStateException("This is a utility class and cannot be instantiated");
}
private static final String AUTHORITIES_KEY = "roles";
private static final String USER_ID = "userId";
private static final String JWT_ISSUE = "https://webapp.lewishome.tw";
private static final String SUBJECT = "JwtAuthorization";
private static final String ACCESS_EXPIRED = "3600";
private static final String SECRET = GlobalConstants.JWT_SECRET;
// 每次開機時,啟動程式會重新產生金鑰SECRET
private static final SecretKey KEY = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
private static SecureDigestAlgorithm<SecretKey, SecretKey> secureAlgorithm = Jwts.SIG.HS256;
/**
* 根據使用者認證資訊產生簽發 JWT Token。
*
* @param authentication Authentication
* @return String JWT token string
*/
public static String createJwtToken(Authentication authentication) {
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// var claimsBuilder = Jwts.claims().subject(username);
var claimsBuilder = Jwts.claims().subject(SUBJECT);
if (!authorities.isEmpty()) {
String userRoles = authorities.stream().map(GrantedAuthority::getAuthority).collect(joining(","));
userRoles = userRoles.toUpperCase() + "," + "ROLE_API_USER";
claimsBuilder.add(AUTHORITIES_KEY, userRoles);
claimsBuilder.add(USER_ID, username);
}
var claims = claimsBuilder.build();
Date expireDate = Date.from(Instant.now().plusSeconds(Long.parseLong(ACCESS_EXPIRED)));
return Jwts.builder()
.claims(claims)
.issuedAt(new Date())
.expiration(expireDate)
.subject(SUBJECT)
.issuer(JWT_ISSUE)
.signWith(KEY, secureAlgorithm)
.compact();
}
/**
* 根據 JWT Token 解析並取得授權資訊
*
* @param token jwt Token String
* @return Authentication 授權
*/
public static Authentication getJwtAuthentication(String token) {
Claims claims = Jwts.parser().verifyWith(KEY).build().parseSignedClaims(token).getPayload();
Object authoritiesClaim = claims.get(AUTHORITIES_KEY);
Collection<? extends GrantedAuthority> authorities = authoritiesClaim == null ? AuthorityUtils.NO_AUTHORITIES
: AuthorityUtils.commaSeparatedStringToAuthorityList(authoritiesClaim.toString());
// User principal = new User(claims.getSubject(), "", authorities);
String userId = (String) claims.get(USER_ID);
User principal = new User(userId, "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
/**
* 驗證 JWT Token 是否有效
*
* @param token jwt Token String
* @return boolean 認證
*/
public static boolean validateJwtToken(String token) {
try {
Jws<Claims> claims = Jwts
.parser().verifyWith(KEY).build()
.parseSignedClaims(token);
// parseClaimsJws will check expiration date. No need do here.
log.info("expiration date: {}", claims.getPayload().getExpiration());
return true;
} catch (JwtException | IllegalArgumentException e) {
log.error("Invalid JWT token: {}", e.getMessage());
}
return false;
}
/**
* 從 HttpServletRequest 取得 Bear JWT Token
*
* @param request HttpServletRequest
* @return String jwt Bearer Token String
*/
public static String getBearJWTToken(HttpServletRequest request) {
String jwtToken = request.getHeader("authorization");
if (StringUtils.isNotBlank(jwtToken) && validateJwtToken(jwtToken)) {
return jwtToken;
}
return null;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtLoginRequestModel.java
package tw.lewishome.webapp.base.security.jwttoken;
import java.io.Serializable;
import lombok.Data;
/**
* JwtLoginRequestModel 用於封裝 JWT 登入請求的資料模型。
*
* 此類別包含使用者名稱、密碼以及 JWT Token 等欄位,
* 主要用於登入流程中,將使用者輸入的資訊傳遞至後端進行驗證。
*
* 欄位說明:
* <ul>
* <li><b>username</b>:使用者名稱,作為登入識別。</li>
* <li><b>password</b>:使用者密碼,需妥善保護避免外洩。</li>
* <li><b>token</b>:JWT Token,登入成功後回傳的驗證令牌。</li>
* </ul>
*
* 實作 Serializable 介面,方便物件序列化與傳輸。
*
* @author Lewis
* @version 1.0
*/
@Data
public class JwtLoginRequestModel implements Serializable {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
*
* Constructs a new JwtLoginRequestModel instance.
* This is the default constructor, implicitly provided by the compiler
* if no other constructors are defined.
*/
public JwtLoginRequestModel() {
// Constructor body (can be empty)
}
private static final long serialVersionUID = 1L;
/** user name */
private String username;
/** user Password */
private String password;
/** jwtToken */
private String token;
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtLoginResponseModel.java
package tw.lewishome.webapp.base.security.jwttoken;
import java.io.Serializable;
import lombok.Data;
/**
*JwtLoginResponseModel 是用於封裝 JWT 登入回應資料的模型類別。
*此類別包含使用者名稱以及伺服器產生的 Bearer Token,並可序列化以便於在網路傳輸或儲存時使用。
*
*主要欄位說明:
* <ul>
* <li><b>userId</b>:使用者名稱,通常為登入帳號。</li>
* <li><b>bearerToken</b>:JWT Bearer Token,代表使用者的授權資訊。</li>
* </ul>
*
*建構子說明:
* <ul>
* <li>
* <b>JwtLoginResponseModel(String userId, String bearerToken)</b>:<br>
* 建立 JwtLoginResponseModel 實例,並初始化使用者名稱與 Bearer Token。
* </li>
* </ul>
*
*範例用途:
* <pre>
* JwtLoginResponseModel response = new JwtLoginResponseModel("user1", "eyJhbGciOiJIUzI1NiIsInR...");
* </pre>
*
* @author Lewis
* @version 1.0
* @since 2024-06
*/
@Data
public class JwtLoginResponseModel implements Serializable {
private static final long serialVersionUID = 1L;
// id(username), bearer Token(roles)
/** user name */
private String userId;
/** generated Bearer Token */
private String bearerToken;
/**
*Constructor for JwtLoginResponseModel.
*
* @param userId user name
* @param bearerToken jwt Bearer Token
*/
public JwtLoginResponseModel(String userId, String bearerToken) {
this.userId = userId;
this.bearerToken = bearerToken;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtAuthenticationFilter.java
package tw.lewishome.webapp.base.security.jwttoken;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.utility.common.FileUtils;
/**
*
* JwtAuthenticationFilter 是一個專為 Spring Security 設計的 JWT 驗證過濾器,繼承自
* {@link OncePerRequestFilter},
* 主要負責攔截特定 API 路徑(如 {@code /systemApi/**}、{@code /systemApiTest/**})並執行 JWT
* Token 驗證。
*
*
*
* 此過濾器運作流程如下:
* <ul>
* <li>每次 HTTP 請求進入時,先判斷請求路徑是否在 {@code DoJwtAuthTokenFilterUrls} 列表中。</li>
* <li>若路徑符合,則嘗試從 Header 取得 JWT Token(通常於 Authorization Bearer 欄位)。</li>
* <li>利用 {@link JwtUtils} 驗證 Token 的有效性與合法性。</li>
* <li>若驗證成功,建立 {@link Authentication} 物件並設置至
* {@link SecurityContextHolder},讓後續安全性檢查可取得認證資訊。</li>
* <li>若驗證失敗,則可記錄存取日誌(程式碼已預留日誌區塊,包含 Session、IP、主機名稱、請求路徑、Token 等資訊)。</li>
* <li>無論驗證結果如何,皆會繼續執行 Filter Chain,確保請求流程不中斷。</li>
* </ul>
*
*
* <h2>主要設計細節:</h2>
* <ul>
* <li>僅對 {@code DoJwtAuthTokenFilterUrls} 列表中的路徑進行 JWT 驗證,其他路徑不受影響。</li>
* <li>採用 {@link AntPathMatcher} 進行路徑比對,支援萬用字元。</li>
* <li>驗證成功後,將認證資訊設置至 SecurityContext,讓 Spring Security 可辨識使用者身分。</li>
* <li>驗證失敗時,預設僅記錄日誌,不會阻斷請求(可依需求調整行為)。</li>
* <li>日誌記錄區塊已註解,開啟後可追蹤存取紀錄,利於安全稽覈。</li>
* </ul>
*
* <h2>使用建議:</h2>
* <ul>
* <li>適用於需以 JWT 驗證保護 API 路徑的 Spring Boot 應用程式。</li>
* <li>可依實際需求擴充驗證邏輯或日誌記錄功能。</li>
* <li>建議搭配自訂的 {@link JwtUtils} 使用,以確保 Token 驗證流程符合專案需求。</li>
* </ul>
*
* <h2>範例用途:</h2>
*
* <pre>
* 路徑,僅允許持有有效 JWT Token 的使用者存取。
* </pre>
*
* @author Lewis
*/
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
*
* Constructs a new JwtAuthenticationLoginApi instance.
* This is the default constructor, implicitly provided by the compiler
* if no other constructors are defined.
*/
public JwtAuthenticationFilter() {
// Constructor body (can be empty)
}
// https://springframework.guru/using-filters-in-spring-web-applications/
// private SysAccessLogRepository sysAccessLogRepository;
/** List of JWTAuth URL 只有以下URL需要做 JWT檢核。 */
private static final List<String> DO_JWT_AUTH_TOKEN_FILTER_URLS = SecurityConstants.DO_JWT_AUTH_TOKEN_FILTER_URLS;
private static final List<String> DO_NOT_FILTER_EXTENSION = new ArrayList<>(Arrays.asList(
"js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot", "otf"));
/** */
@Override
public void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
String jwtToken = JwtUtils.getBearJWTToken(request);
Authentication jwtAuthentication = JwtUtils.getJwtAuthentication(jwtToken);
if (jwtAuthentication != null
&& Boolean.FALSE.equals(jwtAuthentication instanceof AnonymousAuthenticationToken)) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(jwtAuthentication);
SecurityContextHolder.setContext(context);
}
}
} catch (Exception e) {
log.error("JWT Token Authentication Failed", e);
} finally {
filterChain.doFilter(request, response);
}
}
/** */
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) throws ServletException {
String requestNoPathUrl = request.getRequestURI().substring(request.getContextPath().length());
// filter by extension list
String extension = FileUtils.getFileNameSuffix(requestNoPathUrl);
if (extension != null && DO_NOT_FILTER_EXTENSION.contains(extension.replace(".", ""))) {
return true;
}
AntPathMatcher pathMatcher = new AntPathMatcher();
Boolean isDoNotFilterURI = false;
// get URI without context Path which one in the DO_JWT_AUTH_TOKEN_FILTER_URLS
// List
System.out.println("requestNoPathUrl:" + requestNoPathUrl);
for (int i = 0; i < DO_JWT_AUTH_TOKEN_FILTER_URLS.size(); i++) {
String oneFilterUrls = DO_JWT_AUTH_TOKEN_FILTER_URLS.get(i);
if (requestNoPathUrl != null && oneFilterUrls != null) {
isDoNotFilterURI = pathMatcher.match(oneFilterUrls, requestNoPathUrl);
}
if (isDoNotFilterURI) {
break;
}
}
// isDoNotFilterURI 表示有 Match , 回傳 false 不做 Filter
if (isDoNotFilterURI == true) {
return false;
}
return true;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtAuthenticationLoginApi.java
package tw.lewishome.webapp.base.security.jwttoken;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.ResponseEntity.BodyBuilder;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import tw.lewishome.webapp.base.security.CustomAuthenticationProvider;
import tw.lewishome.webapp.base.security.audit.SysSecurityAuditLogService;
/**
* JwtAuthenticationController 負責處理 JWT 登入授權流程,並核發 Bearer Token。
*
* 主要功能:
* <ul>
* <li>接收使用者登入請求,驗證帳號密碼。</li>
* <li>成功驗證後,產生 JWT Token 並回傳給前端。</li>
* <li>記錄登入行為至系統存取紀錄(SysAccessLogEntity)。</li>
* <li>若登入失敗,回傳 403 Forbidden 並記錄失敗行為。</li>
* </ul>
*
*
* 方法說明:
*
* <pre>
* authenticateUser(JwtLoginRequestModel loginRequest, HttpServletRequest request)
* </pre>
* <ul>
* <li>接收登入請求,驗證帳號密碼。</li>
* <li>成功則回傳 JWT Token,失敗則回傳 403 Forbidden。</li>
* <li>皆會記錄存取行為。</li>
* </ul>
*
* 注意事項:
* <ul>
* <li>帳號會自動轉為大寫後進行驗證。</li>
* <li>登入成功與失敗皆會記錄存取行為,包含 IP、SessionId、使用者名稱等資訊。</li>
* </ul>
*
* @author Lewis
* @author lewis
* @since 2024
*/
@RestController
@RequestMapping("/jwtAuth")
public class JwtAuthenticationLoginApi {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
*
* Constructs a new JwtAuthenticationLoginApi instance.
* This is the default constructor, implicitly provided by the compiler
* if no other constructors are defined.
*/
public JwtAuthenticationLoginApi() {
// Constructor body (can be empty)
}
@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;
@Autowired
private SysSecurityAuditLogService sysSecurityAuditLogService;
/**
* 提供 JWT 登入授權程序 , 核發 bearerToken
*
* @param loginRequest JWT登入用 RequestBody 物件
* @param request HttpServletRequest
* @return ResponseEntity ResponseEntity
*/
@RequestMapping("/signin")
// @GetMapping("/signin")
public ResponseEntity<?> authenticateUser(@Validated @RequestBody JwtLoginRequestModel loginRequest,
HttpServletRequest request) {
String username = loginRequest.getUsername();
String password = loginRequest.getPassword();
try {
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
throw new BadCredentialsException("invalid login details");
} else {
String upperUserName = username.toUpperCase();
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(upperUserName,
password);
// 紀錄 API Login 時的 Request( remote IP) Audit AuthenticationSuccessEvent 才能取的 Detail;
// https://stackoverflow.com/questions/4664893/how-to-manually-set-an-authenticated-user-in-spring-security-springmvc
token.setDetails(new WebAuthenticationDetails(request));
Authentication authentication = customAuthenticationProvider.authenticate(token);
if (authentication.isAuthenticated()) {
SecurityContextHolder.getContext().setAuthentication(authentication);
String bearerToken = JwtUtils.createJwtToken(authentication);
BodyBuilder bodyBuilder = ResponseEntity.ok();
if (MediaType.APPLICATION_JSON != null) {
bodyBuilder = bodyBuilder.contentType(MediaType.APPLICATION_JSON);
}
// System.out.println("JWT Login Success");
sysSecurityAuditLogService.addJWTAccessLog(request, username, "JWT Login Success");
return bodyBuilder.body(new JwtLoginResponseModel(username, bearerToken));
}
}
} catch (Exception ex) {
sysSecurityAuditLogService.addJWTAccessLog(request, username, "JWT Login Failed");
return new ResponseEntity<String>("The String ResponseBody with custom status code (403 Forbidden)",
HttpStatus.FORBIDDEN);
}
return null;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtSystemApiTestController.java
package tw.lewishome.webapp.base.security.jwttoken;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
/**
* JWT Token 測試
* 需要於 WebSecurityConfig 的CSRF_ENDPOINTS_WHITELIST
/**
* ",因為API檢查JTW Token 而不是檢查 CSRF 。
*
* @author lewis
* @version $Id: $Id
*/
/**
* JwtSystemApiTestController 負責處理系統 API 測試相關的 RESTful 請求。
*
* 此控制器主要用於測試 JWT 權杖驗證機制,並提供 API 端點讓前端或其他系統可依據指定的 jobId 及功能名稱(function)進行測試呼叫。
*
*
*
* 路徑範例:<br>
* <code>/systemApiTest/test/{jobId}/{function}</code>
*
*
*
* 主要功能說明:
* <ul>
* <li>接收 URL 路徑中的 <b>jobId</b> 與 <b>function</b> 參數,並於後端進行相對應的測試處理。</li>
* <li>可用於驗證 JWT 權限、API 呼叫流程、或測試不同 jobId 與功能組合的行為。</li>
* <li>此 API 需於 <code>WebSecurityConfig</code> 的
* <code>CSRF_ENDPOINTS_WHITELIST</code> 進行白名單設定,因為本 API 主要驗證 JWT Token 而非 CSRF
* Token。</li>
* <li>適合於開發階段進行 API 測試、權限驗證、或系統整合測試。</li>
* </ul>
*
*
*
* 注意事項:
* <ul>
* <li>請確保呼叫此 API 時已正確攜帶 JWT Token,否則將無法通過權限驗證。</li>
* <li>此控制器僅供測試用途,正式環境請勿暴露測試端點。</li>
* <li>需要於 WebSecurityConfig 的CSRF_ENDPOINTS_WHITELIST</li>
* </ul>
*
*
*
* @author Lewis
* @since 2024
*/
@RestController
@RequestMapping("/systemApiTest")
@Slf4j
public class JwtSystemApiTestController {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
*
* Constructs a new JwtSystemApiTestController instance.
* This is the default constructor, implicitly provided by the compiler
*/
public JwtSystemApiTestController() {
// Constructor body (can be empty)
}
/**
*
* callApiTest.
*
*
* @param jobId parm jobId
* @param func parm Fuc
* @return ResponseEntity Jason Array String
*/
@RequestMapping("/test/{jobId}/{function}")
@PreAuthorize("hasRole('API_USERX')")
public ResponseEntity<?> callApiTest(@PathVariable("jobId") String jobId,
@PathVariable("function") String func) {
log.info("test JobId & func Value ==> {}:{}", jobId, func);
return null;
}
/**
*
* callApiTest.
*
*
* @param jobId parm jobId
* @param func parm Fuc
* @return ResponseEntity Jason Array String
*/
@RequestMapping("/test2/{jobId}/{function}")
@PreAuthorize("hasRole('API_USER')")
public ResponseEntity<?> callApiTest2(@PathVariable("jobId") String jobId,
@PathVariable("function") String func) {
log.info("test JobId = {} & func Value ==> {} ", jobId, func);
return null;
}
/**
*
* callApiTest.
*
*
* @param jobId parm jobId
* @param func parm Fuc
* @return ResponseEntity Jason Array String
*/
@RequestMapping("/test3/{jobId}/{function}")
public ResponseEntity<?> callApiTest3(@PathVariable("jobId") String jobId,
@PathVariable("function") String func) {
log.info("test JobId = {} & func Value ==> {} ", jobId, func);
return null;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/jwttoken/JwtAuthRolesExceptionHandler.java
package tw.lewishome.webapp.base.security.jwttoken;
import java.io.IOException;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import tw.lewishome.webapp.base.security.audit.SysSecurityAuditLogService;
import tw.lewishome.webapp.base.utility.common.NetUtils;
import jakarta.servlet.http.HttpServletRequest;
/**
* 全域例外處理器,用於攔截因 Spring Security 註解(如 {@code @PreAuthorize})導致的
* {@link AccessDeniedException}。
*
* 當使用者存取資源時權限不足,會記錄存取日誌並回傳 403 Forbidden 回應。
*
*
* <ul>
* <li>記錄存取失敗的使用者、IP、Session、URL 等資訊至資料庫。</li>
* <li>輸出請求標頭與請求內容至標準錯誤。</li>
* <li>回應存取被拒訊息。</li>
* </ul>
*
* @author Lewis
*/
@RestControllerAdvice
public class JwtAuthRolesExceptionHandler {
/**
* Fix for javadoc warning :
* use of default constructor, which does not provide a comment
* Constructs a new JwtAuthRolesExceptionHandler instance.
* This is the default constructor, implicitly provided by the compiler
* if no other constructors are defined.
*/
public JwtAuthRolesExceptionHandler() {
// Constructor body (can be empty)
}
@Autowired
private SysSecurityAuditLogService sysSecurityAuditLogService;
// EnableMethodSecurity AccessDeniedException (PreAuthorize)
// for Method ==> @PreAuthorize("hasRole('API_USER')")
/**
* 處理當使用者存取被拒絕時(AccessDeniedException)的例外處理方法。
*
* 此方法會記錄存取失敗的相關資訊,包括使用者名稱、IP、存取 URL、Session ID 等, 並將存取紀錄儲存至資料庫。最後回傳 403
* Forbidden 狀態與錯誤訊息給前端。
*
*
* @param ex 被拒絕存取時拋出的 AccessDeniedException 例外
* @param request 當前的 HttpServletRequest 請求物件
* @return 回傳 403 Forbidden 狀態與錯誤訊息的 ResponseEntity
* @throws IOException IOException 當處理請求時發生輸入輸出錯誤時拋出
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<String> handleAccessDeniedException(AccessDeniedException ex, HttpServletRequest request)
throws IOException {
// SysSecurityAuditLogService sysSecurityAuditLogService = new SysSecurityAuditLogService()
sysSecurityAuditLogService.addNewAccessLog(request, "PreAuthorize Failed");
// Get the Authorization header
Map<String, Object> mapHeaderValues = NetUtils.getRequestHeaderAllValues(request);
System.err.println("Request Header: " + mapHeaderValues);
Map<String, Object> mapBodyValues = NetUtils.getRequestBodyAllValues(request);
System.err.println("Request Body: " + mapBodyValues);
return new ResponseEntity<>("Access Denied: You do not have the required role to access this resource.",
HttpStatus.FORBIDDEN);
}
// You can add other exception handlers here for other types of exceptions
}
OAuth2 Client的認證與授權 需要分別需要到Google 以及 Github網站註冊登記並設定
可以參考:
https://ithelp.ithome.com.tw/m/articles/10235929 for Google網站註冊登記設定
https://kucw.io/blog/2019/12/spring-oauth2-bind-github/ for Github網站註冊登記設定
位置:src/main/java/tw/lewishome/webapp/base/security/oauth2/OAuth2JwtConverter.java
package tw.lewishome.webapp.base.security.oauth2;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
/**
* OAuth2JwtConverter 負責將 OAuth2.0 提供商簽發的 JWT(ID token 或 access token)
* 轉換為系統內的使用者資訊與角色。
*
* 此元件解析 OAuth2.0 JWT 中的 standard claim(例如 sub、email、name、roles 等),
* 並將其對應至系統內的使用者概念(例如建立或更新 SysUserProfile)。
*
* 主要功能:
* <ul>
* <li>提取 OAuth2.0 JWT 中的標準 claim(sub、email、name 等)</li>
* <li>解析自訂 claim(例如 roles、groups 等)並轉換為系統角色</li>
* <li>支援多個 OAuth2.0 提供商(Google、Microsoft、自訂 OIDC 伺服器)</li>
* <li>將 OAuth2 使用者資訊同步至系統 SysUserProfile(可選)</li>
* </ul>
*
* 使用場景:
* <ul>
* <li>社群登入(Google、GitHub)claim 與系統用戶對應</li>
* <li>企業 OIDC / OAuth2.0 provider(Azure AD、Okta)整合</li>
* <li>JWT 中包含 roles 或 groups 的場景</li>
* </ul>
*
* @author Lewis
* @since 2024
*/
@Component
@Slf4j
public class OAuth2JwtConverter {
/**
* Fix for javadoc warning: default constructor
* Constructs a new OAuth2JwtConverter instance.
*/
public OAuth2JwtConverter() {
// Constructor body (can be empty)
}
/**
* 從 OAuth2AuthenticationToken 提取授權角色
*
* @param oauth2Token
* OAuth2AuthenticationToken
* @return Collection of GrantedAuthority
*/
public Collection<GrantedAuthority> extractAuthorities(OAuth2AuthenticationToken oauth2Token) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
if (oauth2Token == null || oauth2Token.getPrincipal() == null) {
return authorities;
}
// 加入預設角色
authorities.add(new SimpleGrantedAuthority("ROLE_OAUTH2_USER"));
OAuth2User principal = oauth2Token.getPrincipal();
// 嘗試從 attributes 中提取 roles
Object rolesAttr = principal.getAttribute("roles");
if (rolesAttr instanceof List) {
List<?> roles = (List<?>) rolesAttr;
for (Object role : roles) {
if (role instanceof String) {
String roleStr = (String) role;
if (!roleStr.startsWith("ROLE_")) {
roleStr = "ROLE_" + roleStr;
}
authorities.add(new SimpleGrantedAuthority(roleStr));
}
}
}
// 嘗試從 scope 中提取角色
if (authorities.isEmpty()) {
String scope = principal.getAttribute("scope");
if (scope != null) {
String[] scopes = scope.split(" ");
for (String s : scopes) {
if (s.startsWith("role.")) {
String role = "ROLE_" + s.substring(5).toUpperCase();
authorities.add(new SimpleGrantedAuthority(role));
}
}
}
}
return authorities;
}
/**
* 從 OAuth2 principal 提取電子郵件
*
* @param principal
* OAuth2User
* @return 電子郵件字串,若不存在回傳 null
*/
public String extractEmail(OAuth2User principal) {
if (principal == null) {
return null;
}
// 不同 provider 使用不同的 email claim 名稱
String email = principal.getAttribute("email");
if (email != null) {
return email;
}
// 某些 provider 使用 preferred_username 或自訂 claim
email = principal.getAttribute("preferred_username");
if (email != null) {
return email;
}
return null;
}
/**
* 從 OAuth2 principal 提取使用者名稱
*
* @param principal
* OAuth2User
* @return 使用者名稱,若不存在回傳 null
*/
public String extractUsername(OAuth2User principal) {
if (principal == null) {
return null;
}
// 優先使用 principal 的 name(通常由 provider 填入)
String username = principal.getName();
if (username != null && !username.isEmpty()) {
return username;
}
// 嘗試從 attributes 提取
username = principal.getAttribute("name");
if (username != null) {
return username;
}
return null;
}
/**
* 從 OAuth2 principal 提取全名(first name + last name)
*
* @param principal
* OAuth2User
* @return 全名字串,若不存在回傳 null
*/
public String extractFullName(OAuth2User principal) {
if (principal == null) {
return null;
}
String fullName = principal.getAttribute("name");
if (fullName != null) {
return fullName;
}
String firstName = principal.getAttribute("given_name");
String lastName = principal.getAttribute("family_name");
if (firstName != null || lastName != null) {
return ((firstName != null ? firstName : "") + " " + (lastName != null ? lastName : "")).trim();
}
return null;
}
/**
* 從 OAuth2 principal 提取使用者識別碼(sub claim)
*
* @param principal
* OAuth2User
* @return 使用者識別碼,若不存在回傳 null
*/
public String extractSubject(OAuth2User principal) {
if (principal == null) {
return null;
}
return principal.getAttribute("sub");
}
/**
* 驗證 OAuth2 principal 是否包含必要的 claim
*
* @param principal
* OAuth2User
* @param requiredClaims
* 必要的 claim 名稱陣列
* @return true 若包含所有必要 claim,否則 false
*/
public boolean validateRequiredClaims(OAuth2User principal, String... requiredClaims) {
if (principal == null) {
return false;
}
for (String claim : requiredClaims) {
if (principal.getAttribute(claim) == null) {
log.warn("Required claim '{}' is missing in OAuth2User", claim);
return false;
}
}
return true;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/oauth2/OAuth2AuthenticationClient.java
package tw.lewishome.webapp.base.security.oauth2;
import java.util.ArrayList;
import java.util.List;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
// import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
/**
* OAuth2AuthenticationClient 負責初始化並管理 OAuth2 Client 設定,
* 包含 Google 與 GitHub 的 client 註冊資訊。
* <p>
* 此類別會從系統環境變數或預設常數讀取 OAuth2 client 設定,
* 並建立 {@link ClientRegistrationRepository} Bean,供 Spring Security OAuth2 使用。
* 若未設定任何 registration,會回傳一個空的 InMemoryClientRegistrationRepository,
* 以避免缺少 bean 導致啟動例外。
* </p>
*
* <p>
* 主要功能:
* <ul>
* <li>初始化 Google 與 GitHub 的 OAuth2 client 設定</li>
* <li>建立 {@link ClientRegistrationRepository} Bean</li>
* <li>提供 Google 與 GitHub 的 {@link ClientRegistration} 設定</li>
* </ul>
* </p>
*
* @author Lewishome
* @since 2024
*/
@Configuration
public class OAuth2AuthenticationClient {
@Autowired
SystemEnvReader systemEnvReader;
private String googleClientId;
private String googleClientSecure;
private String googleRedirectUrl;
private String githubClientId;
private String githubClientSecure;
private String githubRedirectUrl;
/**
* 初始化 OAuth2 client 設定
*/
@PostConstruct
private void initializeOAuth2Config() {
this.googleClientId = systemEnvReader.getProperty("OAUTH2_GOOGLE_CLIENT_ID",
SecurityConstants.OAUTH2_GOOGLE_CLIENT_ID);
this.googleClientSecure = systemEnvReader.getProperty("OAUTH2_GOOGLE_CLIENT_SECURET",
SecurityConstants.OAUTH2_GOOGLE_CLIENT_SECURET);
this.googleRedirectUrl = systemEnvReader.getProperty("OAUTH2_GOOGLE_REDIRECT_URL",
SecurityConstants.OAUTH2_GOOGLE_REDIRECT_URL);
this.githubClientId = systemEnvReader.getProperty("OAUTH2_GITHUB_CLIENT_ID",
SecurityConstants.OAUTH2_GITHUB_CLIENT_ID);
this.githubClientSecure = systemEnvReader.getProperty("OAUTH2_GITHUB_CLIENT_SECURET",
SecurityConstants.OAUTH2_GITHUB_CLIENT_SECURET);
this.githubRedirectUrl = systemEnvReader.getProperty("OAUTH2_GITHUB_REDIRECT_URL",
SecurityConstants.OAUTH2_GITHUB_REDIRECT_URL);
}
/**
* 建立 ClientRegistrationRepository bean,以便 OAuth2 client 設定可用。
* 由 Spring Boot 的 OAuth2ClientProperties 讀取 client registration 與 provider 設定。
* 若未設定任何 registration,會回傳一個空的 InMemoryClientRegistrationRepository,避免缺少 bean
* 導致啟動例外。
*/
@Bean
@Primary
public ClientRegistrationRepository clientRegistrationRepository() {
List<ClientRegistration> defaults = new ArrayList<>();
defaults.add(this.googleClientRegistration());
defaults.add(this.githubClientRegistration());
return new InMemoryClientRegistrationRepository(defaults);
}
private ClientRegistration googleClientRegistration() {
return ClientRegistration.withRegistrationId("google")
.clientId(googleClientId)
.clientSecret(googleClientSecure)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.redirectUri(googleRedirectUrl)
.scope("openid", "profile", "email")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName("email")
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.clientName("Google")
.build();
}
private ClientRegistration githubClientRegistration() {
return ClientRegistration.withRegistrationId("github")
.clientId(githubClientId)
.clientSecret(githubClientSecure)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(githubRedirectUrl)
.scope("read:user", "user:email")
.authorizationUri("https://github.com/login/oauth/authorize")
.tokenUri("https://github.com/login/oauth/access_token")
.userInfoUri("https://api.github.com/user")
.userNameAttributeName("id")
.clientName("GitHub")
.build();
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/oauth2/OAuth2UserService.java
package tw.lewishome.webapp.base.security.oauth2;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import lombok.extern.slf4j.Slf4j;
/**
* 統一的 OAuth2UserService,支援 Google 與 GitHub,確保 email 作為 principal。
* <p>
* Google:從 ID token claims 取得 email。
* GitHub:因 /user 端點不一定回傳 email,需額外呼叫 /user/emails 端點取得 email。
* <p>
* 其他 OAuth2 提供者則使用預設行為。
*
* @author LewisHome
*/
@Service
@Slf4j
public class OAuth2UserService extends DefaultOAuth2UserService {
private final RestTemplate restTemplate = new RestTemplate();
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
if ("google".equalsIgnoreCase(registrationId)) {
return loadGoogleUser(userRequest);
} else if ("github".equalsIgnoreCase(registrationId)) {
return loadGitHubUser(userRequest);
}
// 其他提供者則使用預設行為
return super.loadUser(userRequest);
}
/**
* 載入 Google 使用者並確保 email 作為 principal。
*/
private OAuth2User loadGoogleUser(OAuth2UserRequest userRequest) {
OAuth2User user = super.loadUser(userRequest);
// Google 的 ID token 內含 email 欄位
String email = user.getAttribute("email");
if (email != null) {
Map<String, Object> attributes = new LinkedHashMap<>(user.getAttributes());
log.info("Google 使用者 email 作為 principal: {}", email);
// 回傳以 email 作為 principal 的 OAuth2User
return new DefaultOAuth2User(user.getAuthorities(), attributes, "email");
}
log.warn("Google 使用者 email 未在 attributes 中找到,使用預設 principal");
return user;
}
/**
* 載入 GitHub 使用者並確保 email 作為 principal。
* 會從 GitHub 的 /user/emails 端點取得 email。
*/
private OAuth2User loadGitHubUser(OAuth2UserRequest userRequest) {
// 以 "id" 作為 principal(因為 GitHub 回應中一定有 id)
OAuth2User user = super.loadUser(userRequest);
// 嘗試從 GitHub 的 /user/emails 端點取得 email
String email = fetchGitHubUserEmail(userRequest.getAccessToken().getTokenValue());
if (email != null) {
Map<String, Object> attributes = new LinkedHashMap<>(user.getAttributes());
attributes.put("email", email);
log.info("GitHub 使用者 email 已取得並作為 principal: {}", email);
// 回傳以 email 作為 principal 的 OAuth2User
return new DefaultOAuth2User(user.getAuthorities(), attributes, "email");
} else {
log.warn("無法取得 GitHub 使用者 email,退回以 'id' 作為 principal");
return user;
}
}
/**
* 從 GitHub 的 /user/emails 端點取得使用者 email。
* 優先回傳主要 email,其次回傳第一個已驗證 email,最後回傳第一個找到的 email。
*/
@SuppressWarnings({ "rawtypes", "unchecked", "null" })
private String fetchGitHubUserEmail(String accessToken) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<List> response = restTemplate.exchange(
"https://api.github.com/user/emails",
HttpMethod.GET,
entity,
List.class
);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
List<Map<String, Object>> emails = response.getBody();
// 優先 1:尋找主要 email
for (Map<String, Object> emailObj : emails) {
if ((Boolean) emailObj.getOrDefault("primary", false)) {
Object emailValue = emailObj.get("email");
if (emailValue != null) {
return emailValue.toString();
}
}
}
// 優先 2:回傳第一個已驗證 email
for (Map<String, Object> emailObj : emails) {
if ((Boolean) emailObj.getOrDefault("verified", false)) {
Object emailValue = emailObj.get("email");
if (emailValue != null) {
return emailValue.toString();
}
}
}
// 優先 3:回傳第一個 email
if (!emails.isEmpty()) {
Object firstEmail = ((Map<String, Object>) emails.get(0)).get("email");
if (firstEmail != null) {
return firstEmail.toString();
}
}
}
} catch (Exception e) {
log.error("取得 GitHub 使用者 email 時發生錯誤", e);
}
return null;
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/oauth2/OAuth2FailureHandler.java
package tw.lewishome.webapp.base.security.oauth2;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.security.audit.SysSecurityAuditLogService;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
/**
* OAuth2FailureHandler 用於處理 OAuth2 登入失敗的情況,與表單登入的 failure handler 分離。
*/
@Component
@Slf4j
public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired(required = false)
private SysSecurityAuditLogService sysSecurityAuditLogService;
@Autowired(required = false)
SystemEnvReader systemEnvReader;
public OAuth2FailureHandler() {
// Constructor body (can be empty)
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
try {
Authentication current = SecurityContextHolder.getContext().getAuthentication();
if (current != null && current.isAuthenticated()) {
log.debug(
"OAuth2 failure handler invoked but SecurityContext already authenticated; skipping audit. requestUri={}",
request.getRequestURI());
} else {
if (sysSecurityAuditLogService != null) {
log.info("Recording OAuth2 failure for requestUri={}", request.getRequestURI());
sysSecurityAuditLogService.addNewAccessLog(request, "OAuth2 Login Failed");
} else {
log.warn("SysSecurityAuditLogService not available; cannot record OAuth2 failure for requestUri={}",
request.getRequestURI());
}
}
} catch (Throwable t) {
log.error("Audit log failed in OAuth2FailureHandler for requestUri={}", request.getRequestURI(), t);
}
log.info("OAuth2 authentication failed: {}", exception.getMessage());
// Redirect to login page on OAuth2 failure
String loginPageUrl = systemEnvReader.getProperty("LOGIN_PAGE_URL",SecurityConstants.LOGIN_PAGE_URL);
getRedirectStrategy().sendRedirect(request, response, loginPageUrl+"?retry");
}
}
位置:src/main/java/tw/lewishome/webapp/base/security/oauth2/OAuth2SuccessHandler.java
package tw.lewishome.webapp.base.security.oauth2;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.Map;
import java.io.Serializable;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
import tw.lewishome.webapp.database.primary.service.SysUserProfileService;
/**
* OAuth2SuccessHandler 處理 OAuth2.0 登入成功的後續邏輯。
*
* 此元件在使用者通過 OAuth2.0 認證後被 Spring Security 呼叫,可實現:
* <ul>
* <li>記錄登入稽核日誌</li>
* <li>同步 OAuth2 使用者資訊至系統資料庫</li>
* <li>簽發應用自訂的 JWT token</li>
* <li>轉導至指定的成功頁面或 REST 端點</li>
* <li>設定 session 或 cookie 相關資訊</li>
* </ul>
*
* 主要職責:
* <ul>
* <li>在 OAuth2.0 成功後處理 SysUserProfile 的建立/更新</li>
* <li>可選:簽發應用 JWT(若不依賴 OAuth2 provider 的 token)</li>
* <li>記錄登入成功事件(可整合 LoginSuccessLogger)</li>
* <li>決定轉導的目標 URL(首頁、個人頁面、API token 端點等)</li>
* </ul>
*
* 使用場景:
* <ul>
* <li>OAuth2.0 社群登入後建立或更新本地使用者</li>
* <li>簽發 JWT 供 REST API 使用</li>
* <li>跨域登入場景中的 token 傳遞</li>
* </ul>
*
* @author Lewis
* @since 2024
*/
@Component
@Slf4j
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
@Autowired(required = false)
private OAuth2JwtConverter oauth2JwtConverter;
@Autowired(required = false)
SystemEnvReader systemEnvReader;
/**
* Fix for javadoc warning: default constructor
* Constructs a new OAuth2SuccessHandler instance.
*/
public OAuth2SuccessHandler() {
// Default constructor
}
@Autowired(required = false)
private SysUserProfileService sysUserProfileService;
/**
* 在 OAuth2.0 認證成功後被呼叫的方法
*
* @param request
* HTTP 請求
* @param response
* HTTP 回應
* @param authentication
* OAuth2AuthenticationToken(包含使用者資訊)
* @throws IOException
* 若 I/O 錯誤發生
* @throws ServletException
* 若 Servlet 錯誤發生
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if (!(authentication instanceof OAuth2AuthenticationToken)) {
log.warn(
"[OAuth2SuccessHandler] onAuthenticationSuccess - Authentication is NOT OAuth2AuthenticationToken, redirecting to default page");
redirectToDefaultPage(response);
return;
}
try {
OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken) authentication;
OAuth2User principal = oauth2Token.getPrincipal();
// Prepare final copies of attributes/authorities for use inside anonymous class
final Map<String, Object> principalAttributes = principal.getAttributes();
final Collection<? extends GrantedAuthority> principalAuthorities = principal.getAuthorities();
OAuth2User usedPrincipal = principal;
// Extract email and, if present, replace the Authentication principal
// so authentication.getName() will return the email.
String email = extractEmail(principal);
if (email != null && !email.isEmpty()) {
// Use a serializable principal to avoid Redis session serialization errors
EmailPrincipal emailPrincipal = new EmailPrincipal(principalAttributes, principalAuthorities, email);
OAuth2AuthenticationToken newAuth = new OAuth2AuthenticationToken(
emailPrincipal,
oauth2Token.getAuthorities(),
oauth2Token.getAuthorizedClientRegistrationId());
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(newAuth);
// use the new principal for logging/sync
oauth2Token = newAuth;
usedPrincipal = newAuth.getPrincipal();
}
// 記錄登入成功
logLoginSuccess(oauth2Token, usedPrincipal);
// 同步 OAuth2 使用者資訊至系統(可選)
syncUserProfile(oauth2Token, usedPrincipal);
// 決定轉導 URL(可以是首頁、API token 端點或其他)
String redirectUrl = determineTargetUrl(request, oauth2Token);
response.sendRedirect(redirectUrl);
} catch (Exception ex) {
log.error("Error in OAuth2SuccessHandler", ex);
redirectToErrorPage(response);
}
}
/**
* Extract email from OAuth2User attributes
* Handles different OAuth2 providers (Google, GitHub, etc.)
*
* @param principal OAuth2User with attributes
* @return email address or null if not found
*/
private String extractEmail(OAuth2User principal) {
if (principal == null) {
return null;
}
// Try common attribute names for email across different OAuth2 providers
Object email = principal.getAttribute("email");
if (email != null && email instanceof String) {
return (String) email;
}
// For GitHub, email might be in attributes
Object githubEmail = principal.getAttribute("email");
if (githubEmail != null && githubEmail instanceof String) {
return (String) githubEmail;
}
// Fallback to name if email not available
return principal.getName();
}
/**
* 記錄 OAuth2 登入成功事件
*
* @param oauth2Token
* OAuth2AuthenticationToken
* @param principal
* OAuth2User
*/
private void logLoginSuccess(OAuth2AuthenticationToken oauth2Token, OAuth2User principal) {
String username = principal.getName();
String registrationId = oauth2Token.getAuthorizedClientRegistrationId();
String email = oauth2JwtConverter != null ? oauth2JwtConverter.extractEmail(principal) : null;
log.info("OAuth2 login success - registrationId: {}, username: {}, email: {}",
registrationId, username, email);
// 若有 LoginSuccessLogger bean,可在此呼叫以記錄稽核日誌
// loginSuccessLogger.logLoginSuccess(username, "OAuth2:" + registrationId);
}
/**
* 同步 OAuth2 使用者資訊至系統資料庫(SysUserProfile)
*
* @param oauth2Token
* OAuth2AuthenticationToken
* @param principal
* OAuth2User
*/
private void syncUserProfile(OAuth2AuthenticationToken oauth2Token, OAuth2User principal) {
if (sysUserProfileService == null) {
log.debug("SysUserProfileService is not available, skipping user profile sync");
return;
}
// 取得 OAuth2 提供者 ID
String registrationId = oauth2Token.getAuthorizedClientRegistrationId();
try {
// 呼叫服務方法同步使用者資訊
sysUserProfileService.syncOAuth2User(principal, registrationId);
log.debug("OAuth2 user profile synced successfully for provider: {}", registrationId);
} catch (Exception ex) {
log.error("Failed to sync OAuth2 user profile", ex);
// 不中斷登入流程,只記錄錯誤
}
}
/**
* 決定成功登入後的轉導 URL
*
* @param request
* HTTP 請求
* @param oauth2Token
* OAuth2AuthenticationToken
* @return 轉導目標 URL
*/
private String determineTargetUrl(HttpServletRequest request, OAuth2AuthenticationToken oauth2Token) {
// 檢查是否有 redirect_uri parameter
String redirectUri = request.getParameter("redirect_uri");
if (redirectUri != null && isValidRedirectUri(redirectUri)) {
return redirectUri;
}
// 檢查 session 中是否有保存的 target URL
String targetUrl = (String) request.getSession().getAttribute("oauth2_target_url");
if (targetUrl != null) {
request.getSession().remove