Jelajahi Sumber

feat:生成账号登录添加TOTP支持

Jerry Liang 1 bulan lalu
induk
melakukan
287dcac93d

+ 2 - 4
deploy/config.env.example

@@ -49,12 +49,10 @@ FWH_ENERGY="energy.franklinwh.com"
 FWH_OSS="oss.franklinwh.com"
 # TODO: [必填] FWH 生产账号
 FWH_USER="FMU006"
-# TODO: [必填] FWH 生产账号密码(后端会自动做 MD5,此处填明文)
+# TODO: [必填] FWH 生产账号密码(此处填明文,后端提交 MD5 摘要)
 FWH_PASSWORD="CHANGE_ME"
-# 启用 TOTP 时填写认证器提供的 Base32 密钥;留空则沿用密码登录。
+# 账号需要 TOTP 时填写认证器提供的 Base32 密钥;不需要时留空。
 FWH_TOTP_SECRET=""
-# 登录接口接收验证码的 JSON 字段名;启用 TOTP 时必填。
-FWH_TOTP_FIELD=""
 # 可选:设置后在独立端口启用 /fwh-totp 页面(Basic Auth 用户名固定为 fwh)。
 # 请使用独立强密码,勿与 FWH_TOTP_SECRET 或 FWH_PASSWORD 相同。
 FWH_TOTP_VIEW_PASSWORD=""

+ 9 - 0
service/README.md

@@ -97,6 +97,15 @@ Database / Cache (Redis) / 外部服务 (FWH-API)
 | MONGO_DSN | MONGO数据库连接配置,DSN格式,本地保存数据信息。 |
 | REDIS_ADDR | REDIS本地缓存服务器的域名或IP地址,默认"localhost" |
 
+## FWH 生产账号登录
+
+`FWH_PASSWORD` 在配置中填写明文,后端会计算 MD5 摘要提交。后端先调用
+`/api-user/user/login`:如果响应直接包含
+正式 `token`,即完成密码登录;如果响应声明 `mfaRequired`,则需要配置
+`FWH_TOTP_SECRET`,后端会用返回的 `mfaToken` 和当前六位验证码调用
+`/api-user/mfa/login/verify`,再保存验证响应中的正式 `token`。账号要求 MFA 但未配置
+密钥时,登录会明确失败。`FWH_TOTP_FIELD` 已不再使用。
+
 ## FWH TOTP 验证码页面
 
 在后端配置中设置 `FWH_TOTP_SECRET`、独立的 `FWH_TOTP_VIEW_PASSWORD`,并按需设置

+ 1 - 3
service/config/config.go

