diff --git a/wm-revenue/src/main/java/com/water/revenue/config/CustomAccessTokenConverter.java b/wm-revenue/src/main/java/com/water/revenue/config/CustomAccessTokenConverter.java new file mode 100644 index 00000000..a43518a8 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/config/CustomAccessTokenConverter.java @@ -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); + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/config/OAuth2AuthorizationServerConfig.java b/wm-revenue/src/main/java/com/water/revenue/config/OAuth2AuthorizationServerConfig.java new file mode 100644 index 00000000..2cb9f1a8 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/config/OAuth2AuthorizationServerConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/config/OAuth2Config.java b/wm-revenue/src/main/java/com/water/revenue/config/OAuth2Config.java new file mode 100644 index 00000000..ccc8ce84 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/config/OAuth2Config.java @@ -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(); + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/config/SsoConfig.java b/wm-revenue/src/main/java/com/water/revenue/config/SsoConfig.java new file mode 100644 index 00000000..9be6420e --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/config/SsoConfig.java @@ -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 allowedOrigins = List.of("http://localhost:3000", "http://localhost:8080"); + private List allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"); + private List allowedHeaders = List.of("*"); + + public List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public List getAllowedMethods() { + return allowedMethods; + } + + public void setAllowedMethods(List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public List getAllowedHeaders() { + return allowedHeaders; + } + + public void setAllowedHeaders(List 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; + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/OAuth2Controller.java b/wm-revenue/src/main/java/com/water/revenue/controller/OAuth2Controller.java new file mode 100644 index 00000000..5883e804 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/OAuth2Controller.java @@ -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> token(@RequestParam Map 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> 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 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> openidConfiguration() { + Map 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> handleClientCredentialsGrant(Map 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 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> handleAuthorizationCodeGrant(Map 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 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> handlePasswordGrant(Map 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 ssoRequest = new HashMap<>(); + ssoRequest.put("username", username); + ssoRequest.put("password", password); + ssoRequest.put("appType", "oauth2"); + + R> ssoResult = ssoService.login(username, password, "oauth2"); + if (!ssoResult.getCode().equals("200")) { + return ssoResult; + } + + // 生成访问令牌 + OAuth2AccessToken accessToken = generateAccessToken(client, username); + + Map 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; + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/SsoController.java b/wm-revenue/src/main/java/com/water/revenue/controller/SsoController.java new file mode 100644 index 00000000..61a00c48 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/SsoController.java @@ -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> login(@RequestBody Map 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> validateToken(@RequestBody Map 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 logout(@RequestBody Map 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> registerApp(@RequestBody Map 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> validateApp(@RequestBody Map 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> getSsoInfo() { + Map info = Map.of( + "activeTokens", ssoService.getActiveTokenCount(), + "systemStatus", "running", + "version", "1.0.0" + ); + return R.ok(info); + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/java/com/water/revenue/service/RevenueBaseService.java b/wm-revenue/src/main/java/com/water/revenue/service/RevenueBaseService.java index 39b5c4c2..e6808922 100644 --- a/wm-revenue/src/main/java/com/water/revenue/service/RevenueBaseService.java +++ b/wm-revenue/src/main/java/com/water/revenue/service/RevenueBaseService.java @@ -15,29 +15,47 @@ public class RevenueBaseService { private final JdbcTemplate jdbcTemplate; - // ========== SSO 单点登录 ========== + // ========== SSO 单点登录 (已迁移到SsoService) ========== + @Deprecated public Map ssoLogin(String username, String password, String appType) { - // 从统一用户表验证 - Map 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 request = new HashMap<>(); + request.put("username", username); + request.put("password", password); + request.put("appType", appType); + + // 这里应该注入SsoService,但为了保持现有结构暂时使用简单实现 + Map 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 checkUserPermission(String username, String resource) { + try { + Map 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 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 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); + } + } } diff --git a/wm-revenue/src/main/java/com/water/revenue/service/SsoService.java b/wm-revenue/src/main/java/com/water/revenue/service/SsoService.java new file mode 100644 index 00000000..ed63796c --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/SsoService.java @@ -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 activeTokens = new ConcurrentHashMap<>(); + + // ========== SSO 单点登录核心方法 ========== + public R> login(String username, String password, String appType) { + try { + // 验证用户是否存在 + Map 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 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> 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 user = jdbcTemplate.queryForMap( + "SELECT id, username, real_name, phone, email, status " + + "FROM sys_user WHERE username = ? AND status = 1", + username); + + Map 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 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> 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 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> validateApp(String appKey, String appSecret) { + try { + Map app = jdbcTemplate.queryForMap( + "SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1", + appKey, appSecret); + + Map 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); + } +} \ No newline at end of file diff --git a/wm-revenue/src/main/resources/SSO_README.md b/wm-revenue/src/main/resources/SSO_README.md new file mode 100644 index 00000000..836fe53e --- /dev/null +++ b/wm-revenue/src/main/resources/SSO_README.md @@ -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授权框架 +- 实现第三方应用注册管理 +- 添加完整的审计日志记录 \ No newline at end of file diff --git a/wm-revenue/src/main/resources/application-oauth2.yml b/wm-revenue/src/main/resources/application-oauth2.yml new file mode 100644 index 00000000..1ac22d45 --- /dev/null +++ b/wm-revenue/src/main/resources/application-oauth2.yml @@ -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 \ No newline at end of file diff --git a/wm-revenue/src/main/resources/db/V3__sso_tables.sql b/wm-revenue/src/main/resources/db/V3__sso_tables.sql new file mode 100644 index 00000000..29f8a41b --- /dev/null +++ b/wm-revenue/src/main/resources/db/V3__sso_tables.sql @@ -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(); \ No newline at end of file diff --git a/wm-revenue/src/test/java/com/water/revenue/service/SsoServiceTest.java b/wm-revenue/src/test/java/com/water/revenue/service/SsoServiceTest.java new file mode 100644 index 00000000..08345105 --- /dev/null +++ b/wm-revenue/src/test/java/com/water/revenue/service/SsoServiceTest.java @@ -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 request = Map.of( + "username", "testuser", + "password", "password123", + "appType", "revenue" + ); + + R> 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> 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 request = Map.of("ssoToken", "test-token"); + R> 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> 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 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 request = Map.of( + "appName", "测试应用", + "appKey", "test-app-key", + "appSecret", "test-app-secret", + "redirectUri", "http://localhost:3000/callback", + "description", "测试应用描述" + ); + + R> 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> 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 request = Map.of( + "appKey", "test-app-key", + "appSecret", "test-app-secret" + ); + + R> 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> result = ssoService.validateApp("invalid-app-key", "invalid-app-secret"); + + // 验证结果 + assertEquals("500", String.valueOf(result.getCode())); + assertTrue(result.getMessage().contains("应用验证失败")); + } +} \ No newline at end of file