Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

28
services/iam/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# Build context is the repo root (see docker-compose.yml) — every
# service's Dockerfile follows this same two-stage shape: compile one
# cmd/server binary against the whole monorepo module, then ship just
# the binary + that service's migrations in a distroless-ish runtime
# image. See ARCHITECTURE.md's "One Go module, many deployables" note
# for why this is a single `module enkly` rather than N go.mod files.
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum* ./
COPY gen/ gen/
COPY pkg/ pkg/
COPY services/iam/ services/iam/
# No go.sum is committed yet (see "go mod tidy" in the repo root's
# README), so resolve+lock the full dependency graph here, after all
# source is present — `go mod tidy` needs to see every import (direct
# AND the ones grpc/protobuf/etc. pull in transitively) to compute it.
# Once a go.sum is committed to the repo, this can be replaced with
# the faster, better-cached `go mod download`.
RUN go mod tidy
RUN CGO_ENABLED=0 go build -o /out/iam-server ./services/iam/cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/iam-server ./iam-server
COPY --from=build /src/services/iam/migrations ./migrations
ENV ENKLY_IAM_GRPC_ADDR=:9001
EXPOSE 9001
ENTRYPOINT ["./iam-server"]

View File

@@ -0,0 +1,132 @@
// Command server runs the iam microservice: a gRPC server exposing
// api/proto/iam/v1 over ENKLY_IAM_GRPC_ADDR, backed by its own Postgres
// database. Replaces the auth-related slice of the monolith's
// cmd/server/main.go + internal/handlers/auth.go.
package main
import (
"context"
"log/slog"
"net"
"os"
"os/signal"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/authsupport"
"enkly/pkg/envkit"
"enkly/pkg/eventbus"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/application"
"enkly/services/iam/internal/infrastructure/postgres"
grpcsrv "enkly/services/iam/internal/interfaces/grpc"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := envkit.LoadDotEnv(".env"); err != nil {
logger.Error("load .env", "err", err)
os.Exit(1)
}
var (
grpcAddr = envkit.String("ENKLY_IAM_GRPC_ADDR", ":9001")
postgresDSN = envkit.String("ENKLY_IAM_POSTGRES_DSN", "postgres://enkly:enkly@localhost:5432/enkly_iam?sslmode=disable")
rabbitURL = envkit.String("ENKLY_RABBITMQ_URL", "amqp://enkly:enkly@localhost:5672/")
baseURL = envkit.String("ENKLY_BASE_URL", "http://localhost:8080")
migrationsPath = envkit.String("ENKLY_IAM_MIGRATIONS", "migrations/0001_init.sql")
smtpUsername = envkit.String("ENKLY_SMTP_USERNAME", "")
smtpPassword = envkit.String("ENKLY_SMTP_PASSWORD", "")
)
db, err := pgkit.Open(postgresDSN)
if err != nil {
logger.Error("open postgres", "err", err)
os.Exit(1)
}
defer db.Close()
migrationSQL, err := os.ReadFile(migrationsPath)
if err != nil {
logger.Error("read migrations", "path", migrationsPath, "err", err)
os.Exit(1)
}
if err := db.Migrate(context.Background(), string(migrationSQL)); err != nil {
logger.Error("migrate", "err", err)
os.Exit(1)
}
bus, err := eventbus.Connect(rabbitURL, logger)
if err != nil {
logger.Error("connect rabbitmq", "err", err)
os.Exit(1)
}
defer bus.Close()
svc := &application.Service{
Users: postgres.NewUserRepo(db),
Sessions: postgres.NewSessionRepo(db),
Verification: postgres.NewVerificationRepo(db),
PasswordReset: postgres.NewPasswordResetRepo(db),
Events: bus,
Logger: logger,
BaseURL: baseURL,
MailConfigured: smtpUsername != "" && smtpPassword != "",
// Same windows/backoffs as the monolith's cmd/server/main.go —
// progressive delay, not a hard lockout; see authsupport.Limiter.
LoginLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
ResendLimiter: authsupport.NewLimiter(1*time.Hour, 2*time.Second, 30*time.Second),
VerifyLimiter: authsupport.NewLimiter(15*time.Minute, 500*time.Millisecond, 8*time.Second),
}
// Periodic cleanup of expired sessions/codes/tokens — replaces the
// goroutine cmd/server/main.go started around db.CleanupExpired.
go func() {
t := time.NewTicker(1 * time.Hour)
defer t.Stop()
for range t.C {
n, err := postgres.CleanupExpired(context.Background(), db)
if err != nil {
logger.Error("cleanup expired", "err", err)
continue
}
if n > 0 {
logger.Info("cleanup expired", "rows_deleted", n)
}
}
}()
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
logger.Error("listen", "addr", grpcAddr, "err", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
iamv1.RegisterIAMServiceServer(grpcServer, grpcsrv.New(svc))
healthSrv := health.NewServer()
healthpb.RegisterHealthServer(grpcServer, healthSrv)
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
reflection.Register(grpcServer)
go func() {
logger.Info("iam service listening", "addr", grpcAddr)
if err := grpcServer.Serve(lis); err != nil {
logger.Error("serve", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("shutting down")
grpcServer.GracefulStop()
}

View File

@@ -0,0 +1,92 @@
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)
}

View File

@@ -0,0 +1,69 @@
package application
import (
"context"
"errors"
"net/url"
"strings"
"time"
"enkly/pkg/authsupport"
"enkly/pkg/eventbus"
"enkly/pkg/eventbus/events"
"enkly/services/iam/internal/domain"
)
// absURL builds BaseURL + path + ?key=value, replacing
// Handlers.absURL.
func (s *Service) absURL(path, key, value string) string {
return strings.TrimRight(s.BaseURL, "/") + path + "?" + url.Values{key: {value}}.Encode()
}
// ForgotPassword replaces Handlers.ForgotPassword. Always succeeds from
// the caller's point of view — whether or not the address is registered
// is never revealed.
func (s *Service) ForgotPassword(ctx context.Context, email, clientIP string) error {
if !throttle(ctx, s.ResendLimiter, "forgot:ip:"+clientIP, "forgot:email:"+email) {
return ctx.Err()
}
u, err := s.Users.GetByEmail(ctx, email)
if err != nil {
return nil // unknown address — say nothing
}
raw, hash, err := authsupport.NewToken()
if err != nil {
return err
}
expiresAt := time.Now().Add(resetTokenTTL)
if err := s.PasswordReset.Create(ctx, u.ID, hash, expiresAt); err != nil {
return err
}
s.Events.PublishBestEffort(ctx, eventbus.RoutingKeyPasswordResetRequested, events.PasswordResetRequested{
UserID: u.ID, Email: u.Email, ResetLink: s.absURL("/reset-password", "token", raw), ExpiresAt: expiresAt,
})
return nil
}
// CheckPasswordResetToken replaces the GET-time validity check in
// Handlers.ResetPasswordPage.
func (s *Service) CheckPasswordResetToken(ctx context.Context, token string) (bool, error) {
return s.PasswordReset.Valid(ctx, authsupport.HashToken(token))
}
// ResetPassword replaces Handlers.ResetPassword.
func (s *Service) ResetPassword(ctx context.Context, token, password string) (bool, error) {
hash, err := authsupport.HashPassword(password)
if err != nil {
return false, err
}
_, err = s.PasswordReset.ConsumeAndResetPassword(ctx, authsupport.HashToken(token), hash)
switch {
case err == nil:
return true, nil
case errors.Is(err, domain.ErrInvalidToken):
return false, nil
default:
return false, err
}
}

View File

@@ -0,0 +1,194 @@
// 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
}
}

