---
date: 2026-05-04
tags: [auth, entra, ropc, mfa, hybrid-identity, security-tradeoff]
related: backend/src/modules/organization/entra/entra.service.ts, backend/src/modules/organization/auth/auth.service.ts
---

# Entra ROPC 兜底登录 + 应用层 MFA bypass

## 背景

`feature/entra-ropc-fallback` 给登录加了第三条认证路径（cloud-only Entra 用户）。原 `case 'ENTRA'` 分支假设"Entra 与本地 AD 同步"，对 cloud-only 用户永远 401。新逻辑：

```
LOCAL → bcrypt
LDAP  → LDAP → (fail) Entra ROPC 兜底
ENTRA → Entra ROPC → (fail) LDAP 兜底 → (fail) 本地密码兜底
```

ROPC（OAuth2 Resource Owner Password Credentials）行为上等同 LDAP bind：后端 HTTPS POST 用户名密码到 Microsoft token endpoint，拿到 token 即视为验证通过。**前端体验不变**，没有跳转、没有 MFA 弹窗。

## 关键决策：应用层 MFA bypass（AADSTS50076 当成功）

### 背景：Free 版的硬约束

AIxCrypto 租户是 **Microsoft Entra ID Free**，**没有 Conditional Access 能力**——意味着没法做"应用级排除 MFA"。Free 版下能控制 MFA 的粒度只剩两个：

- 租户级：Security Defaults（开/关）
- 用户级：Per-user MFA（每个用户 Enforced/Disabled，老 URL `account.activedirectory.windowsazure.com/UserManagement/MultifactorVerification.aspx`）

如果想"仅这个 app 不要 MFA、其它 Microsoft 服务保留 MFA"——**Free 版做不到**。

### 实测确认

