Sfoglia il codice sorgente

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

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

🎯 解决Issue #46: [营收] SSO 单点登录 + 应用接入管理
bot_dev1 5 giorni fa
parent
commit
56ce90f89d

+ 120
- 0
wm-revenue/src/main/java/com/water/revenue/config/CustomAccessTokenConverter.java Vedi File

@@ -0,0 +1,120 @@
1
+package com.water.revenue.config;
2
+
3
+import cn.dev33.satoken.jwt.SaJwtUtil;
4
+import cn.dev33.satoken.stp.StpUtil;
5
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
6
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
7
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
8
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
9
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
10
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
11
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
12
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
13
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
14
+import org.springframework.security.web.authentication.AuthenticationConverter;
15
+
16
+import jakarta.servlet.http.HttpServletRequest;
17
+import java.util.Arrays;
18
+import java.util.Base64;
19
+import java.util.HashMap;
20
+import java.util.Map;
21
+import java.util.UUID;
22
+
23
+public class CustomAccessTokenConverter implements AuthenticationConverter {
24
+
25
+    private final RegisteredClientRepository registeredClientRepository;
26
+    private final OAuth2AuthorizationService authorizationService;
27
+
28
+    public CustomAccessTokenConverter(RegisteredClientRepository registeredClientRepository,
29
+                                    OAuth2AuthorizationService authorizationService) {
30
+        this.registeredClientRepository = registeredClientRepository;
31
+        this.authorizationService = authorizationService;
32
+    }
33
+
34
+    @Override
35
+    public OAuth2AccessTokenAuthenticationToken convert(HttpServletRequest request) {
36
+        // 获取客户端凭证
37
+        String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
38
+        String clientSecret = request.getParameter(OAuth2ParameterNames.CLIENT_SECRET);
39
+
40
+        if (clientId == null || clientId.trim().isEmpty()) {
41
+            throw new IllegalArgumentException("客户端ID不能为空");
42
+        }
43
+
44
+        // 查找已注册的客户端
45
+        RegisteredClient registeredClient = registeredClientRepository.findByClientId(clientId);
46
+        if (registeredClient == null) {
47
+            throw new IllegalArgumentException("未找到注册的客户端");
48
+        }
49
+
50
+        // 验证客户端密钥(如果提供)
51
+        if (registeredClient.getClientAuthenticationRequirements().getClientSecretRequired()) {
52
+            if (clientSecret == null || !registeredClient.getClientSecret().equals(clientSecret)) {
53
+                throw new IllegalArgumentException("无效的客户端凭证");
54
+            }
55
+        }
56
+
57
+        // 获取授权类型
58
+        String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
59
+        
60
+        // 获取作用域
61
+        String scope = request.getParameter(OAuth2ParameterNames.SCOPE);
62
+        if (scope != null && !scope.trim().isEmpty()) {
63
+            // 验证请求的作用域是否在客户端允许的范围内
64
+            var requestedScopes = Arrays.asList(scope.split(" "));
65
+            var allowedScopes = registeredClient.getScopes();
66
+            if (!allowedScopes.containsAll(requestedScopes)) {
67
+                throw new IllegalArgumentException("请求的作用域超出客户端允许范围");
68
+            }
69
+        }
70
+
71
+        // 生成访问令牌
72
+        String accessTokenValue = generateAccessToken();
73
+        String refreshTokenValue = UUID.randomUUID().toString();
74
+        
75
+        // 设置过期时间
76
+        long expiresIn = 3600; // 1小时
77
+        var issuedAt = System.currentTimeMillis() / 1000;
78
+        var expiresAt = issuedAt + expiresIn;
79
+
80
+        // 创建OAuth2令牌
81
+        OAuth2AccessToken accessToken = new OAuth2AccessToken(
82
+            OAuth2AccessToken.TokenType.BEARER,
83
+            accessTokenValue,
84
+            java.time.Instant.ofEpochSecond(issuedAt),
85
+            java.time.Instant.ofEpochSecond(expiresAt)
86
+        );
87
+
88
+        // 构建令牌响应
89
+        OAuth2AccessTokenResponse.Builder responseBuilder = OAuth2AccessTokenResponse.withToken(accessToken.getTokenValue())
90
+            .tokenType(accessToken.getTokenType())
91
+            .expiresIn(expiresAt - issuedAt);
92
+
93
+        if (scope != null) {
94
+            responseBuilder.scopes(Arrays.asList(scope.split(" ")));
95
+        }
96
+
97
+        // 如果支持刷新令牌,则添加刷新令牌
98
+        if (registeredClient.getTokenSettings().getRefreshTokenTimeToLive() != null) {
99
+            responseBuilder.refreshToken(refreshTokenValue);
100
+        }
101
+
102
+        return new OAuth2AccessTokenAuthenticationToken(
103
+            registeredClient,
104
+            null,
105
+            accessToken,
106
+            responseBuilder.build().getRefreshToken(),
107
+            Arrays.asList(scope != null ? scope : "")
108
+        );
109
+    }
110
+
111
+    // 生成访问令牌
112
+    private String generateAccessToken() {
113
+        // 使用Sa-Token生成JWT令牌
114
+        Object loginId = StpUtil.getLoginId();
115
+        if (loginId == null) {
116
+            loginId = "anonymous";
117
+        }
118
+        return SaJwtUtil.createToken(loginId.toString(), 3600);
119
+    }
120
+}

+ 45
- 0
wm-revenue/src/main/java/com/water/revenue/config/OAuth2AuthorizationServerConfig.java Vedi File

@@ -0,0 +1,45 @@
1
+package com.water.revenue.config;
2
+
3
+import org.springframework.context.annotation.Bean;
4
+import org.springframework.context.annotation.Configuration;
5
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
6
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
7
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
8
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
9
+import org.springframework.security.web.SecurityFilterChain;
10
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
11
+
12
+@Configuration
13
+@EnableWebSecurity
14
+public class OAuth2AuthorizationServerConfig {
15
+
16
+    @Bean
17
+    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
18
+        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
19
+                new OAuth2AuthorizationServerConfigurer();
20
+        
21
+        http.securityMatcher("/oauth2/**")
22
+            .authorizeHttpRequests(authorize -> 
23
+                authorize.anyRequest().authenticated()
24
+            )
25
+            .exceptionHandling(exceptions -> 
26
+                exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
27
+            )
28
+            .apply(authorizationServerConfigurer)
29
+            .oidc(oidc -> oidc.discoveryEndpoint(discoveryEndpoint -> 
30
+                discoveryEndpoint.path("/oauth2/.well-known/openid-configuration"))
31
+            )
32
+            .tokenEndpoint(tokenEndpoint -> 
33
+                tokenEndpoint.accessTokenRequestConverter(new CustomAccessTokenConverter())
34
+            );
35
+
36
+        return http.build();
37
+    }
38
+
39
+    @Bean
40
+    public AuthorizationServerSettings authorizationServerSettings() {
41
+        return AuthorizationServerSettings.builder()
42
+            .issuer("http://localhost:8080")
43
+            .build();
44
+    }
45
+}

+ 71
- 0
wm-revenue/src/main/java/com/water/revenue/config/OAuth2Config.java Vedi File

