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等等)。
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 -->
主應目錄與程式(全部)
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 (登入成功頁面)
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/**"));
}
位置: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;
}
}
}
位置: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);
}
位置: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);
}
}
}
位置: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);
}
}
位置: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);
}
}
位置: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);
}
}
}
位置: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");
}
}
位置: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);
}
}
}
位置: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>
* @Configuration
* public class SessionExpiredConfig {
* @Bean
* public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
* return new ServletListenerRegistrationBean<>(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());
}
}