iT邦幫忙

0

VScode 開發應用系統專案(8-1) - Spring Boot Security 設定與認證前置準備

  • 分享至 

  • xImage
  •  

Spring Boot 安全認證 — 前置基礎準備

概述

Spring Security 框架支援有關使用者登入認證與授權等有關安全管理的功能,這裡以應用系統架構實務,提供以下認證與授權方案

  • 關於一般登入表單(Form Login),提供整合 ActiveDirectory/LDP 、DataBase/UserProfile 、Memory(開發測試)的 CustomAuthenticationProvider 認證與授權

  • 登入表單(Form Login)提供與 Google/Github 的協作OAuth2 Client的認證與授權 。

  • 關於一般API服務的認證,提供API Web 登入結合CustomAuthenticationProvider 認證與授並簽發JSON Web Token (Bearer Token),後續API請求以Http Filter,確認Bearer Token的並賦予Token簽發的授權。

  • 對應CONTENT_SECURITY_POLICY要求(主要是因為弱點掃描),提供CSPNonceFilter,配合對Http Hear 隨機產生nonce變數,並以thymeleaf template 對 Script 實作 nonce-${nonce),確保CONTENT_SECURITY_POLICY要求

  • 以系統稽核要求,所有認證事件(成功、失敗、登出、作業逾時過期)應完整記錄,便於安全分析,(何人(User)於何時(AccessTime)由那一部設備(remote IP)做何動作(Action 例如:Login Success、Login Failure等等)。

準備與檢核

  1. VScode 開發應用系統專案 (1) - 啟動Spring Boot Web專案。
  1. 工具類程式已經準備好可以使用。
  1. Spring Boot資料庫設計與存取 。

Spring Boot security相關套件(pom.xml)


        <!-- Spring Boot Security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.ulisesbocchio</groupId>
            <artifactId>jasypt-spring-boot-starter</artifactId>
            <version>${jasypt-maven.version}</version>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity6</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ldap</groupId>
            <artifactId>spring-ldap-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-ldap</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
        <dependency> 
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- End Spring Boot Security -->

建議的專案系統結構 (目錄package不存在,自行新增)

主應目錄與程式(全部)

Security 相關資料庫設定
資料庫設定參考:  
案例包含SysUserProfile,所以不重複介紹,這裡只增加SysAccessLogEntity 、SysAccessLogRepository、SysUserProfileService
tw.lewishome.webapp.database.primary
├──entity
│   ├── SysUserProfileEntity (系統使用者資料,DataBase認證使用)
│   └── SysAccessLogEntity (系統存取相關安全稽核檔案)  
├──repository
│   ├── SysUserProfileRepository (SysUserDeptEntity 的JPA 相關功能)
│   └── SysAccessLogRepository (SysAccessLogRepository 的JPA 相關功能)
└──service
    └── SysUserProfileService(資料庫認證相關使用者服務)

Security 相關設定
tw.lewishome.webapp.base.security
├── WebSecurityConfig (Spring Security 主設定)
├── CustomAuthenticationProvider (客製化多來源認證器)
├── DataBaseAuthentication(資料庫認證)
├── ActiveDirectoryAuthentication(AD/LDAP認證)
├── MemoryAuthentication(記憶體認證,開發測試用)
├── CSPNonceFilter(Content Security Policy 過濾器)
├── SecurityConstants(Security Policy 相關常變數)
├── jwttoken/
│   ├── JwtUtils (JWT 產生/驗證工具)
│   ├── JwtAuthenticationFilter (JWT Token認證過濾器,確認Token並取得授權相關資料)
│   ├── JwtAuthenticationLoginApi (JWT 登入認證與簽發Token的API)
│   ├── JwtSystemApiTestController (測試驗證 JWT API的功能,包含@PreAuthorize測試用 API)
│   └── JwtAuthRolesExceptionHandler (Method授權Role異常處理)
├── oauth2/
│   ├── OAuth2AuthenticationClient (OAuth2.0 認證)
│   ├── OAuth2JwtConverter (OAuth2.0 簽發Token對應至系統內的使用者)
│   ├── OAuth2FailureHandler (OAuth2.0 失敗處理)
│   └── OAuth2SuccessHandler (OAuth2.0 成功處理)
└── audit/
    ├── SysSecurityAuditLogService (稽核記錄服務)
    ├── LoginSuccessLogger (登入成功稽核)
    ├── LoginFailureLogger (登入失敗稽核)
    ├── SignOffSuccessLogger (登出稽核)
    ├── SessionExpiredListener (作業話過期稽核)
    └── SessionExpiredConfig (作業過期設定)

前端URL相關控制處理
tw.lewishome.webapp.page
└── HomePageController (登入相關 Endpoint URL)	

前端URL相關 Html相關文件(主要使用thymeleaf template)
src/main/resources
├── mybatis (JPA Mybatis Mapper,SysAccessLog的mybaticMapper暫時不用)
│
├── static/ (一般靜態資料)
│   └── css (登入相關頁面)
│   └── img (登入相關頁面)
│   └── js (登入相關頁面)
│
└── templates/ (thymeleaf template)
    └── home (登入相關頁面)
        ├── homePage.html (登入頁面)
        └── landingPage.html (登入成功頁面)
		

SecurityConstants(Security Policy 相關常變數)

package tw.lewishome.webapp.base.security;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;


public class SecurityConstants {

    /** Private constructor to prevent instantiation */
    SecurityConstants() {
        throw new UnsupportedOperationException("This is a utility class and cannot be instantiated.");
    }

    /** Default 這些網址不檢查Login Form認證 */
        public static final String DEFAULT_ENDPOINT_WHITE_LIST = "/webjars/**;/js/**;/css/**;/img/**;/jwtAuth/**;/callback/**;/home";

        /** Default 這些網址不檢查Login Form認證 */
        public static final String DEFAULT_CSRF_ENDPOINT_WHITE_LIST = "/jwtAuth/**;/systemApiTest/**;/opt/fonts/**;/callback/**;/home";

    /**
     * 保護網頁的 contentSecurityPolicyString ,這裡適用 CSP 2.0。
     * 主要的難處是希望所有的 JS 是從Server端的程式碼,未被串改過
     * 不可使用Inline style ,需使用 link href= ,
     * JS 建議最好是 include (src=) 進檔案,避免Inline script
     *
     */
    public static final String DEFAULT_CONTENT_SECURITY_POLICY = "" +
            "default-src 'self'; font-src 'self'; " +
            "img-src * 'self' data: https: ; " +
            "style-src 'self' ; style-src-elem 'self' ; " +
            "script-src 'strict-dynamic' 'nonce-{nonce}' ; " +
            "script-src-elem 'strict-dynamic' 'nonce-{nonce}'; " +
            "form-action 'self'; child-src 'none'; object-src 'none'";

    public static final List<String> DO_NOT_CSP_NONCE_Filter_EXTENSION = new ArrayList<>(Arrays.asList(
            "js", "css", "png", "jpg", "jpeg", "gif", "svg", "ico", "woff", "woff2", "ttf", "eot", "otf"));

    /** 系統的 ENDPOINT 列表 */
    public static List<String> LIST_SYSTEM_ENDPOINT = new ArrayList<>();

    // AD Server Name Default (IP 也可以)
    public static final String AD_SERVER_HOST_NAME = "tw.lewishome";
    // AD Domain Deafult
    public static final String AD_SERVER_DOMAIN = "tw.lewishome";
    // AD Port Deafult 389
    public static final String AD_SERVER_HOST_PORT = "389";

    // 登入頁面必須要於 controller 有 Get method
    public static final String LOGIN_PAGE_URL = "/home";


    // 登入頁面必須要於 controller 有 Get method
    public static final String LANDING_PAGE_URL = "/landing";

    // Login From User Parameter 登入頁面欄位名稱 ==>給 spring security 認證的username變數
    public static final String USER_NAME_PARAMETER = "username";
    
    // Login From Password Parameter 登入頁面 欄位名稱==>給 spring security 認證的password變數
    public static final String USER_PASSWORD_PARAMETER = "ePasscode";

   // OAuth2 Google Client ID 
    public static final String OAUTH2_GOOGLE_CLIENT_ID = "560550383000-950hqkt5hfkgrbskgfq10eelgjjmco8l.apps.googleusercontent.com";
    // OAuth2 Google client Secret  
    public static final String OAUTH2_GOOGLE_CLIENT_SECURET = "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";
    // OAuth2 Google redirect Url  
    public static final String OAUTH2_GOOGLE_REDIRECT_URL = "http://localhost:8080/callback";


    // OAuth2 GitHub Client ID 
    public static final String OAUTH2_GITHUB_CLIENT_ID = "Ov23li0dXSh71nx0xWP2";
    // OAuth2 GitHub client Secret  
    public static final String OAUTH2_GITHUB_CLIENT_SECURET = "b2e48c362044dd83bdb080c48ac8fd8290e74a52";
   // OAuth2 Google redirect Url  
    public static final String OAUTH2_GITHUB_REDIRECT_URL = "http://localhost:8080/callback";

    // DO Jwt Token Auth Urls
    public static final List<String> DO_JWT_AUTH_TOKEN_FILTER_URLS = new ArrayList<>(Arrays.asList(
            "/systemApi/**", "/systemApiTest/**"));
}

Security 相關資料庫設定

1. SysAccessLogEntity (系統存取相關安全稽核檔案)

位置:src/main/java/tw/lewishome/webapp/database/primary/entity/SysAccessLogEntity.java

package tw.lewishome.webapp.database.primary.entity;

import java.io.Serializable;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;

import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import tw.lewishome.webapp.database.audit.EntityAudit;

/**
 * SysAccessLog Table Entity
 *
 * @author lewis
 * @version $Id: $Id
 */
@Entity
@Table(name = "sysaccesslog") // 資料庫的 Table 名稱
@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
public class SysAccessLogEntity extends EntityAudit<String> {
    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SysAccessLogEntity instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SysAccessLogEntity() {
        // Constructor body (can be empty)
    }

    /** serialVersionUID */
    private static final long serialVersionUID = 1L;
    /** Primary Key */
    @EmbeddedId
    public DataKey dataKey;

    /** Session ID */
    @Column(name = "sessionId", length = 128)
    public String sessionId;
    /** Session ID */
    @Column(name = "accessUser", length = 32)
    public String accessUser;
    /** Server Name */
    @Column(name = "serverName", length = 128)
    public String serverName;
    /** remote 登入 IP */
    @Column(name = "remoteIp", length = 64)
    public String remoteIp;
    /** 存取 URL */
    @Column(name = "accessUrl", length = 512)
    public String accessUrl;
    /** 存取 內容 */
    @Column(name = "action", length = 1024)
    public String action;

    /**
     * Entity Key
     *
     */
    @Embeddable
    @Data
    public static class DataKey implements Serializable {
        /**
         * Fix for javadoc warning :
         * use of default constructor, which does not provide a comment
         * Constructs a new DataKey instance.
         * This is the default constructor, implicitly provided by the compiler
         * if no other constructors are defined.
         */
        public DataKey() {
            // Constructor body (can be empty)
        }

        private static final long serialVersionUID = 1L;
        /** UUID */
        @Column(name = "uuid", length = 64)
        public String uuid = UUID.randomUUID().toString();
    }

    /**
     * MyBatis TypeHandler for DataKey
     */
    /**
     * MyBatis BaseTypeHandler} for converting between the application's
     * DataKey object and its JDBC representation.
     *
     */
    public static class DataKeyHandler extends BaseTypeHandler<DataKey> {

        /**
         * Fix for javadoc warning :
         * use of default constructor, which does not provide a comment
         * Constructs a new AsyncServiceWorkerSample instance.
         * This is the default constructor, implicitly provided by the compiler
         * if no other constructors are defined.
         */
        public DataKeyHandler() {
            // Constructor body (can be empty)
        }

        @Override
        public void setNonNullParameter(PreparedStatement ps, int i, DataKey parameter, JdbcType jdbcType)
                throws SQLException {
            try {
                ps.setString(1, parameter.getUuid());
            } catch (Exception e) {
                e.printStackTrace();
            }

        }

        @Override
        public DataKey getNullableResult(ResultSet rs, String columnName) throws SQLException {
            DataKey dataKey = new DataKey();
            if (rs.wasNull() == false) {
                dataKey.setUuid(rs.getString("uuid"));
            }
            return dataKey;
        }

        @Override
        public DataKey getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
            DataKey dataKey = new DataKey();
            if (rs.wasNull() == false) {
                dataKey.setUuid(rs.getString(1));
            }
            return dataKey;
        }

        @Override
        public DataKey getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
            DataKey dataKey = new DataKey();
            if (cs.wasNull() == false) {
                dataKey.setUuid(cs.getString(1));
            }
            return dataKey;
        }
    }
}

