Compare commits

...
Author SHA1 Message Date
gamertan 95d50f0888 Complete passkey recovery transaction
verify / verify (push) Successful in 3m37s
2026-09-03 13:09:22 -04:00
gamertan 1f54c75501 Add atomic initial owner bootstrap
verify / verify (push) Successful in 3m33s
2026-09-03 12:50:16 -04:00
gamertan 277cffed8c Bind passkey enrollment to authenticated user
verify / verify (push) Successful in 3m33s
2026-09-03 12:40:20 -04:00
gamertan 92ef63ba00 Add atomic account registration and local media
verify / verify (push) Successful in 3m35s
2026-09-03 11:51:53 -04:00
gamertan 337b56ec1b docs: publish Web Foundations module landing
verify / verify (push) Successful in 3m37s
2026-09-03 10:05:17 -04:00
33 changed files with 3343 additions and 141 deletions
+61
View File
@@ -2,6 +2,67 @@
# Changelog
## 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
- Add a root-local bootstrap transaction that creates the first passkey-only
application owner, non-personal organization, active membership, direct
owner binding, one-time enrollment digest, and secret-free audit records
atomically.
- Fail closed and roll back the entire bootstrap when the application has not
seeded the configured owner role. The raw enrollment token is returned only
after commit and never enters repository state or audit records.
## v0.1.0-preview.11 — 2026-09-03
- Add expected-user completion for authenticated self-service passkey
enrollment. A mismatched ceremony is consumed and fails before credential
persistence, closing an authorization seam found while dogfooding Gamertan's
account security page.
## v0.1.0-preview.10 — 2026-09-03
- Add atomic public-account registration with required canonical email,
password authentication, printable recovery codes, a personal organization,
direct owner access, and an optional initial passkey. Pending registrations
cannot authenticate, and abandoned drafts expire without reserving identity
fields indefinitely.
- Add password verification without session issuance plus operation-bound
WebAuthn completion hooks, allowing applications to require fresh passkeys
for sensitive actions without imposing passkeys on ordinary customer use.
- Add digest-only recovery-code persistence and short-lived, single-use
recovery grants that consume a code and revoke existing sessions atomically.
- Add bounded raster/PDF media preparation and a hardened content-addressed
local filesystem adapter with atomic writes, private modes, and symlink
rejection.
- Add explicit SQLite open-without-migration and schema-requirement APIs while
preserving the historical migrating `Open` behavior for existing adopters.
- Record application dogfood findings and the independent future commerce
module boundary.
## v0.1.0-preview.9 — 2026-09-03
- Add a documented root package and executable composition example so the
module landing page presents its purpose, package-selection guidance,
security model, and `net/http` integration rather than only a directory
index.
- Add the repository's default MPL-2.0 licence at the conventional root path
so Go package tooling can identify the library licence while preserving the
existing file-level exceptions for starters and operational machinery.
- Rework the public README around progressive adoption, explicit design
promises, package selection, assurance gates, and canonical project links.
## v0.1.0-preview.8 — 2026-08-28
- Preserve `http.Hijacker` through the request-evidence middleware so audited,
+375
View File
@@ -0,0 +1,375 @@
SPDX-License-Identifier: MPL-2.0
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+4
View File
@@ -14,3 +14,7 @@ fails closed on missing or misplaced identifiers.
Full texts are in `LICENSES/`. Combining these MPL-covered packages with an
application does not change the licence of the application's own files; changes
to covered files remain subject to the MPL. This summary is not legal advice.
The root [`LICENSE`](LICENSE) contains the default MPL-2.0 text for package
indexers and repository tooling. More specific file-level SPDX identifiers in
the paths above remain authoritative.
+115 -74
View File
@@ -2,106 +2,147 @@
# Gamertan Web Foundations
> Status: `v0.1.0-preview.8` public preview. APIs may change before a stable
> release; Linux is the maintained release platform.
[![Go Reference](https://pkg.go.dev/badge/gamertan.com/web.svg)](https://pkg.go.dev/gamertan.com/web)
[![Verify](https://gitea.speelman.ca/gamertan/web/actions/workflows/verify.yml/badge.svg?branch=main)](https://gitea.speelman.ca/gamertan/web/actions?workflow=verify.yml)
Small, composable Go packages for the unglamorous boundaries of a careful web
application: request identity, structured request logs, browser security,
passwords, passkeys, sessions, permissions, SQLite persistence, and private
analytics.
**Security-conscious building blocks for ordinary `net/http` applications.**
This is a toolkit, not an application framework. Your application keeps its
router, HTTP policy, HTML, authorization decisions, cache behavior, and
deployment. Each package works with `net/http` and can be adopted independently.
Web Foundations provides small, composable Go packages for the unglamorous
boundaries of a careful web application: request identity, structured request
evidence, browser security, authentication, passkeys, permissions,
organizations, SQLite persistence, abuse controls, and private analytics.
The first preview targets modest Linux servers, local files, SQLite, and normal
Go binaries. It requires no Redis, message broker, hosted identity provider,
telemetry service, or JavaScript framework.
It is a toolkit, not an application framework. Your application keeps its
router, handlers, HTML, authorization decisions, cache behavior, and
deployment. Adopt one boundary at a time; Go compiles and links only the
packages you import.
> **Public preview:** `v0.1.0-preview.13`. APIs may change before a stable
> release. Linux is the maintained release platform.
## Why Web Foundations?
| Design promise | What it means in an application |
| --- | --- |
| `net/http` native | Keep the standard router or any compatible router; there is no framework lifecycle. |
| Explicit security boundaries | Trusted proxies, sensitive log fields, browser origins, and scoped authority are configured deliberately. |
| Bounded and fail-closed | Untrusted inputs are size-limited, and security-critical configuration or storage failures do not quietly weaken policy. |
| Storage-neutral core | Interfaces separate identity and access policy from the optional no-CGO SQLite adapter. |
| Self-hosted by default | No Redis, message broker, hosted identity provider, telemetry service, or JavaScript framework is required. |
## Start with one boundary
| Application need | Begin with |
| --- | --- |
| Request IDs and trustworthy client addresses | [`requestmeta`](requestmeta) |
| Bounded structured request evidence | [`requestmeta`](requestmeta) + [`requestlog`](requestlog) |
| Browser and HTTP security primitives | [`websec`](websec) |
| Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) |
| Atomic password-plus-passkey registration | [`account`](account) |
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Printable single-use recovery codes | [`authrecovery`](authrecovery) |
| Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Organizations, teams, and invitations | [`organizations`](organizations) |
| Organization-scoped roles and temporary access | [`access`](access) |
| Application-classified request abuse | [`abuse`](abuse) |
| Disposable request-log summaries | [`analytics`](analytics) |
The [getting-started guide](docs/GETTING_STARTED.md) explains what each package
owns—and, just as importantly, what remains application policy.
## Install
Pin the preview in an application module, then import only the packages that
application needs:
Pin the preview in an application module:
```bash
go get gamertan.com/web@v0.1.0-preview.8
go get gamertan.com/web@v0.1.0-preview.13
go mod verify
```
An application may also name the first package it intends to adopt:
An application may name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.8
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
```
The version belongs to the `gamertan.com/web` module. Go compiles and links
only the packages the application imports. See the [getting-started guide](docs/GETTING_STARTED.md)
and [module-boundary policy](docs/MODULES.md) before choosing a first slice.
The version belongs to the `gamertan.com/web` module. See the
[module-boundary policy](docs/MODULES.md) before selecting a first slice.
Canonical source, issues, security policy, and release notes live on
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin.
## Compose a request path
Linux is the required and supported release platform. WSL may be used as a
Linux development environment. Native Windows is not a release gate or support
promise; downstream users may evaluate the ordinary Go packages elsewhere
without turning that portability into a maintained compatibility claim.
Build middleware from the application outward. The request metadata resolver
is outermost so every package inside it observes the same request identity:
## Packages
```text
request
└─ requestmeta ─ websec ─ requestlog ─ your router and handlers
```
- [`requestmeta`](requestmeta): trusted-proxy resolution, HTTPS/origin metadata,
and request IDs.
- [`requestlog`](requestlog): bounded versioned records, middleware, sinks, and
private JSONL.
- [`websec`](websec): headers, origin checks, CSRF, redirects, body limits, and
rate limits.
- [`abuse`](abuse): application-classified request abuse with pluggable persistence.
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
passwords, forced first-login rotation, local administrative recovery,
session revocation, platform-level permissions, cookies, and a no-CGO SQLite
adapter.
- [`authwebauthn`](authwebauthn): passkey-only registration, discoverable
login, fresh-operation approval, local recovery tokens, and an ES256-first
WebAuthn policy. See the [passkey integration guide](docs/PASSKEYS.md).
- [`organizations`](organizations) and [`access`](access): organizations,
teams, invitations, resource hierarchy, scoped roles, and audited temporary
access without turning platform operation into tenant-data access.
- [`analytics`](analytics): safe and sensitive aggregate projections over request
records.
```go
var handler http.Handler = router
handler = requestlog.Middleware(sink, logPolicy)(handler)
handler = websec.Headers(headerPolicy)(handler)
handler = resolver.Middleware(handler)
```
The copyable starter under `starters/basic` demonstrates the packages without
turning them into a router or template system.
The copyable [`starters/basic`](starters/basic) server demonstrates that
composition with loopback binding, graceful shutdown, and optional private
JSONL logging.
## Identity and access
- [`auth`](auth) defines storage-neutral users, password credentials, opaque
sessions, platform permissions, and audit events.
- [`account`](account) composes the first password, printable recovery codes,
personal organization, and owner access as one registration transaction,
optionally including an initial passkey.
- [`authhttp`](authhttp) connects those sessions to secure browser cookies and
request context without owning login routes or pages.
- [`authwebauthn`](authwebauthn) provides discoverable passkey login,
enrollment, operation-bound fresh approval, and bounded recovery.
- [`organizations`](organizations) and [`access`](access) keep platform
operation separate from organization-data authority while supporting teams,
invitations, scoped roles, and audited temporary access.
See the [passkey integration guide](docs/PASSKEYS.md) and
[organization/access model](docs/ORGANIZATIONS.md) before exposing account or
administration routes.
## Security and assurance
Client addresses are accepted from forwarding headers only when the immediate
peer and every skipped proxy are explicitly trusted. Sensitive request fields
are off by default. Cryptographic entropy failures fail closed. Logs and
account databases remain private application data and never belong in source
releases.
Every change is checked with formatting, tests, the race detector, vet,
dependency policy, licence policy, public-snapshot allowlisting, and a
reproducible starter build. Scheduled assurance adds vulnerability scanning and
bounded fuzz campaigns.
Read [SECURITY.md](SECURITY.md), the [threat model](docs/THREAT_MODEL.md),
[adoption contract](docs/ADOPTION.md), and
[dependency boundary](docs/DEPENDENCIES.md) before production adoption.
## HTML and templates
Web Foundations deliberately does not provide a template language. Sandwich
Hime is the preferred companion for Gamertan applications that want HTML-first,
typed, ahead-of-time Go templates. The two projects remain independently
usable: this module does not import the `sando` runtime, and Sandwich Hime does
not own middleware, authentication, logging, routing, or deployment.
typed, ahead-of-time Go templates. The projects remain independently usable.
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md), then follow the official
[first site tutorial](https://sandwichhime.com/docs/tutorial/) and
[application integration tutorial](https://sandwichhime.com/docs/tutorial/application/).
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md) and the official
[first-site tutorial](https://sandwichhime.com/docs/tutorial/).
## Security boundary
## Source, support, and licensing
Client addresses are accepted from forwarding headers only when the immediate
peer and every skipped proxy are explicitly trusted. Sensitive request fields
are off by default. Cryptographic entropy failures fail closed. Logs and account
databases remain private application data and never belong in source releases.
Canonical source, issues, security policy, and release notes live on
[Speelman Forge](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin.
See [SECURITY.md](SECURITY.md), [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md),
the [application adoption contract](docs/ADOPTION.md), and
[docs/SERVICES_ROADMAP.md](docs/SERVICES_ROADMAP.md).
## Licensing
This is a multi-license repository with exact file-level SPDX identifiers:
- embeddable packages and adapters: MPL-2.0;
- future standalone network services and operational machinery: AGPL-3.0-only;
- starters, examples, and reusable configuration: 0BSD.
See [LICENSES.md](LICENSES.md). No standalone auth or logging server is included
in this preview.
The libraries and adapters are MPL-2.0. Starters and reusable examples are
0BSD. Future standalone services and operational machinery are
AGPL-3.0-only. Exact file-level SPDX identifiers remain authoritative; see the
[licensing map](LICENSES.md) and [third-party notices](THIRD_PARTY_NOTICES.md).
+338
View File
@@ -0,0 +1,338 @@
// SPDX-License-Identifier: MPL-2.0
// Package account orchestrates atomic account registration. Email is the
// canonical sign-in identifier; username remains the stable public/profile
// identity. Applications may finish with password-only base access or include
// an initial passkey when their onboarding policy requires one.
package account
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
var (
ErrRegistrationNotFound = errors.New("account: registration not found")
ErrPasskeysUnavailable = errors.New("account: passkeys are unavailable")
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
)
type Registration struct {
Digest [32]byte
User auth.User
CreatedAt, ExpiresAt time.Time
}
type RegistrationCompletion struct {
Credential *authwebauthn.Credential
RecoveryDigests [][32]byte
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
CompletedAt time.Time
}
type Repository interface {
CreateRegistration(context.Context, Registration, string, auth.AuditEvent) error
Registration(context.Context, [32]byte, time.Time) (Registration, error)
CompleteRegistration(context.Context, [32]byte, RegistrationCompletion) error
}
type Passkeys interface {
BeginAccountRegistration(context.Context, string, string, []byte) (authwebauthn.BeginResult, error)
FinishAccountRegistration(context.Context, string, []byte, []byte, authwebauthn.RegistrationCommit) (authwebauthn.Credential, error)
}
type Sessions interface {
IssueSession(context.Context, string, time.Duration) (string, auth.Principal, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
RegistrationTTL time.Duration
SessionLifetime time.Duration
RecoveryCodes int
OwnerRole string
}
type Service struct {
repository Repository
passkeys Passkeys
sessions Sessions
random io.Reader
now func() time.Time
draftTTL time.Duration
sessionTTL time.Duration
codeCount int
ownerRole string
}
func New(repository Repository, passkeys Passkeys, sessions Sessions, options Options) (*Service, error) {
if repository == nil || sessions == nil {
return nil, errors.New("account: repository and sessions are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.RegistrationTTL == 0 {
options.RegistrationTTL = 15 * time.Minute
}
if options.SessionLifetime == 0 {
options.SessionLifetime = 12 * time.Hour
}
if options.RecoveryCodes == 0 {
options.RecoveryCodes = authrecovery.DefaultCodeCount
}
if options.OwnerRole == "" {
options.OwnerRole = "owner"
}
if options.RegistrationTTL < 5*time.Minute || options.RegistrationTTL > time.Hour || options.SessionLifetime < 5*time.Minute || options.SessionLifetime > 30*24*time.Hour || options.RecoveryCodes < 5 || options.RecoveryCodes > 20 || !roleName(options.OwnerRole) {
return nil, errors.New("account: invalid registration policy")
}
return &Service{repository: repository, passkeys: passkeys, sessions: sessions, random: options.Random, now: options.Now, draftTTL: options.RegistrationTTL, sessionTTL: options.SessionLifetime, codeCount: options.RecoveryCodes, ownerRole: options.OwnerRole}, nil
}
type StartInput struct {
Email, Username, DisplayName, Password string
}
type StartResult struct {
RegistrationToken string
User auth.User
ExpiresAt time.Time
}
// Start validates and stores a bounded pending registration. The returned
// secret is displayed only to the same browser flow and binds every following
// ceremony to this draft.
func (service *Service) Start(ctx context.Context, input StartInput) (StartResult, error) {
email, err := canonicalEmail(input.Email)
if err != nil {
return StartResult{}, err
}
username := strings.TrimSpace(input.Username)
displayName := strings.TrimSpace(input.DisplayName)
if !usernamePattern.MatchString(username) || displayName == "" || len(displayName) > 128 || strings.ContainsAny(displayName, "\x00\r\n") {
return StartResult{}, errors.New("account: invalid profile")
}
passwordHash, err := auth.HashPasswordWithRandom(input.Password, service.random)
if err != nil {
return StartResult{}, err
}
userID, err := service.token(18)
if err != nil {
return StartResult{}, err
}
rawToken, err := service.token(32)
if err != nil {
return StartResult{}, err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", RegistrationPending: true, CreatedAt: now, UpdatedAt: now}
registration := Registration{Digest: sha256.Sum256([]byte(rawToken)), User: user, CreatedAt: now, ExpiresAt: now.Add(service.draftTTL)}
audit, err := service.authAudit(user.ID, "auth.account.registration.start", "A public account registration was started.")
if err != nil {
return StartResult{}, err
}
if err = service.repository.CreateRegistration(ctx, registration, passwordHash, audit); err != nil {
return StartResult{}, err
}
return StartResult{RegistrationToken: rawToken, User: user, ExpiresAt: registration.ExpiresAt}, nil
}
func (service *Service) BeginPasskey(ctx context.Context, registrationToken, label string) (authwebauthn.BeginResult, error) {
if service.passkeys == nil {
return authwebauthn.BeginResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return authwebauthn.BeginResult{}, err
}
return service.passkeys.BeginAccountRegistration(ctx, registration.User.ID, label, []byte(registrationToken))
}
type FinishResult struct {
User auth.User
Organization organizations.Organization
RecoveryCodes []string
SessionToken string
Principal auth.Principal
PasskeyCredential authwebauthn.Credential
}
// FinishPassword activates a base account without requiring WebAuthn. The
// application can require an operation-bound passkey assertion later for
// sensitive permissions.
func (service *Service) FinishPassword(ctx context.Context, registrationToken string) (FinishResult, error) {
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
completion.AuthAudit, err = service.authAudit(registration.User.ID, "auth.account.registration.complete", "The password-authenticated account registration was completed.")
if err != nil {
return FinishResult{}, err
}
if err = service.repository.CompleteRegistration(ctx, registration.Digest, completion); err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, authwebauthn.Credential{})
}
// FinishWithPasskey completes the same atomic account transaction while also
// storing a verified initial passkey.
func (service *Service) FinishWithPasskey(ctx context.Context, registrationToken, ceremonyToken string, response []byte) (FinishResult, error) {
if service.passkeys == nil {
return FinishResult{}, ErrPasskeysUnavailable
}
registration, err := service.registration(ctx, registrationToken)
if err != nil {
return FinishResult{}, err
}
codes, recoveryDigests, err := authrecovery.GenerateCodeSet(service.random, service.codeCount)
if err != nil {
return FinishResult{}, err
}
completion, err := service.completion(registration, recoveryDigests)
if err != nil {
return FinishResult{}, err
}
credential, err := service.passkeys.FinishAccountRegistration(ctx, ceremonyToken, []byte(registrationToken), response, func(commitCtx context.Context, verified authwebauthn.Credential, passkeyAudit auth.AuditEvent) error {
completion.Credential = &verified
completion.AuthAudit = passkeyAudit
return service.repository.CompleteRegistration(commitCtx, registration.Digest, completion)
})
if err != nil {
return FinishResult{}, err
}
return service.finishSession(ctx, registration, completion, codes, credential)
}
func (service *Service) finishSession(ctx context.Context, registration Registration, completion RegistrationCompletion, codes []string, credential authwebauthn.Credential) (FinishResult, error) {
user := registration.User
user.RegistrationPending = false
user.UpdatedAt = completion.CompletedAt
result := FinishResult{User: user, Organization: completion.Organization, RecoveryCodes: codes, PasskeyCredential: credential}
sessionToken, principal, err := service.sessions.IssueSession(ctx, user.ID, service.sessionTTL)
if err != nil {
// Registration is already durable. Preserve the one-time recovery codes
// in the returned result so an application can display them while asking
// the user to sign in again.
return result, fmt.Errorf("account: registration completed but session issuance failed: %w", err)
}
result.SessionToken, result.Principal = sessionToken, principal
return result, nil
}
func (service *Service) completion(registration Registration, recoveryDigests [][32]byte) (RegistrationCompletion, error) {
organizationID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
bindingID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
slugBytes := make([]byte, 6)
if _, err = io.ReadFull(service.random, slugBytes); err != nil {
return RegistrationCompletion{}, fmt.Errorf("account: secure randomness unavailable: %w", err)
}
now := service.now().UTC()
organization := organizations.Organization{ID: organizationID, Slug: "personal-" + hex.EncodeToString(slugBytes), Name: registration.User.DisplayName + " — Personal", Status: "active", Personal: true, Revision: 1, CreatedAt: now, UpdatedAt: now}
membership := organizations.Membership{OrganizationID: organizationID, UserID: registration.User.ID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: registration.User.ID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: registration.User.ID, GrantedAt: now}
organizationAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
accessAuditID, err := service.token(18)
if err != nil {
return RegistrationCompletion{}, err
}
return RegistrationCompletion{
RecoveryDigests: recoveryDigests,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
OrganizationAudit: organizations.AuditEvent{ID: organizationAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "organization.personal.create", ResourceType: "organization", ResourceID: organizationID, Summary: "Personal organization created during account registration.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: accessAuditID, OrganizationID: organizationID, ActorUserID: registration.User.ID, Action: "access.owner.grant", ResourceType: "user", ResourceID: registration.User.ID, Summary: "Initial personal-organization owner access granted.", CreatedAt: now},
CompletedAt: now,
}, nil
}
func (service *Service) registration(ctx context.Context, raw string) (Registration, error) {
if len(raw) < 32 || len(raw) > 128 {
return Registration{}, ErrRegistrationNotFound
}
if _, err := base64.RawURLEncoding.DecodeString(raw); err != nil {
return Registration{}, ErrRegistrationNotFound
}
return service.repository.Registration(ctx, sha256.Sum256([]byte(raw)), service.now().UTC())
}
func (service *Service) authAudit(userID, action, summary string) (auth.AuditEvent, error) {
id, err := service.token(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: userID, Action: action, ResourceType: "user", ResourceID: userID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) token(size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return "", fmt.Errorf("account: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func canonicalEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
parsed, err := mail.ParseAddress(value)
if err != nil || parsed.Address != value || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("account: a valid email address is required")
}
return value, nil
}
func roleName(value string) bool {
if len(value) < 2 || len(value) > 128 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value[1:] {
if character < 'a' || character > 'z' && (character < '0' || character > '9') && character != '.' && character != '_' && character != '-' {
return false
}
}
return true
}
+35 -17
View File
@@ -31,8 +31,14 @@ type User struct {
ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
// RegistrationPending keeps a partially completed public registration
// ineligible for authentication until its credentials, personal scope, and
// recovery material have been committed atomically.
RegistrationPending bool
}
func (user User) Active() bool { return user.Status == "active" && !user.RegistrationPending }
type Principal struct {
User User
Roles []string
@@ -167,7 +173,7 @@ func (service *Service) ChangePassword(ctx context.Context, userID, currentPassw
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
if !user.Active() {
return ErrInactiveUser
}
if currentPassword == newPassword {
@@ -199,7 +205,7 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
}
if user.Status != "active" {
if !user.Active() {
return User{}, ErrInactiveUser
}
if VerifyPassword(currentHash, input.TemporaryPassword) {
@@ -233,24 +239,36 @@ func (service *Service) ResetPassword(ctx context.Context, input AdministrativeP
return user, nil
}
// VerifyPassword verifies the password credential for an active account
// without creating a session. Applications use it as the first step of a
// bounded multi-factor ceremony and must not treat success as an authenticated
// browser session on its own.
func (service *Service) VerifyPassword(ctx context.Context, identifier, password string) (User, error) {
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return User{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return User{}, ErrInvalidCredentials
}
if !user.Active() {
return User{}, ErrInactiveUser
}
return user, nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")
}
user, hash, err := service.repository.CredentialByIdentifier(ctx, strings.TrimSpace(identifier))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, ErrInvalidCredentials
}
user, err := service.VerifyPassword(ctx, identifier, password)
if err != nil {
_ = VerifyPassword(dummyPasswordHash, password)
return "", Principal{}, fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(hash, password) {
return "", Principal{}, ErrInvalidCredentials
}
if user.Status != "active" {
return "", Principal{}, ErrInactiveUser
return "", Principal{}, err
}
return service.IssueSession(ctx, user.ID, lifetime)
}
@@ -282,7 +300,7 @@ func (service *Service) IssueSession(ctx context.Context, userID string, lifetim
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err
}
if principal.User.Status != "active" {
if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, ErrInactiveUser
}
@@ -302,7 +320,7 @@ func (service *Service) Session(ctx context.Context, token string) (Principal, e
if err != nil {
return Principal{}, fmt.Errorf("auth: load session: %w", err)
}
if principal.User.Status != "active" {
if !principal.User.Active() {
_ = service.repository.DeleteSession(ctx, digest)
return Principal{}, ErrInactiveUser
}
+41
View File
@@ -57,6 +57,31 @@ func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
}
}
func TestVerifyPasswordDoesNotIssueSession(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
repository := &credentialRepository{
user: User{ID: "valid-user-id", Username: "person", Email: "person@example.test", Status: "active"},
hash: hash,
}
service, err := New(repository, Options{})
if err != nil {
t.Fatal(err)
}
user, err := service.VerifyPassword(t.Context(), "person@example.test", "correct horse battery staple")
if err != nil || user.ID != repository.user.ID {
t.Fatalf("user=%+v err=%v", user, err)
}
if repository.sessionCreated {
t.Fatal("password verification issued a session")
}
if _, err = service.VerifyPassword(t.Context(), "person@example.test", "wrong password"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong password err=%v", err)
}
}
type recordingRepository struct {
repositoryStub
deleted bool
@@ -68,6 +93,22 @@ type activeSessionRepository struct {
deleted bool
}
type credentialRepository struct {
repositoryStub
user User
hash string
sessionCreated bool
}
func (repository *credentialRepository) CredentialByIdentifier(context.Context, string) (User, string, error) {
return repository.user, repository.hash, nil
}
func (repository *credentialRepository) CreateSession(context.Context, Session) error {
repository.sessionCreated = true
return nil
}
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
return repository.principal, Session{}, nil
}
+292
View File
@@ -0,0 +1,292 @@
// SPDX-License-Identifier: MPL-2.0
// Package authrecovery provides printable one-time recovery codes and bounded
// recovery grants for password-plus-passkey accounts.
package authrecovery
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const DefaultCodeCount = 10
var (
ErrCodeNotFound = errors.New("authrecovery: recovery code not found")
ErrGrantNotFound = errors.New("authrecovery: recovery grant not found")
ErrPasskeyUnavailable = errors.New("authrecovery: passkey recovery is unavailable")
)
type Grant struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Repository interface {
ReplaceRecoveryCodes(context.Context, string, [][32]byte, time.Time, auth.AuditEvent) error
ConsumeRecoveryCodeAndCreateGrant(context.Context, string, [32]byte, Grant, auth.AuditEvent) 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 {
VerifyPassword(context.Context, string, string) (auth.User, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
CodeCount int
GrantLifetime time.Duration
Passkeys Passkeys
}
type Service struct {
repository Repository
passwords PasswordVerifier
random io.Reader
now func() time.Time
count int
grantTTL time.Duration
passkeys Passkeys
}
func New(repository Repository, passwords PasswordVerifier, options Options) (*Service, error) {
if repository == nil || passwords == nil {
return nil, errors.New("authrecovery: repository and password verifier are required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.CodeCount == 0 {
options.CodeCount = DefaultCodeCount
}
if options.GrantLifetime == 0 {
options.GrantLifetime = 10 * 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 &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
// once; only domain-separated digests are persisted.
func (service *Service) ReplaceCodes(ctx context.Context, userID, actorUserID string) ([]string, error) {
codes, digests, err := GenerateCodeSet(service.random, service.count)
if err != nil {
return nil, err
}
now := service.now().UTC()
auditID, err := token(service.random, 18)
if err != nil {
return nil, err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: actorUserID, Action: "auth.recovery-codes.replace", ResourceType: "user", ResourceID: userID, Summary: "The account recovery-code set was replaced.", CreatedAt: now}
if err = service.repository.ReplaceRecoveryCodes(ctx, userID, digests, now, audit); err != nil {
return nil, err
}
return codes, nil
}
// Begin verifies the password, atomically consumes one code, revokes sessions,
// and returns a short-lived grant. Applications bind the grant to the passkey
// replacement ceremony and do not issue a normal session from it.
func (service *Service) Begin(ctx context.Context, identifier, password, code string) (auth.User, string, error) {
user, err := service.passwords.VerifyPassword(ctx, identifier, password)
if err != nil {
return auth.User{}, "", err
}
digest, err := DigestCode(code)
if err != nil {
return auth.User{}, "", auth.ErrInvalidCredentials
}
rawGrant, err := token(service.random, 32)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
grant := Grant{Digest: sha256.Sum256([]byte(rawGrant)), UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(service.grantTTL)}
auditID, err := token(service.random, 18)
if err != nil {
return auth.User{}, "", err
}
audit := auth.AuditEvent{ID: auditID, ActorUserID: user.ID, Action: "auth.recovery.begin", ResourceType: "user", ResourceID: user.ID, Summary: "A recovery code was consumed and existing sessions were revoked.", CreatedAt: now}
if err = service.repository.ConsumeRecoveryCodeAndCreateGrant(ctx, user.ID, digest, grant, audit); err != nil {
if errors.Is(err, ErrCodeNotFound) {
return auth.User{}, "", auth.ErrInvalidCredentials
}
return auth.User{}, "", err
}
return user, rawGrant, nil
}
func (service *Service) TakeGrant(ctx context.Context, raw string) (auth.User, error) {
digest, err := grantDigest(raw)
if err != nil {
return auth.User{}, err
}
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) {
if random == nil || count < 1 || count > 20 {
return nil, nil, errors.New("authrecovery: invalid code-set request")
}
codes := make([]string, 0, count)
digests := make([][32]byte, 0, count)
seen := make(map[[32]byte]struct{}, count)
for len(codes) < count {
value := make([]byte, 16)
if _, err := io.ReadFull(random, value); err != nil {
return nil, nil, fmt.Errorf("authrecovery: secure randomness unavailable: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value)
code := strings.Join([]string{encoded[0:5], encoded[5:10], encoded[10:15], encoded[15:20], encoded[20:26]}, "-")
digest, _ := DigestCode(code)
if _, duplicate := seen[digest]; duplicate {
continue
}
seen[digest] = struct{}{}
codes = append(codes, code)
digests = append(digests, digest)
}
return codes, digests, nil
}
func DigestCode(code string) ([32]byte, error) {
normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(code), "-", ""), " ", ""))
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalized)
if err != nil || len(decoded) != 16 {
return [32]byte{}, ErrCodeNotFound
}
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) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
+184
View File
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: MPL-2.0
package authrecovery_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestRecoveryCodeIsSingleUseAndRevokesSessions(t *testing.T) {
now := time.Date(2026, 9, 3, 12, 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.person", Email: "recover@example.test", DisplayName: "Recover Person", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
recovery, err := authrecovery.New(store, authService, authrecovery.Options{Random: random, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
codes, err := recovery.ReplaceCodes(t.Context(), user.ID, user.ID)
if err != nil || len(codes) != authrecovery.DefaultCodeCount {
t.Fatalf("codes=%d err=%v", len(codes), err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
loaded, grant, err := recovery.Begin(t.Context(), strings.ToUpper(user.Email), "correct horse battery staple", strings.ToLower(codes[0]))
if err != nil || loaded.ID != user.ID || grant == "" {
t.Fatalf("loaded=%+v grant=%q err=%v", loaded, grant, err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, _, err = recovery.Begin(t.Context(), user.Email, "correct horse battery staple", codes[0]); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("code replay err=%v", err)
}
loaded, err = recovery.TakeGrant(t.Context(), grant)
if err != nil || loaded.ID != user.ID {
t.Fatalf("grant user=%+v err=%v", loaded, err)
}
if _, err = recovery.TakeGrant(t.Context(), grant); !errors.Is(err, authrecovery.ErrGrantNotFound) {
t.Fatalf("grant replay err=%v", err)
}
}
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 }
func (reader *counterReader) Read(target []byte) (int, error) {
for index := range target {
reader.value++
target[index] = reader.value
}
return len(target), nil
}
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
)
func (store *Store) CreateRegistration(ctx context.Context, registration account.Registration, passwordHash string, audit auth.AuditEvent) error {
user := registration.User
if zeroDigest(registration.Digest) || !validPendingUser(user) || !registration.CreatedAt.Equal(user.CreatedAt) || !registration.ExpiresAt.After(registration.CreatedAt) || registration.ExpiresAt.Sub(registration.CreatedAt) > time.Hour || !text(passwordHash, 1024, false) || !validAuditEvent(audit) || audit.ActorUserID != user.ID || audit.ResourceID != user.ID {
return errors.New("authsqlite: invalid account registration")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// A bounded abandoned registration must not reserve its email or username
// forever. Deleting the pending user cascades every private draft artifact.
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_users WHERE registration_pending=1 AND id IN (SELECT user_id FROM gwf_account_registrations WHERE expires_at<=?)`, registration.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,0,1,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_password_credentials(user_id,password_hash,changed_at) VALUES(?,?,?)`, user.ID, passwordHash, user.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_registrations(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, registration.Digest[:], user.ID, registration.CreatedAt.Unix(), registration.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) Registration(ctx context.Context, digest [32]byte, now time.Time) (account.Registration, error) {
if zeroDigest(digest) || now.IsZero() {
return account.Registration{}, account.ErrRegistrationNotFound
}
var registration account.Registration
var passwordChangeRequired, pending int
var created, updated, draftCreated, expires int64
err := 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,r.created_at,r.expires_at FROM gwf_account_registrations r JOIN gwf_users u ON u.id=r.user_id WHERE r.token_hash=? AND r.expires_at>? AND u.registration_pending=1`, digest[:], now.Unix()).Scan(&registration.User.ID, &registration.User.Username, &registration.User.Email, &registration.User.DisplayName, &registration.User.Status, &passwordChangeRequired, &pending, &created, &updated, &draftCreated, &expires)
if errors.Is(err, sql.ErrNoRows) {
return account.Registration{}, account.ErrRegistrationNotFound
}
if err != nil {
return account.Registration{}, err
}
registration.Digest = digest
registration.User.PasswordChangeRequired = passwordChangeRequired == 1
registration.User.RegistrationPending = pending == 1
registration.User.CreatedAt = time.Unix(created, 0).UTC()
registration.User.UpdatedAt = time.Unix(updated, 0).UTC()
registration.CreatedAt = time.Unix(draftCreated, 0).UTC()
registration.ExpiresAt = time.Unix(expires, 0).UTC()
return registration, nil
}
func (store *Store) CompleteRegistration(ctx context.Context, digest [32]byte, completion account.RegistrationCompletion) error {
userID := completion.Membership.UserID
validOptionalCredential := completion.Credential == nil || validCredential(*completion.Credential, true) && completion.Credential.UserID == userID
if zeroDigest(digest) || !validOptionalCredential || len(completion.RecoveryDigests) < 5 || len(completion.RecoveryDigests) > 20 || !validOrganization(completion.Organization) || !completion.Organization.Personal || completion.Membership.OrganizationID != completion.Organization.ID || !opaqueID(userID) || completion.Membership.Status != "active" || completion.Membership.JoinedAt.IsZero() || !validOwnerBinding(completion.OwnerBinding, completion.Organization.ID, userID) || !validAuditEvent(completion.AuthAudit) || completion.AuthAudit.ActorUserID != userID || !validOrganizationAudit(completion.OrganizationAudit, completion.Organization.ID) || !validAccessAudit(completion.AccessAudit) || completion.AccessAudit.OrganizationID != completion.Organization.ID || completion.CompletedAt.IsZero() {
return errors.New("authsqlite: invalid account registration completion")
}
seen := make(map[[32]byte]struct{}, len(completion.RecoveryDigests))
for _, recoveryDigest := range completion.RecoveryDigests {
if zeroDigest(recoveryDigest) {
return errors.New("authsqlite: invalid recovery code digest")
}
if _, exists := seen[recoveryDigest]; exists {
return errors.New("authsqlite: duplicate recovery code digest")
}
seen[recoveryDigest] = struct{}{}
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var registeredUserID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_account_registrations WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], completion.CompletedAt.Unix()).Scan(&registeredUserID)
if errors.Is(err, sql.ErrNoRows) {
return account.ErrRegistrationNotFound
}
if err != nil {
return err
}
if registeredUserID != userID {
return account.ErrRegistrationNotFound
}
var pending int
if err = tx.QueryRowContext(ctx, `SELECT registration_pending FROM gwf_users WHERE id=? AND status='active'`, userID).Scan(&pending); err != nil || pending != 1 {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return account.ErrRegistrationNotFound
}
if completion.Credential != nil {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, completion.Credential.ID, userID, completion.Credential.Label, []byte(completion.Credential.Data), completion.Credential.CreatedAt.Unix()); err != nil {
return err
}
}
for _, recoveryDigest := range completion.RecoveryDigests {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at,used_at) VALUES(?,?,?,NULL)`, userID, recoveryDigest[:], completion.CompletedAt.Unix()); err != nil {
return err
}
}
organization := completion.Organization
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,1,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, userID, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, completion.Membership.OrganizationID, userID, completion.Membership.Status, completion.Membership.JoinedAt.Unix()); err != nil {
return err
}
binding := completion.OwnerBinding
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=?`, binding.ID, organization.ID, userID, binding.Role, userID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: account owner role has not been seeded")
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET registration_pending=0,updated_at=? WHERE id=? AND registration_pending=1`, completion.CompletedAt.Unix(), userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, completion.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, completion.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, completion.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
func validPendingUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && user.RegistrationPending && !user.PasswordChangeRequired && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validOwnerBinding(binding access.Binding, organizationID, userID string) bool {
return opaqueID(binding.ID) && binding.SubjectKind == access.User && binding.SubjectID == userID && safeName(binding.Role) && binding.Scope.OrganizationID == organizationID && binding.Scope.ProjectID == "" && binding.Scope.EnvironmentID == "" && binding.Scope.ServiceID == "" && binding.GrantedBy == userID && !binding.GrantedAt.IsZero()
}
var _ account.Repository = (*Store)(nil)
+155
View File
@@ -0,0 +1,155 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/account"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestAccountRegistrationCommitsEveryRequiredArtifact(t *testing.T) {
store, authService, accountService, passkeys := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "PERSON@example.test", Username: "person.one", DisplayName: "Person One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if started.User.Email != "person@example.test" || !started.User.RegistrationPending {
t.Fatalf("pending user=%+v", started.User)
}
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("pending password verification err=%v", err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`))
if err != nil {
t.Fatal(err)
}
if finished.User.RegistrationPending || finished.User.ID != started.User.ID || len(finished.RecoveryCodes) != 10 || finished.SessionToken == "" || !finished.Organization.Personal {
t.Fatalf("finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
if passkeys.userID != started.User.ID || passkeys.binding != started.RegistrationToken {
t.Fatalf("passkey binding user=%q binding=%q", passkeys.userID, passkeys.binding)
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND role_name='owner'`, started.User.ID, 1)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 0)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); err != nil {
t.Fatalf("completed password verification: %v", err)
}
}
func TestPasswordAccountCanFinishWithoutPasskey(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, true)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "reader@example.test", Username: "reader.one", DisplayName: "Reader One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
finished, err := accountService.FinishPassword(t.Context(), started.RegistrationToken)
if err != nil {
t.Fatal(err)
}
if finished.SessionToken == "" || len(finished.RecoveryCodes) != 10 || len(finished.PasskeyCredential.ID) != 0 {
t.Fatalf("password finish=%+v code-count=%d", finished, len(finished.RecoveryCodes))
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 10)
if _, err = authService.VerifyPassword(t.Context(), "reader@example.test", "correct horse battery staple"); err != nil {
t.Fatalf("password account not active: %v", err)
}
}
func TestAccountRegistrationRollsBackWhenOwnerPolicyIsMissing(t *testing.T) {
store, authService, accountService, _ := accountFixture(t, false)
started, err := accountService.Start(t.Context(), account.StartInput{Email: "rollback@example.test", Username: "rollback.one", DisplayName: "Rollback One", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
if _, err = accountService.BeginPasskey(t.Context(), started.RegistrationToken, "Primary passkey"); err != nil {
t.Fatal(err)
}
if _, err = accountService.FinishWithPasskey(t.Context(), started.RegistrationToken, "ceremony-token", []byte(`{"id":"fixture"}`)); err == nil {
t.Fatal("completion unexpectedly succeeded without seeded owner role")
}
assertCount(t, store, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_recovery_codes WHERE user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_organizations WHERE personal_owner_user_id=?`, started.User.ID, 0)
assertCount(t, store, `SELECT COUNT(*) FROM gwf_account_registrations WHERE user_id=?`, started.User.ID, 1)
if _, err = authService.VerifyPassword(t.Context(), started.User.Email, "correct horse battery staple"); !errors.Is(err, auth.ErrInactiveUser) {
t.Fatalf("rolled-back account became usable: %v", err)
}
}
func accountFixture(t *testing.T, seedOwner bool) (*Store, *auth.Service, *account.Service, *accountPasskeys) {
t.Helper()
store, err := Open(filepath.Join(t.TempDir(), "identity.sqlite"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
if seedOwner {
err = store.SeedAccessPolicy(t.Context(), access.Policy{
Roles: map[string]string{"owner": "Personal organization owner"},
Permissions: map[string]string{"account.view": "View the account"},
Grants: map[string][]string{"owner": {"account.view"}},
})
if err != nil {
t.Fatal(err)
}
}
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
passkeys := &accountPasskeys{now: time.Now().UTC()}
accountService, err := account.New(store, passkeys, authService, account.Options{})
if err != nil {
t.Fatal(err)
}
return store, authService, accountService, passkeys
}
type accountPasskeys struct {
userID, binding string
now time.Time
}
func (passkeys *accountPasskeys) BeginAccountRegistration(_ context.Context, userID, _ string, binding []byte) (authwebauthn.BeginResult, error) {
passkeys.userID = userID
passkeys.binding = string(binding)
return authwebauthn.BeginResult{CeremonyToken: "ceremony-token", PublicKey: []byte(`{}`), ExpiresAt: passkeys.now.Add(5 * time.Minute)}, nil
}
func (passkeys *accountPasskeys) FinishAccountRegistration(ctx context.Context, ceremonyToken string, binding, _ []byte, commit authwebauthn.RegistrationCommit) (authwebauthn.Credential, error) {
if ceremonyToken != "ceremony-token" || string(binding) != passkeys.binding {
return authwebauthn.Credential{}, authwebauthn.ErrOperationBinding
}
credential := authwebauthn.Credential{ID: []byte("fixture-credential-id"), UserID: passkeys.userID, Label: "Primary passkey", Data: []byte(`{"id":"fixture-credential-id"}`), CreatedAt: passkeys.now}
audit := auth.AuditEvent{ID: "passkey-audit-id", ActorUserID: passkeys.userID, Action: "auth.account.passkey", ResourceType: "passkey", ResourceID: "fixture-credential-id", Summary: "The initial account passkey was enrolled.", CreatedAt: passkeys.now}
if err := commit(ctx, credential, audit); err != nil {
return authwebauthn.Credential{}, err
}
return credential, nil
}
func assertCount(t *testing.T, store *Store, query, id string, want int) {
t.Helper()
var got int
if err := store.db.QueryRow(query, id).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("count for %q = %d, want %d", query, got, want)
}
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"errors"
"gamertan.com/web/bootstrap"
)
// CreateInitialOwner commits the root-local bootstrap across identity,
// enrollment, organization, membership, owner access, and all audit records.
func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error {
user := setup.User
organization := setup.Organization
membership := setup.Membership
binding := setup.OwnerBinding
if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID ||
!validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 ||
membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() ||
!validOwnerBinding(binding, organization.ID, user.ID) ||
!validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID ||
!validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID ||
!validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID {
return errors.New("authsqlite: invalid initial owner bootstrap")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); 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=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return errors.New("authsqlite: initial owner role has not been seeded")
}
if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil {
return err
}
if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil {
return err
}
return tx.Commit()
}
var _ bootstrap.Repository = (*Store)(nil)
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/bootstrap"
)
func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}}
accessService, err := access.New(store, policy, access.Options{})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
user, err := store.UserByID(t.Context(), created.User.ID)
if err != nil || user.Email != "cole@example.test" {
t.Fatalf("user=%+v err=%v", user, err)
}
organization, err := store.OrganizationByID(t.Context(), created.Organization.ID)
if err != nil || organization.Personal || organization.Slug != "gamertan" {
t.Fatalf("organization=%+v err=%v", organization, err)
}
memberships, err := store.MembershipsForUser(t.Context(), user.ID)
if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID {
t.Fatalf("memberships=%+v err=%v", memberships, err)
}
decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage")
if err != nil || !decision.Allowed || decision.Role != "home.owner" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
passkeyService := testBootstrapPasskeyService(t, store, now)
begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey")
if err != nil || begin.CeremonyToken == "" {
t.Fatalf("begin=%+v err=%v", begin, err)
}
if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("enrollment replay err=%v", err)
}
var authAudits, accessAudits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil {
t.Fatal(err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil {
t.Fatal(err)
}
if authAudits != 1 || accessAudits != 2 {
t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits)
}
}
func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) {
store, err := Open(t.TempDir() + "/bootstrap.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC)
service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil {
t.Fatal("bootstrap succeeded without seeded role")
}
for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} {
var count int
if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 {
t.Fatalf("table=%s count=%d err=%v", table, count, queryErr)
}
}
}
func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service {
t.Helper()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
return service
}
+10 -9
View File
@@ -29,7 +29,7 @@ func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrol
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
@@ -45,7 +45,7 @@ func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, err
if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
}
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
@@ -53,14 +53,14 @@ func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (au
if !text(identifier, 320, false) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
}
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
if !boundedCredentialID(credentialID) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
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_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
@@ -291,7 +291,7 @@ func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte,
if err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
@@ -310,7 +310,7 @@ func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollme
return auth.User{}, err
}
defer tx.Rollback()
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
if err != nil {
return auth.User{}, err
}
@@ -342,21 +342,22 @@ type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var created, updated int64
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil {
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrUserNotFound
}
return auth.User{}, err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, nil
}
func validPasskeyUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.RegistrationPending && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
+195
View File
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authrecovery"
)
func (store *Store) ReplaceRecoveryCodes(ctx context.Context, userID string, digests [][32]byte, createdAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || len(digests) < 5 || len(digests) > 20 || createdAt.IsZero() || !validAuditEvent(audit) || audit.ResourceID != userID {
return errors.New("authsqlite: invalid recovery-code set")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_codes WHERE user_id=?`, userID); err != nil {
return err
}
for _, digest := range digests {
if zeroDigest(digest) {
return errors.New("authsqlite: invalid recovery-code digest")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES(?,?,?)`, userID, digest[:], createdAt.Unix()); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
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 {
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")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_recovery_codes SET used_at=? WHERE user_id=? AND code_hash=? AND used_at IS NULL`, grant.CreatedAt.Unix(), userID, codeDigest[:])
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil || changed != 1 {
return authrecovery.ErrCodeNotFound
}
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_recovery_grants WHERE user_id=?`, userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, grant.Digest[:], userID, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) TakeRecoveryGrant(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authrecovery.ErrGrantNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
if err = tx.QueryRowContext(ctx, `SELECT user_id FROM gwf_recovery_grants WHERE token_hash=? AND expires_at>?`, digest[:], now.Unix()).Scan(&userID); errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authrecovery.ErrGrantNotFound
} else if err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_recovery_grants WHERE token_hash=?`, digest[:]); err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
+66 -9
View File
@@ -24,6 +24,17 @@ import (
type Store struct{ db *sql.DB }
func Open(path string) (*Store, error) {
return OpenWithOptions(path, OpenOptions{Migrate: true})
}
type OpenOptions struct {
// Migrate preserves the historical Open behavior when true. Applications
// with operator-controlled releases set it false and call Migrate only from
// their explicit migration command.
Migrate bool
}
func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return nil, err
@@ -56,7 +67,7 @@ func Open(path string) (*Store, error) {
store := &Store{db: db}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err = db.PingContext(ctx); err == nil {
if err = db.PingContext(ctx); err == nil && options.Migrate {
err = store.Migrate(ctx)
}
if err != nil {
@@ -66,6 +77,34 @@ func Open(path string) (*Store, error) {
return store, nil
}
const SchemaVersion = 8
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='gamertan_web_migrations'`).Scan(&exists); err != nil || exists == 0 {
return 0, err
}
var version sql.NullInt64
if err := store.db.QueryRowContext(ctx, `SELECT MAX(version) FROM gamertan_web_migrations`).Scan(&version); err != nil {
return 0, err
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}
func (store *Store) RequireCurrentSchema(ctx context.Context) error {
version, err := store.CurrentSchema(ctx)
if err != nil {
return err
}
if version != SchemaVersion {
return fmt.Errorf("authsqlite: schema version %d; run migration for version %d", version, SchemaVersion)
}
return nil
}
func (store *Store) Close() error { return store.db.Close() }
func (store *Store) Ping(ctx context.Context) error { return store.db.PingContext(ctx) }
@@ -77,7 +116,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback()
statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), registration_pending INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -94,6 +133,11 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_recovery_codes (user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, code_hash BLOB NOT NULL, created_at INTEGER NOT NULL, used_at INTEGER, PRIMARY KEY(user_id,code_hash))`,
`CREATE TABLE IF NOT EXISTS gwf_recovery_grants (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_recovery_grants_expiry ON gwf_recovery_grants(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_account_registrations (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_account_registrations_expiry ON gwf_account_registrations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
@@ -132,6 +176,7 @@ func (store *Store) Migrate(ctx context.Context) error {
for _, migration := range []struct {
table, column, definition string
}{
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
@@ -180,6 +225,15 @@ func (store *Store) Migrate(ctx context.Context) error {
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(6,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(7,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(8,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
@@ -212,7 +266,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.RegistrationPending, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil {
return err
}
@@ -228,9 +282,9 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
err := 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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
@@ -238,6 +292,7 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
@@ -248,9 +303,9 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var passwordChangeRequired, registrationPending int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
err := 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,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
@@ -258,6 +313,7 @@ func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.RegistrationPending = registrationPending == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
@@ -346,12 +402,13 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal
var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64
var passwordChangeRequired int
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
var passwordChangeRequired, registrationPending int
err := 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,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &registrationPending, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
}
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
principal.User.RegistrationPending = registrationPending == 1
if err != nil {
return auth.Principal{}, auth.Session{}, err
}
+18
View File
@@ -17,6 +17,24 @@ import (
"gamertan.com/web/organizations"
)
func TestOpenCanRequireExplicitMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "explicit.db")
store, err := OpenWithOptions(path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err = store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("unmigrated database reported current")
}
if err = store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
+95 -16
View File
@@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{})
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
@@ -198,7 +198,41 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{})
return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}, false)
}
// BeginAccountRegistration starts the initial passkey ceremony for a pending
// account. Binding must identify the surrounding single-use registration
// draft; only its digest is retained in ceremony state.
func (service *Service) BeginAccountRegistration(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), 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
@@ -216,15 +250,15 @@ func (service *Service) BeginPasswordMigration(ctx context.Context, userID, labe
if !exists {
return BeginResult{}, ErrPasswordNotAvailable
}
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID))
return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID), false)
}
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte) (BeginResult, error) {
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte, allowPending bool) (BeginResult, error) {
label, err := credentialLabel(label)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, user)
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return BeginResult{}, err
}
@@ -245,7 +279,45 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false)
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, "", [32]byte{}, response, false, false, nil)
}
// FinishRegistrationForUser verifies an ordinary self-service enrollment only
// when the ceremony belongs to the authenticated user selected by the
// application. The ceremony is consumed on mismatch so a leaked token cannot
// be retried through another account session.
func (service *Service) FinishRegistrationForUser(ctx context.Context, ceremonyToken, expectedUserID string, response []byte) (Credential, error) {
expectedUserID = strings.TrimSpace(expectedUserID)
if expectedUserID == "" {
return Credential{}, ErrOperationBinding
}
return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, expectedUserID, [32]byte{}, response, false, false, nil)
}
// FinishAccountRegistration verifies an initial credential and delegates its
// persistence to commit so user activation, personal organization creation,
// owner binding, recovery-code storage, and the passkey can share one
// transaction. A failed commit consumes the WebAuthn ceremony and leaves the
// bounded account draft eligible for a fresh ceremony.
func (service *Service) FinishAccountRegistration(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, 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
@@ -255,18 +327,21 @@ func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyTok
if err != nil {
return Credential{}, err
}
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true)
return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true, false, nil)
}
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) {
func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind, expectedUserID string, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind)
if err != nil {
return Credential{}, err
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword)
if expectedUserID != "" && ceremony.UserID != expectedUserID {
return Credential{}, ErrOperationBinding
}
return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword, allowPending, commit)
}
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) {
func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword, allowPending bool, commit RegistrationCommit) (Credential, error) {
if ceremony.BindingDigest != expectedBinding {
return Credential{}, ErrOperationBinding
}
@@ -277,7 +352,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil {
return Credential{}, err
}
adapter, err := service.user(ctx, user)
adapter, err := service.user(ctx, user, allowPending)
if err != nil {
return Credential{}, err
}
@@ -312,6 +387,10 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
}
if retirePassword {
err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit)
} else if commit != nil {
audit.Action = "auth.account.passkey"
audit.Summary = "The initial account passkey was enrolled."
err = commit(ctx, record, audit)
} else {
err = service.repository.SaveCredential(ctx, record, audit)
}
@@ -358,7 +437,7 @@ func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, r
if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound
}
loaded, lookupErr = service.user(ctx, account)
loaded, lookupErr = service.user(ctx, account, false)
return loaded, lookupErr
}, session, parsed)
if err != nil || loaded == nil || user == nil {
@@ -385,7 +464,7 @@ func (service *Service) BeginApproval(ctx context.Context, userID string, bindin
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, account)
adapter, err := service.user(ctx, account, false)
if err != nil {
return BeginResult{}, err
}
@@ -415,7 +494,7 @@ func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string
if err != nil {
return Approval{}, err
}
adapter, err := service.user(ctx, account)
adapter, err := service.user(ctx, account, false)
if err != nil {
return Approval{}, err
}
@@ -552,8 +631,8 @@ func (service *Service) takeCeremony(ctx context.Context, token, kind string) (C
return ceremony, nil
}
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
if account.Status != "active" {
func (service *Service) user(ctx context.Context, account auth.User, allowPending bool) (*passkeyUser, error) {
if account.Status != "active" || account.RegistrationPending && !allowPending {
return nil, auth.ErrInactiveUser
}
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
+31
View File
@@ -4,6 +4,7 @@ package authwebauthn_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
@@ -53,6 +54,12 @@ func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, "another-user", []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("cross-account registration completion err=%v", err)
}
if _, err = service.FinishRegistrationForUser(t.Context(), begin.CeremonyToken, user.ID, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("mismatched completion did not consume ceremony: %v", err)
}
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
t.Fatalf("readiness without credentials err=%v", err)
@@ -141,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) {
now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
+11 -4
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: MPL-2.0
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
// credential lifecycle, and recovery tokens while delegating protocol parsing
// and signature verification to a pinned WebAuthn implementation.
// Package authwebauthn provides storage-neutral WebAuthn ceremonies for
// passkey login, enrollment, and operation-bound step-up. It owns relying-party
// policy, bounded single-use ceremony state, credential lifecycle, and recovery
// tokens while delegating protocol parsing and signature verification to a
// pinned WebAuthn implementation.
package authwebauthn
import (
@@ -92,6 +93,12 @@ type Approval struct {
ApprovedAt time.Time
}
// RegistrationCommit lets a higher-level account workflow commit a verified
// initial credential together with the rest of the account state. The
// callback receives only public-key credential material and a secret-free
// audit event.
type RegistrationCommit func(context.Context, Credential, auth.AuditEvent) error
// Repository persists passkey-specific state. Implementations must consume
// enrollment tokens and ceremonies atomically and must perform recovery and
// credential removal invariants in transactions.
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: MPL-2.0
// Package bootstrap creates the first application owner and non-personal
// organization as one storage transaction. It is intended for a root-local
// operator command, not for public registration or a network administration
// endpoint.
package bootstrap
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/mail"
"regexp"
"strings"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
"gamertan.com/web/organizations"
)
const defaultEnrollmentLifetime = 15 * time.Minute
var (
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
// Input is the reviewed, non-secret identity and organization metadata from a
// local operator command.
type Input struct {
Username string
Email string
DisplayName string
OrganizationSlug string
OrganizationName string
}
// Setup is the complete secret-free state a repository must commit atomically.
// Enrollment contains only a digest; the raw token remains in the Result.
type Setup struct {
User auth.User
Enrollment authwebauthn.EnrollmentToken
Organization organizations.Organization
Membership organizations.Membership
OwnerBinding access.Binding
AuthAudit auth.AuditEvent
OrganizationAudit organizations.AuditEvent
AccessAudit access.AuditEvent
}
// Result contains the created public records and the one-time enrollment
// secret. Applications must deliver EnrollmentToken through a private channel
// and must never log it.
type Result struct {
User auth.User
Organization organizations.Organization
EnrollmentToken string
ExpiresAt time.Time
}
// Repository owns the single transaction spanning identity, enrollment,
// organization membership, owner access, and their audit events.
type Repository interface {
CreateInitialOwner(context.Context, Setup) error
}
type Options struct {
OwnerRole string
EnrollmentLifetime time.Duration
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
ownerRole string
enrollmentLifetime time.Duration
random io.Reader
now func() time.Time
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("bootstrap: repository is required")
}
if !rolePattern.MatchString(options.OwnerRole) {
return nil, errors.New("bootstrap: owner role is invalid")
}
if options.EnrollmentLifetime == 0 {
options.EnrollmentLifetime = defaultEnrollmentLifetime
}
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
}
// Start atomically creates one active passkey-only owner, one active
// non-personal organization, direct owner access, and a single-use enrollment
// token. It does not create a session or expose a network bootstrap surface.
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
input.Username = strings.TrimSpace(input.Username)
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
return Result{}, errors.New("bootstrap: invalid owner or organization")
}
values, err := service.randomValues(7)
if err != nil {
return Result{}, err
}
now := service.now().UTC()
userID, organizationID, bindingID := values[0], values[1], values[2]
rawToken := values[3]
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
setup := Setup{
User: user,
Enrollment: enrollment,
Organization: organization,
Membership: membership,
OwnerBinding: binding,
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
}
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
return Result{}, err
}
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
}
func (service *Service) randomValues(count int) ([]string, error) {
values := make([]string, count)
for index := range values {
bytes := make([]byte, 24)
if _, err := io.ReadFull(service.random, bytes); err != nil {
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
}
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
}
return values, nil
}
func canonicalEmail(value string) bool {
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
return false
}
address, err := mail.ParseAddress(value)
return err == nil && address.Name == "" && address.Address == value
}
func bounded(value string, maximum int) bool {
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: MPL-2.0
package bootstrap
import (
"context"
"errors"
"testing"
"time"
)
type recordingRepository struct {
setup Setup
err error
}
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
repository.setup = setup
return repository.err
}
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
repository := new(recordingRepository)
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
t.Fatalf("result=%+v setup=%+v", result, setup)
}
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
}
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
}
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
t.Fatalf("expires=%v", result.ExpiresAt)
}
}
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
repository := new(recordingRepository)
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
for _, input := range []Input{
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
} {
if _, startErr := service.Start(t.Context(), input); startErr == nil {
t.Fatalf("unsafe input accepted: %+v", input)
}
}
if repository.setup.User.ID != "" {
t.Fatal("repository was called for rejected input")
}
}
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
repository := &recordingRepository{err: errors.New("commit failed")}
service, err := New(repository, Options{OwnerRole: "home.owner"})
if err != nil {
t.Fatal(err)
}
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
if err == nil || result.EnrollmentToken != "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
// Package web is the documentation root for Gamertan Web Foundations.
//
// Web Foundations is a collection of small, composable Go packages for the
// security-sensitive edges of a web application: request identity, structured
// request evidence, browser security, authentication, passkeys, permissions,
// organizations, SQLite persistence, abuse controls, and private analytics.
//
// It is a toolkit rather than an application framework. Applications keep
// their router, handlers, HTML, authorization decisions, deployment, and
// operational policy. Packages use net/http and can be adopted independently.
// No Redis, message broker, hosted identity provider, telemetry service, or
// JavaScript framework is required.
//
// # Choose a first boundary
//
// Start with the smallest package that owns the boundary you need:
//
// - [requestmeta] resolves request IDs, client addresses, and trusted-proxy
// metadata once for downstream security and logging.
// - [requestlog] records bounded, versioned request observations with
// sensitive fields disabled by default.
// - [websec] supplies HTTP headers, same-origin checks, CSRF protection,
// redirects, body limits, and rate limits.
// - [auth], [authhttp], [authwebauthn], and [authsqlite] provide
// storage-neutral identity, secure browser sessions, passkeys, and an
// optional no-CGO SQLite adapter.
// - [organizations] and [access] model organizations, teams, invitations,
// scoped roles, and audited temporary access.
// - [abuse] applies application-classified request-abuse decisions.
// - [analytics] creates bounded, disposable projections from requestlog
// records without becoming a telemetry service.
//
// # Compose with net/http
//
// Middleware is wrapped from the application outward. A request metadata
// resolver should be outermost so packages inside it agree about request
// identity. The package example shows a complete, executable composition.
// A copyable server with graceful shutdown and optional private JSONL logging
// is available in the repository's starters/basic directory.
//
// # Security model
//
// Untrusted values are bounded before storage or aggregation. Forwarding
// headers affect identity only through explicitly trusted proxies. Sensitive
// request fields require field-by-field opt-in. Security-relevant
// configuration and persistence failures fail closed rather than silently
// weakening policy.
//
// This root package intentionally exports no runtime API. Applications import
// only the subpackages they use.
//
// [abuse]: https://pkg.go.dev/gamertan.com/web/abuse
// [access]: https://pkg.go.dev/gamertan.com/web/access
// [analytics]: https://pkg.go.dev/gamertan.com/web/analytics
// [auth]: https://pkg.go.dev/gamertan.com/web/auth
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
// [websec]: https://pkg.go.dev/gamertan.com/web/websec
package web
+51
View File
@@ -0,0 +1,51 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Web Foundations dogfood notes
This living note records concrete pressure discovered while Gamertan services
adopt Web Foundations. It is implementation evidence, not a promise that every
application concern belongs in the shared module.
## Gamertan accounts and commerce
- The account email remains required and unique. Gamertan uses normalized
email as the canonical login identifier and keeps username as a stable public
identity. Until a mail package exists, the application must not describe an
address as verified merely because it was entered during registration.
- Password authentication is sufficient for an ordinary customer base
session. Privileged application actions use an exact operation binding with
`authwebauthn.BeginApproval` and `FinishApproval`; that is safer than a broad
long-lived "elevated" session. A user without a passkey can use ordinary
features but must enroll one before performing protected work.
- `auth.Service.VerifyPassword` remains available for flows that truly require
password plus passkey before session issuance.
- Public registration exposed a cross-package transaction boundary. The
`account` package now keeps an unusable bounded registration draft and makes
recovery-code digests, personal organization, membership, owner binding,
activation, audits, and an optional initial passkey one repository commit.
A failed WebAuthn ceremony can be restarted, or an ordinary password account
can finish without it, without persisting a partly privileged account.
- Media belongs behind a storage-neutral interface with a hardened local
adapter. Content workflow, references, and authorization remain application
policy.
- Historical `authsqlite.Open` still migrates for compatibility. Applications
with reviewed deployment gates use `OpenWithOptions` with migration disabled,
require the current schema at startup, and invoke `Migrate` only from an
explicit operator command.
- Commerce remains a separately versioned nested module so payment-provider
policy and catalog evolution do not enlarge the authentication core.
- Self-service enrollment exposed an authorization seam: completing a valid
ceremony and checking its user only after persistence is too late.
`FinishRegistrationForUser` now consumes mismatched ceremonies and checks
the application-authenticated user before storing a credential.
- First-owner provisioning exposed another cross-package transaction boundary.
`bootstrap` now commits the passkey-only user, enrollment digest,
non-personal organization, membership, direct owner binding, and audits
together. Applications must seed their owner role first and must write the
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.
+13 -1
View File
@@ -19,13 +19,14 @@ install an imagined framework lifecycle around it.
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy |
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
The packages are ordinary Go imports. Pin the current preview and verify its
module checksum:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.6
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
go mod verify
```
@@ -61,6 +62,17 @@ quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
For the first application owner, prefer `bootstrap.Start`. After explicitly
seeding the application's access policy, it creates the active passkey-only
user, non-personal home organization, membership, direct owner binding,
enrollment digest, and audit events in one repository transaction. A missing
owner role or duplicate identity rolls back every row. The application-owned
root-local command writes the returned raw enrollment token once to an
exclusive mode-`0600` file and must never print or log it.
For applications that still require a temporary password bootstrap,
`auth.GenerateTemporaryPassword` remains available:
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
+7 -1
View File
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
and request the containing module at an exact version:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.6
go get gamertan.com/web/requestmeta@v0.1.0-preview.13
```
Only imported packages are compiled and linked. The packages nevertheless
@@ -52,6 +52,12 @@ Do not split merely to make an architecture diagram look modular. Package
interfaces provide source-level modularity today; modules are introduced only
for an independent dependency and release lifecycle.
The `media` package and `medialocal` adapter deliberately remain in the root
module: they use only the standard library, and applications can adopt the core
interface without importing the local adapter. Commerce is different. Its
provider SDK and independently evolving catalog/payment contract justify a
future nested `gamertan.com/web/commerce` module after application dogfood.
## Session boundaries
Authenticated sessions currently belong to three deliberate packages:
+31 -10
View File
@@ -26,12 +26,17 @@ timestamp, UUID, or counter for the random challenge.
## Application flow
1. A local command calls `Bootstrap` or `Recover` and writes the returned
enrollment token once to a newly created mode-`0600` file.
1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or
`bootstrap.Start` and writes the returned enrollment token once to a newly
created mode-`0600` file. Use `bootstrap.Start` for the first application
owner so identity, organization membership, direct owner access, and audits
cannot be partially committed.
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
`navigator.credentials.create` with the returned `public_key` value.
3. The browser posts the credential and opaque ceremony token to a bounded JSON
endpoint; `FinishRegistration` verifies and stores the public credential.
endpoint; authenticated self-service flows use
`FinishRegistrationForUser` so the application session's user ID is checked
before any public credential is stored.
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
The successful result contains an ordinary opaque `auth` session token.
5. Sensitive operations call `BeginApproval` with a canonical application
@@ -51,13 +56,29 @@ JavaScript, or set sessions automatically.
## Recovery and credential lifecycle
Recovery is deliberately host-local and should never be reachable through an
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
prior enrollment tokens, appends a secret-free audit event, and returns one
15-minute token. It does not delete existing passkeys. After enrolling a
replacement, the operator reviews credential labels and removes lost keys with
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
remotely.
Administrator-assisted `authwebauthn.Recover` is deliberately host-local and
must never be reachable through an HTTP handler. It revokes all user sessions
and pending ceremonies, replaces prior enrollment tokens, appends a
secret-free audit event, and returns one 15-minute token. It does not delete
existing passkeys. After enrolling a replacement, the operator reviews
credential labels and removes lost keys with a fresh passkey-bound removal
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
independent passkeys and complete a local recovery drill.
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MPL-2.0
package web_test
import (
"fmt"
"net/http"
"net/http/httptest"
"gamertan.com/web/requestlog"
"gamertan.com/web/requestmeta"
"gamertan.com/web/websec"
)
func Example() {
resolver, err := requestmeta.New(requestmeta.Config{})
if err != nil {
panic(err)
}
router := http.NewServeMux()
router.HandleFunc("GET /", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusNoContent)
})
var handler http.Handler = router
handler = requestlog.Middleware(nil, requestlog.Policy{
Route: func(*http.Request) string { return "home" },
})(handler)
handler = websec.Headers(func(*http.Request) websec.HeaderPolicy {
return websec.HeaderPolicy{
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
ReferrerPolicy: "no-referrer",
FrameOptions: "DENY",
}
})(handler)
handler = resolver.Middleware(handler)
request := httptest.NewRequest(http.MethodGet, "https://example.test/", nil)
request.RemoteAddr = "192.0.2.10:43120"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
fmt.Println(response.Code)
fmt.Println(response.Header().Get("X-Request-ID") != "")
fmt.Println(response.Header().Get("X-Content-Type-Options"))
// Output:
// 204
// true
// nosniff
}
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: MPL-2.0
// Package media defines bounded media preparation and storage-neutral blob
// interfaces. Applications retain authorization, references, lifecycle, and
// presentation policy.
package media
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
"image/jpeg"
"image/png"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"unicode/utf8"
)
const (
KindImage = "image"
KindAttachment = "attachment"
)
var (
ErrInvalidMedia = errors.New("media: invalid media")
ErrTooLarge = errors.New("media: upload exceeds its size limit")
ErrNotFound = errors.New("media: object not found")
)
type Limits struct {
MaxBytes int64
MaxWidth int
MaxHeight int
MaxPixels int64
}
func (limits Limits) withDefaults() Limits {
if limits.MaxBytes == 0 {
limits.MaxBytes = 10 << 20
}
if limits.MaxWidth == 0 {
limits.MaxWidth = 8192
}
if limits.MaxHeight == 0 {
limits.MaxHeight = 8192
}
if limits.MaxPixels == 0 {
limits.MaxPixels = 40_000_000
}
return limits
}
func (limits Limits) validate() error {
if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 {
return errors.New("media: invalid limits")
}
return nil
}
// Prepared is a sanitized, bounded object ready for durable storage. Raster
// images are decoded and re-encoded so source metadata and unparsed trailing
// bytes are not retained. PDFs are attachments and are never inline media.
type Prepared struct {
Digest [32]byte
Data []byte
MediaType string
Kind string
OriginalName string
Width int
Height int
}
func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) }
type Object struct {
Key string
Size int64
MediaType string
CreatedAt time.Time
}
type Store interface {
Put(context.Context, Prepared) (Object, error)
Open(context.Context, string) (io.ReadCloser, Object, error)
Delete(context.Context, string) error
}
// Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a
// PDF attachment. Animated images are deliberately flattened to the decoded
// first frame. The returned byte slice is owned by the caller.
func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) {
if reader == nil {
return Prepared{}, ErrInvalidMedia
}
limits = limits.withDefaults()
if err := limits.validate(); err != nil {
return Prepared{}, err
}
name, err := boundedName(originalName)
if err != nil {
return Prepared{}, err
}
data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1))
if err != nil {
return Prepared{}, fmt.Errorf("media: read upload: %w", err)
}
if int64(len(data)) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
if len(data) == 0 {
return Prepared{}, ErrInvalidMedia
}
detected := http.DetectContentType(data)
if detected == "application/pdf" && bytes.HasPrefix(data, []byte("%PDF-")) {
result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
imageValue, format, err := image.Decode(bytes.NewReader(data))
if err != nil || format != "jpeg" && format != "png" && format != "gif" {
return Prepared{}, ErrInvalidMedia
}
bounds := imageValue.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) {
return Prepared{}, ErrTooLarge
}
var output bytes.Buffer
mediaType := "image/png"
if format == "jpeg" {
mediaType = "image/jpeg"
err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90})
} else {
err = png.Encode(&output, imageValue)
}
if err != nil {
return Prepared{}, fmt.Errorf("media: sanitize image: %w", err)
}
if int64(output.Len()) > limits.MaxBytes {
return Prepared{}, ErrTooLarge
}
result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height}
result.Digest = sha256.Sum256(result.Data)
return result, nil
}
func Extension(mediaType string) string {
switch mediaType {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "application/pdf":
return ".pdf"
default:
values, _ := mime.ExtensionsByType(mediaType)
if len(values) > 0 {
return values[0]
}
return ""
}
}
func ValidKey(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value)
}
func boundedName(value string) (string, error) {
value = strings.TrimSpace(filepath.Base(value))
if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") {
return "", ErrInvalidMedia
}
return value, nil
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
package media
import (
"bytes"
"errors"
"image"
"image/color"
"image/jpeg"
"strings"
"testing"
)
func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
var source bytes.Buffer
value := image.NewRGBA(image.Rect(0, 0, 3, 2))
value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255})
if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
source.WriteString("secret trailing metadata")
prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" {
t.Fatalf("prepared=%+v", prepared)
}
if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) {
t.Fatal("image source data was not sanitized")
}
}
func TestPreparePDFIsAttachment(t *testing.T) {
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
if err != nil {
t.Fatal(err)
}
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
t.Fatalf("prepared=%+v", prepared)
}
}
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
t.Fatalf("svg err=%v", err)
}
if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) {
t.Fatalf("large err=%v", err)
}
}
+187
View File
@@ -0,0 +1,187 @@
// SPDX-License-Identifier: MPL-2.0
// Package medialocal stores prepared media in a private content-addressed
// filesystem tree.
package medialocal
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"gamertan.com/web/media"
)
type Store struct {
root string
now func() time.Time
}
type Options struct {
Now func() time.Time
}
func Open(root string, options Options) (*Store, error) {
absolute, err := filepath.Abs(root)
if err != nil || filepath.Clean(absolute) != absolute {
return nil, errors.New("medialocal: root must be a clean absolute path")
}
if err = secureDirectory(absolute, true); err != nil {
return nil, err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return nil, fmt.Errorf("medialocal: resolve root: %w", err)
}
if options.Now == nil {
options.Now = time.Now
}
return &Store{root: resolved, now: options.Now}, nil
}
func (store *Store) Put(ctx context.Context, prepared media.Prepared) (media.Object, error) {
if err := ctx.Err(); err != nil {
return media.Object{}, err
}
if len(prepared.Data) == 0 || !media.ValidKey(prepared.Key()) || sha256.Sum256(prepared.Data) != prepared.Digest {
return media.Object{}, media.ErrInvalidMedia
}
shard, target := store.objectPath(prepared.Key())
if err := secureDirectory(shard, true); err != nil {
return media.Object{}, err
}
if object, ok, err := inspect(target, prepared.MediaType); err != nil {
return media.Object{}, err
} else if ok {
if object.Size != int64(len(prepared.Data)) {
return media.Object{}, errors.New("medialocal: existing digest has an unexpected size")
}
return object, nil
}
temporary, err := os.CreateTemp(shard, ".upload-*")
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: create temporary object: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if err = temporary.Chmod(0o640); err == nil {
_, err = temporary.Write(prepared.Data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return media.Object{}, fmt.Errorf("medialocal: write object: %w", err)
}
if err = os.Link(temporaryName, target); err != nil {
if errors.Is(err, os.ErrExist) {
object, ok, inspectErr := inspect(target, prepared.MediaType)
if inspectErr != nil {
return media.Object{}, inspectErr
}
if ok && object.Size == int64(len(prepared.Data)) {
return object, nil
}
}
return media.Object{}, fmt.Errorf("medialocal: commit object: %w", err)
}
return media.Object{Key: prepared.Key(), Size: int64(len(prepared.Data)), MediaType: prepared.MediaType, CreatedAt: store.now().UTC()}, nil
}
func (store *Store) Open(ctx context.Context, key string) (io.ReadCloser, media.Object, error) {
if err := ctx.Err(); err != nil {
return nil, media.Object{}, err
}
if !media.ValidKey(key) {
return nil, media.Object{}, media.ErrNotFound
}
shard, target := store.objectPath(key)
if err := secureDirectory(shard, false); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
return nil, media.Object{}, err
}
before, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return nil, media.Object{}, media.ErrNotFound
}
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 {
return nil, media.Object{}, errors.New("medialocal: object is not a regular file")
}
file, err := os.Open(target)
if err != nil {
return nil, media.Object{}, err
}
after, err := file.Stat()
if err != nil || !os.SameFile(before, after) {
file.Close()
return nil, media.Object{}, errors.New("medialocal: object changed while opening")
}
return file, media.Object{Key: key, Size: after.Size(), CreatedAt: after.ModTime().UTC()}, nil
}
func (store *Store) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
if !media.ValidKey(key) {
return media.ErrNotFound
}
_, target := store.objectPath(key)
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return errors.New("medialocal: refusing to delete a non-regular object")
}
if err = os.Remove(target); errors.Is(err, os.ErrNotExist) {
return media.ErrNotFound
}
return err
}
func (store *Store) objectPath(key string) (string, string) {
shard := filepath.Join(store.root, key[:2])
return shard, filepath.Join(shard, key)
}
func secureDirectory(path string, create bool) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) && create {
if err = os.MkdirAll(path, 0o750); err != nil {
return fmt.Errorf("medialocal: create directory: %w", err)
}
info, err = os.Lstat(path)
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("medialocal: storage directory must not be a symlink")
}
return nil
}
func inspect(path, mediaType string) (media.Object, bool, error) {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return media.Object{}, false, nil
}
if err != nil {
return media.Object{}, false, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return media.Object{}, false, errors.New("medialocal: existing object is not a regular file")
}
return media.Object{Key: filepath.Base(path), Size: info.Size(), MediaType: mediaType, CreatedAt: info.ModTime().UTC()}, true, nil
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package medialocal
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"testing"
"time"
"gamertan.com/web/media"
)
func TestStoreRoundTripAndIdempotentPut(t *testing.T) {
now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC)
store, err := Open(filepath.Join(t.TempDir(), "media"), Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
prepared, err := media.Prepare(bytes.NewReader([]byte("%PDF-1.7\nfixture")), "fixture.pdf", media.Limits{})
if err != nil {
t.Fatal(err)
}
first, err := store.Put(t.Context(), prepared)
if err != nil {
t.Fatal(err)
}
second, err := store.Put(t.Context(), prepared)
if err != nil || second.Key != first.Key || second.Size != first.Size {
t.Fatalf("second=%+v err=%v", second, err)
}
reader, object, err := store.Open(t.Context(), first.Key)
if err != nil {
t.Fatal(err)
}
data, readErr := io.ReadAll(reader)
closeErr := reader.Close()
if readErr != nil || closeErr != nil || !bytes.Equal(data, prepared.Data) || object.Size != int64(len(data)) {
t.Fatalf("round trip object=%+v read=%v close=%v", object, readErr, closeErr)
}
if err = store.Delete(t.Context(), first.Key); err != nil {
t.Fatal(err)
}
if _, _, err = store.Open(t.Context(), first.Key); !errors.Is(err, media.ErrNotFound) {
t.Fatalf("missing err=%v", err)
}
}
func TestOpenRejectsSymlinkRoot(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
if err := os.Mkdir(target, 0o750); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := Open(link, Options{}); err == nil {
t.Fatal("symlink root accepted")
}
}
+18
View File
@@ -6,6 +6,7 @@
.gitignore
CHANGELOG.md
CONTRIBUTING.md
LICENSE
LICENSES.md
LICENSES/0BSD.txt
LICENSES/AGPL-3.0-only.txt
@@ -16,6 +17,7 @@ SECURITY.md
THIRD_PARTY_NOTICES.md
abuse/abuse.go
abuse/abuse_test.go
account/account.go
access/access.go
access/access_test.go
analytics/analytics.go
@@ -26,6 +28,8 @@ auth/auth.go
auth/context.go
auth/password.go
auth/password_test.go
authrecovery/recovery.go
authrecovery/recovery_test.go
auth/service_test.go
authhttp/authhttp.go
authhttp/authhttp_test.go
@@ -33,18 +37,30 @@ authhttp/passkey.go
authhttp/passkey_test.go
authsqlite/store.go
authsqlite/store_test.go
authsqlite/account.go
authsqlite/account_test.go
authsqlite/access.go
authsqlite/bootstrap.go
authsqlite/bootstrap_test.go
authsqlite/organizations.go
authsqlite/passkey.go
authsqlite/passkey_test.go
authsqlite/recovery.go
authwebauthn/fuzz_test.go
authwebauthn/service.go
authwebauthn/service_test.go
authwebauthn/types.go
bootstrap/bootstrap.go
bootstrap/bootstrap_test.go
media/media.go
media/media_test.go
medialocal/store.go
medialocal/store_test.go
internal/webauthnvendored/
docs/ADOPTION.md
docs/ARCHITECTURE.md
docs/DEPENDENCIES.md
docs/DOGFOOD.md
docs/GETTING_STARTED.md
docs/MODULES.md
docs/ORGANIZATIONS.md
@@ -53,6 +69,8 @@ docs/PUBLIC_SNAPSHOT.md
docs/SANDWICH_HIME.md
docs/SERVICES_ROADMAP.md
docs/THREAT_MODEL.md
doc.go
example_test.go
go.mod
go.sum
requestlog/jsonl.go