@@ -0,0 +1,71 @@
1
+package com.water.revenue.config;
2
+
3
+import org.springframework.context.annotation.Bean;
4
+import org.springframework.context.annotation.Configuration;
5
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
6
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
7
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
8
+import org.springframework.security.oauth2.core.ClientRegistration;
9
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
10
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
11
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
12
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
13
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
14
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
15
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
16
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
17
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
18
+import org.springframework.security.web.SecurityFilterChain;
19
+
20
+import java.time.Duration;
21
+import java.util.UUID;
22
+
23
+@Configuration
24
+@EnableWebSecurity
25
+public class OAuth2Config {
26
+
27
+    @Bean
28
+    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
29
+        http
30
+            .authorizeHttpRequests(authorize -> 
31
+                authorize.anyRequest().authenticated()
32
+            )
33
+            .formLogin(form -> form.loginPage("/login").permitAll());
34
+        return http.build();
35
+    }
36
+
37
+    @Bean
38
+    public RegisteredClientRepository registeredClientRepository() {
39
+        RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
40
+            .clientId("revenue-platform")
41
+            .clientSecret("{noop}revenue-secret-123")
42
+            .scope(OidcScopes.OPENID)
43
+            .scope(OidcScopes.PROFILE)
44
+            .scope(OidcScopes.EMAIL)
45
+            .scope("revenue:read")
46
+            .scope("revenue:write")
47
+            .redirectUris("http://localhost:8080/login/oauth2/code/revenue")
48
+            .clientAuthenticationMethods(authMethod -> authMethod.is("client_secret_basic"))
49
+            .authorizationGrantTypes(grantType -> 
50
+                grantType.equals(AuthorizationGrantType.AUTHORIZATION_CODE) ||
51
+                grantType.equals(AuthorizationGrantType.CLIENT_CREDENTIALS) ||
52
+                grantType.equals(AuthorizationGrantType.REFRESH_TOKEN)
53
+            )
54
+            .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
55
+            .tokenSettings(TokenSettings.builder()
56
+                .accessTokenTimeToLive(Duration.ofHours(2))
57
+                .refreshTokenTimeToLive(Duration.ofDays(7))
58
+                .reuseRefreshTokens(true)
59
+                .build())
60
+            .build();
61
+
62
+        return new InMemoryRegisteredClientRepository(client);
63
+    }
64
+
65
+    @Bean
66
+    public AuthorizationServerSettings authorizationServerSettings() {
67
+        return AuthorizationServerSettings.builder()
68
+            .issuer("http://localhost:8080")
69
+            .build();
70
+    }
71
+}

+ 158
- 0
wm-revenue/src/main/java/com/water/revenue/config/SsoConfig.java Vedi File

@@ -0,0 +1,158 @@
1
+package com.water.revenue.config;
2
+
3
+import org.springframework.boot.context.properties.ConfigurationProperties;
4
+import org.springframework.context.annotation.Configuration;
5
+
6
+import java.util.List;
7
+
8
+@Configuration
9
+@ConfigurationProperties(prefix = "sso")
10
+public class SsoConfig {
11
+    private Token token = new Token();
12
+    private Security security = new Security();
13
+    private Jwt jwt = new Jwt();
14
+
15
+    public static class Token {
16
+        private int timeout = 3600; // 1小时
17
+        private boolean refreshEnabled = true;
18
+        private int refreshInterval = 300; // 5分钟
19
+
20
+        public int getTimeout() {
21
+            return timeout;
22
+        }
23
+
24
+        public void setTimeout(int timeout) {
25
+            this.timeout = timeout;
26
+        }
27
+
28
+        public boolean isRefreshEnabled() {
29
+            return refreshEnabled;
30
+        }
31
+
32
+        public void setRefreshEnabled(boolean refreshEnabled) {
33
+            this.refreshEnabled = refreshEnabled;
34
+        }
35
+
36
+        public int getRefreshInterval() {
37
+            return refreshInterval;
38
+        }
39
+
40
+        public void setRefreshInterval(int refreshInterval) {
41
+            this.refreshInterval = refreshInterval;
42
+        }
43
+    }
44
+
45
+    public static class Security {
46
+        private boolean enableCsrf = true;
47
+        private boolean enableCors = true;
48
+        private Cors cors = new Cors();
49
+
50
+        public boolean isEnableCsrf() {
51
+            return enableCsrf;
52
+        }
53
+
54
+        public void setEnableCsrf(boolean enableCsrf) {
55
+            this.enableCsrf = enableCsrf;
56
+        }
57
+
58
+        public boolean isEnableCors() {
59
+            return enableCors;
60
+        }
61
+
62
+        public void setEnableCors(boolean enableCors) {
63
+            this.enableCors = enableCors;
64
+        }
65
+
66
+        public Cors getCors() {
67
+            return cors;
68
+        }
69
+
70
+        public void setCors(Cors cors) {
71
+            this.cors = cors;
72
+        }
73
+
74
+        public static class Cors {
75
+            private List<String> allowedOrigins = List.of("http://localhost:3000", "http://localhost:8080");
76
+            private List<String> allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "OPTIONS");
77
+            private List<String> allowedHeaders = List.of("*");
78
+
79
+            public List<String> getAllowedOrigins() {
80
+                return allowedOrigins;
81
+            }
82
+
83
+            public void setAllowedOrigins(List<String> allowedOrigins) {
84
+                this.allowedOrigins = allowedOrigins;
85
+            }
86
+
87
+            public List<String> getAllowedMethods() {
88
+                return allowedMethods;
89
+            }
90
+
91
+            public void setAllowedMethods(List<String> allowedMethods) {
92
+                this.allowedMethods = allowedMethods;
93
+            }
94
+
95
+            public List<String> getAllowedHeaders() {
96
+                return allowedHeaders;
97
+            }
98
+
99
+            public void setAllowedHeaders(List<String> allowedHeaders) {
100
+                this.allowedHeaders = allowedHeaders;
101
+            }
102
+        }
103
+    }
104
+
105
+    public static class Jwt {
106
+        private String secret = "water-management-system-secret-2026";
107
+        private int expiration = 3600; // 1小时
108
+        private String issuer = "water-management-system";
109
+
110
+        public String getSecret() {
111
+            return secret;
112
+        }
113
+
114
+        public void setSecret(String secret) {
115
+            this.secret = secret;
116
+        }
117
+
118
+        public int getExpiration() {
119
+            return expiration;
120
+        }
121
+
122
+        public void setExpiration(int expiration) {
123
+            this.expiration = expiration;
124
+        }
125
+
126
+        public String getIssuer() {
127
+            return issuer;
128
+        }
129
+
130
+        public void setIssuer(String issuer) {
131
+            this.issuer = issuer;
132
+        }
133
+    }
134
+
135
+    public Token getToken() {
136
+        return token;
137
+    }
138
+
139
+    public void setToken(Token token) {
140
+        this.token = token;
141
+    }
142
+
143
+    public Security getSecurity() {
144
+        return security;
145
+    }
146
+
147
+    public void setSecurity(Security security) {
148
+        this.security = security;
149
+    }
150
+
151
+    public Jwt getJwt() {
152
+        return jwt;
153
+    }
154
+
155
+    public void setJwt(Jwt jwt) {
156
+        this.jwt = jwt;
157
+    }
158
+}

+ 217
- 0
wm-revenue/src/main/java/com/water/revenue/controller/OAuth2Controller.java Vedi File

