This commit is contained in:
28
services/iam/Dockerfile
Normal file
28
services/iam/Dockerfile
Normal 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"]
|
||||
132
services/iam/cmd/server/main.go
Normal file
132
services/iam/cmd/server/main.go
Normal 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()
|
||||
}
|
||||
92
services/iam/internal/application/login.go
Normal file
92
services/iam/internal/application/login.go
Normal 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)
|
||||
}
|
||||
69
services/iam/internal/application/password_reset.go
Normal file
69
services/iam/internal/application/password_reset.go
Normal 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
|
||||
}
|
||||
}
|
||||
194
services/iam/internal/application/signup.go
Normal file
194
services/iam/internal/application/signup.go
Normal 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
|
||||
}
|
||||
}
|
||||
48
services/iam/internal/application/throttle.go
Normal file
48
services/iam/internal/application/throttle.go
Normal 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
|
||||
}
|
||||
}
|
||||
15
services/iam/internal/domain/errors.go
Normal file
15
services/iam/internal/domain/errors.go
Normal 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")
|
||||
)
|
||||
92
services/iam/internal/domain/user.go
Normal file
92
services/iam/internal/domain/user.go
Normal 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)
|
||||
}
|
||||
128
services/iam/internal/infrastructure/postgres/user_repo.go
Normal file
128
services/iam/internal/infrastructure/postgres/user_repo.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
128
services/iam/internal/interfaces/grpc/server.go
Normal file
128
services/iam/internal/interfaces/grpc/server.go
Normal 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
|
||||
}
|
||||
67
services/iam/migrations/0001_init.sql
Normal file
67
services/iam/migrations/0001_init.sql
Normal 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);
|
||||
Reference in New Issue
Block a user