Browse Source

1. 实现了禁用用户名密码登录开关(契约 1.39,安全管控),新增 DMS_PASSWORD_LOGIN_DISABLED 配置(缺省 false),开启后登录接口对全部用户(含内置 admin)最前置返回 403「用户名密码登录已被禁用,请使用单点登录」,守卫先于验证码校验与用户查询——不消耗验证码、不泄露账号存在,门户验签与本地 SSO 代理登录成为仅存登录入口;
2. 回显了开关至登录页,GET /auth/captcha 响应 data 新增必返 passwordLoginDisabled 布尔,前端据此隐藏账号密码表单、仅显示单点登录提示(表单隐藏仅为体验,后端守卫为安全兜底,接口直调同样被拒);
3. 更新了接口契约至 1.39 与 OpenAPI 1.39.0、.env 与两份 .env.example 配置说明(含「开启前须确认门户或本地 SSO 至少一项可用,否则无人能登录」警示),新增开关守卫、关闭回归与真值解析测试(Java 全量 140 用例、前端 460 用例与类型检查全部通过)。

weisijie 4 weeks ago
parent
commit
07862084d2

File diff suppressed because it is too large
+ 11 - 3
DMS_API_CONTRACT.md


+ 11 - 0
backend-java/.env.example

@@ -35,6 +35,17 @@ DMS_CAPTCHA_ENABLED=true
 # 同名用户报"不能创建该名称的用户",外部无法感知系统中存在该账号
 # 同名用户报"不能创建该名称的用户",外部无法感知系统中存在该账号
 DMS_ALLOW_ADMIN_LOGIN=true
 DMS_ALLOW_ADMIN_LOGIN=true
 
 
+# ─────────────────────────────────────────────────────────────
+# 禁用用户名密码登录(安全管控)
+# ─────────────────────────────────────────────────────────────
+# 缺省 false。设为 true 后仅允许单点登录(门户跳转验签、本地 SSO 代理)进入系统:
+# POST /auth/login 一律 403「用户名密码登录已被禁用」(先于验证码校验与用户查询,
+# 不消耗验证码、不泄露账号是否存在),登录页隐藏账号密码表单仅显示单点登录提示。
+# 注意:开启前请确认门户(DMS_PORTAL_*)或本地 SSO(DMS_SSO_PROXY_ENABLED=true)
+# 至少一项可用,否则将无人能登录系统(需改回 .env 后重启方可恢复)
+DMS_PASSWORD_LOGIN_DISABLED=false
+
+
 # 一次一码轮换(安全需求:写操作防重放,缺省 true 开启,当前仅 Java 版实现):
 # 一次一码轮换(安全需求:写操作防重放,缺省 true 开启,当前仅 Java 版实现):
 # 开启后 POST/PUT/DELETE 写操作成功响应经 X-DMS-Access-Token 头返回轮换出的新
 # 开启后 POST/PUT/DELETE 写操作成功响应经 X-DMS-Access-Token 头返回轮换出的新
 # 授权码,旧码对写操作立即失效(401 SESSION_ROTATED);GET 读请求沿用原码不轮换。
 # 授权码,旧码对写操作立即失效(401 SESSION_ROTATED);GET 读请求沿用原码不轮换。

+ 6 - 1
backend-java/src/main/java/com/dms/api/v1/AuthController.java

@@ -2,6 +2,7 @@ package com.dms.api.v1;
 
 
 import com.dms.common.ApiResponse;
 import com.dms.common.ApiResponse;
 import com.dms.common.Errors;
 import com.dms.common.Errors;
+import com.dms.config.DmsProperties;
 import com.dms.security.AuthRequired;
 import com.dms.security.AuthRequired;
 import com.dms.service.AuthService;
 import com.dms.service.AuthService;
 import com.dms.service.CaptchaService;
 import com.dms.service.CaptchaService;