2. SysAccessLogRepository (存取SysAccessLogEntity相關JAP功能)

位置:src/main/java/tw/lewishome/webapp/database/primary/repository/SysAccessLogRepository.java

package tw.lewishome.webapp.database.primary.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import tw.lewishome.webapp.database.primary.entity.SysAccessLogEntity;

/**
* SysScheduleLog JPA Repository
*
* @author lewis
* @version $Id: $Id
*/
@Transactional
@Repository
public interface SysAccessLogRepository extends JpaRepository<SysAccessLogEntity, SysAccessLogEntity.DataKey>,
      JpaSpecificationExecutor<SysAccessLogEntity> {

  /**
   * JPA Named Query     
   * 以Method名稱的操作資料庫資料 (inner Class DataKey 的欄位須以底線連接標示)
   * find By DataKey_Uuid .
   * 
   * @param uuid a  String  object
   * @return a
   *         {@link tw.lewishome.webapp.database.primary.entity.SysScheduleLogEntity}
   *         object
   */
  public SysAccessLogEntity findByDataKey_Uuid(String uuid);

 /**
   * JPA Named Query     
   * 以Method名稱的操作資料庫資料 (inner Class DataKey 的欄位須以底線連接標示)
   * find By DataKey_UserId .
   * 
   * @param sessionId  session id 
   * @param action  action  
   * @return a
   *         {@link tw.lewishome.webapp.database.primary.entity.SysScheduleLogEntity}
   *         object
   */
    public SysAccessLogEntity findBySessionIdAndAction(String sessionId, String action);

  /**
   * JPA PSQL 
   * 以 SQL語法,操作資料庫資料
   * 注意 冒號與變數不可有空白  (:parmAccessUser)
   * 以 Entity欄位作為 SQL查詢欄位,與系統使用欄位名稱一致。
   * @param accessUser access user
   * @return List SysAccessLogEntity List
   */
  @Query("SELECT u FROM SysAccessLogEntity u WHERE u.accessUser = :accessUser")
  public List<SysAccessLogEntity> findAllAccessUser(@Param("parmAccessUser") String accessUser);

  /**
   * JPA  PSQL Native
   *
   * 以資料庫 Table 欄位作為 SQL查詢欄位,與系統使用欄位名稱不同,但可以利用 SQL 工具驗證語法。
   * @param accessUser schedule task name
   * @return List SysAccessLogEntity List
   */
   @NativeQuery(value = "SELECT * FROM sysaccesselog WHERE access_user = :parmAccessUser")
  public List<SysAccessLogEntity> findAllAccessUser2(@Param("parmAccessUser") String accessUser);

}

