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
225 lines
7.0 KiB
Go
225 lines
7.0 KiB
Go
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 无 sid,GetSessionByID 不应被调用。
|
||
}
|
||
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 |