Files
backend/internal/middleware/auth_pipeline_test.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.

- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
  refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
  against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
  that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
  and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
  is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
  now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
  with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
  from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00

225 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"with_you/internal/model"
"with_you/internal/pkg/jwt"
"with_you/internal/service"
)
// fakeIDP 实现 IdentityProvider 接口,用于测试。
type fakeIDP struct {
user *model.User
userErr error
roles []string
roleErr error
session *model.Session
sessErr error
}
func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) {
if f.userErr != nil {
return nil, f.userErr
}
return f.user, nil
}
func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) {
if f.roleErr != nil {
return nil, f.roleErr
}
if f.roles == nil {
return []string{}, nil
}
return f.roles, nil
}
func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) {
if f.sessErr != nil {
return nil, f.sessErr
}
return f.session, nil
}
// newTestJWT 构造测试用 JWT 服务。
func newTestJWT() service.JWTService {
return service.NewJWTService("test-secret", 3600, 86400)
}
// runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。
//
// Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。
func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) {
t.Helper()
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
r.ServeHTTP(w, req)
return w.Code, &testPrincipal{called: nextCalled}
}
type testPrincipal struct {
called bool
}
// TestRequireAuth_MissingHeader 缺失 Authorization → 401。
func TestRequireAuth_MissingHeader(t *testing.T) {
jwtSvc := newTestJWT()
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}}
code, _ := runRequireAuth(t, jwtSvc, idp, "")
if code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。
func TestRequireAuth_RefreshTokenRejected(t *testing.T) {
jwtSvc := newTestJWT()
// 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1")
idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}}
code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok)
if code != http.StatusUnauthorized {
t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_BannedUser banned 用户 → 403。
func TestRequireAuth_BannedUser(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusBanned},
roles: []string{},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusForbidden {
t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden)
}
}
// TestRequireAuth_RevokedSession 会话已撤销 → 401。
func TestRequireAuth_RevokedSession(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
revokedAt := time.Now().Add(-time.Minute)
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt},
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusUnauthorized {
t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_UserNotFound 用户不存在 → 401。
func TestRequireAuth_UserNotFound(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
userErr: gorm.ErrRecordNotFound,
}
code, _ := runRequireAuth(t, jwtSvc, idp, accessTok)
if code != http.StatusUnauthorized {
t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized)
}
}
// TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。
func TestRequireAuth_ValidAccess(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{model.RoleUser},
session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)},
}
// 这里需要校验 next 被调用,所以用更完整的测试。
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+accessTok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK)
}
if !nextCalled {
t.Error("next handler should be called")
}
}
// TestRequireAuth_LegacyToken 旧 access token无 sid宽容通过。
func TestRequireAuth_LegacyToken(t *testing.T) {
jwtSvc := newTestJWT()
jwtLib := jwt.New("test-secret", time.Hour, time.Hour)
// 签发不带 sid 的 access token模拟旧客户端
accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "")
idp := &fakeIDP{
user: &model.User{ID: "u1", Status: model.UserStatusActive},
roles: []string{},
// 旧 token 无 sidGetSessionByID 不应被调用。
}
gin.SetMode(gin.TestMode)
nextCalled := false
r := gin.New()
r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) {
nextCalled = true
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer "+accessTok)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("legacy access token should be 200 (compat window), status = %d", w.Code)
}
if !nextCalled {
t.Error("next handler should be called for legacy token")
}
}
// fakeIDP 的 session 路径校验:确保不传 session 时也不影响。
var _ = errors.New