3. SysUserProfileService(使用者SysUserProfile認證相關服務)

位置:src/main/java/tw/lewishome/webapp/database/primary/service/SysUserProfileService.java

package tw.lewishome.webapp.database.primary.service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.security.oauth2.OAuth2JwtConverter;
import tw.lewishome.webapp.base.utility.common.TypeConvert;
import tw.lewishome.webapp.database.primary.entity.SysUserDeptEntity;
import tw.lewishome.webapp.database.primary.entity.SysUserProfileEntity;
import tw.lewishome.webapp.database.primary.repository.SysUserDeptRepository;
import tw.lewishome.webapp.database.primary.repository.SysUserProfileRepository;

/**
 * 取得系統使用者認證順序的服務類別。
 *
 * 此服務負責根據使用者最後一次成功登入所使用的認證方式,
 * 產生一組認證方式的執行順序清單。若使用者尚未有登入紀錄,
 * 則會回傳預設的認證順序。
 *
 *
 *
 * 預設認證順序為:<br>
 * 1. activeDirectory<br>
 * 2. dataBase<br>
 * 3. memory<br>
 *
 *
 *
 * 若使用者有最後一次認證方式,則該方式會被排在最前面,
 * 其餘預設認證方式則依序補齊(不重複)。
 *
 *
 *
 * 本服務使用 Spring Cache 機制,將結果快取於 "catchUserAuthSeq",
 * 以提升效能。
 *
 * 同時支援 OAuth2.0 使用者的同步邏輯:
 * <ul>
 * <li>{@link #syncOAuth2User(String, String, String, String)} — 根據 OAuth2.0 提供商資訊建立或更新 SysUserProfile。</li>
 * </ul>
 *
 * @author Lewis
 * @version 1.0
 */
@Service
@Slf4j
public class SysUserProfileService {

    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SysUserProfileService instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SysUserProfileService() {
        // Constructor body (can be empty)
    }

    @Autowired
    private SysUserProfileRepository sysUserProfileRepository;

    @Autowired
    private SysUserDeptRepository sysUserDeptRepository;

    @Autowired(required = false)
    private OAuth2JwtConverter oauth2JwtConverter;


    /**
     * 取得指定使用者的認證順序清單。
     *
     * 此方法會根據使用者最後一次成功登入所使用的認證方式,
     * 產生一組認證方式的執行順序清單。若使用者尚未有登入紀錄,
     * 則會回傳預設的認證順序。
     *
     * 預設認證順序為:
     * 1. activeDirectory
     * 2. dataBase
     * 3. memory
     *
     * 若使用者有最後一次認證方式,則該方式會被排在最前面,
     * 其餘預設認證方式則依序補齊(不重複)。
     *
     * 本方法使用 Spring Cache 機制,將結果快取於 "catchUserAuthSeq",
     * 以提升效能。
     *
     * @param userId 使用者ID,用於查詢使用者的最後認證方式
     * @return 包含認證順序的清單,依照優先順序排列
     */
    @Cacheable(cacheNames = "catchUserAuthSeq", key = "#userId", cacheManager = "caffeineCacheManager")
    public List<String> getSysUserLastAuth(String userId) {
        // 執行認證順序
        List<String> doAuthSeq = new ArrayList<>();
        // Default 認證順序
        List<String> defaultAuthSeq = new ArrayList<>(Arrays.asList("activeDirectory", "dataBase", "memory"));

        String lastAuthMethod = "";

        SysUserProfileEntity.DataKey dataKey = new SysUserProfileEntity.DataKey();
        dataKey.setUserId(userId);
        Optional<SysUserProfileEntity> optionalSysUserProfileEntity = sysUserProfileRepository.findById(dataKey);

        if (optionalSysUserProfileEntity.isPresent()) {
            SysUserProfileEntity oneSysUserProfileEntity = optionalSysUserProfileEntity.get();
            lastAuthMethod = oneSysUserProfileEntity.getUserLastAuth();
        }
        if (StringUtils.isNoneBlank(lastAuthMethod)) {
            doAuthSeq.add(lastAuthMethod);
        }
        // 加入其他認證方法 , 作為後續認證的順序
        for (int i = 0; i < defaultAuthSeq.size(); i++) {
            String oneDefaultAuth = defaultAuthSeq.get(i);
            if (Boolean.FALSE.equals(doAuthSeq.contains(oneDefaultAuth))) {
                doAuthSeq.add(oneDefaultAuth);
            }
        }
        return doAuthSeq;
    }

