feat(svc): reject disabled users in Login, LoginWithPasskey, and Refresh
This commit is contained in:
@@ -72,6 +72,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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"}
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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" {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -30,12 +37,13 @@ func setupAuthService(t *testing.T) *AuthService {
|
||||
sessionRepo := repository.NewSessionRepository(db)
|
||||
credentialRepo := repository.NewCredentialRepository(db)
|
||||
|
||||
return NewAuthService(
|
||||
svc := NewAuthService(
|
||||
userRepo, sessionRepo, credentialRepo,
|
||||
[]byte("test-secret"),
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
return svc, userRepo
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user