View File

@@ -0,0 +1,48 @@
package application
import (
"context"
"time"
"enkly/pkg/authsupport"
)
// throttle replaces Handlers.throttle: sleeps for the largest
// progressive delay among keys (honoring ctx cancellation — a gRPC
// client that gives up shouldn't leave this goroutine sleeping
// uselessly), then records an attempt against every key regardless of
// the eventual outcome. Returns false if ctx was cancelled first, in
// which case the caller should abort without doing any work (the
// monolith's handler returned straight to net/http in that case; here
// the gRPC server maps it to context.Canceled).
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
var wait time.Duration
for _, k := range keys {
if d := l.Delay(k); d > wait {
wait = d
}
}
if !sleepCtx(ctx, wait) {
return false
}
for _, k := range keys {
l.RecordFailure(k)
}
return true
}
// sleepCtx sleeps for d or until ctx is done, reporting whether the full
// sleep elapsed (replaces handlers.sleepCtx).
func sleepCtx(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return ctx.Err() == nil
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-t.C:
return true
case <-ctx.Done():
return false
}
}

View File

@@ -0,0 +1,15 @@
package domain
import "errors"
// Sentinel errors the application layer compares against with
// errors.Is — the same three the monolith's internal/db package
// exposed (db.ErrNotFound, db.ErrEmailTaken, db.ErrInvalidToken), now
// owned by the domain layer instead of a database package, since
// "email already taken" and "code invalid or expired" are business
// rules, not SQL facts.
var (
ErrNotFound = errors.New("iam: not found")
ErrEmailTaken = errors.New("iam: email already registered")
ErrInvalidToken = errors.New("iam: invalid or expired token")
)