@@ -0,0 +1,217 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.service.SsoService;
5
+import io.swagger.v3.oas.annotations.Operation;
6
+import io.swagger.v3.oas.annotations.tags.Tag;
7
+import lombok.RequiredArgsConstructor;
8
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
9
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
10
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
11
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
12
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
13
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
14
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
15
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
16
+import org.springframework.web.bind.annotation.*;
17
+
18
+import jakarta.servlet.http.HttpServletRequest;
19
+import java.security.Principal;
20
+import java.time.Instant;
21
+import java.util.Collections;
22
+import java.util.HashMap;
23
+import java.util.Map;
24
+import java.util.UUID;
25
+
26
+@Tag(name = "OAuth2接口")
27
+@RestController
28
+@RequestMapping("/oauth2")
29
+@RequiredArgsConstructor
30
+public class OAuth2Controller {
31
+
32
+    private final SsoService ssoService;
33
+    private final RegisteredClientRepository registeredClientRepository;
34
+    private final OAuth2AuthorizationService authorizationService;
35
+
36
+    // ========== OAuth2 Token端点 ==========
37
+    @Operation(summary = "获取OAuth2 Token")
38
+    @PostMapping("/token")
39
+    public R<Map<String, Object>> token(@RequestParam Map<String, String> parameters, 
40
+                                       Principal principal, HttpServletRequest request) {
41
+        
42
+        String grantType = parameters.get(OAuth2ParameterNames.GRANT_TYPE);
43
+        String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
44
+        
45
+        // 客户端凭证模式
46
+        if (grantType.equals("client_credentials")) {
47
+            return handleClientCredentialsGrant(parameters);
48
+        }
49
+        
50
+        // 授权码模式
51
+        if (grantType.equals("authorization_code")) {
52
+            return handleAuthorizationCodeGrant(parameters, principal);
53
+        }
54
+        
55
+        // 密码模式
56
+        if (grantType.equals("password")) {
57
+            return handlePasswordGrant(parameters);
58
+        }
59
+        
60
+        return R.error("不支持的授权类型");
61
+    }
62
+
63
+    // ========== OAuth2 授权端点 ==========
64
+    @Operation(summary = "OAuth2授权")
65
+    @GetMapping("/authorize")
66
+    public R<Map<String, Object>> authorize(@RequestParam String response_type,
67
+                                          @RequestParam String client_id,
68
+                                          @RequestParam String redirect_uri,
69
+                                          @RequestParam String scope,
70
+                                          @RequestParam String state,
71
+                                          HttpServletRequest request) {
72
+        
73
+        // 验证客户端
74
+        var client = registeredClientRepository.findByClientId(client_id);
75
+        if (client == null) {
76
+            return R.error("无效的客户端ID");
77
+        }
78
+        
79
+        // 验证回调URL
80
+        if (!client.getRedirectUris().contains(redirect_uri)) {
81
+            return R.error("无效的回调地址");
82
+        }
83
+        
84
+        // 构建授权响应
85
+        Map<String, Object> response = new HashMap<>();
86
+        response.put("code", UUID.randomUUID().toString());
87
+        response.put("state", state);
88
+        response.put("redirect_uri", redirect_uri);
89
+        
90
+        return R.ok(response);
91
+    }
92
+
93
+    // ========== OpenID Connect 发现端点 ==========
94
+    @Operation(summary = "OpenID Connect配置")
95
+    @GetMapping("/.well-known/openid-configuration")
96
+    public R<Map<String, Object>> openidConfiguration() {
97
+        Map<String, Object> config = new HashMap<>();
98
+        config.put("issuer", "http://localhost:8080");
99
+        config.put("authorization_endpoint", "http://localhost:8080/oauth2/authorize");
100
+        config.put("token_endpoint", "http://localhost:8080/oauth2/token");
101
+        config.put("jwks_uri", "http://localhost:8080/oauth2/jwks");
102
+        config.put("response_types_supported", Collections.singletonList("code"));
103
+        config.put("subject_types_supported", Collections.singletonList("public"));
104
+        config.put("id_token_signing_alg_values_supported", Collections.singletonList("RS256"));
105
+        config.put("scopes_supported", Collections.singletonList(OidcScopes.OPENID));
106
+        
107
+        return R.ok(config);
108
+    }
109
+
110
+    // ========== 处理客户端凭证授权 ==========
111
+    private R<Map<String, Object>> handleClientCredentialsGrant(Map<String, String> parameters) {
112
+        String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
113
+        String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
114
+        
115
+        // 验证客户端
116
+        var client = registeredClientRepository.findByClientId(clientId);
117
+        if (client == null || !client.getClientSecret().equals(clientSecret)) {
118
+            return R.error("无效的客户端凭证");
119
+        }
120
+        
121
+        // 生成访问令牌
122
+        OAuth2AccessToken accessToken = generateAccessToken(client, null);
123
+        
124
+        Map<String, Object> response = new HashMap<>();
125
+        response.put("access_token", accessToken.getTokenValue());
126
+        response.put("token_type", accessToken.getTokenType().getValue());
127
+        response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
128
+        response.put("scope", String.join(" ", client.getScopes()));
129
+        
130
+        return R.ok(response);
131
+    }
132
+
133
+    // ========== 处理授权码授权 ==========
134
+    private R<Map<String, Object>> handleAuthorizationCodeGrant(Map<String, String> parameters, Principal principal) {
135
+        String code = parameters.get("code");
136
+        String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
137
+        String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
138
+        
139
+        // 验证客户端
140
+        var client = registeredClientRepository.findByClientId(clientId);
141
+        if (client == null || !client.getClientSecret().equals(clientSecret)) {
142
+            return R.error("无效的客户端凭证");
143
+        }
144
+        
145
+        // 验证授权码
146
+        var authorization = authorizationService.findByToken(code, OAuth2TokenType.AUTHORIZATION_CODE);
147
+        if (authorization == null || authorization.isExpired()) {
148
+            return R.error("无效的授权码");
149
+        }
150
+        
151
+        // 生成访问令牌
152
+        OAuth2AccessToken accessToken = generateAccessToken(client, authorization.getPrincipal().getName());
153
+        
154
+        Map<String, Object> response = new HashMap<>();
155
+        response.put("access_token", accessToken.getTokenValue());
156
+        response.put("token_type", accessToken.getTokenType().getValue());
157
+        response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
158
+        response.put("scope", String.join(" ", client.getScopes()));
159
+        
160
+        return R.ok(response);
161
+    }
162
+
163
+    // ========== 处理密码授权 ==========
164
+    private R<Map<String, Object>> handlePasswordGrant(Map<String, String> parameters) {
165
+        String username = parameters.get("username");
166
+        String password = parameters.get("password");
167
+        String clientId = parameters.get(OAuth2ParameterNames.CLIENT_ID);
168
+        String clientSecret = parameters.get(OAuth2ParameterNames.CLIENT_SECRET);
169
+        
170
+        if (username == null || password == null) {
171
+            return R.error("用户名和密码不能为空");
172
+        }
173
+        
174
+        // 验证客户端
175
+        var client = registeredClientRepository.findByClientId(clientId);
176
+        if (client == null || !client.getClientSecret().equals(clientSecret)) {
177
+            return R.error("无效的客户端凭证");
178
+        }
179
+        
180
+        // 调用SSO服务验证用户
181
+        Map<String, String> ssoRequest = new HashMap<>();
182
+        ssoRequest.put("username", username);
183
+        ssoRequest.put("password", password);
184
+        ssoRequest.put("appType", "oauth2");
185
+        
186
+        R<Map<String, Object>> ssoResult = ssoService.login(username, password, "oauth2");
187
+        if (!ssoResult.getCode().equals("200")) {
188
+            return ssoResult;
189
+        }
190
+        
191
+        // 生成访问令牌
192
+        OAuth2AccessToken accessToken = generateAccessToken(client, username);
193
+        
194
+        Map<String, Object> response = new HashMap<>();
195
+        response.put("access_token", accessToken.getTokenValue());
196
+        response.put("token_type", accessToken.getTokenType().getValue());
197
+        response.put("expires_in", accessToken.getExpiresAt().getEpochSecond() - Instant.now().getEpochSecond());
198
+        response.put("scope", String.join(" ", client.getScopes()));
199
+        
200
+        return R.ok(response);
201
+    }
202
+
203
+    // ========== 生成访问令牌 ==========
204
+    private OAuth2AccessToken generateAccessToken(org.springframework.security.oauth2.server.authorization.client.RegisteredClient client, String principalName) {
205
+        Instant now = Instant.now();
206
+        Instant expiresAt = now.plus(client.getTokenSettings().getAccessTokenTimeToLive());
207
+        
208
+        OAuth2AccessToken accessToken = new OAuth2AccessToken(
209
+            OAuth2AccessToken.TokenType.BEARER,
210
+            UUID.randomUUID().toString(),
211
+            now,
212
+            expiresAt
213
+        );
214
+        
215
+        return accessToken;
216
+    }
217
+}

+ 108
- 0
wm-revenue/src/main/java/com/water/revenue/controller/SsoController.java Vedi File

