fwh_login_test.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. package fwh_pro
  2. import (
  3. "crypto/md5"
  4. "encoding/hex"
  5. "encoding/json"
  6. "fwh-toolbox-server/utils/httpClient"
  7. "strings"
  8. "testing"
  9. "time"
  10. )
  11. func loginTestResponse(data any) *httpClient.Response {
  12. body, _ := json.Marshal(map[string]any{"code": 200, "success": true, "data": data})
  13. return &httpClient.Response{StatusCode: 200, Body: body}
  14. }
  15. func withLoginTestConfig(t *testing.T, secret string) {
  16. t.Helper()
  17. oldUser, oldPassword, oldSecret, oldInfo := fwhUser, fwhPassword, fwhTotpSecret, loginInfo
  18. passwordHash := md5.Sum([]byte("plain-password"))
  19. fwhUser, fwhPassword, fwhTotpSecret = "user", hex.EncodeToString(passwordHash[:]), secret
  20. t.Cleanup(func() {
  21. fwhUser, fwhPassword, fwhTotpSecret, loginInfo = oldUser, oldPassword, oldSecret, oldInfo
  22. })
  23. }
  24. func TestLoginDirectWithoutTOTP(t *testing.T) {
  25. withLoginTestConfig(t, "")
  26. calls := 0
  27. post := func(path string, body any, clientID string) (*httpClient.Response, error) {
  28. calls++
  29. if path != "/api-user/user/login" || clientID != "device-1" {
  30. t.Fatalf("unexpected request: %s, %s", path, clientID)
  31. }
  32. payload, ok := body.(LoginPayload)
  33. if !ok || payload.Password != fwhPassword || payload.Timezone != "Asia/Shanghai" || payload.Lang != "ZH_CN" {
  34. t.Fatalf("unexpected login payload: %+v", payload)
  35. }
  36. return loginTestResponse(map[string]any{"userId": 7, "token": "direct-token"}), nil
  37. }
  38. if err := loginWithPost(post, time.Now, "device-1"); err != nil {
  39. t.Fatal(err)
  40. }
  41. if calls != 1 || loginInfo.Token != "direct-token" || loginInfo.UserId != 7 {
  42. t.Fatalf("direct login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
  43. }
  44. }
  45. func TestLoginTOTPFromMFAResponse(t *testing.T) {
  46. withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
  47. calls := 0
  48. post := func(path string, body any, clientID string) (*httpClient.Response, error) {
  49. calls++
  50. if clientID != "device-1" {
  51. t.Fatalf("client ID changed between login steps")
  52. }
  53. switch calls {
  54. case 1:
  55. if path != "/api-user/user/login" {
  56. t.Fatalf("unexpected first path: %s", path)
  57. }
  58. return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
  59. case 2:
  60. verify, ok := body.(MfaVerifyPayload)
  61. if path != "/api-user/mfa/login/verify" || !ok || verify.MfaToken != "temporary-token" || verify.Code != "287082" || verify.Method != "TOTP" || verify.DeviceFingerprint != clientID || verify.RememberDevice != "" {
  62. t.Fatalf("unexpected verify request: path=%s, body=%+v", path, body)
  63. }
  64. return loginTestResponse(map[string]any{"userId": 9, "token": "verified-token"}), nil
  65. default:
  66. t.Fatalf("too many login requests")
  67. return nil, nil
  68. }
  69. }
  70. if err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1"); err != nil {
  71. t.Fatal(err)
  72. }
  73. if calls != 2 || loginInfo.Token != "verified-token" || loginInfo.UserId != 9 {
  74. t.Fatalf("TOTP login result: calls=%d, token=%q, userId=%d", calls, loginInfo.Token, loginInfo.UserId)
  75. }
  76. }
  77. func TestLoginRequiresConfiguredTOTPWhenMFARequested(t *testing.T) {
  78. withLoginTestConfig(t, "")
  79. loginInfo.Token = "old-token"
  80. calls := 0
  81. post := func(path string, body any, clientID string) (*httpClient.Response, error) {
  82. calls++
  83. return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
  84. }
  85. err := loginWithPost(post, time.Now, "device-1")
  86. if err == nil || !strings.Contains(err.Error(), "FWH_TOTP_SECRET") || calls != 1 || loginInfo.Token != "" {
  87. t.Fatalf("missing TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
  88. }
  89. }
  90. func TestLoginWithConfiguredSecretAcceptsDirectToken(t *testing.T) {
  91. withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
  92. calls := 0
  93. post := func(path string, body any, clientID string) (*httpClient.Response, error) {
  94. calls++
  95. return loginTestResponse(map[string]any{"userId": 4, "token": "direct-token"}), nil
  96. }
  97. if err := loginWithPost(post, time.Now, "device-1"); err != nil || calls != 1 || loginInfo.Token != "direct-token" {
  98. t.Fatalf("direct response with secret: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
  99. }
  100. }
  101. func TestLoginRejectedTOTPDoesNotReuseOldToken(t *testing.T) {
  102. withLoginTestConfig(t, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")
  103. loginInfo.Token = "old-token"
  104. calls := 0
  105. post := func(path string, body any, clientID string) (*httpClient.Response, error) {
  106. calls++
  107. if calls == 1 {
  108. return loginTestResponse(map[string]any{"mfaRequired": true, "mfaToken": "temporary-token", "mfaMethod": "TOTP"}), nil
  109. }
  110. return &httpClient.Response{StatusCode: 200, Body: []byte(`{"code":401,"success":false}`)}, nil
  111. }
  112. err := loginWithPost(post, func() time.Time { return time.Unix(59, 0) }, "device-1")
  113. if err == nil || calls != 2 || loginInfo.Token != "" {
  114. t.Fatalf("rejected TOTP handling: err=%v, calls=%d, token=%q", err, calls, loginInfo.Token)
  115. }
  116. }