diff --git a/pom.xml b/pom.xml index ac8a5d0..6bcd663 100644 --- a/pom.xml +++ b/pom.xml @@ -39,20 +39,12 @@ - com.arrokoth.framework.boot arrokoth-framework-starter ${arrokoth.version} - - - com.arrokoth.framework - basic-authorization-server - 1.2.0.RELEASE - - org.springframework.boot spring-boot-configuration-processor @@ -81,6 +73,15 @@ + + + + com.nimbusds + nimbus-jose-jwt + 9.47 + + + com.mysql mysql-connector-j @@ -93,6 +94,18 @@ 5.8.39 + + net.sf.uadetector + uadetector-resources + 2014.10 + + + jsr305 + com.google.code.findbugs + + + + diff --git a/src/main/java/com/arrokoth/standalone/authorization/StandaloneServerApplication.java b/src/main/java/com/arrokoth/standalone/authorization/StandaloneServerApplication.java index 2c743ff..8e434cc 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/StandaloneServerApplication.java +++ b/src/main/java/com/arrokoth/standalone/authorization/StandaloneServerApplication.java @@ -1,12 +1,10 @@ package com.arrokoth.standalone.authorization; -import com.arrokoth.basic.annotation.EnableArrokothBasicModule; import com.arrokoth.framework.boot.annotation.EnableGracefulRestResponse; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @EnableGracefulRestResponse -@EnableArrokothBasicModule @SpringBootApplication public class StandaloneServerApplication { diff --git a/src/main/java/com/arrokoth/standalone/authorization/common/basic/BasicModel.java b/src/main/java/com/arrokoth/standalone/authorization/common/basic/BasicModel.java new file mode 100644 index 0000000..643ac9a --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/common/basic/BasicModel.java @@ -0,0 +1,41 @@ +package com.arrokoth.standalone.authorization.common.basic; + +import java.util.Date; +import java.util.List; + +public class BasicModel { + + public record Token( + String access_token, + String refresh_token, + String token_type, + long expires_in + ) { + } + + + public record BasicUser( + String username, + String nickname, + String avatar, + String introduction, + List roles, + List permissions + ) { + } + + + + public record UserSessionInfo( + String username, + String sessionId, + Date lastRequest, + boolean expired + ) { + } + + public record LoginRequest(String username, String password) { + } + + +} diff --git a/src/main/java/com/arrokoth/standalone/authorization/common/exception/JwtException.java b/src/main/java/com/arrokoth/standalone/authorization/common/exception/JwtException.java new file mode 100644 index 0000000..920886f --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/common/exception/JwtException.java @@ -0,0 +1,16 @@ +package com.arrokoth.standalone.authorization.common.exception; + + + + +public class JwtException extends RuntimeException { + + + public JwtException(String message) { + super(message); + } + + public JwtException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/common/util/JwtUtils.java b/src/main/java/com/arrokoth/standalone/authorization/common/util/JwtUtils.java new file mode 100644 index 0000000..bfe3b04 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/common/util/JwtUtils.java @@ -0,0 +1,307 @@ +package com.arrokoth.standalone.authorization.common.util; + +import com.arrokoth.standalone.authorization.common.exception.JwtException; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import java.security.SecureRandom; +import java.text.ParseException; +import java.util.*; + +@Slf4j +public class JwtUtils { + + + // ================== 配置常量 ================== + private static final String ISSUER = "https://www.arrokoth-info.com"; + public static final Long DEFAULT_EXPIRATION = 3600000L; // 默认有效期 1小时 (ms) + private static final String SECRET_KEY = generateSecureSecret(); + private static final Long REFRESH_EXPIRATION = 86400000L; // 24 小时 + private static final String REFRESH_SECRET_KEY = generateSecureSecret(); + private static final Map BLACKLIST = new HashMap<>(); + + // ================== Token 创建方法 ================== + + /** + * 创建一个新的访问 Token + */ + public static String createAccessToken(String username) { + try { + return createAccessToken(username, username, DEFAULT_EXPIRATION); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } + + /** + * 创建一个新的访问 Token,并指定过期时间 + */ + public static String createAccessToken(String subject, String username, Long expiration) throws JOSEException { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .issuer(ISSUER) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + expiration)) + .jwtID(UUID.randomUUID().toString()) + .claim("username", username) + .build(); + + JWSHeader header = new JWSHeader(JWSAlgorithm.HS256); + SignedJWT signedJWT = new SignedJWT(header, claimsSet); + + MACSigner signer = new MACSigner(SECRET_KEY.getBytes()); + signedJWT.sign(signer); + + return signedJWT.serialize(); + } + + /** + * 创建 Refresh Token + */ + public static String createRefreshToken(String subject) throws JwtException { + try { + JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() + .subject(subject) + .issuer(ISSUER) + .issueTime(new Date()) + .expirationTime(new Date(System.currentTimeMillis() + REFRESH_EXPIRATION)) + .jwtID(UUID.randomUUID().toString()) + .build(); + + JWSHeader header = new JWSHeader(JWSAlgorithm.HS256); + SignedJWT signedJWT = new SignedJWT(header, claimsSet); + + MACSigner signer = new MACSigner(REFRESH_SECRET_KEY.getBytes()); + signedJWT.sign(signer); + + return signedJWT.serialize(); + } catch (JOSEException e) { + throw new JwtException("Failed to sign refresh token", e); + } + } + + // ================== Token 解析验证方法 ================== + + /** + * 解析并验证 Token + */ + public static JWTClaimsSet parseToken(String token) { + try { + SignedJWT signedJWT = SignedJWT.parse(token); + + MACVerifier verifier = new MACVerifier(SECRET_KEY.getBytes()); + if (!signedJWT.verify(verifier)) { + throw new JwtException("Invalid signature"); + } + + return signedJWT.getJWTClaimsSet(); + } catch (ParseException | JOSEException e) { + throw new JwtException("Failed to parse token", e); + } + } + + /** + * 验证 Token 是否属于当前用户且未过期 + */ + public static Boolean validateToken(String token, String username) { + try { + JWTClaimsSet claims = parseToken(token); + + if (isTokenBlacklisted(token)) { + log.error("Token is blacklisted."); + return false; + } + + if (!claims.getSubject().equals(username)) { + log.error("Subject mismatch."); + return false; + } + + if (claims.getExpirationTime().before(new Date())) { + log.error("Token expired."); + return false; + } + + return true; + + } catch (JwtException e) { + System.err.println("Token validation failed: " + e.getMessage()); + return false; + } + } + + /** + * 验证 Refresh Token 是否有效 + */ + public static Boolean validateRefreshToken(String refreshToken, String expectedSubject) throws JwtException { + try { + SignedJWT signedJWT = SignedJWT.parse(refreshToken); + + MACVerifier verifier = new MACVerifier(REFRESH_SECRET_KEY.getBytes()); + if (!signedJWT.verify(verifier)) { + return false; + } + + JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); + if (claims.getExpirationTime().before(new Date())) { + return false; + } + + return claims.getSubject().equals(expectedSubject); + } catch (ParseException | JOSEException e) { + throw new JwtException("Invalid refresh token", e); + } + } + + // ================== Token 信息提取方法 ================== + + /** + * 提取用户名 + */ + public static String extractUsername(String token) { + return parseToken(token).getSubject(); + } + + /** + * 获取 Token 的过期时间 + */ + public static Date extractExpiration(String token) { + return parseToken(token).getExpirationTime(); + } + + /** + * 获取 Token 的 jti (JWT ID) + */ + public static String extractJti(String token) { + return parseToken(token).getJWTID(); + } + + /** + * 判断 Token 是否已过期 + */ + public static Boolean isTokenExpired(String token) { + try { + return extractExpiration(token).before(new Date()); + } catch (Exception e) { + return true; // 出错也视为过期 + } + } + + // ================== Token 黑名单管理 ================== + + /** + * 将 Token 加入黑名单 + */ + public static void invalidateToken(String token) throws JwtException { + JWTClaimsSet claims = parseToken(token); + String jti = claims.getJWTID(); + Date expiration = claims.getExpirationTime(); + BLACKLIST.put(jti, expiration); + } + + /** + * 判断 Token 是否在黑名单中 + */ + public static Boolean isTokenBlacklisted(String token) throws JwtException { + JWTClaimsSet claims = parseToken(token); + String jti = claims.getJWTID(); + Date expiration = claims.getExpirationTime(); + + if (BLACKLIST.containsKey(jti)) { + return true; + } + // 清理过期 Token(可定时任务执行) + BLACKLIST.entrySet().removeIf(entry -> entry.getValue().before(new Date())); + return false; + } + + // ================== 辅助工具方法 ================== + + /** + * 生成安全的密钥(Base64 编码的 256 位密钥) + */ + private static String generateSecureSecret() { + byte[] key = new byte[32]; // 256 bits + new SecureRandom().nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + + /** + * 从请求头提取 Token + */ + public static String extractTokenFromHeader(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; + } + + // ================== 测试入口 ================== + public void main(String[] args) { + log.error("=== 模拟用户登录 ==="); + + String username = "user123"; + String accessToken = JwtUtils.createAccessToken(username); + String refreshToken = JwtUtils.createRefreshToken(username); + + log.info("Access Token: {}", accessToken); + log.info("Refresh Token: {}", refreshToken); + + log.error("=== 验证 Access Token ==="); + try { + Boolean valid = JwtUtils.validateToken(accessToken, username); + log.info("Is access token valid? {}", valid); + } catch (JwtException e) { + System.err.println("Validation failed: " + e.getMessage()); + } + + log.error("=== 使用 Refresh Token 刷新 Access Token ==="); + try { + Boolean refreshValid = JwtUtils.validateRefreshToken(refreshToken, username); + if (refreshValid) { + String newAccessToken = JwtUtils.createAccessToken(username); // 重新生成 + log.info("New Access Token: {}", newAccessToken); + } else { + System.err.println("Refresh token invalid."); + } + } catch (JwtException e) { + System.err.println("Refresh failed: " + e.getMessage()); + } + + log.error("=== 注销 Token ==="); + try { + JwtUtils.invalidateToken(accessToken); + Boolean blacklisted = JwtUtils.isTokenBlacklisted(accessToken); + log.info("Is access token blacklisted? {}", blacklisted); + } catch (JwtException e) { + System.err.println("Invalidate failed: " + e.getMessage()); + } + + log.error("=== 再次验证已被注销的 Token ==="); + try { + Boolean stillValid = JwtUtils.validateToken(accessToken, username); + log.info("Is invalidated token still valid? {}", stillValid); + } catch (JwtException e) { + System.err.println("Validation after logout failed: " + e.getMessage()); + } + + log.error("=== 模拟 Token 过期 ==="); + try { + String expiredToken = createAccessToken("temp_user", "temp_user", 1000L); // 1秒有效期 + log.info("Expired Token: {}", expiredToken); + Thread.sleep(1000); + Boolean expired = JwtUtils.isTokenExpired(expiredToken); + log.info("Is token expired? {}", expired); + } catch (Exception e) { + log.error("",e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/config/AuthorizationServerAutoConfigurer.java b/src/main/java/com/arrokoth/standalone/authorization/config/AuthorizationServerAutoConfigurer.java index de1cf2f..4b6055f 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/config/AuthorizationServerAutoConfigurer.java +++ b/src/main/java/com/arrokoth/standalone/authorization/config/AuthorizationServerAutoConfigurer.java @@ -1,7 +1,7 @@ package com.arrokoth.standalone.authorization.config; -import com.arrokoth.basic.properties.AuthorizationServerProperties; -import com.arrokoth.basic.properties.SecurityWebProperties; +import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties; +import com.arrokoth.standalone.authorization.properties.SecurityWebProperties; import lombok.AllArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/arrokoth/standalone/authorization/config/OAuthComponentConfig.java b/src/main/java/com/arrokoth/standalone/authorization/config/OAuthComponentConfig.java index 99c0041..e845b5b 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/config/OAuthComponentConfig.java +++ b/src/main/java/com/arrokoth/standalone/authorization/config/OAuthComponentConfig.java @@ -1,18 +1,43 @@ package com.arrokoth.standalone.authorization.config; -import com.arrokoth.basic.properties.AuthorizationServerProperties; -import com.arrokoth.basic.properties.SecurityWebProperties; +import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties; +import com.arrokoth.standalone.authorization.properties.SecurityWebProperties; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +@Slf4j @Configuration public class OAuthComponentConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean("sessionRegistry") + public SessionRegistry sessionRegistry() { + return new SessionRegistryImpl(); + } + + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + log.info("Creating authentication manager for authentication configuration"); + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean public AuthorizationServerSettings authorizationServerSettings(AuthorizationServerProperties authorizationServerProperties, SecurityWebProperties securityWebProperties) { @@ -23,9 +48,5 @@ public class OAuthComponentConfig { } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); // 仅用于演示,生产环境请使用BCryptPasswordEncoder - } } diff --git a/src/main/java/com/arrokoth/standalone/authorization/config/SecurityWebAutoConfigurer.java b/src/main/java/com/arrokoth/standalone/authorization/config/SecurityWebAutoConfigurer.java index c40e95e..dc359c4 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/config/SecurityWebAutoConfigurer.java +++ b/src/main/java/com/arrokoth/standalone/authorization/config/SecurityWebAutoConfigurer.java @@ -1,70 +1,94 @@ package com.arrokoth.standalone.authorization.config; - -import com.arrokoth.basic.properties.SecurityWebProperties; import com.arrokoth.standalone.authorization.filter.JwtRequestFilter; -import lombok.AllArgsConstructor; +import com.arrokoth.standalone.authorization.properties.SecurityWebProperties; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; @Slf4j @Configuration -@AllArgsConstructor +@RequiredArgsConstructor @EnableConfigurationProperties(SecurityWebProperties.class) public class SecurityWebAutoConfigurer { private final SecurityWebProperties securityWebProperties; + private final SessionRegistry sessionRegistry; + private final JwtRequestFilter jwtRequestFilter; - private final JwtRequestFilter jwtRequestFilter; // 你的 Token 过滤器 + private final AuthenticationSuccessHandler sampleAuthenticationSuccessHandler; + private final AuthenticationFailureHandler sampleAuthenticationFailureHandler; + private final LogoutSuccessHandler sampleLogoutSuccessHandler; @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { - log.debug("Configuring default security filter chain"); - http - .csrf(AbstractHttpConfigurer::disable) // 前后端分离通常关闭CSRF - .cors(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // 无状态Session - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS).permitAll() - .requestMatchers(securityWebProperties.getMergedPermittedUrls().toArray(new String[0])).permitAll() - .anyRequest().authenticated()) - .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); // 插入Token验证Filter - - http.formLogin(form -> form - .loginPage(securityWebProperties.getLoginPage()) - .loginProcessingUrl(securityWebProperties.getLoginProcessingUrl()) - .permitAll() - ) - - - .logout(logout -> logout - .logoutUrl(SecurityWebProperties.AXIOS_LOGOUT_PROCESSING_URL) -// .logoutSuccessUrl(securityWebProperties.getLogoutSuccessUrl()) - .invalidateHttpSession(true) // 注销时销毁 session - .deleteCookies("JSESSIONID", "Authorization") - .permitAll() - ); - - + log.info("Initializing security configuration with permitted URLs: {}", securityWebProperties.getMergedPermittedUrls()); + // 基础安全配置 + configureBaseSecurity(http); + // JWT认证配置 + configureJwtAuthentication(http); + // 表单登录配置 + configureFormLogin(http); + // 登出配置 + configureLogout(http); return http.build(); } - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { - log.info("Configuring authentication manager for authentication configuration"); - return authenticationConfiguration.getAuthenticationManager(); + private void configureBaseSecurity(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .maximumSessions(10) + .sessionRegistry(sessionRegistry) // ✅ 注入 sessionRegistry Bean + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS).permitAll() + .requestMatchers(securityWebProperties.getMergedPermittedUrls().toArray(new String[0])).permitAll() + .anyRequest().authenticated()); + } + + private void configureJwtAuthentication(HttpSecurity http) { + http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); + } + + private void configureFormLogin(HttpSecurity http) throws Exception { + + + http.formLogin(form -> form + .loginPage(securityWebProperties.getLoginPage()) + .defaultSuccessUrl("/dashboard") + .successHandler(sampleAuthenticationSuccessHandler) + .failureHandler(sampleAuthenticationFailureHandler) + .loginProcessingUrl(securityWebProperties.getLoginProcessingUrl()) + .permitAll()); + } + + private void configureLogout(HttpSecurity http) throws Exception { + http.logout(logout -> logout + .logoutUrl(SecurityWebProperties.AXIOS_LOGOUT_PROCESSING_URL) + .logoutSuccessHandler(sampleLogoutSuccessHandler) + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID", "Authorization") + .permitAll()); } + + + } \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/controller/AuthorizationController.java b/src/main/java/com/arrokoth/standalone/authorization/controller/AuthorizationController.java index dbb6f6a..d735db2 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/controller/AuthorizationController.java +++ b/src/main/java/com/arrokoth/standalone/authorization/controller/AuthorizationController.java @@ -10,6 +10,7 @@ import org.springframework.ui.Model; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.view.RedirectView; import java.security.Principal; import java.util.HashMap; @@ -28,14 +29,17 @@ public class AuthorizationController { private final OAuth2ConsentService oAuth2ConsentService; - - @GetMapping("/login") - public String home() { - return "login"; // 对应templates/login.html + public String login() { + return "login"; } - + @GetMapping("/login2") + public RedirectView home() { + RedirectView redirectView = new RedirectView(); + redirectView.setUrl("http://127.0.0.1:5173/#/login"); + return redirectView; + } @GetMapping(value = "/oauth2/consent") public String consent(Principal principal, Model model, diff --git a/src/main/java/com/arrokoth/standalone/authorization/controller/LoginController.java b/src/main/java/com/arrokoth/standalone/authorization/controller/LoginController.java index 1fb2a0a..0e0f9c0 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/controller/LoginController.java +++ b/src/main/java/com/arrokoth/standalone/authorization/controller/LoginController.java @@ -1,21 +1,21 @@ package com.arrokoth.standalone.authorization.controller; -import com.arrokoth.basic.properties.SecurityWebProperties; -import com.arrokoth.basic.request.LoginRequest; -import com.arrokoth.basic.response.Token; -import com.arrokoth.basic.service.AuthorizationService; +import com.arrokoth.standalone.authorization.common.basic.BasicModel; +import com.arrokoth.standalone.authorization.properties.SecurityWebProperties; +import com.arrokoth.standalone.authorization.service.AuthorizationService; +import com.arrokoth.standalone.authorization.common.util.JwtUtils; import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +@Slf4j @RequestMapping @RestController @RequiredArgsConstructor @@ -26,10 +26,24 @@ public class LoginController { @Operation(summary = "登录", description = "获取登录Token信息") @PostMapping(SecurityWebProperties.AXIOS_LOGIN_PROCESSING_URL) - public Token homeLogin(@Valid @RequestBody LoginRequest loginRequest) { - UsernamePasswordAuthenticationToken passwordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()); + public BasicModel.Token homeLogin(@Valid @RequestBody BasicModel.LoginRequest loginRequest) { + UsernamePasswordAuthenticationToken passwordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password()); Authentication authenticate = authenticationManager.authenticate(passwordAuthenticationToken); SecurityContextHolder.getContext().setAuthentication(authenticate); return authorizationService.login(loginRequest); } + + + @GetMapping(SecurityWebProperties.DEFAULT_GET_USER_INFO_URL) + @Operation(summary = "获取登录用户信息", description = "获取登录用户信息") + public BasicModel.BasicUser getBasicUser() { + return authorizationService.getBasicUser(); + } + + @Operation(summary = "登出", description = "获取登录Token信息") + @PostMapping(SecurityWebProperties.AXIOS_LOGOUT_PROCESSING_URL) + public void logout(HttpServletRequest request) { + String token = JwtUtils.extractTokenFromHeader(request); + authorizationService.logout(JwtUtils.extractJti(token)); + } } diff --git a/src/main/java/com/arrokoth/standalone/authorization/controller/OnlineUserController.java b/src/main/java/com/arrokoth/standalone/authorization/controller/OnlineUserController.java new file mode 100644 index 0000000..160ead0 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/controller/OnlineUserController.java @@ -0,0 +1,24 @@ +package com.arrokoth.standalone.authorization.controller; + +import com.arrokoth.standalone.authorization.common.basic.BasicModel; +import com.arrokoth.standalone.authorization.service.OnlineUserService; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/online/user") +@AllArgsConstructor +public class OnlineUserController { + + private final OnlineUserService onlineUserService; + + @GetMapping("/list") + public List list() { + return onlineUserService.getOnlineUsers(); + + } +} diff --git a/src/main/java/com/arrokoth/standalone/authorization/controller/TestController.java b/src/main/java/com/arrokoth/standalone/authorization/controller/TestController.java deleted file mode 100644 index f5a873c..0000000 --- a/src/main/java/com/arrokoth/standalone/authorization/controller/TestController.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.arrokoth.standalone.authorization.controller; - - -import org.springframework.web.bind.annotation.*; - -import java.util.HashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api/test") -public class TestController { - - /** - * GET 请求测试:无参数,直接返回字符串 - */ - @GetMapping("/hello") - public String sayHello() { - return "Hello, Spring Boot!"; - } - - /** - * GET 请求测试:带路径参数 - * 示例 URL: /api/test/user/123 - */ - @GetMapping("/user/{id}") - public String getUserById(@PathVariable Long id) { - return "User ID: " + id; - } - - /** - * GET 请求测试:带查询参数 - * 示例 URL: /api/test/greet?name=John - */ - @GetMapping("/greet") - public String greetUser(@RequestParam String name) { - return "Hello, " + name + "!"; - } - - /** - * POST 请求测试:接收 JSON 数据并返回 Map 响应 - */ - @PostMapping("/echo") - public Map echoData(@RequestBody Map payload) { - Map response = new HashMap<>(); - response.put("received", true); - response.put("data", payload); - return response; - } - - /** - * PUT 请求测试:更新资源 - * 示例 URL: /api/test/update/456 - */ - @PutMapping("/update/{id}") - public String updateResource(@PathVariable String id, @RequestBody Map payload) { - return "Resource ID " + id + " updated with data: " + payload; - } - - /** - * DELETE 请求测试:删除资源 - * 示例 URL: /api/test/delete/789 - */ - @DeleteMapping("/delete/{id}") - public String deleteResource(@PathVariable String id) { - return "Resource ID " + id + " deleted."; - } -} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/filter/JwtRequestFilter.java b/src/main/java/com/arrokoth/standalone/authorization/filter/JwtRequestFilter.java index c9b25e2..dfea692 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/filter/JwtRequestFilter.java +++ b/src/main/java/com/arrokoth/standalone/authorization/filter/JwtRequestFilter.java @@ -1,6 +1,6 @@ package com.arrokoth.standalone.authorization.filter; -import com.arrokoth.basic.util.JwtUtils; +import com.arrokoth.standalone.authorization.common.util.JwtUtils; import com.arrokoth.standalone.authorization.store.redis.RedisTokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAccessDeniedHandler.java b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAccessDeniedHandler.java new file mode 100644 index 0000000..5a39158 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package com.arrokoth.standalone.authorization.handle; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class SampleAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + String username = request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "匿名用户"; + String ipAddress = request.getRemoteAddr(); + String userAgent = request.getHeader("User-Agent"); + // 记录授权失败的详细信息 + log.info("授权失败 [原因: {}] 用户名={}, IP={}, 浏览器={}, URL={}", + accessDeniedException.getMessage(), // 添加失败原因 + username, ipAddress, userAgent, request.getRequestURI()); + // 返回403页面 + response.sendRedirect("/403"); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationFailureHandler.java b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationFailureHandler.java new file mode 100644 index 0000000..3ef7155 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationFailureHandler.java @@ -0,0 +1,58 @@ +package com.arrokoth.standalone.authorization.handle; + + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import net.sf.uadetector.ReadableUserAgent; +import net.sf.uadetector.UserAgentStringParser; +import net.sf.uadetector.service.UADetectorServiceFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +@Slf4j +@Component +public class SampleAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + + private static final UserAgentStringParser PARSER = UADetectorServiceFactory.getResourceModuleParser(); + + private final ApplicationEventPublisher eventPublisher; + + public SampleAuthenticationFailureHandler(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + // 设置错误信息 + String errorMessage = "用户名或密码输入错误"; + request.getSession().setAttribute("loginError", errorMessage); + + String sessionId = request.getSession().getId(); + + String username = request.getParameter("username"); // 获取用户输入的用户名 + String ipAddress = request.getRemoteAddr(); + String userAgent = request.getHeader(HttpHeaders.USER_AGENT); + + // 记录登录失败的详细信息 + log.info("登录失败: 用户名={}, IP地址={}, 浏览器信息={}, 错误原因={}", username, ipAddress, userAgent, exception.getMessage()); + // 重定向到登录页面 + + // 解析 User-Agent + ReadableUserAgent userAgentInfo = PARSER.parse(userAgent); + // 发布事件 + + setDefaultFailureUrl("/login?error=true"); + super.onAuthenticationFailure(request, response, exception); + } +} + diff --git a/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationSuccessHandler.java b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationSuccessHandler.java new file mode 100644 index 0000000..78c6c03 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleAuthenticationSuccessHandler.java @@ -0,0 +1,73 @@ +package com.arrokoth.standalone.authorization.handle; + +import com.arrokoth.standalone.authorization.common.util.JwtUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.sf.uadetector.ReadableUserAgent; +import net.sf.uadetector.UserAgentStringParser; +import net.sf.uadetector.service.UADetectorServiceFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@AllArgsConstructor +public class SampleAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + private static final UserAgentStringParser PARSER = UADetectorServiceFactory.getResourceModuleParser(); + private final ApplicationEventPublisher eventPublisher; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + String username = authentication.getName(); + String ipAddress = request.getRemoteAddr(); + String sessionId = request.getSession().getId(); + Object principal = authentication.getPrincipal(); + String userAgent = request.getHeader(HttpHeaders.USER_AGENT); + log.info("登录成功: 用户名={}, SessionId={}, IP地址={}, 浏览器信息={}, Principal={}, ", username, sessionId, ipAddress, userAgent, principal); + // 解析 User-Agent + ReadableUserAgent userAgentInfo = PARSER.parse(userAgent); + super.onAuthenticationSuccess(request, response, authentication); + + + // 生成 JWT Token + String accessToken = JwtUtils.createAccessToken(username); + String refreshToken = JwtUtils.createRefreshToken(username); + + // 设置响应头 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + // 构造返回的 Token 对象(可以定义一个 DTO) + Map data = new HashMap<>(); + data.put("access_token", accessToken); + data.put("refresh_token", refreshToken); + data.put("token_type", "Bearer"); + data.put("expires_in", JwtUtils.DEFAULT_EXPIRATION); + + Map body = new HashMap<>(); + body.put("code", HttpStatus.OK.value()); + body.put("success", Boolean.TRUE); + body.put("status", HttpStatus.OK.value()); + body.put("message", "success"); + body.put("data", "data"); + body.put("timestamp", System.currentTimeMillis()); + + // 序列化为 JSON 并写入响应 + ObjectMapper mapper = new ObjectMapper(); + mapper.writeValue(response.getWriter(), body); + + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/handle/SampleLogoutSuccessHandler.java b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleLogoutSuccessHandler.java new file mode 100644 index 0000000..7f270f9 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/handle/SampleLogoutSuccessHandler.java @@ -0,0 +1,58 @@ +package com.arrokoth.standalone.authorization.handle; + +import cn.hutool.core.collection.CollectionUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +@Slf4j +@Component +@RequiredArgsConstructor +public class SampleLogoutSuccessHandler implements LogoutSuccessHandler { + + + private final SessionRegistry sessionRegistry; + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + log.info("User logged out: {}", authentication != null ? authentication.getName() : "Unknown"); + + + if (authentication != null && authentication.getPrincipal() instanceof UserDetails) { + // 强制清理 SessionRegistry + List allSessions = sessionRegistry.getAllSessions(authentication.getPrincipal(), false); + if (CollectionUtil.isNotEmpty(allSessions)){ + allSessions.forEach(SessionInformation::expireNow); + } + } + + + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.OK.value()); + + Map body = new HashMap<>(); + body.put("code", HttpStatus.OK.value()); + body.put("success", Boolean.TRUE); + body.put("status", HttpStatus.OK.value()); + body.put("message", "Logout successful"); + body.put("timestamp", System.currentTimeMillis()); + new ObjectMapper().writeValue(response.getWriter(), body); + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/properties/AuthorizationServerProperties.java b/src/main/java/com/arrokoth/standalone/authorization/properties/AuthorizationServerProperties.java new file mode 100644 index 0000000..69d7485 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/properties/AuthorizationServerProperties.java @@ -0,0 +1,60 @@ +package com.arrokoth.standalone.authorization.properties; + + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 授权服务器相关配置属性 + */ +@Data +@ConfigurationProperties(prefix = "arrokoth.authorization.server") +public class AuthorizationServerProperties { + + private static final String DEFAULT_LOGIN_PAGE = "/login"; + private static final String DEFAULT_LOGOUT_SUCCESS_URL = "/login?logout"; + private static final List DEFAULT_PERMIT_URLS = Arrays.asList( + "/login", "/logout", "/connect/logout", + "/assets/**", "/static/**", "/webjars/**", "/error", "/oauth2/**" + ); + /** + * 授权确认页面路径,默认为 "/oauth2/consent" + */ + private String consentPage = "/oauth2/consent"; + /** + * OAuth2 授权端点路径,默认为 "/oauth2/authorize" + */ + private String authorizationEndpoint = "/oauth2/authorize"; + /** + * JWT Issuer 值,默认为 "https://www.arrokoth-info.com" + */ + private String issuer = "https://www.arrokoth-info.com"; + /** + * 登出成功后的跳转 URL。 + * 默认值为 {@link #DEFAULT_LOGOUT_SUCCESS_URL},即 "/login?logout"。 + * 如果在 application.yml 中配置了 app.security.logout-success-url,则使用配置值; + * 否则使用该默认值。 + */ + private String logoutSuccessUrl = DEFAULT_LOGOUT_SUCCESS_URL; + /** + * 需要放行(无需认证即可访问)的 URL 列表。 + * 默认为空列表,由 {@link #getMergedPermittedUrls()} 方法结合默认值进行合并处理。 + */ + private List permitUrls = new ArrayList<>(); + + + /** + * 合并默认值与自定义配置,并去重 + */ + public List getMergedPermittedUrls() { + return Stream.concat(DEFAULT_PERMIT_URLS.stream(), permitUrls.stream()) + .distinct() + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/properties/SecurityWebProperties.java b/src/main/java/com/arrokoth/standalone/authorization/properties/SecurityWebProperties.java new file mode 100644 index 0000000..1dfd170 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/properties/SecurityWebProperties.java @@ -0,0 +1,161 @@ +package com.arrokoth.standalone.authorization.properties; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 授权服务器相关配置属性类。 + *

+ * 用于绑定配置文件中以 "arrokoth.security.web" 为前缀的属性, + * 并提供登录、登出、权限放行等配置项。 + *

+ */ +@Slf4j +@Data +@Component +@ConfigurationProperties(prefix = "arrokoth.security.web") +public class SecurityWebProperties { + + private String frontendSuccessUrl = "http://127.0.0.1:5173/#/dashboard"; + private String frontendLogoutSuccessUrl = "http://127.0.0.1:5173/#/login"; + + /** + * Axios 请求使用的登录处理 URL。 + * 默认值为 "/user/login"。 + */ + public static final String AXIOS_LOGIN_PROCESSING_URL = "/user/login"; + + /** + * Axios 请求使用的登出处理 URL。 + * 默认值为 "/home/logout"。 + */ + public static final String AXIOS_LOGOUT_PROCESSING_URL = "/user/logout"; + + /** + * 默认的登录处理 URL。 + * 默认值为 "/web/login"。 + */ + public static final String DEFAULT_LOGIN_PROCESSING_URL = "/web/login"; + + /** + * 默认的登出成功跳转 URL。 + * 默认值为 "/login?logout"。 + */ + public static final String DEFAULT_LOGOUT_SUCCESS_URL = "/login?logout"; + + + + + + /** + * 默认的登出成功跳转 URL。 + * 默认值为 "/login?logout"。 + */ + public static final String DEFAULT_GET_USER_INFO_URL = "/user/info"; + + /** + * 默认的登出页面 URL。 + * 默认值为 "/login"。 + */ + private static final String DEFAULT_LOGOUT_PAGE = "/login"; + + /** + * 默认的无需认证即可访问的 URL 列表。 + */ + private static final List DEFAULT_PERMIT_URLS = Arrays.asList( + AXIOS_LOGIN_PROCESSING_URL, + DEFAULT_LOGIN_PROCESSING_URL, + "/login", + "/logout", + "/connect/logout", + "/assets/**", + "/static/**", + "/webjars/**", + "/actuator/**", + "/error", + "/oauth2/**" + ); + + /** + * Swagger 相关的无需认证即可访问的 URL 列表。 + */ + private static final List SWAGGER_URLS = Arrays.asList( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/doc.html", + "/swagger-ui.html" + ); + + + /** + * 登出成功后的跳转 URL。 + *

+ * 默认值为 {@link #DEFAULT_LOGOUT_SUCCESS_URL},即 "/login?logout"。 + * 如果在 application.yml 中配置了 arrokoth.security.web.logout-success-url,则使用配置值; + * 否则使用该默认值。 + */ + private String logoutSuccessUrl = DEFAULT_LOGOUT_SUCCESS_URL; + + + /** + * 系统登录首页。 + *

+ * 默认值为 "/login"。 + * 可通过 arrokoth.security.web.login-page 自定义。 + */ + private String loginPage = DEFAULT_LOGOUT_PAGE; + + + + /** + * 默认的登录处理 URL。 + * 默认值为 "/web/login"。 + */ + private String loginProcessingUrl = DEFAULT_LOGIN_PROCESSING_URL; + + + /** + * 是否启用 Swagger UI。 + *

+ * 默认值为 false。 + * 若启用(true),则自动将 Swagger 相关路径加入免认证访问列表。 + */ + private Boolean swaggerUi = false; + + /** + * 自定义需要放行(无需认证即可访问)的 URL 列表。 + *

+ * 默认为空列表,由 {@link #getMergedPermittedUrls()} 方法结合默认值进行合并处理。 + */ + private List permitUrls = new ArrayList<>(); + + /** + * 获取合并后的免认证访问 URL 列表。 + *

+ * 包括: + * - 默认的免认证路径(如登录页、静态资源等) + * - 用户自定义的免认证路径 + * - 如果启用了 Swagger,则包括 Swagger 资源路径 + * + * @return 合并去重后的 URL 列表 + */ + public List getMergedPermittedUrls() { + return Stream.>builder() + .add(DEFAULT_PERMIT_URLS) + .add(permitUrls != null ? permitUrls : Collections.emptyList()) + .add(swaggerUi ? SWAGGER_URLS : Collections.emptyList()) + .build() + .flatMap(List::stream) // 扁平化为 String 流 + .distinct() // 去重 + .sorted() // 排序(可选) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/arrokoth/standalone/authorization/service/AuthorizationService.java b/src/main/java/com/arrokoth/standalone/authorization/service/AuthorizationService.java new file mode 100644 index 0000000..7e6a900 --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/service/AuthorizationService.java @@ -0,0 +1,12 @@ +package com.arrokoth.standalone.authorization.service; + +import com.arrokoth.standalone.authorization.common.basic.BasicModel; + +public interface AuthorizationService { + + BasicModel.Token login(BasicModel.LoginRequest loginRequest); + + BasicModel.BasicUser getBasicUser(); + + void logout(String jti); +} diff --git a/src/main/java/com/arrokoth/standalone/authorization/service/OnlineUserService.java b/src/main/java/com/arrokoth/standalone/authorization/service/OnlineUserService.java new file mode 100644 index 0000000..d8a265e --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/service/OnlineUserService.java @@ -0,0 +1,15 @@ +package com.arrokoth.standalone.authorization.service; + +import com.arrokoth.standalone.authorization.common.basic.BasicModel; + +import java.util.List; + +public interface OnlineUserService { + + + /** + * 获取所有已登录用户的 Session 信息 + */ + List getOnlineUsers(); +} + diff --git a/src/main/java/com/arrokoth/standalone/authorization/service/impl/AuthorizationServiceImpl.java b/src/main/java/com/arrokoth/standalone/authorization/service/impl/AuthorizationServiceImpl.java new file mode 100644 index 0000000..b4ee36d --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/service/impl/AuthorizationServiceImpl.java @@ -0,0 +1,44 @@ +package com.arrokoth.standalone.authorization.service.impl; + +import com.arrokoth.standalone.authorization.common.basic.BasicModel; +import com.arrokoth.standalone.authorization.service.AuthorizationService; +import com.arrokoth.standalone.authorization.common.util.JwtUtils; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class AuthorizationServiceImpl implements AuthorizationService { + + @Override + public BasicModel.Token login(BasicModel.LoginRequest loginRequest) { + + String accessToken = JwtUtils.createAccessToken(loginRequest.username()); + String refreshToken = JwtUtils.createRefreshToken(loginRequest.username()); + + return new BasicModel.Token(accessToken, + refreshToken, + "Bearer", + JwtUtils.DEFAULT_EXPIRATION); + } + + + @Override + public BasicModel.BasicUser getBasicUser() { + return new BasicModel.BasicUser( + "admin", + "管理员", + "https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif", + "I am a super administrator...", + List.of("admin"), + List.of("*:*:*") + ); + } + + + @Override + public void logout(String jti) { + + } + +} diff --git a/src/main/java/com/arrokoth/standalone/authorization/service/impl/OnlineUserServiceImpl.java b/src/main/java/com/arrokoth/standalone/authorization/service/impl/OnlineUserServiceImpl.java new file mode 100644 index 0000000..0e97cda --- /dev/null +++ b/src/main/java/com/arrokoth/standalone/authorization/service/impl/OnlineUserServiceImpl.java @@ -0,0 +1,51 @@ +package com.arrokoth.standalone.authorization.service.impl; + +import cn.hutool.json.JSONUtil; +import com.arrokoth.standalone.authorization.common.basic.BasicModel; +import com.arrokoth.standalone.authorization.service.OnlineUserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OnlineUserServiceImpl implements OnlineUserService { + + + private final SessionRegistry sessionRegistry; + + @Override + public List getOnlineUsers() { + List result = new ArrayList<>(); + // 获取所有已认证的用户 principals + List principals = sessionRegistry.getAllPrincipals(); + + for (Object principal : principals) { + // principal 通常是 UserDetails 或 String(用户名) + String username; + if (principal instanceof UserDetails) { + username = ((UserDetails) principal).getUsername(); + } else { + username = principal.toString(); + } + // 获取该用户的所有 session + List sessions = sessionRegistry.getAllSessions(principal, false); + log.debug(JSONUtil.toJsonStr(sessions)); + + for (SessionInformation session : sessions) { + result.add(new BasicModel.UserSessionInfo(username, session.getSessionId(), session.getLastRequest(), session.isExpired())); + } + } + + return result; + } + + +} diff --git a/src/main/java/com/arrokoth/standalone/authorization/store/redis/RedisTokenService.java b/src/main/java/com/arrokoth/standalone/authorization/store/redis/RedisTokenService.java index 003a8fa..d17cb14 100644 --- a/src/main/java/com/arrokoth/standalone/authorization/store/redis/RedisTokenService.java +++ b/src/main/java/com/arrokoth/standalone/authorization/store/redis/RedisTokenService.java @@ -1,6 +1,6 @@ package com.arrokoth.standalone.authorization.store.redis; -import com.arrokoth.basic.util.JwtUtils; +import com.arrokoth.standalone.authorization.common.util.JwtUtils; import com.nimbusds.jose.JOSEException; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cc54724..7d31e7a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,7 @@ server: port: 8080 + servlet: + context-path: arrokoth: logger: @@ -62,7 +64,7 @@ logging: root: INFO com.arrokoth: DEBUG org.springdoc: INFO - org.springframework: INFO + org.springframework.security: DEBUG mybatis: type-aliases-package: com.arrokoth.**.domain diff --git a/src/main/resources/static/assets/css/login.css b/src/main/resources/static/assets/css/login.css index 6892bb2..5519f8f 100644 --- a/src/main/resources/static/assets/css/login.css +++ b/src/main/resources/static/assets/css/login.css @@ -1,77 +1,207 @@ -/* 页面整体布局 */ +/* 全局样式 */ body { + background: linear-gradient(135deg, #f5f7fa, #c3cfe2); + height: 100vh; margin: 0; - height: 100vh; /* 设置页面高度为视口高度 */ - display: flex; /* 使用 Flexbox 布局 */ - align-items: center; /* 垂直居中 */ - justify-content: flex-end; /* 水平靠右 */ - background-color: #f0f2f5; /* 背景颜色 */ + display: flex; + justify-content: center; + align-items: center; + font-family: Arial, sans-serif; } -/* 登录框样式 */ -.form-signin { - max-width: 400px; - padding: 15px; - background-color: #ffffff; +.login-container { + width: 100%; + max-width: 820px; + background: white; border-radius: 10px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - margin-right: 20px; /* 右侧留出一定间距 */ + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + display: flex; + overflow: hidden; } -.form-signin h1 { +.left-panel { + width: 50%; + padding: 20px; + background: linear-gradient(135deg, #4a5568, #333); + color: white; text-align: center; - margin-bottom: 20px; - color: #333; + display: flex; + flex-direction: column; + justify-content: center; } -.form-floating > input { - border-radius: 5px; - font-size: 14px; +.right-panel { + width: 50%; + padding: 30px; + display: flex; + flex-direction: column; } -.form-floating > label { - font-size: 14px; -} - -/* 验证码图片样式 */ -#captchaImage { - cursor: pointer; - transition: transform 0.2s ease; -} - -#captchaImage:hover { - transform: scale(1.05); -} - -/* 错误提示样式 */ -.alert { - margin-top: 10px; - font-size: 14px; -} - -/* 登录按钮样式 */ -.btn-primary { - background-color: #007bff; - border-color: #007bff; - font-size: 16px; - padding: 10px; -} - -.btn-primary:hover { - background-color: #0056b3; - border-color: #0056b3; -} - -/* 响应式设计 - 小屏幕适配 */ +/* 响应式设计 */ @media (max-width: 768px) { - body { - justify-content: center; /* 在小屏幕上水平居中 */ + .login-container { + flex-direction: column; + padding: 30px; } - .form-signin { - margin-right: 0; /* 移除右侧间距 */ - margin-left: 0; /* 确保左右对称 */ - max-width: 100%; /* 宽度占满容器 */ - width: 90%; /* 设置宽度为父容器的 90% */ + .left-panel, .right-panel { + width: 100%; + padding: 20px; } +} + +/* Tab 样式 */ +.login-form { + display: flex; + justify-content: space-around; + margin-bottom: 20px; +} + +.login-form a { + text-decoration: none; + color: #333; + font-size: 16px; + + border-bottom: 2px solid transparent; + transition: all 0.3s ease; +} + +.login-form a.active { + border-bottom: 2px solid #e74c3c; + color: #e74c3c; +} + +/* Tab 内容样式 */ +.tab-content { + display: none; + min-height: 260px; + overflow: hidden; + transition: opacity 0.3s ease; +} + +.tab-content.active { + display: block; + opacity: 1; +} + +/* 表单样式 */ +form { + padding: 20px; + border-radius: 5px; + background: #f9f9f9; +} + +.form-group { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.form-group label { + width: 70px; /* 固定宽度,确保所有标签对齐 */ + text-align: right; + margin-right: 10px; /* 距离输入框留出空隙 */ + color: #333; + font-weight: normal; + white-space: nowrap; /* 防止换行 */ +} +.form-group input, +.form-group select, +.form-group .captcha-btn { + flex: 1; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + box-sizing: border-box; +} + +.form-group input{ + flex: 2; +} + +#pass-captcha-input{ + width: 110px; +} + +#captcha-image{ + margin-left: 2px; /* 验证码图片与输入框之间的间距 */ + /*width: 70px;*/ + /*height: 50px;*/ + vertical-align: middle; + cursor:pointer; +} + +.submit-btn { + width: 100%; + padding: 10px; + background: linear-gradient(90deg, #e74c3c, #c0392b); + border: none; + border-radius: 5px; + color: white; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1); +} + + +.captcha-btn { + display: inline-block; + color: #333; + background-color: #fff; + border: 1px solid #333; + border-radius: 5px; + cursor: pointer; + transition: all 0.3s ease; + margin-bottom: 5px; +} + + + +.submit-btn:disabled, .captcha-btn:disabled { + background-color: #ccc; + cursor: not-allowed; +} + + +.submit-btn:hover { + background: linear-gradient(90deg, #c0392b, #e74c3c); + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +/* 社交登录样式 */ +.social-login { + display: flex; + justify-content: center; + margin-top: 20px; +} + +.social-login a { + font-size: 24px; + color: #333; + margin: 0 10px; + transition: all 0.3s ease; +} + +.social-login a:hover { + transform: scale(1.2); + color: #e74c3c; +} + +/* 页脚样式 */ +footer { + position: fixed; + bottom: 0; + width: 100%; + background: rgba(255, 255, 255, 0.8); + padding: 10px 0; + box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.1); +} + +footer p { + margin: 0; + font-size: 14px; + color: #777; + text-align: center; } \ No newline at end of file diff --git a/src/main/resources/static/assets/js/login.js b/src/main/resources/static/assets/js/login.js new file mode 100644 index 0000000..5c00e7b --- /dev/null +++ b/src/main/resources/static/assets/js/login.js @@ -0,0 +1,130 @@ +// assets/js/login.js + +document.addEventListener('DOMContentLoaded', function() { + // 获取上下文路径 + const CONTEXT_PATH = document.body.getAttribute('data-context-path') || ''; + + // 初始化 Tab 切换功能 + initTabs(); + + // 初始化验证码发送功能 + initCaptchaSender(); + + // 其他初始化函数可以在这里调用 +}); + +/** + * 初始化 Tab 切换功能 + */ +function initTabs() { + document.querySelectorAll('.tab-link').forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + + // 移除所有 Tab 的 active 类 + document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + + // 添加当前 Tab 的 active 类 + this.classList.add('active'); + const tabId = this.getAttribute('data-tab'); + document.getElementById(tabId).classList.add('active'); + }); + }); +} + +/** + * 初始化验证码发送功能 + */ +function initCaptchaSender() { + const sendCaptchaBtn = document.getElementById('sendCaptchaBtn'); + const submitBtn = document.getElementById('submitBtn'); + const emailInput = document.getElementById('phone-input'); + + if (!sendCaptchaBtn || !submitBtn || !emailInput) return; + + let countdown = 60; // 倒计时时间 + let timer = null; + + // 获取 CSRF Token + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content'); + + // 点击发送验证码按钮 + sendCaptchaBtn.addEventListener('click', function() { + const email = emailInput.value.trim(); + if (!email) { + alert('请输入有效的手机号/邮箱'); + return; + } + + // 发起 POST 请求 + fetch(`${CONTEXT_PATH}oauth2/email/generate?email=${encodeURIComponent(email)}`, { + headers: { + [csrfHeader]: csrfToken + } + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('验证码发送失败'); + } + }) + .then(data => { + // 启用提交按钮 + submitBtn.disabled = false; + // 禁用发送验证码按钮并开始倒计时 + startCountdown(sendCaptchaBtn, countdown); + }) + .catch(error => { + console.error('Error:', error); + alert('验证码发送失败,请稍后再试'); + }); + }); +} + +/** + * 开始倒计时 + * @param {HTMLElement} button - 按钮元素 + * @param {number} initialCount - 初始倒计时秒数 + */ +function startCountdown(button, initialCount) { + let countdown = initialCount; + button.disabled = true; + button.textContent = `重新发送(${countdown})`; + + const timer = setInterval(() => { + countdown--; + button.textContent = `重新发送(${countdown})`; + if (countdown <= 0) { + clearInterval(timer); + button.disabled = false; + button.textContent = '发送验证码'; + } + }, 1000); +} + +/** + * 刷新验证码 + */ +function refreshCaptcha() { + const captchaImage = document.getElementById('captcha-image'); + if (!captchaImage) return; + + fetch(`${CONTEXT_PATH}oauth/web/captcha`) + .then(response => response.json()) + .then(data => { + if (data.success && data.data && data.data.image) { + captchaImage.src = data.data.image; + } else { + console.error('Failed to retrieve captcha:', data.message || 'Unknown error'); + } + }) + .catch(error => console.error('Error fetching captcha:', error)); +} + +// 暴露公共方法供全局使用 +window.LoginUtils = { + refreshCaptcha +}; \ No newline at end of file diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html new file mode 100644 index 0000000..24932c0 --- /dev/null +++ b/src/main/resources/templates/dashboard.html @@ -0,0 +1,272 @@ + + + + + + 企业身份认证中心 - 首页 + + + + + + + + + + +
+
+ + + + +
+ + + + +
+
+

欢迎回来,张三!

+

+ 您上次登录时间:2025年8月7日 14:30:22 | IP: 192.168.1.100 +

+
+
+ + +
快捷入口
+ + + +
+ +
+
+
+
最近活动
+
+
+
+
+
+ 登录成功 +
通过 Chrome 登录
+
+
+
08-07 14:30
+
192.168.1.100
+
+
+
+
+ 修改手机号 +
安全信息更新
+
+
+
08-06 09:15
+
192.168.1.101
+
+
+
+
+ SSO 登录 CRM +
自动跳转成功
+
+
+
08-05 18:00
+
192.168.1.100
+
+
+
+
+
+
+ + +
+
+
+
安全提示
+
+
+
    +
  • + + 已启用 MFA 多因素认证 +
  • +
  • + + 密码已使用 89 天,建议尽快修改 +
  • +
  • + + 当前角色:普通员工 +
  • +
  • + + 无异常登录记录 +
  • +
+
+
+
+
+ + + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index f6eee02..a7e83f8 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -8,234 +8,27 @@ - - - + + - +