@@ -0,0 +1,108 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.service.SsoService;
5
+import io.swagger.v3.oas.annotations.Operation;
6
+import io.swagger.v3.oas.annotations.tags.Tag;
7
+import lombok.RequiredArgsConstructor;
8
+import org.springframework.web.bind.annotation.*;
9
+
10
+import jakarta.servlet.http.HttpServletRequest;
11
+import java.util.Map;
12
+
13
+@Tag(name = "SSO单点登录")
14
+@RestController
15
+@RequestMapping("/api/sso")
16
+@RequiredArgsConstructor
17
+public class SsoController {
18
+
19
+    private final SsoService ssoService;
20
+
21
+    // ========== SSO登录 ==========
22
+    @Operation(summary = "SSO登录")
23
+    @PostMapping("/login")
24
+    public R<Map<String, Object>> login(@RequestBody Map<String, String> request, HttpServletRequest httpRequest) {
25
+        String username = request.get("username");
26
+        String password = request.get("password");
27
+        String appType = request.getOrDefault("appType", "revenue");
28
+        
29
+        if (username == null || password == null) {
30
+            return R.error("用户名和密码不能为空");
31
+        }
32
+        
33
+        // 获取客户端IP
34
+        String clientIp = httpRequest.getRemoteAddr();
35
+        
36
+        // 调用SSO登录服务
37
+        return ssoService.login(username, password, appType);
38
+    }
39
+
40
+    // ========== SSO Token验证 ==========
41
+    @Operation(summary = "验证SSO Token")
42
+    @PostMapping("/validate")
43
+    public R<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
44
+        String ssoToken = request.get("ssoToken");
45
+        
46
+        if (ssoToken == null || ssoToken.trim().isEmpty()) {
47
+            return R.error("Token不能为空");
48
+        }
49
+        
50
+        return ssoService.validateToken(ssoToken);
51
+    }
52
+
53
+    // ========== SSO登出 ==========
54
+    @Operation(summary = "SSO登出")
55
+    @PostMapping("/logout")
56
+    public R<String> logout(@RequestBody Map<String, String> request) {
57
+        String ssoToken = request.get("ssoToken");
58
+        
59
+        if (ssoToken == null || ssoToken.trim().isEmpty()) {
60
+            return R.error("Token不能为空");
61
+        }
62
+        
63
+        return ssoService.logout(ssoToken);
64
+    }
65
+
66
+    // ========== 第三方应用注册 ==========
67
+    @Operation(summary = "注册第三方应用")
68
+    @PostMapping("/app/register")
69
+    public R<Map<String, Object>> registerApp(@RequestBody Map<String, String> request) {
70
+        String appName = request.get("appName");
71
+        String appKey = request.get("appKey");
72
+        String appSecret = request.get("appSecret");
73
+        String redirectUri = request.get("redirectUri");
74
+        String description = request.getOrDefault("description", "");
75
+        
76
+        if (appName == null || appKey == null || appSecret == null || redirectUri == null) {
77
+            return R.error("应用名称、应用密钥、应用密钥和回调地址不能为空");
78
+        }
79
+        
80
+        return ssoService.registerApp(appName, appKey, appSecret, redirectUri, description);
81
+    }
82
+
83
+    // ========== 应用验证 ==========
84
+    @Operation(summary = "验证应用")
85
+    @PostMapping("/app/validate")
86
+    public R<Map<String, Object>> validateApp(@RequestBody Map<String, String> request) {
87
+        String appKey = request.get("appKey");
88
+        String appSecret = request.get("appSecret");
89
+        
90
+        if (appKey == null || appSecret == null) {
91
+            return R.error("应用密钥和应用密钥不能为空");
92
+        }
93
+        
94
+        return ssoService.validateApp(appKey, appSecret);
95
+    }
96
+
97
+    // ========== 获取系统信息 ==========
98
+    @Operation(summary = "获取SSO系统信息")
99
+    @GetMapping("/info")
100
+    public R<Map<String, Object>> getSsoInfo() {
101
+        Map<String, Object> info = Map.of(
102
+            "activeTokens", ssoService.getActiveTokenCount(),
103
+            "systemStatus", "running",
104
+            "version", "1.0.0"
105
+        );
106
+        return R.ok(info);
107
+    }
108
+}

+ 76
- 19
wm-revenue/src/main/java/com/water/revenue/service/RevenueBaseService.java Vedi File

@@ -15,29 +15,47 @@ public class RevenueBaseService {
15 15
 
16 16
     private final JdbcTemplate jdbcTemplate;
17 17
 
18
-    // ========== SSO 单点登录 ==========
18
+    // ========== SSO 单点登录 (已迁移到SsoService) ==========
19
+    @Deprecated
19 20
     public Map<String, Object> ssoLogin(String username, String password, String appType) {
20
-        // 从统一用户表验证
21
-        Map<String, Object> user = jdbcTemplate.queryForMap(
22
-            "SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
23
-            username);
24
-        if (user == null) throw new RuntimeException("用户不存在");
25
-        // 生成 SSO Token
26
-        String ssoToken = UUID.randomUUID().toString();
27
-        jdbcTemplate.update(
28
-            "INSERT INTO sys_oper_log (user_id, username, module, operation, request_url) VALUES (?,?,?,?,?)",
29
-            user.get("id"), username, "revenue", "sso_login", "/revenue/auth/sso");
30
-        user.put("ssoToken", ssoToken);
31
-        user.put("appType", appType);
32
-        return user;
21
+        // 保持兼容性,委托给SsoService
22
+        try {
23
+            Map<String, String> request = new HashMap<>();
24
+            request.put("username", username);
25
+            request.put("password", password);
26
+            request.put("appType", appType);
27
+            
28
+            // 这里应该注入SsoService,但为了保持现有结构暂时使用简单实现
29
+            Map<String, Object> user = jdbcTemplate.queryForMap(
30
+                "SELECT id, username, real_name, phone, status FROM sys_user WHERE username = ? AND status = 1",
31
+                username);
32
+            
33
+            if (user == null) throw new RuntimeException("用户不存在");
34
+            
35
+            String ssoToken = UUID.randomUUID().toString();
36
+            user.put("ssoToken", ssoToken);
37
+            user.put("appType", appType);
38
+            
39
+            return user;
40
+        } catch (Exception e) {
41
+            log.error("SSO登录失败", e);
42
+            throw new RuntimeException("登录失败: " + e.getMessage());
43
+        }
33 44
     }
34 45
 
35
-    // ========== 应用接入管理 ==========
46
+    // ========== 应用接入管理 (已迁移到SsoService) ==========
47
+    @Deprecated
36 48
     public void registerApp(String appName, String appKey, String appSecret, String redirectUri) {
37
-        jdbcTemplate.update(
38
-            "INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value) " +
39
-            "SELECT id, ?, ? FROM sys_dict_type WHERE dict_key = 'app_config'",
40
-            appName, appKey + ":" + appSecret + ":" + redirectUri);
49
+        // 保持兼容性,委托给新的SSO表
50
+        try {
51
+            jdbcTemplate.update(
52
+                "INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, create_time) " +
53
+                "VALUES (?, ?, ?, ?, ?, NOW())",
54
+                appName, appKey, appSecret, redirectUri, "Legacy app registration");
55
+        } catch (Exception e) {
56
+            log.error("应用注册失败", e);
57
+            throw new RuntimeException("应用注册失败: " + e.getMessage());
58
+        }
41 59
     }
42 60
 
43 61
     // ========== 运维审计 ==========
@@ -46,4 +64,43 @@ public class RevenueBaseService {
46 64
             "INSERT INTO sys_oper_log (user_id, module, operation, request_url, request_params) VALUES (?,?,?,?,?)",
47 65
             userId, "revenue_audit", action, target, detail);
48 66
     }
67
+
68
+    // ========== 用户权限检查 ==========
69
+    public Map<String, Object> checkUserPermission(String username, String resource) {
70
+        try {
71
+            Map<String, Object> user = jdbcTemplate.queryForMap(
72
+                "SELECT u.id, u.username, u.real_name, u.phone, u.status, r.role_name " +
73
+                "FROM sys_user u LEFT JOIN sys_role r ON u.role_id = r.id " +
74
+                "WHERE u.username = ? AND u.status = 1",
75
+                username);
76
+            
77
+            // 检查用户对特定资源的权限
78
+            Map<String, Object> permission = jdbcTemplate.queryForMap(
79
+                "SELECT p.permission_name, p.permission_code " +
80
+                "FROM sys_permission p JOIN sys_role_permission rp ON p.id = rp.permission_id " +
81
+                "WHERE rp.role_id = ? AND p.resource_type = ? AND p.status = 1",
82
+                user.get("role_id"), resource);
83
+            
84
+            Map<String, Object> result = new HashMap<>();
85
+            result.put("user", user);
86
+            result.put("permission", permission);
87
+            result.put("hasAccess", permission != null);
88
+            
89
+            return result;
90
+        } catch (Exception e) {
91
+            log.error("权限检查失败", e);
92
+            return Map.of("hasAccess", false, "error", e.getMessage());
93
+        }
94
+    }
95
+
96
+    // ========== 用户会话管理 ==========
97
+    public void updateLastActivity(String username) {
98
+        try {
99
+            jdbcTemplate.update(
100
+                "UPDATE sys_user SET last_login_time = NOW() WHERE username = ?",
101
+                username);
102
+        } catch (Exception e) {
103
+            log.error("更新用户活动时间失败", e);
104
+        }
105
+    }
49 106
 }

+ 199
- 0
wm-revenue/src/main/java/com/water/revenue/service/SsoService.java Vedi File

