Compare commits
7 Commits
66ac8af463
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
963dd2068b | ||
|
|
7ebf1f09a1 | ||
|
|
2b527171e0 | ||
|
|
417856b7be | ||
|
|
bca87e7b0d | ||
|
|
c29abb1b83 | ||
|
|
8382cbe0c3 |
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
mvn versions:display-dependency-updates
|
||||||
|
|
||||||
|
mvn versions:update-properties
|
||||||
|
|
||||||
|
mvn versions:set -DnewVersion=1.2.0.RELEASE
|
||||||
|
```
|
||||||
|
|
||||||
37
pom.xml
37
pom.xml
@@ -5,12 +5,12 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.5.3</version>
|
<version>4.0.0-M1</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>com.arrokoth</groupId>
|
<groupId>com.arrokoth</groupId>
|
||||||
<artifactId>authorization-server-standalone</artifactId>
|
<artifactId>authorization-server-standalone</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>1.2.0.RELEASE</version>
|
||||||
<name>authorization-server-standalone</name>
|
<name>authorization-server-standalone</name>
|
||||||
<description>authorization-server-standalone</description>
|
<description>authorization-server-standalone</description>
|
||||||
<url/>
|
<url/>
|
||||||
@@ -34,25 +34,17 @@
|
|||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<spring.boot.version>3.5.3</spring.boot.version>
|
<spring.boot.version>3.5.3</spring.boot.version>
|
||||||
|
|
||||||
<arrokoth.version>1.1.0.RELEASE</arrokoth.version>
|
<arrokoth.version>1.2.0.RELEASE</arrokoth.version>
|
||||||
<arrokoth.bom.version>1.1.0.RELEASE</arrokoth.bom.version>
|
<arrokoth.bom.version>1.2.0.RELEASE</arrokoth.bom.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.arrokoth.framework.boot</groupId>
|
<groupId>com.arrokoth.framework.boot</groupId>
|
||||||
<artifactId>arrokoth-framework-starter</artifactId>
|
<artifactId>arrokoth-framework-starter</artifactId>
|
||||||
<version>${arrokoth.version}</version>
|
<version>${arrokoth.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 通用认证模块 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.arrokoth.framework</groupId>
|
|
||||||
<artifactId>basic-authorization-server</artifactId>
|
|
||||||
<version>1.0.1-RELEASE</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
@@ -81,6 +73,15 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- JWT 支持 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.nimbusds</groupId>
|
||||||
|
<artifactId>nimbus-jose-jwt</artifactId>
|
||||||
|
<version>9.47</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.mysql</groupId>
|
<groupId>com.mysql</groupId>
|
||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
@@ -93,6 +94,18 @@
|
|||||||
<version>5.8.39</version>
|
<version>5.8.39</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.sf.uadetector</groupId>
|
||||||
|
<artifactId>uadetector-resources</artifactId>
|
||||||
|
<version>2014.10</version>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>jsr305</artifactId>
|
||||||
|
<groupId>com.google.code.findbugs</groupId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<!-- Apache Commons Pool2 -->
|
<!-- Apache Commons Pool2 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.arrokoth.standalone.authorization;
|
package com.arrokoth.standalone.authorization;
|
||||||
|
|
||||||
import com.arrokoth.basic.annotation.EnableArrokothBasicModule;
|
|
||||||
import com.arrokoth.framework.boot.annotation.EnableGracefulRestResponse;
|
import com.arrokoth.framework.boot.annotation.EnableGracefulRestResponse;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
@EnableGracefulRestResponse
|
@EnableGracefulRestResponse
|
||||||
@EnableArrokothBasicModule
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class StandaloneServerApplication {
|
public class StandaloneServerApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> roles,
|
||||||
|
List<String> permissions
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public record UserSessionInfo(
|
||||||
|
String username,
|
||||||
|
String sessionId,
|
||||||
|
Date lastRequest,
|
||||||
|
boolean expired
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(String username, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.common.util;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.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<String, Date> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.arrokoth.standalone.authorization.config;
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
import com.arrokoth.basic.properties.AuthorizationServerProperties;
|
import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties;
|
||||||
import com.arrokoth.basic.properties.SecurityWebProperties;
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -43,11 +43,19 @@ public class AuthorizationServerAutoConfigurer {
|
|||||||
authorizationServerConfigurer.authorizationEndpoint(
|
authorizationServerConfigurer.authorizationEndpoint(
|
||||||
authorizationEndpoint -> authorizationEndpoint.consentPage(authorizationServerProperties.getConsentPage()));
|
authorizationEndpoint -> authorizationEndpoint.consentPage(authorizationServerProperties.getConsentPage()));
|
||||||
|
|
||||||
|
http
|
||||||
|
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
|
||||||
|
// 仅匹配 OAuth2 授权服务器端点(如 /oauth2/authorize, /token 等)
|
||||||
|
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
|
||||||
|
.with(authorizationServerConfigurer, (authorizationServer) ->
|
||||||
|
authorizationServer
|
||||||
|
.oidc(Customizer.withDefaults()) // Initialize `OidcConfigurer`
|
||||||
|
);
|
||||||
|
|
||||||
// 开始构建 HTTP 安全配置
|
// 开始构建 HTTP 安全配置
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable) // 暂时禁用 CSRF 保护(可根据需要启用)
|
.csrf(AbstractHttpConfigurer::disable) // 暂时禁用 CSRF 保护(可根据需要启用)
|
||||||
// 仅匹配 OAuth2 授权服务器端点(如 /oauth2/authorize, /token 等)
|
|
||||||
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
|
|
||||||
// 请求授权规则:所有匹配该过滤链的请求都必须经过身份验证
|
// 请求授权规则:所有匹配该过滤链的请求都必须经过身份验证
|
||||||
.authorizeHttpRequests((authorize) -> authorize
|
.authorizeHttpRequests((authorize) -> authorize
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||||
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j // 使用 Lombok 提供的日志记录器
|
||||||
|
@Configuration // 标记为 Spring 配置类
|
||||||
|
public class JwkConfig {
|
||||||
|
private static KeyPair generateRsaKey() {
|
||||||
|
KeyPair keyPair;
|
||||||
|
try {
|
||||||
|
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
keyPairGenerator.initialize(2048);
|
||||||
|
keyPair = keyPairGenerator.generateKeyPair();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException(ex);
|
||||||
|
}
|
||||||
|
return keyPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JWKSource<SecurityContext> jwkSource() {
|
||||||
|
KeyPair keyPair = generateRsaKey();
|
||||||
|
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
|
||||||
|
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
|
||||||
|
RSAKey rsaKey = new RSAKey.Builder(publicKey)
|
||||||
|
.privateKey(privateKey)
|
||||||
|
.keyID(UUID.randomUUID().toString())
|
||||||
|
.build();
|
||||||
|
JWKSet jwkSet = new JWKSet(rsaKey);
|
||||||
|
return new ImmutableJWKSet<>(jwkSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
|
||||||
|
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,18 +1,43 @@
|
|||||||
package com.arrokoth.standalone.authorization.config;
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
import com.arrokoth.basic.properties.AuthorizationServerProperties;
|
import com.arrokoth.standalone.authorization.properties.AuthorizationServerProperties;
|
||||||
import com.arrokoth.basic.properties.SecurityWebProperties;
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||||
|
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
public class OAuthComponentConfig {
|
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
|
@Bean
|
||||||
public AuthorizationServerSettings authorizationServerSettings(AuthorizationServerProperties authorizationServerProperties,
|
public AuthorizationServerSettings authorizationServerSettings(AuthorizationServerProperties authorizationServerProperties,
|
||||||
SecurityWebProperties securityWebProperties) {
|
SecurityWebProperties securityWebProperties) {
|
||||||
@@ -23,9 +48,5 @@ public class OAuthComponentConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public PasswordEncoder passwordEncoder() {
|
|
||||||
return new BCryptPasswordEncoder(); // 仅用于演示,生产环境请使用BCryptPasswordEncoder
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,96 @@
|
|||||||
package com.arrokoth.standalone.authorization.config;
|
package com.arrokoth.standalone.authorization.config;
|
||||||
|
|
||||||
|
|
||||||
import com.arrokoth.basic.properties.SecurityWebProperties;
|
|
||||||
import com.arrokoth.standalone.authorization.filter.JwtRequestFilter;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
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.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.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@AllArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@EnableConfigurationProperties(SecurityWebProperties.class)
|
@EnableConfigurationProperties(SecurityWebProperties.class)
|
||||||
public class SecurityWebAutoConfigurer {
|
public class SecurityWebAutoConfigurer {
|
||||||
|
|
||||||
private final SecurityWebProperties securityWebProperties;
|
private final SecurityWebProperties securityWebProperties;
|
||||||
|
private final SessionRegistry sessionRegistry;
|
||||||
|
private final JwtRequestFilter jwtRequestFilter;
|
||||||
|
// private final LoginFilter loginFilter;
|
||||||
|
|
||||||
private final JwtRequestFilter jwtRequestFilter; // 你的 Token 过滤器
|
private final AuthenticationSuccessHandler sampleAuthenticationSuccessHandler;
|
||||||
|
private final AuthenticationFailureHandler sampleAuthenticationFailureHandler;
|
||||||
|
private final LogoutSuccessHandler sampleLogoutSuccessHandler;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
log.debug("Configuring default security filter chain");
|
log.info("Initializing security configuration with permitted URLs: {}", securityWebProperties.getMergedPermittedUrls());
|
||||||
http
|
// 基础安全配置
|
||||||
.csrf(AbstractHttpConfigurer::disable) // 前后端分离通常关闭CSRF
|
configureBaseSecurity(http);
|
||||||
.cors(AbstractHttpConfigurer::disable)
|
// JWT认证配置
|
||||||
.sessionManagement(session -> session
|
configureJwtAuthentication(http);
|
||||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // 无状态Session
|
// 表单登录配置
|
||||||
.authorizeHttpRequests(auth -> auth
|
configureFormLogin(http);
|
||||||
.requestMatchers(HttpMethod.OPTIONS).permitAll()
|
// 登出配置
|
||||||
.requestMatchers(securityWebProperties.getMergedPermittedUrls().toArray(new String[0])).permitAll()
|
configureLogout(http);
|
||||||
.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()
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
private void configureBaseSecurity(HttpSecurity http) throws Exception {
|
||||||
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
|
http
|
||||||
log.info("Configuring authentication manager for authentication configuration");
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
return authenticationConfiguration.getAuthenticationManager();
|
.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);
|
||||||
|
// http.addFilterAt(loginFilter, 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.ui.Model;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.servlet.view.RedirectView;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -28,14 +29,17 @@ public class AuthorizationController {
|
|||||||
|
|
||||||
private final OAuth2ConsentService oAuth2ConsentService;
|
private final OAuth2ConsentService oAuth2ConsentService;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
public String home() {
|
public String login() {
|
||||||
return "login"; // 对应templates/login.html
|
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")
|
@GetMapping(value = "/oauth2/consent")
|
||||||
public String consent(Principal principal, Model model,
|
public String consent(Principal principal, Model model,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.controller;
|
||||||
|
|
||||||
|
import com.arrokoth.standalone.authorization.common.basic.BasicModel;
|
||||||
|
import com.arrokoth.standalone.authorization.common.util.JwtUtils;
|
||||||
|
import com.arrokoth.standalone.authorization.properties.SecurityWebProperties;
|
||||||
|
import com.arrokoth.standalone.authorization.service.AuthorizationService;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RequestMapping
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoginController {
|
||||||
|
|
||||||
|
private final AuthorizationService authorizationService;
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
@Operation(summary = "登录", description = "获取登录Token信息")
|
||||||
|
@PostMapping(SecurityWebProperties.AXIOS_LOGIN_PROCESSING_URL)
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BasicModel.UserSessionInfo> list() {
|
||||||
|
return onlineUserService.getOnlineUsers();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> echoData(@RequestBody Map<String, Object> payload) {
|
|
||||||
Map<String, Object> 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<String, String> 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.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.arrokoth.standalone.authorization.exception;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public class JwtException extends RuntimeException {
|
||||||
|
|
||||||
|
|
||||||
|
public JwtException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public JwtException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
package com.arrokoth.standalone.authorization.filter;
|
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 com.arrokoth.standalone.authorization.store.redis.RedisTokenService;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class JwtRequestFilter extends OncePerRequestFilter {
|
public class JwtRequestFilter extends OncePerRequestFilter {
|
||||||
@@ -26,47 +29,139 @@ public class JwtRequestFilter extends OncePerRequestFilter {
|
|||||||
private final RedisTokenService redisTokenService;
|
private final RedisTokenService redisTokenService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
final String authorizationHeader = request.getHeader("Authorization");
|
try {
|
||||||
|
// 1. 提取 JWT Token
|
||||||
String jwt = null;
|
String jwt = extractJwtFromRequest(request);
|
||||||
String username = null;
|
if (jwt == null) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
|
|
||||||
jwt = authorizationHeader.substring(7);
|
|
||||||
try {
|
|
||||||
username = JwtUtils.extractUsername(jwt);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("Failed to extract username from token");
|
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 没有 token,继续链(可能是登录接口等不需要认证的路径)
|
|
||||||
chain.doFilter(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
// 2. 解析用户名
|
||||||
|
String username = JwtUtils.extractUsername(jwt);
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
log.warn("JWT token does not contain a valid username: {}", maskToken(jwt));
|
||||||
|
sendUnauthorizedResponse(response, "Invalid token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检查 Token 是否被拉黑(退出登录状态)
|
||||||
if (redisTokenService.isBlacklisted(jwt)) {
|
if (redisTokenService.isBlacklisted(jwt)) {
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token is blacklisted");
|
log.warn("Token is blacklisted: {}", maskToken(jwt));
|
||||||
|
sendUnauthorizedResponse(response, "Token is blacklisted");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
// 4. 若用户未认证,则进行认证
|
||||||
|
if (isUserNotAuthenticated(username)) {
|
||||||
|
authenticateUser(jwt, username, request);
|
||||||
|
}
|
||||||
|
|
||||||
if (JwtUtils.validateToken(jwt, userDetails.getUsername())) {
|
// 5. 继续过滤链
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
chain.doFilter(request, response);
|
||||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
} catch (Exception ex) {
|
||||||
} else {
|
log.warn("JWT filter processing failed for request: {}", request.getRequestURI(), ex);
|
||||||
// token 无效或已过期
|
sendUnauthorizedResponse(response, "Unauthorized: " + ex.getMessage());
|
||||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token is expired or invalid");
|
}
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取 JWT Token
|
||||||
|
*/
|
||||||
|
private String extractJwtFromRequest(HttpServletRequest request) {
|
||||||
|
// 优先从 Authorization: Bearer <token>
|
||||||
|
String bearerToken = request.getHeader("Authorization");
|
||||||
|
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
|
||||||
|
return bearerToken.substring(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其次尝试从 X-Token 参数获取(URL 或表单)
|
||||||
|
String tokenParam = request.getParameter("X-Token");
|
||||||
|
if (tokenParam != null && !tokenParam.isBlank()) {
|
||||||
|
return tokenParam.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前上下文是否已认证为指定用户
|
||||||
|
*/
|
||||||
|
private boolean isUserNotAuthenticated(String username) {
|
||||||
|
var authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return authentication == null
|
||||||
|
|| !authentication.isAuthenticated()
|
||||||
|
|| !username.equals(authentication.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对用户进行身份认证并设置 SecurityContext
|
||||||
|
*/
|
||||||
|
private void authenticateUser(String jwt, String username, HttpServletRequest request) {
|
||||||
|
UserDetails userDetails;
|
||||||
|
try {
|
||||||
|
userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("User not found during JWT authentication: {}", username);
|
||||||
|
throw new RuntimeException("Invalid token or user does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!JwtUtils.validateToken(jwt, userDetails.getUsername())) {
|
||||||
|
log.warn("JWT token validation failed for user: {}", username);
|
||||||
|
throw new RuntimeException("Token is expired or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails, null, userDetails.getAuthorities());
|
||||||
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
log.debug("Authenticated user: {} via JWT", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 401 响应
|
||||||
|
*/
|
||||||
|
private void sendUnauthorizedResponse(HttpServletResponse response, String message) throws IOException {
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志脱敏:隐藏长 Token 的中间部分
|
||||||
|
*/
|
||||||
|
private String maskToken(String token) {
|
||||||
|
if (token == null || token.length() <= 10) return token;
|
||||||
|
return token.substring(0, 5) + "..." + token.substring(token.length() - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封装的打印请求详情方法
|
||||||
|
*/
|
||||||
|
private void logRequestDetails(HttpServletRequest request) {
|
||||||
|
String requestURL = request.getRequestURL().toString();
|
||||||
|
String queryString = request.getQueryString();
|
||||||
|
|
||||||
|
// 打印基本信息
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
log.debug("Request URL: {}", requestURL);
|
||||||
|
if (queryString != null) {
|
||||||
|
log.debug("Query Parameters: {}", queryString);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chain.doFilter(request, response);
|
// 打印参数(GET/POST 通用)
|
||||||
|
Enumeration<String> paramNames = request.getParameterNames();
|
||||||
|
while (paramNames.hasMoreElements()) {
|
||||||
|
String paramName = paramNames.nextElement();
|
||||||
|
String[] paramValues = request.getParameterValues(paramName);
|
||||||
|
if (log.isDebugEnabled()) {
|
||||||
|
for (String value : paramValues) {
|
||||||
|
log.debug("Request Param: {} = {}", paramName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//package com.arrokoth.standalone.authorization.filter;
|
||||||
|
//
|
||||||
|
//import com.arrokoth.framework.boot.graceful.response.RestResponseFactory;
|
||||||
|
//import com.arrokoth.framework.boot.rest.RestResponse;
|
||||||
|
//import com.arrokoth.standalone.authorization.common.basic.BasicModel;
|
||||||
|
//import com.arrokoth.standalone.authorization.service.AuthorizationService;
|
||||||
|
//import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
//import jakarta.servlet.FilterChain;
|
||||||
|
//import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
//import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
//import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
//import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
//import org.springframework.security.authentication.AuthenticationServiceException;
|
||||||
|
//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
//import org.springframework.security.core.Authentication;
|
||||||
|
//import org.springframework.security.core.session.SessionRegistry;
|
||||||
|
//import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
//import org.springframework.stereotype.Component;
|
||||||
|
//
|
||||||
|
//import java.io.IOException;
|
||||||
|
//import java.io.PrintWriter;
|
||||||
|
//
|
||||||
|
//@Component
|
||||||
|
//public class LoginFilter extends UsernamePasswordAuthenticationFilter {
|
||||||
|
//
|
||||||
|
// @Autowired
|
||||||
|
// private SessionRegistry sessionRegistry;
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// AuthorizationService authorizationService;
|
||||||
|
//
|
||||||
|
// @Autowired
|
||||||
|
// RestResponseFactory restResponseFactory;
|
||||||
|
//
|
||||||
|
// @Autowired
|
||||||
|
// public LoginFilter(AuthenticationManager authenticationManager,AuthorizationService authorizationService) {
|
||||||
|
// this.authorizationService =authorizationService;
|
||||||
|
// this.setAuthenticationManager(authenticationManager);
|
||||||
|
// this.setFilterProcessesUrl("/user/login"); // 设置登录URL
|
||||||
|
// }
|
||||||
|
// @Override
|
||||||
|
// public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
|
||||||
|
// if (!request.getMethod().equals("POST")) {
|
||||||
|
// throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
|
||||||
|
// }
|
||||||
|
// try {
|
||||||
|
// // 解析请求体中的 JSON
|
||||||
|
// ObjectMapper mapper = new ObjectMapper();
|
||||||
|
// BasicModel.LoginRequest loginRequest = mapper.readValue(request.getInputStream(), BasicModel.LoginRequest.class);
|
||||||
|
// // 创建认证 Token
|
||||||
|
// UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password());
|
||||||
|
// return getAuthenticationManager().authenticate(authRequest);
|
||||||
|
// } catch (IOException e) {
|
||||||
|
// throw new RuntimeException("Could not read request", e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// protected void successfulAuthentication(HttpServletRequest request,
|
||||||
|
// HttpServletResponse response,
|
||||||
|
// FilterChain chain,
|
||||||
|
// Authentication authentication) throws IOException {
|
||||||
|
// ObjectMapper mapper = new ObjectMapper();
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Object principal = authentication.getPrincipal();
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// BasicModel.LoginRequest loginRequest = new BasicModel.LoginRequest("admin","");
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// // 登录成功:生成 Token 并返回
|
||||||
|
// BasicModel.Token token = authorizationService.login(loginRequest);
|
||||||
|
// RestResponse restResponse = restResponseFactory.newSuccessInstance(token);
|
||||||
|
//
|
||||||
|
// response.setContentType("application/json;charset=UTF-8");
|
||||||
|
// PrintWriter out = response.getWriter();
|
||||||
|
// String jsonResponse = mapper.writeValueAsString(restResponse);
|
||||||
|
// out.print(jsonResponse);
|
||||||
|
// out.flush();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<String, Object> 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<String, Object> 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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SessionInformation> 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> permitUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并默认值与自定义配置,并去重
|
||||||
|
*/
|
||||||
|
public List<String> getMergedPermittedUrls() {
|
||||||
|
return Stream.concat(DEFAULT_PERMIT_URLS.stream(), permitUrls.stream())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权服务器相关配置属性类。
|
||||||
|
* <p>
|
||||||
|
* 用于绑定配置文件中以 "arrokoth.security.web" 为前缀的属性,
|
||||||
|
* 并提供登录、登出、权限放行等配置项。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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<String> DEFAULT_PERMIT_URLS = Arrays.asList(
|
||||||
|
AXIOS_LOGIN_PROCESSING_URL,
|
||||||
|
DEFAULT_LOGIN_PROCESSING_URL,
|
||||||
|
"/favicon.*",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/connect/logout",
|
||||||
|
"/assets/**",
|
||||||
|
"/static/**",
|
||||||
|
"/webjars/**",
|
||||||
|
"/actuator/**",
|
||||||
|
"/error",
|
||||||
|
"/oauth2/**"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swagger 相关的无需认证即可访问的 URL 列表。
|
||||||
|
*/
|
||||||
|
private static final List<String> SWAGGER_URLS = Arrays.asList(
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-resources/**",
|
||||||
|
"/webjars/**",
|
||||||
|
"/doc.html",
|
||||||
|
"/swagger-ui.html"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出成功后的跳转 URL。
|
||||||
|
* <p>
|
||||||
|
* 默认值为 {@link #DEFAULT_LOGOUT_SUCCESS_URL},即 "/login?logout"。
|
||||||
|
* 如果在 application.yml 中配置了 arrokoth.security.web.logout-success-url,则使用配置值;
|
||||||
|
* 否则使用该默认值。
|
||||||
|
*/
|
||||||
|
private String logoutSuccessUrl = DEFAULT_LOGOUT_SUCCESS_URL;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统登录首页。
|
||||||
|
* <p>
|
||||||
|
* 默认值为 "/login"。
|
||||||
|
* 可通过 arrokoth.security.web.login-page 自定义。
|
||||||
|
*/
|
||||||
|
private String loginPage = DEFAULT_LOGOUT_PAGE;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认的登录处理 URL。
|
||||||
|
* 默认值为 "/web/login"。
|
||||||
|
*/
|
||||||
|
private String loginProcessingUrl = DEFAULT_LOGIN_PROCESSING_URL;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用 Swagger UI。
|
||||||
|
* <p>
|
||||||
|
* 默认值为 false。
|
||||||
|
* 若启用(true),则自动将 Swagger 相关路径加入免认证访问列表。
|
||||||
|
*/
|
||||||
|
private Boolean swaggerUi = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义需要放行(无需认证即可访问)的 URL 列表。
|
||||||
|
* <p>
|
||||||
|
* 默认为空列表,由 {@link #getMergedPermittedUrls()} 方法结合默认值进行合并处理。
|
||||||
|
*/
|
||||||
|
private List<String> permitUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合并后的免认证访问 URL 列表。
|
||||||
|
* <p>
|
||||||
|
* 包括:
|
||||||
|
* - 默认的免认证路径(如登录页、静态资源等)
|
||||||
|
* - 用户自定义的免认证路径
|
||||||
|
* - 如果启用了 Swagger,则包括 Swagger 资源路径
|
||||||
|
*
|
||||||
|
* @return 合并去重后的 URL 列表
|
||||||
|
*/
|
||||||
|
public List<String> getMergedPermittedUrls() {
|
||||||
|
return Stream.<List<String>>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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<BasicModel.UserSessionInfo> getOnlineUsers();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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 username = loginRequest.username();
|
||||||
|
|
||||||
|
String accessToken = JwtUtils.createAccessToken(username);
|
||||||
|
String refreshToken = JwtUtils.createRefreshToken(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) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<BasicModel.UserSessionInfo> getOnlineUsers() {
|
||||||
|
List<BasicModel.UserSessionInfo> result = new ArrayList<>();
|
||||||
|
// 获取所有已认证的用户 principals
|
||||||
|
List<Object> 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<SessionInformation> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -73,6 +73,62 @@ public class RegisteredClientRepositoryStore {
|
|||||||
.build())
|
.build())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return new InMemoryRegisteredClientRepository(oidcClient, gatewayClient);
|
RegisteredClient certificateClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||||
|
.clientId("certificate-authority-client")
|
||||||
|
.clientSecret(bCryptPasswordEncoder.encode("certificate-authority-secret"))
|
||||||
|
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||||
|
// 回调地址(授权码返回地址)
|
||||||
|
.redirectUris(uris -> uris.addAll(
|
||||||
|
List.of(
|
||||||
|
"http://127.0.0.1:8092/login/oauth2/code/messaging-client-oidc",
|
||||||
|
"http://127.0.0.1:9529/callback"
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.postLogoutRedirectUri("http://127.0.0.1:8082/logged-out")
|
||||||
|
.scope(OidcScopes.OPENID)
|
||||||
|
.scope(OidcScopes.PROFILE)
|
||||||
|
.scope("certificate.read")
|
||||||
|
.scope("certificate.write")
|
||||||
|
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
|
||||||
|
.tokenSettings(TokenSettings.builder()
|
||||||
|
.accessTokenTimeToLive(Duration.ofHours(1))
|
||||||
|
.refreshTokenTimeToLive(Duration.ofHours(10))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
RegisteredClient salaryClient = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||||
|
.clientId("salary-standalone-client")
|
||||||
|
.clientSecret(bCryptPasswordEncoder.encode("salary-secret"))
|
||||||
|
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
|
||||||
|
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
|
||||||
|
// 回调地址(授权码返回地址)
|
||||||
|
.redirectUris(uris -> uris.addAll(
|
||||||
|
List.of(
|
||||||
|
|
||||||
|
"http://127.0.0.1:9528/cash-admin/callback",
|
||||||
|
"https://www.yyds8848.com/cash-admin/callback",
|
||||||
|
|
||||||
|
"http://127.0.0.1:8092/login/oauth2/code/messaging-client-oidc",
|
||||||
|
"http://127.0.0.1:9528/callback"
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.postLogoutRedirectUri("http://127.0.0.1:8082/logged-out")
|
||||||
|
.scope(OidcScopes.OPENID)
|
||||||
|
.scope(OidcScopes.PROFILE)
|
||||||
|
.scope("salary.read")
|
||||||
|
.scope("salary.write")
|
||||||
|
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
|
||||||
|
.tokenSettings(TokenSettings.builder()
|
||||||
|
.accessTokenTimeToLive(Duration.ofHours(1))
|
||||||
|
.refreshTokenTimeToLive(Duration.ofHours(10))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return new InMemoryRegisteredClientRepository(oidcClient, gatewayClient, certificateClient, salaryClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,19 @@ public class UserDetailsServiceStore {
|
|||||||
@Bean
|
@Bean
|
||||||
public UserDetailsService users(PasswordEncoder passwordEncoder) {
|
public UserDetailsService users(PasswordEncoder passwordEncoder) {
|
||||||
UserDetails user = User.withUsername("admin")
|
UserDetails user = User.withUsername("admin")
|
||||||
.password(passwordEncoder.encode("password"))
|
.password(passwordEncoder.encode("123456"))
|
||||||
.roles("admin", "normal")
|
.roles("admin", "normal")
|
||||||
.authorities("app", "web")
|
.authorities("app", "web")
|
||||||
.build();
|
.build();
|
||||||
return new InMemoryUserDetailsManager(user);
|
|
||||||
|
|
||||||
|
|
||||||
|
UserDetails user2 = User.withUsername("guest")
|
||||||
|
.password(passwordEncoder.encode("yyds@8848"))
|
||||||
|
.roles("normal")
|
||||||
|
.authorities("app")
|
||||||
|
.build();
|
||||||
|
return new InMemoryUserDetailsManager(user,user2);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.arrokoth.standalone.authorization.store.redis;
|
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 com.nimbusds.jose.JOSEException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
context-path:
|
||||||
|
|
||||||
arrokoth:
|
arrokoth:
|
||||||
logger:
|
logger:
|
||||||
@@ -45,7 +47,7 @@ spring:
|
|||||||
name: authorization-server-standalone
|
name: authorization-server-standalone
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: localhost
|
host: 127.0.0.1
|
||||||
port: 6379
|
port: 6379
|
||||||
password: yyds@8848
|
password: yyds@8848
|
||||||
lettuce:
|
lettuce:
|
||||||
@@ -62,20 +64,20 @@ logging:
|
|||||||
root: INFO
|
root: INFO
|
||||||
com.arrokoth: DEBUG
|
com.arrokoth: DEBUG
|
||||||
org.springdoc: INFO
|
org.springdoc: INFO
|
||||||
org.springframework: INFO
|
org.springframework.security: DEBUG
|
||||||
|
|
||||||
#mybatis:
|
mybatis:
|
||||||
# type-aliases-package: com.arrokoth.**.domain
|
type-aliases-package: com.arrokoth.**.domain
|
||||||
# mapper-locations: classpath*:com.arrokoth/**/mapper/xml/*.xml
|
mapper-locations: classpath*:com.arrokoth/**/mapper/xml/*.xml
|
||||||
# configuration:
|
configuration:
|
||||||
# map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
#mybatis-plus:
|
mybatis-plus:
|
||||||
# type-aliases-package: com.arrokoth.**.domain
|
type-aliases-package: com.arrokoth.**.domain
|
||||||
# mapper-locations: classpath*:com.arrokoth/**/mapper/xml/*.xml
|
mapper-locations: classpath*:com.arrokoth/**/mapper/xml/*.xml
|
||||||
# global-config:
|
global-config:
|
||||||
# banner: true
|
banner: true
|
||||||
# enable-sql-runner: true
|
enable-sql-runner: true
|
||||||
# configuration:
|
configuration:
|
||||||
# map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
# check-config-location: true
|
check-config-location: true
|
||||||
@@ -1,77 +1,207 @@
|
|||||||
/* 页面整体布局 */
|
/* 全局样式 */
|
||||||
body {
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
|
||||||
|
height: 100vh;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100vh; /* 设置页面高度为视口高度 */
|
display: flex;
|
||||||
display: flex; /* 使用 Flexbox 布局 */
|
justify-content: center;
|
||||||
align-items: center; /* 垂直居中 */
|
align-items: center;
|
||||||
justify-content: flex-end; /* 水平靠右 */
|
font-family: Arial, sans-serif;
|
||||||
background-color: #f0f2f5; /* 背景颜色 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 登录框样式 */
|
.login-container {
|
||||||
.form-signin {
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 820px;
|
||||||
padding: 15px;
|
background: white;
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||||
margin-right: 20px; /* 右侧留出一定间距 */
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-signin h1 {
|
.left-panel {
|
||||||
|
width: 50%;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #4a5568, #333);
|
||||||
|
color: white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
color: #333;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-floating > input {
|
.right-panel {
|
||||||
border-radius: 5px;
|
width: 50%;
|
||||||
font-size: 14px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
body {
|
.login-container {
|
||||||
justify-content: center; /* 在小屏幕上水平居中 */
|
flex-direction: column;
|
||||||
|
padding: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-signin {
|
.left-panel, .right-panel {
|
||||||
margin-right: 0; /* 移除右侧间距 */
|
width: 100%;
|
||||||
margin-left: 0; /* 确保左右对称 */
|
padding: 20px;
|
||||||
max-width: 100%; /* 宽度占满容器 */
|
|
||||||
width: 90%; /* 设置宽度为父容器的 90% */
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
}
|
}
|
||||||
130
src/main/resources/static/assets/js/login.js
Normal file
130
src/main/resources/static/assets/js/login.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
272
src/main/resources/templates/dashboard.html
Normal file
272
src/main/resources/templates/dashboard.html
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>企业身份认证中心 - 首页</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Font Awesome 图标 -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
#sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: #ecf0f1;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
#sidebar .sidebar-header {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #1a252f;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#sidebar ul.components {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
#sidebar ul li a {
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #bdc3c7;
|
||||||
|
border-bottom: 1px solid #34495e;
|
||||||
|
}
|
||||||
|
#sidebar ul li a:hover {
|
||||||
|
background-color: #34495e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.welcome-card {
|
||||||
|
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.quick-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.quick-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
.activity-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px dashed #eee;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 50px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<div class="row g-0">
|
||||||
|
<!-- 侧边栏 -->
|
||||||
|
<nav id="sidebar" class="col-md-3 col-lg-2 d-md-block">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<i class="fas fa-shield-alt me-2"></i>认证中心
|
||||||
|
</div>
|
||||||
|
<ul class="nav flex-column mt-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="#"><i class="fas fa-home me-2"></i> 仪表盘</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#"><i class="fas fa-user-friends me-2"></i> 用户管理</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#"><i class="fas fa-th-large me-2"></i> 应用接入</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#"><i class="fas fa-lock me-2"></i> 安全设置</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#"><i class="fas fa-history me-2"></i> 登录日志</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#"><i class="fas fa-cog me-2"></i> 系统设置</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4" id="content">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm rounded mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<button class="btn btn-primary d-md-none" id="sidebarCollapse">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="dropdown">
|
||||||
|
<a class="dropdown-toggle text-decoration-none" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<img src="https://via.placeholder.com/30" alt="头像" class="rounded-circle me-2">
|
||||||
|
<span>张三</span>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="#"><i class="fas fa-user me-2"></i> 个人资料</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#"><i class="fas fa-cog me-2"></i> 设置</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item text-danger" href="#"><i class="fas fa-sign-out-alt me-2"></i> 退出登录</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 欢迎卡片 -->
|
||||||
|
<div class="card welcome-card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title"><i class="fas fa-rocket me-2"></i> 欢迎回来,张三!</h4>
|
||||||
|
<p class="card-text">
|
||||||
|
<small>您上次登录时间:2025年8月7日 14:30:22 | IP: 192.168.1.100</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷入口 -->
|
||||||
|
<h5><i class="fas fa-th-large me-2"></i>快捷入口</h5>
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<a href="#" class="text-decoration-none">
|
||||||
|
<div class="quick-card">
|
||||||
|
<i class="fas fa-key text-primary mb-2" style="font-size: 24px;"></i>
|
||||||
|
<h6>修改密码</h6>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<a href="#" class="text-decoration-none">
|
||||||
|
<div class="quick-card">
|
||||||
|
<i class="fas fa-mobile-alt text-success mb-2" style="font-size: 24px;"></i>
|
||||||
|
<h6>MFA 绑定</h6>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<a href="#" class="text-decoration-none">
|
||||||
|
<div class="quick-card">
|
||||||
|
<i class="fas fa-list-alt text-warning mb-2" style="font-size: 24px;"></i>
|
||||||
|
<h6>权限申请</h6>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<a href="#" class="text-decoration-none">
|
||||||
|
<div class="quick-card">
|
||||||
|
<i class="fas fa-project-diagram text-info mb-2" style="font-size: 24px;"></i>
|
||||||
|
<h6>我的应用</h6>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近活动与安全提示 -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- 活动日志 -->
|
||||||
|
<div class="col-lg-8 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0"><i class="fas fa-history text-primary"></i> 最近活动</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<div class="list-group-item activity-item d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>登录成功</strong>
|
||||||
|
<div class="text-muted small">通过 Chrome 登录</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div>08-07 14:30</div>
|
||||||
|
<div class="text-muted small">192.168.1.100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item activity-item d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>修改手机号</strong>
|
||||||
|
<div class="text-muted small">安全信息更新</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div>08-06 09:15</div>
|
||||||
|
<div class="text-muted small">192.168.1.101</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-group-item activity-item d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>SSO 登录 CRM</strong>
|
||||||
|
<div class="text-muted small">自动跳转成功</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div>08-05 18:00</div>
|
||||||
|
<div class="text-muted small">192.168.1.100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 安全提示 -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h6 class="mb-0"><i class="fas fa-shield-alt text-danger"></i> 安全提示</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-3">
|
||||||
|
<i class="fas fa-check-circle text-success me-2"></i>
|
||||||
|
已启用 MFA 多因素认证
|
||||||
|
</li>
|
||||||
|
<li class="mb-3">
|
||||||
|
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||||
|
密码已使用 89 天,建议尽快修改
|
||||||
|
</li>
|
||||||
|
<li class="mb-3">
|
||||||
|
<i class="fas fa-user-shield text-info me-2"></i>
|
||||||
|
当前角色:普通员工
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-info-circle text-secondary me-2"></i>
|
||||||
|
无异常登录记录
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 页脚 -->
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2025 企业名称科技有限公司. 保留所有权利。 | <a href="#">隐私政策</a> | <a href="#">用户协议</a></p>
|
||||||
|
<p>技术支持:<a href="mailto:support@company.com">support@company.com</a></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS Bundle (含 Popper) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 移动端侧边栏开关
|
||||||
|
document.getElementById('sidebarCollapse').addEventListener('click', function () {
|
||||||
|
document.querySelector('#sidebar').classList.toggle('d-none');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,234 +8,27 @@
|
|||||||
<link rel="stylesheet" th:href="@{/assets/css/bootstrap.min.css}"
|
<link rel="stylesheet" th:href="@{/assets/css/bootstrap.min.css}"
|
||||||
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||||
|
<!-- Custom Styles -->
|
||||||
<!-- 自定义样式 -->
|
<link rel="stylesheet" th:href="@{/assets/css/login.css}" crossorigin="anonymous">
|
||||||
<style>
|
|
||||||
/* 全局样式 */
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 820px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-panel {
|
|
||||||
width: 50%;
|
|
||||||
padding: 20px;
|
|
||||||
background: linear-gradient(135deg, #4a5568, #333);
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
width: 50%;
|
|
||||||
padding: 30px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 响应式设计 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.login-container {
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-context-path="${#httpServletRequest.contextPath}">
|
||||||
<!-- CSRF Token -->
|
<!-- CSRF Token -->
|
||||||
<meta name="_csrf" content="${_csrf.token}"/>
|
<meta name="_csrf" content="${_csrf.token}"/>
|
||||||
<meta name="_csrf_header" content="${_csrf.headerName}"/>
|
<meta name="_csrf_header" content="${_csrf.headerName}"/>
|
||||||
|
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
|
<!-- 左侧面板保持不变 -->
|
||||||
<div class="left-panel">
|
<div class="left-panel">
|
||||||
<h3>企业级 统一身份认证服务平台</h3>
|
<h3>企业级 统一身份认证服务平台</h3>
|
||||||
<p>
|
<p></p>
|
||||||
</p>
|
|
||||||
<p>为企业量身定制的安全保障</p>
|
<p>为企业量身定制的安全保障</p>
|
||||||
<p>
|
<p>
|
||||||
旨在提供一种简单而强大而灵活的认证解决方案,以保护企业应用的安全性和隐私性。它允许用户通过一组凭据(如用户名和密码、手机号验证码或第三方社交账号)登录到多个应用或服务,而无需对每个应用单独进行注册和身份验证</p>
|
旨在提供一种简单而强大而灵活的认证解决方案,以保护企业应用的安全性和隐私性。它允许用户通过一组凭据(如用户名和密码、手机号验证码或第三方社交账号)登录到多个应用或服务,而无需对每个应用单独进行注册和身份验证</p>
|
||||||
<hr>
|
<hr>
|
||||||
<p>构建于信任之上,确保数据安全与隐私</p>
|
<p>构建于信任之上,确保数据安全与隐私</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧面板保持不变 -->
|
||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -250,22 +43,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tab-content active" id="password-tab">
|
<div class="tab-content active" id="password-tab">
|
||||||
<form method="post" th:action="@{/web/login}">
|
<form method="post" th:action="@{/web/login}">
|
||||||
|
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username-input">用户名</label>
|
<label for="username-input">用户名</label>
|
||||||
<input id="username-input" name="username" placeholder="请输入用户名" type="text" value="admin">
|
<input id="username-input" name="username" placeholder="请输入用户名" type="text" value="">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password-input">密码</label>
|
<label for="password-input">密码</label>
|
||||||
<input id="password-input" name="password" placeholder="请输入密码" type="password"
|
<input id="password-input" name="password" placeholder="请输入密码" type="password"
|
||||||
value="password">
|
value="">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="display: none">
|
<div class="form-group" style="display: none">
|
||||||
<label for="pass-captcha-input">验证码</label>
|
<label for="pass-captcha-input">验证码</label>
|
||||||
<div>
|
<div>
|
||||||
<input id="pass-captcha-input" placeholder="请输入验证码" type="text"/>
|
<input id="pass-captcha-input" placeholder="请输入验证码" type="text"/>
|
||||||
<!-- 新增:验证码图片展示 -->
|
<!-- 新增:验证码图片展示 -->
|
||||||
<img id="captcha-image" alt="验证码"
|
<img id="captcha-image" alt="验证码"
|
||||||
src="" onclick="refreshCaptcha()">
|
src="" onclick="LoginUtils.refreshCaptcha()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="alert alert-danger" role="alert" th:if="${param.error}">用户名或密码输入错误,请重新输入。</div>
|
<div class="alert alert-danger" role="alert" th:if="${param.error}">用户名或密码输入错误,请重新输入。</div>
|
||||||
@@ -279,7 +73,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tab-content" id="password-less-tab">
|
<div class="tab-content" id="password-less-tab">
|
||||||
<form method="post" th:action="@{/oauth2/email/login}" id="loginForm">
|
<form method="post" th:action="@{/oauth2/email/login}" id="loginForm">
|
||||||
<!-- <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>-->
|
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="phone-input">手机号/邮箱</label>
|
<label for="phone-input">手机号/邮箱</label>
|
||||||
<input type="text" id="phone-input" name="email" value="546732225@qq.com"
|
<input type="text" id="phone-input" name="email" value="546732225@qq.com"
|
||||||
@@ -310,101 +104,7 @@
|
|||||||
<p>© 北京阿罗科斯信息技术有限责任公司 版权所有 | 联系我们 | 帮助中心 |</p>
|
<p>© 北京阿罗科斯信息技术有限责任公司 版权所有 | 联系我们 | 帮助中心 |</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- JavaScript 实现 Tab 切换 -->
|
<!-- 引入 JavaScript 文件 -->
|
||||||
<script th:inline="javascript">
|
<script th:src="@{/assets/js/login.js}"></script>
|
||||||
const CONTEXT_PATH = /*[[ @{/} ]]*/ ''; // 动态获取 context-path
|
|
||||||
|
|
||||||
// 页面加载时加载验证码
|
|
||||||
window.onload = function () {
|
|
||||||
// refreshCaptcha();
|
|
||||||
};
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
const sendCaptchaBtn = document.getElementById('sendCaptchaBtn');
|
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
|
||||||
const emailInput = document.getElementById('phone-input');
|
|
||||||
|
|
||||||
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)}`)
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('验证码发送失败');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
// 启用提交按钮
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
// 禁用发送验证码按钮并开始倒计时
|
|
||||||
sendCaptchaBtn.disabled = true;
|
|
||||||
sendCaptchaBtn.textContent = `重新发送(${countdown})`;
|
|
||||||
|
|
||||||
timer = setInterval(() => {
|
|
||||||
countdown--;
|
|
||||||
sendCaptchaBtn.textContent = `重新发送(${countdown})`;
|
|
||||||
if (countdown <= 0) {
|
|
||||||
clearInterval(timer);
|
|
||||||
sendCaptchaBtn.disabled = false;
|
|
||||||
sendCaptchaBtn.textContent = '发送验证码';
|
|
||||||
countdown = 60; // 重置倒计时
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('验证码发送失败,请稍后再试');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function refreshCaptcha() {
|
|
||||||
fetch(`${CONTEXT_PATH}oauth/web/captcha`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success && data.data && data.data.image) {
|
|
||||||
document.getElementById('captcha-image').src = data.data.image;
|
|
||||||
} else {
|
|
||||||
console.error('Failed to retrieve captcha:', data.message || 'Unknown error');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Error fetching captcha:', error));
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user