iT邦幫忙

0

VScode 開發應用系統專案(8-2) - Spring Boot Security JWT 及 OAuth2 認證

  • 分享至 

  • xImage
  •  

Spring Boot 安全認證 — JWT 及 OAuth2 認證

概述

接續應用系統專案(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 成功處理)

JWT token 登入認證以及測試驗證功能

1. JwtUtils (JWT 產生/驗證工具)

位置: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;
    }
}

2. JwtLoginRequestModel (請求JWT Token認證登入的 Model)

位置: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;
}

3. JwtLoginResponseModel (JWT 登入認證後與簽發Token的Model)

位置: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;
    }
}

4. JwtAuthenticationFilter (JWT Token認證過濾器,確認Token並取得授權相關資料)

位置: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;

    }
}

5. JwtAuthenticationLoginApi (JWT 登入認證與簽發Token的API)

位置: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;
    }
}

6. JwtSystemApiTestController (測試驗證 JWT API的功能,包含@PreAuthorize測試用 API)

位置: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;
    }

}

7 JwtAuthRolesExceptionHandler (Method @PreAuthorize role 異常處理)

位置: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.0 認證與授權模組

OAuth2 Client的認證與授權 需要分別需要到Google 以及 Github網站註冊登記並設定

可以參考:

  1. https://ithelp.ithome.com.tw/m/articles/10235929 for Google網站註冊登記設定

  2. https://kucw.io/blog/2019/12/spring-oauth2-bind-github/ for Github網站註冊登記設定

1. OAuth2JwtConverter (OAuth2.0 簽發Token對應至系統內的使用者)

位置: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;
    }
}

2. OAuth2AuthenticationClient (OAuth2.0 認證)

位置: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();
        }

}

3. OAuth2UserService (OAuth2.0 失敗處理)

位置: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;
    }
}

4. OAuth2FailureHandler (OAuth2.0 失敗處理)

位置: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");
    }
}

5. OAuth2SuccessHandler (OAuth2.0 成功處理)

位置: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

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言