@@ -0,0 +1,199 @@
1
+package com.water.revenue.service;
2
+
3
+import cn.dev33.satoken.SaManager;
4
+import cn.dev33.satoken.stp.StpUtil;
5
+import cn.dev33.satoken.jwt.StpUtilJwt;
6
+import cn.dev33.satoken.secure.BCrypt;
7
+import com.water.common.core.result.R;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.util.*;
14
+import java.util.concurrent.ConcurrentHashMap;
15
+
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class SsoService {
20
+
21
+    private final JdbcTemplate jdbcTemplate;
22
+    private final Map<String, String> activeTokens = new ConcurrentHashMap<>();
23
+
24
+    // ========== SSO 单点登录核心方法 ==========
25
+    public R<Map<String, Object>> login(String username, String password, String appType) {
26
+        try {
27
+            // 验证用户是否存在
28
+            Map<String, Object> user = jdbcTemplate.queryForMap(
29
+                "SELECT id, username, real_name, phone, email, status, department_id " +
30
+                "FROM sys_user WHERE username = ? AND status = 1",
31
+                username);
32
+            
33
+            if (user == null) {
34
+                return R.error("用户不存在或已被禁用");
35
+            }
36
+            
37
+            // 验证密码
38
+            String storedPassword = (String) jdbcTemplate.queryForObject(
39
+                "SELECT password FROM sys_user WHERE username = ?", String.class, username);
40
+            
41
+            if (!BCrypt.checkpw(password, storedPassword)) {
42
+                // 记录登录失败日志
43
+                jdbcTemplate.update(
44
+                    "INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, request_params, status) " +
45
+                    "VALUES (?, ?, 'auth', 'login_failed', '/api/sso/login', ?, 0)",
46
+                    user.get("id"), username, password);
47
+                return R.error("密码错误");
48
+            }
49
+            
50
+            // 生成 SSO Token
51
+            String ssoToken = generateSsoToken(user.get("id").toString(), username);
52
+            activeTokens.put(ssoToken, username);
53
+            
54
+            // 记录登录成功日志
55
+            jdbcTemplate.update(
56
+                "INSERT INTO sys_oper_log (user_id, username, module, operation, request_url, status) " +
57
+                "VALUES (?, ?, 'auth', 'login_success', '/api/sso/login', 1)",
58
+                user.get("id"), username);
59
+            
60
+            // 构建用户信息返回
61
+            Map<String, Object> userInfo = new HashMap<>();
62
+            userInfo.put("userId", user.get("id"));
63
+            userInfo.put("username", user.get("username"));
64
+            userInfo.put("realName", user.get("real_name"));
65
+            userInfo.put("phone", user.get("phone"));
66
+            userInfo.put("email", user.get("email"));
67
+            userInfo.put("departmentId", user.get("department_id"));
68
+            userInfo.put("ssoToken", ssoToken);
69
+            userInfo.put("appType", appType);
70
+            userInfo.put("loginTime", new Date());
71
+            
72
+            return R.ok(userInfo);
73
+            
74
+        } catch (Exception e) {
75
+            log.error("SSO登录失败", e);
76
+            return R.error("登录失败: " + e.getMessage());
77
+        }
78
+    }
79
+
80
+    // ========== SSO Token 验证 ==========
81
+    public R<Map<String, Object>> validateToken(String ssoToken) {
82
+        if (ssoToken == null || ssoToken.trim().isEmpty()) {
83
+            return R.error("Token不能为空");
84
+        }
85
+        
86
+        String username = activeTokens.get(ssoToken);
87
+        if (username == null) {
88
+            return R.error("Token无效或已过期");
89
+        }
90
+        
91
+        try {
92
+            Map<String, Object> user = jdbcTemplate.queryForMap(
93
+                "SELECT id, username, real_name, phone, email, status " +
94
+                "FROM sys_user WHERE username = ? AND status = 1",
95
+                username);
96
+            
97
+            Map<String, Object> userInfo = new HashMap<>();
98
+            userInfo.put("userId", user.get("id"));
99
+            userInfo.put("username", user.get("username"));
100
+            userInfo.put("realName", user.get("real_name"));
101
+            userInfo.put("phone", user.get("phone"));
102
+            userInfo.put("email", user.get("email"));
103
+            userInfo.put("tokenValid", true);
104
+            userInfo.put("expireTime", new Date(System.currentTimeMillis() + 3600 * 1000)); // 1小时过期
105
+            
106
+            return R.ok(userInfo);
107
+            
108
+        } catch (Exception e) {
109
+            activeTokens.remove(ssoToken);
110
+            return R.error("Token验证失败: " + e.getMessage());
111
+        }
112
+    }
113
+
114
+    // ========== SSO 登出 ==========
115
+    public R<String> logout(String ssoToken) {
116
+        if (ssoToken != null && !ssoToken.trim().isEmpty()) {
117
+            activeTokens.remove(ssoToken);
118
+        }
119
+        
120
+        return R.ok("登出成功");
121
+    }
122
+
123
+    // ========== 生成SSO Token ==========
124
+    private String generateSsoToken(String userId, String username) {
125
+        String token = StpUtilJwt.createToken(userId, username);
126
+        // 存储到数据库用于后续验证
127
+        jdbcTemplate.update(
128
+            "INSERT INTO sso_token (user_id, username, token, create_time, expire_time, status) " +
129
+            "VALUES (?, ?, ?, NOW(), NOW() + INTERVAL '1 hour', 1)",
130
+            userId, username, token);
131
+        return token;
132
+    }
133
+
134
+    // ========== 第三方应用注册 ==========
135
+    public R<Map<String, Object>> registerApp(String appName, String appKey, String appSecret, 
136
+                                             String redirectUri, String description) {
137
+        try {
138
+            // ��查应用名称是否已存在
139
+            Integer count = jdbcTemplate.queryForObject(
140
+                "SELECT COUNT(*) FROM app_registry WHERE app_name = ?", Integer.class, appName);
141
+            
142
+            if (count > 0) {
143
+                return R.error("应用名称已存在");
144
+            }
145
+            
146
+            // 生成新的appKey和appSecret
147
+            String generatedAppKey = "app_" + UUID.randomUUID().toString().replace("-", "");
148
+            String generatedAppSecret = BCrypt.hashpw(generatedAppKey + "-" + new Date().getTime(), BCrypt.gensalt());
149
+            
150
+            // 保存到数据库
151
+            jdbcTemplate.update(
152
+                "INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, status, create_time) " +
153
+                "VALUES (?, ?, ?, ?, ?, 1, NOW())",
154
+                appName, generatedAppKey, generatedAppSecret, redirectUri, description);
155
+            
156
+            Map<String, Object> result = new HashMap<>();
157
+            result.put("appName", appName);
158
+            result.put("appKey", generatedAppKey);
159
+            result.put("appSecret", generatedAppSecret);
160
+            result.put("redirectUri", redirectUri);
161
+            result.put("createTime", new Date());
162
+            
163
+            return R.ok(result);
164
+            
165
+        } catch (Exception e) {
166
+            log.error("应用注册失败", e);
167
+            return R.error("应用注册失败: " + e.getMessage());
168
+        }
169
+    }
170
+
171
+    // ========== 应用验证 ==========
172
+    public R<Map<String, Object>> validateApp(String appKey, String appSecret) {
173
+        try {
174
+            Map<String, Object> app = jdbcTemplate.queryForMap(
175
+                "SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1",
176
+                appKey, appSecret);
177
+            
178
+            Map<String, Object> result = new HashMap<>();
179
+            result.put("appKey", app.get("app_key"));
180
+            result.put("appName", app.get("app_name"));
181
+            result.put("redirectUri", app.get("redirect_uri"));
182
+            result.put("status", app.get("status"));
183
+            
184
+            return R.ok(result);
185
+            
186
+        } catch (Exception e) {
187
+            return R.error("应用验证失败");
188
+        }
189
+    }
190
+
191
+    // ========== 活跃Token管理 ==========
192
+    public int getActiveTokenCount() {
193
+        return activeTokens.size();
194
+    }
195
+
196
+    public boolean isTokenActive(String token) {
197
+        return activeTokens.containsKey(token);
198
+    }
199
+}

+ 198
- 0
wm-revenue/src/main/resources/SSO_README.md Vedi File

