From 2d004cd251d18d58e4daf374ee01293fcfe66e8e Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Fri, 15 May 2026 20:59:34 +0200 Subject: [PATCH] feat(08-01): add social identity schema foundation --- backend/internal/auth/types.go | 3 +- .../internal/db/queries/user_identities.sql | 41 ++++++++++ backend/internal/db/queries/users.sql | 28 +++++++ backend/internal/web/handlers_auth.go | 17 +++- backend/internal/web/handlers_auth_test.go | 78 ++++++++++++++++++- .../web/social_identity_migration_test.go | 34 ++++++++ .../web/social_identity_schema_test.go | 48 ++++++++++++ backend/migrations/0006_social_identities.sql | 36 +++++++++ 8 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 backend/internal/db/queries/user_identities.sql create mode 100644 backend/internal/web/social_identity_migration_test.go create mode 100644 backend/internal/web/social_identity_schema_test.go create mode 100644 backend/migrations/0006_social_identities.sql diff --git a/backend/internal/auth/types.go b/backend/internal/auth/types.go index fb18410..3ef8675 100644 --- a/backend/internal/auth/types.go +++ b/backend/internal/auth/types.go @@ -5,6 +5,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) // User is the domain representation of a registered user. @@ -12,7 +13,7 @@ import ( type User struct { ID uuid.UUID Email string - PasswordHash string + PasswordHash pgtype.Text CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/db/queries/user_identities.sql b/backend/internal/db/queries/user_identities.sql new file mode 100644 index 0000000..90748d1 --- /dev/null +++ b/backend/internal/db/queries/user_identities.sql @@ -0,0 +1,41 @@ +-- name: GetUserIdentityByProviderSubject :one +SELECT id, user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, created_at, updated_at, last_login_at +FROM user_identities +WHERE provider = $1 AND provider_subject = $2; + +-- name: ListUserIdentitiesByUser :many +SELECT id, user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, created_at, updated_at, last_login_at +FROM user_identities +WHERE user_id = $1 +ORDER BY provider; + +-- name: InsertUserIdentity :one +INSERT INTO user_identities ( + user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, last_login_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, now()) +RETURNING id, user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, created_at, updated_at, last_login_at; + +-- name: UpdateUserIdentityLogin :one +UPDATE user_identities +SET email = $3, + email_verified = $4, + display_name = COALESCE($5, display_name), + avatar_url = COALESCE($6, avatar_url), + last_login_at = now(), + updated_at = now() +WHERE provider = $1 AND provider_subject = $2 +RETURNING id, user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, created_at, updated_at, last_login_at; + +-- name: UpdateUserIdentityEmail :one +UPDATE user_identities +SET email = $3, + updated_at = now() +WHERE provider = $1 AND provider_subject = $2 +RETURNING id, user_id, provider, provider_subject, email, email_verified, + display_name, avatar_url, created_at, updated_at, last_login_at; diff --git a/backend/internal/db/queries/users.sql b/backend/internal/db/queries/users.sql index 3c28de0..9b114bc 100644 --- a/backend/internal/db/queries/users.sql +++ b/backend/internal/db/queries/users.sql @@ -3,7 +3,35 @@ INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, password_hash, created_at, updated_at; +-- name: InsertSocialUser :one +INSERT INTO users (email, password_hash) +VALUES ($1, NULL) +RETURNING id, email, password_hash, created_at, updated_at; + -- name: GetUserByEmail :one SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = $1; + +-- name: GetUserByID :one +SELECT id, email, password_hash, created_at, updated_at +FROM users +WHERE id = $1; + +-- name: IsSocialOnlyUserByEmail :one +SELECT EXISTS ( + SELECT 1 + FROM users + WHERE email = $1 + AND password_hash IS NULL +)::boolean; + +-- name: UpdateUserEmailIfAvailable :one +UPDATE users AS u +SET email = $2, + updated_at = now() +WHERE u.id = $1 + AND NOT EXISTS ( + SELECT 1 FROM users AS u2 WHERE u2.email = $2 AND u2.id <> u.id + ) +RETURNING u.id, u.email, u.password_hash, u.created_at, u.updated_at; diff --git a/backend/internal/web/handlers_auth.go b/backend/internal/web/handlers_auth.go index 083bd0d..269893d 100644 --- a/backend/internal/web/handlers_auth.go +++ b/backend/internal/web/handlers_auth.go @@ -15,6 +15,7 @@ import ( "github.com/gorilla/csrf" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" ) // AuthDeps holds the dependencies shared by all auth handlers. @@ -106,11 +107,16 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc { // 6. Insert user row. user, err := deps.Queries.InsertUser(ctx, sqlc.InsertUserParams{ Email: normalized, - PasswordHash: hash, + PasswordHash: pgtype.Text{String: hash, Valid: true}, }) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { + if socialOnly, socialErr := deps.Queries.IsSocialOnlyUserByEmail(ctx, normalized); socialErr == nil && socialOnly { + errs.Email = "An account already exists for this email. Sign in with your provider." + renderSignupError(w, r, templates.SignupForm{Email: email}, errs, http.StatusUnprocessableEntity) + return + } // Unique-constraint violation on email (T-2-19). // Specific error message is acceptable on signup per CONTEXT.md specifics. errs.Email = "That email is already in use." @@ -238,7 +244,14 @@ func LoginPostHandler(deps AuthDeps) http.HandlerFunc { } // 7. Verify password (argon2id constant-time compare, T-2-13). - ok, err := auth.Verify(user.PasswordHash, password) + if !user.PasswordHash.Valid { + // Social-only accounts have no local password. Keep the same generic + // credential failure used for unknown email and wrong password. + errs.General = errInvalidCreds + renderLoginError(w, r, templates.LoginForm{Email: email}, errs, http.StatusUnauthorized) + return + } + ok, err := auth.Verify(user.PasswordHash.String, password) if err != nil || !ok { // D-20: uses the same constant as unknown-email case (single source of truth). errs.General = errInvalidCreds diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 46b40a0..0bd5e0a 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -16,6 +16,8 @@ import ( "backend/internal/auth" "backend/internal/db/sqlc" + + "github.com/jackc/pgx/v5/pgtype" ) // testCSRFKey is a fixed 32-byte key used by all test routers. It is NOT a @@ -94,7 +96,7 @@ func preInsertUser(t *testing.T, ctx context.Context, q *sqlc.Queries, email, pa } user, err := q.InsertUser(ctx, sqlc.InsertUserParams{ Email: strings.ToLower(email), - PasswordHash: hash, + PasswordHash: pgtype.Text{String: hash, Valid: true}, }) if err != nil { t.Fatalf("preInsertUser: InsertUser: %v", err) @@ -102,6 +104,15 @@ func preInsertUser(t *testing.T, ctx context.Context, q *sqlc.Queries, email, pa return user } +func preInsertSocialOnlyUser(t *testing.T, ctx context.Context, q *sqlc.Queries, email string) sqlc.User { + t.Helper() + user, err := q.InsertSocialUser(ctx, strings.ToLower(email)) + if err != nil { + t.Fatalf("preInsertSocialOnlyUser: InsertSocialUser: %v", err) + } + return user +} + // hashCookieValue decodes a base64url cookie value and returns the hex-encoded // SHA-256 hash — this is the session ID stored in the DB (D-05). func hashCookieValue(t *testing.T, cookieValue string) string { @@ -173,8 +184,8 @@ func TestSignup_Success(t *testing.T) { if err != nil { t.Fatalf("GetUserByEmail: %v", err) } - if !strings.HasPrefix(user.PasswordHash, "$argon2id$") { - t.Errorf("password_hash = %q; want $argon2id$ prefix", user.PasswordHash) + if !user.PasswordHash.Valid || !strings.HasPrefix(user.PasswordHash.String, "$argon2id$") { + t.Errorf("password_hash = %#v; want valid $argon2id$ prefix", user.PasswordHash) } // Session row must exist for the user. @@ -188,6 +199,35 @@ func TestSignup_Success(t *testing.T) { } } +func TestSignup_SocialOnlyExistingUserShowsProviderMessage(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + preInsertSocialOnlyUser(t, ctx, q, "social-only@example.com") + + csrfToken, csrfCookies := getCSRFToken(t, router, "/signup", nil) + form := url.Values{"email": {"social-only@example.com"}, "password": {"correct-horse-12"}, "_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "An account already exists for this email. Sign in with your provider.") { + t.Fatalf("body missing social-only signup conflict copy; got: %s", rec.Body.String()) + } +} + func TestSignup_Success_HTMX(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() @@ -578,6 +618,38 @@ func TestLogin_UnknownEmail(t *testing.T) { } } +func TestLogin_SocialOnlyUserGetsGenericInvalidCredentials(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + router := newTestRouter(q, store) + preInsertSocialOnlyUser(t, ctx, q, "social-login@example.com") + + csrfToken, csrfCookies := getCSRFToken(t, router, "/login", nil) + form := url.Values{"email": {"social-login@example.com"}, "password": {"correct-horse-12chars"}, "_csrf": {csrfToken}} + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401", rec.Code) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("Invalid email or password")) { + t.Fatalf("body must contain generic invalid credentials; got: %s", rec.Body.String()) + } + if c := getSessionCookie(rec); c != nil { + t.Fatal("session cookie must NOT be set for social-only password login") + } +} + func TestLogin_ValidationError_BadEmail(t *testing.T) { pool, cleanup := setupTestDB(t) defer cleanup() diff --git a/backend/internal/web/social_identity_migration_test.go b/backend/internal/web/social_identity_migration_test.go new file mode 100644 index 0000000..425de1c --- /dev/null +++ b/backend/internal/web/social_identity_migration_test.go @@ -0,0 +1,34 @@ +package web + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSocialIdentityMigrationDefinesNullablePasswordAndProviderIdentity(t *testing.T) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + path := filepath.Join(filepath.Dir(filename), "..", "..", "migrations", "0006_social_identities.sql") + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read migration: %v", err) + } + + text := string(body) + required := []string{ + "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL", + "CREATE TABLE user_identities", + "provider IN ('google', 'apple')", + "UNIQUE (provider, provider_subject)", + } + for _, needle := range required { + if !strings.Contains(text, needle) { + t.Fatalf("migration missing %q", needle) + } + } +} diff --git a/backend/internal/web/social_identity_schema_test.go b/backend/internal/web/social_identity_schema_test.go new file mode 100644 index 0000000..4cd69af --- /dev/null +++ b/backend/internal/web/social_identity_schema_test.go @@ -0,0 +1,48 @@ +package web + +import ( + "context" + "testing" +) + +func TestSocialIdentitySchemaSupportsNullablePasswordAndProviderIdentity(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + var userID string + if err := pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash) + VALUES ('social-schema@example.com', NULL) + RETURNING id::text + `).Scan(&userID); err != nil { + t.Fatalf("insert social-only user with NULL password_hash: %v", err) + } + + if _, err := pool.Exec(ctx, ` + INSERT INTO user_identities ( + user_id, provider, provider_subject, email, email_verified, display_name, avatar_url, last_login_at + ) + VALUES ( + $1, 'google', 'google-sub-1', 'social-schema@example.com', true, + 'Social Schema', 'https://example.com/avatar.png', now() + ) + `, userID); err != nil { + t.Fatalf("insert provider identity: %v", err) + } + + var count int + if err := pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM user_identities + WHERE provider = 'google' + AND provider_subject = 'google-sub-1' + AND email_verified = true + `).Scan(&count); err != nil { + t.Fatalf("count provider identity: %v", err) + } + if count != 1 { + t.Fatalf("identity count = %d; want 1", count) + } +} diff --git a/backend/migrations/0006_social_identities.sql b/backend/migrations/0006_social_identities.sql new file mode 100644 index 0000000..9d724f7 --- /dev/null +++ b/backend/migrations/0006_social_identities.sql @@ -0,0 +1,36 @@ +-- migrations/0006_social_identities.sql +-- Phase 8: Social sign-in identities. + +-- +goose Up + +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; + +CREATE TABLE user_identities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider text NOT NULL CHECK (provider IN ('google', 'apple')), + provider_subject text NOT NULL, + email citext NOT NULL, + email_verified boolean NOT NULL DEFAULT true, + display_name text, + avatar_url text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz, + UNIQUE (provider, provider_subject) +); + +CREATE INDEX user_identities_user_id_idx ON user_identities(user_id); + +-- +goose Down + +DROP TABLE IF EXISTS user_identities; + +-- Social-only rows cannot be made password-login capable during downgrade. +-- This sentinel is deliberately not a valid PHC hash; email/password login +-- treats invalid hashes as invalid credentials. +UPDATE users +SET password_hash = '__social_only_downgrade_no_password__' +WHERE password_hash IS NULL; + +ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL;