Bladeren bron

role增加验证码

robert 6 dagen geleden
bovenliggende
commit
7241d088e2
7 gewijzigde bestanden met toevoegingen van 492 en 23 verwijderingen
  1. 36 3
      README.md
  2. 34 3
      auth.go
  3. 88 0
      cli.go
  4. 164 0
      example/web_admin/index.html
  5. 32 17
      example/web_admin/main.go
  6. 36 0
      server.go
  7. 102 0
      tcp_server.go

+ 36 - 3
README.md

@@ -30,6 +30,10 @@
     - **节点级会话管理**:Session 采用本地内存存储,支持持久连接绑定,极大减少了 Raft 日志的非必要开销。
     - **分布式登录限制**:基于 Raft 强一致性的登录失败计数与锁定机制,防止暴力破解(连续 3 次失败锁定 1 分钟)。
     - **全局一致性**:用户、角色及登录锁定状态通过 Raft 共识同步到全集群。
+- ✅ **多因素认证 (MFA / 2FA)**
+    - **TOTP 支持**:集成标准的 Google Authenticator 协议 (TOTP),提供更高安全级的身份验证。
+    - **自选启用**:用户可自主决定是否开启双因素认证,不强制但推荐关键账户启用。
+    - **全平台支持**:CLI 命令行与 Web Admin 管理端均完整支持 MFA 的设置、验证与登录流程。
 - ✅ **TCP API 接口**
     - 提供基于持久连接的 TCP 文本协议接口(类似 Redis),支持 `LOGIN`, `AUTH`, `GET`, `SET` 等命令,方便集成到各类客户端。
 - ✅ **领导权转移 (Leadership Transfer)**
@@ -164,7 +168,9 @@ err := server1.Join("node3", "127.0.0.1:9003")
     *   `count <pattern>`:统计符合模式的 Key 数量(仅统计有权访问的 Key)。
 *   **权限管理**:
     *   `auth-init <root_pass>`:初始化权限系统(仅能执行一次)。
-    *   `login <user> <pass> [code]`:登录系统(全局会话)。
+    *   `login <user> <pass> [code]`:登录系统(全局会话)。如果启用了 MFA,系统会提示输入验证码。
+    *   `user mfa enable`:开启当前用户的双因素认证(显示密钥及二维码链接)。
+    *   `user mfa disable`:关闭当前用户的双因素认证。
     *   `logout`:登出当前会话(全局注销)。
     *   `whoami`:查看当前登录用户及 Token 信息。
 *   **集群管理** (Admin Only):
@@ -227,7 +233,28 @@ err := server1.Join("node3", "127.0.0.1:9003")
 -   **本地 Session**: Session 验证纯内存操作,无网络 IO。
 -   **Superuser 快速通道**: 对于拥有 `*` 权限的用户(如 root),系统会直接下推查询到 DB 引擎。
 
-### 2. TCP API 与 Web 管理演示
+### 2. 双因素认证 (MFA) 核心实现
+
+为了提高账户安全性,系统集成了标准的 TOTP (Time-Based One-Time Password) 算法,实现了完整的双因素认证流程。
+
+#### 核心实现
+-   **密钥生成**: 使用 `crypto/rand` 生成安全的随机种子,并编码为标准 Base32 格式,兼容 Google Authenticator。
+-   **验证算法**: 实现了标准的 HMAC-SHA1 TOTP 算法,支持 30秒 时间窗口,并包含前后各 30秒 的容错窗口,防止时钟偏移导致的验证失败。
+-   **强制执行**: 在登录流程中 (`Login` 方法),如果用户 `MFAEnabled` 为 true,系统会强制检查 `otp` 参数,验证失败或缺失将拒绝登录。
+
+#### 使用说明
+-   **CLI 端**:
+    1.  登录后执行 `user mfa enable`。
+    2.  系统将显示密钥及二维码链接。
+    3.  输入验证码确认后开启。
+    4.  下次登录时,系统会提示 `Two-Factor Authentication Required`,此时需输入 MFA 码。
+-   **Web Admin 端**:
+    1.  在侧边栏点击 **Settings**。
+    2.  点击 **Enable 2FA**,系统会生成二维码。
+    3.  使用 Authenticator App 扫描二维码。
+    4.  输入 6 位验证码完成绑定。
+
+### 3. TCP API 与 Web 管理演示
 系统现在提供了一个基于 TCP 的持久连接 API,并配套了一个 Web 管理端示例。
 
 **TCP API 协议(默认端口:Raft端口 + 10,如 9011)**:
@@ -240,6 +267,12 @@ err := server1.Join("node3", "127.0.0.1:9003")
 < OK
 ```
 
+**新增 MFA 相关指令**:
+- `MFA-GENERATE`: 生成新的 TOTP 密钥与 URL。
+- `MFA-ENABLE <secret> <code>`: 验证并开启 MFA。
+- `MFA-DISABLE`: 关闭 MFA。
+- `MFA-STATUS`: 查询当前 MFA 状态。
+
 **运行 Web 管理端:**
 ```bash
 cd example/web_admin
@@ -247,7 +280,7 @@ go run main.go
 # 访问 http://localhost:8088
 ```
 
-### 3. 运行演示 (Example)
+### 4. 运行演示 (Example)
 我们在 `example/role` 目录下提供了一个完整的演示案例,模拟了“销售经理 vs 普通销售”的折扣权限控制场景。
 
 **步骤 1: 编译并启动 Node 1**

+ 34 - 3
auth.go

@@ -2,6 +2,7 @@ package raft
 
 import (
 	"crypto/hmac"
+	"crypto/rand"
 	"crypto/sha1"
 	"crypto/sha256"
 	"encoding/base32"
@@ -148,12 +149,20 @@ func generateToken() string {
 	return hex.EncodeToString(h[:])
 }
 
-// Helper for TOTP (Google Authenticator)
-func validateTOTP(secret string, code string) bool {
+// ValidateTOTP validates a TOTP code against a secret
+func ValidateTOTP(secret string, code string) bool {
 	// Decode base32 secret
 	key, err := base32.StdEncoding.DecodeString(strings.ToUpper(secret))
 	if err != nil {
-		return false
+		// Try with padding if failed? Or assume standard encoding.
+		// Some libs produce non-padded.
+		if padding := len(secret) % 8; padding != 0 {
+			secret += strings.Repeat("=", 8-padding)
+			key, err = base32.StdEncoding.DecodeString(strings.ToUpper(secret))
+		}
+		if err != nil {
+			return false
+		}
 	}
 
 	// Current time steps (30s window)
@@ -162,6 +171,28 @@ func validateTOTP(secret string, code string) bool {
 	return checkTOTP(key, now, code) || checkTOTP(key, now-30, code) || checkTOTP(key, now+30, code)
 }
 
+// GenerateMFASecret generates a new random Base32 secret for TOTP
+func GenerateMFASecret() (string, error) {
+	b := make([]byte, 20) // 160 bits is recommended
+	_, err := rand.Read(b)
+	if err != nil {
+		return "", err
+	}
+	return base32.StdEncoding.EncodeToString(b), nil
+}
+
+// GenerateOTPAuthURL generates a URL for QR code generation
+func GenerateOTPAuthURL(username, secret, issuer string) string {
+	// otpauth://totp/Issuer:User?secret=SECRET&issuer=Issuer
+	return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", 
+		issuer, username, secret, issuer)
+}
+
+// validateTOTP kept for backward compatibility if used internally, forwarding to exported
+func validateTOTP(secret string, code string) bool {
+	return ValidateTOTP(secret, code)
+}
+
 func checkTOTP(key []byte, timestamp int64, code string) bool {
 	step := timestamp / 30
 	buf := make([]byte, 8)

+ 88 - 0
cli.go

@@ -402,6 +402,16 @@ func (c *CLI) registerDefaultCommands() {
 				return
 			}
 			t, err := server.AuthManager.Login(username, password, code, "cli")
+			if err != nil && err.Error() == "mfa code required" {
+				fmt.Printf("%sTwo-Factor Authentication Required%s\n", ColorYellow, ColorReset)
+				fmt.Print("Enter MFA Code: ")
+				code, err = readPassword()
+				fmt.Println()
+				if err == nil {
+					t, err = server.AuthManager.Login(username, password, code, "cli")
+				}
+			}
+
 			if err != nil {
 				printBoxed(fmt.Sprintf("%sLogin Failed:%s %v", ColorRed, ColorReset, err))
 			} else {
@@ -411,6 +421,84 @@ func (c *CLI) registerDefaultCommands() {
 				printBoxed(fmt.Sprintf("%sLogin Success!%s", ColorGreen, ColorReset))
 			}
 
+		case "mfa":
+			if len(args) < 1 {
+				printBoxed("Usage: user mfa <enable|disable>")
+				return
+			}
+			// Require Login
+			c.mu.RLock()
+			token := c.token
+			c.mu.RUnlock()
+			if token == "" {
+				printBoxed("Error: You must be logged in.")
+				return
+			}
+
+			switch args[0] {
+			case "enable":
+				// Generate
+				secret, err := GenerateMFASecret()
+				if err != nil {
+					printBoxed(fmt.Sprintf("Error generating secret: %v", err))
+					return
+				}
+				
+				// Get Username
+				session, err := server.AuthManager.GetSession(token)
+				if err != nil {
+					printBoxed("Session expired")
+					return
+				}
+				
+				url := GenerateOTPAuthURL(session.Username, secret, "RaftKV")
+				
+				// Show info
+				fmt.Println()
+				fmt.Printf("%sSetup 2FA%s\n", ColorCyan, ColorReset)
+				fmt.Println("1. Install Google Authenticator or compatible app.")
+				fmt.Println("2. Add account manually using this secret:")
+				fmt.Printf("   %s%s%s\n", ColorGreen, secret, ColorReset)
+				fmt.Printf("   (URL: %s)\n", url)
+				fmt.Println("3. Enter the 6-digit code to verify.")
+				fmt.Println()
+				
+				fmt.Print("Verification Code: ")
+				code, err := readPassword()
+				fmt.Println()
+				
+				if !ValidateTOTP(secret, code) {
+					printBoxed(fmt.Sprintf("%sError: Invalid Code%s", ColorRed, ColorReset))
+					return
+				}
+				
+				if err := server.SetUserMFA(session.Username, secret, true, token); err != nil {
+					printBoxed(fmt.Sprintf("Error saving user: %v", err))
+				} else {
+					printBoxed(fmt.Sprintf("%sMFA Enabled Successfully!%s", ColorGreen, ColorReset))
+				}
+
+			case "disable":
+				fmt.Print("Are you sure you want to disable MFA? (y/N): ")
+				var resp string
+				fmt.Scanln(&resp)
+				if strings.ToLower(resp) != "y" {
+					return
+				}
+				
+				session, err := server.AuthManager.GetSession(token)
+				if err != nil {
+					printBoxed("Session expired")
+					return
+				}
+				
+				if err := server.SetUserMFA(session.Username, "", false, token); err != nil {
+					printBoxed(fmt.Sprintf("Error saving user: %v", err))
+				} else {
+					printBoxed(fmt.Sprintf("%sMFA Disabled%s", ColorGreen, ColorReset))
+				}
+			}
+
 		case "logout": // Was logout
 			if token != "" {
 				server.Logout(token)

+ 164 - 0
example/web_admin/index.html

@@ -6,6 +6,7 @@
     <title>Raft KV Admin</title>
     <script src="https://cdn.tailwindcss.com"></script>
     <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
     <script>
         tailwind.config = {
             darkMode: 'class',
@@ -86,6 +87,12 @@
                     <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
                     Roles
                 </a>
+                
+                <div class="pt-4 pb-2 px-2 text-xs font-bold text-slate-500 uppercase">Account</div>
+                <a @click="checkMFA(); tab = 'settings'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'settings', 'hover:bg-dark-700': tab !== 'settings'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
+                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
+                    Settings
+                </a>
             </nav>
 
             <div class="p-4 border-t border-dark-700">
@@ -284,6 +291,52 @@
                     </div>
                 </div>
 
+                <!-- Settings Tab -->
+                <div x-show="tab === 'settings'" class="space-y-6">
+                    <div class="bg-dark-800 rounded-2xl border border-dark-700 p-8">
+                        <h3 class="text-xl font-bold text-white mb-6">Security Settings</h3>
+                        
+                        <div class="flex items-start justify-between">
+                            <div>
+                                <div class="font-bold text-white text-lg mb-1">Two-Factor Authentication (2FA)</div>
+                                <div class="text-slate-400 max-w-lg">Protect your account by requiring a code from your authenticator app (like Google Authenticator) in addition to your password.</div>
+                            </div>
+                            <div>
+                                <template x-if="mfaStatus === 'enabled'">
+                                    <button @click="disableMFA" class="bg-red-500/10 text-red-400 border border-red-500/50 px-6 py-2 rounded-lg font-bold hover:bg-red-500/20 transition">Disable 2FA</button>
+                                </template>
+                                <template x-if="mfaStatus !== 'enabled'">
+                                    <button @click="startMFASetup" class="bg-brand-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-brand-500 transition">Enable 2FA</button>
+                                </template>
+                            </div>
+                        </div>
+
+                        <!-- MFA Setup Area -->
+                        <div x-show="isSettingUpMFA" class="mt-8 border-t border-dark-700 pt-8" x-transition>
+                            <h4 class="text-lg font-bold text-white mb-4">Setup 2FA</h4>
+                            <div class="flex flex-col md:flex-row gap-8">
+                                <div class="bg-white p-4 rounded-lg inline-block">
+                                    <canvas id="qr" width="200" height="200"></canvas>
+                                </div>
+                                <div class="flex-1 space-y-4">
+                                    <div class="p-4 bg-dark-900 rounded-lg border border-dark-700">
+                                        <div class="text-xs text-slate-500 uppercase font-bold mb-1">Secret Key</div>
+                                        <div class="font-mono text-xl text-white tracking-wider" x-text="mfaSecret"></div>
+                                    </div>
+                                    <div>
+                                        <label class="block text-sm font-medium mb-2 text-slate-400">Verification Code</label>
+                                        <div class="flex gap-2">
+                                            <input x-model="mfaCode" type="text" placeholder="000000" maxlength="6" class="w-40 bg-dark-900 border border-dark-700 rounded-lg p-3 text-white text-center font-mono text-lg outline-none focus:border-brand-500">
+                                            <button @click="verifyAndEnableMFA" class="bg-green-600 text-white px-6 rounded-lg font-bold hover:bg-green-500">Verify & Enable</button>
+                                        </div>
+                                    </div>
+                                    <p class="text-sm text-slate-500">Scan the QR code with your authenticator app, or enter the secret key manually. Then enter the 6-digit code to confirm.</p>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
                 <!-- Users Tab -->
                 <div x-show="tab === 'users'" class="space-y-6">
                     <!-- Actions -->
@@ -321,6 +374,7 @@
                                             </span>
                                         </td>
                                         <td class="p-4 text-right">
+                                            <button @click="openEditUserModal(u)" class="text-blue-500 hover:text-blue-400 text-sm mr-3">Edit</button>
                                             <button @click="unlockUser(u.username)" class="text-yellow-500 hover:text-yellow-400 text-sm mr-3">Reset Lock</button>
                                             <!-- <button class="text-red-500 hover:text-red-400 text-sm">Delete</button> -->
                                         </td>
@@ -396,6 +450,27 @@
         </div>
     </div>
 
+    <!-- Edit User Modal -->
+    <div x-show="showEditUserModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
+        <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
+            <h3 class="text-xl font-bold text-white mb-4">Edit User <span x-text="editUserForm.username" class="text-brand-500"></span></h3>
+            <div class="space-y-4">
+                <div>
+                    <label class="block text-sm font-medium mb-1 text-slate-400">New Password (Optional)</label>
+                    <input x-model="editUserForm.password" type="password" placeholder="Leave empty to keep current" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
+                </div>
+                <div>
+                    <label class="block text-sm font-medium mb-1 text-slate-400">Roles</label>
+                    <input x-model="editUserForm.roles" type="text" placeholder="Roles (comma separated)" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
+                </div>
+                <div class="flex gap-3 pt-2">
+                    <button @click="updateUser" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500">Save Changes</button>
+                    <button @click="showEditUserModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
     <!-- Add Role Modal -->
     <div x-show="showAddRoleModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
         <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
@@ -486,6 +561,8 @@
                 // Modals
                 showAddUserModal: false,
                 newUser: { username: '', password: '', roles: '' },
+                showEditUserModal: false,
+                editUserForm: { username: '', password: '', roles: '' },
                 showAddRoleModal: false,
                 newRoleName: '',
 
@@ -495,6 +572,12 @@
                 selectedRole: '',
                 newPerm: { pattern: '', actions: [], min: '', max: '' },
 
+                // MFA
+                mfaStatus: 'unknown',
+                isSettingUpMFA: false,
+                mfaSecret: '',
+                mfaCode: '',
+
                 init() {
                     if (this.token) {
                         this.isLoggedIn = true;
@@ -647,6 +730,29 @@
                     }
                 },
 
+                openEditUserModal(user) {
+                    this.editUserForm = {
+                        username: user.username,
+                        password: '', // Clear password
+                        roles: (user.roles || []).join(',')
+                    };
+                    this.showEditUserModal = true;
+                },
+
+                async updateUser() {
+                    try {
+                        const u = this.editUserForm.username;
+                        const p = this.editUserForm.password || '-';
+                        const r = this.editUserForm.roles; // allow empty string to clear roles
+                        
+                        await this.api('USER_UPDATE', [u, p, r]);
+                        this.showEditUserModal = false;
+                        this.fetchUsers();
+                    } catch (e) {
+                        alert(e.message);
+                    }
+                },
+
                 async createRole() {
                     try {
                         await this.api('ROLE_CREATE', [this.newRoleName]);
@@ -719,6 +825,64 @@
                         await this.api('USER_UNLOCK', [username]);
                         alert("Unlocked");
                     } catch (e) { alert(e.message); }
+                },
+
+                async checkMFA() {
+                    try {
+                        const res = await this.api('MFA-STATUS');
+                        // Expect "OK <status>" -> api() returns "<status>"
+                        this.mfaStatus = res.trim();
+                    } catch (e) {
+                        console.error("MFA Check failed", e);
+                    }
+                },
+
+                async startMFASetup() {
+                    try {
+                        // MFA-GENERATE returns "secret url"
+                        const res = await this.api('MFA-GENERATE');
+                        const parts = res.trim().split(' ');
+                        this.mfaSecret = parts[0];
+                        const url = parts[1];
+                        
+                        this.isSettingUpMFA = true;
+                        
+                        // Render QR
+                        // Wait for transition
+                        setTimeout(() => {
+                            new QRious({
+                                element: document.getElementById('qr'),
+                                value: url,
+                                size: 200
+                            });
+                        }, 100);
+                    } catch (e) {
+                        alert(e.message);
+                    }
+                },
+
+                async verifyAndEnableMFA() {
+                    if (!this.mfaCode) return alert("Enter code");
+                    try {
+                        await this.api('MFA-ENABLE', [this.mfaSecret, this.mfaCode]);
+                        alert("MFA Enabled Successfully!");
+                        this.isSettingUpMFA = false;
+                        this.mfaCode = '';
+                        this.mfaStatus = 'enabled';
+                    } catch (e) {
+                        alert(e.message);
+                    }
+                },
+
+                async disableMFA() {
+                    if (!confirm("Are you sure you want to disable 2FA?")) return;
+                    try {
+                        await this.api('MFA-DISABLE');
+                        alert("MFA Disabled");
+                        this.mfaStatus = 'disabled';
+                    } catch (e) {
+                        alert(e.message);
+                    }
                 }
             }
         }

+ 32 - 17
example/web_admin/main.go

@@ -124,32 +124,47 @@ func handleAPI(w http.ResponseWriter, r *http.Request) {
 
 		reader := bufio.NewReader(c)
 
-		// 4. Authenticate if token present (Except for LOGIN command itself)
-		if req.Command != "LOGIN" && token != "" {
-			fmt.Fprintf(c, "AUTH %s\n\n", token)
+		// 4. Send Commands (Pipelining)
+		// Instead of Write(AUTH) -> Read(OK) -> Write(CMD) -> Read(RES) (2 RTT)
+		// We do: Write(AUTH + CMD) -> Read(OK) -> Read(RES) (1 RTT)
+
+		// Buffer for sending
+		var sendBuf strings.Builder
+
+		// Add AUTH command if needed
+		shouldAuth := req.Command != "LOGIN" && token != ""
+		if shouldAuth {
+			fmt.Fprintf(&sendBuf, "AUTH %s\n\n", token)
+		}
+
+		// Add Actual Command
+		cmdStr := req.Command
+		if len(req.Args) > 0 {
+			cmdStr += " " + strings.Join(req.Args, " ")
+		}
+		// Send command followed by empty line to terminate headers
+		fmt.Fprintf(&sendBuf, "%s\n\n", cmdStr)
+
+		// Send everything in one packet (if possible)
+		if _, err := fmt.Fprint(c, sendBuf.String()); err != nil {
+			return err
+		}
+
+		// 5. Read Responses
+
+		// Consume AUTH response first
+		if shouldAuth {
 			authResp, err := reader.ReadString('\n')
 			if err != nil {
 				return err
 			}
 			if !strings.HasPrefix(authResp, "OK") {
-				// Special handling: if auth fails, we return a specific error that is NOT a network error
-				// but we still want to return it to the client.
-				// However, if we are reusing a connection, maybe the previous session is interfering?
-				// AUTH command should override previous session.
-				// If it fails here, it means the token is invalid.
+				// Special handling: if auth fails, session is likely expired
 				return fmt.Errorf("Session Expired")
 			}
 		}
 
-		// 5. Construct TCP Command
-		cmdStr := req.Command
-		if len(req.Args) > 0 {
-			cmdStr += " " + strings.Join(req.Args, " ")
-		}
-		// Send command followed by empty line to terminate headers (protocol requirement)
-		fmt.Fprintf(c, "%s\n\n", cmdStr)
-
-		// 6. Read TCP Response
+		// 6. Read Actual Command Response
 		respStr, err := reader.ReadString('\n')
 		if err != nil {
 			return err

+ 36 - 0
server.go

@@ -584,6 +584,42 @@ func (s *KVServer) ChangeUserPassword(username, newPassword string, token string
 	return s.AuthManager.ChangePassword(username, newPassword)
 }
 
+// SetUserMFA updates a user's MFA settings (Admin or Self)
+func (s *KVServer) SetUserMFA(username string, secret string, enabled bool, token string) error {
+	if s.AuthManager.IsEnabled() {
+		session, err := s.AuthManager.GetSession(token)
+		if err != nil {
+			return err
+		}
+
+		// Allow if Self
+		if session.Username == username {
+			// OK
+		} else {
+			// Or has write permission on target user
+			targetKey := AuthUserPrefix + username
+			if err := s.AuthManager.CheckPermission(token, targetKey, ActionWrite, ""); err != nil {
+				if !s.IsAdmin(token) {
+					return fmt.Errorf("permission denied: cannot modify user '%s'", username)
+				}
+			}
+		}
+	}
+
+	user, err := s.AuthManager.GetUser(username)
+	if err != nil {
+		return err
+	}
+
+	// Create a copy to modify
+	newUser := *user
+	newUser.MFASecret = secret
+	newUser.MFAEnabled = enabled
+	
+	// Use underlying UpdateUser (which syncs to Raft)
+	return s.AuthManager.UpdateUser(newUser)
+}
+
 // Role Management Helpers
 
 // CreateRole creates a new role (Delegated Admin)

+ 102 - 0
tcp_server.go

@@ -308,6 +308,65 @@ func (s *KVServer) executeCommand(session *TCPClientSession, line string) string
 				resp = "OK"
 			}
 		}
+
+	case "MFA-GENERATE":
+		if session.token == "" {
+			resp = "ERR not authenticated"
+		} else {
+			secret, err := GenerateMFASecret()
+			if err != nil {
+				resp = fmt.Sprintf("ERR %v", err)
+			} else {
+				url := GenerateOTPAuthURL(session.username, secret, "RaftKV")
+				resp = fmt.Sprintf("OK %s %s", secret, url)
+			}
+		}
+
+	case "MFA-ENABLE":
+		if len(parts) < 3 {
+			resp = "ERR usage: MFA-ENABLE <secret> <code>"
+		} else if session.token == "" {
+			resp = "ERR not authenticated"
+		} else {
+			secret := parts[1]
+			code := parts[2]
+			if !ValidateTOTP(secret, code) {
+				resp = "ERR invalid code"
+			} else {
+				if err := s.SetUserMFA(session.username, secret, true, session.token); err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					resp = "OK"
+				}
+			}
+		}
+
+	case "MFA-DISABLE":
+		if session.token == "" {
+			resp = "ERR not authenticated"
+		} else {
+			if err := s.SetUserMFA(session.username, "", false, session.token); err != nil {
+				resp = fmt.Sprintf("ERR %v", err)
+			} else {
+				resp = "OK"
+			}
+		}
+
+	case "MFA-STATUS":
+		if session.token == "" {
+			resp = "ERR not authenticated"
+		} else {
+			user, err := s.AuthManager.GetUser(session.username)
+			if err != nil {
+				resp = fmt.Sprintf("ERR %v", err)
+			} else {
+				status := "disabled"
+				if user.MFAEnabled {
+					status = "enabled"
+				}
+				resp = fmt.Sprintf("OK %s", status)
+			}
+		}
 	
 	case "WHOAMI":
 		if session.username == "" {
@@ -518,6 +577,49 @@ func (s *KVServer) executeCommand(session *TCPClientSession, line string) string
 			}
 		}
 
+	case "USER_UPDATE":
+		// Usage: USER_UPDATE <username> <new_pass|-> <new_roles|->
+		if len(parts) < 4 {
+			resp = "ERR usage: USER_UPDATE <username> <new_pass|-> <new_roles|->"
+		} else {
+			username := parts[1]
+			newPass := parts[2]
+			newRolesStr := parts[3]
+
+			// Get existing user to preserve other fields
+			userPtr, err := s.AuthManager.GetUser(username)
+			if err != nil {
+				resp = fmt.Sprintf("ERR %v", err)
+			} else {
+				// Create copy to modify
+				user := *userPtr
+				
+				// Update Password if requested
+				if newPass != "-" {
+					salt := fmt.Sprintf("%d", time.Now().UnixNano())
+					user.Salt = salt
+					user.PasswordHash = HashPassword(newPass, salt)
+				}
+				
+				// Update Roles if requested
+				if newRolesStr != "-" {
+					if newRolesStr == "" {
+						user.Roles = []string{}
+					} else {
+						user.Roles = strings.Split(newRolesStr, ",")
+					}
+				}
+				
+				// Use Server method which performs permission check
+				err := s.UpdateUser(user, session.token)
+				if err != nil {
+					resp = fmt.Sprintf("ERR %v", err)
+				} else {
+					resp = "OK"
+				}
+			}
+		}
+
 	case "ROLE_CREATE":
 		// Usage: ROLE_CREATE <name>
 		if len(parts) < 2 {