feat(08-01): add social identity schema foundation
This commit is contained in:
parent
2f4a4f9ebb
commit
2d004cd251
8 changed files with 279 additions and 6 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
backend/internal/db/queries/user_identities.sql
Normal file
41
backend/internal/db/queries/user_identities.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
34
backend/internal/web/social_identity_migration_test.go
Normal file
34
backend/internal/web/social_identity_migration_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/internal/web/social_identity_schema_test.go
Normal file
48
backend/internal/web/social_identity_schema_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/migrations/0006_social_identities.sql
Normal file
36
backend/migrations/0006_social_identities.sql
Normal 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;
|
||||||
Loading…
Reference in a new issue