- Add VerifyPassword RPC to Identity Service - Added to proto file and generated code - Implemented in Identity Service gRPC server - Added to Identity Service client interface and gRPC client - Complete RefreshToken implementation - Store refresh tokens in database using RefreshToken entity - Validate refresh tokens with expiration checking - Revoke refresh tokens on logout and token rotation - Integrate Authz Service for role retrieval - Added AuthzServiceClient to Auth Service - Get user roles during login and token refresh - Gracefully handle Authz Service failures - Require JWT secret in configuration - Removed default secret fallback - Service fails to start if JWT secret is not configured - Fix Consul health checks for Docker - Services now register with Docker service names (e.g., audit-service) - Allows Consul (in Docker) to reach services via Docker DNS - Health checks use gRPC service names instead of localhost This completes all TODOs in auth_service_fx.go and fixes the Consul health check failures in Docker environments.
435 lines
13 KiB
Go
435 lines
13 KiB
Go
// Package main provides FX providers for Identity Service.
|
|
// This file creates the service inline to avoid importing internal packages.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
identityv1 "git.dcentral.systems/toolz/goplt/api/proto/generated/identity/v1"
|
|
"git.dcentral.systems/toolz/goplt/internal/ent"
|
|
"git.dcentral.systems/toolz/goplt/internal/ent/user"
|
|
"git.dcentral.systems/toolz/goplt/internal/infra/database"
|
|
"git.dcentral.systems/toolz/goplt/pkg/config"
|
|
"git.dcentral.systems/toolz/goplt/pkg/logger"
|
|
"go.uber.org/fx"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/crypto/argon2"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/health"
|
|
"google.golang.org/grpc/health/grpc_health_v1"
|
|
"google.golang.org/grpc/reflection"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// userService provides user management functionality.
|
|
type userService struct {
|
|
client *database.Client
|
|
logger logger.Logger
|
|
}
|
|
|
|
// generateToken generates a random token.
|
|
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 fmt.Sprintf("%x", b), nil
|
|
}
|
|
|
|
// hashPassword hashes a password using argon2id.
|
|
func hashPassword(password string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
|
}
|
|
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
|
|
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
|
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2.Version, 64*1024, 3, 4, b64Salt, b64Hash), nil
|
|
}
|
|
|
|
// verifyPassword verifies a password against a hash.
|
|
func verifyPassword(password, hash string) (bool, error) {
|
|
// Simplified verification - in production use proper parsing
|
|
parts := strings.Split(hash, "$")
|
|
if len(parts) != 6 {
|
|
return false, fmt.Errorf("invalid hash format")
|
|
}
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
actualHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, uint32(len(expectedHash)))
|
|
return subtle.ConstantTimeCompare(expectedHash, actualHash) == 1, nil
|
|
}
|
|
|
|
// createUser creates a new user.
|
|
func (s *userService) createUser(ctx context.Context, email, username, pwd, firstName, lastName string) (*ent.User, error) {
|
|
exists, err := s.client.User.Query().Where(user.Email(email)).Exist(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check email: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("email already exists")
|
|
}
|
|
|
|
passwordHash, err := hashPassword(pwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
|
}
|
|
|
|
verificationToken, err := generateToken()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
|
|
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 {
|
|
return nil, fmt.Errorf("failed to create user: %w", err)
|
|
}
|
|
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 {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// updateUser updates a user.
|
|
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 {
|
|
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: %w", err)
|
|
}
|
|
if exists {
|
|
return nil, fmt.Errorf("email already taken")
|
|
}
|
|
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)
|
|
}
|
|
return update.Save(ctx)
|
|
}
|
|
|
|
// deleteUser deletes a user.
|
|
func (s *userService) deleteUser(ctx context.Context, id string) error {
|
|
return s.client.User.DeleteOneID(id).Exec(ctx)
|
|
}
|
|
|
|
// verifyEmail verifies a user's email.
|
|
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 {
|
|
return fmt.Errorf("invalid token")
|
|
}
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetVerified(true).
|
|
ClearEmailVerificationToken().
|
|
Save(ctx)
|
|
return err
|
|
}
|
|
|
|
// requestPasswordReset requests a password reset.
|
|
func (s *userService) requestPasswordReset(ctx context.Context, email string) (string, error) {
|
|
u, err := s.getUserByEmail(ctx, email)
|
|
if err != nil {
|
|
return "", nil // Don't reveal if user exists
|
|
}
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
expiresAt := time.Now().Add(24 * time.Hour)
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetPasswordResetToken(token).
|
|
SetPasswordResetExpiresAt(expiresAt).
|
|
Save(ctx)
|
|
return token, err
|
|
}
|
|
|
|
// resetPassword resets a password.
|
|
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 {
|
|
return fmt.Errorf("invalid token")
|
|
}
|
|
if !u.PasswordResetExpiresAt.IsZero() && u.PasswordResetExpiresAt.Before(time.Now()) {
|
|
return fmt.Errorf("token expired")
|
|
}
|
|
passwordHash, err := hashPassword(newPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.client.User.UpdateOneID(u.ID).
|
|
SetPasswordHash(passwordHash).
|
|
ClearPasswordResetToken().
|
|
ClearPasswordResetExpiresAt().
|
|
Save(ctx)
|
|
return err
|
|
}
|
|
|
|
// verifyPassword verifies a password.
|
|
func (s *userService) verifyPassword(ctx context.Context, email, pwd string) (*ent.User, error) {
|
|
u, err := s.getUserByEmail(ctx, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
valid, err := verifyPassword(pwd, u.PasswordHash)
|
|
if err != nil || !valid {
|
|
return nil, fmt.Errorf("invalid password")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// identityServerImpl implements the IdentityService gRPC server.
|
|
type identityServerImpl struct {
|
|
identityv1.UnimplementedIdentityServiceServer
|
|
service *userService
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// GetUser retrieves a user by ID.
|
|
func (s *identityServerImpl) GetUser(ctx context.Context, req *identityv1.GetUserRequest) (*identityv1.GetUserResponse, error) {
|
|
u, err := s.service.getUser(ctx, req.Id)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
|
|
}
|
|
return &identityv1.GetUserResponse{
|
|
User: &identityv1.User{
|
|
Id: u.ID,
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LastName: u.LastName,
|
|
EmailVerified: u.Verified,
|
|
CreatedAt: u.CreatedAt.Unix(),
|
|
UpdatedAt: u.UpdatedAt.Unix(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// GetUserByEmail retrieves a user by email.
|
|
func (s *identityServerImpl) GetUserByEmail(ctx context.Context, req *identityv1.GetUserByEmailRequest) (*identityv1.GetUserByEmailResponse, error) {
|
|
u, err := s.service.getUserByEmail(ctx, req.Email)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.NotFound, "user not found: %v", err)
|
|
}
|
|
return &identityv1.GetUserByEmailResponse{
|
|
User: &identityv1.User{
|
|
Id: u.ID,
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LastName: u.LastName,
|
|
EmailVerified: u.Verified,
|
|
CreatedAt: u.CreatedAt.Unix(),
|
|
UpdatedAt: u.UpdatedAt.Unix(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// CreateUser creates a new user.
|
|
func (s *identityServerImpl) CreateUser(ctx context.Context, req *identityv1.CreateUserRequest) (*identityv1.CreateUserResponse, error) {
|
|
u, err := s.service.createUser(ctx, req.Email, req.Username, req.Password, req.FirstName, req.LastName)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create user: %v", err)
|
|
}
|
|
return &identityv1.CreateUserResponse{
|
|
User: &identityv1.User{
|
|
Id: u.ID,
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LastName: u.LastName,
|
|
EmailVerified: u.Verified,
|
|
CreatedAt: u.CreatedAt.Unix(),
|
|
UpdatedAt: u.UpdatedAt.Unix(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// UpdateUser updates a user.
|
|
func (s *identityServerImpl) UpdateUser(ctx context.Context, req *identityv1.UpdateUserRequest) (*identityv1.UpdateUserResponse, error) {
|
|
u, err := s.service.updateUser(ctx, req.Id, req.Email, req.Username, req.FirstName, req.LastName)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to update user: %v", err)
|
|
}
|
|
return &identityv1.UpdateUserResponse{
|
|
User: &identityv1.User{
|
|
Id: u.ID,
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LastName: u.LastName,
|
|
EmailVerified: u.Verified,
|
|
CreatedAt: u.CreatedAt.Unix(),
|
|
UpdatedAt: u.UpdatedAt.Unix(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// DeleteUser deletes a user.
|
|
func (s *identityServerImpl) DeleteUser(ctx context.Context, req *identityv1.DeleteUserRequest) (*identityv1.DeleteUserResponse, error) {
|
|
if err := s.service.deleteUser(ctx, req.Id); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to delete user: %v", err)
|
|
}
|
|
return &identityv1.DeleteUserResponse{Success: true}, nil
|
|
}
|
|
|
|
// VerifyEmail verifies a user's email.
|
|
func (s *identityServerImpl) VerifyEmail(ctx context.Context, req *identityv1.VerifyEmailRequest) (*identityv1.VerifyEmailResponse, error) {
|
|
if err := s.service.verifyEmail(ctx, req.Token); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "failed to verify email: %v", err)
|
|
}
|
|
return &identityv1.VerifyEmailResponse{Success: true}, nil
|
|
}
|
|
|
|
// RequestPasswordReset requests a password reset.
|
|
func (s *identityServerImpl) RequestPasswordReset(ctx context.Context, req *identityv1.RequestPasswordResetRequest) (*identityv1.RequestPasswordResetResponse, error) {
|
|
_, err := s.service.requestPasswordReset(ctx, req.Email)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to request password reset: %v", err)
|
|
}
|
|
return &identityv1.RequestPasswordResetResponse{Success: true}, nil
|
|
}
|
|
|
|
// ResetPassword resets a password.
|
|
func (s *identityServerImpl) ResetPassword(ctx context.Context, req *identityv1.ResetPasswordRequest) (*identityv1.ResetPasswordResponse, error) {
|
|
if err := s.service.resetPassword(ctx, req.Token, req.NewPassword); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "failed to reset password: %v", err)
|
|
}
|
|
return &identityv1.ResetPasswordResponse{Success: true}, nil
|
|
}
|
|
|
|
// VerifyPassword verifies a user's password.
|
|
func (s *identityServerImpl) VerifyPassword(ctx context.Context, req *identityv1.VerifyPasswordRequest) (*identityv1.VerifyPasswordResponse, error) {
|
|
u, err := s.service.verifyPassword(ctx, req.Email, req.Password)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "invalid credentials: %v", err)
|
|
}
|
|
return &identityv1.VerifyPasswordResponse{
|
|
User: &identityv1.User{
|
|
Id: u.ID,
|
|
Email: u.Email,
|
|
Username: u.Username,
|
|
FirstName: u.FirstName,
|
|
LastName: u.LastName,
|
|
EmailVerified: u.Verified,
|
|
CreatedAt: u.CreatedAt.Unix(),
|
|
UpdatedAt: u.UpdatedAt.Unix(),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// provideIdentityService creates the identity service and gRPC server.
|
|
func provideIdentityService() fx.Option {
|
|
return fx.Options(
|
|
// User service
|
|
fx.Provide(func(client *database.Client, log logger.Logger) (*userService, error) {
|
|
return &userService{
|
|
client: client,
|
|
logger: log,
|
|
}, nil
|
|
}),
|
|
|
|
// gRPC server implementation
|
|
fx.Provide(func(userService *userService, log logger.Logger) (*identityServerImpl, error) {
|
|
zapLogger, _ := zap.NewProduction()
|
|
return &identityServerImpl{
|
|
service: userService,
|
|
logger: zapLogger,
|
|
}, nil
|
|
}),
|
|
|
|
// gRPC server wrapper
|
|
fx.Provide(func(
|
|
serverImpl *identityServerImpl,
|
|
cfg config.ConfigProvider,
|
|
log logger.Logger,
|
|
) (*grpcServerWrapper, error) {
|
|
port := cfg.GetInt("services.identity.port")
|
|
if port == 0 {
|
|
port = 8082
|
|
}
|
|
|
|
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
|
}
|
|
|
|
grpcServer := grpc.NewServer()
|
|
identityv1.RegisterIdentityServiceServer(grpcServer, serverImpl)
|
|
|
|
// Register health service
|
|
healthServer := health.NewServer()
|
|
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
|
|
healthServer.SetServingStatus("identity.v1.IdentityService", grpc_health_v1.HealthCheckResponse_SERVING)
|
|
|
|
// Register reflection for grpcurl
|
|
reflection.Register(grpcServer)
|
|
|
|
return &grpcServerWrapper{
|
|
server: grpcServer,
|
|
listener: listener,
|
|
port: port,
|
|
logger: log,
|
|
}, nil
|
|
}),
|
|
)
|
|
}
|