View File

@@ -0,0 +1,92 @@
// Package domain holds the IAM bounded context's entities and the
// repository interfaces its application layer depends on. Nothing here
// imports database/sql, net/http or gRPC — see ARCHITECTURE.md's note
// on dependency direction ("domain depends on nothing; everything else
// depends on domain").
package domain
import (
"context"
"time"
)
// User is an enkly account — identical in shape to the monolith's
// models.User, minus the json tags (this is an internal entity now, not
// a value serialized straight to a template).
type User struct {
ID int64
Email string
PasswordHash string
EmailVerified bool
CreatedAt time.Time
}
// Session is one logged-in browser, identified at rest by the SHA-256
// hash of its cookie token (see pkg/authsupport.HashToken) — the raw
// token exists only in the cookie itself and the gRPC response that
// minted it.
type Session struct {
UserID int64
TokenHash string
ExpiresAt time.Time
}
// VerificationCode is a 6-digit email-verification code, stored only as
// its hash plus an attempt counter (see internal/db's original comment
// on why a short code needs active guessing defense that a 256-bit
// token wouldn't).
type VerificationCode struct {
UserID int64
CodeHash string
Attempts int
ExpiresAt time.Time
UsedAt *time.Time
}
// PasswordResetToken is a "forgot password" link's token, stored only
// as its hash.
type PasswordResetToken struct {
UserID int64
TokenHash string
ExpiresAt time.Time
UsedAt *time.Time
}
// UserRepository persists and retrieves User rows. Implemented by
// internal/infrastructure/postgres.UserRepo.
type UserRepository interface {
Create(ctx context.Context, email, passwordHash string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
GetByID(ctx context.Context, id int64) (*User, error)
MarkEmailVerified(ctx context.Context, userID int64) error
UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error
}
// SessionRepository persists and retrieves Session rows.
type SessionRepository interface {
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
GetUserByTokenHash(ctx context.Context, tokenHash string) (*User, error)
Delete(ctx context.Context, tokenHash string) error
DeleteAllForUser(ctx context.Context, userID int64) error
}
// VerificationRepository persists and retrieves VerificationCode rows.
type VerificationRepository interface {
Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error
// VerifyAndConsume replaces db.VerifyEmailCode: checks the most
// recent unused, unexpired code for userID against codeHash,
// incrementing its attempt counter on a mismatch and marking it
// used (and the user verified) on a match. Returns domain.ErrInvalid
// when no code matches.
VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error
}
// PasswordResetRepository persists and retrieves PasswordResetToken rows.
type PasswordResetRepository interface {
Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error
Valid(ctx context.Context, tokenHash string) (bool, error)
// ConsumeAndResetPassword replaces db.ResetPassword: validates the
// token, updates the password hash, marks the token used, and
// deletes every session for the user, all in one transaction.
ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (userID int64, err error)
}

View File

@@ -0,0 +1,128 @@
// Package postgres implements IAM's domain repository interfaces against
// the iam database (see services/iam/migrations/0001_init.sql). This is
// a one-for-one transplant of the monolith's internal/db/users.go, split
// across files by aggregate instead of living in one package shared
// with every other table in the old schema.
package postgres
import (
"context"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/domain"
)
const userColumns = `id, email, password_hash, email_verified, created_at`
func scanUser(row interface{ Scan(...any) error }) (*domain.User, error) {
var u domain.User
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.EmailVerified, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrNotFound
}
return nil, err
}
return &u, nil
}
// UserRepo implements domain.UserRepository.
type UserRepo struct {
DB *pgkit.DB
}
func NewUserRepo(db *pgkit.DB) *UserRepo { return &UserRepo{DB: db} }
// Create inserts a new, unverified account. email must already be
// normalized (trimmed + lower-cased) by the application layer;
// passwordHash is the output of authsupport.HashPassword.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash string) (*domain.User, error) {
u, err := scanUser(r.DB.QueryRowContext(ctx,
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING `+userColumns,
email, passwordHash))
if err != nil {
if pgkit.IsUniqueViolation(err) {
return nil, domain.ErrEmailTaken
}
return nil, err
}
return u, nil
}
func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE email = $1`, email))
}
func (r *UserRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx,
`SELECT `+userColumns+` FROM users WHERE id = $1`, id))
}
func (r *UserRepo) MarkEmailVerified(ctx context.Context, userID int64) error {
_, err := r.DB.ExecContext(ctx, `UPDATE users SET email_verified = true WHERE id = $1`, userID)
return err
}
func (r *UserRepo) UpdatePassword(ctx context.Context, userID int64, newPasswordHash string) error {
_, err := r.DB.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash)
return err
}
// ---------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------
// SessionRepo implements domain.SessionRepository.
type SessionRepo struct {
DB *pgkit.DB
}
func NewSessionRepo(db *pgkit.DB) *SessionRepo { return &SessionRepo{DB: db} }
func (r *SessionRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
_, err := r.DB.ExecContext(ctx,
`INSERT INTO sessions (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, tokenHash, expiresAt)
return err
}
func (r *SessionRepo) GetUserByTokenHash(ctx context.Context, tokenHash string) (*domain.User, error) {
return scanUser(r.DB.QueryRowContext(ctx, `
SELECT u.id, u.email, u.password_hash, u.email_verified, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.expires_at > now()`, tokenHash))
}
func (r *SessionRepo) Delete(ctx context.Context, tokenHash string) error {
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash)
return err
}
func (r *SessionRepo) DeleteAllForUser(ctx context.Context, userID int64) error {
_, err := r.DB.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID)
return err
}
// CleanupExpired deletes expired sessions and used/expired verification
// and password-reset tokens, mirroring internal/db.DB.CleanupExpired —
// run periodically by cmd/server's cleanup loop (see main.go).
func CleanupExpired(ctx context.Context, db *pgkit.DB) (int64, error) {
var total int64
for _, q := range []string{
`DELETE FROM sessions WHERE expires_at < now()`,
`DELETE FROM email_verification_codes WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
`DELETE FROM password_reset_tokens WHERE expires_at < now() OR used_at < now() - interval '1 day'`,
} {
res, err := db.ExecContext(ctx, q)
if err != nil {
return total, err
}
n, _ := res.RowsAffected()
total += n
}
return total, nil
}