用 Microsoft 内置 Azure CLI public client (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`) 跨 tenant 测试 chentao.jia@aixcrypto.ai：

```bash
curl -X POST "https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token" \
  -d grant_type=password -d client_id=04b07795-8ddb-461a-bbee-02f9e1bf7b46 \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "username=user@aixcrypto.ai" -d "password=<correct>"
```

返回 `AADSTS50076`：MFA required。再用错密码同账号测：返回 `AADSTS50126`：invalid password。

**对照证明：** 50076 出现 ⇔ 密码已被 Microsoft 验证通过、仅因租户级 MFA 策略拒发 token。Microsoft 在拒绝前已经完成了密码校验。

### 决策

`EntraService.authenticatePassword` 把以下三个错误码当作"密码验证通过"处理（return true）：

- `AADSTS50076` — MFA required
- `AADSTS50079` — User must enroll MFA
- `AADSTS50072` — User needs MFA enrollment

### 安全边界（必须知情）

**这是应用层主动忽略 Microsoft 的 MFA 策略**——比 ROPC 协议本身更激进的决策。明确含义：

| 场景 | 我们这个 app | Microsoft 其它服务（Outlook/Teams/OneDrive）|
|---|---|---|
| 用户输对密码 | ✅ 直接进 | 弹 MFA |
| 攻击者拿到泄露密码 | ✅ 直接进 | 被 MFA 拦下 |

意义：
1. 我们这个 app 的安全姿态低于 Microsoft 365 服务——攻击者拿到密码进我们 app，但拿不到员工邮箱。
2. **比"关 Security Defaults"或"per-user MFA Disable"更安全**——后两者会让用户在所有 Microsoft 服务上都失去 MFA 保护，本方案只在我们这个 app 内有效。
3. Microsoft 审计日志里这些用户**没有"成功登录"记录**（因为没拿到 token）——出事查日志会看到"用户没登录 Microsoft 但已经在我们系统操作"的诡异场景。这个场景需要 SRE/安全方知情。
4. Microsoft 在 deprecate ROPC，未来可能加客户端检测对抗这种模式。今天能用，6-12 个月可能要切 OIDC。

### 接受这个 tradeoff 的前提

- 你们 app 入口已经有别的边界控制（VPN-only / IP allowlist / 仅内网可达），或者
- 业务可接受"密码即唯一凭据"的风险等级
- Stakeholder（IT / 安全方）知情并同意

## IT 配置（最小化）

由于走"应用层 bypass"路线，**IT 不需要碰 Security Defaults / Conditional Access / Per-user MFA**。只需要：

1. **App Registration → Authentication → Allow public client flows = Yes**
   - 位置：[entra.microsoft.com](https://entra.microsoft.com) → Applications → App registrations → 选中我们的 app → Authentication → 拉到底 Advanced settings
   - 不开这个 → ROPC 第一秒报 `AADSTS7000218`，全员 fail
   - **这是 Free 版唯一需要 IT 改的开关**

不需要改 Security Defaults。**保留它开着，让其它 Microsoft 服务继续受 MFA 保护**——这是安全姿态最优解。

## 验证（IT 配置后用 curl 验证）

```bash
TENANT_ID=c24194cb-4ec1-4df0-ae66-d3bfcd43ae72
CLIENT_ID=<from .env: AZURE_CLIENT_ID for AIxCrypto>

# 错密码测试
curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=${CLIENT_ID}" \
  -d "scope=https://graph.microsoft.com/.default" \
  -d "username=test-user@aixcrypto.ai" \
  -d "password=WRONG"
```

预期：`AADSTS50126`（密码错）—— ROPC 协议层通畅。

不期望：`AADSTS7000218`（public client flow 没开）。

如果对 chentao.jia 输对密码返回 `AADSTS50076`——后端会把这个当成功，前端登录通过。

## AADSTS 错误码对照表

| Code | 含义 | 后端处理 |
|---|---|---|
| 200 + token | 全 OK | ✅ 通过 |
| **50076** | MFA required | ✅ **通过（应用层 bypass）** |
| **50079** | MFA enroll required | ✅ **通过（同上）** |
| **50072** | MFA enroll required | ✅ **通过（同上）** |
| 50126 | 密码错 | ❌ 401 |
| 50034 | 用户不存在 | ❌ 401 |
| 50053 | 账号锁定 | ❌ 401 |
| 50158 | 联邦用户 | ❌ 401（走 SSO 跳转，本期不支持） |
| 7000218 | Public client flows 没开 | ❌ 配置错误，IT 修复 App Registration |

## 不直观的点（拍 PR 前给 reviewer 看）

1. **ROPC 不是浏览器跳转 OAuth**——尽管协议层是 OAuth2，行为上跟 LDAP bind 同构，前端不感知。
2. **AADSTS50076 出现 = 密码已验证通过**——Microsoft 在拒发 token 前已经完成密码检查。错密码会先返回 50126，不会走到 MFA 阶段。
3. **Free 版 Entra ID 没有应用级 MFA 排除能力**——这是 Microsoft 的 license boundary，不是配置问题。要么升级 P1（~$6/人/月）走 CA Policy，要么走应用层 bypass，没有第三条便宜路径。
4. **应用层 bypass 的安全姿态比"关 Security Defaults"更好**——很反直觉，但事实是只让我们这一个 app"装作没看见 MFA"，其它 Microsoft 服务的 MFA 保护完整保留，攻击面比租户级关 MFA 小很多。
5. **Free 版下"Multifactor authentication"菜单在新 Entra Admin Center 被锁后**，per-user MFA 改要走老 URL `account.activedirectory.windowsazure.com/UserManagement/MultifactorVerification.aspx`，新菜单提示 trial 是为了推 Premium。

## 未来工作（不在本 PR 内）

- **OIDC popup 登录**作为更安全的替代方案，给员工/正式用户用（"Sign in with Microsoft" 按钮 + 弹窗 MFA）
- 当前 ROPC 路径作为兜底（服务账号 / 灾备登录 / cloud-only 临时账号）
- 如果未来升级 Entra ID P1，把 50076 处理改回 `return false` + 配 CA exclude，安全姿态升级