    /**
     * 取得指定使用者的所有上級部門領導者資訊
     * 
     * 此方法透過使用者ID查詢該使用者所屬部門,並逐層向上追蹤
     * 所有父部門,將這些上級部門的資訊存儲在Map中返回。
     * 
     * @param userId 使用者ID,用於查詢使用者的部門資訊
     * @return 包含所有上級部門資訊的Map,其中:
     *         - key: 部門ID
     *         - value: 部門實體物件 (SysUserDeptEntity)
     *         若找不到使用者或部門資訊,則返回空的Map     * 
     */
    public Map<String,SysUserDeptEntity> getSysUserLeaders(String userId) {    
        Map<String,SysUserDeptEntity> allLeadersDept = new HashMap<>();

        // get SysUserProfile
        SysUserProfileEntity.DataKey userDataKey = new SysUserProfileEntity.DataKey();
        userDataKey.setUserId(userId);
        SysUserProfileEntity oneSysUserProfileEntity = sysUserProfileRepository.findById(userDataKey).orElse(null);
        if (oneSysUserProfileEntity == null) {
            return allLeadersDept;
        }
        String userDeptId = oneSysUserProfileEntity.getUserDept();

        SysUserDeptEntity.DataKey deptDataKey = new SysUserDeptEntity.DataKey();
        deptDataKey.setDeptId(userDeptId);
        SysUserDeptEntity oneSysUserDeptEntity = sysUserDeptRepository.findById(deptDataKey).orElse(null);
        if (oneSysUserDeptEntity == null){
            return allLeadersDept;
        }
        String parentDeptId = oneSysUserDeptEntity.getParentDeptId();
        while ( StringUtils.isNotBlank(parentDeptId)){
            SysUserDeptEntity.DataKey parentDataKey = new SysUserDeptEntity.DataKey();
            SysUserDeptEntity parentSysUserDeptEntity = sysUserDeptRepository.findById(parentDataKey).orElse(null);
            if (parentSysUserDeptEntity == null){
                parentDeptId = null ;
            } else {
                parentDeptId = parentSysUserDeptEntity.getParentDeptId();
                allLeadersDept.put(parentDeptId, parentSysUserDeptEntity);
            }
        }

        return allLeadersDept;

    }

    /**
     * 取得指定使用者所在部門的所有使用者資訊
     * 
     * <p>此方法會根據使用者ID查詢該使用者的部門,
     * 然後取得該部門中所有使用者的個人資料。
     * 
     * @param userId 使用者ID,用於查詢使用者所屬部門
     * @return 一個Map物件,其中:
     *         <ul>
     *           <li>Key: 使用者ID (String)</li>
     *           <li>Value: 使用者個人資料實體 ({@link SysUserProfileEntity})</li>
     *         </ul>
     *         如果找不到該使用者或該部門無其他使用者,
     *         則返回空的Map物件
     * 
     */
    public Map<String,SysUserProfileEntity> getAllDeptUsers(String userId) {  
        Map<String,SysUserProfileEntity> allDeptUsers = new HashMap<>();

        // get SysUserProfile
        SysUserProfileEntity.DataKey userDataKey = new SysUserProfileEntity.DataKey();  
        userDataKey.setUserId(userId);
        SysUserProfileEntity oneSysUserProfileEntity = sysUserProfileRepository.findById(userDataKey).orElse(null);
        if (oneSysUserProfileEntity == null) {
            return allDeptUsers;
        }
        String userDeptId = oneSysUserProfileEntity.getUserDept();
        List<SysUserProfileEntity> deptUsers = sysUserProfileRepository.findByUserDept(userDeptId);
        for (SysUserProfileEntity deptUser : deptUsers){
            allDeptUsers.put(deptUser.getDataKey().getUserId(), deptUser);
        }  
        return allDeptUsers;
    }

    /**
     * 同步 OAuth2.0 使用者資訊至 SysUserProfile
     * 
     * <p>此方法根據 OAuth2.0 提供商的使用者資訊,建立或更新本地 SysUserProfile 記錄。
     * 若使用者不存在則建立新記錄,若已存在則更新相關欄位。
     * 
     * <p>同步邏輯:
     * <ol>
     * <li>根據 email 或 subject (OAuth2.0 sub claim) 查詢本地使用者是否存在。</li>
     * <li>若不存在,建立新的 SysUserProfileEntity,userId 使用 email 作為唯一識別。</li>
     * <li>若已存在,更新 userName、userEmail、userLastAuth、以及相關時間戳。</li>
     * <li>將 OAuth2.0 提供商名稱記錄於 userLastAuth 欄位(例如 "oauth2:google")。</li>
     * <li>最後將新建或更新的記錄儲存至資料庫。</li>
     * </ol>
     * 
     * @param email                使用者電子郵件(來自 OAuth2.0 provider)
     * @param fullName             使用者全名(來自 OAuth2.0 provider)
     * @param oauth2ProviderName   OAuth2.0 提供商名稱(例如 "google"、"github"、"microsoft")
     * @param oAuth2Subject        OAuth2.0 sub claim(使用者在提供商端的唯一識別碼)
     * @return 已同步或新建的 SysUserProfileEntity,若同步失敗則回傳 null
     */
    public SysUserProfileEntity syncOAuth2User(String email, String fullName, String oauth2ProviderName, 
                                                String oAuth2Subject) {
        if (StringUtils.isBlank(email)) {
            log.warn("Email is blank, cannot sync OAuth2 user");
            return null;
        }

        try {
            // 構建 OAuth2 標識(用於 userLastAuth)
            String oauth2AuthMethod = "oauth2:" + (StringUtils.isBlank(oauth2ProviderName) ? "unknown" : oauth2ProviderName.toLowerCase());

            // 嘗試根據 email 查詢既有使用者
            // 注意:如果使用者表支援 email 查詢,可使用 findByUserEmail(email)
            // 否則可使用 userId == email 作為簡單對應
            SysUserProfileEntity.DataKey dataKey = new SysUserProfileEntity.DataKey();
            dataKey.setUserId(email);

            Optional<SysUserProfileEntity> existingUser = sysUserProfileRepository.findById(dataKey);
            SysUserProfileEntity userProfile;

            if (existingUser.isPresent()) {
                // 更新既有使用者
                userProfile = existingUser.get();
                log.debug("Updating existing OAuth2 user: {}", email);
            } else {
                // 建立新使用者
                userProfile = new SysUserProfileEntity();
                userProfile.setDataKey(dataKey);
                log.debug("Creating new OAuth2 user: {}", email);
            }

            // 更新使用者資訊
            userProfile.setUserName(StringUtils.isBlank(fullName) ? email : fullName);
            userProfile.setUserEmail(email);
            userProfile.setUserLastAuth(oauth2AuthMethod);
            userProfile.setUserIsVaild(true); // OAuth2 登入成功表示帳號有效
            userProfile.setExpired(false);
            userProfile.setLocked(false);

            // 若沒有設定部門,可設定為預設部門(可根據系統需求調整)
            if (StringUtils.isBlank(userProfile.getUserDept())) {
                userProfile.setUserDept(""); // 或設定為預設部門 ID
            }

            // 儲存或更新
            SysUserProfileEntity savedUser = sysUserProfileRepository.saveAndFlush(userProfile);
            log.info("Successfully synced OAuth2 user: email={}, provider={}, subject={}", 
                    email, oauth2ProviderName, oAuth2Subject);

            return savedUser;

        } catch (Exception ex) {
            log.error("Error syncing OAuth2 user: email={}, provider={}", email, oauth2ProviderName, ex);
            return null;
        }
    }