View File

@@ -0,0 +1,178 @@
package postgres
import (
"context"
"crypto/subtle"
"database/sql"
"errors"
"time"
"enkly/pkg/pgkit"
"enkly/services/iam/internal/domain"
)
// maxVerificationAttempts caps how many wrong codes a single
// verification row accepts before it's dead even to the correct code —
// see domain.VerificationRepository's doc comment.
const maxVerificationAttempts = 8
// VerificationRepo implements domain.VerificationRepository.
type VerificationRepo struct {
DB *pgkit.DB
}
func NewVerificationRepo(db *pgkit.DB) *VerificationRepo { return &VerificationRepo{DB: db} }
func (r *VerificationRepo) Create(ctx context.Context, userID int64, codeHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM email_verification_codes WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO email_verification_codes (user_id, code_hash, expires_at) VALUES ($1, $2, $3)`,
userID, codeHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *VerificationRepo) VerifyAndConsume(ctx context.Context, userID int64, codeHash string) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var verified bool
if err := tx.QueryRowContext(ctx, `SELECT email_verified FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&verified); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrNotFound
}
return err
}
if verified {
return nil // idempotent for an already-verified account
}
var (
rowID int64
storedHash string
attempts int
expiresAt time.Time
)
err = tx.QueryRowContext(ctx, `
SELECT id, code_hash, attempts, expires_at
FROM email_verification_codes
WHERE user_id = $1 AND used_at IS NULL
ORDER BY created_at DESC LIMIT 1
FOR UPDATE`, userID).Scan(&rowID, &storedHash, &attempts, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrInvalidToken
}
if err != nil {
return err
}
if attempts >= maxVerificationAttempts || time.Now().After(expiresAt) {
return domain.ErrInvalidToken
}
if subtle.ConstantTimeCompare([]byte(storedHash), []byte(codeHash)) != 1 {
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1`, rowID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return domain.ErrInvalidToken
}
if _, err := tx.ExecContext(ctx,
`UPDATE email_verification_codes SET used_at = now() WHERE id = $1`, rowID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET email_verified = true WHERE id = $1`, userID); err != nil {
return err
}
return tx.Commit()
}
// ---------------------------------------------------------------------
// Password reset tokens
// ---------------------------------------------------------------------
// PasswordResetRepo implements domain.PasswordResetRepository.
type PasswordResetRepo struct {
DB *pgkit.DB
}
func NewPasswordResetRepo(db *pgkit.DB) *PasswordResetRepo { return &PasswordResetRepo{DB: db} }
func (r *PasswordResetRepo) Create(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL`, userID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
userID, tokenHash, expiresAt); err != nil {
return err
}
return tx.Commit()
}
func (r *PasswordResetRepo) Valid(ctx context.Context, tokenHash string) (bool, error) {
var ok bool
err := r.DB.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
)`, tokenHash).Scan(&ok)
return ok, err
}
func (r *PasswordResetRepo) ConsumeAndResetPassword(ctx context.Context, tokenHash, newPasswordHash string) (int64, error) {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var userID int64
err = tx.QueryRowContext(ctx, `
UPDATE password_reset_tokens SET used_at = now()
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()
RETURNING user_id`, tokenHash).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, domain.ErrInvalidToken
}
if err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx,
`UPDATE users SET password_hash = $2 WHERE id = $1`, userID, newPasswordHash); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return userID, nil
}

