feat: 实现SSO单点登录+应用接入管理功能 (Issue #46)

- 新增OAuth2.0授权框架支持
- 实现SSO单点登录核心功能
- 添加第三方应用注册和管理
- 完整的权限控制和审计日志
- 支持多种OAuth2授权模式
- JWT Token认证和会话管理
- 数据库表结构和初始化脚本
- 单元测试覆盖
- 更新RevenueBaseService保持向后兼容

🎯 解决Issue #46: [营收] SSO 单点登录 + 应用接入管理
This commit is contained in:
2026-06-14 17:17:11 +08:00
parent af20c2aa09
commit 56ce90f89d
12 changed files with 1643 additions and 19 deletions
@@ -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);
}
}
@@ -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);
}
}
+198
View File
@@ -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("应用验证失败"));
}
}