    /**
     * 同步 OAuth2.0 使用者資訊至 SysUserProfile(使用 OAuth2User 物件)
     * 
     * <p>此為 {@link #syncOAuth2User(String, String, String, String)} 的便利方法,
     * 自動從 OAuth2User 物件提取必要資訊。
     * 
     * @param oauth2User           OAuth2User 物件
     * @param oauth2ProviderName   OAuth2.0 提供商名稱
     * @return 已同步或新建的 SysUserProfileEntity,若同步失敗則回傳 null
     */
    public SysUserProfileEntity syncOAuth2User(OAuth2User oauth2User, String oauth2ProviderName) {
        if (oauth2User == null) {
            log.warn("OAuth2User is null, cannot sync");
            return null;
        }

        String email = null;
        String fullName = null;
        String subject = null;

        // 使用 OAuth2JwtConverter 提取資訊
        if (oauth2JwtConverter != null) {
            email = oauth2JwtConverter.extractEmail(oauth2User);
            fullName = oauth2JwtConverter.extractFullName(oauth2User);
            subject = oauth2JwtConverter.extractSubject(oauth2User);
        }

        // 若 converter 未提取到,嘗試直接從 principal 獲取
        if (StringUtils.isBlank(email)) {
            email = oauth2User.getAttribute("email");
        }
        if (StringUtils.isBlank(fullName)) {
            fullName = oauth2User.getAttribute("name");
        }
        if (StringUtils.isBlank(subject)) {
            subject = oauth2User.getName();
        }

        // GitHub 特殊處理:GitHub 在預設情況下可能不回傳 email,
        // 若為 github provider,嘗試使用 login 屬性組成一個 no-reply email,
        // 並以 login 填補 fullName、以 id 填補 subject(如尚未取得)
        if (StringUtils.equalsIgnoreCase(oauth2ProviderName, "github")) {
            String login = oauth2User.getAttribute("login");
            String githubId = oauth2User.getAttribute("id") != null ? TypeConvert.toString(oauth2User.getAttribute("id")) : null;
            if (StringUtils.isBlank(email) && StringUtils.isNotBlank(login)) {
                // GitHub no-reply fallback
                email = login + "@users.noreply.github.com";
            }
            if (StringUtils.isBlank(fullName) && StringUtils.isNotBlank(login)) {
                fullName = login;
            }
            if (StringUtils.isBlank(subject) && StringUtils.isNotBlank(githubId)) {
                subject = githubId;
            }
        }

        return syncOAuth2User(email, fullName, oauth2ProviderName, subject);
    }

    /**
     * 更新最後認證成功的方法
     * 
     * @param userId
     *                   login user ID
     * @param authMethod
     *                   last success authentication method
     */
    public void updateSysUserLastAuth(String userId, String authMethod) {
        SysUserProfileEntity.DataKey dataKey = new SysUserProfileEntity.DataKey();
        dataKey.setUserId(userId);
        Optional<SysUserProfileEntity> optionalSysUserProfileEntity = sysUserProfileRepository.findById(dataKey);
        if (optionalSysUserProfileEntity.isPresent()) {
            SysUserProfileEntity oneSysUserProfileEntity = optionalSysUserProfileEntity.get();
            oneSysUserProfileEntity.setUserLastAuth(authMethod);
            sysUserProfileRepository.saveAndFlush(oneSysUserProfileEntity);
        } else {
            log.warn("Cannot update user last auth, user not found: {}", userId);
        }                   
    }
}


audit (系統存取相關稽核功能)

1. SysSecurityAuditLogService (稽核記錄服務)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/SysSecurityAuditLogService.java

package tw.lewishome.webapp.base.security.audit;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;
import tw.lewishome.webapp.GlobalConstants;
import tw.lewishome.webapp.base.utility.common.NetUtils;
import tw.lewishome.webapp.database.primary.entity.SysAccessLogEntity;
import tw.lewishome.webapp.database.primary.repository.SysAccessLogRepository;

/**
 * SysAccessLog Audit服務
 * 
 *
 * @author lewis
 */
@Component
public class SysSecurityAuditLogService {
    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SysSecurityAuditLogService instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SysSecurityAuditLogService() {
        // Constructor body (can be empty)
    }

    @Autowired
    private SysAccessLogRepository sysAccessLogRepository;

