348 lines
8.9 KiB
Go
348 lines
8.9 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
// Package tokens stores personal access tokens for programmatic writes
|
||
|
|
// to the public API. Tokens live in tokens.toml next to users.toml; the
|
||
|
|
// raw token is shown exactly once, at creation time, and only its
|
||
|
|
// SHA-256 digest is stored.
|
||
|
|
package tokens
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"os"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TokenPrefix marks volumen API tokens.
|
||
|
|
const TokenPrefix = "vol_"
|
||
|
|
|
||
|
|
// ValidScopes are the recognised API token scopes. There is no read
|
||
|
|
// scope: every read endpoint is public, so a token can only widen
|
||
|
|
// access to the write and delete operations.
|
||
|
|
var ValidScopes = []string{"write", "delete"}
|
||
|
|
|
||
|
|
// ErrNoValidScope is returned when a token was requested with an
|
||
|
|
// explicit scope list that names no recognised scope. Treating it as
|
||
|
|
// "unrestricted" would hand out more access than the caller asked for.
|
||
|
|
var ErrNoValidScope = errors.New("no valid scope in the requested list")
|
||
|
|
|
||
|
|
// Token is one stored access token (digest only, never the raw value).
|
||
|
|
type Token struct {
|
||
|
|
Name string
|
||
|
|
TokenHash string
|
||
|
|
Created string
|
||
|
|
LastUsed string
|
||
|
|
Scopes []string // nil means unrestricted
|
||
|
|
}
|
||
|
|
|
||
|
|
// HasScope reports whether the token grants scope.
|
||
|
|
func (t *Token) HasScope(scope string) bool {
|
||
|
|
if t.Scopes == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return slices.Contains(t.Scopes, scope)
|
||
|
|
}
|
||
|
|
|
||
|
|
// HashToken returns the SHA-256 hex digest of a raw token.
|
||
|
|
func HashToken(raw string) string {
|
||
|
|
sum := sha256.Sum256([]byte(raw))
|
||
|
|
return hex.EncodeToString(sum[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
// GenerateToken mints a new raw token with the volumen prefix.
|
||
|
|
// crypto/rand.Text returns 128 bits of randomness in a URL-safe alphabet,
|
||
|
|
// and panics on a system failure rather than returning a weak value.
|
||
|
|
func GenerateToken() string {
|
||
|
|
return TokenPrefix + rand.Text()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store is the file-backed store of API access tokens, with an mtime
|
||
|
|
// snapshot cache so that authentication does not re-read the file on
|
||
|
|
// every request.
|
||
|
|
type Store struct {
|
||
|
|
path string
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
cached []Token
|
||
|
|
snapshot fileSnapshot
|
||
|
|
haveCache bool
|
||
|
|
|
||
|
|
lock sync.Mutex
|
||
|
|
}
|
||
|
|
|
||
|
|
type fileSnapshot struct {
|
||
|
|
present bool
|
||
|
|
mtime int64
|
||
|
|
size int64
|
||
|
|
}
|
||
|
|
|
||
|
|
// New opens the token store at path.
|
||
|
|
func New(path string) *Store {
|
||
|
|
return &Store{path: path}
|
||
|
|
}
|
||
|
|
|
||
|
|
// All returns every stored token record.
|
||
|
|
func (s *Store) All() []Token {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
tokens, err := s.loadLocked()
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("tokens: cannot read the token file", "path", s.path, "error", err)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return slices.Clone(tokens)
|
||
|
|
}
|
||
|
|
|
||
|
|
// loadLocked returns the cached records, rebuilding them when the file
|
||
|
|
// changed. A missing file is not an error; an unreadable or unparsable
|
||
|
|
// one is. The caller must hold s.mu.
|
||
|
|
func (s *Store) loadLocked() ([]Token, error) {
|
||
|
|
snapshot := s.buildSnapshot()
|
||
|
|
if s.haveCache && snapshot == s.snapshot {
|
||
|
|
return s.cached, nil
|
||
|
|
}
|
||
|
|
tokens, err := s.readFile()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
s.snapshot = snapshot
|
||
|
|
s.cached = tokens
|
||
|
|
s.haveCache = true
|
||
|
|
return tokens, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) buildSnapshot() fileSnapshot {
|
||
|
|
info, err := os.Stat(s.path)
|
||
|
|
if err != nil {
|
||
|
|
return fileSnapshot{}
|
||
|
|
}
|
||
|
|
return fileSnapshot{present: true, mtime: info.ModTime().UnixNano(), size: info.Size()}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) readFile() ([]Token, error) {
|
||
|
|
raw, err := os.ReadFile(s.path)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, os.ErrNotExist) {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("read %s: %w", s.path, err)
|
||
|
|
}
|
||
|
|
data, err := interpres.ParseMap(raw)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("parse %s: %w", s.path, err)
|
||
|
|
}
|
||
|
|
var out []Token
|
||
|
|
for _, entry := range tomlfile.Tables(data["tokens"]) {
|
||
|
|
name := tomlfile.String(entry["name"])
|
||
|
|
hash := tomlfile.String(entry["token_hash"])
|
||
|
|
if name == "" || hash == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
token := Token{
|
||
|
|
Name: name,
|
||
|
|
TokenHash: hash,
|
||
|
|
Created: tomlfile.String(entry["created"]),
|
||
|
|
LastUsed: tomlfile.String(entry["last_used"]),
|
||
|
|
}
|
||
|
|
if scopes := tomlfile.Strings(entry["scopes"]); len(scopes) > 0 {
|
||
|
|
token.Scopes = scopes
|
||
|
|
}
|
||
|
|
out = append(out, token)
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create mints a token. It returns nil and an empty raw value when the
|
||
|
|
// name is taken or empty, when the scope list names no recognised
|
||
|
|
// scope, or when the file cannot be written. An empty scope list creates
|
||
|
|
// an unrestricted token, which only a caller that asks for one gets.
|
||
|
|
func (s *Store) Create(name string, scopes []string) (*Token, string, error) {
|
||
|
|
name = strings.TrimSpace(name)
|
||
|
|
if name == "" {
|
||
|
|
return nil, "", errors.New("token name must not be empty")
|
||
|
|
}
|
||
|
|
if len(scopes) > 0 {
|
||
|
|
valid := make([]string, 0, len(scopes))
|
||
|
|
for _, scope := range scopes {
|
||
|
|
if slices.Contains(ValidScopes, scope) && !slices.Contains(valid, scope) {
|
||
|
|
valid = append(valid, scope)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(valid) == 0 {
|
||
|
|
return nil, "", ErrNoValidScope
|
||
|
|
}
|
||
|
|
scopes = valid
|
||
|
|
}
|
||
|
|
s.lock.Lock()
|
||
|
|
defer s.lock.Unlock()
|
||
|
|
existing, err := s.reload()
|
||
|
|
if err != nil {
|
||
|
|
return nil, "", err
|
||
|
|
}
|
||
|
|
for _, token := range existing {
|
||
|
|
if token.Name == name {
|
||
|
|
return nil, "", fmt.Errorf("a token named %q already exists", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
raw := GenerateToken()
|
||
|
|
record := Token{
|
||
|
|
Name: name,
|
||
|
|
TokenHash: HashToken(raw),
|
||
|
|
Created: nowISO(),
|
||
|
|
Scopes: scopes,
|
||
|
|
}
|
||
|
|
if err := s.persist(append(existing, record)); err != nil {
|
||
|
|
return nil, "", err
|
||
|
|
}
|
||
|
|
return &record, raw, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Authenticate returns the matching record for a presented raw token.
|
||
|
|
func (s *Store) Authenticate(raw string) *Token {
|
||
|
|
if !strings.HasPrefix(raw, TokenPrefix) {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
digest := HashToken(raw)
|
||
|
|
all := s.All()
|
||
|
|
for i := range all {
|
||
|
|
if hmac.Equal([]byte(all[i].TokenHash), []byte(digest)) {
|
||
|
|
return &all[i]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Touch refreshes last_used, at most once per UTC day per token.
|
||
|
|
func (s *Store) Touch(name string) {
|
||
|
|
today := nowISO()[:10]
|
||
|
|
// Fast path under the cache lock: when the cached records already
|
||
|
|
// carry today for this token, no write and no reload are needed, so
|
||
|
|
// authentication does not serialise on the file.
|
||
|
|
s.mu.Lock()
|
||
|
|
if cached, err := s.loadLocked(); err == nil {
|
||
|
|
for i := range cached {
|
||
|
|
if cached[i].Name == name && strings.HasPrefix(cached[i].LastUsed, today) {
|
||
|
|
s.mu.Unlock()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
s.mu.Unlock()
|
||
|
|
|
||
|
|
s.lock.Lock()
|
||
|
|
defer s.lock.Unlock()
|
||
|
|
tokens, err := s.reload()
|
||
|
|
if err != nil {
|
||
|
|
slog.Warn("tokens: cannot refresh last_used", "path", s.path, "error", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
changed := false
|
||
|
|
for i := range tokens {
|
||
|
|
if tokens[i].Name == name && !strings.HasPrefix(tokens[i].LastUsed, today) {
|
||
|
|
tokens[i].LastUsed = nowISO()
|
||
|
|
changed = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if changed {
|
||
|
|
if err := s.persist(tokens); err != nil {
|
||
|
|
slog.Warn("tokens: cannot persist last_used", "path", s.path, "error", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Revoke removes a token by name; false when nothing was removed or the
|
||
|
|
// file cannot be written.
|
||
|
|
func (s *Store) Revoke(name string) bool {
|
||
|
|
s.lock.Lock()
|
||
|
|
defer s.lock.Unlock()
|
||
|
|
tokens, err := s.reload()
|
||
|
|
if err != nil {
|
||
|
|
slog.Error("tokens: refusing to revoke, the token file is unreadable",
|
||
|
|
"path", s.path, "error", err)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
remaining := make([]Token, 0, len(tokens))
|
||
|
|
for _, token := range tokens {
|
||
|
|
if token.Name != name {
|
||
|
|
remaining = append(remaining, token)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(remaining) == len(tokens) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return s.persist(remaining) == nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// reload re-reads the file, refusing to carry on when it cannot be
|
||
|
|
// parsed: writing back a list derived from an unreadable file would
|
||
|
|
// destroy the tokens it contains. The caller must hold s.lock.
|
||
|
|
func (s *Store) reload() ([]Token, error) {
|
||
|
|
s.Invalidate()
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
return s.loadLocked()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Health reports why the token file cannot be read, or nil when it is
|
||
|
|
// fine or absent.
|
||
|
|
func (s *Store) Health() error {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
_, err := s.loadLocked()
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Invalidate drops the cached records so the next read re-reads the
|
||
|
|
// file.
|
||
|
|
func (s *Store) Invalidate() {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.cached = nil
|
||
|
|
s.snapshot = fileSnapshot{}
|
||
|
|
s.haveCache = false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Store) persist(tokens []Token) error {
|
||
|
|
entries := make([]map[string]any, 0, len(tokens))
|
||
|
|
for _, token := range tokens {
|
||
|
|
entry := map[string]any{
|
||
|
|
"name": token.Name,
|
||
|
|
"token_hash": token.TokenHash,
|
||
|
|
"created": token.Created,
|
||
|
|
}
|
||
|
|
if token.LastUsed != "" {
|
||
|
|
entry["last_used"] = token.LastUsed
|
||
|
|
}
|
||
|
|
if len(token.Scopes) > 0 {
|
||
|
|
scopes := make([]string, len(token.Scopes))
|
||
|
|
for i, scope := range token.Scopes {
|
||
|
|
scopes[i] = scope
|
||
|
|
}
|
||
|
|
entry["scopes"] = scopes
|
||
|
|
}
|
||
|
|
entries = append(entries, entry)
|
||
|
|
}
|
||
|
|
if err := tomlfile.Write(s.path, "tokens", entries); err != nil {
|
||
|
|
slog.Error("tokens: cannot persist", "path", s.path, "error", err)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
s.Invalidate()
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func nowISO() string {
|
||
|
|
return time.Now().UTC().Format("2006-01-02T15:04:05-07:00")
|
||
|
|
}
|