This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var _ auth.OwnProfileRepository = (*Store)(nil)
|
||||
|
||||
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
|
||||
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
|
||||
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
|
||||
AND u.registration_pending=0 AND u.password_change_required=0`
|
||||
|
||||
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
|
||||
var profile auth.OwnProfile
|
||||
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
|
||||
if zeroDigest(session) || now.IsZero() {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
|
||||
}
|
||||
|
||||
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
|
||||
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
|
||||
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
|
||||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
|
||||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
|
||||
return auth.OwnProfile{}, auth.ErrProfileInput
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// This first statement takes the writer lock and tests the real current
|
||||
// session/account/revision together. No read-before-write lock upgrade race.
|
||||
set := `display_name=?`
|
||||
args := []any{value}
|
||||
if change.Field == "username" {
|
||||
set = `username=?,username_normalized=?`
|
||||
args = append(args, normalize(value))
|
||||
}
|
||||
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
|
||||
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
|
||||
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
|
||||
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
|
||||
if err != nil {
|
||||
var constraint *sqlite.Error
|
||||
if errors.As(err, &constraint) && constraint.Code() == 2067 {
|
||||
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
|
||||
}
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if changed != 1 {
|
||||
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if profile.UserID != change.UserID {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return auth.OwnProfile{}, auth.ErrProfileConflict
|
||||
}
|
||||
if change.Field == "username" {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
}
|
||||
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
)
|
||||
|
||||
type profileFixture struct {
|
||||
store *Store
|
||||
path string
|
||||
now time.Time
|
||||
user auth.User
|
||||
session, other auth.Session
|
||||
}
|
||||
|
||||
func newProfileFixture(t *testing.T) profileFixture {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "identity.sqlite")
|
||||
store, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||
other := session
|
||||
other.Digest = sha256.Sum256([]byte("other-session"))
|
||||
for _, s := range []auth.Session{session, other} {
|
||||
if err = store.CreateSession(t.Context(), s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return profileFixture{store, path, now, user, session, other}
|
||||
}
|
||||
|
||||
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
|
||||
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
|
||||
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
|
||||
}
|
||||
|
||||
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||
if err != nil || initial.Revision != 1 {
|
||||
t.Fatalf("initial revision: %d %v", initial.Revision, err)
|
||||
}
|
||||
change, audit := f.change("display_name", " Émilie ★ ", 1)
|
||||
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
|
||||
t.Fatalf("display update: %+v %v", updated, err)
|
||||
}
|
||||
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||
t.Fatal("display edit revoked session", err)
|
||||
}
|
||||
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
change, audit = f.change("username", "new.reader", 2)
|
||||
change.ExpectedPasswordHash = "fixture-hash"
|
||||
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
|
||||
t.Fatalf("username update: %+v %v", updated, err)
|
||||
}
|
||||
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("other session survived: %v", err)
|
||||
}
|
||||
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
|
||||
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
|
||||
t.Fatal("credential identity changed", err)
|
||||
}
|
||||
var count int
|
||||
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("audits: %d %v", count, err)
|
||||
}
|
||||
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
|
||||
t.Fatalf("restart: %+v %v", recovered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
|
||||
for _, test := range []struct{ name, sql string }{
|
||||
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
|
||||
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
|
||||
{"suspended", `UPDATE gwf_users SET status='suspended'`},
|
||||
{"disabled", `UPDATE gwf_users SET status='disabled'`},
|
||||
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
|
||||
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
if _, err := f.store.db.Exec(test.sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change, audit := f.change("display_name", "not allowed", 1)
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("access: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
f := newProfileFixture(t)
|
||||
change, audit := f.change("username", "new.reader", 1)
|
||||
change.UserID = "another-user"
|
||||
audit.ActorUserID = change.UserID
|
||||
audit.ResourceID = change.UserID
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("foreign user: %v", err)
|
||||
}
|
||||
change, audit = f.change("username", "new.reader", 1)
|
||||
change.ExpectedPasswordHash = "old-verified-hash"
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||
t.Fatalf("changed password: %v", err)
|
||||
}
|
||||
change.ExpectedPasswordHash = "fixture-hash"
|
||||
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
|
||||
t.Fatal("duplicate audit accepted")
|
||||
}
|
||||
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
|
||||
t.Fatalf("rollback: %+v %v", profile, err)
|
||||
}
|
||||
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||
t.Fatal("audit failure revoked session", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
otherUser := f.user
|
||||
otherUser.ID = "another-user"
|
||||
otherUser.Username = "another.reader"
|
||||
otherUser.Email = "another@example.test"
|
||||
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change, audit := f.change("username", "ANOTHER.READER", 1)
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
|
||||
t.Fatalf("unique name: %v", err)
|
||||
}
|
||||
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, store := range []*Store{f.store, second} {
|
||||
wg.Add(1)
|
||||
go func(store *Store) {
|
||||
defer wg.Done()
|
||||
change, audit := f.change("display_name", "New Name", 1)
|
||||
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
results <- err
|
||||
}(store)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, auth.ErrProfileConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
|
||||
}
|
||||
// Recreate the actual previous schema without rewriting its identity rows.
|
||||
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
|
||||
t.Fatalf("prior schema: %d %v", version, err)
|
||||
}
|
||||
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||
t.Fatal("startup accepted old schema")
|
||||
}
|
||||
if err = f.store.Migrate(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
|
||||
t.Fatalf("migration: %+v %v", profile, err)
|
||||
}
|
||||
if err = f.store.Migrate(t.Context()); err != nil {
|
||||
t.Fatal("idempotent migration", err)
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,8 @@ func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *t
|
||||
// Reconstruct the previous invitation schema in this disposable database.
|
||||
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
|
||||
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
|
||||
DELETE FROM gamertan_web_migrations WHERE version=10`); err != nil {
|
||||
ALTER TABLE gwf_users DROP COLUMN profile_revision;
|
||||
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||
|
||||
+5
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const SchemaVersion = 10
|
||||
const SchemaVersion = 11
|
||||
|
||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||
var exists int
|
||||
@@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
table, column, definition string
|
||||
}{
|
||||
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
||||
{"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`},
|
||||
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
||||
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
||||
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
||||
@@ -244,6 +245,9 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user