passkey_handler_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. package handler
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type passkeySwitchSettingRepo struct {
  16. value string
  17. values map[string]string
  18. err error
  19. }
  20. type passkeyCaptchaVerifierStub struct {
  21. calls int
  22. proof service.TencentCaptchaProof
  23. }
  24. func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
  25. s.calls++
  26. s.proof = proof
  27. return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
  28. }
  29. type passkeyBeginSessionStoreStub struct {
  30. service.PasskeySessionStore
  31. storeCalls int
  32. }
  33. func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) {
  34. s.storeCalls++
  35. return "passkey-session", nil
  36. }
  37. func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
  38. return nil, service.ErrSettingNotFound
  39. }
  40. func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) {
  41. return r.value, r.err
  42. }
  43. func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
  44. func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
  45. if r.err != nil {
  46. return nil, r.err
  47. }
  48. return r.values, nil
  49. }
  50. func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
  51. return nil
  52. }
  53. func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) {
  54. return map[string]string{}, nil
  55. }
  56. func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil }
  57. func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. recorder := httptest.NewRecorder()
  60. context, _ := gin.CreateTestContext(recorder)
  61. context.Request = httptest.NewRequest(
  62. http.MethodPost,
  63. "/api/v1/auth/passkey/login/finish",
  64. strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
  65. )
  66. context.Request.Header.Set("Content-Type", "application/json")
  67. _, ok := bindPasskeyFinishRequest(context)
  68. require.False(t, ok)
  69. require.Equal(t, http.StatusBadRequest, recorder.Code)
  70. }
  71. func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. repo := &passkeySwitchSettingRepo{value: "false"}
  74. settings := service.NewSettingService(repo, &config.Config{
  75. WebAuthn: config.WebAuthnConfig{Enabled: true},
  76. })
  77. handler := NewPasskeyHandler(nil, nil, settings)
  78. recorder := httptest.NewRecorder()
  79. ginContext, _ := gin.CreateTestContext(recorder)
  80. ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
  81. handler.BeginLogin(ginContext)
  82. require.Equal(t, http.StatusForbidden, recorder.Code)
  83. require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED")
  84. }
  85. func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
  86. gin.SetMode(gin.TestMode)
  87. settings := service.NewSettingService(
  88. &passkeySwitchSettingRepo{err: errors.New("database unavailable")},
  89. &config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}},
  90. )
  91. handler := NewPasskeyHandler(nil, nil, settings)
  92. recorder := httptest.NewRecorder()
  93. ginContext, _ := gin.CreateTestContext(recorder)
  94. ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
  95. handler.BeginLogin(ginContext)
  96. require.Equal(t, http.StatusInternalServerError, recorder.Code)
  97. require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
  98. }
  99. func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) {
  100. t.Helper()
  101. cfg := &config.Config{WebAuthn: config.WebAuthnConfig{
  102. Enabled: true,
  103. RPDisplayName: "Sub2API",
  104. RPID: "sub2api.example.com",
  105. RPOrigins: []string{"https://sub2api.example.com"},
  106. }}
  107. repo := &passkeySwitchSettingRepo{
  108. value: "true",
  109. values: map[string]string{
  110. service.SettingKeyTencentCaptchaEnabled: "true",
  111. service.SettingKeyTencentCaptchaAppID: "123456789",
  112. service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
  113. service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
  114. service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
  115. },
  116. }
  117. settings := service.NewSettingService(repo, cfg)
  118. verifier := &passkeyCaptchaVerifierStub{}
  119. authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
  120. authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
  121. sessions := &passkeyBeginSessionStoreStub{}
  122. passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil)
  123. require.NoError(t, err)
  124. return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions
  125. }
  126. func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
  127. recorder := httptest.NewRecorder()
  128. ginContext, _ := gin.CreateTestContext(recorder)
  129. ginContext.Request = httptest.NewRequest(
  130. http.MethodPost,
  131. "/api/v1/auth/passkey/login/begin",
  132. strings.NewReader(body),
  133. )
  134. ginContext.Request.Header.Set("Content-Type", "application/json")
  135. return ginContext, recorder
  136. }
  137. func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) {
  138. gin.SetMode(gin.TestMode)
  139. handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
  140. ginContext, recorder := newPasskeyBeginLoginContext(`{}`)
  141. handler.BeginLogin(ginContext)
  142. require.Equal(t, http.StatusBadRequest, recorder.Code)
  143. require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
  144. require.Zero(t, verifier.calls)
  145. require.Zero(t, sessions.storeCalls)
  146. }
  147. func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) {
  148. gin.SetMode(gin.TestMode)
  149. handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
  150. ginContext, recorder := newPasskeyBeginLoginContext(
  151. `{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`,
  152. )
  153. handler.BeginLogin(ginContext)
  154. require.Equal(t, http.StatusOK, recorder.Code)
  155. require.Equal(t, 1, verifier.calls)
  156. require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
  157. require.Equal(t, 1, sessions.storeCalls)
  158. }
  159. func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
  160. gin.SetMode(gin.TestMode)
  161. handler := NewPasskeyHandler(nil, nil, nil)
  162. recorder := httptest.NewRecorder()
  163. ginContext, _ := gin.CreateTestContext(recorder)
  164. ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil)
  165. handler.List(ginContext)
  166. require.Equal(t, http.StatusUnauthorized, recorder.Code)
  167. require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
  168. }