    /**
     * 將傳入的 HttpServletRequest 與指定的Audit動作封裝成 SysAccessLogEntity 並立即儲存為存取日誌。
     * 此方法會:
     *  - 從 request.getSession().getId() 取得 sessionId
     *  - 使用 NetUtils.getClientIpAddress(request) 取得客戶端 IP
     *  - 以 GlobalConstants.HOST_SERVER_NAME 作為伺服器名稱
     *  - 以 request.getRequestURI() 作為存取的 URL
     *  - 以 request.getParameter("username") 作為存取使用者(若無此參數則為 null)
     *  - 將傳入的 auditAction 設為動作欄位
     *  - 呼叫 sysAccessLogRepository.saveAndFlush(...) 將記錄立即寫入持久層
     *
     * 注意:
     *  - request 不可為 null,且須可存取 session;否則可能拋出 NullPointerException 或 IllegalStateException。
     *  - 若持久層儲存失敗,會拋出相應的運行時例外(例如資料存取相關例外)。
     *
     * @param request 要記錄之 HttpServletRequest,供擷取 sessionId、client IP、request URI 與 "username" 參數
     * @param auditAction 要記錄的Audit動作字串(例如 "LOGIN", "LOGOUT", "UPDATE" 等)
     * @throws NullPointerException 若傳入的 request 為 null 時可能拋出
     * @throws RuntimeException 若在儲存或 flush 過程中發生持久層錯誤,會拋出相關運行時例外
     */
    public void addNewAccessLog(HttpServletRequest request,String auditAction ) {

        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(request.getSession().getId());
        newSysAccessLogEntity.setRemoteIp(NetUtils.getClientIpAddress(request));
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl(request.getRequestURI());
        newSysAccessLogEntity.setAccessUser(request.getParameter("username"));
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }

    /**
     * 針對已過期的工作階段記錄存取日誌。
     * 
     * @param sessionId 工作階段識別碼,用於查找相關的登入記錄
     * @param auditAction 稽核動作類型,用於標示存取日誌的動作
     * 
     * 此方法會:
     * 1. 根據工作階段識別碼查找對應的登入成功記錄
     * 2. 如果找不到登入記錄,則直接返回
     * 3. 建立新的存取日誌實體,繼承原登入記錄的相關資訊
     * 4. 將新的存取日誌儲存至資料庫
     */
    public void addExpiredAccessLog(String sessionId,String auditAction) {
        // find login access log info
        SysAccessLogEntity oneSAccessLogEntity = sysAccessLogRepository.findBySessionIdAndAction(sessionId,"Login Success");
        if (oneSAccessLogEntity == null) {
            return;
        }
        // create new access log entity        
        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(sessionId);
        newSysAccessLogEntity.setRemoteIp(oneSAccessLogEntity.getRemoteIp());
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl("expired by session event");
        newSysAccessLogEntity.setAccessUser(oneSAccessLogEntity.getAccessUser());
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }     
    
    /**
     * 記錄使用者JWT存取日誌
     * 
     * @param request HTTP請求物件,用於獲取會話ID、客戶端IP和請求URI
     * @param useName 使用者名稱
     * @param auditAction 稽核動作描述
     * 
     * 此方法將創建一個新的系統存取日誌實體(SysAccessLogEntity),
     *          並保存以下資訊:
     *          - 會話ID
     *          - 遠端IP位址
     *          - 伺服器名稱
     *          - 存取URL
     *          - 存取使用者
     *          - 執行動作
     *          最後將日誌資料持久化儲存到資料庫中。
     */
    public void addJWTAccessLog(HttpServletRequest request ,String useName,String auditAction) {
        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(request.getSession().getId());
        newSysAccessLogEntity.setRemoteIp(NetUtils.getClientIpAddress(request));
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl(request.getRequestURI());
        newSysAccessLogEntity.setAccessUser(useName);
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }
}

2. LoginSuccessLogger (登入成功稽核)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/LoginSuccessLogger.java

package tw.lewishome.webapp.base.security.audit;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;
import tw.lewishome.webapp.GlobalConstants;
import tw.lewishome.webapp.base.utility.common.NetUtils;
import tw.lewishome.webapp.database.primary.entity.SysAccessLogEntity;
import tw.lewishome.webapp.database.primary.repository.SysAccessLogRepository;

/**
 * SysAccessLog Audit服務
 * 
 *
 * @author lewis
 */
@Component
public class SysSecurityAuditLogService {
    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SysSecurityAuditLogService instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SysSecurityAuditLogService() {
        // Constructor body (can be empty)
    }

    @Autowired
    private SysAccessLogRepository sysAccessLogRepository;

    /**
     * 將傳入的 HttpServletRequest 與指定的Audit動作封裝成 SysAccessLogEntity 並立即儲存為存取日誌。
     * 此方法會:
     *  - 從 request.getSession().getId() 取得 sessionId
     *  - 使用 NetUtils.getClientIpAddress(request) 取得客戶端 IP
     *  - 以 GlobalConstants.HOST_SERVER_NAME 作為伺服器名稱
     *  - 以 request.getRequestURI() 作為存取的 URL
     *  - 以 request.getParameter("username") 作為存取使用者(若無此參數則為 null)
     *  - 將傳入的 auditAction 設為動作欄位
     *  - 呼叫 sysAccessLogRepository.saveAndFlush(...) 將記錄立即寫入持久層
     *
     * 注意:
     *  - request 不可為 null,且須可存取 session;否則可能拋出 NullPointerException 或 IllegalStateException。
     *  - 若持久層儲存失敗,會拋出相應的運行時例外(例如資料存取相關例外)。
     *
     * @param request 要記錄之 HttpServletRequest,供擷取 sessionId、client IP、request URI 與 "username" 參數
     * @param auditAction 要記錄的Audit動作字串(例如 "LOGIN", "LOGOUT", "UPDATE" 等)
     * @throws NullPointerException 若傳入的 request 為 null 時可能拋出
     * @throws RuntimeException 若在儲存或 flush 過程中發生持久層錯誤,會拋出相關運行時例外
     */
    public void addNewAccessLog(HttpServletRequest request,String auditAction ) {

        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(request.getSession().getId());
        newSysAccessLogEntity.setRemoteIp(NetUtils.getClientIpAddress(request));
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl(request.getRequestURI());
        newSysAccessLogEntity.setAccessUser(request.getParameter("username"));
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }

    /**
     * 針對已過期的工作階段記錄存取日誌。
     * 
     * @param sessionId 工作階段識別碼,用於查找相關的登入記錄
     * @param auditAction 稽核動作類型,用於標示存取日誌的動作
     * 
     * 此方法會:
     * 1. 根據工作階段識別碼查找對應的登入成功記錄
     * 2. 如果找不到登入記錄,則直接返回
     * 3. 建立新的存取日誌實體,繼承原登入記錄的相關資訊
     * 4. 將新的存取日誌儲存至資料庫
     */
    public void addExpiredAccessLog(String sessionId,String auditAction) {
        // find login access log info
        SysAccessLogEntity oneSAccessLogEntity = sysAccessLogRepository.findBySessionIdAndAction(sessionId,"Login Success");
        if (oneSAccessLogEntity == null) {
            return;
        }
        // create new access log entity        
        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(sessionId);
        newSysAccessLogEntity.setRemoteIp(oneSAccessLogEntity.getRemoteIp());
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl("expired by session event");
        newSysAccessLogEntity.setAccessUser(oneSAccessLogEntity.getAccessUser());
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }     
    