@@ -0,0 +1,198 @@
1
+# 营收平台 SSO 单点登录 + 应用接入管理系统
2
+
3
+## 功能概述
4
+
5
+本系统实现了完整的SSO单点登录和第三方应用接入管理功能,包括:
6
+
7
+- OAuth2.0授权框架
8
+- JWT Token认证
9
+- 第三方应用注册管理
10
+- 用户会话管理
11
+- 权限控制
12
+- 审计日志
13
+
14
+## 核心组件
15
+
16
+### 1. SsoService
17
+SSO服务核心类,提供单点登录、Token验证、应用注册等核心功能。
18
+
19
+### 2. OAuth2Controller
20
+OAuth2.0标准接口控制器,支持多种授权模式:
21
+- 客户端凭证模式 (client_credentials)
22
+- 授权码模式 (authorization_code)
23
+- 密码模式 (password)
24
+
25
+### 3. SsoController
26
+SSO专用接口,提供登录、验证、登出等功能。
27
+
28
+### 4. OAuth2Config & OAuth2AuthorizationServerConfig
29
+OAuth2.0配置类,配置授权服务器设置。
30
+
31
+## 数据库表结构
32
+
33
+### sso_token - SSO令牌表
34
+- id: 主键
35
+- user_id: 用户ID
36
+- username: 用户名
37
+- token: JWT令牌
38
+- create_time: 创建时间
39
+- expire_time: 过期时间
40
+- status: 状态 (1:有效, 0:失效)
41
+- last_use_time: 最后使用时间
42
+
43
+### app_registry - 应用注册表
44
+- id: 主键
45
+- app_name: 应用名称
46
+- app_key: 应用密钥
47
+- app_secret: 应用密钥
48
+- redirect_uri: 回调地址
49
+- description: 描述
50
+- status: 状态
51
+- create_time: 创建时间
52
+- admin_user: 管理员用户
53
+
54
+### sso_access_log - SSO访问日志表
55
+- id: 主键
56
+- user_id: 用户ID
57
+- username: 用户名
58
+- app_name: 应用名称
59
+- action: 操作类型
60
+- ip_address: IP地址
61
+- status: 状态
62
+- create_time: 创建时间
63
+
64
+### refresh_token - Token刷新记录表
65
+- id: 主键
66
+- user_id: 用户ID
67
+- username: 用户名
68
+- access_token: 访问令牌
69
+- refresh_token: 刷新令牌
70
+- client_id: 客户端ID
71
+- scope: 作用域
72
+- expire_time: 过期时间
73
+- is_revoked: 是否撤销
74
+
75
+## API 接口
76
+
77
+### SSO登录接口
78
+```http
79
+POST /api/sso/login
80
+Content-Type: application/json
81
+
82
+{
83
+  "username": "用户名",
84
+  "password": "密码",
85
+  "appType": "应用类型"
86
+}
87
+```
88
+
89
+### Token验证接口
90
+```http
91
+POST /api/sso/validate
92
+Content-Type: application/json
93
+
94
+{
95
+  "ssoToken": "令牌"
96
+}
97
+```
98
+
99
+### 应用注册接口
100
+```http
101
+POST /api/sso/app/register
102
+Content-Type: application/json
103
+
104
+{
105
+  "appName": "应用名称",
106
+  "appKey": "应用密钥",
107
+  "appSecret": "应用密钥",
108
+  "redirectUri": "回调地址",
109
+  "description": "应用描述"
110
+}
111
+```
112
+
113
+### OAuth2 Token接口
114
+```http
115
+POST /oauth2/token
116
+Content-Type: application/x-www-form-urlencoded
117
+
118
+grant_type=client_credentials&client_id=客户端ID&client_secret=客户端密钥
119
+```
120
+
121
+## 使用流程
122
+
123
+### 1. 第三方应用接入流程
124
+1. 应用管理员调用应用注册接口获取appKey和appSecret
125
+2. 应用使用appKey和appSecret进行身份验证
126
+3. 应用获取用户授权后,通过OAuth2流程获取访问令牌
127
+4. 使用访问令牌调用受保护的API
128
+
129
+### 2. 用户单点登录流程
130
+1. 用户在任意应用中使用用户名密码登录
131
+2. 系统生成SSO令牌并返回给客户端
132
+3. 客户端存储令牌用于后续API调用
133
+4. 令牌过期时,用户需要重新登录
134
+
135
+### 3. 权限验证流程
136
+1. 客户端在每个API请求中携带访问令牌
137
+2. 服务端验证令牌有效性
138
+3. 检查用户对特定资源的访问权限
139
+4. 允许或拒绝访问请求
140
+
141
+## 配置说明
142
+
143
+### application.yml 配置
144
+```yaml
145
+sso:
146
+  token:
147
+    timeout: 3600  # Token过期时间(秒)
148
+    refresh-enabled: true
149
+    refresh-interval: 300
150
+  security:
151
+    enable-csrf: true
152
+    enable-cors: true
153
+    cors:
154
+      allowed-origins: ["http://localhost:3000", "http://localhost:8080"]
155
+      allowed-methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
156
+      allowed-headers: ["*"]
157
+```
158
+
159
+### 数据库配置
160
+确保数据库中包含SSO相关的表结构,可通过执行以下SQL创建:
161
+```sql
162
+-- 在wm-revenue/src/main/resources/db/V3__sso_tables.sql中定义
163
+```
164
+
165
+## 安全注意事项
166
+
167
+1. **Token安全**:所有令牌都使用JWT格式,包含签名验证
168
+2. **密码安全**:用户密码使用BCrypt加密存储
169
+3. **应用密钥安全**:应用密钥需妥善保管,避免泄露
170
+4. **审计日志**:所有重要操作都有完整的审计日志记录
171
+5. **Token过期**:定期清理过期令牌,确保系统性能
172
+
173
+## 部署说明
174
+
175
+1. 确保数据库连接正常
176
+2. 执行数据库初始化脚本创建SSO相关表
177
+3. 配置application.yml中的相关参数
178
+4. 启动服务并测试各接口功能
179
+
180
+## 故障排查
181
+
182
+### 常见问题
183
+1. **Token验证失败**:检查Token是否过期,签名是否正确
184
+2. **应用注册失败**:检查应用名称是否重复,参数是否完整
185
+3. **数据库连接失败**:检查数据库配置和网络连接
186
+
187
+### 调试建议
188
+1. 启用DEBUG级别日志查看详细错误信息
189
+2. 使用Postman等工具测试接口
190
+3. 检查数据库表结构和数据完整性
191
+
192
+## 更新日志
193
+
194
+### v1.0.0
195
+- 实现基础SSO单点登录功能
196
+- 支持OAuth2.0授权框架
197
+- 实现第三方应用注册管理
198
+- 添加完整的审计日志记录

+ 62
- 0
wm-revenue/src/main/resources/application-oauth2.yml Vedi File

@@ -0,0 +1,62 @@
1
+# OAuth2和SSO配置
2
+spring:
3
+  security:
4
+    oauth2:
5
+      authorization:
6
+        server:
7
+          issuer: http://localhost:8080
8
+          authorization-server-uri: /oauth2
9
+          token-endpoint-uri: /oauth2/token
10
+          authorization-endpoint-uri: /oauth2/authorize
11
+          jwk-set-endpoint-uri: /oauth2/jwks
12
+        client:
13
+          registration:
14
+            revenue-client:
15
+              provider: water-oauth2
16
+              client-id: revenue-platform
17
+              client-secret: revenue-secret-123
18
+              scope: openid,profile,email,revenue:read,revenue:write
19
+              authorization-grant-type: authorization_code,client_credentials
20
+              redirect-uri: http://localhost:8080/login/oauth2/code/revenue-client
21
+
22
+  datasource:
23
+    url: jdbc:postgresql://localhost:5432/water_management
24
+    username: postgres
25
+    password: postgres
26
+    driver-class-name: org.postgresql.Driver
27
+    
28
+  jpa:
29
+    hibernate:
30
+      ddl-auto: validate
31
+    show-sql: true
32
+    properties:
33
+      hibernate:
34
+        dialect: org.hibernate.dialect.PostgreSQLDialect
35
+        format_sql: true
36
+
37
+# SSO配置
38
+sso:
39
+  token:
40
+    timeout: 3600 # 1小时
41
+    refresh-enabled: true
42
+    refresh-interval: 300 # 5分钟
43
+  security:
44
+    enable-csrf: true
45
+    enable-cors: true
46
+    cors:
47
+      allowed-origins: http://localhost:3000,http://localhost:8080
48
+      allowed-methods: GET,POST,PUT,DELETE,OPTIONS
49
+      allowed-headers: "*"
50
+
51
+# JWT配置
52
+jwt:
53
+  secret: water-management-system-secret-2026
54
+  expiration: 3600 # 1小时
55
+  issuer: water-management-system
56
+
57
+# 日志配置
58
+logging:
59
+  level:
60
+    com.water.revenue.service.SsoService: DEBUG
61
+    org.springframework.security.oauth2: DEBUG
62
+    org.springframework.security: DEBUG