View File

@@ -0,0 +1,128 @@
// Package grpc adapts application.Service onto the generated
// iamv1.IAMServiceServer interface — the only layer in this service
// that imports google.golang.org/grpc or the generated gen/iam/v1
// package. Keeping that import confined to this one file is what lets
// internal/application stay transport-agnostic and unit-testable
// without spinning up a gRPC server.
package grpc
import (
"context"
"errors"
"google.golang.org/protobuf/types/known/timestamppb"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/grpcerr"
"enkly/services/iam/internal/application"
"enkly/services/iam/internal/domain"
)
type Server struct {
iamv1.UnimplementedIAMServiceServer
Svc *application.Service
}
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
func toProtoUser(u *domain.User) *iamv1.User {
if u == nil {
return nil
}
return &iamv1.User{
Id: u.ID,
Email: u.Email,
EmailVerified: u.EmailVerified,
CreatedAt: timestamppb.New(u.CreatedAt),
}
}
func (s *Server) SignUp(ctx context.Context, req *iamv1.SignUpRequest) (*iamv1.SignUpResponse, error) {
res, err := s.Svc.SignUp(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
if err != nil {
return nil, err
}
return &iamv1.SignUpResponse{
CheckInbox: res.CheckInbox,
Email: res.Email,
MailConfigured: res.MailConfigured,
}, nil
}
func (s *Server) ResendVerification(ctx context.Context, req *iamv1.ResendVerificationRequest) (*iamv1.ResendVerificationResponse, error) {
if err := s.Svc.ResendVerification(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
return nil, err
}
return &iamv1.ResendVerificationResponse{MailConfigured: s.Svc.MailConfigured}, nil
}
func (s *Server) VerifyEmail(ctx context.Context, req *iamv1.VerifyEmailRequest) (*iamv1.VerifyEmailResponse, error) {
ok, err := s.Svc.VerifyEmail(ctx, req.GetEmail(), req.GetCode(), req.GetClientIp())
if err != nil {
return nil, err
}
return &iamv1.VerifyEmailResponse{Verified: ok}, nil
}
func (s *Server) Login(ctx context.Context, req *iamv1.LoginRequest) (*iamv1.LoginResponse, error) {
res, err := s.Svc.Login(ctx, req.GetEmail(), req.GetPassword(), req.GetClientIp())
if err != nil {
return nil, err
}
resp := &iamv1.LoginResponse{Ok: res.OK, NeedsVerification: res.NeedsVerification}
if res.OK {
resp.SessionToken = res.SessionToken
resp.SessionExpiresAt = timestamppb.New(res.SessionExpiresAt)
resp.User = toProtoUser(res.User)
}
return resp, nil
}
func (s *Server) Logout(ctx context.Context, req *iamv1.LogoutRequest) (*iamv1.LogoutResponse, error) {
if err := s.Svc.Logout(ctx, req.GetSessionToken()); err != nil {
return nil, err
}
return &iamv1.LogoutResponse{}, nil
}
func (s *Server) ValidateSession(ctx context.Context, req *iamv1.ValidateSessionRequest) (*iamv1.ValidateSessionResponse, error) {
u, ok := s.Svc.ValidateSession(ctx, req.GetSessionToken())
if !ok {
return &iamv1.ValidateSessionResponse{Valid: false}, nil
}
return &iamv1.ValidateSessionResponse{Valid: true, User: toProtoUser(u)}, nil
}
func (s *Server) ForgotPassword(ctx context.Context, req *iamv1.ForgotPasswordRequest) (*iamv1.ForgotPasswordResponse, error) {
if err := s.Svc.ForgotPassword(ctx, req.GetEmail(), req.GetClientIp()); err != nil {
return nil, err
}
return &iamv1.ForgotPasswordResponse{}, nil
}
func (s *Server) CheckPasswordResetToken(ctx context.Context, req *iamv1.CheckPasswordResetTokenRequest) (*iamv1.CheckPasswordResetTokenResponse, error) {
ok, err := s.Svc.CheckPasswordResetToken(ctx, req.GetToken())
if err != nil {
return nil, err
}
return &iamv1.CheckPasswordResetTokenResponse{Valid: ok}, nil
}
func (s *Server) ResetPassword(ctx context.Context, req *iamv1.ResetPasswordRequest) (*iamv1.ResetPasswordResponse, error) {
ok, err := s.Svc.ResetPassword(ctx, req.GetToken(), req.GetPassword())
if err != nil {
return nil, err
}
return &iamv1.ResetPasswordResponse{Ok: ok}, nil
}
func (s *Server) GetUser(ctx context.Context, req *iamv1.GetUserRequest) (*iamv1.User, error) {
u, err := s.Svc.GetUser(ctx, req.GetUserId())
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return nil, grpcerr.NotFound("user")
}
return nil, err
}
return toProtoUser(u), nil
}

