This commit is contained in:
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.26 — 2026-09-05
|
||||||
|
|
||||||
|
- Add optional self-profile readers and revision-checked username/display-name
|
||||||
|
writes. Recheck the active session, account and expected revision atomically
|
||||||
|
with a secret-free audit; preserve immutable user identity and ownership.
|
||||||
|
- Username edits revoke other sessions but preserve the acting session. A
|
||||||
|
password-confirmed write can require the exact verified credential hash,
|
||||||
|
rejecting a concurrent password reset. Applications own reauthentication,
|
||||||
|
operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits.
|
||||||
|
- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows
|
||||||
|
begin at revision 1; startup with migrations disabled rejects older schemas.
|
||||||
|
Do not run older writers against schema 11 as a database rollback strategy.
|
||||||
|
- Email changes are deliberately absent; pending-address verification and mail
|
||||||
|
delivery are separate work. Test invalid/restricted sessions, collisions,
|
||||||
|
concurrent/stale edits, audit rollback, restart and schema-10 migration.
|
||||||
|
|
||||||
## v0.1.0-preview.25 — 2026-09-05
|
## v0.1.0-preview.25 — 2026-09-05
|
||||||
|
|
||||||
- Add optional, credential-free user and organization directory readers for
|
- Add optional, credential-free user and organization directory readers for
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
|||||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
packages you import.
|
||||||
|
|
||||||
> **Public preview:** `v0.1.0-preview.25`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.26`. APIs may change before a stable
|
||||||
> release. Linux is the maintained release platform.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.25
|
go get gamertan.com/web@v0.1.0-preview.26
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProfileInput = errors.New("auth: invalid profile change")
|
||||||
|
ErrProfileAccess = errors.New("auth: profile session is unavailable")
|
||||||
|
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
|
||||||
|
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OwnProfile contains mutable identity, not credentials or organization roles.
|
||||||
|
// Revision is independent of timestamps and increases for every profile edit.
|
||||||
|
type OwnProfile struct {
|
||||||
|
UserID, Username, Email, DisplayName string
|
||||||
|
Revision int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileEdit is a trusted repository command, not an HTTP input model. The
|
||||||
|
// application must authenticate the session, validate CSRF/origin and rate-limit
|
||||||
|
// mutations. Username edits additionally require recent reauthentication (and
|
||||||
|
// any account-specific MFA). For password reauthentication, supply the verified
|
||||||
|
// hash so a concurrent password reset invalidates the write. After verified
|
||||||
|
// passkey approval, leave it empty. Do not log or serialize this command.
|
||||||
|
type ProfileEdit struct {
|
||||||
|
UserID string
|
||||||
|
SessionDigest [32]byte
|
||||||
|
ExpectedRevision int64
|
||||||
|
Field, Value string
|
||||||
|
ExpectedPasswordHash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeProfileValue validates only supported fields. Email is deliberately
|
||||||
|
// absent: verified mailbox changes need a separate pending/confirmation flow.
|
||||||
|
func NormalizeProfileValue(field, value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch field {
|
||||||
|
case "username":
|
||||||
|
if !identifierPattern.MatchString(value) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
case "display_name":
|
||||||
|
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", ErrProfileInput
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnProfileRepository is optional; no change to the authentication Repository
|
||||||
|
// interface is required. It derives access from the current session, never from
|
||||||
|
// a site-wide administrator flag. Implementations atomically recheck identity,
|
||||||
|
// session and revision, mutate one field, and append the audit. Username edits
|
||||||
|
// revoke other sessions but preserve the acting session. They never reassign
|
||||||
|
// stable IDs, memberships, passkeys, billing identities or historical records.
|
||||||
|
type OwnProfileRepository interface {
|
||||||
|
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
|
||||||
|
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeProfileValue(t *testing.T) {
|
||||||
|
for _, value := range []struct{ field, value, want string }{
|
||||||
|
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
|
||||||
|
} {
|
||||||
|
got, err := NormalizeProfileValue(value.field, value.value)
|
||||||
|
if err != nil || got != value.want {
|
||||||
|
t.Fatalf("normalization: %q %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []struct{ field, value string }{
|
||||||
|
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
|
||||||
|
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
|
||||||
|
} {
|
||||||
|
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
|
||||||
|
t.Fatalf("invalid field accepted: %s", value.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzProfileValue(f *testing.F) {
|
||||||
|
f.Add("username", "reader.one")
|
||||||
|
f.Add("display_name", "Émilie")
|
||||||
|
f.Add("email", "a@example.test")
|
||||||
|
f.Fuzz(func(t *testing.T, field, value string) {
|
||||||
|
normal, err := NormalizeProfileValue(field, value)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(normal) == 0 || len(normal) > 128 {
|
||||||
|
t.Fatal("unbounded value")
|
||||||
|
}
|
||||||
|
again, err := NormalizeProfileValue(field, normal)
|
||||||
|
if err != nil || again != normal {
|
||||||
|
t.Fatal("unstable normalization")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
// 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;
|
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;
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
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
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchemaVersion = 10
|
const SchemaVersion = 11
|
||||||
|
|
||||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||||
var exists int
|
var exists int
|
||||||
@@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
|||||||
table, column, definition string
|
table, column, definition string
|
||||||
}{
|
}{
|
||||||
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
{"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", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
||||||
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
||||||
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 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 {
|
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
|
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()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
|
|||||||
operator exclusions, synchronization, and publishing workflow remain
|
operator exclusions, synchronization, and publishing workflow remain
|
||||||
application-owned. Useful pressure from that migration may improve a general
|
application-owned. Useful pressure from that migration may improve a general
|
||||||
interface, but it may not smuggle EQL-specific policy into this module.
|
interface, but it may not smuggle EQL-specific policy into this module.
|
||||||
|
|
||||||
|
## Optional personal-profile editing
|
||||||
|
|
||||||
|
`auth.OwnProfileRepository` supports a narrow self-service boundary independently
|
||||||
|
of instance-directory authorization. Load the profile using the current session
|
||||||
|
digest; derive the target from that result. Normalize one username or display
|
||||||
|
name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into
|
||||||
|
`auth.ProfileEdit`, which carries trusted identity and credential-check state.
|
||||||
|
|
||||||
|
Require CSRF/origin validation for browser writes and rate-limit credential work.
|
||||||
|
For username changes, verify the current password (supply its hash as
|
||||||
|
`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval;
|
||||||
|
enforce any additional authentication policy your application requires. Include
|
||||||
|
the session, user, value and expected profile revision in the passkey binding.
|
||||||
|
The SQLite transaction rechecks session/account/revision and any verified hash,
|
||||||
|
updates one field, revokes other sessions for username edits, and appends audit.
|
||||||
|
Do not log the command or include secret material in its audit.
|
||||||
|
|
||||||
|
Schema 11 adds `profile_revision` without changing stable identity keys. Run an
|
||||||
|
explicit migration before starting an adopter with automatic migration disabled.
|
||||||
|
Keep the pre-migration backup; adjacent older binaries are not approved writers
|
||||||
|
for the migrated schema. Email-change enrollment/confirmation is not implemented
|
||||||
|
by this interface and must not be simulated with an unverified direct update.
|
||||||
|
|||||||
+14
-2
@@ -8,6 +8,18 @@ application concern belongs in the shared module.
|
|||||||
|
|
||||||
## Gamertan accounts and commerce
|
## Gamertan accounts and commerce
|
||||||
|
|
||||||
|
- Personal identity editing is not instance administration. `OwnProfileRepository`
|
||||||
|
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
||||||
|
command, never a browser request model. SQLite schema 11 adds a monotonic
|
||||||
|
revision because timestamps alone cannot distinguish two edits in one second.
|
||||||
|
Session/account/revision checks, mutation and audit share one write transaction.
|
||||||
|
Username edits invalidate other sessions without changing immutable IDs,
|
||||||
|
memberships, credentials, orders or provider billing identities. A password
|
||||||
|
proof binds the verified hash into that transaction; passkey proofs must bind
|
||||||
|
the exact user/session/field/value/revision before calling it. The application
|
||||||
|
chooses account-specific reauthentication and owns its credential-work limits.
|
||||||
|
Email requires a separate verified change protocol, not another accepted field.
|
||||||
|
|
||||||
- Instance operators need all-user/all-organization directories, not a staff
|
- Instance operators need all-user/all-organization directories, not a staff
|
||||||
roster or implicit membership in every business. Optional bounded readers now
|
roster or implicit membership in every business. Optional bounded readers now
|
||||||
expose identity/profile records without credentials, independent of membership.
|
expose identity/profile records without credentials, independent of membership.
|
||||||
@@ -47,8 +59,8 @@ application concern belongs in the shared module.
|
|||||||
policy; there is no new database schema or commerce dependency in Foundations.
|
policy; there is no new database schema or commerce dependency in Foundations.
|
||||||
|
|
||||||
- The account email remains required and unique. Gamertan uses normalized
|
- The account email remains required and unique. Gamertan uses normalized
|
||||||
email as the canonical login identifier and keeps username as a stable public
|
email as the canonical login identifier; the immutable user ID, not the editable
|
||||||
identity. Until a mail package exists, the application must not describe an
|
username, owns account relationships. Until a mail package exists, it must not describe an
|
||||||
address as verified merely because it was entered during registration.
|
address as verified merely because it was entered during registration.
|
||||||
- Password authentication is sufficient for an ordinary customer base
|
- Password authentication is sufficient for an ordinary customer base
|
||||||
session. Privileged application actions use an exact operation binding with
|
session. Privileged application actions use an exact operation binding with
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
|||||||
module checksum:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.26
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ analytics/geo.go
|
|||||||
auth/auth.go
|
auth/auth.go
|
||||||
auth/context.go
|
auth/context.go
|
||||||
auth/directory.go
|
auth/directory.go
|
||||||
|
auth/profile.go
|
||||||
|
auth/profile_test.go
|
||||||
auth/password.go
|
auth/password.go
|
||||||
auth/password_test.go
|
auth/password_test.go
|
||||||
authrecovery/recovery.go
|
authrecovery/recovery.go
|
||||||
@@ -44,6 +46,8 @@ authsqlite/store.go
|
|||||||
authsqlite/store_test.go
|
authsqlite/store_test.go
|
||||||
authsqlite/directory.go
|
authsqlite/directory.go
|
||||||
authsqlite/directory_test.go
|
authsqlite/directory_test.go
|
||||||
|
authsqlite/profile.go
|
||||||
|
authsqlite/profile_test.go
|
||||||
authsqlite/account.go
|
authsqlite/account.go
|
||||||
authsqlite/account_test.go
|
authsqlite/account_test.go
|
||||||
authsqlite/access.go
|
authsqlite/access.go
|
||||||
|
|||||||
Reference in New Issue
Block a user