Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- 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>
198 lines
5.1 KiB
Go
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[:])
|
|
}
|