- Implement Audit Service (2.5) - gRPC server with Record and Query operations - Database persistence with audit schema - Service registry integration - Entry point: cmd/audit-service - Implement Identity Service (2.2) - User CRUD operations - Password hashing with argon2id - Email verification and password reset flows - Entry point: cmd/identity-service - Fix package naming conflicts in user_service.go - Implement Auth Service (2.1) - JWT token generation and validation - Login, RefreshToken, ValidateToken, Logout RPCs - Integration with Identity Service - Entry point: cmd/auth-service - Note: RefreshToken entity needs Ent generation - Implement Authz Service (2.3, 2.4) - Permission checking and authorization - User roles and permissions retrieval - RBAC-based authorization - Entry point: cmd/authz-service - Implement gRPC clients for all services - Auth, Identity, Authz, and Audit clients - Service discovery integration - Full gRPC communication - Add service configurations to config/default.yaml - Create SUMMARY.md with implementation details and testing instructions - Fix compilation errors in Identity Service (password package conflicts) - All services build successfully and tests pass
341 lines
8.6 KiB
Go
341 lines
8.6 KiB
Go
// Package service provides user service business logic.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.dcentral.systems/toolz/goplt/internal/ent"
|
|
"git.dcentral.systems/toolz/goplt/internal/ent/user"
|
|
"git.dcentral.systems/toolz/goplt/pkg/logger"
|
|
passwordpkg "git.dcentral.systems/toolz/goplt/services/identity/internal/password"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// UserService provides user management functionality.
|
|
type UserService struct {
|
|
client *ent.Client
|
|
logger logger.Logger
|
|
}
|
|
|
|
// NewUserService creates a new user service.
|
|
func NewUserService(client *ent.Client, log logger.Logger) *UserService {
|
|
return &UserService{
|
|
client: client,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
// generateToken generates a random token for email verification or password reset.
|
|
func generateToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
return base64.URLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// CreateUser creates a new user.
|
|
func (s *UserService) CreateUser(ctx context.Context, email, username, password, firstName, lastName string) (*ent.User, error) {
|
|
// Check if user with email already exists
|
|
exists, err := s.client.User.Query().
|
|
Where(user.Email(email)).
|
|
Exist(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check email existence: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("user with email %s already exists", email)
|
|
}
|
|
|
|
// Hash password
|
|
passwordHash, err := passwordpkg.Hash(password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Generate email verification token
|
|
verificationToken, err := generateToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
|
}
|
|
|
|
// Create user
|
|
create := s.client.User.Create().
|
|
SetID(fmt.Sprintf("%d", time.Now().UnixNano())).
|
|
SetEmail(email).
|
|
SetPasswordHash(passwordHash).
|
|
SetVerified(false).
|
|
SetEmailVerificationToken(verificationToken)
|
|
|
|
if username != "" {
|
|
create = create.SetUsername(username)
|
|
}
|
|
if firstName != "" {
|
|
create = create.SetFirstName(firstName)
|
|
}
|
|
if lastName != "" {
|
|
create = create.SetLastName(lastName)
|
|
}
|
|
|
|
u, err := create.Save(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create user",
|
|
zap.Error(err),
|
|
zap.String("email", email),
|
|
)
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User created",
|
|
zap.String("user_id", u.ID),
|
|
zap.String("email", email),
|
|
)
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// GetUser retrieves a user by ID.
|
|
func (s *UserService) GetUser(ctx context.Context, id string) (*ent.User, error) {
|
|
u, err := s.client.User.Get(ctx, id)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// GetUserByEmail retrieves a user by email.
|
|
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*ent.User, error) {
|
|
u, err := s.client.User.Query().
|
|
Where(user.Email(email)).
|
|
Only(ctx)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to get user by email: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// UpdateUser updates a user's profile.
|
|
func (s *UserService) UpdateUser(ctx context.Context, id string, email, username, firstName, lastName *string) (*ent.User, error) {
|
|
update := s.client.User.UpdateOneID(id)
|
|
|
|
if email != nil {
|
|
// Check if email is already taken by another user
|
|
exists, err := s.client.User.Query().
|
|
Where(user.Email(*email), user.IDNEQ(id)).
|
|
Exist(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check email existence: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("email %s is already taken", *email)
|
|
}
|
|
update = update.SetEmail(*email)
|
|
}
|
|
if username != nil {
|
|
update = update.SetUsername(*username)
|
|
}
|
|
if firstName != nil {
|
|
update = update.SetFirstName(*firstName)
|
|
}
|
|
if lastName != nil {
|
|
update = update.SetLastName(*lastName)
|
|
}
|
|
|
|
u, err := update.Save(ctx)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to update user: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User updated",
|
|
zap.String("user_id", id),
|
|
)
|
|
|
|
return u, nil
|
|
}
|
|
|
|
// DeleteUser deletes a user (soft delete by setting verified to false, or hard delete).
|
|
func (s *UserService) DeleteUser(ctx context.Context, id string) error {
|
|
err := s.client.User.DeleteOneID(id).Exec(ctx)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return fmt.Errorf("user not found: %w", err)
|
|
}
|
|
return fmt.Errorf("failed to delete user: %w", err)
|
|
}
|
|
|
|
s.logger.Info("User deleted",
|
|
zap.String("user_id", id),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyEmail verifies a user's email address using a verification token.
|
|
func (s *UserService) VerifyEmail(ctx context.Context, token string) error {
|
|
u, err := s.client.User.Query().
|
|
Where(user.EmailVerificationToken(token)).
|
|
Only(ctx)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return fmt.Errorf("invalid verification token")
|
|
}
|
|
return fmt.Errorf("failed to find user: %w", err)
|
|
}
|
|
|
|
// Update user to verified and clear token
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetVerified(true).
|
|
ClearEmailVerificationToken().
|
|
Save(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify email: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Email verified",
|
|
zap.String("user_id", u.ID),
|
|
zap.String("email", u.Email),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RequestPasswordReset requests a password reset token.
|
|
func (s *UserService) RequestPasswordReset(ctx context.Context, email string) (string, error) {
|
|
u, err := s.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
// Don't reveal if user exists or not (security best practice)
|
|
return "", nil
|
|
}
|
|
|
|
// Generate reset token
|
|
resetToken, err := generateToken()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to generate reset token: %w", err)
|
|
}
|
|
|
|
// Set reset token with expiration (24 hours)
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetPasswordResetToken(resetToken).
|
|
SetPasswordResetExpiresAt(expiresAt).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set reset token: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Password reset requested",
|
|
zap.String("user_id", u.ID),
|
|
zap.String("email", email),
|
|
)
|
|
|
|
return resetToken, nil
|
|
}
|
|
|
|
// ResetPassword resets a user's password using a reset token.
|
|
func (s *UserService) ResetPassword(ctx context.Context, token, newPassword string) error {
|
|
u, err := s.client.User.Query().
|
|
Where(user.PasswordResetToken(token)).
|
|
Only(ctx)
|
|
if err != nil {
|
|
if ent.IsNotFound(err) {
|
|
return fmt.Errorf("invalid reset token")
|
|
}
|
|
return fmt.Errorf("failed to find user: %w", err)
|
|
}
|
|
|
|
// Check if token is expired
|
|
if !u.PasswordResetExpiresAt.IsZero() && u.PasswordResetExpiresAt.Before(time.Now()) {
|
|
return fmt.Errorf("reset token has expired")
|
|
}
|
|
|
|
// Hash new password
|
|
passwordHash, err := passwordpkg.Hash(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Update password and clear reset token
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetPasswordHash(passwordHash).
|
|
ClearPasswordResetToken().
|
|
ClearPasswordResetExpiresAt().
|
|
Save(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reset password: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Password reset",
|
|
zap.String("user_id", u.ID),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ChangePassword changes a user's password with old password verification.
|
|
func (s *UserService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
|
u, err := s.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify old password
|
|
valid, err := passwordpkg.Verify(oldPassword, u.PasswordHash)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify password: %w", err)
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("invalid old password")
|
|
}
|
|
|
|
// Hash new password
|
|
passwordHash, err := passwordpkg.Hash(newPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
// Update password
|
|
_, err = s.client.User.UpdateOneID(userID).
|
|
SetPasswordHash(passwordHash).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to change password: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Password changed",
|
|
zap.String("user_id", userID),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyPassword verifies a password against a user's password hash.
|
|
func (s *UserService) VerifyPassword(ctx context.Context, email, password string) (*ent.User, error) {
|
|
u, err := s.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
valid, err := passwordpkg.Verify(password, u.PasswordHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to verify password: %w", err)
|
|
}
|
|
if !valid {
|
|
return nil, fmt.Errorf("invalid password")
|
|
}
|
|
|
|
return u, nil
|
|
}
|