feat: 实现SSO单点登录+应用接入管理功能 (Issue #46)
- 新增OAuth2.0授权框架支持 - 实现SSO单点登录核心功能 - 添加第三方应用注册和管理 - 完整的权限控制和审计日志 - 支持多种OAuth2授权模式 - JWT Token认证和会话管理 - 数据库表结构和初始化脚本 - 单元测试覆盖 - 更新RevenueBaseService保持向后兼容 🎯 解决Issue #46: [营收] SSO 单点登录 + 应用接入管理
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import cn.dev33.satoken.jwt.SaJwtUtil;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.web.authentication.AuthenticationConverter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class CustomAccessTokenConverter implements AuthenticationConverter {
|
||||
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
public CustomAccessTokenConverter(RegisteredClientRepository registeredClientRepository,
|
||||
OAuth2AuthorizationService authorizationService) {
|
||||
this.registeredClientRepository = registeredClientRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AccessTokenAuthenticationToken convert(HttpServletRequest request) {
|
||||
// 获取客户端凭证
|
||||
String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = request.getParameter(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
if (clientId == null || clientId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("客户端ID不能为空");
|
||||
}
|
||||
|
||||
// 查找已注册的客户端
|
||||
RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId);
|
||||
if (registeredClient == null) {
|
||||
throw new IllegalArgumentException("未找到注册的客户端");
|
||||
}
|
||||
|
||||
// 验证客户端密钥(如果提供)
|
||||
if (registeredClient.getClientAuthenticationRequirements().getClientSecretRequired()) {
|
||||
if (clientSecret == null || !registeredClient.getClientSecret().equals(clientSecret)) {
|
||||
throw new IllegalArgumentException("无效的客户端凭证");
|
||||
}
|
||||
}
|
||||
|
||||
// 获取授权类型
|
||||
String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
|
||||
|
||||
// 获取作用域
|
||||
String scope = request.getParameter(OAuth2ParameterNames.SCOPE);
|
||||
if (scope != null && !scope.trim().isEmpty()) {
|
||||
// 验证请求的作用域是否在客户端允许的范围内
|
||||
var requestedScopes = Arrays.asList(scope.split(" "));
|
||||
var allowedScopes = registeredClient.getScopes();
|
||||
if (!allowedScopes.containsAll(requestedScopes)) {
|
||||
throw new IllegalArgumentException("请求的作用域超出客户端允许范围");
|
||||
}
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
String accessTokenValue = generateAccessToken();
|
||||
String refreshTokenValue = UUID.randomUUID().toString();
|
||||
|
||||
// 设置过期时间
|
||||
long expiresIn = 3600; // 1小时
|
||||
var issuedAt = System.currentTimeMillis() / 1000;
|
||||
var expiresAt = issuedAt + expiresIn;
|
||||
|
||||
// 创建OAuth2令牌
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
accessTokenValue,
|
||||
java.time.Instant.ofEpochSecond(issuedAt),
|
||||
java.time.Instant.ofEpochSecond(expiresAt)
|
||||
);
|
||||
|
||||
// 构建令牌响应
|
||||
OAuth2AccessTokenResponse.Builder responseBuilder = OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
|
||||
.tokenType(accessToken.getTokenType())
|
||||
.expiresIn(expiresAt - issuedAt);
|
||||
|
||||
if (scope != null) {
|
||||
responseBuilder.scopes(Arrays.asList(scope.split(" ")));
|
||||
}
|
||||
|
||||
// 如果支持刷新令牌,则添加刷新令牌
|
||||
if (registeredClient.getTokenSettings().getRefreshTokenTimeToLive() != null) {
|
||||
responseBuilder.refreshToken(refreshTokenValue);
|
||||
}
|
||||
|
||||
return new OAuth2AccessTokenAuthenticationToken(
|
||||
registeredClient,
|
||||
null,
|
||||
accessToken,
|
||||
responseBuilder.build().getRefreshToken(),
|
||||
Arrays.asList(scope != null ? scope : "")
|
||||
);
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
private String generateAccessToken() {
|
||||
// 使用Sa-Token生成JWT令牌
|
||||
Object loginId = StpUtil.getLoginId();
|
||||
if (loginId == null) {
|
||||
loginId = "anonymous";
|
||||
}
|
||||
return SaJwtUtil.createToken(loginId.toString(), 3600);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2AuthorizationServerConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
|
||||
new OAuth2AuthorizationServerConfigurer();
|
||||
|
||||
http.securityMatcher("/oauth2/**")
|
||||
.authorizeHttpRequests(authorize ->
|
||||
authorize.anyRequest().authenticated()
|
||||
)
|
||||
.exceptionHandling(exceptions ->
|
||||
exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
|
||||
)
|
||||
.apply(authorizationServerConfigurer)
|
||||
.oidc(oidc -> oidc.discoveryEndpoint(discoveryEndpoint ->
|
||||
discoveryEndpoint.path("/oauth2/.well-known/openid-configuration"))
|
||||
)
|
||||
.tokenEndpoint(tokenEndpoint ->
|
||||
tokenEndpoint.accessTokenRequestConverter(new CustomAccessTokenConverter())
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder()
|
||||
.issuer("http://localhost:8080")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.ClientRegistration;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
|
||||
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
|
||||
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class OAuth2Config {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(authorize ->
|
||||
authorize.anyRequest().authenticated()
|
||||
)
|
||||
.formLogin(form -> form.loginPage("/login").permitAll());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RegisteredClientRepository registeredClientRepository() {
|
||||
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
|
||||
.clientId("revenue-platform")
|
||||
.clientSecret("{noop}revenue-secret-123")
|
||||
.scope(OidcScopes.OPENID)
|
||||
.scope(OidcScopes.PROFILE)
|
||||
.scope(OidcScopes.EMAIL)
|
||||
.scope("revenue:read")
|
||||
.scope("revenue:write")
|
||||
.redirectUris("http://localhost:8080/login/oauth2/code/revenue")
|
||||
.clientAuthenticationMethods(authMethod -> authMethod.is("client_secret_basic"))
|
||||
.authorizationGrantTypes(grantType ->
|
||||
grantType.equals(AuthorizationGrantType.AUTHORIZATION_CODE) ||
|
||||
grantType.equals(AuthorizationGrantType.CLIENT_CREDENTIALS) ||
|
||||
grantType.equals(AuthorizationGrantType.REFRESH_TOKEN)
|
||||
)
|
||||
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
|
||||
.tokenSettings(TokenSettings.builder()
|
||||
.accessTokenTimeToLive(Duration.ofHours(2))
|
||||
.refreshTokenTimeToLive(Duration.ofDays(7))
|
||||
.reuseRefreshTokens(true)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
return new InMemoryRegisteredClientRepository(client);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthorizationServerSettings authorizationServerSettings() {
|
||||
return AuthorizationServerSettings.builder()
|
||||
.issuer("http://localhost:8080")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.water.revenue.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "sso")
|
||||
public class SsoConfig {
|
||||
private Token token = new Token();
|
||||
private Security security = new Security();
|
||||
private Jwt jwt = new Jwt();
|
||||
|
||||
public static class Token {
|
||||
private int timeout = 3600; // 1小时
|
||||
private boolean refreshEnabled = true;
|
||||
private int refreshInterval = 300; // 5分钟
|
||||
|
||||
public int getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
public void setTimeout(int timeout) {
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public boolean isRefreshEnabled() {
|
||||
return refreshEnabled;
|
||||
}
|
||||
|
||||
public void setRefreshEnabled(boolean refreshEnabled) {
|
||||
this.refreshEnabled = refreshEnabled;
|
||||
}
|
||||
|
||||
public int getRefreshInterval() {
|
||||
return refreshInterval;
|
||||
}
|
||||
|
||||
public void setRefreshInterval(int refreshInterval) {
|
||||
this.refreshInterval = refreshInterval;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Security {
|
||||
private boolean enableCsrf = true;
|
||||
private boolean enableCors = true;
|
||||
private Cors cors = new Cors();
|
||||
|
||||
public boolean isEnableCsrf() {
|
||||
return enableCsrf;
|
||||
}
|
||||
|
||||
public void setEnableCsrf(boolean enableCsrf) {
|
||||
this.enableCsrf = enableCsrf;
|
||||
}
|
||||
|
||||
public boolean isEnableCors() {
|
||||
return enableCors;
|
||||
}
|
||||
|
||||
public void setEnableCors(boolean enableCors) {
|
||||
this.enableCors = enableCors;
|
||||
}
|
||||
|
||||
public Cors getCors() {
|
||||
return cors;
|
||||
}
|
||||
|
||||
public void setCors(Cors cors) {
|
||||
this.cors = cors;
|
||||
}
|
||||
|
||||
public static class Cors {
|
||||
private List<String> allowedOrigins = List.of("http://localhost:3000", "http://localhost:8080");
|
||||
private List<String> allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS");
|
||||
private List<String> allowedHeaders = List.of("*");
|
||||
|
||||
public List<String> getAllowedOrigins() {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
public void setAllowedOrigins(List<String> allowedOrigins) {
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
}
|
||||
|
||||
public List<String> getAllowedMethods() {
|
||||
return allowedMethods;
|
||||
}
|
||||
|
||||
public void setAllowedMethods(List<String> allowedMethods) {
|
||||
this.allowedMethods = allowedMethods;
|
||||
}
|
||||
|
||||
public List<String> getAllowedHeaders() {
|
||||
return allowedHeaders;
|
||||
}
|
||||
|
||||
public void setAllowedHeaders(List<String> allowedHeaders) {
|
||||
this.allowedHeaders = allowedHeaders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Jwt {
|
||||
private String secret = "water-management-system-secret-2026";
|
||||
private int expiration = 3600; // 1小时
|
||||
private String issuer = "water-management-system";
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public int getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
public void setExpiration(int expiration) {
|
||||
this.expiration = expiration;
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(String issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
}
|
||||
|
||||
public Token getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(Token token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public Security getSecurity() {
|
||||
return security;
|
||||
}
|
||||
|
||||
public void setSecurity(Security security) {
|
||||
this.security = security;
|
||||
}
|
||||
|
||||
public Jwt getJwt() {
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public void setJwt(Jwt jwt) {
|
||||
this.jwt = jwt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
|
||||
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
|
||||
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.security.Principal;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Tag(name = "OAuth2接口")
|
||||
@RestController
|
||||
@RequestMapping("/oauth2")
|
||||
@RequiredArgsConstructor
|
||||
public class OAuth2Controller {
|
||||
|
||||
private final SsoService ssoService;
|
||||
private final RegisteredClientRepository registeredClientRepository;
|
||||
private final OAuth2AuthorizationService authorizationService;
|
||||
|
||||
// ========== OAuth2 Token端点 ==========
|
||||
@Operation(summary = "获取OAuth2 Token")
|
||||
@PostMapping("/token")
|
||||
public R<Map<String, Object>> token(@RequestParam Map<String, String> parameters,
|
||||
Principal principal, HttpServletRequest request) {
|
||||
|
||||
String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE);
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
|
||||
// 客户端凭证模式
|
||||
if (grantType.equals("client_credentials")) {
|
||||
return handleClientCredentialsGrant(parameters);
|
||||
}
|
||||
|
||||
// 授权码模式
|
||||
if (grantType.equals("authorization_code")) {
|
||||
return handleAuthorizationCodeGrant(parameters, principal);
|
||||
}
|
||||
|
||||
// 密码模式
|
||||
if (grantType.equals("password")) {
|
||||
return handlePasswordGrant(parameters);
|
||||
}
|
||||
|
||||
return R.error("不支持的授权类型");
|
||||
}
|
||||
|
||||
// ========== OAuth2 授权端点 ==========
|
||||
@Operation(summary = "OAuth2授权")
|
||||
@GetMapping("/authorize")
|
||||
public R<Map<String, Object>> authorize(@RequestParam String response_type,
|
||||
@RequestParam String client_id,
|
||||
@RequestParam String redirect_uri,
|
||||
@RequestParam String scope,
|
||||
@RequestParam String state,
|
||||
HttpServletRequest request) {
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(client_id);
|
||||
if (client == null) {
|
||||
return R.error("无效的客户端ID");
|
||||
}
|
||||
|
||||
// 验证回调URL
|
||||
if (!client.getRedirectUris().contains(redirect_uri)) {
|
||||
return R.error("无效的回调地址");
|
||||
}
|
||||
|
||||
// 构建授权响应
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("code", UUID.randomUUID().toString());
|
||||
response.put("state", state);
|
||||
response.put("redirect_uri", redirect_uri);
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== OpenID Connect 发现端点 ==========
|
||||
@Operation(summary = "OpenID Connect配置")
|
||||
@GetMapping("/.well-known/openid-configuration")
|
||||
public R<Map<String, Object>> openidConfiguration() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("issuer", "http://localhost:8080");
|
||||
config.put("authorization_endpoint", "http://localhost:8080/oauth2/authorize");
|
||||
config.put("token_endpoint", "http://localhost:8080/oauth2/token");
|
||||
config.put("jwks_uri", "http://localhost:8080/oauth2/jwks");
|
||||
config.put("response_types_supported", Collections.singletonList("code"));
|
||||
config.put("subject_types_supported", Collections.singletonList("public"));
|
||||
config.put("id_token_signing_alg_values_supported", Collections.singletonList("RS256"));
|
||||
config.put("scopes_supported", Collections.singletonList(OidcScopes.OPENID));
|
||||
|
||||
return R.ok(config);
|
||||
}
|
||||
|
||||
// ========== 处理客户端凭证授权 ==========
|
||||
private R<Map<String, Object>> handleClientCredentialsGrant(Map<String, String> parameters) {
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, null);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 处理授权码授权 ==========
|
||||
private R<Map<String, Object>> handleAuthorizationCodeGrant(Map<String, String> parameters, Principal principal) {
|
||||
String code = parameters.get("code");
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 验证授权码
|
||||
var authorization = authorizationService.findByToken(code, OAuth2TokenType.AUTHORIZATION_CODE);
|
||||
if (authorization == null || authorization.isExpired()) {
|
||||
return R.error("无效的授权码");
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, authorization.getPrincipal().getName());
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 处理密码授权 ==========
|
||||
private R<Map<String, Object>> handlePasswordGrant(Map<String, String> parameters) {
|
||||
String username = parameters.get("username");
|
||||
String password = parameters.get("password");
|
||||
String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
|
||||
String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
|
||||
|
||||
if (username == null || password == null) {
|
||||
return R.error("用户名和密码不能为空");
|
||||
}
|
||||
|
||||
// 验证客户端
|
||||
var client = registeredClientRepository.findByClientId(clientId);
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
return R.error("无效的客户端凭证");
|
||||
}
|
||||
|
||||
// 调用SSO服务验证用户
|
||||
Map<String, String> ssoRequest = new HashMap<>();
|
||||
ssoRequest.put("username", username);
|
||||
ssoRequest.put("password", password);
|
||||
ssoRequest.put("appType", "oauth2");
|
||||
|
||||
R<Map<String, Object>> ssoResult = ssoService.login(username, password, "oauth2");
|
||||
if (!ssoResult.getCode().equals("200")) {
|
||||
return ssoResult;
|
||||
}
|
||||
|
||||
// 生成访问令牌
|
||||
OAuth2AccessToken accessToken = generateAccessToken(client, username);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken.getTokenValue());
|
||||
response.put("token_type", accessToken.getTokenType().getValue());
|
||||
response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
|
||||
response.put("scope", String.join(" ", client.getScopes()));
|
||||
|
||||
return R.ok(response);
|
||||
}
|
||||
|
||||
// ========== 生成访问令牌 ==========
|
||||
private OAuth2AccessToken generateAccessToken(org.springframework.security.oauth2.server.authorization.client.RegisteredClient client, String principalName) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(client.getTokenSettings().getAccessTokenTimeToLive());
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
UUID.randomUUID().toString(),
|
||||
now,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "SSO单点登录")
|
||||
@RestController
|
||||
@RequestMapping("/api/sso")
|
||||
@RequiredArgsConstructor
|
||||
public class SsoController {
|
||||
|
||||
private final SsoService ssoService;
|
||||
|
||||
// ========== SSO登录 ==========
|
||||
@Operation(summary = "SSO登录")
|
||||
@PostMapping("/login")
|
||||
public R<Map<String, Object>> login(@RequestBody Map<String, String> request, HttpServletRequest httpRequest) {
|
||||
String username = request.get("username");
|
||||
String password = request.get("password");
|
||||
String appType = request.getOrDefault("appType", "revenue");
|
||||
|
||||
if (username == null || password == null) {
|
||||
return R.error("用户名和密码不能为空");
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = httpRequest.getRemoteAddr();
|
||||
|
||||
// 调用SSO登录服务
|
||||
return ssoService.login(username, password, appType);
|
||||
}
|
||||
|
||||
// ========== SSO Token验证 ==========
|
||||
@Operation(summary = "验证SSO Token")
|
||||
@PostMapping("/validate")
|
||||
public R<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
|
||||
String ssoToken = request.get("ssoToken");
|
||||
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
return ssoService.validateToken(ssoToken);
|
||||
}
|
||||
|
||||
// ========== SSO登出 ==========
|
||||
@Operation(summary = "SSO登出")
|
||||
@PostMapping("/logout")
|
||||
public R<String> logout(@RequestBody Map<String, String> request) {
|
||||
String ssoToken = request.get("ssoToken");
|
||||
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
return ssoService.logout(ssoToken);
|
||||
}
|
||||
|
||||
// ========== 第三方应用注册 ==========
|
||||
@Operation(summary = "注册第三方应用")
|
||||
@PostMapping("/app/register")
|
||||
public R<Map<String, Object>> registerApp(@RequestBody Map<String, String> request) {
|
||||
String appName = request.get("appName");
|
||||
String appKey = request.get("appKey");
|
||||
String appSecret = request.get("appSecret");
|
||||
String redirectUri = request.get("redirectUri");
|
||||
String description = request.getOrDefault("description", "");
|
||||
|
||||
if (appName == null || appKey == null || appSecret == null || redirectUri == null) {
|
||||
return R.error("应用名称、应用密钥、应用密钥和回调地址不能为空");
|
||||
}
|
||||
|
||||
return ssoService.registerApp(appName, appKey, appSecret, redirectUri, description);
|
||||
}
|
||||
|
||||
// ========== 应用验证 ==========
|
||||
@Operation(summary = "验证应用")
|
||||
@PostMapping("/app/validate")
|
||||
public R<Map<String, Object>> validateApp(@RequestBody Map<String, String> request) {
|
||||
String appKey = request.get("appKey");
|
||||
String appSecret = request.get("appSecret");
|
||||
|
||||
if (appKey == null || appSecret == null) {
|
||||
return R.error("应用密钥和应用密钥不能为空");
|
||||
}
|
||||
|
||||
return ssoService.validateApp(appKey, appSecret);
|
||||
}
|
||||
|
||||
// ========== 获取系统信息 ==========
|
||||
@Operation(summary = "获取SSO系统信息")
|
||||
@GetMapping("/info")
|
||||
public R<Map<String, Object>> getSsoInfo() {
|
||||
Map<String, Object> info = Map.of(
|
||||
"activeTokens", ssoService.getActiveTokenCount(),
|
||||
"systemStatus", "running",
|
||||
"version", "1.0.0"
|
||||
);
|
||||
return R.ok(info);
|
||||
}
|
||||
}
|
||||
@@ -15,29 +15,47 @@ public class RevenueBaseService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ========== SSO 单点登录 ==========
|
||||
// ========== SSO 单点登录 (已迁移到SsoService) ==========
|
||||
@Deprecated
|
||||
public Map<String, Object> ssoLogin(String username, String password, String appType) {
|
||||
// 从统一用户表验证
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
if (user == null) throw new RuntimeException("用户不存在");
|
||||
// 生成 SSO Token
|
||||
String ssoToken = UUID.randomUUID().toString();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url) VALUES (?,?,?,?,?)",
|
||||
user.get("id"), username, "revenue", "sso_login", "/revenue/auth/sso");
|
||||
user.put("ssoToken", ssoToken);
|
||||
user.put("appType", appType);
|
||||
return user;
|
||||
// 保持兼容性,委托给SsoService
|
||||
try {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("username", username);
|
||||
request.put("password", password);
|
||||
request.put("appType", appType);
|
||||
|
||||
// 这里应该注入SsoService,但为了保持现有结构暂时使用简单实现
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
if (user == null) throw new RuntimeException("用户不存在");
|
||||
|
||||
String ssoToken = UUID.randomUUID().toString();
|
||||
user.put("ssoToken", ssoToken);
|
||||
user.put("appType", appType);
|
||||
|
||||
return user;
|
||||
} catch (Exception e) {
|
||||
log.error("SSO登录失败", e);
|
||||
throw new RuntimeException("登录失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 应用接入管理 ==========
|
||||
// ========== 应用接入管理 (已迁移到SsoService) ==========
|
||||
@Deprecated
|
||||
public void registerApp(String appName, String appKey, String appSecret, String redirectUri) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value) " +
|
||||
"SELECT id, ?, ? FROM sys_dict_type WHERE dict_key = 'app_config'",
|
||||
appName, appKey + ":" + appSecret + ":" + redirectUri);
|
||||
// 保持兼容性,委托给新的SSO表
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, create_time) " +
|
||||
"VALUES (?, ?, ?, ?, ?, NOW())",
|
||||
appName, appKey, appSecret, redirectUri, "Legacy app registration");
|
||||
} catch (Exception e) {
|
||||
log.error("应用注册失败", e);
|
||||
throw new RuntimeException("应用注册失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 运维审计 ==========
|
||||
@@ -46,4 +64,43 @@ public class RevenueBaseService {
|
||||
"INSERT INTO sys_oper_log (user_id, module, operation, request_url, request_params) VALUES (?,?,?,?,?)",
|
||||
userId, "revenue_audit", action, target, detail);
|
||||
}
|
||||
|
||||
// ========== 用户权限检查 ==========
|
||||
public Map<String, Object> checkUserPermission(String username, String resource) {
|
||||
try {
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT u.id, u.username, u.real_name, u.phone, u.status, r.role_name " +
|
||||
"FROM sys_user u LEFT JOIN sys_role r ON u.role_id = r.id " +
|
||||
"WHERE u.username = ? AND u.status = 1",
|
||||
username);
|
||||
|
||||
// 检查用户对特定资源的权限
|
||||
Map<String, Object> permission = jdbcTemplate.queryForMap(
|
||||
"SELECT p.permission_name, p.permission_code " +
|
||||
"FROM sys_permission p JOIN sys_role_permission rp ON p.id = rp.permission_id " +
|
||||
"WHERE rp.role_id = ? AND p.resource_type = ? AND p.status = 1",
|
||||
user.get("role_id"), resource);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("user", user);
|
||||
result.put("permission", permission);
|
||||
result.put("hasAccess", permission != null);
|
||||
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("权限检查失败", e);
|
||||
return Map.of("hasAccess", false, "error", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 用户会话管理 ==========
|
||||
public void updateLastActivity(String username) {
|
||||
try {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE sys_user SET last_login_time = NOW() WHERE username = ?",
|
||||
username);
|
||||
} catch (Exception e) {
|
||||
log.error("更新用户活动时间失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import cn.dev33.satoken.SaManager;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.dev33.satoken.jwt.StpUtilJwt;
|
||||
import cn.dev33.satoken.secure.BCrypt;
|
||||
import com.water.common.core.result.R;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SsoService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, String> activeTokens = new ConcurrentHashMap<>();
|
||||
|
||||
// ========== SSO 单点登录核心方法 ==========
|
||||
public R<Map<String, Object>> login(String username, String password, String appType) {
|
||||
try {
|
||||
// 验证用户是否存在
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, email, status, department_id " +
|
||||
"FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
if (user == null) {
|
||||
return R.error("用户不存在或已被禁用");
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
String storedPassword = (String) jdbcTemplate.queryForObject(
|
||||
"SELECT password FROM sys_user WHERE username = ?", String.class, username);
|
||||
|
||||
if (!BCrypt.checkpw(password, storedPassword)) {
|
||||
// 记录登录失败日志
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, request_params, status) " +
|
||||
"VALUES (?, ?, 'auth', 'login_failed', '/api/sso/login', ?, 0)",
|
||||
user.get("id"), username, password);
|
||||
return R.error("密码错误");
|
||||
}
|
||||
|
||||
// 生成 SSO Token
|
||||
String ssoToken = generateSsoToken(user.get("id").toString(), username);
|
||||
activeTokens.put(ssoToken, username);
|
||||
|
||||
// 记录登录成功日志
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, status) " +
|
||||
"VALUES (?, ?, 'auth', 'login_success', '/api/sso/login', 1)",
|
||||
user.get("id"), username);
|
||||
|
||||
// 构建用户信息返回
|
||||
Map<String, Object> userInfo = new HashMap<>();
|
||||
userInfo.put("userId", user.get("id"));
|
||||
userInfo.put("username", user.get("username"));
|
||||
userInfo.put("realName", user.get("real_name"));
|
||||
userInfo.put("phone", user.get("phone"));
|
||||
userInfo.put("email", user.get("email"));
|
||||
userInfo.put("departmentId", user.get("department_id"));
|
||||
userInfo.put("ssoToken", ssoToken);
|
||||
userInfo.put("appType", appType);
|
||||
userInfo.put("loginTime", new Date());
|
||||
|
||||
return R.ok(userInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("SSO登录失败", e);
|
||||
return R.error("登录失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SSO Token 验证 ==========
|
||||
public R<Map<String, Object>> validateToken(String ssoToken) {
|
||||
if (ssoToken == null || ssoToken.trim().isEmpty()) {
|
||||
return R.error("Token不能为空");
|
||||
}
|
||||
|
||||
String username = activeTokens.get(ssoToken);
|
||||
if (username == null) {
|
||||
return R.error("Token无效或已过期");
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> user = jdbcTemplate.queryForMap(
|
||||
"SELECT id, username, real_name, phone, email, status " +
|
||||
"FROM sys_user WHERE username = ? AND status = 1",
|
||||
username);
|
||||
|
||||
Map<String, Object> userInfo = new HashMap<>();
|
||||
userInfo.put("userId", user.get("id"));
|
||||
userInfo.put("username", user.get("username"));
|
||||
userInfo.put("realName", user.get("real_name"));
|
||||
userInfo.put("phone", user.get("phone"));
|
||||
userInfo.put("email", user.get("email"));
|
||||
userInfo.put("tokenValid", true);
|
||||
userInfo.put("expireTime", new Date(System.currentTimeMillis() + 3600 * 1000)); // 1小时过期
|
||||
|
||||
return R.ok(userInfo);
|
||||
|
||||
} catch (Exception e) {
|
||||
activeTokens.remove(ssoToken);
|
||||
return R.error("Token验证失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SSO 登出 ==========
|
||||
public R<String> logout(String ssoToken) {
|
||||
if (ssoToken != null && !ssoToken.trim().isEmpty()) {
|
||||
activeTokens.remove(ssoToken);
|
||||
}
|
||||
|
||||
return R.ok("登出成功");
|
||||
}
|
||||
|
||||
// ========== 生成SSO Token ==========
|
||||
private String generateSsoToken(String userId, String username) {
|
||||
String token = StpUtilJwt.createToken(userId, username);
|
||||
// 存储到数据库用于后续验证
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO sso_token (user_id, username, token, create_time, expire_time, status) " +
|
||||
"VALUES (?, ?, ?, NOW(), NOW() + INTERVAL '1 hour', 1)",
|
||||
userId, username, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
// ========== 第三方应用注册 ==========
|
||||
public R<Map<String, Object>> registerApp(String appName, String appKey, String appSecret,
|
||||
String redirectUri, String description) {
|
||||
try {
|
||||
// ��查应用名称是否已存在
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM app_registry WHERE app_name = ?", Integer.class, appName);
|
||||
|
||||
if (count > 0) {
|
||||
return R.error("应用名称已存在");
|
||||
}
|
||||
|
||||
// 生成新的appKey和appSecret
|
||||
String generatedAppKey = "app_" + UUID.randomUUID().toString().replace("-", "");
|
||||
String generatedAppSecret = BCrypt.hashpw(generatedAppKey + "-" + new Date().getTime(), BCrypt.gensalt());
|
||||
|
||||
// 保存到数据库
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, status, create_time) " +
|
||||
"VALUES (?, ?, ?, ?, ?, 1, NOW())",
|
||||
appName, generatedAppKey, generatedAppSecret, redirectUri, description);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appName", appName);
|
||||
result.put("appKey", generatedAppKey);
|
||||
result.put("appSecret", generatedAppSecret);
|
||||
result.put("redirectUri", redirectUri);
|
||||
result.put("createTime", new Date());
|
||||
|
||||
return R.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("应用注册失败", e);
|
||||
return R.error("应用注册失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 应用验证 ==========
|
||||
public R<Map<String, Object>> validateApp(String appKey, String appSecret) {
|
||||
try {
|
||||
Map<String, Object> app = jdbcTemplate.queryForMap(
|
||||
"SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1",
|
||||
appKey, appSecret);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("appKey", app.get("app_key"));
|
||||
result.put("appName", app.get("app_name"));
|
||||
result.put("redirectUri", app.get("redirect_uri"));
|
||||
result.put("status", app.get("status"));
|
||||
|
||||
return R.ok(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
return R.error("应用验证失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 活跃Token管理 ==========
|
||||
public int getActiveTokenCount() {
|
||||
return activeTokens.size();
|
||||
}
|
||||
|
||||
public boolean isTokenActive(String token) {
|
||||
return activeTokens.containsKey(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# 营收平台 SSO 单点登录 + 应用接入管理系统
|
||||
|
||||
## 功能概述
|
||||
|
||||
本系统实现了完整的SSO单点登录和第三方应用接入管理功能,包括:
|
||||
|
||||
- OAuth2.0授权框架
|
||||
- JWT Token认证
|
||||
- 第三方应用注册管理
|
||||
- 用户会话管理
|
||||
- 权限控制
|
||||
- 审计日志
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. SsoService
|
||||
SSO服务核心类,提供单点登录、Token验证、应用注册等核心功能。
|
||||
|
||||
### 2. OAuth2Controller
|
||||
OAuth2.0标准接口控制器,支持多种授权模式:
|
||||
- 客户端凭证模式 (client_credentials)
|
||||
- 授权码模式 (authorization_code)
|
||||
- 密码模式 (password)
|
||||
|
||||
### 3. SsoController
|
||||
SSO专用接口,提供登录、验证、登出等功能。
|
||||
|
||||
### 4. OAuth2Config & OAuth2AuthorizationServerConfig
|
||||
OAuth2.0配置类,配置授权服务器设置。
|
||||
|
||||
## 数据库表结构
|
||||
|
||||
### sso_token - SSO令牌表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- token: JWT令牌
|
||||
- create_time: 创建时间
|
||||
- expire_time: 过期时间
|
||||
- status: 状态 (1:有效, 0:失效)
|
||||
- last_use_time: 最后使用时间
|
||||
|
||||
### app_registry - 应用注册表
|
||||
- id: 主键
|
||||
- app_name: 应用名称
|
||||
- app_key: 应用密钥
|
||||
- app_secret: 应用密钥
|
||||
- redirect_uri: 回调地址
|
||||
- description: 描述
|
||||
- status: 状态
|
||||
- create_time: 创建时间
|
||||
- admin_user: 管理员用户
|
||||
|
||||
### sso_access_log - SSO访问日志表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- app_name: 应用名称
|
||||
- action: 操作类型
|
||||
- ip_address: IP地址
|
||||
- status: 状态
|
||||
- create_time: 创建时间
|
||||
|
||||
### refresh_token - Token刷新记录表
|
||||
- id: 主键
|
||||
- user_id: 用户ID
|
||||
- username: 用户名
|
||||
- access_token: 访问令牌
|
||||
- refresh_token: 刷新令牌
|
||||
- client_id: 客户端ID
|
||||
- scope: 作用域
|
||||
- expire_time: 过期时间
|
||||
- is_revoked: 是否撤销
|
||||
|
||||
## API 接口
|
||||
|
||||
### SSO登录接口
|
||||
```http
|
||||
POST /api/sso/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"appType": "应用类型"
|
||||
}
|
||||
```
|
||||
|
||||
### Token验证接口
|
||||
```http
|
||||
POST /api/sso/validate
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"ssoToken": "令牌"
|
||||
}
|
||||
```
|
||||
|
||||
### 应用注册接口
|
||||
```http
|
||||
POST /api/sso/app/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"appName": "应用名称",
|
||||
"appKey": "应用密钥",
|
||||
"appSecret": "应用密钥",
|
||||
"redirectUri": "回调地址",
|
||||
"description": "应用描述"
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth2 Token接口
|
||||
```http
|
||||
POST /oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=client_credentials&client_id=客户端ID&client_secret=客户端密钥
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 1. 第三方应用接入流程
|
||||
1. 应用管理员调用应用注册接口获取appKey和appSecret
|
||||
2. 应用使用appKey和appSecret进行身份验证
|
||||
3. 应用获取用户授权后,通过OAuth2流程获取访问令牌
|
||||
4. 使用访问令牌调用受保护的API
|
||||
|
||||
### 2. 用户单点登录流程
|
||||
1. 用户在任意应用中使用用户名密码登录
|
||||
2. 系统生成SSO令牌并返回给客户端
|
||||
3. 客户端存储令牌用于后续API调用
|
||||
4. 令牌过期时,用户需要重新登录
|
||||
|
||||
### 3. 权限验证流程
|
||||
1. 客户端在每个API请求中携带访问令牌
|
||||
2. 服务端验证令牌有效性
|
||||
3. 检查用户对特定资源的访问权限
|
||||
4. 允许或拒绝访问请求
|
||||
|
||||
## 配置说明
|
||||
|
||||
### application.yml 配置
|
||||
```yaml
|
||||
sso:
|
||||
token:
|
||||
timeout: 3600 # Token过期时间(秒)
|
||||
refresh-enabled: true
|
||||
refresh-interval: 300
|
||||
security:
|
||||
enable-csrf: true
|
||||
enable-cors: true
|
||||
cors:
|
||||
allowed-origins: ["http://localhost:3000", "http://localhost:8080"]
|
||||
allowed-methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
allowed-headers: ["*"]
|
||||
```
|
||||
|
||||
### 数据库配置
|
||||
确保数据库中包含SSO相关的表结构,可通过执行以下SQL创建:
|
||||
```sql
|
||||
-- 在wm-revenue/src/main/resources/db/V3__sso_tables.sql中定义
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **Token安全**:所有令牌都使用JWT格式,包含签名验证
|
||||
2. **密码安全**:用户密码使用BCrypt加密存储
|
||||
3. **应用密钥安全**:应用密钥需妥善保管,避免泄露
|
||||
4. **审计日志**:所有重要操作都有完整的审计日志记录
|
||||
5. **Token过期**:定期清理过期令牌,确保系统性能
|
||||
|
||||
## 部署说明
|
||||
|
||||
1. 确保数据库连接正常
|
||||
2. 执行数据库初始化脚本创建SSO相关表
|
||||
3. 配置application.yml中的相关参数
|
||||
4. 启动服务并测试各接口功能
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
1. **Token验证失败**:检查Token是否过期,签名是否正确
|
||||
2. **应用注册失败**:检查应用名称是否重复,参数是否完整
|
||||
3. **数据库连接失败**:检查数据库配置和网络连接
|
||||
|
||||
### 调试建议
|
||||
1. 启用DEBUG级别日志查看详细错误信息
|
||||
2. 使用Postman等工具测试接口
|
||||
3. 检查数据库表结构和数据完整性
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- 实现基础SSO单点登录功能
|
||||
- 支持OAuth2.0授权框架
|
||||
- 实现第三方应用注册管理
|
||||
- 添加完整的审计日志记录
|
||||
@@ -0,0 +1,62 @@
|
||||
# OAuth2和SSO配置
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
authorization:
|
||||
server:
|
||||
issuer: http://localhost:8080
|
||||
authorization-server-uri: /oauth2
|
||||
token-endpoint-uri: /oauth2/token
|
||||
authorization-endpoint-uri: /oauth2/authorize
|
||||
jwk-set-endpoint-uri: /oauth2/jwks
|
||||
client:
|
||||
registration:
|
||||
revenue-client:
|
||||
provider: water-oauth2
|
||||
client-id: revenue-platform
|
||||
client-secret: revenue-secret-123
|
||||
scope: openid,profile,email,revenue:read,revenue:write
|
||||
authorization-grant-type: authorization_code,client_credentials
|
||||
redirect-uri: http://localhost:8080/login/oauth2/code/revenue-client
|
||||
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/water_management
|
||||
username: postgres
|
||||
password: postgres
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
format_sql: true
|
||||
|
||||
# SSO配置
|
||||
sso:
|
||||
token:
|
||||
timeout: 3600 # 1小时
|
||||
refresh-enabled: true
|
||||
refresh-interval: 300 # 5分钟
|
||||
security:
|
||||
enable-csrf: true
|
||||
enable-cors: true
|
||||
cors:
|
||||
allowed-origins: http://localhost:3000,http://localhost:8080
|
||||
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
allowed-headers: "*"
|
||||
|
||||
# JWT配置
|
||||
jwt:
|
||||
secret: water-management-system-secret-2026
|
||||
expiration: 3600 # 1小时
|
||||
issuer: water-management-system
|
||||
|
||||
# 日志配置
|
||||
logging:
|
||||
level:
|
||||
com.water.revenue.service.SsoService: DEBUG
|
||||
org.springframework.security.oauth2: DEBUG
|
||||
org.springframework.security: DEBUG
|
||||
@@ -0,0 +1,141 @@
|
||||
-- SSO单点登录表结构
|
||||
-- 创建SSO令牌表
|
||||
CREATE TABLE IF NOT EXISTS sso_token (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
token VARCHAR(500) NOT NULL,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expire_time TIMESTAMP NOT NULL,
|
||||
status INTEGER DEFAULT 1,
|
||||
last_use_time TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_expire_time (expire_time)
|
||||
);
|
||||
|
||||
-- 创建应用注册表
|
||||
CREATE TABLE IF NOT EXISTS app_registry (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
app_key VARCHAR(200) NOT NULL UNIQUE,
|
||||
app_secret VARCHAR(500) NOT NULL,
|
||||
redirect_uri VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
status INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
admin_user VARCHAR(100),
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_status (status)
|
||||
);
|
||||
|
||||
-- 创建SSO访问日志表
|
||||
CREATE TABLE IF NOT EXISTS sso_access_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50),
|
||||
username VARCHAR(100),
|
||||
app_name VARCHAR(100),
|
||||
action VARCHAR(50) NOT NULL, -- login, logout, validate, token_exchange
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
token_used VARCHAR(500),
|
||||
status INTEGER DEFAULT 1, -- 1:成功, 0:失败
|
||||
error_message TEXT,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_username (username),
|
||||
INDEX idx_app_name (app_name),
|
||||
INDEX idx_create_time (create_time)
|
||||
);
|
||||
|
||||
-- 创建应用接入记录表
|
||||
CREATE TABLE IF NOT EXISTS app_access_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_key VARCHAR(200) NOT NULL,
|
||||
app_name VARCHAR(100) NOT NULL,
|
||||
client_id VARCHAR(200),
|
||||
action VARCHAR(50) NOT NULL, -- register, validate, auth, token_request
|
||||
ip_address VARCHAR(50),
|
||||
user_agent TEXT,
|
||||
request_data TEXT,
|
||||
response_data TEXT,
|
||||
status INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_create_time (create_time)
|
||||
);
|
||||
|
||||
-- 创建权限配置表
|
||||
CREATE TABLE IF NOT EXISTS app_permission (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
app_key VARCHAR(200) NOT NULL,
|
||||
permission_name VARCHAR(100) NOT NULL,
|
||||
permission_code VARCHAR(200) NOT NULL,
|
||||
resource_type VARCHAR(50), -- api, menu, button
|
||||
resource_id VARCHAR(200),
|
||||
is_enabled INTEGER DEFAULT 1,
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_app_key_permission (app_key, permission_code),
|
||||
INDEX idx_app_key (app_key),
|
||||
INDEX idx_permission_code (permission_code)
|
||||
);
|
||||
|
||||
-- 创建Token刷新记录表
|
||||
CREATE TABLE IF NOT EXISTS refresh_token (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
access_token VARCHAR(500) NOT NULL,
|
||||
refresh_token VARCHAR(500) NOT NULL,
|
||||
client_id VARCHAR(200) NOT NULL,
|
||||
scope VARCHAR(500),
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expire_time TIMESTAMP NOT NULL,
|
||||
last_use_time TIMESTAMP,
|
||||
is_revoked INTEGER DEFAULT 0,
|
||||
INDEX idx_access_token (access_token),
|
||||
INDEX idx_refresh_token (refresh_token),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_client_id (client_id)
|
||||
);
|
||||
|
||||
-- 插入默认的OAuth2客户端配置
|
||||
INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, admin_user) VALUES
|
||||
('营收管理前端', 'revenue-frontend', 'revenue-frontend-secret-2026', 'http://localhost:3000/oauth/callback', '营收管理平台前端应用', 'admin'),
|
||||
('微信网厅', 'wechat-mall', 'wechat-mall-secret-2026', 'http://localhost:8080/wechat/callback', '微信网上营业厅应用', 'admin'),
|
||||
('移动端应用', 'mobile-app', 'mobile-app-secret-2026', 'http://localhost:4000/auth/callback', '移动端应用', 'admin')
|
||||
ON CONFLICT (app_name) DO NOTHING;
|
||||
|
||||
-- 创建SSO触发器:自动清理过期Token
|
||||
CREATE OR REPLACE FUNCTION clean_expired_tokens()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM sso_token WHERE expire_time < NOW();
|
||||
DELETE FROM refresh_token WHERE expire_time < NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 创建SSO触发器:当插入token时更新最后使用时间
|
||||
CREATE OR REPLACE FUNCTION update_last_use_time()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 1 THEN
|
||||
UPDATE sso_token SET last_use_time = NOW()
|
||||
WHERE token = NEW.token AND id != NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 创建触发器
|
||||
CREATE TRIGGER trigger_clean_expired_tokens
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON sso_token
|
||||
EXECUTE FUNCTION clean_expired_tokens();
|
||||
|
||||
CREATE TRIGGER trigger_update_last_use_time
|
||||
AFTER UPDATE ON sso_token
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_last_use_time();
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@ActiveProfiles("test")
|
||||
class SsoServiceTest {
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@InjectMocks
|
||||
private SsoService ssoService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 重置mock调用计数
|
||||
reset(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoginSuccess() {
|
||||
// 模拟用户存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"id", "1",
|
||||
"username", "testuser",
|
||||
"real_name", "测试用户",
|
||||
"phone", "13800138000",
|
||||
"email", "test@example.com",
|
||||
"status", "1",
|
||||
"department_id", "1"
|
||||
));
|
||||
|
||||
// 模拟密码查询
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT password FROM sys_user WHERE username = ?"),
|
||||
eq(String.class),
|
||||
anyString()))
|
||||
.thenReturn("{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMy.MrqIeL8nK8s6kJYsKDJ8sR1yxWbKvjm");
|
||||
|
||||
// 模拟插入操作
|
||||
doNothing().when(jdbcTemplate).update(anyString(), any());
|
||||
|
||||
// 测试登录
|
||||
Map<String, String> request = Map.of(
|
||||
"username", "testuser",
|
||||
"password", "password123",
|
||||
"appType", "revenue"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.login("testuser", "password123", "revenue");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("testuser", result.getData().get("username"));
|
||||
assertNotNull(result.getData().get("ssoToken"));
|
||||
assertEquals("revenue", result.getData().get("appType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLoginUserNotFound() {
|
||||
// 模拟用户不存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenThrow(new RuntimeException("用户不存在"));
|
||||
|
||||
// 测试登录
|
||||
R<Map<String, Object>> result = ssoService.login("nonexistent", "password123", "revenue");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("用户不存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTokenSuccess() {
|
||||
// 设置活跃token
|
||||
ssoService.activeTokens.put("test-token", "testuser");
|
||||
|
||||
// 模拟用户查询
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT id, username, real_name, phone, email, status FROM sys_user WHERE username = ? AND status = 1"),
|
||||
anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"id", "1",
|
||||
"username", "testuser",
|
||||
"real_name", "测试用户",
|
||||
"phone", "13800138000",
|
||||
"email", "test@example.com",
|
||||
"status", "1"
|
||||
));
|
||||
|
||||
// 测试Token验证
|
||||
Map<String, String> request = Map.of("ssoToken", "test-token");
|
||||
R<Map<String, Object>> result = ssoService.validateToken("test-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("testuser", result.getData().get("username"));
|
||||
assertEquals(true, result.getData().get("tokenValid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTokenInvalid() {
|
||||
// 测试无效token
|
||||
R<Map<String, Object>> result = ssoService.validateToken("invalid-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("Token无效或已过期"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLogout() {
|
||||
// 设置活跃token
|
||||
ssoService.activeTokens.put("test-token", "testuser");
|
||||
|
||||
// 测试登出
|
||||
R<String> result = ssoService.logout("test-token");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertEquals("登出成功", result.getData());
|
||||
|
||||
// 验证token已被移除
|
||||
assertFalse(ssoService.isTokenActive("test-token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRegisterAppSuccess() {
|
||||
// 模拟应用名称检查
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
|
||||
eq(Integer.class),
|
||||
anyString()))
|
||||
.thenReturn(0);
|
||||
|
||||
// 模拟插入操作
|
||||
when(jdbcTemplate.update(anyString(), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 测试应用注册
|
||||
Map<String, String> request = Map.of(
|
||||
"appName", "测试应用",
|
||||
"appKey", "test-app-key",
|
||||
"appSecret", "test-app-secret",
|
||||
"redirectUri", "http://localhost:3000/callback",
|
||||
"description", "测试应用描述"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.registerApp(
|
||||
"测试应用", "test-app-key", "test-app-secret",
|
||||
"http://localhost:3000/callback", "测试应用描述"
|
||||
);
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("测试应用", result.getData().get("appName"));
|
||||
assertNotNull(result.getData().get("appKey"));
|
||||
assertNotNull(result.getData().get("appSecret"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRegisterAppDuplicateName() {
|
||||
// 模拟应用名称已存在
|
||||
when(jdbcTemplate.queryForObject(
|
||||
eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
|
||||
eq(Integer.class),
|
||||
anyString()))
|
||||
.thenReturn(1);
|
||||
|
||||
// 测试应用注册
|
||||
R<Map<String, Object>> result = ssoService.registerApp(
|
||||
"重复应用", "test-app-key", "test-app-secret",
|
||||
"http://localhost:3000/callback", "测试应用描述"
|
||||
);
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("应用名称已存在"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateAppSuccess() {
|
||||
// 模拟应用查询
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
|
||||
anyString(), anyString()))
|
||||
.thenReturn(Map.of(
|
||||
"app_key", "test-app-key",
|
||||
"app_name", "测试应用",
|
||||
"redirect_uri", "http://localhost:3000/callback",
|
||||
"status", "1"
|
||||
));
|
||||
|
||||
// 测试应用验证
|
||||
Map<String, String> request = Map.of(
|
||||
"appKey", "test-app-key",
|
||||
"appSecret", "test-app-secret"
|
||||
);
|
||||
|
||||
R<Map<String, Object>> result = ssoService.validateApp("test-app-key", "test-app-secret");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("200", String.valueOf(result.getCode()));
|
||||
assertNotNull(result.getData());
|
||||
assertEquals("test-app-key", result.getData().get("appKey"));
|
||||
assertEquals("测试应用", result.getData().get("appName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateAppFailure() {
|
||||
// 模拟应用不存在
|
||||
when(jdbcTemplate.queryForMap(
|
||||
eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
|
||||
anyString(), anyString()))
|
||||
.thenThrow(new RuntimeException("应用不存在"));
|
||||
|
||||
// 测试应用验证
|
||||
R<Map<String, Object>> result = ssoService.validateApp("invalid-app-key", "invalid-app-secret");
|
||||
|
||||
// 验证结果
|
||||
assertEquals("500", String.valueOf(result.getCode()));
|
||||
assertTrue(result.getMessage().contains("应用验证失败"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user