feat(svc): reject disabled users in Login, LoginWithPasskey, and Refresh

This commit is contained in:
2026-07-04 17:13:02 +08:00
parent bff131ba42
commit 118b7e4d2a
2 changed files with 179 additions and 1 deletions
+21
View File
@@ -72,6 +72,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
Username: username, Username: username,
Email: email, Email: email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
Status: model.StatusActive,
} }
if err := s.userRepo.Create(ctx, user); err != nil { if err := s.userRepo.Create(ctx, user); err != nil {
@@ -94,6 +95,10 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
return nil, model.NewInternalError("find user", err) return nil, model.NewInternalError("find user", err)
} }
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
}
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil { if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
} }
@@ -126,6 +131,14 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
} }
user, err := s.userRepo.FindByID(ctx, claims.UserID)
if err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil { if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
return nil, model.NewInternalError("delete old session", err) return nil, model.NewInternalError("delete old session", err)
} }
@@ -188,6 +201,14 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
return nil, model.NewInternalError("find credential", err) return nil, model.NewInternalError("find credential", err)
} }
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
if err != nil {
return nil, model.NewInternalError("find user", err)
}
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
}
if cred.Type != "app_passkey" { if cred.Type != "app_passkey" {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"} return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
} }
+158 -1
View File
@@ -16,6 +16,13 @@ import (
) )
func setupAuthService(t *testing.T) *AuthService { func setupAuthService(t *testing.T) *AuthService {
svc, _ := setupAuthServiceWithRepos(t)
return svc
}
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
// for tests that need direct repo access (e.g., soft-deleting users).
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -30,12 +37,13 @@ func setupAuthService(t *testing.T) *AuthService {
sessionRepo := repository.NewSessionRepository(db) sessionRepo := repository.NewSessionRepository(db)
credentialRepo := repository.NewCredentialRepository(db) credentialRepo := repository.NewCredentialRepository(db)
return NewAuthService( svc := NewAuthService(
userRepo, sessionRepo, credentialRepo, userRepo, sessionRepo, credentialRepo,
[]byte("test-secret"), []byte("test-secret"),
15*time.Minute, 15*time.Minute,
7*24*time.Hour, 7*24*time.Hour,
) )
return svc, userRepo
} }
func TestAuthService_Register(t *testing.T) { func TestAuthService_Register(t *testing.T) {
@@ -404,3 +412,152 @@ func TestAuthService_RefreshWithAccessToken(t *testing.T) {
t.Fatal("expected error when using access token for refresh, got nil") t.Fatal("expected error when using access token for refresh, got nil")
} }
} }
func TestAuthService_LoginDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Login(ctx, "alice@example.com", "password123")
if err == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid email or password" {
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
}
}
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
// Get error for wrong password (active user)
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
// Soft-delete user, then try to login
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
// Both errors should have the same message (no user enumeration)
if disabledErr == nil {
t.Fatal("expected error for disabled user login, got nil")
}
var aeDisabled *model.AppError
if !errors.As(disabledErr, &aeDisabled) {
t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr)
}
var aeWrongPw *model.AppError
if !errors.As(wrongPwErr, &aeWrongPw) {
t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr)
}
if aeDisabled.Message != aeWrongPw.Message {
t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message)
}
if aeDisabled.Message != "invalid email or password" {
t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password")
}
}
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
t.Fatalf("Login = %v", err)
}
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
if err != nil {
t.Fatalf("ParseToken = %v", err)
}
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
if err != nil {
t.Fatalf("CreatePasskey = %v", err)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
if err == nil {
t.Fatal("expected error for disabled user passkey login, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid passkey" {
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
}
}
func TestAuthService_RefreshDisabledUser(t *testing.T) {
svc, userRepo := setupAuthServiceWithRepos(t)
ctx := context.Background()
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
if err != nil {
t.Fatalf("Register = %v", err)
}
pair, err := svc.Login(ctx, "alice@example.com", "password123")
if err != nil {
t.Fatalf("Login = %v", err)
}
// Soft-delete the user
if err := userRepo.Delete(ctx, user.ID); err != nil {
t.Fatalf("Delete = %v", err)
}
_, err = svc.Refresh(ctx, pair.RefreshToken)
if err == nil {
t.Fatal("expected error for disabled user refresh, got nil")
}
var ae *model.AppError
if !errors.As(err, &ae) {
t.Fatalf("expected AppError, got %T: %v", err, err)
}
if ae.Status != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
}
if ae.Message != "invalid token" {
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
}
}