feat(08-01): add social identity schema foundation

This commit is contained in:
Arthur Belleville 2026-05-15 20:59:34 +02:00
parent 2f4a4f9ebb
commit 2d004cd251
No known key found for this signature in database
8 changed files with 279 additions and 6 deletions

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
) )
// User is the domain representation of a registered user. // User is the domain representation of a registered user.
@ -12,7 +13,7 @@ import (
type User struct { type User struct {
ID uuid.UUID ID uuid.UUID
Email string Email string
PasswordHash string PasswordHash pgtype.Text
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }

View file

@ -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;

View file

@ -3,7 +3,35 @@ INSERT INTO users (email, password_hash)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, email, password_hash, created_at, updated_at; 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 -- name: GetUserByEmail :one
SELECT id, email, password_hash, created_at, updated_at SELECT id, email, password_hash, created_at, updated_at
FROM users FROM users
WHERE email = $1; 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;

View file

@ -15,6 +15,7 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
) )
// AuthDeps holds the dependencies shared by all auth handlers. // AuthDeps holds the dependencies shared by all auth handlers.
@ -106,11 +107,16 @@ func SignupPostHandler(deps AuthDeps) http.HandlerFunc {
// 6. Insert user row. // 6. Insert user row.
user, err := deps.Queries.InsertUser(ctx, sqlc.InsertUserParams{ user, err := deps.Queries.InsertUser(ctx, sqlc.InsertUserParams{
Email: normalized, Email: normalized,
PasswordHash: hash, PasswordHash: pgtype.Text{String: hash, Valid: true},
}) })
if err != nil { if err != nil {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { 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). // Unique-constraint violation on email (T-2-19).
// Specific error message is acceptable on signup per CONTEXT.md specifics. // Specific error message is acceptable on signup per CONTEXT.md specifics.
errs.Email = "That email is already in use." 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). // 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 { if err != nil || !ok {
// D-20: uses the same constant as unknown-email case (single source of truth). // D-20: uses the same constant as unknown-email case (single source of truth).
errs.General = errInvalidCreds errs.General = errInvalidCreds

View file

@ -16,6 +16,8 @@ import (
"backend/internal/auth" "backend/internal/auth"
"backend/internal/db/sqlc" "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 // 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{ user, err := q.InsertUser(ctx, sqlc.InsertUserParams{
Email: strings.ToLower(email), Email: strings.ToLower(email),
PasswordHash: hash, PasswordHash: pgtype.Text{String: hash, Valid: true},
}) })
if err != nil { if err != nil {
t.Fatalf("preInsertUser: InsertUser: %v", err) 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 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 // 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). // SHA-256 hash — this is the session ID stored in the DB (D-05).
func hashCookieValue(t *testing.T, cookieValue string) string { func hashCookieValue(t *testing.T, cookieValue string) string {
@ -173,8 +184,8 @@ func TestSignup_Success(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetUserByEmail: %v", err) t.Fatalf("GetUserByEmail: %v", err)
} }
if !strings.HasPrefix(user.PasswordHash, "$argon2id$") { if !user.PasswordHash.Valid || !strings.HasPrefix(user.PasswordHash.String, "$argon2id$") {
t.Errorf("password_hash = %q; want $argon2id$ prefix", user.PasswordHash) t.Errorf("password_hash = %#v; want valid $argon2id$ prefix", user.PasswordHash)
} }
// Session row must exist for the user. // 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) { func TestSignup_Success_HTMX(t *testing.T) {
pool, cleanup := setupTestDB(t) pool, cleanup := setupTestDB(t)
defer cleanup() 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) { func TestLogin_ValidationError_BadEmail(t *testing.T) {
pool, cleanup := setupTestDB(t) pool, cleanup := setupTestDB(t)
defer cleanup() defer cleanup()

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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;