@@ -32,7 +32,6 @@ type FwhApiConfig struct {
 	FwhUser       string
 	FwhPassword   string
 	FwhTotpSecret string
-	FwhTotpField  string
 }
 
 type FwhMqttConfig struct {
@@ -108,7 +107,7 @@ type Config struct {
 // var FWH_ENERGY string = "energy.franklinwh.com"
 // var FWH_OSS string = "oss.franklinwh.com"
 // var FWH_USER string = ""
-// var FWH_PASSWORD string = "" // MD5 - PASSWORD
+// var FWH_PASSWORD string = "" // The production API receives its MD5 digest.
 
 // LoadConfig 读取配置文件和环境变量。
 // 可选参数 configPaths 指定候选配置文件路径(按顺序尝试,命中第一个存在的即停)。
@@ -203,7 +202,6 @@ func LoadConfig(configPaths ...string) (*Config, error) {
 			FwhUser:       v.GetString("FWH_USER"),
 			FwhPassword:   fwhApiPassword,
 			FwhTotpSecret: v.GetString("FWH_TOTP_SECRET"),
-			FwhTotpField:  v.GetString("FWH_TOTP_FIELD"),
 		},
 		FwhTestApi: FwhApiConfig{
 			FwhEnergy:   v.GetString("FWH_TEST_ENERGY"),

+ 28 - 0
service/config/config_test.go

@@ -0,0 +1,28 @@
+package config
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestFWHPasswordsAreMD5Encoded(t *testing.T) {
+	path := filepath.Join(t.TempDir(), "config.env")
+	if err := os.WriteFile(path, []byte("FWH_PASSWORD=plain-password\nFWH_TEST_PASSWORD=test-password\n"), 0600); err != nil {
+		t.Fatal(err)
+	}
+	cfg, err := LoadConfig(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	productionHash := md5.Sum([]byte("plain-password"))
+	if cfg.FwhApi.FwhPassword != hex.EncodeToString(productionHash[:]) {
+		t.Fatalf("production password was not MD5 encoded")
+	}
+	testHash := md5.Sum([]byte("test-password"))
+	if cfg.FwhTestApi.FwhPassword != hex.EncodeToString(testHash[:]) {
+		t.Fatalf("test environment password hashing changed")
+	}
+}

+ 15 - 2
service/repositories/api/fwh_pro/fwhHttpClient.go

@@ -21,7 +21,6 @@ var (
 	fwhUser       string
 	fwhPassword   string
 	fwhTotpSecret string
-	fwhTotpField  string
 	fwhHttpClient httpClient.HttpClient
 )
 
@@ -32,7 +31,6 @@ func FwhHttpInit(cfg *config.FwhApiConfig) {
 	fwhUser = cfg.FwhUser
 	fwhPassword = cfg.FwhPassword
 	fwhTotpSecret = cfg.FwhTotpSecret
-	fwhTotpField = cfg.FwhTotpField
 
 	fwhHttpClient = httpClient.HttpClient{}
 	fwhHttpClient.RegisterLogin(reLogin)
@@ -98,6 +96,21 @@ func FwhHttpPostWithoutToken(url string, body any) (*httpClient.Response, error)
 	return fwhHttpClient.HttpPost(url, body, &opt)
 }
 
+// postFwhLoginRequest uses the headers shared by both browser login steps.
+func postFwhLoginRequest(path string, body any, clientID string) (*httpClient.Response, error) {
+	t := timeout / time.Second
+	opt := httpClient.HttpClientOptions{
+		Timeout: &t,
+		Header: &http.Header{
+			"Accept":    {"application/json", "text/plain", "*/*"},
+			"Client-Id": {clientID},
+			"Lang":      {"ZH_CN"},
+			"Optsource": {"6"},
+		},
+	}
+	return fwhHttpClient.HttpPost(getFullEnergyUrl(path), body, &opt)
+}
+
 func FwhHttpPost(url string, body any) (*httpClient.Response, error) {
 	t := timeout / time.Second
 	opt := httpClient.HttpClientOptions{

+ 80 - 31
service/repositories/api/fwh_pro/fwh_login.go

@@ -3,7 +3,11 @@ package fwh_pro
 import (
 	"encoding/json"
 	"fmt"
+	"fwh-toolbox-server/utils/httpClient"
+	"strings"
 	"time"
+
+	"github.com/google/uuid"
 )
 
 var loginInfo LoginData = LoginData{
@@ -15,12 +19,26 @@ type LoginPayload struct {
 	LoginName string `json:"loginName"`
 	Password  string `json:"password"`
 	Type      int    `json:"type"`
+	Timezone  string `json:"timezone"`
 	Lang      string `json:"lang"`
 }
 
 type LoginData struct {
-	UserId uint   `json:"userId"`
-	Token  string `json:"token"`
+	UserId              uint     `json:"userId"`
+	Token               string   `json:"token"`
+	MfaRequired         bool     `json:"mfaRequired"`
+	MfaToken            string   `json:"mfaToken"`
+	MfaMethod           string   `json:"mfaMethod"`
+	AvailableMfaMethods []string `json:"availableMfaMethods"`
+}
+
+type MfaVerifyPayload struct {
+	MfaToken          string `json:"mfaToken"`
+	Code              string `json:"code"`
+	Method            string `json:"method"`
+	DeviceFingerprint string `json:"deviceFingerprint"`
+	RememberDevice    string `json:"rememberDevice"`
+	Lang              string `json:"lang"`
 }
 
 type LoginResponse struct {
@@ -44,52 +62,71 @@ type JwtPayload struct {
 }
 
 func Login() error {
+	return loginWithPost(postFwhLoginRequest, time.Now, uuid.NewString())
+}
+
+type loginPostFunc func(path string, body any, clientID string) (*httpClient.Response, error)
+
+func loginWithPost(post loginPostFunc, now func() time.Time, clientID string) error {
 	// A failed re-login must not leave a previously issued token available for reuse.
 	ClearToken()
-	// 要发送的 JSON
 	payload := LoginPayload{
 		LoginName: fwhUser,
 		Password:  fwhPassword,
 		Type:      1,
+		Timezone:  "Asia/Shanghai",
 		Lang:      "ZH_CN",
 	}
 
-	var body any = payload
-	if fwhTotpSecret != "" {
-		if fwhTotpField == "" {
-			return fmt.Errorf("FWH_TOTP_FIELD is required when FWH_TOTP_SECRET is set")
-		}
-		if fwhTotpField == "loginName" || fwhTotpField == "password" || fwhTotpField == "type" || fwhTotpField == "lang" {
-			return fmt.Errorf("FWH_TOTP_FIELD conflicts with an existing login field")
-		}
-		code, err := generateTOTP(fwhTotpSecret, time.Now())
-		if err != nil {
-			return err
-		}
-		body = map[string]any{
-			"loginName":  payload.LoginName,
-			"password":   payload.Password,
-			"type":       payload.Type,
-			"lang":       payload.Lang,
-			fwhTotpField: code,
-		}
-	}
-
-	response, err := FwhHttpPostWithoutToken(getFullOssUrl("/api-user/user/login"), body)
+	response, err := post("/api-user/user/login", payload, clientID)
 	if err != nil {
 		return err
 	}
-	// logger.Std.Printf("Status: %s\nBody: %s", resp.Status, body)
 
 	var loginRes LoginResponse
-	// 反序列化(unmarshal)
-	jsonErr := json.Unmarshal(response.Body, &loginRes)
-	if jsonErr != nil {
-		return jsonErr
+	if err := json.Unmarshal(response.Body, &loginRes); err != nil {
+		return err
 	}
-	if response.StatusCode != 200 || !loginRes.Success || loginRes.Data.Token == "" {
+	if response.StatusCode != 200 || !loginRes.Success || loginRes.Code != 200 {
 		return fmt.Errorf("FWH login rejected (HTTP %d, code %d)", response.StatusCode, loginRes.Code)
 	}
+	if loginRes.Data.MfaRequired {
+		if fwhTotpSecret == "" {
+			return fmt.Errorf("FWH login requires TOTP; set FWH_TOTP_SECRET")
+		}
+		if loginRes.Data.MfaToken == "" {
+			return fmt.Errorf("FWH login requires TOTP but returned no mfaToken")
+		}
+		if !supportsTOTP(loginRes.Data) {
+			return fmt.Errorf("FWH login requires an unsupported MFA method")
+		}
+		code, err := generateTOTP(fwhTotpSecret, now())
+		if err != nil {
+			return err
+		}
+		verify := MfaVerifyPayload{
+			MfaToken:          loginRes.Data.MfaToken,
+			Code:              code,
+			Method:            "TOTP",
+			DeviceFingerprint: clientID,
+			RememberDevice:    "",
+			Lang:              "ZH_CN",
+		}
+		response, err = post("/api-user/mfa/login/verify", verify, clientID)
+		if err != nil {
+			return err
+		}
+		loginRes = LoginResponse{}
+		if err := json.Unmarshal(response.Body, &loginRes); err != nil {
+			return err
+		}
+		if response.StatusCode != 200 || !loginRes.Success || loginRes.Code != 200 {
+			return fmt.Errorf("FWH TOTP verification rejected (HTTP %d, code %d)", response.StatusCode, loginRes.Code)
+		}
+	}
+	if loginRes.Data.Token == "" {
+		return fmt.Errorf("FWH login returned no token")
+	}
 
 	loginInfo.UserId = loginRes.Data.UserId
 	loginInfo.Token = loginRes.Data.Token
@@ -97,6 +134,18 @@ func Login() error {
 	return nil
 }
 
+func supportsTOTP(data LoginData) bool {
+	if strings.EqualFold(data.MfaMethod, "TOTP") {
+		return true
+	}
+	for _, method := range data.AvailableMfaMethods {
+		if strings.EqualFold(method, "TOTP") {
+			return true
+		}
+	}
+	return false
+}
+
 // 弃用:token payload被加密
 // func isTokenExpired(token string) (bool, error) {
 // 	parts := strings.Split(token, ".")

+ 124 - 0
service/repositories/api/fwh_pro/fwh_login_test.go

@@ -0,0 +1,124 @@
+package fwh_pro
+
+import (
+	"crypto/md5"
+	"encoding/hex"
+	"encoding/json"
+	"fwh-toolbox-server/utils/httpClient"
+	"strings"
+	"testing"
+	"time"
+)
+
+func loginTestResponse(data any) *httpClient.Response {
+	body, _ := json.Marshal(map[string]any{"code": 200, "success": true, "data": data})
+	return &httpClient.Response{StatusCode: 200, Body: body}
+}
+
+func withLoginTestConfig(t *testing.T, secret string) {
+	t.Helper()
+	oldUser, oldPassword, oldSecret, oldInfo := fwhUser, fwhPassword, fwhTotpSecret, loginInfo
+	passwordHash := md5.Sum([]byte("plain-password"))
+	fwhUser, fwhPassword, fwhTotpSecret = "user", hex.EncodeToString(passwordHash[:]), secret
+	t.Cleanup(func() {
+		fwhUser, fwhPassword, fwhTotpSecret, loginInfo = oldUser, oldPassword, oldSecret, oldInfo
+	})
+}
+
+func TestLoginDirectWithoutTOTP(t *testing.T) {
+	withLoginTestConfig(t, "")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if path != "/api-user/user/login" || clientID != "device-1" {
+			t.Fatalf("unexpected request: %s, %s", path, clientID)
+		}
+		payload, ok := body.(LoginPayload)
+		if !ok || payload.Password != fwhPassword || payload.Timezone != "Asia/Shanghai" || payload.Lang != "ZH_CN" {
+			t.Fatalf("unexpected login payload: %+v", payload)
+		}
+		return loginTestResponse(map[string]any{"userId": 7, "token": "direct-token"}), nil
+	}
+	if err := loginWithPost(post, time.Now, "device-1"); err != nil {
+		t.Fatal(err)
+	}
+	if calls != 1 || loginInfo.Token != "direct-token" || loginInfo.UserId != 7 {
+		t.Fatalf("direct login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
+	}
+}
+
+func TestLoginTOTPFromMFAResponse(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if clientID != "device-1" {
+			t.Fatalf("client ID changed between login steps")
+		}
+		switch calls {
+		case 1:
+			if path != "/api-user/user/login" {
+				t.Fatalf("unexpected first path: %s", path)
+			}
+			return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+		case 2:
+			verify, ok := body.(MfaVerifyPayload)
+			if path != "/api-user/mfa/login/verify" || !ok || verify.MfaToken != "temporary-token" || verify.Code != "287082" || verify.Method != "TOTP" || verify.DeviceFingerprint != clientID || verify.RememberDevice != "" {
+				t.Fatalf("unexpected verify request: path=%s, body=%+v", path, body)
+			}
+			return loginTestResponse(map[string]any{"userId": 9, "token": "verified-token"}), nil
+		default:
+			t.Fatalf("too many login requests")
+			return nil, nil
+		}
+	}
+	if err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1"); err != nil {
+		t.Fatal(err)
+	}
+	if calls != 2 || loginInfo.Token != "verified-token" || loginInfo.UserId != 9 {
+		t.Fatalf("TOTP login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
+	}
+}
+
+func TestLoginRequiresConfiguredTOTPWhenMFARequested(t *testing.T) {
+	withLoginTestConfig(t, "")
+	loginInfo.Token = "old-token"
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+	}
+	err := loginWithPost(post, time.Now, "device-1")
+	if err == nil || !strings.Contains(err.Error(), "FWH_TOTP_SECRET") || calls != 1 || loginInfo.Token != "" {
+		t.Fatalf("missing TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}
+
+func TestLoginWithConfiguredSecretAcceptsDirectToken(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		return loginTestResponse(map[string]any{"userId": 4, "token": "direct-token"}), nil
+	}
+	if err := loginWithPost(post, time.Now, "device-1"); err != nil || calls != 1 || loginInfo.Token != "direct-token" {
+		t.Fatalf("direct response with secret: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}
+
+func TestLoginRejectedTOTPDoesNotReuseOldToken(t *testing.T) {
+	withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
+	loginInfo.Token = "old-token"
+	calls := 0
+	post := func(path string, body any, clientID string) (*httpClient.Response, error) {
+		calls++
+		if calls == 1 {
+			return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
+		}
+		return &httpClient.Response{StatusCode: 200, Body: []byte(`{"code":401,"success":false}`)}, nil
+	}
+	err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1")
+	if err == nil || calls != 2 || loginInfo.Token != "" {
+		t.Fatalf("rejected TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
+	}
+}