@@ -29,16 +30,20 @@ public class AuthController {
 
 
     private final AuthService authService;
     private final AuthService authService;
     private final CaptchaService captchaService;
     private final CaptchaService captchaService;
+    private final DmsProperties dmsProperties;
 
 
-    public AuthController(AuthService authService, CaptchaService captchaService) {
+    public AuthController(AuthService authService, CaptchaService captchaService,
+                          DmsProperties dmsProperties) {
         this.authService = authService;
         this.authService = authService;
         this.captchaService = captchaService;
         this.captchaService = captchaService;
+        this.dmsProperties = dmsProperties;
     }
     }
 
 
     /** 登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。 */
     /** 登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。 */
     @GetMapping("/captcha")
     @GetMapping("/captcha")
     public ApiResponse<Object> captcha() {
     public ApiResponse<Object> captcha() {
         Map<String, Object> data = new LinkedHashMap<>();
         Map<String, Object> data = new LinkedHashMap<>();
+        data.put("passwordLoginDisabled", dmsProperties.passwordLoginDisabled());
         if (!captchaService.enabled()) {
         if (!captchaService.enabled()) {
             data.put("enabled", false);
             data.put("enabled", false);
             return ApiResponse.ok(data);
             return ApiResponse.ok(data);

+ 12 - 0
backend-java/src/main/java/com/dms/config/DmsProperties.java

@@ -50,6 +50,8 @@ public class DmsProperties {
     private final boolean captchaEnabled;
     private final boolean captchaEnabled;
     /** 超级管理员登录开关(安全需求7):false 时内置admin禁止登录、列表隐藏、创建伪报错。 */
     /** 超级管理员登录开关(安全需求7):false 时内置admin禁止登录、列表隐藏、创建伪报错。 */
     private final boolean adminLoginAllowed;
     private final boolean adminLoginAllowed;
+    /** 禁用用户名密码登录(安全管控):true 时仅允许单点登录(门户验签/本地SSO代理)。 */
+    private final boolean passwordLoginDisabled;
     /** 一次一码轮换(安全需求):写操作成功后轮换授权码并经响应头返回,旧码写操作即拒;
     /** 一次一码轮换(安全需求):写操作成功后轮换授权码并经响应头返回,旧码写操作即拒;
      *  关闭时登录不落会话代(存量会话自然过渡),缺省启用。 */
      *  关闭时登录不落会话代(存量会话自然过渡),缺省启用。 */
     private final boolean tokenRotationEnabled;
     private final boolean tokenRotationEnabled;
@@ -154,6 +156,11 @@ public class DmsProperties {
                 "DMS_ALLOW_ADMIN_LOGIN", "true").trim().toLowerCase(Locale.ROOT);
                 "DMS_ALLOW_ADMIN_LOGIN", "true").trim().toLowerCase(Locale.ROOT);
         this.adminLoginAllowed =
         this.adminLoginAllowed =
                 Set.of("1", "true", "yes", "on").contains(adminLoginValue);
                 Set.of("1", "true", "yes", "on").contains(adminLoginValue);
+        // 安全管控需求:禁用用户名密码登录开关,缺省 false;开启后仅单点登录可入。
+        String passwordLoginValue = environment.getProperty(
+                "DMS_PASSWORD_LOGIN_DISABLED", "false").trim().toLowerCase(Locale.ROOT);
+        this.passwordLoginDisabled =
+                Set.of("1", "true", "yes", "on").contains(passwordLoginValue);
         // 安全需求:一次一码轮换开关,缺省 true;关闭时写操作不比对、不轮换、不落会话代。
         // 安全需求:一次一码轮换开关,缺省 true;关闭时写操作不比对、不轮换、不落会话代。
         String rotationValue = environment.getProperty(
         String rotationValue = environment.getProperty(
                 "DMS_TOKEN_ROTATION_ENABLED", "true").trim().toLowerCase(Locale.ROOT);
                 "DMS_TOKEN_ROTATION_ENABLED", "true").trim().toLowerCase(Locale.ROOT);
@@ -366,6 +373,11 @@ public class DmsProperties {
         return adminLoginAllowed;
         return adminLoginAllowed;
     }
     }
 
 
+    /** 禁用用户名密码登录开关(安全管控);未配置默认不禁用。 */
+    public boolean passwordLoginDisabled() {
+        return passwordLoginDisabled;
+    }
+
     /** 一次一码轮换开关(安全需求);未配置默认启用。 */
     /** 一次一码轮换开关(安全需求);未配置默认启用。 */
     public boolean tokenRotationEnabled() {
     public boolean tokenRotationEnabled() {
         return tokenRotationEnabled;
         return tokenRotationEnabled;

+ 5 - 0
backend-java/src/main/java/com/dms/service/AuthService.java

@@ -50,6 +50,11 @@ public class AuthService {
     public Map<String, Object> login(String username, String password, boolean keepSignedIn,
     public Map<String, Object> login(String username, String password, boolean keepSignedIn,
                                      String captchaId, String captchaCode,
                                      String captchaId, String captchaCode,
                                      HttpServletRequest request) {
                                      HttpServletRequest request) {
+        // 安全管控:禁用用户名密码登录时仅允许单点登录(门户验签/本地SSO代理),
+        // 守卫置于最前——验证码不消耗、用户名不查询,不泄露任何账号信息
+        if (dmsProperties.passwordLoginDisabled()) {
+            throw Errors.forbidden("用户名密码登录已被禁用,请使用单点登录");
+        }
         // 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定(安全需求4)
         // 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定(安全需求4)
         captchaService.verify(captchaId, captchaCode);
         captchaService.verify(captchaId, captchaCode);
         // 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
         // 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP

+ 78 - 0
backend-java/src/test/java/com/dms/service/PasswordLoginSwitchTest.java

@@ -0,0 +1,78 @@
+package com.dms.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.dms.common.DmsException;
+import com.dms.config.DmsProperties;
+import com.dms.mapper.UserMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * 禁用用户名密码登录开关(安全管控,DMS_PASSWORD_LOGIN_DISABLED 缺省 false):
+ * 开启后 login 最前置 403 拒绝(不消耗验证码、不查询用户),关闭时登录照常走原路径。
+ */
+class PasswordLoginSwitchTest {
+
+    private static DmsProperties props(boolean disabled) {
+        MockEnvironment environment = new MockEnvironment();
+        if (disabled) {
+            environment.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "true");
+        }
+        return new DmsProperties(environment);
+    }
+
+    private static AuthService service(UserMapper userMapper,
+            CaptchaService captchaService, DmsProperties props) {
+        return new AuthService(userMapper, mock(AuditService.class),
+                mock(LoginIpService.class), mock(UserSummaryService.class),
+                mock(LoginLockoutService.class), captchaService, null, props);
+    }
+
+    @Test
+    void loginRejectedBeforeCaptchaAndDbLookupWhenDisabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        CaptchaService captchaService = mock(CaptchaService.class);
+        AuthService service = service(userMapper, captchaService, props(true));
+        // 403 FORBIDDEN:对全部用户名(含内置 admin)一律拒绝,消息引导单点登录
+        assertThatThrownBy(() -> service.login("someone", "Passw0rd!", false, null, null, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "FORBIDDEN")
+                .hasMessage("用户名密码登录已被禁用,请使用单点登录");
+        // 守卫先于验证码消费与用户查询:两者均不触达,不泄露任何账号信息
+        verify(captchaService, never()).verify(any(), any());
+        verifyNoInteractions(userMapper);
+    }
+
+    @Test
+    void loginProceedsOnOriginalPathWhenEnabled() {
+        UserMapper userMapper = mock(UserMapper.class);
+        when(userMapper.findActiveByUsername("someone")).thenReturn(null);
+        CaptchaService captchaService = mock(CaptchaService.class);
+        AuthService service = service(userMapper, captchaService, props(false));
+        // 未配置开关(缺省 false):照常查库,用户不存在报密码错误(原语义不变)
+        assertThatThrownBy(() -> service.login("someone", "bad", false, null, null, null))
+                .isInstanceOf(DmsException.class)
+                .hasFieldOrPropertyWithValue("code", "INVALID_CREDENTIALS");
+        verify(userMapper).findActiveByUsername("someone");
+    }
+
+    @Test
+    void propsParseTruthinessAndDefault() {
+        assertThat(props(false).passwordLoginDisabled()).isFalse();
+        MockEnvironment on = new MockEnvironment();
+        on.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "1");
+        assertThat(new DmsProperties(on).passwordLoginDisabled()).isTrue();
+        MockEnvironment bogus = new MockEnvironment();
+        bogus.setProperty("DMS_PASSWORD_LOGIN_DISABLED", "yes-on-please");
+        // 仅 1/true/yes/on 视为开启,其余(含乱写)一律关闭
+        assertThat(new DmsProperties(bogus).passwordLoginDisabled()).isFalse();
+    }
+}

+ 11 - 0
backend/.env.example

@@ -42,6 +42,17 @@ DMS_CAPTCHA_ENABLED=true
 # 名称的用户",外部无法感知系统中存在该账号
 # 名称的用户",外部无法感知系统中存在该账号
 DMS_ALLOW_ADMIN_LOGIN=true
 DMS_ALLOW_ADMIN_LOGIN=true
 
 
+# ─────────────────────────────────────────────────────────────
+# 禁用用户名密码登录(安全管控)
+# ─────────────────────────────────────────────────────────────
+# 缺省 false。设为 true 后仅允许单点登录(门户跳转验签、本地 SSO 代理)进入系统:
+# POST /auth/login 一律 403「用户名密码登录已被禁用」(先于验证码校验与用户查询,
+# 不消耗验证码、不泄露账号是否存在),登录页隐藏账号密码表单仅显示单点登录提示。
+# 注意:开启前请确认门户(DMS_PORTAL_*)或本地 SSO(DMS_SSO_PROXY_ENABLED=true)
+# 至少一项可用,否则将无人能登录系统(需改回 .env 后重启方可恢复)
+DMS_PASSWORD_LOGIN_DISABLED=false
+
+
 # 一次一码轮换(安全需求:写操作防重放,缺省 true,当前仅 Java 版实现):
 # 一次一码轮换(安全需求:写操作防重放,缺省 true,当前仅 Java 版实现):
 # 开启后写操作成功响应经 X-DMS-Access-Token 头返回新授权码,旧码写操作即拒;
 # 开启后写操作成功响应经 X-DMS-Access-Token 头返回新授权码,旧码写操作即拒;
 # false 时登录不落会话代、写操作不比对不轮换。列 session_jti 由 0038/V24 迁移添加
 # false 时登录不落会话代、写操作不比对不轮换。列 session_jti 由 0038/V24 迁移添加

File diff suppressed because it is too large
+ 0 - 1
backend/openapi/openapi.yaml


+ 2 - 1
backend/openapi/paths/auth.yaml

@@ -20,10 +20,11 @@ Captcha:
                 requestId: {type: string, format: uuid}
                 requestId: {type: string, format: uuid}
                 data:
                 data:
                   type: object
                   type: object
-                  required: [enabled]
+                  required: [enabled, passwordLoginDisabled]
                   additionalProperties: false
                   additionalProperties: false
                   properties:
                   properties:
                     enabled: {type: boolean, description: 验证码开关当前值}
                     enabled: {type: boolean, description: 验证码开关当前值}
+                    passwordLoginDisabled: {type: boolean, description: 禁用用户名密码登录开关回显(1.39安全管控,仅Java版);true时登录页隐藏账号密码表单,POST /auth/login一律403}
                     captchaId: {type: string, description: 验证码标识,登录时原样携带;enabled=true时必返}
                     captchaId: {type: string, description: 验证码标识,登录时原样携带;enabled=true时必返}
                     image: {type: string, description: data:image/png;base64图片,img标签src直接使用;enabled=true时必返}
                     image: {type: string, description: data:image/png;base64图片,img标签src直接使用;enabled=true时必返}
       '500':
       '500':

+ 7 - 0
frontend/src/app/App.tsx

@@ -2653,6 +2653,12 @@ function LoginPage({ onLogin, notice }: {
                   </div>
                   </div>
                 </div>
                 </div>
               )}
               )}
+              {captcha?.passwordLoginDisabled ? (
+                <div className="flex items-start gap-2 px-3 py-3 rounded-sm" style={{ background: `rgba(var(--dms-line-rgb),0.08)`, border: `1px solid rgba(var(--dms-deep-rgb),0.2)` }}>
+                  <AlertCircle size={16} style={{ color: B, flexShrink: 0, marginTop: 4 }} />
+                  <span className="text-base font-black leading-relaxed" style={{ color: N }}>用户名密码登录已被禁用,请通过门户或单点登录进入系统</span>
+                </div>
+              ) : (
               <div className="space-y-4">
               <div className="space-y-4">
                 {[
                 {[
                   { label: "用户名", key: "username", placeholder: "请输入用户名" },
                   { label: "用户名", key: "username", placeholder: "请输入用户名" },
@@ -2709,6 +2715,7 @@ function LoginPage({ onLogin, notice }: {
                   {submitting ? "登 录 中…" : "登 录"}
                   {submitting ? "登 录 中…" : "登 录"}
                 </button>
                 </button>
               </div>
               </div>
+              )}
               <p className="text-center text-sm font-bold mt-6" style={{ color: `rgba(var(--dms-deep-rgb),0.3)` }}>本系统为涉密系统 · 严禁未授权访问</p>
               <p className="text-center text-sm font-bold mt-6" style={{ color: `rgba(var(--dms-deep-rgb),0.3)` }}>本系统为涉密系统 · 严禁未授权访问</p>
             </div>
             </div>
           </div>
           </div>

+ 2 - 0
frontend/src/features/auth/authTypes.ts

@@ -24,6 +24,8 @@ export interface LoginRequest {
 /** 验证码签发响应(安全需求4):关闭时仅 enabled=false。 */
 /** 验证码签发响应(安全需求4):关闭时仅 enabled=false。 */
 export interface CaptchaData {
 export interface CaptchaData {
   enabled: boolean;
   enabled: boolean;
+  /** 密码登录禁用开关回显(安全管控):true 时登录页隐藏账号密码表单。 */
+  passwordLoginDisabled?: boolean;
   captchaId?: string;
   captchaId?: string;
   image?: string;
   image?: string;
 }
 }

Some files were not shown because too many files changed in this diff