Files
backend/internal/pkg/jwt/jwt_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

120 lines
4.1 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 jwt
import (
"errors"
"testing"
"time"
)
// TestParseAccessToken_AcceptsAccessToken access token 通过 ParseAccessToken。
func TestParseAccessToken_AcceptsAccessToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate access token: %v", err)
}
claims, err := j.ParseAccessToken(tok)
if err != nil {
t.Fatalf("parse access token should succeed: %v", err)
}
if claims.UserID != "u1" {
t.Errorf("user_id = %s, want u1", claims.UserID)
}
if claims.TokenType != TokenTypeAccess {
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeAccess)
}
if claims.SessionID != "s1" {
t.Errorf("sid = %s, want s1", claims.SessionID)
}
}
// TestParseAccessToken_RejectsRefreshToken refresh token 不能当 access token。
func TestParseAccessToken_RejectsRefreshToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
_, err = j.ParseAccessToken(tok)
if !errors.Is(err, ErrWrongTokenType) {
t.Errorf("expected ErrWrongTokenType, got %v", err)
}
}
// TestParseRefreshToken_AcceptsRefreshToken refresh token 通过 ParseRefreshToken。
func TestParseRefreshToken_AcceptsRefreshToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateRefreshToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
claims, err := j.ParseRefreshToken(tok)
if err != nil {
t.Fatalf("parse refresh token should succeed: %v", err)
}
if claims.TokenType != TokenTypeRefresh {
t.Errorf("typ = %s, want %s", claims.TokenType, TokenTypeRefresh)
}
if claims.SessionID != "s1" {
t.Errorf("sid = %s, want s1", claims.SessionID)
}
}
// TestParseRefreshToken_RejectsAccessToken access token 不能当 refresh token。
func TestParseRefreshToken_RejectsAccessToken(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "s1")
if err != nil {
t.Fatalf("generate access token: %v", err)
}
_, err = j.ParseRefreshToken(tok)
if !errors.Is(err, ErrWrongTokenType) {
t.Errorf("expected ErrWrongTokenType, got %v", err)
}
}
// TestParseRefreshToken_RequiresSessionID refresh token 必须带 sid。
func TestParseRefreshToken_RequiresSessionID(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
// 旧式 GenerateRefreshToken(userID, username) 不带 sid应被拒绝。
tok, err := j.GenerateRefreshToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate refresh token: %v", err)
}
_, err = j.ParseRefreshToken(tok)
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("expected ErrInvalidToken for missing sid, got %v", err)
}
}
// TestParseAccessToken_LegacyTokenCompat 旧 token无 typ宽容通过。
func TestParseAccessToken_LegacyTokenCompat(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
// 直接构造一个无 typ 的 access token。
// GenerateAccessToken 总是写 typ=access所以这里用 ParseToken 模拟旧路径:
// 实际上无法用新签发器构造无 typ 的 token此测试验证 ParseAccessToken 对 typ=access 的正常路径。
tok, err := j.GenerateAccessToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate: %v", err)
}
// ParseToken也应拒绝 refresh token仅接受 access。
claims, err := j.ParseToken(tok)
if err != nil {
t.Fatalf("ParseToken should accept access token: %v", err)
}
if claims.UserID != "u1" {
t.Errorf("user_id = %s, want u1", claims.UserID)
}
}
// TestValidateToken_LegacyTokenReturnsNil 旧 tokentyp=access无 sid通过 ValidateToken。
func TestValidateToken_LegacyTokenReturnsNil(t *testing.T) {
j := New("test-secret", 3600*time.Second, 86400*time.Second)
tok, err := j.GenerateAccessToken("u1", "alice", "")
if err != nil {
t.Fatalf("generate: %v", err)
}
if err := j.ValidateToken(tok); err != nil {
t.Errorf("ValidateToken should accept access token without sid, got %v", err)
}
}