Files
enkly-ai/services/iam/internal/application/login.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

93 lines
2.9 KiB
Go

package application
import (
"context"
"errors"
"time"
"enkly/pkg/authsupport"
"enkly/services/iam/internal/domain"
)
// sessionTTL matches the monolith's cookie Max-Age for the session
// cookie (30 days) — see handlers.Handlers.startSession.
const sessionTTL = 30 * 24 * time.Hour
// LoginResult is what the gRPC server maps onto LoginResponse.
type LoginResult struct {
OK bool
NeedsVerification bool
SessionToken string
SessionExpiresAt time.Time
User *domain.User
}
// Login replaces Handlers.Login: verifies credentials in constant time
// (an unknown email is checked against a dummy hash so the response
// takes the same time either way) and, on success, mints a session.
func (s *Service) Login(ctx context.Context, email, password, clientIP string) (LoginResult, error) {
ipKey, emailKey := "login:ip:"+clientIP, "login:email:"+email
if !throttle(ctx, s.LoginLimiter, ipKey, emailKey) {
return LoginResult{}, ctx.Err()
}
user, err := s.Users.GetByEmail(ctx, email)
if errors.Is(err, domain.ErrNotFound) {
authsupport.VerifyPassword(password, s.getDummyHash()) // constant-time decoy
return LoginResult{}, nil
}
if err != nil {
return LoginResult{}, err
}
if !authsupport.VerifyPassword(password, user.PasswordHash) {
return LoginResult{}, nil
}
if !user.EmailVerified {
return LoginResult{NeedsVerification: true}, nil
}
s.LoginLimiter.RecordSuccess(ipKey)
s.LoginLimiter.RecordSuccess(emailKey)
raw, hash, err := authsupport.NewToken()
if err != nil {
return LoginResult{}, err
}
expiresAt := time.Now().Add(sessionTTL)
if err := s.Sessions.Create(ctx, user.ID, hash, expiresAt); err != nil {
return LoginResult{}, err
}
return LoginResult{OK: true, SessionToken: raw, SessionExpiresAt: expiresAt, User: user}, nil
}
// Logout replaces Handlers.Logout.
func (s *Service) Logout(ctx context.Context, sessionToken string) error {
if sessionToken == "" {
return nil
}
return s.Sessions.Delete(ctx, authsupport.HashToken(sessionToken))
}
// ValidateSession replaces the session half of Handlers.RequireAuth
// (db.GetSessionUser). Called by the gateway on every authenticated
// request, so this is the one IAM RPC on the hot path of practically
// every request in the system — see ARCHITECTURE.md's "Session
// validation latency" note for the caching strategy a production
// rollout should add here.
func (s *Service) ValidateSession(ctx context.Context, sessionToken string) (*domain.User, bool) {
if sessionToken == "" {
return nil, false
}
u, err := s.Sessions.GetUserByTokenHash(ctx, authsupport.HashToken(sessionToken))
if err != nil {
return nil, false
}
return u, true
}
// GetUser replaces a direct db.DB lookup other services occasionally
// needed (e.g. billing resolving an email from a user_id for Stripe).
func (s *Service) GetUser(ctx context.Context, userID int64) (*domain.User, error) {
return s.Users.GetByID(ctx, userID)
}