195 lines
6.7 KiB
Go
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
|
|
}
|
|
}
|