163 lines
4.4 KiB
Go
163 lines
4.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"hyapp-admin-server/internal/config"
|
|
"hyapp-admin-server/internal/model"
|
|
authcore "hyapp-admin-server/internal/service"
|
|
)
|
|
|
|
type fakeAuthStore struct {
|
|
user model.User
|
|
tokens map[string]model.RefreshToken
|
|
nextTokenID uint
|
|
}
|
|
|
|
func newFakeAuthStore(t *testing.T) *fakeAuthStore {
|
|
t.Helper()
|
|
passwordHash, err := authcore.HashPassword("secret")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
return &fakeAuthStore{
|
|
user: model.User{
|
|
ID: 7,
|
|
Username: "admin",
|
|
Name: "Admin",
|
|
PasswordHash: passwordHash,
|
|
Status: model.UserStatusActive,
|
|
Roles: []model.Role{{
|
|
Permissions: []model.Permission{{Code: "user:view"}},
|
|
}},
|
|
},
|
|
tokens: make(map[string]model.RefreshToken),
|
|
}
|
|
}
|
|
|
|
func (s *fakeAuthStore) FindUserByUsername(username string) (*model.User, error) {
|
|
if username != s.user.Username {
|
|
return nil, errors.New("not found")
|
|
}
|
|
user := s.user
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) FindUserByID(id uint) (*model.User, error) {
|
|
if id != s.user.ID {
|
|
return nil, errors.New("not found")
|
|
}
|
|
user := s.user
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) CreateRefreshToken(token model.RefreshToken) error {
|
|
if token.ID == 0 {
|
|
s.nextTokenID++
|
|
token.ID = s.nextTokenID
|
|
}
|
|
s.tokens[token.TokenHash] = token
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) RotateRefreshToken(oldTokenID uint, token model.RefreshToken) error {
|
|
if err := s.CreateRefreshToken(token); err != nil {
|
|
return err
|
|
}
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
for hash, existing := range s.tokens {
|
|
if existing.ID == oldTokenID && existing.RevokedAtMS == nil {
|
|
existing.RevokedAtMS = &nowMS
|
|
s.tokens[hash] = existing
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("old token not found")
|
|
}
|
|
|
|
func (s *fakeAuthStore) FindRefreshToken(hash string) (*model.RefreshToken, error) {
|
|
token, ok := s.tokens[hash]
|
|
if !ok || token.RevokedAtMS != nil || token.ExpiresAtMS <= time.Now().UTC().UnixMilli() {
|
|
return nil, errors.New("not found")
|
|
}
|
|
return &token, nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) RevokeRefreshToken(hash string) error {
|
|
token, ok := s.tokens[hash]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
token.RevokedAtMS = &nowMS
|
|
s.tokens[hash] = token
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) TouchUserLogin(uint) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) CreateLoginLog(model.LoginLog) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) ResetUserPassword(id uint, password string) error {
|
|
if id != s.user.ID {
|
|
return errors.New("not found")
|
|
}
|
|
passwordHash, err := authcore.HashPassword(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.user.PasswordHash = passwordHash
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeAuthStore) activeRefreshTokenCount() int {
|
|
count := 0
|
|
nowMS := time.Now().UTC().UnixMilli()
|
|
for _, token := range s.tokens {
|
|
if token.RevokedAtMS == nil && token.ExpiresAtMS > nowMS {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func TestRefreshRotatesOnlyCurrentSessionAndKeepsMultiLogin(t *testing.T) {
|
|
store := newFakeAuthStore(t)
|
|
service := NewService(store, authcore.NewAuthService("test-secret", time.Hour), config.Config{RefreshTokenTTL: 7 * 24 * time.Hour})
|
|
|
|
first, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.1", UserAgent: "browser-a"})
|
|
if err != nil {
|
|
t.Fatalf("first login: %v", err)
|
|
}
|
|
second, err := service.Login(LoginInput{Username: "admin", Password: "secret", IP: "127.0.0.2", UserAgent: "browser-b"})
|
|
if err != nil {
|
|
t.Fatalf("second login: %v", err)
|
|
}
|
|
if store.activeRefreshTokenCount() != 2 {
|
|
t.Fatalf("expected two active sessions after multi-login, got %d", store.activeRefreshTokenCount())
|
|
}
|
|
|
|
refreshedFirst, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"})
|
|
if err != nil {
|
|
t.Fatalf("refresh first session: %v", err)
|
|
}
|
|
if refreshedFirst.RefreshToken == "" || refreshedFirst.RefreshToken == first.RefreshToken {
|
|
t.Fatalf("refresh must rotate current refresh token")
|
|
}
|
|
if _, err := service.Refresh(first.RefreshToken, LoginInput{IP: "127.0.0.1", UserAgent: "browser-a"}); err == nil {
|
|
t.Fatalf("old refresh token must be revoked after rotation")
|
|
}
|
|
if store.activeRefreshTokenCount() != 2 {
|
|
t.Fatalf("rotation should keep one session per login point, got %d", store.activeRefreshTokenCount())
|
|
}
|
|
if _, err := service.Refresh(second.RefreshToken, LoginInput{IP: "127.0.0.2", UserAgent: "browser-b"}); err != nil {
|
|
t.Fatalf("second session should remain refreshable: %v", err)
|
|
}
|
|
}
|