diff --git a/.env.example b/.env.example index 866eb86..76a6a07 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue # Release builds: com.myhoneydue.honeyDue # Debug builds: com.myhoneydue.honeyDue.dev APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev -APPLE_TEAM_ID=V3PF3M6B6U +APPLE_TEAM_ID=X86BR9WTLD # APNs Settings (iOS Push Notifications) # Direct APNs integration - no external push server needed diff --git a/deploy-k3s-dev/scripts/00-init.sh b/deploy-k3s-dev/scripts/00-init.sh index 122ddaf..2ebabc1 100755 --- a/deploy-k3s-dev/scripts/00-init.sh +++ b/deploy-k3s-dev/scripts/00-init.sh @@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)" EMAIL_USER="treytartt@fastmail.com" APNS_KEY_ID="9R5Q7ZX874" -APNS_TEAM_ID="V3PF3M6B6U" +APNS_TEAM_ID="X86BR9WTLD" log "" log "Pre-filled from existing dev server:" diff --git a/deploy-k3s/manifests/kratos/configmap.yaml b/deploy-k3s/manifests/kratos/configmap.yaml index c70246e..3a2bb80 100644 --- a/deploy-k3s/manifests/kratos/configmap.yaml +++ b/deploy-k3s/manifests/kratos/configmap.yaml @@ -65,7 +65,18 @@ data: # capability — see operator notes in README.md §5). - id: apple provider: apple + # Production bundle id. Apple issues id_tokens with + # `aud` = the requesting app's bundle id, so this is the + # primary audience Kratos verifies against. client_id: com.myhoneydue.honeyDue + # Debug builds out of Xcode use a `.dev` bundle id (see + # iosApp/honeyDue.xcodeproj — Debug config). Their id_tokens + # therefore have `aud: com.myhoneydue.honeyDue.dev`, which + # the primary client_id check rejects. Whitelist the dev + # audience so Apple Sign In works from a non-Release Xcode + # build without per-build Kratos reconfiguration. + additional_id_token_audiences: + - com.myhoneydue.honeyDue.dev apple_team_id: X86BR9WTLD apple_private_key_id: HQD3NCF99C mapper_url: file:///etc/kratos/oidc.apple.jsonnet @@ -195,6 +206,10 @@ data: // Maps Apple OIDC claims onto the honeyDue identity schema. Apple only // returns the name on the very first authorization and not in the ID // token claims, so only email is mapped here. + // + // NOTE: we intentionally do NOT carry Apple's email_verified across via + // verified_addresses. Product decision: every account-creation flow — + // including Sign in with Apple — must complete an email verification step. local claims = std.extVar('claims'); { identity: { diff --git a/internal/handlers/auth_handler.go b/internal/handlers/auth_handler.go index c673453..d86a73c 100644 --- a/internal/handlers/auth_handler.go +++ b/internal/handlers/auth_handler.go @@ -49,6 +49,28 @@ func noStore(c echo.Context) { c.Response().Header().Set("Cache-Control", "no-store") } +// Register handles POST /api/auth/register/ — creates a new password account. +// +// The identity is admin-created in Kratos with an unverified email and no +// auto-sent code (see services.AuthService.Register). The client logs in right +// after to get a session, then completes email verification. Returns 201 with +// no token; 409 if the email is taken; 400 on a weak password. +func (h *AuthHandler) Register(c echo.Context) error { + var req requests.RegisterRequest + if err := c.Bind(&req); err != nil { + return apperrors.BadRequest("error.invalid_request_body") + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err)) + } + if err := h.authService.Register(c.Request().Context(), &req); err != nil { + return err + } + return c.JSON(http.StatusCreated, map[string]string{ + "message": "Account created. Please verify your email.", + }) +} + // CurrentUser handles GET /api/auth/me/ func (h *AuthHandler) CurrentUser(c echo.Context) error { noStore(c) @@ -63,6 +85,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error { return err } + // user_profile.verified is a one-time mirror set at provision time + // (see middleware/kratos_auth.go::provision). Kratos remains the source + // of truth for email-verification state — it can flip from false → true + // the instant the user completes the verification flow, and nothing + // updates the local column. Override the response with the live value + // the Kratos auth middleware already stashed in context so /auth/me + // reflects current reality. Also opportunistically sync the DB mirror + // (best-effort, ignore error) so background queries that read the + // column see the same answer. + if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok { + mirrorStale := response.Profile != nil && response.Profile.Verified != verified + if response.Profile != nil { + response.Profile.Verified = verified + } + if verified && mirrorStale { + _ = h.authService.MarkUserVerified(c.Request().Context(), user.ID) + } + } + return c.JSON(http.StatusOK, response) } diff --git a/internal/kratos/client.go b/internal/kratos/client.go index 30d383f..9b10d25 100644 --- a/internal/kratos/client.go +++ b/internal/kratos/client.go @@ -5,10 +5,12 @@ package kratos import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -19,6 +21,16 @@ import ( // or inactive — the caller should respond 401. var ErrUnauthorized = errors.New("kratos: session invalid or inactive") +// ErrIdentityExists is returned by CreateIdentity when an identity with the +// same credential identifier (email) already exists — caller should respond 409. +var ErrIdentityExists = errors.New("kratos: identity already exists") + +// ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the +// password against its policy (too short, breached, etc.) — caller responds 400. +type ErrInvalidCredentials struct{ Reason string } + +func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason } + // Client talks to the Ory Kratos public and admin APIs. type Client struct { publicURL string @@ -112,6 +124,94 @@ func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Sess return &s, nil } +// CreateIdentity admin-creates a Kratos identity with a password credential and +// an UNVERIFIED email, returning the created identity (with its UUID). +// +// Why admin-create instead of the self-service registration flow: self-service +// registration always auto-emails a verification code to a flow whose id Kratos +// never returns to the client (verified 2026-06-03 — true with and without the +// session after-hook), so the client can never submit the user's code to the +// right flow. Admin creation runs no registration hooks and sends no email, so +// honeyDue drives verification explicitly afterward: the client starts its own +// verification flow (the single code) and the user enters it. The new identity +// is fully usable immediately — the client logs in right after to get a session +// (Kratos permits login for unverified identities; app access is gated on the +// verified flag, not on Kratos login). +// +// password is sent in cleartext over the in-cluster admin API (never exposed +// publicly); Kratos hashes it with the configured bcrypt cost and applies the +// password policy, returning 400 on a weak/breached password and 409 on a +// duplicate email. +func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) { + if c.adminURL == "" { + return nil, errors.New("kratos: admin URL not configured") + } + payload := map[string]any{ + "schema_id": "honeydue", + "traits": map[string]any{ + "email": email, + "name": map[string]any{"first": firstName, "last": lastName}, + }, + "credentials": map[string]any{ + "password": map[string]any{ + "config": map[string]any{"password": password}, + }, + }, + } + buf, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("kratos create identity: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusCreated, http.StatusOK: + var id Identity + if err := json.NewDecoder(resp.Body).Decode(&id); err != nil { + return nil, fmt.Errorf("kratos create identity: decode: %w", err) + } + return &id, nil + case http.StatusConflict: + return nil, ErrIdentityExists + case http.StatusBadRequest: + body, _ := io.ReadAll(resp.Body) + return nil, &ErrInvalidCredentials{Reason: kratosReason(body)} + default: + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body)) + } +} + +// kratosReason pulls a human-readable reason out of a Kratos error body +// ({"error":{"reason":"...","message":"..."}}), falling back to the raw body. +func kratosReason(body []byte) string { + var env struct { + Error struct { + Reason string `json:"reason"` + Message string `json:"message"` + } `json:"error"` + } + if json.Unmarshal(body, &env) == nil { + if env.Error.Reason != "" { + return env.Error.Reason + } + if env.Error.Message != "" { + return env.Error.Message + } + } + return strings.TrimSpace(string(body)) +} + // DeleteIdentity permanently removes a Kratos identity by its UUID via the // admin API (DELETE /admin/identities/{id}). A 404 is treated as success — // the identity is already gone, which is the desired end state, so the call diff --git a/internal/middleware/kratos_auth.go b/internal/middleware/kratos_auth.go index 53d3ca1..27f3509 100644 --- a/internal/middleware/kratos_auth.go +++ b/internal/middleware/kratos_auth.go @@ -24,8 +24,10 @@ const ( AuthUserKey = "auth_user" // AuthTokenKey stores the raw session credential in the echo context. AuthTokenKey = "auth_token" - // authVerifiedKey stores the Kratos email-verified flag in the context. - authVerifiedKey = "auth_email_verified" + // AuthVerifiedKey stores the Kratos email-verified flag in the context. + // Handlers can read this to override stale local mirrors like + // user_profile.verified with the live Kratos truth. + AuthVerifiedKey = "auth_email_verified" // UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache. UserCacheTTL = 5 * time.Minute @@ -76,7 +78,7 @@ func (m *KratosAuth) Authenticate() echo.MiddlewareFunc { } c.Set(AuthUserKey, user) c.Set(AuthTokenKey, cred) - c.Set(authVerifiedKey, verified) + c.Set(AuthVerifiedKey, verified) return next(c) } } @@ -90,7 +92,7 @@ func (m *KratosAuth) OptionalAuthenticate() echo.MiddlewareFunc { if user, verified, cred, err := m.resolve(c); err == nil { c.Set(AuthUserKey, user) c.Set(AuthTokenKey, cred) - c.Set(authVerifiedKey, verified) + c.Set(AuthVerifiedKey, verified) } return next(c) } @@ -105,7 +107,7 @@ func (m *KratosAuth) RequireVerified() echo.MiddlewareFunc { if GetAuthUser(c) == nil { return apperrors.Unauthorized("error.not_authenticated") } - if verified, _ := c.Get(authVerifiedKey).(bool); !verified { + if verified, _ := c.Get(AuthVerifiedKey).(bool); !verified { return apperrors.Forbidden("error.email_not_verified") } return next(c) diff --git a/internal/repositories/user_repo.go b/internal/repositories/user_repo.go index 8195b83..84846b7 100644 --- a/internal/repositories/user_repo.go +++ b/internal/repositories/user_repo.go @@ -66,6 +66,16 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) { return &user, nil } +// MarkVerified sets user_userprofile.verified=true for the given user. +// Syncs the local mirror with Kratos's verifiable_addresses.verified after +// a successful verification flow. Idempotent — re-flipping an already-true +// row is a guarded no-op write. +func (r *UserRepository) MarkVerified(userID uint) error { + return r.db.Model(&models.UserProfile{}). + Where("user_id = ? AND verified = ?", userID, false). + Update("verified", true).Error +} + // FindByIDWithProfile finds a user by ID with profile preloaded func (r *UserRepository) FindByIDWithProfile(id uint) (*models.User, error) { var user models.User diff --git a/internal/router/router.go b/internal/router/router.go index fb1033c..235b97e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -328,8 +328,13 @@ func SetupRouter(deps *Dependencies) *echo.Echo { // API group api := e.Group("/api") { - // Session lifecycle (login, register, logout, password reset) is - // handled by Ory Kratos — no public auth routes in this service. + // Session lifecycle (login, logout, password reset, email verification) + // is handled directly by Ory Kratos from the client. Registration is the + // exception: it goes through this endpoint, which admin-creates the + // Kratos identity so no verification email is auto-sent to an + // unreachable flow (see handlers.AuthHandler.Register). Public — the + // caller has no session yet. + api.POST("/auth/register/", authHandler.Register) // Public data routes (no auth required) setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler) diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 9d0ec0a..58a0932 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -48,6 +48,33 @@ func (s *AuthService) SetKratosClient(k *kratos.Client) { s.kratos = k } +// Register admin-creates a Kratos identity for a new password account with an +// unverified email and no auto-sent verification email (see +// kratos.Client.CreateIdentity for the rationale). The client logs in +// immediately afterward to obtain a session, then drives email verification +// explicitly. No local auth_user row is created here — the KratosAuth +// middleware lazily provisions it on the first authenticated request. +func (s *AuthService) Register(ctx context.Context, req *requests.RegisterRequest) error { + if s.kratos == nil { + return apperrors.Internal(errors.New("identity service unavailable")) + } + _, err := s.kratos.CreateIdentity(ctx, req.Email, req.FirstName, req.LastName, req.Password) + if err == nil { + return nil + } + switch { + case errors.Is(err, kratos.ErrIdentityExists): + return apperrors.Conflict("error.email_already_taken") + default: + var invalid *kratos.ErrInvalidCredentials + if errors.As(err, &invalid) { + return apperrors.BadRequest("error.password_complexity") + } + log.Error().Err(err).Msg("Kratos identity creation failed") + return apperrors.Internal(err) + } +} + // GetCurrentUser returns the current authenticated user with profile. func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*responses.CurrentUserResponse, error) { user, err := s.userRepo.WithContext(ctx).FindByIDWithProfile(userID) @@ -65,6 +92,20 @@ func (s *AuthService) GetCurrentUser(ctx context.Context, userID uint) (*respons return &response, nil } +// MarkUserVerified flips the local user_profile.verified and auth_user.verified +// mirrors to true. Called opportunistically from CurrentUser when the live +// Kratos email-verified flag is true but the local mirror is stale (the user +// completed verification after the local row was provisioned). Best-effort: +// the canonical truth lives in Kratos's verifiable_addresses, so a failure +// here only means a brief stale read on background-only queries. +func (s *AuthService) MarkUserVerified(ctx context.Context, userID uint) error { + if err := s.userRepo.WithContext(ctx).MarkVerified(userID); err != nil { + log.Warn().Err(err).Uint("user_id", userID).Msg("Failed to mirror verified flag from Kratos") + return err + } + return nil +} + // UpdateProfile updates a user's profile fields. func (s *AuthService) UpdateProfile(ctx context.Context, userID uint, req *requests.UpdateProfileRequest) (*responses.CurrentUserResponse, error) { user, err := s.userRepo.WithContext(ctx).FindByID(userID)