diff --git a/internal/service/auth.go b/internal/service/auth.go index 4b24563..584ce04 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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"} } diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index 3901c7f..767edce 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -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") + } +}