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
|