Files
enkly-ai/services/iam/internal/application/signup.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

195 lines
6.7 KiB
Go

// Package application holds IAM's use cases — the direct transplant of
// internal/handlers/auth.go's business logic with every net/http,
// html/template and cookie concern stripped out (those stayed behind in
// the gateway service; see services/gateway). Each exported method here
// corresponds 1:1 to an rpc in api/proto/iam/v1/iam.proto and to the
// handler function named in its doc comment.
package application
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"enkly/pkg/authsupport"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/iam/internal/domain"
)
const (
verifyCodeTTL = 15 * time.Minute
resetTokenTTL = time.Hour
)
// Publisher is the subset of *eventbus.Bus the application layer needs —
// defined here (rather than importing eventbus.Bus directly into every
// signature) so a unit test can supply a fake.
type Publisher interface {
PublishBestEffort(ctx context.Context, routingKey string, payload any)
}
// Service implements every IAM use case. Constructed once in
// cmd/server/main.go and handed to the gRPC server
// (internal/interfaces/grpc).
type Service struct {
Users domain.UserRepository
Sessions domain.SessionRepository
Verification domain.VerificationRepository
PasswordReset domain.PasswordResetRepository
Events Publisher
Logger *slog.Logger
BaseURL string // for building the password-reset link, same as Handlers.BaseURL
// MailConfigured mirrors mail.Mailer.Configured() purely so SignUp/
// ResendVerification can report the same "written to the server log
// instead" hint the monolith showed — IAM never sends mail itself
// (see services/notification), it just knows whether the deployment
// has SMTP creds set, from the same ENKLY_SMTP_* env vars.
MailConfigured bool
LoginLimiter *authsupport.Limiter
ResendLimiter *authsupport.Limiter
VerifyLimiter *authsupport.Limiter
dummyHashOnce sync.Once
dummyHash string
}
func (s *Service) getDummyHash() string {
s.dummyHashOnce.Do(func() { s.dummyHash, _ = authsupport.HashPassword("enkly-dummy-password") })
return s.dummyHash
}
// SignUpResult is what the gRPC server maps onto SignUpResponse.
type SignUpResult struct {
CheckInbox bool
Email string
MailConfigured bool
}
// SignUp replaces Handlers.Signup. Email normalization and password
// confirmation are assumed already validated by the caller (the gateway
// does this at the edge, same division as the monolith's normalizeEmail/
// validatePassword helpers) — email arrives already normalized.
func (s *Service) SignUp(ctx context.Context, email, password, clientIP string) (SignUpResult, error) {
// Signups share the resend limiter (keyed per IP): creating accounts
// triggers outbound email, the same abuse vector as resending it.
if !throttle(ctx, s.ResendLimiter, "signup:"+clientIP) {
return SignUpResult{}, ctx.Err()
}
hash, err := authsupport.HashPassword(password)
if err != nil {
return SignUpResult{}, err
}
user, err := s.Users.Create(ctx, email, hash)
switch {
case err == nil:
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserRegistered, events.UserRegistered{
UserID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt,
})
case errors.Is(err, domain.ErrEmailTaken):
// Don't reveal that the address is registered: fall through to
// the same "check your inbox" outcome, quietly resending a
// code if the existing account was never verified.
existing, gerr := s.Users.GetByEmail(ctx, email)
if gerr != nil || existing.EmailVerified {
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
}
user = existing
default:
return SignUpResult{}, err
}
if err := s.issueVerificationCode(ctx, user); err != nil {
return SignUpResult{}, err
}
return SignUpResult{CheckInbox: true, Email: email, MailConfigured: s.MailConfigured}, nil
}
// issueVerificationCode replaces sendVerificationEmail: mints a code,
// stores its hash, and publishes an event for the notification service
// to deliver it — decoupled from this call's own success/failure
// (see pkg/eventbus.Bus.PublishBestEffort's doc comment on why this
// must never block or fail the signup/resend response).
func (s *Service) issueVerificationCode(ctx context.Context, u *domain.User) error {
raw, hash, err := authsupport.NewCode()
if err != nil {
return err
}
expiresAt := time.Now().Add(verifyCodeTTL)
if err := s.Verification.Create(ctx, u.ID, hash, expiresAt); err != nil {
return err
}
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyVerificationCodeIssued, events.VerificationCodeIssued{
UserID: u.ID, Email: u.Email, Code: raw, ExpiresAt: expiresAt,
})
return nil
}
// ResendVerification replaces Handlers.ResendVerification.
func (s *Service) ResendVerification(ctx context.Context, email, clientIP string) error {
// Limit by both address and IP — one address can't be spammed, and
// one client can't spray many addresses.
if !throttle(ctx, s.ResendLimiter, "resend:ip:"+clientIP, "resend:email:"+email) {
return ctx.Err()
}
u, err := s.Users.GetByEmail(ctx, email)
if err == nil && !u.EmailVerified {
if err := s.issueVerificationCode(ctx, u); err != nil {
s.Logger.Error("resend verification", "err", err, "user_id", u.ID)
}
}
// Same answer whether or not the account exists / needs verifying —
// errors swallowed deliberately, matching the monolith.
return nil
}
// VerifyEmail replaces Handlers.VerifyEmail.
func (s *Service) VerifyEmail(ctx context.Context, email, code, clientIP string) (bool, error) {
ipKey, emailKey := "verify:ip:"+clientIP, "verify:email:"+email
// Progressive delay by IP and by email — a distinct limiter from
// login/resend, since guessing a 6-digit code is a distinct abuse
// vector from password-guessing or triggering outbound mail.
var wait time.Duration
for _, k := range []string{ipKey, emailKey} {
if dl := s.VerifyLimiter.Delay(k); dl > wait {
wait = dl
}
}
if !sleepCtx(ctx, wait) {
return false, ctx.Err()
}
user, err := s.Users.GetByEmail(ctx, email)
if errors.Is(err, domain.ErrNotFound) {
s.VerifyLimiter.RecordFailure(ipKey)
s.VerifyLimiter.RecordFailure(emailKey)
return false, nil // don't reveal whether the address is registered
}
if err != nil {
return false, err
}
err = s.Verification.VerifyAndConsume(ctx, user.ID, authsupport.HashToken(code))
switch {
case err == nil:
s.VerifyLimiter.RecordSuccess(ipKey)
s.VerifyLimiter.RecordSuccess(emailKey)
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyUserEmailVerified, events.UserEmailVerified{
UserID: user.ID, Email: user.Email,
})
return true, nil
case errors.Is(err, domain.ErrInvalidToken):
s.VerifyLimiter.RecordFailure(ipKey)
s.VerifyLimiter.RecordFailure(emailKey)
return false, nil
default:
return false, err
}
}