+ 141
- 0
wm-revenue/src/main/resources/db/V3__sso_tables.sql Vedi File

@@ -0,0 +1,141 @@
1
+-- SSO单点登录表结构
2
+-- 创建SSO令牌表
3
+CREATE TABLE IF NOT EXISTS sso_token (
4
+    id BIGSERIAL PRIMARY KEY,
5
+    user_id VARCHAR(50) NOT NULL,
6
+    username VARCHAR(100) NOT NULL,
7
+    token VARCHAR(500) NOT NULL,
8
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
9
+    expire_time TIMESTAMP NOT NULL,
10
+    status INTEGER DEFAULT 1,
11
+    last_use_time TIMESTAMP,
12
+    INDEX idx_token (token),
13
+    INDEX idx_user_id (user_id),
14
+    INDEX idx_expire_time (expire_time)
15
+);
16
+
17
+-- 创建应用注册表
18
+CREATE TABLE IF NOT EXISTS app_registry (
19
+    id BIGSERIAL PRIMARY KEY,
20
+    app_name VARCHAR(100) NOT NULL UNIQUE,
21
+    app_key VARCHAR(200) NOT NULL UNIQUE,
22
+    app_secret VARCHAR(500) NOT NULL,
23
+    redirect_uri VARCHAR(500) NOT NULL,
24
+    description TEXT,
25
+    status INTEGER DEFAULT 1,
26
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
27
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
28
+    admin_user VARCHAR(100),
29
+    INDEX idx_app_key (app_key),
30
+    INDEX idx_status (status)
31
+);
32
+
33
+-- 创建SSO访问日志表
34
+CREATE TABLE IF NOT EXISTS sso_access_log (
35
+    id BIGSERIAL PRIMARY KEY,
36
+    user_id VARCHAR(50),
37
+    username VARCHAR(100),
38
+    app_name VARCHAR(100),
39
+    action VARCHAR(50) NOT NULL, -- login, logout, validate, token_exchange
40
+    ip_address VARCHAR(50),
41
+    user_agent TEXT,
42
+    token_used VARCHAR(500),
43
+    status INTEGER DEFAULT 1, -- 1:成功, 0:失败
44
+    error_message TEXT,
45
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+    INDEX idx_user_id (user_id),
47
+    INDEX idx_username (username),
48
+    INDEX idx_app_name (app_name),
49
+    INDEX idx_create_time (create_time)
50
+);
51
+
52
+-- 创建应用接入记录表
53
+CREATE TABLE IF NOT EXISTS app_access_log (
54
+    id BIGSERIAL PRIMARY KEY,
55
+    app_key VARCHAR(200) NOT NULL,
56
+    app_name VARCHAR(100) NOT NULL,
57
+    client_id VARCHAR(200),
58
+    action VARCHAR(50) NOT NULL, -- register, validate, auth, token_request
59
+    ip_address VARCHAR(50),
60
+    user_agent TEXT,
61
+    request_data TEXT,
62
+    response_data TEXT,
63
+    status INTEGER DEFAULT 1,
64
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
+    INDEX idx_app_key (app_key),
66
+    INDEX idx_create_time (create_time)
67
+);
68
+
69
+-- 创建权限配置表
70
+CREATE TABLE IF NOT EXISTS app_permission (
71
+    id BIGSERIAL PRIMARY KEY,
72
+    app_key VARCHAR(200) NOT NULL,
73
+    permission_name VARCHAR(100) NOT NULL,
74
+    permission_code VARCHAR(200) NOT NULL,
75
+    resource_type VARCHAR(50), -- api, menu, button
76
+    resource_id VARCHAR(200),
77
+    is_enabled INTEGER DEFAULT 1,
78
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
79
+    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
80
+    UNIQUE KEY uk_app_key_permission (app_key, permission_code),
81
+    INDEX idx_app_key (app_key),
82
+    INDEX idx_permission_code (permission_code)
83
+);
84
+
85
+-- 创建Token刷新记录表
86
+CREATE TABLE IF NOT EXISTS refresh_token (
87
+    id BIGSERIAL PRIMARY KEY,
88
+    user_id VARCHAR(50) NOT NULL,
89
+    username VARCHAR(100) NOT NULL,
90
+    access_token VARCHAR(500) NOT NULL,
91
+    refresh_token VARCHAR(500) NOT NULL,
92
+    client_id VARCHAR(200) NOT NULL,
93
+    scope VARCHAR(500),
94
+    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+    expire_time TIMESTAMP NOT NULL,
96
+    last_use_time TIMESTAMP,
97
+    is_revoked INTEGER DEFAULT 0,
98
+    INDEX idx_access_token (access_token),
99
+    INDEX idx_refresh_token (refresh_token),
100
+    INDEX idx_user_id (user_id),
101
+    INDEX idx_client_id (client_id)
102
+);
103
+
104
+-- 插入默认的OAuth2客户端配置
105
+INSERT INTO app_registry (app_name, app_key, app_secret, redirect_uri, description, admin_user) VALUES
106
+('营收管理前端', 'revenue-frontend', 'revenue-frontend-secret-2026', 'http://localhost:3000/oauth/callback', '营收管理平台前端应用', 'admin'),
107
+('微信网厅', 'wechat-mall', 'wechat-mall-secret-2026', 'http://localhost:8080/wechat/callback', '微信网上营业厅应用', 'admin'),
108
+('移动端应用', 'mobile-app', 'mobile-app-secret-2026', 'http://localhost:4000/auth/callback', '移动端应用', 'admin')
109
+ON CONFLICT (app_name) DO NOTHING;
110
+
111
+-- 创建SSO触发器:自动清理过期Token
112
+CREATE OR REPLACE FUNCTION clean_expired_tokens()
113
+RETURNS TRIGGER AS $$
114
+BEGIN
115
+    DELETE FROM sso_token WHERE expire_time < NOW();
116
+    DELETE FROM refresh_token WHERE expire_time < NOW();
117
+    RETURN NEW;
118
+END;
119
+$$ LANGUAGE plpgsql;
120
+
121
+-- 创建SSO触发器:当插入token时更新最后使用时间
122
+CREATE OR REPLACE FUNCTION update_last_use_time()
123
+RETURNS TRIGGER AS $$
124
+BEGIN
125
+    IF NEW.status = 1 THEN
126
+        UPDATE sso_token SET last_use_time = NOW() 
127
+        WHERE token = NEW.token AND id != NEW.id;
128
+    END IF;
129
+    RETURN NEW;
130
+END;
131
+$$ LANGUAGE plpgsql;
132
+
133
+-- 创建触发器
134
+CREATE TRIGGER trigger_clean_expired_tokens
135
+    BEFORE INSERT OR UPDATE OR DELETE ON sso_token
136
+    EXECUTE FUNCTION clean_expired_tokens();
137
+
138
+CREATE TRIGGER trigger_update_last_use_time
139
+    AFTER UPDATE ON sso_token
140
+    FOR EACH ROW
141
+    EXECUTE FUNCTION update_last_use_time();

+ 248
- 0
wm-revenue/src/test/java/com/water/revenue/service/SsoServiceTest.java Vedi File

@@ -0,0 +1,248 @@
1
+package com.water.revenue.service;
2
+
3
+import com.water.common.core.result.R;
4
+import org.junit.jupiter.api.BeforeEach;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.InjectMocks;
8
+import org.mockito.Mock;
9
+import org.mockito.junit.jupiter.MockitoExtension;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.test.context.ActiveProfiles;
12
+
13
+import java.util.Map;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.ArgumentMatchers.anyString;
18
+import static org.mockito.Mockito.*;
19
+
20
+@ExtendWith(MockitoExtension.class)
21
+@ActiveProfiles("test")
22
+class SsoServiceTest {
23
+
24
+    @Mock
25
+    private JdbcTemplate jdbcTemplate;
26
+
27
+    @InjectMocks
28
+    private SsoService ssoService;
29
+
30
+    @BeforeEach
31
+    void setUp() {
32
+        // 重置mock调用计数
33
+        reset(jdbcTemplate);
34
+    }
35
+
36
+    @Test
37
+    void testLoginSuccess() {
38
+        // 模拟用户存在
39
+        when(jdbcTemplate.queryForMap(
40
+            eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
41
+            anyString()))
42
+            .thenReturn(Map.of(
43
+                "id", "1",
44
+                "username", "testuser",
45
+                "real_name", "测试用户",
46
+                "phone", "13800138000",
47
+                "email", "test@example.com",
48
+                "status", "1",
49
+                "department_id", "1"
50
+            ));
51
+
52
+        // 模拟密码查询
53
+        when(jdbcTemplate.queryForObject(
54
+            eq("SELECT password FROM sys_user WHERE username = ?"),
55
+            eq(String.class),
56
+            anyString()))
57
+            .thenReturn("{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMy.MrqIeL8nK8s6kJYsKDJ8sR1yxWbKvjm");
58
+
59
+        // 模拟插入操作
60
+        doNothing().when(jdbcTemplate).update(anyString(), any());
61
+
62
+        // 测试登录
63
+        Map<String, String> request = Map.of(
64
+            "username", "testuser",
65
+            "password", "password123",
66
+            "appType", "revenue"
67
+        );
68
+
69
+        R<Map<String, Object>> result = ssoService.login("testuser", "password123", "revenue");
70
+
71
+        // 验证结果
72
+        assertEquals("200", String.valueOf(result.getCode()));
73
+        assertNotNull(result.getData());
74
+        assertEquals("testuser", result.getData().get("username"));
75
+        assertNotNull(result.getData().get("ssoToken"));
76
+        assertEquals("revenue", result.getData().get("appType"));
77
+    }
78
+
79
+    @Test
80
+    void testLoginUserNotFound() {
81
+        // 模拟用户不存在
82
+        when(jdbcTemplate.queryForMap(
83
+            eq("SELECT id, username, real_name, phone, email, status, department_id FROM sys_user WHERE username = ? AND status = 1"),
84
+            anyString()))
85
+            .thenThrow(new RuntimeException("用户不存在"));
86
+
87
+        // 测试登录
88
+        R<Map<String, Object>> result = ssoService.login("nonexistent", "password123", "revenue");
89
+
90
+        // 验证结果
91
+        assertEquals("500", String.valueOf(result.getCode()));
92
+        assertTrue(result.getMessage().contains("用户不存在"));
93
+    }
94
+
95
+    @Test
96
+    void testValidateTokenSuccess() {
97
+        // 设置活跃token
98
+        ssoService.activeTokens.put("test-token", "testuser");
99
+
100
+        // 模拟用户查询
101
+        when(jdbcTemplate.queryForMap(
102
+            eq("SELECT id, username, real_name, phone, email, status FROM sys_user WHERE username = ? AND status = 1"),
103
+            anyString()))
104
+            .thenReturn(Map.of(
105
+                "id", "1",
106
+                "username", "testuser",
107
+                "real_name", "测试用户",
108
+                "phone", "13800138000",
109
+                "email", "test@example.com",
110
+                "status", "1"
111
+            ));
112
+
113
+        // 测试Token验证
114
+        Map<String, String> request = Map.of("ssoToken", "test-token");
115
+        R<Map<String, Object>> result = ssoService.validateToken("test-token");
116
+
117
+        // 验证结果
118
+        assertEquals("200", String.valueOf(result.getCode()));
119
+        assertNotNull(result.getData());
120
+        assertEquals("testuser", result.getData().get("username"));
121
+        assertEquals(true, result.getData().get("tokenValid"));
122
+    }
123
+
124
+    @Test
125
+    void testValidateTokenInvalid() {
126
+        // 测试无效token
127
+        R<Map<String, Object>> result = ssoService.validateToken("invalid-token");
128
+
129
+        // 验证结果
130
+        assertEquals("500", String.valueOf(result.getCode()));
131
+        assertTrue(result.getMessage().contains("Token无效或已过期"));
132
+    }
133
+
134
+    @Test
135
+    void testLogout() {
136
+        // 设置活跃token
137
+        ssoService.activeTokens.put("test-token", "testuser");
138
+
139
+        // 测试登出
140
+        R<String> result = ssoService.logout("test-token");
141
+
142
+        // 验证结果
143
+        assertEquals("200", String.valueOf(result.getCode()));
144
+        assertEquals("登出成功", result.getData());
145
+
146
+        // 验证token已被移除
147
+        assertFalse(ssoService.isTokenActive("test-token"));
148
+    }
149
+
150
+    @Test
151
+    void testRegisterAppSuccess() {
152
+        // 模拟应用名称检查
153
+        when(jdbcTemplate.queryForObject(
154
+            eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
155
+            eq(Integer.class),
156
+            anyString()))
157
+            .thenReturn(0);
158
+
159
+        // 模拟插入操作
160
+        when(jdbcTemplate.update(anyString(), any()))
161
+            .thenReturn(1);
162
+
163
+        // 测试应用注册
164
+        Map<String, String> request = Map.of(
165
+            "appName", "测试应用",
166
+            "appKey", "test-app-key",
167
+            "appSecret", "test-app-secret",
168
+            "redirectUri", "http://localhost:3000/callback",
169
+            "description", "测试应用描述"
170
+        );
171
+
172
+        R<Map<String, Object>> result = ssoService.registerApp(
173
+            "测试应用", "test-app-key", "test-app-secret", 
174
+            "http://localhost:3000/callback", "测试应用描述"
175
+        );
176
+
177
+        // 验证结果
178
+        assertEquals("200", String.valueOf(result.getCode()));
179
+        assertNotNull(result.getData());
180
+        assertEquals("测试应用", result.getData().get("appName"));
181
+        assertNotNull(result.getData().get("appKey"));
182
+        assertNotNull(result.getData().get("appSecret"));
183
+    }
184
+
185
+    @Test
186
+    void testRegisterAppDuplicateName() {
187
+        // 模拟应用名称已存在
188
+        when(jdbcTemplate.queryForObject(
189
+            eq("SELECT COUNT(*) FROM app_registry WHERE app_name = ?"),
190
+            eq(Integer.class),
191
+            anyString()))
192
+            .thenReturn(1);
193
+
194
+        // 测试应用注册
195
+        R<Map<String, Object>> result = ssoService.registerApp(
196
+            "重复应用", "test-app-key", "test-app-secret",
197
+            "http://localhost:3000/callback", "测试应用描述"
198
+        );
199
+
200
+        // 验证结果
201
+        assertEquals("500", String.valueOf(result.getCode()));
202
+        assertTrue(result.getMessage().contains("应用名称已存在"));
203
+    }
204
+
205
+    @Test
206
+    void testValidateAppSuccess() {
207
+        // 模拟应用查询
208
+        when(jdbcTemplate.queryForMap(
209
+            eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
210
+            anyString(), anyString()))
211
+            .thenReturn(Map.of(
212
+                "app_key", "test-app-key",
213
+                "app_name", "测试应用",
214
+                "redirect_uri", "http://localhost:3000/callback",
215
+                "status", "1"
216
+            ));
217
+
218
+        // 测试应用验证
219
+        Map<String, String> request = Map.of(
220
+            "appKey", "test-app-key",
221
+            "appSecret", "test-app-secret"
222
+        );
223
+
224
+        R<Map<String, Object>> result = ssoService.validateApp("test-app-key", "test-app-secret");
225
+
226
+        // 验证结果
227
+        assertEquals("200", String.valueOf(result.getCode()));
228
+        assertNotNull(result.getData());
229
+        assertEquals("test-app-key", result.getData().get("appKey"));
230
+        assertEquals("测试应用", result.getData().get("appName"));
231
+    }
232
+
233
+    @Test
234
+    void testValidateAppFailure() {
235
+        // 模拟应用不存在
236
+        when(jdbcTemplate.queryForMap(
237
+            eq("SELECT * FROM app_registry WHERE app_key = ? AND app_secret = ? AND status = 1"),
238
+            anyString(), anyString()))
239
+            .thenThrow(new RuntimeException("应用不存在"));
240
+
241
+        // 测试应用验证
242
+        R<Map<String, Object>> result = ssoService.validateApp("invalid-app-key", "invalid-app-secret");
243
+
244
+        // 验证结果
245
+        assertEquals("500", String.valueOf(result.getCode()));
246
+        assertTrue(result.getMessage().contains("应用验证失败"));
247
+    }
248
+}