Files
DockMV/internal/store/tokens.go
T
kawaandClaude Sonnet 5 8d20d9be84
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Feature 1: Local accounts, replacing shared token
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 15:13:54 +02:00

198 lines
5.1 KiB
Go

package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// ErrTokenNotFound is returned for an unknown, revoked, or not-owned token.
var ErrTokenNotFound = errors.New("token not found")
// APIToken is a personal access token, scoped to the account that created it.
// The plaintext is only ever returned once, by Create.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list
Hash string `json:"hash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// Tokens is a JSON-backed collection of personal API tokens.
type Tokens struct {
path string
mu sync.RWMutex
// items holds every token by id.
items map[string]APIToken
// byHash maps a token's sha256 hex digest to its id, for authentication
// lookups without ever storing the plaintext.
byHash map[string]string
}
// NewTokens loads (or creates) the tokens file at path.
func NewTokens(path string) (*Tokens, error) {
t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return t, nil
}
if err != nil {
return nil, fmt.Errorf("read tokens: %w", err)
}
var list []APIToken
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse tokens file %s: %w", path, err)
}
for _, tok := range list {
t.items[tok.ID] = tok
t.byHash[tok.Hash] = tok.ID
}
return t, nil
}
// List returns every token owned by userID, hash stripped, newest first.
func (t *Tokens) List(userID string) []APIToken {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
if tok.UserID == userID {
out = append(out, redactToken(tok))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// Create mints a new token for userID and returns it (hash stripped) plus the
// plaintext secret, which is never stored and never retrievable again.
func (t *Tokens) Create(userID, name string) (APIToken, string, error) {
plain, err := newTokenSecret()
if err != nil {
return APIToken{}, "", fmt.Errorf("generate token: %w", err)
}
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
tok := APIToken{
ID: newID(),
UserID: userID,
Name: name,
Hint: plain[len(plain)-4:],
Hash: hash,
CreatedAt: time.Now(),
}
t.items[tok.ID] = tok
t.byHash[hash] = tok.ID
if err := t.flush(); err != nil {
return APIToken{}, "", err
}
return redactToken(tok), plain, nil
}
// Authenticate looks up the token behind a plaintext secret and records it as
// used. It does not check ownership: any valid token authenticates as its
// owner, which the caller then treats as the request's identity.
func (t *Tokens) Authenticate(plain string) (APIToken, error) {
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
id, ok := t.byHash[hash]
if !ok {
return APIToken{}, ErrTokenNotFound
}
tok := t.items[id]
now := time.Now()
tok.LastUsedAt = &now
t.items[id] = tok
if err := t.flush(); err != nil {
return APIToken{}, err
}
return redactToken(tok), nil
}
// Revoke deletes a token, refusing if it is not owned by userID.
func (t *Tokens) Revoke(userID, id string) error {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.items[id]
if !ok || tok.UserID != userID {
return ErrTokenNotFound
}
delete(t.items, id)
delete(t.byHash, tok.Hash)
return t.flush()
}
// RevokeAllForUser deletes every token owned by userID, e.g. when the account
// itself is deleted.
func (t *Tokens) RevokeAllForUser(userID string) error {
t.mu.Lock()
defer t.mu.Unlock()
for id, tok := range t.items {
if tok.UserID == userID {
delete(t.items, id)
delete(t.byHash, tok.Hash)
}
}
return t.flush()
}
// flush writes the file. The caller must hold the write lock.
func (t *Tokens) flush() error {
list := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
list = append(list, tok)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := t.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write tokens: %w", err)
}
if err := os.Rename(tmp, t.path); err != nil {
return fmt.Errorf("replace tokens file: %w", err)
}
return nil
}
func redactToken(tok APIToken) APIToken {
tok.Hash = ""
return tok
}
// newTokenSecret generates a token in the form dmv_<48 hex characters>.
func newTokenSecret() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "dmv_" + hex.EncodeToString(b), nil
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}