Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe6bd94c9a | ||
|
|
17bd9453e2 | ||
|
|
d8b09c8ae5 | ||
|
|
95d50f0888 |
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.1.0-preview.16 — 2026-09-03
|
||||||
|
|
||||||
|
- Add bounded organization-member and direct user-role listings for
|
||||||
|
application-owned access administration pages. Direct listings deliberately
|
||||||
|
exclude team and narrower resource grants rather than flattening distinct
|
||||||
|
authority into one apparent role.
|
||||||
|
- Add atomic direct-role replacement with exact expected-binding checks,
|
||||||
|
transactional access audit, active-member validation, and final active
|
||||||
|
direct-owner protection. SQLite serializes competing replacements so stale
|
||||||
|
administration fails with a stable conflict instead of partially applying.
|
||||||
|
- Record the Gamertan administration dogfood boundary: applications authorize
|
||||||
|
the route and fresh passkey assertion, while Foundations owns the reusable
|
||||||
|
storage transaction and invariants.
|
||||||
|
|
||||||
|
## v0.1.0-preview.15 — 2026-09-03
|
||||||
|
|
||||||
|
- Permit applications to opt into an exact non-default HTTPS WebAuthn origin
|
||||||
|
port for `localhost` and reserved `.test` relying-party IDs. The configured
|
||||||
|
origin remains exact, production origins remain portless by default, and
|
||||||
|
malformed, default, non-canonical, zero, or out-of-range ports fail closed.
|
||||||
|
- Record the Gamertan local-Caddy dogfood pressure that required this explicit
|
||||||
|
development boundary without weakening cross-origin ceremony rejection.
|
||||||
|
|
||||||
|
## v0.1.0-preview.14 — 2026-09-03
|
||||||
|
|
||||||
|
- Reject header-only, truncated, and structurally invalid PDF uploads in the
|
||||||
|
bounded media preparer. Accepted attachments now require a supported PDF
|
||||||
|
version, terminal EOF marker, numeric in-range `startxref`, and either a
|
||||||
|
traditional xref/trailer or xref-stream object at the declared offset.
|
||||||
|
- Keep PDF handling storage-neutral and non-rendering: applications still own
|
||||||
|
authorization, reference tracking, attachment disposition, and lifecycle.
|
||||||
|
|
||||||
|
## v0.1.0-preview.13 — 2026-09-03
|
||||||
|
|
||||||
|
- Complete the password-plus-recovery-code flow with a short-lived restricted
|
||||||
|
grant bound into a replacement-passkey ceremony. Completion atomically
|
||||||
|
consumes the grant, stores the verified passkey, replaces every recovery
|
||||||
|
code, revokes any intervening sessions and ceremonies, and records both
|
||||||
|
audits without issuing a normal session.
|
||||||
|
- Keep failed completion retryable until grant expiry: a duplicate credential
|
||||||
|
or other transaction failure rolls back grant consumption and recovery-code
|
||||||
|
replacement, while a mismatched WebAuthn binding consumes only the affected
|
||||||
|
ceremony.
|
||||||
|
|
||||||
## v0.1.0-preview.12 — 2026-09-03
|
## v0.1.0-preview.12 — 2026-09-03
|
||||||
|
|
||||||
- Add a root-local bootstrap transaction that creates the first passkey-only
|
- Add a root-local bootstrap transaction that creates the first passkey-only
|
||||||
|
|||||||
@@ -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.12`. APIs may change before a stable
|
> **Public preview:** `v0.1.0-preview.16`. 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.12
|
go get gamertan.com/web@v0.1.0-preview.16
|
||||||
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.12
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
+87
-5
@@ -18,8 +18,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
ErrLastOwner = errors.New("access: the last active direct owner must be preserved")
|
||||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
ErrRoleChangeConflict = errors.New("access: role binding changed")
|
||||||
|
ErrRoleUnchanged = errors.New("access: role is unchanged")
|
||||||
|
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||||
|
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type SubjectKind string
|
type SubjectKind string
|
||||||
@@ -116,6 +119,8 @@ type Repository interface {
|
|||||||
Grant(context.Context, Binding) error
|
Grant(context.Context, Binding) error
|
||||||
Revoke(context.Context, string, string, time.Time) error
|
Revoke(context.Context, string, string, time.Time) error
|
||||||
EffectiveBindings(context.Context, string, string) ([]Binding, error)
|
EffectiveBindings(context.Context, string, string) ([]Binding, error)
|
||||||
|
OrganizationUserBindings(context.Context, string, int) ([]Binding, error)
|
||||||
|
ReplaceOrganizationUserRole(context.Context, []string, Binding, string, AuditEvent) error
|
||||||
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
|
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
|
||||||
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
||||||
AppendAccessAudit(context.Context, AuditEvent) error
|
AppendAccessAudit(context.Context, AuditEvent) error
|
||||||
@@ -123,8 +128,9 @@ type Repository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Random io.Reader
|
Random io.Reader
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
OwnerRole string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -132,6 +138,7 @@ type Service struct {
|
|||||||
policy Policy
|
policy Policy
|
||||||
random io.Reader
|
random io.Reader
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
ownerRole string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
||||||
@@ -147,7 +154,12 @@ func New(repository Repository, policy Policy, options Options) (*Service, error
|
|||||||
if options.Now == nil {
|
if options.Now == nil {
|
||||||
options.Now = time.Now
|
options.Now = time.Now
|
||||||
}
|
}
|
||||||
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
|
if options.OwnerRole != "" {
|
||||||
|
if _, ok := policy.Roles[options.OwnerRole]; !ok {
|
||||||
|
return nil, errors.New("access: owner role is unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Seed(ctx context.Context) error {
|
func (service *Service) Seed(ctx context.Context) error {
|
||||||
@@ -183,6 +195,62 @@ func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error)
|
|||||||
return binding, nil
|
return binding, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrganizationUserBindings lists active, direct, organization-wide user role
|
||||||
|
// bindings. Team and narrower project/environment/service grants remain
|
||||||
|
// separate because an administration screen must not silently flatten their
|
||||||
|
// authority into one apparent role.
|
||||||
|
func (service *Service) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]Binding, error) {
|
||||||
|
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
|
||||||
|
return nil, errors.New("access: invalid organization binding query")
|
||||||
|
}
|
||||||
|
return service.repository.OrganizationUserBindings(ctx, organizationID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizationUserRoleChange struct {
|
||||||
|
OrganizationID string
|
||||||
|
UserID string
|
||||||
|
Role string
|
||||||
|
ActorUserID string
|
||||||
|
RequestID string
|
||||||
|
ExpectedBindingIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceOrganizationUserRole atomically replaces every current direct,
|
||||||
|
// organization-wide role for one active member with exactly one role. The
|
||||||
|
// expected binding IDs make concurrent administration fail closed. When an
|
||||||
|
// owner role is configured, the repository also protects the final active
|
||||||
|
// direct owner in the same transaction.
|
||||||
|
func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
|
||||||
|
if service.ownerRole == "" {
|
||||||
|
return Binding{}, errors.New("access: owner role is required for role replacement")
|
||||||
|
}
|
||||||
|
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || !text(input.RequestID, 128, true) {
|
||||||
|
return Binding{}, errors.New("access: invalid organization role replacement")
|
||||||
|
}
|
||||||
|
if _, ok := service.policy.Roles[input.Role]; !ok {
|
||||||
|
return Binding{}, errors.New("access: unknown role")
|
||||||
|
}
|
||||||
|
expected, err := canonicalBindingIDs(input.ExpectedBindingIDs)
|
||||||
|
if err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
bindingID, err := randomID(service.random)
|
||||||
|
if err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
auditID, err := randomID(service.random)
|
||||||
|
if err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
now := service.now().UTC()
|
||||||
|
binding := Binding{ID: bindingID, SubjectKind: User, SubjectID: input.UserID, Role: input.Role, Scope: Scope{OrganizationID: input.OrganizationID}, GrantedBy: input.ActorUserID, GrantedAt: now}
|
||||||
|
audit := AuditEvent{ID: auditID, OrganizationID: input.OrganizationID, ActorUserID: input.ActorUserID, Action: "access.role.replace", ResourceType: "user", ResourceID: input.UserID, RequestID: input.RequestID, Summary: "Direct organization role replaced", CreatedAt: now}
|
||||||
|
if err = service.repository.ReplaceOrganizationUserRole(ctx, expected, binding, service.ownerRole, audit); err != nil {
|
||||||
|
return Binding{}, err
|
||||||
|
}
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
type Decision struct {
|
type Decision struct {
|
||||||
Allowed bool
|
Allowed bool
|
||||||
Source string
|
Source string
|
||||||
@@ -265,6 +333,20 @@ func randomID(random io.Reader) (string, error) {
|
|||||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canonicalBindingIDs(values []string) ([]string, error) {
|
||||||
|
if len(values) > 16 {
|
||||||
|
return nil, errors.New("access: invalid expected role bindings")
|
||||||
|
}
|
||||||
|
result := append([]string(nil), values...)
|
||||||
|
sort.Strings(result)
|
||||||
|
for index, value := range result {
|
||||||
|
if !idPattern.MatchString(value) || index > 0 && result[index-1] == value {
|
||||||
|
return nil, errors.New("access: invalid expected role bindings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func text(value string, limit int, emptyOK bool) bool {
|
func text(value string, limit int, emptyOK bool) bool {
|
||||||
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-2
@@ -4,6 +4,8 @@ package access
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -56,9 +58,57 @@ func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizationUserRoleReplacementIsBoundedAndCanonical(t *testing.T) {
|
||||||
|
now := time.Unix(2000, 0).UTC()
|
||||||
|
policy := Policy{Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"}, Permissions: map[string]string{"site.view": "View site"}, Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}}}
|
||||||
|
if _, err := New(&repositoryStub{}, policy, Options{OwnerRole: "missing"}); err == nil {
|
||||||
|
t.Fatal("unknown owner role accepted")
|
||||||
|
}
|
||||||
|
repository := &repositoryStub{}
|
||||||
|
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding, err := service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{
|
||||||
|
OrganizationID: "org-12345678",
|
||||||
|
UserID: "user-12345678",
|
||||||
|
Role: "viewer",
|
||||||
|
ActorUserID: "user-87654321",
|
||||||
|
RequestID: "request-12345678",
|
||||||
|
ExpectedBindingIDs: []string{"binding-22222222", "binding-11111111"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if binding.Role != "viewer" || binding.SubjectKind != User || binding.Scope != (Scope{OrganizationID: "org-12345678"}) || binding.GrantedAt != now {
|
||||||
|
t.Fatalf("binding=%+v", binding)
|
||||||
|
}
|
||||||
|
if !slices.Equal(repository.replacedExpected, []string{"binding-11111111", "binding-22222222"}) || repository.replacedOwnerRole != "owner" {
|
||||||
|
t.Fatalf("expected=%v owner=%q", repository.replacedExpected, repository.replacedOwnerRole)
|
||||||
|
}
|
||||||
|
if repository.replacedAccessAudit.Action != "access.role.replace" || repository.replacedAccessAudit.ResourceID != "user-12345678" || repository.replacedAccessAudit.RequestID != "request-12345678" {
|
||||||
|
t.Fatalf("audit=%+v", repository.replacedAccessAudit)
|
||||||
|
}
|
||||||
|
if _, err = service.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{OrganizationID: "org-12345678", UserID: "user-12345678", Role: "viewer", ActorUserID: "user-87654321", ExpectedBindingIDs: []string{"binding-11111111", "binding-11111111"}}); err == nil {
|
||||||
|
t.Fatal("duplicate expected binding accepted")
|
||||||
|
}
|
||||||
|
serviceWithoutOwner, err := New(&repositoryStub{}, policy, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = serviceWithoutOwner.ReplaceOrganizationUserRole(t.Context(), OrganizationUserRoleChange{}); err == nil || errors.Is(err, ErrRoleChangeConflict) {
|
||||||
|
t.Fatalf("missing owner role err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type repositoryStub struct {
|
type repositoryStub struct {
|
||||||
bindings []Binding
|
bindings []Binding
|
||||||
breakGlass []BreakGlass
|
breakGlass []BreakGlass
|
||||||
|
organizationUser []Binding
|
||||||
|
replacedExpected []string
|
||||||
|
replacedBinding Binding
|
||||||
|
replacedOwnerRole string
|
||||||
|
replacedAccessAudit AuditEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
||||||
@@ -67,6 +117,16 @@ func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error
|
|||||||
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
||||||
return repository.bindings, nil
|
return repository.bindings, nil
|
||||||
}
|
}
|
||||||
|
func (repository *repositoryStub) OrganizationUserBindings(context.Context, string, int) ([]Binding, error) {
|
||||||
|
return repository.organizationUser, nil
|
||||||
|
}
|
||||||
|
func (repository *repositoryStub) ReplaceOrganizationUserRole(_ context.Context, expected []string, binding Binding, ownerRole string, audit AuditEvent) error {
|
||||||
|
repository.replacedExpected = append([]string(nil), expected...)
|
||||||
|
repository.replacedBinding = binding
|
||||||
|
repository.replacedOwnerRole = ownerRole
|
||||||
|
repository.replacedAccessAudit = audit
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
||||||
repository.breakGlass = []BreakGlass{grant}
|
repository.breakGlass = []BreakGlass{grant}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+116
-6
@@ -17,13 +17,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gamertan.com/web/auth"
|
"gamertan.com/web/auth"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultCodeCount = 10
|
const DefaultCodeCount = 10
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
|
||||||
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
|
||||||
|
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Grant struct {
|
type Grant struct {
|
||||||
@@ -39,6 +41,38 @@ type Repository interface {
|
|||||||
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
TakeRecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PasskeyRepository adds the transactional boundary required to finish a
|
||||||
|
// password-plus-recovery-code flow without issuing a normal session.
|
||||||
|
type PasskeyRepository interface {
|
||||||
|
Repository
|
||||||
|
RecoveryGrant(context.Context, [32]byte, time.Time) (auth.User, error)
|
||||||
|
CompletePasskeyRecovery(context.Context, PasskeyCompletion) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passkeys performs recovery-bound WebAuthn registration ceremonies.
|
||||||
|
type Passkeys interface {
|
||||||
|
BeginRecoveryRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
|
||||||
|
FinishRecoveryRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyCompletion contains the public credential, digest-only replacement
|
||||||
|
// codes, and secret-free audits committed after a recovery ceremony.
|
||||||
|
type PasskeyCompletion struct {
|
||||||
|
GrantDigest [32]byte
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
RecoveryDigests [][32]byte
|
||||||
|
PasskeyAudit auth.AuditEvent
|
||||||
|
RecoveryAudit auth.AuditEvent
|
||||||
|
CompletedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyFinishResult returns the verified credential and the new plaintext
|
||||||
|
// recovery codes. Applications must display the codes once and retain none.
|
||||||
|
type PasskeyFinishResult struct {
|
||||||
|
Credential authwebauthn.Credential
|
||||||
|
RecoveryCodes []string
|
||||||
|
}
|
||||||
|
|
||||||
type PasswordVerifier interface {
|
type PasswordVerifier interface {
|
||||||
VerifyPassword(context.Context, string, string) (auth.User, error)
|
VerifyPassword(context.Context, string, string) (auth.User, error)
|
||||||
}
|
}
|
||||||
@@ -48,6 +82,7 @@ type Options struct {
|
|||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
CodeCount int
|
CodeCount int
|
||||||
GrantLifetime time.Duration
|
GrantLifetime time.Duration
|
||||||
|
Passkeys Passkeys
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -57,6 +92,7 @@ type Service struct {
|
|||||||
now func() time.Time
|
now func() time.Time
|
||||||
count int
|
count int
|
||||||
grantTTL time.Duration
|
grantTTL time.Duration
|
||||||
|
passkeys Passkeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
|
||||||
@@ -78,7 +114,7 @@ func New(repository Repository, passwords PasswordVerifier, options Options) (*S
|
|||||||
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
if options.CodeCount < 5 || options.CodeCount > 20 || options.GrantLifetime < 2*time.Minute || options.GrantLifetime > 30*time.Minute {
|
||||||
return nil, errors.New("authrecovery: invalid recovery policy")
|
return nil, errors.New("authrecovery: invalid recovery policy")
|
||||||
}
|
}
|
||||||
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime}, nil
|
return &Service{repository: repository, passwords: passwords, random: options.Random, now: options.Now, count: options.CodeCount, grantTTL: options.GrantLifetime, passkeys: options.Passkeys}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
// ReplaceCodes creates a complete new recovery-code set. Codes are returned
|
||||||
@@ -133,10 +169,74 @@ func (service *Service) Begin(ctx context.Context, identifier, password, code st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
|
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
|
||||||
if len(raw) < 32 || len(raw) > 128 {
|
digest, err := grantDigest(raw)
|
||||||
return auth.User{}, ErrGrantNotFound
|
if err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
}
|
}
|
||||||
return service.repository.TakeRecoveryGrant(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
|
return service.repository.TakeRecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginPasskey starts a ceremony only for a live restricted recovery grant.
|
||||||
|
// The raw grant remains application-held so a failed or interrupted ceremony
|
||||||
|
// can be restarted until the grant expires.
|
||||||
|
func (service *Service) BeginPasskey(ctx context.Context, rawGrant, label string) (authwebauthn.BeginResult, error) {
|
||||||
|
repository, ok := service.repository.(PasskeyRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return authwebauthn.BeginResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.BeginResult{}, err
|
||||||
|
}
|
||||||
|
return service.passkeys.BeginRecoveryRegistration(ctx, user.ID, label, []byte(rawGrant))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishPasskey consumes the grant only inside the transaction that stores the
|
||||||
|
// verified passkey and a fresh recovery-code set. It never issues a session.
|
||||||
|
func (service *Service) FinishPasskey(ctx context.Context, rawGrant, ceremonyToken string, response []byte) (PasskeyFinishResult, error) {
|
||||||
|
repository, ok := service.repository.(PasskeyRepository)
|
||||||
|
if !ok || service.passkeys == nil {
|
||||||
|
return PasskeyFinishResult{}, ErrPasskeyUnavailable
|
||||||
|
}
|
||||||
|
digest, err := grantDigest(rawGrant)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
user, err := repository.RecoveryGrant(ctx, digest, service.now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
codes, digests, err := GenerateCodeSet(service.random, service.count)
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
credential, err := service.passkeys.FinishRecoveryRegistration(ctx, ceremonyToken, []byte(rawGrant), response, func(commitContext context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
|
||||||
|
if verified.UserID != user.ID {
|
||||||
|
return errors.New("authrecovery: recovery identity mismatch")
|
||||||
|
}
|
||||||
|
completedAt := service.now().UTC()
|
||||||
|
auditID, auditErr := token(service.random, 18)
|
||||||
|
if auditErr != nil {
|
||||||
|
return auditErr
|
||||||
|
}
|
||||||
|
recoveryAudit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.complete", ResourceType: "user", ResourceID: user.ID, Summary: "Account recovery enrolled a replacement passkey and replaced the recovery-code set.", CreatedAt: completedAt}
|
||||||
|
return repository.CompletePasskeyRecovery(commitContext, PasskeyCompletion{
|
||||||
|
GrantDigest: digest,
|
||||||
|
Credential: verified,
|
||||||
|
RecoveryDigests: digests,
|
||||||
|
PasskeyAudit: passkeyAudit,
|
||||||
|
RecoveryAudit: recoveryAudit,
|
||||||
|
CompletedAt: completedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return PasskeyFinishResult{}, err
|
||||||
|
}
|
||||||
|
return PasskeyFinishResult{Credential: credential, RecoveryCodes: codes}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
|
func GenerateCodeSet(random io.Reader, count int) ([]string, [][32]byte, error) {
|
||||||
@@ -173,6 +273,16 @@ func DigestCode(code string) ([32]byte, error) {
|
|||||||
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
|
return sha256.Sum256(append([]byte("gamertan-web-recovery-code-v1\x00"), decoded...)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func grantDigest(raw string) ([32]byte, error) {
|
||||||
|
if len(raw) < 32 || len(raw) > 128 {
|
||||||
|
return [32]byte{}, ErrGrantNotFound
|
||||||
|
}
|
||||||
|
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
|
||||||
|
return [32]byte{}, ErrGrantNotFound
|
||||||
|
}
|
||||||
|
return sha256.Sum256([]byte(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func token(random io.Reader, size int) (string, error) {
|
func token(random io.Reader, size int) (string, error) {
|
||||||
value := make([]byte, size)
|
value := make([]byte, size)
|
||||||
if _, err := io.ReadFull(random, value); err != nil {
|
if _, err := io.ReadFull(random, value); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
package authrecovery_test
|
package authrecovery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +16,8 @@ import (
|
|||||||
"gamertan.com/web/auth"
|
"gamertan.com/web/auth"
|
||||||
"gamertan.com/web/authrecovery"
|
"gamertan.com/web/authrecovery"
|
||||||
"gamertan.com/web/authsqlite"
|
"gamertan.com/web/authsqlite"
|
||||||
|
"gamertan.com/web/authwebauthn"
|
||||||
|
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
||||||
@@ -61,6 +67,112 @@ func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPasskeyRecoveryAtomicallyReplacesCodesWithoutIssuingSession(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
|
||||||
|
store, err := authsqlite.Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
random := &counterReader{}
|
||||||
|
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.passkey", Email: "recover-passkey@example.test", DisplayName: "Recover Passkey", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
existingID := bytes.Repeat([]byte{7}, 32)
|
||||||
|
existingJSON, err := json.Marshal(wa.Credential{ID: existingID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: existingID, UserID: user.ID, Label: "Existing passkey", Data: existingJSON, CreatedAt: now}, auth.AuditEvent{ID: "existing-passkey-audit", ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(existingID), Summary: "Existing passkey fixture.", CreatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
passkeys := &passkeyRecoveryStub{now: now, credentialID: existingID}
|
||||||
|
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }, Passkeys: passkeys})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
oldCodes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, grant, err := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
begin, err := recovery.BeginPasskey(t.Context(), grant, "Replacement passkey")
|
||||||
|
if err != nil || begin.CeremonyToken == "" || passkeys.userID != user.ID || passkeys.beginBinding != grant {
|
||||||
|
t.Fatalf("begin=%+v passkeys=%+v err=%v", begin, passkeys, err)
|
||||||
|
}
|
||||||
|
if _, err = recovery.FinishPasskey(t.Context(), grant, begin.CeremonyToken, []byte(`{"fixture":true}`)); err == nil {
|
||||||
|
t.Fatal("duplicate credential unexpectedly committed")
|
||||||
|
}
|
||||||
|
if _, err = recovery.BeginPasskey(t.Context(), grant, "Retry replacement"); err != nil {
|
||||||
|
t.Fatalf("failed completion consumed recovery grant: %v", err)
|
||||||
|
}
|
||||||
|
lateSession, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeys.credentialID = bytes.Repeat([]byte{8}, 32)
|
||||||
|
result, err := recovery.FinishPasskey(t.Context(), grant, "retry-ceremony-token", []byte(`{"fixture":true}`))
|
||||||
|
if err != nil || len(result.RecoveryCodes) != authrecovery.DefaultCodeCount || !bytes.Equal(result.Credential.ID, passkeys.credentialID) {
|
||||||
|
t.Fatalf("result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
if passkeys.finishBinding != grant {
|
||||||
|
t.Fatal("finish ceremony was not bound to the restricted recovery grant")
|
||||||
|
}
|
||||||
|
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
|
||||||
|
t.Fatalf("completed grant replay err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = authService.Session(t.Context(), lateSession); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||||
|
t.Fatalf("session created during recovery survived completion: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", oldCodes[1]); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old recovery-code set survived completion: %v", err)
|
||||||
|
}
|
||||||
|
if _, newGrant, beginErr := recovery.Begin(t.Context(), user.Email, "correct horse battery staple", result.RecoveryCodes[0]); beginErr != nil || newGrant == "" {
|
||||||
|
t.Fatalf("new recovery code unavailable: grant=%q err=%v", newGrant, beginErr)
|
||||||
|
}
|
||||||
|
credentials, err := store.CredentialsByUserID(t.Context(), user.ID)
|
||||||
|
if err != nil || len(credentials) != 2 {
|
||||||
|
t.Fatalf("credentials=%+v err=%v", credentials, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyRecoveryStub struct {
|
||||||
|
now time.Time
|
||||||
|
userID string
|
||||||
|
credentialID []byte
|
||||||
|
beginBinding string
|
||||||
|
finishBinding string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stub *passkeyRecoveryStub) BeginRecoveryRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
|
||||||
|
stub.userID = userID
|
||||||
|
stub.beginBinding = string(binding)
|
||||||
|
return authwebauthn.BeginResult{CeremonyToken: "recovery-ceremony-token", PublicKey: json.RawMessage(`{"challenge":"fixture"}`), ExpiresAt: stub.now.Add(5 * time.Minute)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stub *passkeyRecoveryStub) FinishRecoveryRegistration(ctx context.Context, _ string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
|
||||||
|
stub.finishBinding = string(binding)
|
||||||
|
encoded, err := json.Marshal(wa.Credential{ID: stub.credentialID, PublicKey: []byte{1, 2, 3}})
|
||||||
|
if err != nil {
|
||||||
|
return authwebauthn.Credential{}, err
|
||||||
|
}
|
||||||
|
credential := authwebauthn.Credential{ID: append([]byte(nil), stub.credentialID...), UserID: stub.userID, Label: "Replacement passkey", Data: encoded, CreatedAt: stub.now}
|
||||||
|
audit := auth.AuditEvent{ID: "recovery-passkey-audit", ActorUserID: stub.userID, Action: "auth.recovery.passkey", ResourceType: "passkey", ResourceID: base64.RawURLEncoding.EncodeToString(stub.credentialID), Summary: "A replacement passkey was enrolled during account recovery.", CreatedAt: stub.now}
|
||||||
|
if err = commit(ctx, credential, audit); err != nil {
|
||||||
|
return authwebauthn.Credential{}, err
|
||||||
|
}
|
||||||
|
return credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
type counterReader struct{ value byte }
|
type counterReader struct{ value byte }
|
||||||
|
|
||||||
func (reader *counterReader) Read(target []byte) (int, error) {
|
func (reader *counterReader) Read(target []byte) (int, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gamertan.com/web/access"
|
"gamertan.com/web/access"
|
||||||
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
|
||||||
|
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
|
||||||
|
return nil, errors.New("authsqlite: invalid organization binding query")
|
||||||
|
}
|
||||||
|
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
|
||||||
|
FROM gwf_access_bindings b
|
||||||
|
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
|
||||||
|
WHERE b.organization_id=? AND b.subject_kind='user'
|
||||||
|
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||||
|
AND b.revoked_at IS NULL
|
||||||
|
ORDER BY b.subject_id,b.role_name,b.id
|
||||||
|
LIMIT ?`, organizationID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]access.Binding, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var binding access.Binding
|
||||||
|
var granted int64
|
||||||
|
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
binding.SubjectKind = access.User
|
||||||
|
binding.Scope = access.Scope{OrganizationID: organizationID}
|
||||||
|
binding.GrantedAt = time.Unix(granted, 0).UTC()
|
||||||
|
result = append(result, binding)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
|
||||||
|
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
|
||||||
|
return errors.New("authsqlite: invalid organization role replacement")
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Acquire the SQLite write lock before reading the optimistic binding set.
|
||||||
|
// This serializes competing role replacements so the loser observes the
|
||||||
|
// committed binding IDs and returns ErrRoleChangeConflict instead of an
|
||||||
|
// ambiguous busy-snapshot error.
|
||||||
|
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
|
||||||
|
WHERE organization_id=? AND user_id=? AND status='active'
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
|
||||||
|
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return errors.New("authsqlite: role grantor is not active in organization")
|
||||||
|
}
|
||||||
|
|
||||||
|
var active int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||||
|
FROM gwf_organization_memberships m
|
||||||
|
JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active'
|
||||||
|
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||||
|
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if active != 1 {
|
||||||
|
return errors.New("authsqlite: access subject is not active in organization")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
|
||||||
|
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
|
||||||
|
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
|
||||||
|
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var currentIDs []string
|
||||||
|
var currentRoles []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id, role string
|
||||||
|
if err = rows.Scan(&id, &role); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
currentIDs = append(currentIDs, id)
|
||||||
|
currentRoles = append(currentRoles, role)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !slices.Equal(currentIDs, expected) {
|
||||||
|
return access.ErrRoleChangeConflict
|
||||||
|
}
|
||||||
|
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
|
||||||
|
return access.ErrRoleUnchanged
|
||||||
|
}
|
||||||
|
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
|
||||||
|
var otherOwners int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id)
|
||||||
|
FROM gwf_access_bindings b
|
||||||
|
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active'
|
||||||
|
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
|
||||||
|
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? AND b.role_name=?
|
||||||
|
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
|
||||||
|
AND b.revoked_at IS NULL`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if otherOwners == 0 {
|
||||||
|
return access.ErrLastOwner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=?
|
||||||
|
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
|
||||||
|
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
|
||||||
|
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
|
||||||
|
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||||
|
return errors.New("authsqlite: replacement role has not been seeded")
|
||||||
|
}
|
||||||
|
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
|
||||||
|
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(expected) > 16 || !slices.IsSorted(expected) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for index, id := range expected {
|
||||||
|
if !opaqueID(id) || index > 0 && expected[index-1] == id {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
|
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
|
||||||
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
|
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
|
||||||
return errors.New("authsqlite: invalid break-glass event")
|
return errors.New("authsqlite: invalid break-glass event")
|
||||||
|
|||||||
@@ -261,6 +261,34 @@ func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]or
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) OrganizationMemberships(ctx context.Context, organizationID string, limit int) ([]organizations.Membership, error) {
|
||||||
|
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
|
||||||
|
return nil, errors.New("authsqlite: invalid organization member query")
|
||||||
|
}
|
||||||
|
rows, err := store.db.QueryContext(ctx, `SELECT m.user_id,m.status,m.joined_at
|
||||||
|
FROM gwf_organization_memberships m
|
||||||
|
JOIN gwf_organizations o ON o.id=m.organization_id
|
||||||
|
WHERE m.organization_id=?
|
||||||
|
ORDER BY m.joined_at,m.user_id
|
||||||
|
LIMIT ?`, organizationID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]organizations.Membership, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var membership organizations.Membership
|
||||||
|
var joined int64
|
||||||
|
if err = rows.Scan(&membership.UserID, &membership.Status, &joined); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
membership.OrganizationID = organizationID
|
||||||
|
membership.JoinedAt = time.Unix(joined, 0).UTC()
|
||||||
|
result = append(result, membership)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
|
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
|
||||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||||
return nil, errors.New("authsqlite: invalid team query")
|
return nil, errors.New("authsqlite: invalid team query")
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
package authsqlite
|
package authsqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,6 +40,100 @@ func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, dig
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) RecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
|
||||||
|
if zeroDigest(digest) || now.IsZero() {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.registration_pending,u.created_at,u.updated_at FROM gwf_recovery_grants g JOIN gwf_users u ON u.id=g.user_id WHERE g.token_hash=? AND g.expires_at>?`, digest[:], now.Unix()))
|
||||||
|
if errors.Is(err, auth.ErrUserNotFound) {
|
||||||
|
return auth.User{}, authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompletePasskeyRecovery(ctx context.Context, completion authrecovery.PasskeyCompletion) error {
|
||||||
|
credential := completion.Credential
|
||||||
|
credentialResource := base64.RawURLEncoding.EncodeToString(credential.ID)
|
||||||
|
if zeroDigest(completion.GrantDigest) || !validCredential(credential, true) || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || completion.CompletedAt.IsZero() || !validAuditEvent(completion.PasskeyAudit) || !validAuditEvent(completion.RecoveryAudit) || completion.PasskeyAudit.ActorUserID != credential.UserID || completion.PasskeyAudit.Action != "auth.recovery.passkey" || completion.PasskeyAudit.ResourceType != "passkey" || completion.PasskeyAudit.ResourceID != credentialResource || completion.RecoveryAudit.ActorUserID != credential.UserID || completion.RecoveryAudit.Action != "auth.recovery.complete" || completion.RecoveryAudit.ResourceType != "user" || completion.RecoveryAudit.ResourceID != credential.UserID {
|
||||||
|
return errors.New("authsqlite: invalid passkey recovery completion")
|
||||||
|
}
|
||||||
|
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if zeroDigest(digest) {
|
||||||
|
return errors.New("authsqlite: invalid recovery-code digest")
|
||||||
|
}
|
||||||
|
if _, exists := seen[digest]; exists {
|
||||||
|
return errors.New("authsqlite: duplicate recovery-code digest")
|
||||||
|
}
|
||||||
|
seen[digest] = struct{}{}
|
||||||
|
}
|
||||||
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var userID string
|
||||||
|
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>? RETURNING user_id`, completion.GrantDigest[:], completion.CompletedAt.Unix()).Scan(&userID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return authrecovery.ErrGrantNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID != credential.UserID {
|
||||||
|
return errors.New("authsqlite: passkey recovery identity mismatch")
|
||||||
|
}
|
||||||
|
var active, pending int
|
||||||
|
if err = tx.QueryRowContext(ctx, `SELECT status='active',registration_pending FROM gwf_users WHERE id=?`, userID).Scan(&active, &pending); err != nil || active != 1 || pending != 0 {
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
existing, err := tx.QueryContext(ctx, `SELECT credential_id FROM gwf_passkey_credentials WHERE user_id=?`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for existing.Next() {
|
||||||
|
var id []byte
|
||||||
|
if err = existing.Scan(&id); err != nil {
|
||||||
|
existing.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if bytes.Equal(id, credential.ID) {
|
||||||
|
existing.Close()
|
||||||
|
return errors.New("authsqlite: passkey credential already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = existing.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, userID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, digest := range completion.RecoveryDigests {
|
||||||
|
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, digest[:], completion.CompletedAt.Unix()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.PasskeyAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = appendAudit(ctx, tx, completion.RecoveryAudit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
|
func (store *Store) ConsumeRecoveryCodeAndCreateGrant(ctx context.Context, userID string, codeDigest [32]byte, grant authrecovery.Grant, audit auth.AuditEvent) error {
|
||||||
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
|
if !opaqueID(userID) || zeroDigest(codeDigest) || grant.UserID != userID || zeroDigest(grant.Digest) || grant.CreatedAt.IsZero() || !grant.ExpiresAt.After(grant.CreatedAt) || grant.ExpiresAt.Sub(grant.CreatedAt) > 30*time.Minute || !validAuditEvent(audit) || audit.ResourceID != userID {
|
||||||
return errors.New("authsqlite: invalid recovery attempt")
|
return errors.New("authsqlite: invalid recovery attempt")
|
||||||
|
|||||||
@@ -477,3 +477,169 @@ func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) {
|
|||||||
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
|
t.Fatalf("archived organization decision=%+v err=%v", decision, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
|
||||||
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
now := time.Date(2026, 9, 3, 16, 0, 0, 0, time.UTC)
|
||||||
|
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.owner", Email: "access-owner@example.test", DisplayName: "Access Owner", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "access.member", Email: "access-member@example.test", DisplayName: "Access Member", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "access-admin", Name: "Access Admin", OwnerUserID: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
policy := access.Policy{
|
||||||
|
Roles: map[string]string{"owner": "Owner", "viewer": "Viewer"},
|
||||||
|
Permissions: map[string]string{"site.view": "View site"},
|
||||||
|
Grants: map[string][]string{"owner": {"site.view"}, "viewer": {"site.view"}},
|
||||||
|
}
|
||||||
|
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = accessService.Seed(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownerBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
memberBinding, err := accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "narrow", Name: "Narrow"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID, ProjectID: project.ID}, GrantedBy: owner.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
members, err := organizationService.Members(t.Context(), organization.ID, 10)
|
||||||
|
if err != nil || len(members) != 2 || !membershipPresent(members, owner.ID, "active") || !membershipPresent(members, member.ID, "active") {
|
||||||
|
t.Fatalf("members=%+v err=%v", members, err)
|
||||||
|
}
|
||||||
|
direct, err := accessService.OrganizationUserBindings(t.Context(), organization.ID, 10)
|
||||||
|
if err != nil || len(direct) != 2 {
|
||||||
|
t.Fatalf("direct=%+v err=%v", direct, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type replacementResult struct {
|
||||||
|
binding access.Binding
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan replacementResult, 2)
|
||||||
|
for _, requestID := range []string{"request-member-owner-one", "request-member-owner-two"} {
|
||||||
|
requestID := requestID
|
||||||
|
go func() {
|
||||||
|
<-start
|
||||||
|
binding, replaceErr := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: requestID, ExpectedBindingIDs: []string{memberBinding.ID}})
|
||||||
|
results <- replacementResult{binding: binding, err: replaceErr}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
var memberOwner access.Binding
|
||||||
|
var successful, conflicted int
|
||||||
|
for range 2 {
|
||||||
|
result := <-results
|
||||||
|
switch {
|
||||||
|
case result.err == nil:
|
||||||
|
successful++
|
||||||
|
memberOwner = result.binding
|
||||||
|
case errors.Is(result.err, access.ErrRoleChangeConflict):
|
||||||
|
conflicted++
|
||||||
|
default:
|
||||||
|
t.Fatalf("concurrent replacement err=%v", result.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successful != 1 || conflicted != 1 {
|
||||||
|
t.Fatalf("concurrent replacements success=%d conflict=%d", successful, conflicted)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-stale", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrRoleChangeConflict) {
|
||||||
|
t.Fatalf("stale replacement err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: owner.ID, RequestID: "request-unchanged", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrRoleUnchanged) {
|
||||||
|
t.Fatalf("unchanged replacement err=%v", err)
|
||||||
|
}
|
||||||
|
ownerViewer, err := accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-owner-viewer", ExpectedBindingIDs: []string{ownerBinding.ID}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-last-owner", ExpectedBindingIDs: []string{memberOwner.ID}}); !errors.Is(err, access.ErrLastOwner) {
|
||||||
|
t.Fatalf("last-owner demotion err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-restore-owner", ExpectedBindingIDs: []string{ownerViewer.ID}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "suspended", owner.ID, "request-suspend"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
members, err = organizationService.Members(t.Context(), organization.ID, 10)
|
||||||
|
if err != nil || len(members) != 2 || !membershipPresent(members, member.ID, "suspended") {
|
||||||
|
t.Fatalf("suspended members=%+v err=%v", members, err)
|
||||||
|
}
|
||||||
|
if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "viewer", ActorUserID: owner.ID, RequestID: "request-suspended", ExpectedBindingIDs: []string{memberOwner.ID}}); err == nil {
|
||||||
|
t.Fatal("suspended member role was replaced")
|
||||||
|
}
|
||||||
|
if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, member.ID, "active", owner.ID, "request-reactivate"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateAudit := access.AuditEvent{ID: "audit-duplicate-1234", OrganizationID: organization.ID, ActorUserID: owner.ID, Action: "access.role.replace", ResourceType: "user", ResourceID: member.ID, RequestID: "request-rollback", Summary: "Direct organization role replaced", CreatedAt: now}
|
||||||
|
if err = store.AppendAccessAudit(t.Context(), duplicateAudit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replacement := access.Binding{ID: "binding-rollback-1234", SubjectKind: access.User, SubjectID: member.ID, Role: "viewer", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID, GrantedAt: now}
|
||||||
|
if err = store.ReplaceOrganizationUserRole(t.Context(), []string{memberOwner.ID}, replacement, "owner", duplicateAudit); err == nil {
|
||||||
|
t.Fatal("audit failure did not roll back role replacement")
|
||||||
|
}
|
||||||
|
direct, err = store.OrganizationUserBindings(t.Context(), organization.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var memberRoles []string
|
||||||
|
for _, binding := range direct {
|
||||||
|
if binding.SubjectID == member.ID {
|
||||||
|
memberRoles = append(memberRoles, binding.ID+":"+binding.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(memberRoles) != 1 || memberRoles[0] != memberOwner.ID+":owner" {
|
||||||
|
t.Fatalf("rollback member roles=%v", memberRoles)
|
||||||
|
}
|
||||||
|
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func membershipPresent(values []organizations.Membership, userID, status string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value.UserID == userID && value.Status == status {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+56
-6
@@ -12,8 +12,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,9 +38,14 @@ const (
|
|||||||
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
RPID string
|
RPID string
|
||||||
RPDisplayName string
|
RPDisplayName string
|
||||||
Origin string
|
Origin string
|
||||||
|
// AllowDevelopmentPort permits an explicit non-default HTTPS port only
|
||||||
|
// for localhost or a reserved .test relying-party ID. Production origins
|
||||||
|
// remain portless, while local applications can terminate trusted HTTPS
|
||||||
|
// without requiring a privileged listener.
|
||||||
|
AllowDevelopmentPort bool
|
||||||
EnrollmentLifetime time.Duration
|
EnrollmentLifetime time.Duration
|
||||||
RegistrationTTL time.Duration
|
RegistrationTTL time.Duration
|
||||||
LoginTTL time.Duration
|
LoginTTL time.Duration
|
||||||
@@ -66,7 +73,7 @@ func New(repository Repository, authService *auth.Service, config Config) (*Serv
|
|||||||
if repository == nil || authService == nil {
|
if repository == nil || authService == nil {
|
||||||
return nil, errors.New("authwebauthn: repository and auth service are required")
|
return nil, errors.New("authwebauthn: repository and auth service are required")
|
||||||
}
|
}
|
||||||
if err := validateOrigin(config.RPID, config.Origin); err != nil {
|
if err := validateOrigin(config.RPID, config.Origin, config.AllowDevelopmentPort); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
|
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
|
||||||
@@ -218,6 +225,23 @@ func (service *Service) BeginAccountRegistration(ctx context.Context, userID, la
|
|||||||
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BeginRecoveryRegistration starts a replacement-passkey ceremony bound to a
|
||||||
|
// short-lived recovery grant selected by the application. The grant itself is
|
||||||
|
// never persisted in ceremony state; only its digest is retained.
|
||||||
|
func (service *Service) BeginRecoveryRegistration(ctx context.Context, userID, label string, binding []byte) (BeginResult, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 {
|
||||||
|
return BeginResult{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
|
||||||
|
if err != nil {
|
||||||
|
return BeginResult{}, err
|
||||||
|
}
|
||||||
|
if user.RegistrationPending || user.Status != "active" {
|
||||||
|
return BeginResult{}, auth.ErrInactiveUser
|
||||||
|
}
|
||||||
|
return service.beginRegistration(ctx, user, label, CeremonyRegistration, BindingDigest(binding), false)
|
||||||
|
}
|
||||||
|
|
||||||
// BeginPasswordMigration starts registration for an already authenticated
|
// BeginPasswordMigration starts registration for an already authenticated
|
||||||
// password-backed user. Completion atomically retires the password and revokes
|
// password-backed user. Completion atomically retires the password and revokes
|
||||||
// all sessions, including the session that authorized this ceremony.
|
// all sessions, including the session that authorized this ceremony.
|
||||||
@@ -289,6 +313,20 @@ func (service *Service) FinishAccountRegistration(ctx context.Context, ceremonyT
|
|||||||
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, true, commit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FinishRecoveryRegistration verifies a replacement passkey and delegates its
|
||||||
|
// persistence to commit so recovery-grant consumption, credential storage, and
|
||||||
|
// recovery-code replacement can share one transaction.
|
||||||
|
func (service *Service) FinishRecoveryRegistration(ctx context.Context, ceremonyToken string, binding, response []byte, commit RegistrationCommit) (Credential, error) {
|
||||||
|
if len(binding) < 16 || len(binding) > 4096 || commit == nil {
|
||||||
|
return Credential{}, ErrOperationBinding
|
||||||
|
}
|
||||||
|
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", BindingDigest(binding), response, false, false, func(commitContext context.Context, credential Credential, audit auth.AuditEvent) error {
|
||||||
|
audit.Action = "auth.recovery.passkey"
|
||||||
|
audit.Summary = "A replacement passkey was enrolled during account recovery."
|
||||||
|
return commit(commitContext, credential, audit)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// FinishPasswordMigration verifies the new passkey and persists it together
|
// FinishPasswordMigration verifies the new passkey and persists it together
|
||||||
// with password retirement and session revocation in one storage transaction.
|
// with password retirement and session revocation in one storage transaction.
|
||||||
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
|
||||||
@@ -694,12 +732,24 @@ func passwordMigrationBinding(userID string) [32]byte {
|
|||||||
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
|
return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOrigin(rpID, rawOrigin string) error {
|
func validateOrigin(rpID, rawOrigin string, allowDevelopmentPort bool) error {
|
||||||
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
|
||||||
return errors.New("authwebauthn: relying-party ID and origin are required")
|
return errors.New("authwebauthn: relying-party ID and origin are required")
|
||||||
}
|
}
|
||||||
origin, err := url.Parse(rawOrigin)
|
origin, err := url.Parse(rawOrigin)
|
||||||
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
|
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
|
||||||
|
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
||||||
|
}
|
||||||
|
port := origin.Port()
|
||||||
|
if port == "" {
|
||||||
|
if origin.Host != rpID {
|
||||||
|
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
developmentRP := rpID == "localhost" || strings.HasSuffix(rpID, ".test")
|
||||||
|
value, portErr := strconv.ParseUint(port, 10, 16)
|
||||||
|
if !allowDevelopmentPort || !developmentRP || portErr != nil || value == 0 || value == 443 || strconv.FormatUint(value, 10) != port || origin.Host != net.JoinHostPort(rpID, port) {
|
||||||
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package authwebauthn_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -147,6 +148,30 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecoveryRegistrationIsBoundAndConsumesMismatchedCeremony(t *testing.T) {
|
||||||
|
now := time.Date(2026, 9, 3, 13, 0, 0, 0, time.UTC)
|
||||||
|
store, authService, service := newService(t, &now, &counterReader{})
|
||||||
|
defer store.Close()
|
||||||
|
user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "recover.bound", Email: "recover-bound@example.test", DisplayName: "Recover Bound", Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding := bytes.Repeat([]byte("restricted recovery grant "), 2)
|
||||||
|
begin, err := service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", binding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||||
|
t.Fatalf("tampered recovery binding err=%v", err)
|
||||||
|
}
|
||||||
|
if _, err = service.FinishRecoveryRegistration(t.Context(), begin.CeremonyToken, binding, []byte(`{}`), func(context.Context, authwebauthn.Credential, auth.AuditEvent) error { return nil }); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
|
||||||
|
t.Fatalf("mismatched completion did not consume recovery ceremony: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = service.BeginRecoveryRegistration(t.Context(), user.ID, "Replacement passkey", []byte("short")); !errors.Is(err, authwebauthn.ErrOperationBinding) {
|
||||||
|
t.Fatalf("short recovery binding err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
|
func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) {
|
||||||
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
|
||||||
@@ -206,11 +231,25 @@ func TestConfigurationAndEntropyFailures(t *testing.T) {
|
|||||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
|
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
|
||||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
|
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
|
||||||
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
|
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
|
||||||
|
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:8443"},
|
||||||
|
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com:8443", AllowDevelopmentPort: true},
|
||||||
|
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:443", AllowDevelopmentPort: true},
|
||||||
|
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:08443", AllowDevelopmentPort: true},
|
||||||
|
{RPID: "localhost", RPDisplayName: "Tend", Origin: "https://localhost:0", AllowDevelopmentPort: true},
|
||||||
} {
|
} {
|
||||||
if _, err = authwebauthn.New(store, authService, config); err == nil {
|
if _, err = authwebauthn.New(store, authService, config); err == nil {
|
||||||
t.Fatalf("accepted config=%+v", config)
|
t.Fatalf("accepted config=%+v", config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, config := range []authwebauthn.Config{
|
||||||
|
{RPID: "localhost", RPDisplayName: "Tend Local", Origin: "https://localhost:8443", AllowDevelopmentPort: true},
|
||||||
|
{RPID: "tend.test", RPDisplayName: "Tend Local", Origin: "https://tend.test:8443", AllowDevelopmentPort: true},
|
||||||
|
} {
|
||||||
|
configured, configureErr := authwebauthn.New(store, authService, config)
|
||||||
|
if configureErr != nil || configured == nil {
|
||||||
|
t.Fatalf("development config=%+v service=%v err=%v", config, configured, configureErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
|
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -43,3 +43,20 @@ application concern belongs in the shared module.
|
|||||||
non-personal organization, membership, direct owner binding, and audits
|
non-personal organization, membership, direct owner binding, and audits
|
||||||
together. Applications must seed their owner role first and must write the
|
together. Applications must seed their owner role first and must write the
|
||||||
returned raw token only to a newly created private file.
|
returned raw token only to a newly created private file.
|
||||||
|
- Recovery-code consumption alone is not a complete recovery path. The
|
||||||
|
restricted grant must survive an interrupted authenticator prompt yet be
|
||||||
|
consumed in the same transaction that stores the verified replacement
|
||||||
|
passkey and replacement code digests. `authrecovery.BeginPasskey` and
|
||||||
|
`FinishPasskey` now provide that boundary without creating an authenticated
|
||||||
|
session; Gamertan keeps the raw grant only in a short-lived HttpOnly cookie.
|
||||||
|
- A portless-only WebAuthn origin rule made an unprivileged local HTTPS
|
||||||
|
exercise impossible even though WebAuthn origins include ports. The passkey
|
||||||
|
service now permits an explicit development port only when applications opt
|
||||||
|
in and the RP ID is `localhost` or reserved `.test`; production origins keep
|
||||||
|
the original portless default.
|
||||||
|
- Gamertan's staff-access page exposed a dangerous composition gap between
|
||||||
|
individual grant/revoke calls. Foundations now owns one optimistic,
|
||||||
|
transactional direct-role replacement that preserves the final active
|
||||||
|
owner and appends its audit before commit. The application still owns route
|
||||||
|
authorization, role presentation, CSRF, and the exact fresh-passkey
|
||||||
|
operation binding.
|
||||||
|
|||||||
@@ -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.12
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.12
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.16
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ team membership can be removed independently. Configure `OwnerRole` when
|
|||||||
constructing the service before exposing membership-removal operations. The
|
constructing the service before exposing membership-removal operations. The
|
||||||
SQLite adapter then refuses to suspend or remove the final active direct owner.
|
SQLite adapter then refuses to suspend or remove the final active direct owner.
|
||||||
|
|
||||||
|
For a reviewed access-administration page, use `organizations.Members` to list
|
||||||
|
bounded active and suspended memberships, and
|
||||||
|
`access.OrganizationUserBindings` to list only current direct,
|
||||||
|
organization-wide user roles. The latter intentionally excludes team grants
|
||||||
|
and project, environment, or service bindings. Replace a member's direct role
|
||||||
|
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
|
||||||
|
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
|
||||||
|
rejects stale state, writes the new binding and audit event atomically, and
|
||||||
|
will not demote the final active direct owner. The application must still
|
||||||
|
authorize the administrator and bind any required fresh passkey assertion to
|
||||||
|
the organization, target user, target role, and expected IDs.
|
||||||
|
|
||||||
`access.Service` evaluates a permission against a complete resource scope:
|
`access.Service` evaluates a permission against a complete resource scope:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|||||||
+28
-7
@@ -9,6 +9,11 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
|||||||
## Fixed security policy
|
## Fixed security policy
|
||||||
|
|
||||||
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
|
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
|
||||||
|
- Keep production origins portless. For local development only,
|
||||||
|
`AllowDevelopmentPort` permits one explicit non-default port when the RP ID
|
||||||
|
is exactly `localhost` or beneath the reserved `.test` top-level domain. The
|
||||||
|
configured origin, browser `Origin`, and WebAuthn verifier origin must still
|
||||||
|
match exactly.
|
||||||
- Reject cross-origin ceremonies.
|
- Reject cross-origin ceremonies.
|
||||||
- Require discoverable credentials and user verification.
|
- Require discoverable credentials and user verification.
|
||||||
- Request no attestation conveyance.
|
- Request no attestation conveyance.
|
||||||
@@ -56,13 +61,29 @@ JavaScript, or set sessions automatically.
|
|||||||
|
|
||||||
## Recovery and credential lifecycle
|
## Recovery and credential lifecycle
|
||||||
|
|
||||||
Recovery is deliberately host-local and should never be reachable through an
|
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
|
||||||
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
|
must never be reachable through an HTTP handler. It revokes all user sessions
|
||||||
prior enrollment tokens, appends a secret-free audit event, and returns one
|
and pending ceremonies, replaces prior enrollment tokens, appends a
|
||||||
15-minute token. It does not delete existing passkeys. After enrolling a
|
secret-free audit event, and returns one 15-minute token. It does not delete
|
||||||
replacement, the operator reviews credential labels and removes lost keys with
|
existing passkeys. After enrolling a replacement, the operator reviews
|
||||||
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
|
credential labels and removes lost keys with a fresh passkey-bound removal
|
||||||
remotely.
|
ceremony. The final passkey cannot be removed remotely.
|
||||||
|
|
||||||
|
An account may separately expose self-service password-plus-recovery-code
|
||||||
|
recovery through `authrecovery`. `Begin` verifies the password, consumes one
|
||||||
|
printable code, revokes sessions, and returns a short-lived grant—not a normal
|
||||||
|
session. Keep that grant in a narrowly scoped, Secure, HttpOnly, SameSite cookie
|
||||||
|
and never place it in a URL. `BeginPasskey` binds its digest into the WebAuthn
|
||||||
|
ceremony. `FinishPasskey` atomically consumes the grant, stores the verified
|
||||||
|
replacement passkey, replaces the entire recovery-code set, revokes any
|
||||||
|
sessions or ceremonies created during recovery, and returns the new plaintext
|
||||||
|
codes exactly once. It does not issue a session; return the user to normal
|
||||||
|
login after displaying and saving the new codes.
|
||||||
|
|
||||||
|
A failed storage commit leaves the restricted grant available for a fresh
|
||||||
|
ceremony until expiry. A binding mismatch consumes the mismatched ceremony.
|
||||||
|
Applications must use generic failure responses and the same credential-attempt
|
||||||
|
rate limiting as login.
|
||||||
|
|
||||||
Before enabling production mutations, applications should require at least two
|
Before enabling production mutations, applications should require at least two
|
||||||
independent passkeys and complete a local recovery drill.
|
independent passkeys and complete a local recovery drill.
|
||||||
|
|||||||
+57
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -121,7 +122,7 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
detected := http.DetectContentType(data)
|
detected := http.DetectContentType(data)
|
||||||
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
|
if detected == "application/pdf" && validPDF(data) {
|
||||||
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
|
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
|
||||||
result.Digest = sha256.Sum256(result.Data)
|
result.Digest = sha256.Sum256(result.Data)
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -156,6 +157,61 @@ func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, er
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validPDF performs a deliberately bounded structural check without trying to
|
||||||
|
// render or interpret document content. It rejects header-only spoofing and
|
||||||
|
// truncated uploads by requiring a supported header, terminal EOF marker, a
|
||||||
|
// numeric startxref offset, and either a traditional xref table with trailer
|
||||||
|
// or an xref-stream object at that offset.
|
||||||
|
func validPDF(data []byte) bool {
|
||||||
|
if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
headerEnd := bytes.IndexAny(data, "\r\n")
|
||||||
|
if headerEnd < 8 || headerEnd > 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
header := string(bytes.TrimSpace(data[:headerEnd]))
|
||||||
|
if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ")
|
||||||
|
if !bytes.HasSuffix(trimmed, []byte("%%EOF")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
eof := len(trimmed) - len("%%EOF")
|
||||||
|
start := bytes.LastIndex(trimmed[:eof], []byte("startxref"))
|
||||||
|
if start < headerEnd {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cursor := start + len("startxref")
|
||||||
|
for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') {
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
digits := cursor
|
||||||
|
for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 {
|
||||||
|
cursor++
|
||||||
|
}
|
||||||
|
if cursor == digits {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64)
|
||||||
|
if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
target := trimmed[int(offset):start]
|
||||||
|
if bytes.HasPrefix(target, []byte("xref")) {
|
||||||
|
return bytes.Contains(target, []byte("trailer"))
|
||||||
|
}
|
||||||
|
lineEnd := bytes.IndexByte(target, '\n')
|
||||||
|
if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef"))
|
||||||
|
}
|
||||||
|
|
||||||
func Extension(mediaType string) string {
|
func Extension(mediaType string) string {
|
||||||
switch mediaType {
|
switch mediaType {
|
||||||
case "image/jpeg":
|
case "image/jpeg":
|
||||||
|
|||||||
+21
-1
@@ -5,6 +5,7 @@ package media
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -33,7 +34,8 @@ func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPreparePDFIsAttachment(t *testing.T) {
|
func TestPreparePDFIsAttachment(t *testing.T) {
|
||||||
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
|
pdf := minimalPDF()
|
||||||
|
prepared, err := Prepare(bytes.NewReader(pdf), "guide.pdf", Limits{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,18 @@ func TestPreparePDFIsAttachment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrepareRejectsMalformedPDF(t *testing.T) {
|
||||||
|
for _, source := range []string{
|
||||||
|
"%PDF-1.7\nsmall fixture",
|
||||||
|
"%PDF-9.9\nxref\ntrailer\nstartxref\n9\n%%EOF",
|
||||||
|
"%PDF-1.7\nxref\ntrailer\nstartxref\n999999\n%%EOF",
|
||||||
|
} {
|
||||||
|
if _, err := Prepare(strings.NewReader(source), "broken.pdf", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||||
|
t.Fatalf("malformed PDF error=%v source=%q", err, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
||||||
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||||
t.Fatalf("svg err=%v", err)
|
t.Fatalf("svg err=%v", err)
|
||||||
@@ -50,3 +64,9 @@ func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
|||||||
t.Fatalf("large err=%v", err)
|
t.Fatalf("large err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func minimalPDF() []byte {
|
||||||
|
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||||
|
offset := len(prefix)
|
||||||
|
return append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", offset))...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package medialocal
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,7 +21,9 @@ func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
|
prefix := []byte("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")
|
||||||
|
pdf := append(prefix, []byte(fmt.Sprintf("xref\n0 2\n0000000000 65535 f \n0000000009 00000 n \ntrailer\n<< /Size 2 /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(prefix)))...)
|
||||||
|
prepared, err := media.Prepare(bytes.NewReader(pdf), "fixture.pdf", media.Limits{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ type Repository interface {
|
|||||||
Invitations(context.Context, string, int) ([]Invitation, error)
|
Invitations(context.Context, string, int) ([]Invitation, error)
|
||||||
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
|
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
|
||||||
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
||||||
|
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
||||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||||
}
|
}
|
||||||
@@ -331,6 +332,16 @@ func (service *Service) Memberships(ctx context.Context, userID string) ([]Membe
|
|||||||
return service.repository.MembershipsForUser(ctx, userID)
|
return service.repository.MembershipsForUser(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Members returns a bounded, stable list of active and suspended memberships
|
||||||
|
// for one organization. Authorization remains an application concern because
|
||||||
|
// the same storage primitive serves different organization policies.
|
||||||
|
func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) {
|
||||||
|
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
|
||||||
|
return nil, errors.New("organizations: invalid member query")
|
||||||
|
}
|
||||||
|
return service.repository.OrganizationMemberships(ctx, organizationID, limit)
|
||||||
|
}
|
||||||
|
|
||||||
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
|
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
|
||||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
||||||
return nil, errors.New("organizations: invalid team query")
|
return nil, errors.New("organizations: invalid team query")
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type repositoryStub struct {
|
|||||||
invitation Invitation
|
invitation Invitation
|
||||||
invitationErr error
|
invitationErr error
|
||||||
acceptedUser string
|
acceptedUser string
|
||||||
|
members []Membership
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
|
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
|
||||||
@@ -109,4 +110,7 @@ func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte
|
|||||||
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (repository *repositoryStub) OrganizationMemberships(context.Context, string, int) ([]Membership, error) {
|
||||||
|
return repository.members, nil
|
||||||
|
}
|
||||||
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
|
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
|
||||||
|
|||||||
Reference in New Issue
Block a user