middleware_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. package auth
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "testing"
  6. "time"
  7. "github.com/gin-gonic/gin"
  8. )
  9. func init() { gin.SetMode(gin.TestMode) }
  10. func newTestRouter(store Store) *gin.Engine {
  11. r := gin.New()
  12. r.Use(Authenticate(store))
  13. r.GET("/whoami", func(c *gin.Context) {
  14. id := CurrentIdentity(c)
  15. c.JSON(http.StatusOK, gin.H{
  16. "kind": id.String(),
  17. "user_id": id.UserID,
  18. "scope": id.Scope,
  19. "totp_verified": id.TotpVerified,
  20. })
  21. })
  22. return r
  23. }
  24. func do(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
  25. w := httptest.NewRecorder()
  26. r.ServeHTTP(w, req)
  27. return w
  28. }
  29. func TestAuthenticateAnonymous(t *testing.T) {
  30. store := newFakeStore()
  31. r := newTestRouter(store)
  32. w := do(r, httptest.NewRequest(http.MethodGet, "/whoami", nil))
  33. if w.Code != http.StatusOK {
  34. t.Fatalf("状态码 = %d", w.Code)
  35. }
  36. if body := w.Body.String(); body != `{"kind":"anonymous","scope":"","totp_verified":false,"user_id":""}` {
  37. t.Errorf("匿名身份响应异常: %s", body)
  38. }
  39. }
  40. func TestAuthenticateSession(t *testing.T) {
  41. store := newFakeStore()
  42. plain, _ := newSessionToken()
  43. store.sessions[HashToken(plain)] = Session{
  44. ID: "sess-1", UserID: "user-1", ExpiresAt: time.Now().Add(time.Hour),
  45. }
  46. r := newTestRouter(store)
  47. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  48. req.AddCookie(&http.Cookie{Name: CookieSession, Value: plain})
  49. w := do(r, req)
  50. if w.Code != http.StatusOK {
  51. t.Fatalf("状态码 = %d", w.Code)
  52. }
  53. if len(store.touchedSessions) != 1 || store.touchedSessions[0] != "sess-1" {
  54. t.Error("有效会话应刷新 last_used_at")
  55. }
  56. }
  57. func TestAuthenticateInvalidSessionIsAnonymous(t *testing.T) {
  58. store := newFakeStore()
  59. r := newTestRouter(store)
  60. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  61. req.AddCookie(&http.Cookie{Name: CookieSession, Value: "garbage"})
  62. w := do(r, req)
  63. if w.Code != http.StatusOK {
  64. t.Fatalf("无效会话应视为匿名,状态码 = %d", w.Code)
  65. }
  66. if got := w.Body.String(); got != `{"kind":"anonymous","scope":"","totp_verified":false,"user_id":""}` {
  67. t.Errorf("无效会话应降级为匿名: %s", got)
  68. }
  69. }
  70. func TestAuthenticateToken(t *testing.T) {
  71. store := newFakeStore()
  72. plain, _, _ := newAPIToken(ScopeRead)
  73. store.tokens[HashToken(plain)] = APIToken{
  74. ID: "tok-1", UserID: "user-1", Scope: ScopeRead,
  75. }
  76. r := newTestRouter(store)
  77. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  78. req.Header.Set("Authorization", "Bearer "+plain)
  79. w := do(r, req)
  80. if w.Code != http.StatusOK {
  81. t.Fatalf("状态码 = %d", w.Code)
  82. }
  83. if len(store.touchedTokens) != 1 {
  84. t.Error("有效 Token 应刷新 last_used_at")
  85. }
  86. }
  87. func TestAuthenticateRevokedOrExpiredToken(t *testing.T) {
  88. past := time.Now().Add(-time.Hour)
  89. future := time.Now().Add(time.Hour)
  90. cases := []struct {
  91. name string
  92. token APIToken
  93. }{
  94. {name: "已撤销", token: APIToken{RevokedAt: &past}},
  95. {name: "已过期", token: APIToken{ExpiresAt: &past}},
  96. {name: "撤销时间为零值以外", token: APIToken{ExpiresAt: &future, RevokedAt: &past}},
  97. }
  98. for _, tc := range cases {
  99. t.Run(tc.name, func(t *testing.T) {
  100. store := newFakeStore()
  101. plain, _, _ := newAPIToken(ScopeRead)
  102. tc.token.ID, tc.token.UserID, tc.token.Scope = "tok-1", "user-1", ScopeRead
  103. store.tokens[HashToken(plain)] = tc.token
  104. r := newTestRouter(store)
  105. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  106. req.Header.Set("Authorization", "Bearer "+plain)
  107. w := do(r, req)
  108. if w.Code != http.StatusUnauthorized {
  109. t.Errorf("状态码 = %d,期望 401", w.Code)
  110. }
  111. })
  112. }
  113. }
  114. func TestAuthenticateInvalidTokenDoesNotFallbackToCookie(t *testing.T) {
  115. store := newFakeStore()
  116. sessToken, _ := newSessionToken()
  117. store.sessions[HashToken(sessToken)] = Session{ID: "sess-1", UserID: "user-1"}
  118. r := newTestRouter(store)
  119. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  120. req.Header.Set("Authorization", "Bearer bogus")
  121. req.AddCookie(&http.Cookie{Name: CookieSession, Value: sessToken})
  122. w := do(r, req)
  123. // auth.md 6.1:Token 匹配失败返回 401,不退回 cookie
  124. if w.Code != http.StatusUnauthorized {
  125. t.Errorf("非法 Bearer 应立即 401,实际 %d", w.Code)
  126. }
  127. }
  128. func TestBearerOverridesCookie(t *testing.T) {
  129. store := newFakeStore()
  130. sessToken, _ := newSessionToken()
  131. store.sessions[HashToken(sessToken)] = Session{ID: "sess-1", UserID: "session-user"}
  132. token, _, _ := newAPIToken(ScopeReadWrite)
  133. store.tokens[HashToken(token)] = APIToken{ID: "tok-1", UserID: "token-user", Scope: ScopeReadWrite}
  134. r := newTestRouter(store)
  135. req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
  136. req.Header.Set("Authorization", "Bearer "+token)
  137. req.AddCookie(&http.Cookie{Name: CookieSession, Value: sessToken})
  138. w := do(r, req)
  139. if got := w.Body.String(); !contains(got, `"user_id":"token-user"`) || !contains(got, `"kind":"token"`) {
  140. t.Errorf("Bearer 应优先于 cookie: %s", got)
  141. }
  142. }
  143. func contains(s, sub string) bool {
  144. return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
  145. }
  146. func indexOf(s, sub string) int {
  147. for i := 0; i+len(sub) <= len(s); i++ {
  148. if s[i:i+len(sub)] == sub {
  149. return i
  150. }
  151. }
  152. return -1
  153. }
  154. func TestRequireHelpers(t *testing.T) {
  155. store := newFakeStore()
  156. // 通过过 TOTP 的会话 token 带 :totp 标记,查库时先剥离再哈希(session_token.go)
  157. verified, _ := newSessionTokenFor(true)
  158. store.sessions[HashToken(HashTokenForTest(verified))] = Session{ID: "s", UserID: "u", ExpiresAt: time.Now().Add(time.Hour)}
  159. notVerified, _ := newSessionToken()
  160. store.sessions[HashToken(notVerified)] = Session{ID: "s2", UserID: "u", TotpVerified: false, ExpiresAt: time.Now().Add(time.Hour)}
  161. readToken, _, _ := newAPIToken(ScopeRead)
  162. store.tokens[HashToken(readToken)] = APIToken{ID: "t1", UserID: "u", Scope: ScopeRead}
  163. newRouter := func(mw gin.HandlerFunc) *gin.Engine {
  164. r := gin.New()
  165. r.Use(Authenticate(store), mw)
  166. r.GET("/p", func(c *gin.Context) { c.Status(http.StatusOK) })
  167. return r
  168. }
  169. withCookie := func(name string) *http.Request {
  170. req := httptest.NewRequest(http.MethodGet, "/p", nil)
  171. req.AddCookie(&http.Cookie{Name: CookieSession, Value: name})
  172. return req
  173. }
  174. withToken := func(tok string) *http.Request {
  175. req := httptest.NewRequest(http.MethodGet, "/p", nil)
  176. req.Header.Set("Authorization", "Bearer "+tok)
  177. return req
  178. }
  179. t.Run("RequireLogin", func(t *testing.T) {
  180. r := newRouter(RequireLogin())
  181. if c := do(r, httptest.NewRequest(http.MethodGet, "/p", nil)).Code; c != http.StatusUnauthorized {
  182. t.Errorf("匿名应 401,实际 %d", c)
  183. }
  184. if c := do(r, withCookie(verified)).Code; c != http.StatusOK {
  185. t.Errorf("会话应放行,实际 %d", c)
  186. }
  187. if c := do(r, withToken(readToken)).Code; c != http.StatusOK {
  188. t.Errorf("Token 应放行,实际 %d", c)
  189. }
  190. })
  191. t.Run("RequireWrite", func(t *testing.T) {
  192. r := newRouter(RequireWrite())
  193. if c := do(r, httptest.NewRequest(http.MethodGet, "/p", nil)).Code; c != http.StatusUnauthorized {
  194. t.Errorf("匿名应 401,实际 %d", c)
  195. }
  196. if c := do(r, withToken(readToken)).Code; c != http.StatusForbidden {
  197. t.Errorf("read Token 写操作应 403,实际 %d", c)
  198. }
  199. if c := do(r, withCookie(notVerified)).Code; c != http.StatusOK {
  200. t.Errorf("会话写操作应放行,实际 %d", c)
  201. }
  202. })
  203. t.Run("RequireTotpVerified", func(t *testing.T) {
  204. r := newRouter(RequireTotpVerified())
  205. if c := do(r, withCookie(notVerified)).Code; c != http.StatusForbidden {
  206. t.Errorf("未通过 TOTP 的会话应 403,实际 %d", c)
  207. }
  208. if c := do(r, withToken(readToken)).Code; c != http.StatusForbidden {
  209. t.Errorf("Token 应 403,实际 %d", c)
  210. }
  211. if c := do(r, withCookie(verified)).Code; c != http.StatusOK {
  212. t.Errorf("通过 TOTP 的会话应放行,实际 %d", c)
  213. }
  214. })
  215. }
  216. func TestVisibilityFor(t *testing.T) {
  217. anon := visibilityFor(Identity{Kind: Anonymous})
  218. if anon.Condition != VisibilityPublic || len(anon.Args) != 0 {
  219. t.Errorf("匿名可见性条件异常: %+v", anon)
  220. }
  221. user := visibilityFor(Identity{Kind: SessionUser, UserID: "u1"})
  222. if user.Condition != VisibilityOwnedOrPublic {
  223. t.Errorf("登录用户可见性条件异常: %+v", user)
  224. }
  225. if len(user.Args) != 1 || user.Args[0] != "u1" {
  226. t.Errorf("登录用户可见性参数异常: %+v", user.Args)
  227. }
  228. // Token 与会话的可见性规则相同(auth.md 6.3)
  229. tok := visibilityFor(Identity{Kind: TokenUser, UserID: "u1", Scope: ScopeRead})
  230. if tok.Condition != user.Condition {
  231. t.Error("Token 与 Session 的可见性条件应一致")
  232. }
  233. }
苏ICP备2026082406号
苏公网安备 待审核