mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 06:57:51 +00:00
Part 2 of 3 for #2173 (companion linking): the server routes, bearer auth and the link handshake. Builds on #2174 (merged); `upstream/master` is merged in, so the diff shows only this part. Everything here is behind `userManagement.enabled` and answers 404 with it off. ## Changes in this PR **Device tokens and bearer auth** - `POST /api/auth/device-token`: email + password returns a bearer token, stored as a `kind = 'device'` session with a label. Same failure answers and the same rate limit as login. - Server-enforced scope for a bearer token: `/api/auth/me`, `/api/auth/logout`, `/api/account/companions*`, `/api/account/settings`. Everything else answers 403. - A bearer header only counts when there is no session cookie, so an auth proxy that adds its own `Authorization` header does not hide a logged-in browser. Cookies never accept a device token. - The sessions list on the account API carries `kind` and `label`; logout with a bearer token revokes that device. **Linking a companion** - `POST /api/account/companions/challenge` `{pubkey}` returns `{challenge, expiresAt, host}`: 32 random bytes, single use, 5 minutes, bound to the caller and the pubkey. - `POST /api/account/companions` `{pubkey, challenge, signature, name}`: the signature is the companion's Ed25519 signature over `corescope-link:<host>:<challenge>` (`internal/sigvalidate.VerifyMessage`). The challenge is consumed in every case. - A companion linked to another account moves to the caller (the newest proof wins). Both users get a `companion.transfer` audit row and the previous owner a mail. That mail is a security notice: it is sent whatever the node notification settings are. - `GET /api/account/companions` (with `lastSeenAt` from `client_receptions`) and `DELETE /api/account/companions/{pubkey}`. - Linking does not touch the synced `meshcore-my-nodes`: a companion is not a node to monitor. - Admin user detail lists linked companions; the account data export includes companions and device sessions. **Coverage and config** - `GET /api/rx-coverage?mine=1`: only coverage of the caller's linked companions (401 when logged out, 403 for a device token). With `gaps=1` (#2172) the reply carries no `gaps` member under `mine=1`, because the track query takes one companion and cannot narrow to a set yet (`TestRxCoverageMineOmitsGaps`). - `clientRxCoverage.requireLinkedCompanion` (default off) is read and reported in `/api/config/client`, which also announces `userManagement.companionLinking`, so CoreDrive RX shows its login only where linking exists. The ingestor side is part 3. - CORS: for origins in `corsAllowedOrigins`, the device-token routes alone also allow POST/PUT/DELETE and the `Authorization` and `Content-Type` headers. No credentialed CORS. Documented in `config.example.json`. - OpenAPI and `docs/api-spec.md` cover the new routes. ## Verification - 26 new tests in `cmd/server` and `internal/sigvalidate`, among them: `TestCompanionLinkFlow`, `TestCompanionLinkRejections` (signature by another key, malformed signature, challenge bound to another pubkey, unknown challenge; reuse and expiry are covered by the store tests in #2174), `TestCompanionTransfer`, `TestCompanionTransferMailIgnoresNotificationSettings`, `TestBearerScopedRoutes`, `TestBearerIgnoredWithCookie`, `TestDeviceTokenSharesLoginRateLimit`, `TestCORSBearerRoutes`, `TestCompanionRoutesAbsentWhenOff`, `TestRxCoverageMine`, `TestRxCoverageMineOmitsGaps` (fails without the `mine == nil` guard), `TestVerifyMessage`. - `cmd/server`, `internal/sigvalidate` and `internal/users`: `go vet` and `go test ./...` green, `gofmt` clean. - The linking flow runs on analyzer.on8ar.eu since 9 October 2026 with CoreDrive RX v1.19.0 (a real companion linked through the signed challenge). ## Not in this PR - The ingestor's linked-only filter, the account page (Devices, Companions), the "My coverage" toggle and the user docs: part 3. - Per-user broker credentials and per-user API keys (non-goals in the spec). --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
218 lines
7.8 KiB
Go
218 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/users"
|
|
)
|
|
|
|
// accountExport is GET /api/account/export: everything users.db holds about
|
|
// the logged-in account (docs/specs/2026-10-08-account-export-and-users-backup-design.md).
|
|
// Credentials are left out on purpose: the password hash, session, link and
|
|
// unsubscribe tokens and companion link challenges; of a pending email
|
|
// change only the new address is exported. Other accounts appear as ids only (activatedBy, audit).
|
|
type accountExport struct {
|
|
FormatVersion int `json:"formatVersion"`
|
|
ExportedAt string `json:"exportedAt"`
|
|
Instance string `json:"instance"`
|
|
Profile exportProfile `json:"profile"`
|
|
Sessions []exportSession `json:"sessions"`
|
|
Companions []exportCompanion `json:"companions"`
|
|
Settings *exportSettings `json:"settings"`
|
|
Proposals []exportProposal `json:"proposals"`
|
|
Notifications exportNotifications `json:"notifications"`
|
|
Audit []exportAuditEntry `json:"audit"`
|
|
Mail []exportMail `json:"mail"`
|
|
}
|
|
|
|
type exportProfile struct {
|
|
ID int64 `json:"id"`
|
|
Email string `json:"email"`
|
|
PendingEmail *string `json:"pendingEmail"` // unconfirmed email change: the address only
|
|
DisplayName string `json:"displayName"`
|
|
Role users.Role `json:"role"`
|
|
Status users.Status `json:"status"`
|
|
CreatedAt string `json:"createdAt"`
|
|
LastLoginAt *string `json:"lastLoginAt"`
|
|
EmailBouncing bool `json:"emailBouncing"`
|
|
ActivatedAt *string `json:"activatedAt"`
|
|
ActivatedBy *int64 `json:"activatedBy"`
|
|
}
|
|
|
|
type exportSession struct {
|
|
Kind string `json:"kind"` // "web" or "device"
|
|
Label string `json:"label,omitempty"` // device name
|
|
CreatedAt string `json:"createdAt"`
|
|
LastSeenAt string `json:"lastSeenAt"`
|
|
UserAgent string `json:"userAgent,omitempty"`
|
|
}
|
|
|
|
type exportCompanion struct {
|
|
Pubkey string `json:"pubkey"`
|
|
Name string `json:"name"`
|
|
LinkedAt string `json:"linkedAt"`
|
|
}
|
|
|
|
type exportSettings struct {
|
|
Revision int64 `json:"revision"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
Doc settingsDoc `json:"doc"`
|
|
}
|
|
|
|
type exportProposal struct {
|
|
Kind string `json:"kind"`
|
|
Subject string `json:"subject"`
|
|
Status users.ProposalStatus `json:"status"`
|
|
Note string `json:"note"`
|
|
CreatedAt string `json:"createdAt"`
|
|
DecidedAt *string `json:"decidedAt"`
|
|
}
|
|
|
|
type exportNotifications struct {
|
|
Prefs *exportNotifyPrefs `json:"prefs"`
|
|
Watches []exportWatch `json:"watches"`
|
|
}
|
|
|
|
type exportNotifyPrefs struct {
|
|
Enabled bool `json:"enabled"`
|
|
Events []string `json:"events"`
|
|
}
|
|
|
|
type exportWatch struct {
|
|
Pubkey string `json:"pubkey"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
type exportAuditEntry struct {
|
|
At string `json:"at"`
|
|
Action string `json:"action"`
|
|
ActorUserID *int64 `json:"actorUserId"`
|
|
TargetUserID *int64 `json:"targetUserId"`
|
|
Detail map[string]string `json:"detail"`
|
|
}
|
|
|
|
type exportMail struct {
|
|
To string `json:"to"`
|
|
Purpose string `json:"purpose"`
|
|
SentAt string `json:"sentAt"`
|
|
Status string `json:"status"`
|
|
StatusAt string `json:"statusAt"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Events []mailEventJSON `json:"events"`
|
|
}
|
|
|
|
// buildAccountExport reads every section for u. It only reads users.db.
|
|
func (a *authService) buildAccountExport(u *users.User, now time.Time) (*accountExport, error) {
|
|
x := &accountExport{
|
|
FormatVersion: 1,
|
|
ExportedAt: rfc3339(now),
|
|
Instance: a.set.baseURL.String(),
|
|
Profile: exportProfile{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, Status: u.Status,
|
|
CreatedAt: rfc3339(u.CreatedAt), LastLoginAt: rfc3339Ptr(u.LastLoginAt), EmailBouncing: u.EmailBouncing,
|
|
ActivatedAt: rfc3339Ptr(u.ActivatedAt), ActivatedBy: u.ActivatedBy},
|
|
Sessions: []exportSession{},
|
|
Companions: []exportCompanion{},
|
|
Proposals: []exportProposal{},
|
|
Notifications: exportNotifications{Watches: []exportWatch{}},
|
|
Audit: []exportAuditEntry{},
|
|
Mail: []exportMail{},
|
|
}
|
|
pending, err := a.st.PendingEmailChange(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pending email: %w", err)
|
|
}
|
|
if pending != "" {
|
|
x.Profile.PendingEmail = &pending
|
|
}
|
|
sessions, err := a.st.ListSessions(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sessions: %w", err)
|
|
}
|
|
for _, s := range sessions {
|
|
x.Sessions = append(x.Sessions, exportSession{Kind: s.Kind, Label: s.Label, CreatedAt: rfc3339(s.CreatedAt), LastSeenAt: rfc3339(s.LastSeenAt), UserAgent: s.UserAgent})
|
|
}
|
|
links, err := a.st.ListCompanionLinks(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("companions: %w", err)
|
|
}
|
|
for _, l := range links {
|
|
x.Companions = append(x.Companions, exportCompanion{Pubkey: l.Pubkey, Name: l.Name, LinkedAt: rfc3339(l.LinkedAt)})
|
|
}
|
|
rec, err := a.st.SettingsRecordFor(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("settings: %w", err)
|
|
}
|
|
if rec != nil {
|
|
var doc settingsDoc
|
|
if err := json.Unmarshal([]byte(rec.Doc), &doc); err != nil {
|
|
return nil, fmt.Errorf("settings decode (%d bytes): %w", len(rec.Doc), err)
|
|
}
|
|
x.Settings = &exportSettings{Revision: rec.Version.Revision, UpdatedAt: rfc3339(rec.UpdatedAt), Doc: doc}
|
|
}
|
|
props, err := a.st.AllProposalsByUser(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("proposals: %w", err)
|
|
}
|
|
for _, p := range props {
|
|
x.Proposals = append(x.Proposals, exportProposal{Kind: p.Kind, Subject: p.Subject, Status: p.Status, Note: p.Note,
|
|
CreatedAt: rfc3339(p.CreatedAt), DecidedAt: rfc3339Ptr(p.DecidedAt)})
|
|
}
|
|
prefs, err := a.st.StoredNotifyPrefs(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notification prefs: %w", err)
|
|
}
|
|
if prefs != nil {
|
|
x.Notifications.Prefs = &exportNotifyPrefs{Enabled: prefs.Enabled, Events: prefs.Events}
|
|
}
|
|
watches, err := a.st.WatchesFor(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("watches: %w", err)
|
|
}
|
|
for _, w := range watches {
|
|
x.Notifications.Watches = append(x.Notifications.Watches, exportWatch{Pubkey: w.Pubkey, CreatedAt: rfc3339(w.CreatedAt)})
|
|
}
|
|
entries, err := a.st.AuditAllFor(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("audit: %w", err)
|
|
}
|
|
for _, e := range entries {
|
|
x.Audit = append(x.Audit, exportAuditEntry{At: rfc3339(e.At), Action: e.Action,
|
|
ActorUserID: e.ActorUserID, TargetUserID: e.TargetUserID, Detail: e.Detail})
|
|
}
|
|
mails, err := a.st.MailAllForUser(u.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mail: %w", err)
|
|
}
|
|
for _, m := range mails {
|
|
em := exportMail{To: m.ToEmail, Purpose: m.Purpose, SentAt: rfc3339(m.SentAt), Status: m.LastEvent,
|
|
StatusAt: rfc3339(m.LastEventAt), Reason: m.LastReason, Events: []mailEventJSON{}}
|
|
for _, e := range m.Events {
|
|
em.Events = append(em.Events, mailEventJSON{Event: e.Event, At: rfc3339(e.At), Reason: e.Reason})
|
|
}
|
|
x.Mail = append(x.Mail, em)
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// handleAccountExport answers the export as a download. GET, so a plain
|
|
// link works and no CSRF token is needed. The user.export audit row is
|
|
// written after the export is built, so the file shows the state before it.
|
|
func (s *Server) handleAccountExport(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
|
|
a := s.auth
|
|
now := time.Now().UTC()
|
|
x, err := a.buildAccountExport(u, now)
|
|
if err != nil {
|
|
log.Printf("[users] export for user #%d: %v", u.ID, err)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
a.audit(idPtr(u.ID), "user.export", idPtr(u.ID), nil)
|
|
w.Header().Set("Content-Disposition", `attachment; filename="corescope-account-`+now.Format("2006-01-02")+`.json"`)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
writeJSON(w, x)
|
|
}
|