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"
|
||||
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
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)
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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