1
0

api_auth_test.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. package controller
  2. import (
  3. "crypto/tls"
  4. "crypto/x509"
  5. "net/http"
  6. "net/http/cookiejar"
  7. "net/http/httptest"
  8. "path/filepath"
  9. "testing"
  10. "github.com/gin-contrib/sessions"
  11. "github.com/gin-contrib/sessions/cookie"
  12. "github.com/gin-gonic/gin"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  15. "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
  16. "github.com/mhsanaei/3x-ui/v3/internal/web/session"
  17. )
  18. // newAPIAuthTestEngine builds a gin engine that mirrors the production auth
  19. // wiring: the sessions middleware, then checkAPIAuth guarding a sentinel
  20. // handler that reports whether c.Next() was reached and whether api_authed was
  21. // set. The APIController is the zero value, exactly as NewAPIController leaves
  22. // its service fields (they query the global DB), so this exercises the real
  23. // auth path. A fresh temp DB is initialised per test.
  24. func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
  25. t.Helper()
  26. gin.SetMode(gin.TestMode)
  27. dbDir := t.TempDir()
  28. t.Setenv("XUI_DB_FOLDER", dbDir)
  29. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  30. t.Fatalf("InitDB: %v", err)
  31. }
  32. t.Cleanup(func() { _ = database.CloseDB() })
  33. engine := gin.New()
  34. store := cookie.NewStore([]byte("api-auth-test-secret"))
  35. engine.Use(sessions.Sessions("3x-ui", store))
  36. a := &APIController{}
  37. // Logs in as the first user so the session path can be exercised over a
  38. // cookie round-trip without reaching into checkAPIAuth's internals.
  39. engine.GET("/test-login", func(c *gin.Context) {
  40. u, err := a.userService.GetFirstUser()
  41. if err != nil {
  42. c.Status(http.StatusInternalServerError)
  43. return
  44. }
  45. if err := session.SetLoginUser(c, u); err != nil {
  46. c.Status(http.StatusInternalServerError)
  47. return
  48. }
  49. c.Status(http.StatusOK)
  50. })
  51. api := engine.Group("/panel/api")
  52. api.Use(a.checkAPIAuth)
  53. api.GET("/ping", func(c *gin.Context) {
  54. c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
  55. })
  56. return engine, a
  57. }
  58. // TestCheckAPIAuth_BearerSuccess characterizes the bearer-token path: a valid
  59. // token reaches the handler and sets api_authed (the contract the later
  60. // client-cert branch must match).
  61. func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
  62. engine, _ := newAPIAuthTestEngine(t)
  63. const plaintext = "characterization-token-value"
  64. if err := database.GetDB().Create(&model.ApiToken{
  65. Name: "t1",
  66. Token: crypto.HashTokenSHA256(plaintext),
  67. Enabled: true,
  68. }).Error; err != nil {
  69. t.Fatalf("seed token: %v", err)
  70. }
  71. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  72. req.Header.Set("Authorization", "Bearer "+plaintext)
  73. w := httptest.NewRecorder()
  74. engine.ServeHTTP(w, req)
  75. if w.Code != http.StatusOK {
  76. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  77. }
  78. if got := w.Body.String(); got != `{"api_authed":true}` {
  79. t.Fatalf("body = %s, want api_authed true", got)
  80. }
  81. }
  82. // TestCheckAPIAuth_AcceptsVerifiedClientCert asserts that a completed mTLS
  83. // handshake (a non-empty verified client chain) authenticates the request even
  84. // with no bearer token and no session — the equivalent of a valid token — and
  85. // sets api_authed so the CSRF middleware lets mutations through.
  86. func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
  87. engine, _ := newAPIAuthTestEngine(t)
  88. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  89. req.TLS = &tls.ConnectionState{
  90. VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
  91. }
  92. w := httptest.NewRecorder()
  93. engine.ServeHTTP(w, req)
  94. if w.Code != http.StatusOK {
  95. t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
  96. }
  97. if got := w.Body.String(); got != `{"api_authed":true}` {
  98. t.Fatalf("body = %s, want api_authed true", got)
  99. }
  100. }
  101. // TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no
  102. // verified client chain is NOT treated as authenticated (it falls through to the
  103. // bearer/session paths) — so the cert branch can't accidentally authorize plain
  104. // browser HTTPS.
  105. func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) {
  106. engine, _ := newAPIAuthTestEngine(t)
  107. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  108. req.TLS = &tls.ConnectionState{} // handshake done, but no client cert verified
  109. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  110. w := httptest.NewRecorder()
  111. engine.ServeHTTP(w, req)
  112. if w.Code != http.StatusUnauthorized {
  113. t.Fatalf("status = %d, want 401 (unauthenticated, no verified chain)", w.Code)
  114. }
  115. }
  116. // TestCheckAPIAuth_RejectsUnauthenticated characterizes the reject paths: no
  117. // bearer token and no session yields 401 for XHR callers and 404 otherwise.
  118. func TestCheckAPIAuth_RejectsUnauthenticated(t *testing.T) {
  119. engine, _ := newAPIAuthTestEngine(t)
  120. cases := []struct {
  121. name string
  122. xhr bool
  123. want int
  124. }{
  125. {"xhr gets 401", true, http.StatusUnauthorized},
  126. {"non-xhr gets 404", false, http.StatusNotFound},
  127. }
  128. for _, c := range cases {
  129. t.Run(c.name, func(t *testing.T) {
  130. req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
  131. if c.xhr {
  132. req.Header.Set("X-Requested-With", "XMLHttpRequest")
  133. }
  134. w := httptest.NewRecorder()
  135. engine.ServeHTTP(w, req)
  136. if w.Code != c.want {
  137. t.Fatalf("status = %d, want %d", w.Code, c.want)
  138. }
  139. })
  140. }
  141. }
  142. // TestCheckAPIAuth_SessionLoginPasses characterizes the session path: a
  143. // logged-in browser session (no bearer token) reaches the handler.
  144. func TestCheckAPIAuth_SessionLoginPasses(t *testing.T) {
  145. engine, _ := newAPIAuthTestEngine(t)
  146. db := database.GetDB()
  147. var n int64
  148. if err := db.Model(&model.User{}).Count(&n).Error; err != nil {
  149. t.Fatalf("count users: %v", err)
  150. }
  151. if n == 0 {
  152. if err := db.Create(&model.User{Username: "sess", Password: "x"}).Error; err != nil {
  153. t.Fatalf("seed user: %v", err)
  154. }
  155. }
  156. ts := httptest.NewServer(engine)
  157. defer ts.Close()
  158. jar, err := cookiejar.New(nil)
  159. if err != nil {
  160. t.Fatalf("cookiejar: %v", err)
  161. }
  162. client := &http.Client{Jar: jar}
  163. loginResp, err := client.Get(ts.URL + "/test-login")
  164. if err != nil {
  165. t.Fatalf("login: %v", err)
  166. }
  167. loginResp.Body.Close()
  168. if loginResp.StatusCode != http.StatusOK {
  169. t.Fatalf("login status = %d, want 200", loginResp.StatusCode)
  170. }
  171. pingResp, err := client.Get(ts.URL + "/panel/api/ping")
  172. if err != nil {
  173. t.Fatalf("ping: %v", err)
  174. }
  175. pingResp.Body.Close()
  176. if pingResp.StatusCode != http.StatusOK {
  177. t.Fatalf("session ping status = %d, want 200", pingResp.StatusCode)
  178. }
  179. }