    /**
     * 記錄使用者JWT存取日誌
     * 
     * @param request HTTP請求物件,用於獲取會話ID、客戶端IP和請求URI
     * @param useName 使用者名稱
     * @param auditAction 稽核動作描述
     * 
     * 此方法將創建一個新的系統存取日誌實體(SysAccessLogEntity),
     *          並保存以下資訊:
     *          - 會話ID
     *          - 遠端IP位址
     *          - 伺服器名稱
     *          - 存取URL
     *          - 存取使用者
     *          - 執行動作
     *          最後將日誌資料持久化儲存到資料庫中。
     */
    public void addJWTAccessLog(HttpServletRequest request ,String useName,String auditAction) {
        SysAccessLogEntity newSysAccessLogEntity = new SysAccessLogEntity();
        SysAccessLogEntity.DataKey dataKey = new SysAccessLogEntity.DataKey();
        newSysAccessLogEntity.setDataKey(dataKey);
        newSysAccessLogEntity.setSessionId(request.getSession().getId());
        newSysAccessLogEntity.setRemoteIp(NetUtils.getClientIpAddress(request));
        newSysAccessLogEntity.setServerName(GlobalConstants.HOST_SERVER_NAME);
        newSysAccessLogEntity.setAccessUrl(request.getRequestURI());
        newSysAccessLogEntity.setAccessUser(useName);
        newSysAccessLogEntity.setAction(auditAction);
        sysAccessLogRepository.saveAndFlush(newSysAccessLogEntity);
    }
}

3. LoginFailureLogger (登入失敗稽核)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/LoginFailureLogger.java

