mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 00:57:28 +00:00
feat(users): companion linking store (schema v6, device sessions, link challenges) (#2174)
Part 1 of 3 for #2173 (companion linking). This PR adds only the `users.db` side: the schema and store methods the server (part 2) and ingestor (part 3) build on. No route, no UI and no behaviour change for a running server. ## Changes in this PR - **Design spec** `docs/specs/2026-10-08-companion-linking-design.md`, including the optional linked-only ingest. - **Schema v6** (`internal/users/schema.go`): - `sessions` gains `kind` (`'web'` or `'device'`), `label` and `scopes` (`''` = full, for web sessions). - `companion_links (pubkey PRIMARY KEY, user_id, name, linked_at)`, cascading on user delete. - `link_challenges`: single-use challenges bound to a user and a pubkey, with an expiry. - **Device sessions** (`sessions.go`): create a device session with a label and scopes, list sessions with kind and label. - **Challenges** (`challenges.go`): issue and consume a challenge exactly once; consumed, expired, or bound to another user or pubkey all fail. - **Companion links** (`companions.go`): link, list, unlink. Linking a pubkey that belongs to another user moves it and reports the previous owner (transfer detection), so the server can audit and mail. - **Janitor**: the existing janitor also prunes expired challenges (`cmd/server/auth_service.go`, 3 lines). ## Upgrade note The store migrates `users.db` to v6 on start. A binary that only knows v5 refuses to open a v6 file (`users: database schema version 6 is newer than this binary supports (5)`), so rolling back after this lands needs the pre-upgrade `users.db` backup. ## Verification - `internal/users`: new tests for the v6 migration from v5, device sessions, challenges (single use, expiry, binding) and companion links (transfer, cascade on user delete). `go vet` and `go test ./...` green. - `cmd/server` full suite green against the v6 store. ## Not in this PR - Routes, bearer auth and the link handshake: part 2. - Ingestor filter, account page and "My coverage": part 3. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3004d977e
commit
9897d52cf6
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/users"
|
||||
)
|
||||
|
||||
func TestJanitorPrunesExpiredLinkChallenges(t *testing.T) {
|
||||
f := newAuthFixture(t)
|
||||
dave := f.registerAndActivate(t, "dave@example.org", "Dave", pw)
|
||||
pk := strings.Repeat("cd", 32)
|
||||
f.st.SetClock(func() time.Time { return time.Now().Add(-10 * time.Minute) })
|
||||
ch, _, err := f.st.CreateLinkChallenge(dave.me.ID, pk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.st.SetClock(time.Now)
|
||||
f.srv.auth.prune()
|
||||
// Pruned → missing. Without the janitor it would still be there, expired.
|
||||
if err := f.st.ConsumeLinkChallenge(dave.me.ID, pk, ch); !errors.Is(err, users.ErrChallengeMissing) {
|
||||
t.Fatalf("expired challenge survived the janitor: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,9 @@ func (a *authService) prune() {
|
||||
if _, err := a.st.PruneExpiredSessions(); err != nil {
|
||||
log.Printf("[users] prune sessions: %v", err)
|
||||
}
|
||||
if _, err := a.st.PruneLinkChallenges(); err != nil {
|
||||
log.Printf("[users] prune link challenges: %v", err)
|
||||
}
|
||||
if _, err := a.st.PruneTokens(7 * 24 * time.Hour); err != nil {
|
||||
log.Printf("[users] prune tokens: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Companion Linking (User Management, Sub-project F), Design Spec
|
||||
|
||||
**Date:** 2026-10-08
|
||||
**Status:** Approved (design). Not implemented.
|
||||
**Scope:** sub-project **F** of the optional user-management track. Builds on A
|
||||
(accounts, sessions), B (settings sync, `meshcore-my-nodes`) and E (notifications).
|
||||
The client side lives in CoreDrive RX (`efiten/coredrive-rx`,
|
||||
`docs/superpowers/specs/2026-10-08-corescope-login-design.md`).
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
CoreDrive RX companions publish coverage under `meshcore/client/<pubkey>/packets`
|
||||
with one shared broker account. CoreScope knows a companion only by that pubkey.
|
||||
With user management on, a logged-in user wants:
|
||||
|
||||
1. **Ownership.** Every companion they drive with shows up under their "My nodes",
|
||||
so the existing watch and notification features apply to it.
|
||||
2. **Attribution.** The coverage their companions collected is theirs: a
|
||||
"My coverage" view, and their linked companions listed on the account page.
|
||||
|
||||
Two things block this today:
|
||||
|
||||
- Nothing ties a pubkey to a user. `meshcore-my-nodes` is a free-form synced list;
|
||||
anyone can put any pubkey in it, and two users can both "own" the same one. That
|
||||
is fine for favourites but not for attributing data.
|
||||
- RX is a separate app, often on another origin. Sessions are cookies with CSRF, and
|
||||
`corsMiddleware` deliberately allows no credentialed CORS (`cors.go:44`). Cookies
|
||||
across origins are also unreliable on iOS Safari, which is where people drive.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Topic | Decision |
|
||||
|---|---|---|
|
||||
| 1 | Auth for RX | A **device token** (bearer), issued by email + password login. One code path for same-origin and cross-origin deployments. No cookies, no CSRF. |
|
||||
| 2 | Token storage | A row in the existing `sessions` table with `kind = 'device'`, so it shows under **Devices** on the account page and is revoked the same way. |
|
||||
| 3 | Token scope | Narrow and server-enforced: `/api/auth/me`, `/api/auth/logout`, `/api/account/companions*`, `/api/account/settings`. Everything else answers 403 to a bearer token. |
|
||||
| 4 | Ownership proof | The companion signs a server challenge with its own Ed25519 identity key (MeshCore companion `CMD_SIGN_*`). No signature, no link. |
|
||||
| 5 | Conflicts | The newest valid proof wins. Whoever holds the key owns the companion (devices change hands). The previous owner gets an audit row, and a mail when notifications are enabled. |
|
||||
| 6 | Attribution | A read-time join `client_receptions.rx_pubkey → companion_links`. All coverage of a linked companion counts for its current owner, including rows from before the link. The ingestor and the RX payload contract do not change. |
|
||||
| 7 | My nodes | Linking also adds the pubkey to the user's `meshcore-my-nodes`. Unlinking does **not** remove it: that list is the user's own choice. |
|
||||
| 8 | Linked-only ingest | Opt-in admin setting `clientRxCoverage.requireLinkedCompanion`. When it is on, the ingestor drops every `meshcore/client/<pubkey>/…` message whose pubkey is not in `companion_links`. It is a filter, not a security boundary (see *Linked-only ingest*). |
|
||||
| 9 | Per-user API keys | Out of scope. The token table gets a `kind` and `scopes` so a later `api_key` kind (named, shown once, own scopes, revoked separately) is a small addition. The RX device token is never reused for external access. |
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-user publish credentials or broker ACLs (a possible later sub-project; it
|
||||
would change the RX payload contract).
|
||||
- Per-user API keys for external tools (see decision 8).
|
||||
- Showing other users who owns a companion. Links are private to their owner and
|
||||
to admins.
|
||||
|
||||
## Data model (`users.db`, schema v6)
|
||||
|
||||
```sql
|
||||
ALTER TABLE sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (kind IN ('web','device'));
|
||||
ALTER TABLE sessions ADD COLUMN label TEXT NOT NULL DEFAULT ''; -- device name
|
||||
ALTER TABLE sessions ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; -- '' = full (web)
|
||||
|
||||
CREATE TABLE companion_links (
|
||||
pubkey TEXT PRIMARY KEY, -- 64 lowercase hex
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
linked_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX companion_links_user ON companion_links(user_id);
|
||||
|
||||
CREATE TABLE link_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY, -- SHA-256 of the raw challenge
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Device tokens reuse the session token format (256-bit random, stored as SHA-256).
|
||||
`csrf_token` is filled but unused for `kind = 'device'`. Expiry is 90 days, sliding
|
||||
on use, like web sessions but with its own constant. The janitor prunes expired
|
||||
challenges with the other expired rows.
|
||||
|
||||
"Last seen" for a companion is not stored: the account page reads the newest
|
||||
`client_receptions` row for that pubkey from the analyzer database.
|
||||
|
||||
## API
|
||||
|
||||
All routes exist only when `userManagement.enabled`, and answer 404 otherwise, like
|
||||
the rest of A–E. Rate limits use the existing token buckets (per IP and per user).
|
||||
|
||||
### Device token
|
||||
|
||||
- `POST /api/auth/device-token {email, password, deviceName}`
|
||||
→ `200 {token, expiresAt, user: {id, displayName}}`.
|
||||
The same constant-cost failure path, rate limits and login audit row as
|
||||
`/api/auth/login`. `deviceName` is trimmed, control characters are stripped, and
|
||||
it is capped at 64 characters.
|
||||
- Authentication: `Authorization: Bearer <token>`. `withUser` accepts either a
|
||||
cookie session (with the CSRF check for writes) or a bearer token (no CSRF,
|
||||
scope check). A bearer token on a route outside its scope answers 403.
|
||||
- `POST /api/auth/logout` with a bearer token revokes that device row.
|
||||
- `GET /api/account/sessions` returns `kind` and `label`. The **Devices** list shows
|
||||
a device token as "CoreDrive RX – <label>" with its last use, and the existing
|
||||
"log out" button revokes it.
|
||||
|
||||
### Companions
|
||||
|
||||
- `POST /api/account/companions/challenge {pubkey}` → `200 {challenge, expiresAt, host}`. `host` is the host of `userManagement.publicBaseUrl`; the client signs it as-is, so a client that reaches CoreScope under another name still signs what the server verifies.
|
||||
32 random bytes as hex, valid for 5 minutes, single use, bound to user and pubkey.
|
||||
- `POST /api/account/companions {pubkey, challenge, signature, name}`:
|
||||
1. Look up the challenge by hash. Missing, expired, or bound to another user or
|
||||
pubkey → 410. It is consumed in every case.
|
||||
2. Verify `signature` (64 bytes hex, Ed25519) over the UTF-8 message
|
||||
`"corescope-link:" + host + ":" + challenge`, where `host` is the host of
|
||||
`userManagement.publicBaseUrl`. This uses `internal/sigvalidate`. Invalid → 400.
|
||||
3. Upsert `companion_links`. If the pubkey belonged to another user, write an
|
||||
audit row for both users, and mail the previous owner if notifications are
|
||||
enabled for them.
|
||||
4. Add `{pubkey, name, addedAt}` to the user's `meshcore-my-nodes`, unless it is
|
||||
already there. This is a server-side read-modify-write of the settings document
|
||||
that bumps its revision, so open web clients pick it up through the normal
|
||||
B conflict flow. If the document is at the size cap, the link still succeeds
|
||||
and the response says `myNodes: "full"`.
|
||||
5. `200 {pubkey, name, linkedAt, myNodes: "added" | "present" | "full"}`.
|
||||
- `GET /api/account/companions` → `[{pubkey, name, linkedAt, lastSeenAt}]`.
|
||||
- `DELETE /api/account/companions/{pubkey}` → 204. Leaves `meshcore-my-nodes` alone.
|
||||
- Admin: `GET /api/admin/users/{id}` gains `companions`. The audit kinds are
|
||||
`companion_link`, `companion_unlink` and `companion_transfer`.
|
||||
|
||||
### Coverage attribution
|
||||
|
||||
- `GET /api/rx-coverage?mine=1` filters to the caller's linked companions. It needs a
|
||||
session; without one it answers 401. The existing response shape is unchanged.
|
||||
- The coverage page shows a "My coverage" toggle only to logged-in users.
|
||||
|
||||
### Linked-only ingest
|
||||
|
||||
- Config: `clientRxCoverage.requireLinkedCompanion` (bool, default false). It only
|
||||
takes effect with `userManagement.enabled`. If user management is off, the server
|
||||
and the ingestor log a startup warning and ignore the setting.
|
||||
- Ingestor: before any client handler runs (packets, rf, regions), it checks the
|
||||
topic pubkey against an in-memory set of linked pubkeys read from `users.db`.
|
||||
- The set refreshes every 60 s, which picks up unlinks.
|
||||
- On a miss, the ingestor re-reads the set at most once per 5 s before dropping.
|
||||
A companion that was just linked is therefore accepted within seconds, and
|
||||
an RX client can publish right after a successful link.
|
||||
- Dropped messages are counted per reason in the ingestor stats file
|
||||
(`client_unlinked_dropped`), never logged per message.
|
||||
- `/api/config/client` gains `clientRxRequireLinkedCompanion: true` when the setting
|
||||
is in effect. The field is omitted otherwise. RX reads it to hold its queue
|
||||
instead of publishing data that would be dropped.
|
||||
- **It is not a security boundary.** All RX clients share one broker account, so
|
||||
anyone with that password can publish under a linked pubkey. The setting keeps
|
||||
honest unlinked clients out. Real enforcement needs per-user publish credentials
|
||||
(the later "publish token" sub-project).
|
||||
- Unlinking does not delete coverage that is already stored.
|
||||
|
||||
### CORS
|
||||
|
||||
For origins in `corsAllowedOrigins`, and only on the bearer-scoped routes above, the
|
||||
preflight also allows `POST, PUT, DELETE` and the `Authorization` and `Content-Type`
|
||||
headers. `Access-Control-Allow-Credentials` stays off. Same-origin needs nothing.
|
||||
|
||||
## Account page (`public/account.js`)
|
||||
|
||||
- **Devices:** device tokens render with their label and a device marker; nothing
|
||||
else changes.
|
||||
- A new **Companions** section below Devices lists name, short pubkey, linked since
|
||||
and last seen, each with an **Unlink** button. When the list is empty, it explains
|
||||
that companions are linked by logging in from CoreDrive RX.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Answer |
|
||||
|---|---|
|
||||
| Feature off | 404 on every route above. |
|
||||
| Wrong credentials | 401, the same message as `/api/auth/login`. |
|
||||
| Bearer token outside its scope | 403. |
|
||||
| Expired or revoked token | 401. RX clears the token. |
|
||||
| Challenge missing, expired or used | 410. |
|
||||
| Bad signature or malformed pubkey | 400. |
|
||||
| Rate limited | 429 with `Retry-After`. |
|
||||
|
||||
Tokens, challenges and signatures are never logged.
|
||||
|
||||
## Testing
|
||||
|
||||
- `internal/users`: the v6 migration; the device-session kind, label and scopes;
|
||||
sliding expiry; the challenge lifecycle (expiry, single use, binding); the link
|
||||
upsert and transfer; cascade on user delete.
|
||||
- `cmd/server`:
|
||||
- Device-token issue: success, the constant-cost failure, the rate limit, the audit row.
|
||||
- Bearer scope: allowed routes pass, others answer 403, and no CSRF is needed.
|
||||
- Linking with a real MeshCore key and signature fixture: success, wrong host in
|
||||
the message, reused challenge, a challenge bound to another pubkey.
|
||||
- Transfer between two users: the audit rows and the mail.
|
||||
- The `meshcore-my-nodes` merge: keeps existing items, skips duplicates, reports
|
||||
`full` at the cap.
|
||||
- `rx-coverage?mine=1`.
|
||||
- CORS headers only for allowlisted origins and only on scoped routes.
|
||||
- Everything answers 404 with the feature off.
|
||||
- `cmd/ingestor`: with `requireLinkedCompanion` off, nothing changes. With it on:
|
||||
linked pubkeys pass, unlinked pubkeys are dropped and counted, a link made after
|
||||
the last refresh is accepted through the miss refresh, the 5 s cap holds under a
|
||||
flood of unknown pubkeys, an unlink is honoured after the periodic refresh, and
|
||||
user management off means the setting is ignored with a warning.
|
||||
- `/api/config/client`: the field is present only when the setting is in effect.
|
||||
- e2e: log in on the account page, see a seeded device token under Devices and a
|
||||
linked companion under Companions, then unlink it.
|
||||
|
||||
## Rollout
|
||||
|
||||
Opt-in through `userManagement.enabled`, like A–E. Deployments where RX runs on
|
||||
another origin add that origin to `corsAllowedOrigins`. An RX build without account
|
||||
support is unaffected, **except** under `requireLinkedCompanion`: then its data is
|
||||
dropped. Turn that setting on only once the RX clients in use support linking.
|
||||
@@ -0,0 +1,79 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LinkChallengeTTL is how long a companion-link challenge stays valid.
|
||||
const LinkChallengeTTL = 5 * time.Minute
|
||||
|
||||
// challengeHash is the stored form of a challenge: SHA-256 of its 32 raw bytes.
|
||||
func challengeHash(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CreateLinkChallenge issues a single-use challenge bound to userID and
|
||||
// pubkey. It returns the 32 random bytes as lowercase hex; only their hash
|
||||
// is stored.
|
||||
func (s *Store) CreateLinkChallenge(userID int64, pubkey string) (string, time.Time, error) {
|
||||
pk, err := NormalizePubkey(pubkey)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("users: challenge: %w", err)
|
||||
}
|
||||
exp := unix(s.now().Add(LinkChallengeTTL))
|
||||
if _, err := s.db.Exec(`INSERT INTO link_challenges (challenge_hash, user_id, pubkey, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
challengeHash(raw), userID, pk, exp); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("users: create link challenge: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(raw), fromUnix(exp), nil
|
||||
}
|
||||
|
||||
// ConsumeLinkChallenge checks a challenge returned by the client and deletes
|
||||
// it in every case. It returns ErrChallengeMissing (unknown, malformed or
|
||||
// already used), ErrChallengeExpired, or ErrChallengeMismatch (bound to
|
||||
// another user or pubkey). One DELETE … RETURNING statement, so two
|
||||
// concurrent consumers cannot both succeed.
|
||||
func (s *Store) ConsumeLinkChallenge(userID int64, pubkey, challenge string) error {
|
||||
raw, err := hex.DecodeString(challenge)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return ErrChallengeMissing
|
||||
}
|
||||
var owner, exp int64
|
||||
var pk string
|
||||
err = s.db.QueryRow(`DELETE FROM link_challenges WHERE challenge_hash = ? RETURNING user_id, pubkey, expires_at`,
|
||||
challengeHash(raw)).Scan(&owner, &pk, &exp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrChallengeMissing
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("users: consume link challenge: %w", err)
|
||||
}
|
||||
if !s.now().Before(fromUnix(exp)) {
|
||||
return ErrChallengeExpired
|
||||
}
|
||||
want, perr := NormalizePubkey(pubkey)
|
||||
if owner != userID || perr != nil || pk != want {
|
||||
return ErrChallengeMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneLinkChallenges deletes challenges that have expired.
|
||||
func (s *Store) PruneLinkChallenges() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM link_challenges WHERE expires_at <= ?`, unix(s.now()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizePubkey(t *testing.T) {
|
||||
pk := strings.Repeat("ab", 32)
|
||||
if got, err := NormalizePubkey(" " + strings.ToUpper(pk) + " "); err != nil || got != pk {
|
||||
t.Fatalf("NormalizePubkey = %q, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"", pk[:62], pk + "00", strings.Repeat("zz", 32)} {
|
||||
if _, err := NormalizePubkey(bad); !errors.Is(err, ErrBadPubkey) {
|
||||
t.Fatalf("NormalizePubkey(%q) err = %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkChallengeLifecycle(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
pk := strings.Repeat("ab", 32)
|
||||
ch, exp, err := st.CreateLinkChallenge(a.ID, strings.ToUpper(pk))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := hex.DecodeString(ch)
|
||||
if err != nil || len(raw) != 32 || ch != strings.ToLower(ch) || !exp.Equal(clk.Now().Add(LinkChallengeTTL)) {
|
||||
t.Fatalf("CreateLinkChallenge = %q, %v, %v", ch, exp, err)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM link_challenges WHERE challenge_hash = ? AND pubkey = ? AND user_id = ?`,
|
||||
challengeHash(raw), pk, a.ID).Scan(&n); err != nil || n != 1 {
|
||||
t.Fatalf("stored challenge rows = %d, %v (want SHA-256 of the raw bytes)", n, err)
|
||||
}
|
||||
if _, _, err := st.CreateLinkChallenge(a.ID, "nothex"); !errors.Is(err, ErrBadPubkey) {
|
||||
t.Fatalf("bad pubkey err = %v", err)
|
||||
}
|
||||
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); err != nil {
|
||||
t.Fatalf("first consume: %v", err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
|
||||
t.Fatalf("second consume err = %v, want single use", err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, "zz"); !errors.Is(err, ErrChallengeMissing) {
|
||||
t.Fatalf("malformed challenge err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkChallengeBindingAndExpiry(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
pk := strings.Repeat("ab", 32)
|
||||
otherPk := strings.Repeat("cd", 32)
|
||||
|
||||
ch, _, _ := st.CreateLinkChallenge(a.ID, pk)
|
||||
if err := st.ConsumeLinkChallenge(b.ID, pk, ch); !errors.Is(err, ErrChallengeMismatch) {
|
||||
t.Fatalf("other user err = %v", err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
|
||||
t.Fatalf("mismatch did not consume: %v", err)
|
||||
}
|
||||
|
||||
ch, _, _ = st.CreateLinkChallenge(a.ID, pk)
|
||||
if err := st.ConsumeLinkChallenge(a.ID, otherPk, ch); !errors.Is(err, ErrChallengeMismatch) {
|
||||
t.Fatalf("other pubkey err = %v", err)
|
||||
}
|
||||
|
||||
ch, _, _ = st.CreateLinkChallenge(a.ID, pk)
|
||||
clk.Advance(LinkChallengeTTL)
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeExpired) {
|
||||
t.Fatalf("expired err = %v", err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, ch); !errors.Is(err, ErrChallengeMissing) {
|
||||
t.Fatalf("expired challenge not consumed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneLinkChallenges(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
pk := strings.Repeat("ab", 32)
|
||||
old, _, _ := st.CreateLinkChallenge(a.ID, pk)
|
||||
clk.Advance(3 * time.Minute)
|
||||
fresh, _, _ := st.CreateLinkChallenge(a.ID, pk)
|
||||
clk.Advance(3 * time.Minute) // old expired, fresh has 2 minutes left
|
||||
|
||||
n, err := st.PruneLinkChallenges()
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("PruneLinkChallenges = %d, %v", n, err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, old); !errors.Is(err, ErrChallengeMissing) {
|
||||
t.Fatalf("pruned challenge err = %v", err)
|
||||
}
|
||||
if err := st.ConsumeLinkChallenge(a.ID, pk, fresh); err != nil {
|
||||
t.Fatalf("live challenge pruned: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NormalizePubkey returns a companion's public key as 64 lowercase hex
|
||||
// characters, or ErrBadPubkey.
|
||||
func NormalizePubkey(s string) (string, error) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if len(s) != 64 {
|
||||
return "", ErrBadPubkey
|
||||
}
|
||||
if _, err := hex.DecodeString(s); err != nil {
|
||||
return "", ErrBadPubkey
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// CompanionLink ties a companion's pubkey to the user who proved they hold
|
||||
// its key. A pubkey has at most one owner.
|
||||
type CompanionLink struct {
|
||||
Pubkey string
|
||||
UserID int64
|
||||
Name string
|
||||
LinkedAt time.Time
|
||||
}
|
||||
|
||||
const companionCols = `pubkey, user_id, name, linked_at`
|
||||
|
||||
func scanCompanion(row rowScanner) (*CompanionLink, error) {
|
||||
var l CompanionLink
|
||||
var at int64
|
||||
if err := row.Scan(&l.Pubkey, &l.UserID, &l.Name, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.LinkedAt = fromUnix(at)
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// UpsertCompanionLink links pubkey to userID; the newest proof wins. When
|
||||
// the pubkey belonged to another user it moves to userID with a fresh
|
||||
// LinkedAt, and prevOwner is that user's id. Otherwise prevOwner is 0; a
|
||||
// re-link by the same owner only updates the name. name is cleaned with
|
||||
// CleanLabel. The caller must have consumed a valid challenge and verified
|
||||
// the signature first.
|
||||
func (s *Store) UpsertCompanionLink(userID int64, pubkey, name string) (*CompanionLink, int64, error) {
|
||||
pk, err := NormalizePubkey(pubkey)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
name = CleanLabel(name)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var prev, linkedAt int64
|
||||
err = tx.QueryRow(`SELECT user_id, linked_at FROM companion_links WHERE pubkey = ?`, pk).Scan(&prev, &linkedAt)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
prev, linkedAt = 0, unix(s.now())
|
||||
case err != nil:
|
||||
tx.Rollback()
|
||||
return nil, 0, fmt.Errorf("users: read companion link: %w", err)
|
||||
case prev != userID:
|
||||
linkedAt = unix(s.now())
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO companion_links (pubkey, user_id, name, linked_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(pubkey) DO UPDATE SET user_id = excluded.user_id, name = excluded.name, linked_at = excluded.linked_at`,
|
||||
pk, userID, name, linkedAt); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, fmt.Errorf("users: upsert companion link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, 0, fmt.Errorf("users: upsert companion link: %w", err)
|
||||
}
|
||||
if prev == userID {
|
||||
prev = 0
|
||||
}
|
||||
return &CompanionLink{Pubkey: pk, UserID: userID, Name: name, LinkedAt: fromUnix(linkedAt)}, prev, nil
|
||||
}
|
||||
|
||||
// ListCompanionLinks returns a user's linked companions, newest first.
|
||||
func (s *Store) ListCompanionLinks(userID int64) ([]CompanionLink, error) {
|
||||
rows, err := s.db.Query(`SELECT `+companionCols+` FROM companion_links WHERE user_id = ? ORDER BY linked_at DESC, pubkey`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CompanionLink
|
||||
for rows.Next() {
|
||||
l, err := scanCompanion(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetCompanionLink returns the link for pubkey, or ErrNotFound.
|
||||
func (s *Store) GetCompanionLink(pubkey string) (*CompanionLink, error) {
|
||||
pk, err := NormalizePubkey(pubkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, err := scanCompanion(s.db.QueryRow(`SELECT `+companionCols+` FROM companion_links WHERE pubkey = ?`, pk))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("users: get companion link: %w", err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// DeleteCompanionLink unlinks pubkey, only if it is linked to userID.
|
||||
func (s *Store) DeleteCompanionLink(userID int64, pubkey string) error {
|
||||
pk, err := NormalizePubkey(pubkey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectOne(s.db.Exec(`DELETE FROM companion_links WHERE pubkey = ? AND user_id = ?`, pk, userID))
|
||||
}
|
||||
|
||||
// LinkedPubkeys returns every linked pubkey, sorted. (The ingestor does not
|
||||
// call this: it reads users.db read-only with raw SQL.)
|
||||
func (s *Store) LinkedPubkeys() ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT pubkey FROM companion_links ORDER BY pubkey`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
if err := rows.Scan(&pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pk)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompanionLinkUpsertAndTransfer(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
pk := strings.Repeat("ab", 32)
|
||||
|
||||
l, prev, err := st.UpsertCompanionLink(a.ID, strings.ToUpper(pk), " Car\n ")
|
||||
if err != nil || prev != 0 {
|
||||
t.Fatalf("first link: prev=%d err=%v", prev, err)
|
||||
}
|
||||
first := clk.Now()
|
||||
if l.Pubkey != pk || l.UserID != a.ID || l.Name != "Car" || !l.LinkedAt.Equal(first) {
|
||||
t.Fatalf("first link = %+v", l)
|
||||
}
|
||||
|
||||
clk.Advance(time.Hour)
|
||||
l, prev, err = st.UpsertCompanionLink(a.ID, pk, "Car 2")
|
||||
if err != nil || prev != 0 || l.Name != "Car 2" || !l.LinkedAt.Equal(first) {
|
||||
t.Fatalf("re-link by owner = %+v, prev=%d, err=%v", l, prev, err)
|
||||
}
|
||||
if got, _ := st.GetCompanionLink(pk); got == nil || got.Name != "Car 2" || !got.LinkedAt.Equal(first) {
|
||||
t.Fatalf("after re-link Get = %+v", got)
|
||||
}
|
||||
|
||||
clk.Advance(time.Hour)
|
||||
l, prev, err = st.UpsertCompanionLink(b.ID, pk, "Bike")
|
||||
if err != nil || prev != a.ID {
|
||||
t.Fatalf("transfer: prev=%d (want %d) err=%v", prev, a.ID, err)
|
||||
}
|
||||
if l.UserID != b.ID || !l.LinkedAt.Equal(clk.Now()) {
|
||||
t.Fatalf("transferred link = %+v", l)
|
||||
}
|
||||
got, err := st.GetCompanionLink(pk)
|
||||
if err != nil || got.UserID != b.ID || got.Name != "Bike" {
|
||||
t.Fatalf("Get after transfer = %+v, %v", got, err)
|
||||
}
|
||||
if list, _ := st.ListCompanionLinks(a.ID); len(list) != 0 {
|
||||
t.Fatalf("previous owner still lists it: %+v", list)
|
||||
}
|
||||
if list, _ := st.ListCompanionLinks(b.ID); len(list) != 1 || list[0].Pubkey != pk {
|
||||
t.Fatalf("new owner list = %+v", list)
|
||||
}
|
||||
if _, _, err := st.UpsertCompanionLink(a.ID, "short", "x"); !errors.Is(err, ErrBadPubkey) {
|
||||
t.Fatalf("bad pubkey err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanionLinkListDeleteAndAll(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
pk1, pk2, pk3 := strings.Repeat("03", 32), strings.Repeat("01", 32), strings.Repeat("02", 32)
|
||||
st.UpsertCompanionLink(a.ID, pk1, "one")
|
||||
clk.Advance(time.Minute)
|
||||
st.UpsertCompanionLink(a.ID, pk2, "two")
|
||||
st.UpsertCompanionLink(b.ID, pk3, "three")
|
||||
|
||||
list, err := st.ListCompanionLinks(a.ID)
|
||||
if err != nil || len(list) != 2 || list[0].Pubkey != pk2 || list[1].Pubkey != pk1 {
|
||||
t.Fatalf("ListCompanionLinks (newest first) = %+v, %v", list, err)
|
||||
}
|
||||
all, err := st.LinkedPubkeys()
|
||||
if err != nil || !reflect.DeepEqual(all, []string{pk2, pk3, pk1}) {
|
||||
t.Fatalf("LinkedPubkeys = %v, %v", all, err)
|
||||
}
|
||||
|
||||
if err := st.DeleteCompanionLink(b.ID, pk1); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("deleting someone else's link = %v", err)
|
||||
}
|
||||
if err := st.DeleteCompanionLink(a.ID, strings.ToUpper(pk1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetCompanionLink(pk1); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Get after delete err = %v", err)
|
||||
}
|
||||
if err := st.DeleteCompanionLink(a.ID, pk1); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("second delete err = %v", err)
|
||||
}
|
||||
if _, err := st.GetCompanionLink("nothex"); !errors.Is(err, ErrBadPubkey) {
|
||||
t.Fatalf("Get bad pubkey err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a user removes their links, challenges and device tokens.
|
||||
func TestCompanionDataCascadesOnUserDelete(t *testing.T) {
|
||||
st, _ := newTestStore(t)
|
||||
a := mustCreate(t, st, "a@example.org", "A")
|
||||
b := mustCreate(t, st, "b@example.org", "B")
|
||||
pkA, pkB := strings.Repeat("aa", 32), strings.Repeat("bb", 32)
|
||||
st.UpsertCompanionLink(a.ID, pkA, "")
|
||||
st.UpsertCompanionLink(b.ID, pkB, "")
|
||||
if _, _, err := st.CreateLinkChallenge(a.ID, pkA); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _, err := st.CreateDeviceSession(a.ID, "Phone", []string{"rx"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := st.Delete(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetCompanionLink(pkA); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("link survived user delete: %v", err)
|
||||
}
|
||||
if all, _ := st.LinkedPubkeys(); !reflect.DeepEqual(all, []string{pkB}) {
|
||||
t.Fatalf("LinkedPubkeys after delete = %v", all)
|
||||
}
|
||||
var n int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM link_challenges WHERE user_id = ?`, a.ID).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("challenges after user delete = %d, %v", n, err)
|
||||
}
|
||||
if _, err := st.LookupSession(raw); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("device token survived user delete: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestDeviceSessionKindLabelScopes(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "dev@example.org", "Dev")
|
||||
_, web, err := st.CreateSession(u.ID, time.Hour, "browser")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if web.Kind != SessionKindWeb || web.Label != "" || web.Scopes != nil || !web.HasScope("anything") {
|
||||
t.Fatalf("web session = %+v", web)
|
||||
}
|
||||
|
||||
raw, dev, err := st.CreateDeviceSession(u.ID, " Pixel\x07 8 ", []string{"rx", "account"}, "CoreDriveRX/1.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw == "" || dev.Kind != SessionKindDevice || dev.Label != "Pixel 8" || dev.CSRFToken == "" ||
|
||||
!reflect.DeepEqual(dev.Scopes, []string{"rx", "account"}) || !dev.ExpiresAt.Equal(clk.Now().Add(DeviceSessionTTL)) {
|
||||
t.Fatalf("CreateDeviceSession = %+v", dev)
|
||||
}
|
||||
|
||||
got, err := st.LookupSession(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != dev.ID || got.Kind != SessionKindDevice || got.Label != "Pixel 8" || got.UserAgent != "CoreDriveRX/1.0" ||
|
||||
!reflect.DeepEqual(got.Scopes, []string{"rx", "account"}) {
|
||||
t.Fatalf("LookupSession = %+v", got)
|
||||
}
|
||||
if !got.HasScope("rx") || !got.HasScope("account") || got.HasScope("admin") || got.HasScope("") {
|
||||
t.Fatalf("HasScope wrong for %v", got.Scopes)
|
||||
}
|
||||
|
||||
list, err := st.ListSessions(u.ID)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("ListSessions = %+v, %v", list, err)
|
||||
}
|
||||
kinds := map[string]string{}
|
||||
for _, s := range list {
|
||||
kinds[s.Kind] = s.Label
|
||||
}
|
||||
if l, ok := kinds[SessionKindDevice]; !ok || l != "Pixel 8" {
|
||||
t.Fatalf("device row missing from list: %+v", list)
|
||||
}
|
||||
if l, ok := kinds[SessionKindWeb]; !ok || l != "" {
|
||||
t.Fatalf("web row missing from list: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceSessionSlidingExpiry(t *testing.T) {
|
||||
st, clk := newTestStore(t)
|
||||
u := mustCreate(t, st, "slide@example.org", "Slide")
|
||||
raw, dev, err := st.CreateDeviceSession(u.ID, "Phone", []string{"rx"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(DeviceSessionTTL - time.Hour)
|
||||
if err := st.ExtendSession(dev.ID, DeviceSessionTTL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clk.Advance(DeviceSessionTTL - time.Hour) // well past the original expiry
|
||||
if _, err := st.LookupSession(raw); err != nil {
|
||||
t.Fatalf("extended device session expired early: %v", err)
|
||||
}
|
||||
clk.Advance(2 * time.Hour)
|
||||
if _, err := st.LookupSession(raw); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expired device session err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceSessionRevoke(t *testing.T) {
|
||||
st, _ := newTestStore(t)
|
||||
u := mustCreate(t, st, "rev@example.org", "Rev")
|
||||
other := mustCreate(t, st, "oth@example.org", "Oth")
|
||||
raw1, d1, _ := st.CreateDeviceSession(u.ID, "One", []string{"rx"}, "")
|
||||
raw2, _, _ := st.CreateDeviceSession(u.ID, "Two", []string{"rx"}, "")
|
||||
|
||||
if err := st.DeleteSession(other.ID, d1.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("revoking someone else's device = %v", err)
|
||||
}
|
||||
if err := st.DeleteSession(u.ID, d1.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.LookupSession(raw1); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("revoked device token still resolves: %v", err)
|
||||
}
|
||||
if err := st.DeleteSessionByToken(raw2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.LookupSession(raw2); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("logged-out device token still resolves: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceSessionRejectsBadScopes(t *testing.T) {
|
||||
st, _ := newTestStore(t)
|
||||
u := mustCreate(t, st, "scope@example.org", "Scope")
|
||||
if _, _, err := st.CreateDeviceSession(u.ID, "x", nil, ""); !errors.Is(err, ErrNoScopes) {
|
||||
t.Fatalf("nil scopes err = %v", err)
|
||||
}
|
||||
for _, bad := range []string{"", "a,b", "Rx", "rx scope"} {
|
||||
if _, _, err := st.CreateDeviceSession(u.ID, "x", []string{bad}, ""); err == nil {
|
||||
t.Fatalf("scope %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if list, _ := st.ListSessions(u.ID); len(list) != 0 {
|
||||
t.Fatalf("rejected device sessions were stored: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanLabel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" Car ": "Car",
|
||||
"a\tb\nc\x00d": "abcd",
|
||||
"bad\xffutf8": "badutf8",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := CleanLabel(in); got != want {
|
||||
t.Errorf("CleanLabel(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
long := CleanLabel(strings.Repeat("é", 70))
|
||||
if utf8.RuneCountInString(long) != 64 {
|
||||
t.Fatalf("CleanLabel cap = %d runes", utf8.RuneCountInString(long))
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,25 @@ var migrations = [][]string{
|
||||
PRIMARY KEY (user_id, event, subject)
|
||||
)`,
|
||||
},
|
||||
{ // v6: sub-project F, companion linking (device sessions, linked companions)
|
||||
`ALTER TABLE sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (kind IN ('web','device'))`,
|
||||
`ALTER TABLE sessions ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE sessions ADD COLUMN scopes TEXT NOT NULL DEFAULT ''`,
|
||||
`CREATE TABLE companion_links (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
linked_at INTEGER NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX companion_links_user ON companion_links(user_id)`,
|
||||
`CREATE TABLE link_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
pubkey TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A users.db written by a v5 binary gains the companion-linking schema and
|
||||
// keeps its sessions, which become web sessions with no label or scopes.
|
||||
func TestMigrateV5DatabaseToV6(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "users.db")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stmts := []string{`CREATE TABLE schema_version (version INTEGER NOT NULL)`, `INSERT INTO schema_version (version) VALUES (5)`}
|
||||
for _, m := range migrations[:5] {
|
||||
stmts = append(stmts, m...)
|
||||
}
|
||||
stmts = append(stmts,
|
||||
`INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`,
|
||||
`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at) VALUES ('h', 1, 'c', 1, 4102444800, 1)`)
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("build v5 db: %v", err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
st, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open v5 db: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if len(migrations) < 6 {
|
||||
t.Fatal("migration v6 missing")
|
||||
}
|
||||
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
|
||||
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
|
||||
}
|
||||
|
||||
var kind, label, scopes string
|
||||
if err := st.db.QueryRow(`SELECT kind, label, scopes FROM sessions WHERE token_hash = 'h'`).Scan(&kind, &label, &scopes); err != nil {
|
||||
t.Fatalf("v5 session lost: %v", err)
|
||||
}
|
||||
if kind != "web" || label != "" || scopes != "" {
|
||||
t.Fatalf("migrated session kind=%q label=%q scopes=%q", kind, label, scopes)
|
||||
}
|
||||
if _, err := st.db.Exec(`UPDATE sessions SET kind = 'api_key' WHERE token_hash = 'h'`); err == nil || !strings.Contains(err.Error(), "CHECK") {
|
||||
t.Fatalf("kind outside web/device accepted: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(`UPDATE sessions SET kind = 'device' WHERE token_hash = 'h'`); err != nil {
|
||||
t.Fatalf("kind device rejected: %v", err)
|
||||
}
|
||||
|
||||
pk := strings.Repeat("ab", 32)
|
||||
if _, err := st.db.Exec(`INSERT INTO companion_links (pubkey, user_id, linked_at) VALUES (?, 1, 1)`, pk); err != nil {
|
||||
t.Fatalf("companion_links: %v", err)
|
||||
}
|
||||
var name string
|
||||
if err := st.db.QueryRow(`SELECT name FROM companion_links WHERE pubkey = ?`, pk).Scan(&name); err != nil || name != "" {
|
||||
t.Fatalf("companion_links default name = %q, %v", name, err)
|
||||
}
|
||||
if _, err := st.db.Exec(`INSERT INTO companion_links (pubkey, user_id, linked_at) VALUES (?, 99, 1)`, strings.Repeat("cd", 32)); err == nil {
|
||||
t.Fatal("companion_links accepted an unknown user (foreign key off?)")
|
||||
}
|
||||
if _, err := st.db.Exec(`INSERT INTO link_challenges (challenge_hash, user_id, pubkey, expires_at) VALUES ('x', 1, ?, 1)`, pk); err != nil {
|
||||
t.Fatalf("link_challenges: %v", err)
|
||||
}
|
||||
var idx int
|
||||
if err := st.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'companion_links_user'`).Scan(&idx); err != nil || idx != 1 {
|
||||
t.Fatalf("index companion_links_user = %d, %v", idx, err)
|
||||
}
|
||||
}
|
||||
@@ -6,35 +6,122 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Session is one logged-in device. The raw token exists only in the cookie.
|
||||
// Session kinds (sessions.kind).
|
||||
const (
|
||||
SessionKindWeb = "web"
|
||||
SessionKindDevice = "device"
|
||||
)
|
||||
|
||||
// DeviceSessionTTL is a device token's lifetime, sliding on use: callers
|
||||
// pass it to ExtendSession whenever the token is used.
|
||||
const DeviceSessionTTL = 90 * 24 * time.Hour
|
||||
|
||||
// Session is one logged-in browser or device. The raw token exists only in
|
||||
// the cookie (web) or on the device (bearer).
|
||||
type Session struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
CSRFToken string
|
||||
CSRFToken string // filled but unused for device sessions
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
LastSeenAt time.Time
|
||||
UserAgent string
|
||||
Kind string // SessionKindWeb or SessionKindDevice
|
||||
Label string // device name; "" for web sessions
|
||||
Scopes []string // nil = full access (web sessions)
|
||||
}
|
||||
|
||||
// HasScope reports whether the session may use scope. Web sessions have
|
||||
// full access; device sessions only their listed scopes.
|
||||
func (s *Session) HasScope(scope string) bool {
|
||||
if s.Kind != SessionKindDevice {
|
||||
return true
|
||||
}
|
||||
for _, sc := range s.Scopes {
|
||||
if sc == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const maxUserAgent = 200
|
||||
|
||||
const sessionCols = `id, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent`
|
||||
const maxLabel = 64
|
||||
|
||||
// CleanLabel makes a user-supplied device or companion name safe to store:
|
||||
// invalid UTF-8 and control characters removed, trimmed, at most 64 runes.
|
||||
func CleanLabel(s string) string {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
s = strings.TrimSpace(s)
|
||||
if r := []rune(s); len(r) > maxLabel {
|
||||
s = strings.TrimSpace(string(r[:maxLabel]))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func validScope(sc string) bool {
|
||||
if sc == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range sc {
|
||||
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == ':' || r == '.' || r == '_' || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func splitScopes(v string) []string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(v, ",")
|
||||
}
|
||||
|
||||
const sessionCols = `id, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent, kind, label, scopes`
|
||||
|
||||
func scanSession(row rowScanner) (*Session, error) {
|
||||
var s Session
|
||||
var created, expires, seen int64
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.CSRFToken, &created, &expires, &seen, &s.UserAgent); err != nil {
|
||||
var scopes string
|
||||
if err := row.Scan(&s.ID, &s.UserID, &s.CSRFToken, &created, &expires, &seen, &s.UserAgent, &s.Kind, &s.Label, &scopes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.CreatedAt, s.ExpiresAt, s.LastSeenAt = fromUnix(created), fromUnix(expires), fromUnix(seen)
|
||||
s.Scopes = splitScopes(scopes)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSession starts a session and returns the raw cookie token.
|
||||
// CreateSession starts a web session and returns the raw cookie token.
|
||||
func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string) (string, *Session, error) {
|
||||
return s.createSession(userID, ttl, userAgent, SessionKindWeb, "", nil)
|
||||
}
|
||||
|
||||
// CreateDeviceSession issues a device (bearer) token for userID, limited to
|
||||
// scopes and valid for DeviceSessionTTL. label is cleaned with CleanLabel.
|
||||
func (s *Store) CreateDeviceSession(userID int64, label string, scopes []string, userAgent string) (string, *Session, error) {
|
||||
if len(scopes) == 0 {
|
||||
return "", nil, ErrNoScopes
|
||||
}
|
||||
for _, sc := range scopes {
|
||||
if !validScope(sc) {
|
||||
return "", nil, fmt.Errorf("users: invalid scope %q", sc)
|
||||
}
|
||||
}
|
||||
return s.createSession(userID, DeviceSessionTTL, userAgent, SessionKindDevice, CleanLabel(label), scopes)
|
||||
}
|
||||
|
||||
func (s *Store) createSession(userID int64, ttl time.Duration, userAgent, kind, label string, scopes []string) (string, *Session, error) {
|
||||
raw, hash, err := NewToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
@@ -47,8 +134,9 @@ func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string)
|
||||
userAgent = strings.ToValidUTF8(userAgent[:maxUserAgent], "")
|
||||
}
|
||||
now := s.now()
|
||||
res, err := s.db.Exec(`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, hash, userID, csrf, unix(now), unix(now.Add(ttl)), unix(now), userAgent)
|
||||
res, err := s.db.Exec(`INSERT INTO sessions (token_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, user_agent, kind, label, scopes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, hash, userID, csrf, unix(now), unix(now.Add(ttl)), unix(now), userAgent,
|
||||
kind, label, strings.Join(scopes, ","))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -57,7 +145,8 @@ func (s *Store) CreateSession(userID int64, ttl time.Duration, userAgent string)
|
||||
return "", nil, err
|
||||
}
|
||||
return raw, &Session{ID: id, UserID: userID, CSRFToken: csrf, CreatedAt: fromUnix(unix(now)),
|
||||
ExpiresAt: fromUnix(unix(now.Add(ttl))), LastSeenAt: fromUnix(unix(now)), UserAgent: userAgent}, nil
|
||||
ExpiresAt: fromUnix(unix(now.Add(ttl))), LastSeenAt: fromUnix(unix(now)), UserAgent: userAgent,
|
||||
Kind: kind, Label: label, Scopes: splitScopes(strings.Join(scopes, ","))}, nil
|
||||
}
|
||||
|
||||
// LookupSession resolves a raw cookie token. Unknown and expired sessions
|
||||
|
||||
@@ -26,6 +26,15 @@ var (
|
||||
// password hash the caller verified (a re-register or an admin got
|
||||
// there first).
|
||||
ErrAccountChanged = errors.New("users: account changed since it was read")
|
||||
// ErrNoScopes: a device session must name what it may do; an empty
|
||||
// scope list would mean full (web) access.
|
||||
ErrNoScopes = errors.New("users: a device session needs at least one scope")
|
||||
ErrBadPubkey = errors.New("users: pubkey must be 64 hex characters")
|
||||
// Link challenges (companion linking). A challenge is consumed by every
|
||||
// lookup, whichever of these it returns.
|
||||
ErrChallengeMissing = errors.New("users: link challenge unknown or already used")
|
||||
ErrChallengeExpired = errors.New("users: link challenge expired")
|
||||
ErrChallengeMismatch = errors.New("users: link challenge bound to another user or pubkey")
|
||||
)
|
||||
|
||||
// noLimit is SQLite's LIMIT value for "every row" (the account export).
|
||||
|
||||
Reference in New Issue
Block a user