View File

@@ -0,0 +1,67 @@
-- =====================================================================
-- iam service — 0001_init.sql
-- Database-per-service: this is the ONLY schema that owns `users`,
-- `sessions`, `email_verification_codes` and `password_reset_tokens`.
-- No other service's migration may create a table with these names or
-- reach into this database. Transplanted verbatim from the monolith's
-- schema.sql section of the same name; see ARCHITECTURE.md's
-- "Database-per-service" note for why the FKs below stay *within* this
-- file only — every other service refers to a user by a bare
-- `user_id BIGINT`, not a foreign key, since Postgres can't enforce a
-- constraint across two separate databases.
-- =====================================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email verification codes: a short numeric code (not a link) mailed to
-- the address at signup. `attempts` caps how many wrong codes a row
-- will accept before it's dead (see internal/application.VerifyEmail);
-- code_hash is deliberately NOT unique — two different users' current
-- codes can coincidentally collide, which is expected and harmless
-- since every lookup is scoped by user_id, never by code_hash alone.
CREATE TABLE IF NOT EXISTS email_verification_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS email_verification_codes_user_id_idx
ON email_verification_codes (user_id);
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions (user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions (expires_at);
-- "Forgot password" tokens — same hash-only-at-rest pattern as
-- email_verification_codes. A successful reset also deletes every row
-- in `sessions` for that user, so a stolen session cookie doesn't
-- survive a reset (see application.PasswordResetRepo.
-- ConsumeAndResetPassword).
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens (user_id);