package tw.lewishome.webapp.base.security.audit;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 *
 * LoginFailureLogger 是一個 Spring Component,繼承自
 * {@link org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler},
 * 用於處理使用者登入失敗時的相關紀錄與後續動作。
 *
 *
 *
 * 當使用者登入失敗時,會觸發
 * {@link #onAuthenticationFailure(HttpServletRequest, HttpServletResponse, AuthenticationException)}
 * 方法,
 * 此方法會將登入失敗的相關資訊(如 Session ID、遠端 IP、伺服器名稱、存取 URL、使用者帳號等)記錄至資料庫,
 * 以利後續稽覈與安全分析。紀錄完成後,會將使用者導向至 "/home?retry" 頁面。
 *
 *
 *
 * 主要功能包含:
 * <ul>
 * <li>記錄登入失敗事件至 SysAccessLog 資料表</li>
 * <li>收集並儲存登入失敗時的相關資訊(Session、IP、帳號等)</li>
 * <li>導向使用者至重試登入頁面</li>
 * </ul>
 *
 *
 *
 * 依賴:
 * <ul>
 * <li>{@link tw.lewishome.webapp.database.primary.repository.SysAccessLogRepository}:用於存取與儲存登入失敗的稽覈紀錄</li>
 * <li>{@link tw.lewishome.webapp.base.utility.common.NetUtils#getClientIpAddress(HttpServletRequest)}:取得用戶端
 * IP 位址</li>
 * <li>{@link tw.lewishome.webapp.GlobalConstants#HOST_SERVER_NAME}:取得伺服器主機名稱</li>
 * </ul>
 *
 *
 * @author Lewis
 * @since 2024
 */
@Component
@Slf4j
public class LoginFailureLogger extends SimpleUrlAuthenticationFailureHandler {

        /**
         * Fix for javadoc warning :
         * use of default constructor, which does not provide a comment
         * Constructs a new LoginFailureLogger instance.
         * This is the default constructor, implicitly provided by the compiler
         * if no other constructors are defined.
         */
        public LoginFailureLogger() {
                // Constructor body (can be empty)
        }

        @Autowired
        private SysSecurityAuditLogService sysSecurityAuditLogService;

        @Autowired(required = false)
        SystemEnvReader systemEnvReader;

        /**  */
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException exception) throws IOException, ServletException {
                log.info("[LoginFailureLogger] onAuthenticationFailure - exception: {}", exception.getMessage());

                try {
                        sysSecurityAuditLogService.addNewAccessLog(request, "Login Failed");
                } catch (Throwable t) {
                        log.warn("[LoginFailureLogger] Audit log failed: {}", t.getMessage(), t);
                        System.err.println("Audit log failed in LoginFailureLogger: " + t);
                }

                try {
                        // Use direct response.sendRedirect() instead of getRedirectStrategy()
                        // to ensure redirect happens even if response was partially written
                        String loginPageUrl = systemEnvReader.getProperty("LOGIN_PAGE_URL",
                                        SecurityConstants.LOGIN_PAGE_URL);
                        String redirectUrl = request.getContextPath() + loginPageUrl + "?retry";
                        log.info("[LoginFailureLogger] Redirecting to: {} , response committed: {}", redirectUrl,
                                        response.isCommitted());

                        // Check if response is already committed
                        if (!response.isCommitted()) {
                                response.sendRedirect(redirectUrl);
                        } else {
                                log.warn("[LoginFailureLogger] Response already committed, cannot redirect");
                        }
                } catch (IOException e) {
                        log.error("[LoginFailureLogger] Failed to send redirect: {}", e.getMessage(), e);
                }
        }
}

4. SignOffSuccessLogger (登出稽核)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/SignOffSuccessLogger.java

package tw.lewishome.webapp.base.security.audit;

import java.io.IOException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tw.lewishome.webapp.base.security.SecurityConstants;
import tw.lewishome.webapp.base.utility.common.SystemEnvReader;

/**
 *
 * 登出成功日誌記錄器,繼承自
 * {@link org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler},
 * 用於在使用者成功登出時,記錄相關的存取日誌資訊。
 *
 *
 *
 * 此元件會於 Spring Security 登出成功事件觸發時,將使用者的登出行為、Session 資訊、IP、伺服器名稱、存取 URL 及使用者資訊等,
 * 寫入至系統存取日誌資料庫,方便後續稽覈與追蹤。
 *
 *
 *
 * 主要功能如下:
 * <ul>
 * <li>取得並記錄使用者的 Session ID。</li>
 * <li>取得並記錄使用者的遠端 IP 位址。</li>
 * <li>記錄伺服器名稱。</li>
 * <li>記錄存取的 URL。</li>
 * <li>記錄登出動作及使用者資訊。</li>
 * <li>將上述資訊存入</li>
 * <li>登出成功後導向至 /home?logout 頁面。</li>
 * </ul>
 * 典型使用情境:於 Spring Security 登出流程中,確保每次登出皆有完整的操作記錄,便於系統稽覈與安全追蹤。
 *
 *
 * @author Lewis
 */
@Component
public class SignOffSuccessLogger extends SimpleUrlLogoutSuccessHandler {
        /**
         * Fix for javadoc warning :
         * use of default constructor, which does not provide a comment
         * Constructs a new SignOffSuccessLogger instance.
         * This is the default constructor, implicitly provided by the compiler
         * if no other constructors are defined.
         * 
         */
        public SignOffSuccessLogger() {
                // Constructor body (can be empty)
        }

        @Autowired
        private SysSecurityAuditLogService sysSecurityAuditLogService;

        @Autowired(required = false)
        SystemEnvReader systemEnvReader;

        /**
         * 
         *
         * 系統Spring Boot Security相關程式 登入成功紀錄明細
         */
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
                        Authentication authentication)
                        throws IOException, ServletException {

                sysSecurityAuditLogService.addNewAccessLog(request, "Logout Success");
                String loginPageUrl = systemEnvReader.getProperty("LOGIN_PAGE_URL",SecurityConstants.LOGIN_PAGE_URL);
                getRedirectStrategy().sendRedirect(request, response, loginPageUrl + "?logout");
        }
}

5. SessionExpiredListener (作業話過期稽核)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/SessionExpiredListener.java

package tw.lewishome.webapp.base.security.audit;

// import org.apache.catalina.session.StandardSessionFacade;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.security.core.session.SessionDestroyedEvent;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

//https://www.baeldung.com/spring-security-session

/**
 * 監聽 Spring Security 的 SessionDestroyedEvent 事件元件,當使用者的 Session 過期或被銷毀時自動觸發。
 *
 * 此監聽器主要負責記錄使用者的存取紀錄,將 Session 結束時的相關資訊(如 Session ID、遠端 IP、使用者帳號、伺服器名稱等)
 * 儲存至資料庫,方便後續稽覈與追蹤。
 *
 *
 *
 * 詳細流程說明:
 *  <ul>
 * <li>取得被銷毀的 Session ID。</li>
 * <li>透過 SysAccessLogRepository 查詢該 Session 是否有「Login Success」的存取紀錄。</li>
 * <li>若無登入成功紀錄,則不用紀錄</li>
 * <li>將SessionExpired紀錄儲存至資料庫。</li>
 *  </ul>
 *
 *
 *
 * 適用於需要稽覈使用者登入、登出、Session 過期等安全事件的系統。
 *
 *
 * @author Lewis
 * @version $Id: $Id
 */
@Component
@Slf4j
public class SessionExpiredListener implements ApplicationListener<SessionDestroyedEvent> {
    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SessionExpiredListener instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SessionExpiredListener() {
        // Constructor body (can be empty)
    }

    @Autowired
    private SysSecurityAuditLogService sysSecurityAuditLogService;

    /**  */
    @Override
    public void onApplicationEvent(@NonNull SessionDestroyedEvent event) {
        try {
            String sessionId = event.getId();
            // SysAccessLog
            log.info("Session Destroyed Event for sessionId: {}", sessionId);
            sysSecurityAuditLogService.addExpiredAccessLog(sessionId, "Session expired");

        } catch (Exception e) {
            log.error("SessionExpiredListener Exception: {}", e.getMessage(), e);
        }
    }

}

6. SessionExpiredConfig (作業過期設定)

位置:src/main/java/tw/lewishome/webapp/base/security/audit/SessionExpiredConfig.java

package tw.lewishome.webapp.base.security.audit;

import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.session.HttpSessionEventPublisher;

//https://stackoverflow.com/questions/52838124/applicationlistenersessiondestroyedevent-is-not-called

/**
 *SessionExpiredConfig 類別用於詳細配置 Spring Boot 應用程式的 Session 過期監聽機制。 
 *
 *此類別會將 {@link org.springframework.security.web.session.HttpSessionEventPublisher} 註冊為 Servlet 監聽器,
 * 使 Spring Security 能夠監控 HTTP Session 的建立與銷毀事件。這對於以下安全性需求至關重要: 
 * <ul>
 *   <li>即時偵測使用者 Session 過期,提升應用程式安全性。</li>
 *   <li>支援多重登入控制(如:同一帳號僅允許單一 Session 存在)。</li>
 *   <li>協助觸發 SessionDestroyedEvent,便於記錄使用者登出或 Session 失效行為。</li>
 *   <li>與 Spring Security 的 ConcurrentSessionControlAuthenticationStrategy 配合,防止 Session 併發。</li>
 * </ul>
 *
 *此設定通常用於需要嚴格控管使用者登入狀態的企業級應用程式,
 * 例如:金融、電商、或需遵循資安規範的系統。 
 *
 *主要流程: 
 *  <ul>
 *   <li>Spring Boot 啟動時,透過 @Bean 註冊 HttpSessionEventPublisher。</li>
 *   <li>當 Session 建立或銷毀時,HttpSessionEventPublisher 會發送相應事件給 Spring Security。</li>
 *   <li>Spring Security 根據事件執行登入狀態管理、Session 過期處理等邏輯。</li>
 *  </ul>
 *
 *範例程式碼: 
 * <pre>
 * &#64;Configuration
 * public class SessionExpiredConfig {
 *     &#64;Bean
 *     public ServletListenerRegistrationBean&lt;HttpSessionEventPublisher&gt; httpSessionEventPublisher() {
 *         return new ServletListenerRegistrationBean&lt;&gt;(new HttpSessionEventPublisher());
 *     }
 * }
 * </pre>
 *
 *建議搭配 Spring Security 設定檔案(如 SecurityConfig)一同使用,
 * 以達到最佳的 Session 管理效果。 
 *
 * @author Lewis
 * @since 1.0
 */
@Configuration
public class SessionExpiredConfig {

    /**
     * Fix for javadoc warning :
     * use of default constructor, which does not provide a comment
     * Constructs a new SessionExpiredConfig instance.
     * This is the default constructor, implicitly provided by the compiler
     * if no other constructors are defined.
     */
    public SessionExpiredConfig() {
        // Constructor body (can be empty)
    }

    /**
     *httpSessionEventPublisher. 
     *
     * @return ServletListenerRegistrationBean ServletListenerRegistrationBean
     */
    @Bean
    public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
        return new ServletListenerRegistrationBean<HttpSessionEventPublisher>(new HttpSessionEventPublisher());
    }
}


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

尚未有邦友留言

立即登入留言