Module Go github.com/vblogio/excalicloud (imports internes mis à jour), image Docker excalicloud:local, paquet npm excalicloud-frontend, champ source des exports .excalidraw, README et THIRD_PARTY_NOTICES. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MgtkAhkuqFbMAuTgTDeFh
323 lines
8.9 KiB
Go
323 lines
8.9 KiB
Go
// Package tokens gère les jetons d'accès personnels à l'API et au serveur MCP :
|
|
// jetons créés depuis l'interface (sans expiration) et jetons délivrés par le
|
|
// petit serveur OAuth 2.1 intégré (connecteurs claude.ai, Claude Desktop…).
|
|
//
|
|
// Les jetons ne sont conservés que hachés (SHA-256) dans DATA_DIR/.tokens.json
|
|
// (0600), rattachés à l'utilisateur qui les a créés : ils donnent accès à son
|
|
// espace personnel et à ses favoris, comme sa session.
|
|
package tokens
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/vblogio/excalicloud/internal/auth"
|
|
)
|
|
|
|
const (
|
|
fileName = ".tokens.json"
|
|
|
|
apiPrefix = "exc_" // jeton personnel
|
|
accessPrefix = "exca_" // jeton d'accès OAuth
|
|
refreshPrefix = "excr_" // jeton de rafraîchissement OAuth
|
|
|
|
KindAPI = "api"
|
|
KindOAuth = "oauth"
|
|
|
|
// AccessTTL est la durée de vie d'un jeton d'accès OAuth ; le client le
|
|
// renouvelle avec son jeton de rafraîchissement (rotation à chaque usage).
|
|
AccessTTL = time.Hour
|
|
// IdleTTL : une autorisation OAuth inutilisée pendant cette durée expire.
|
|
IdleTTL = 90 * 24 * time.Hour
|
|
|
|
MaxNameLen = 80
|
|
maxAPIPerUser = 50
|
|
maxOAuthPerUser = 20
|
|
touchEvery = time.Minute // fréquence d'enregistrement de LastUsed
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("jeton introuvable")
|
|
ErrInvalid = errors.New("nom de jeton invalide")
|
|
ErrTooMany = errors.New("trop de jetons : en révoquer avant d'en créer")
|
|
)
|
|
|
|
// Token est un jeton enregistré. Les champs *Hash ne sortent jamais du paquet.
|
|
type Token struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
UserID string `json:"userId"`
|
|
User *auth.User `json:"user,omitempty"` // nil : authentification locale ou absente
|
|
Hash string `json:"hash"`
|
|
Expires time.Time `json:"expires,omitzero"` // jeton d'accès OAuth
|
|
RefreshHash string `json:"refreshHash,omitempty"` // OAuth
|
|
ClientID string `json:"clientId,omitempty"` // OAuth
|
|
Client string `json:"client,omitempty"` // hôte de redirection du client OAuth
|
|
Created time.Time `json:"created"`
|
|
LastUsed time.Time `json:"lastUsed,omitzero"`
|
|
}
|
|
|
|
// Info est la vue d'un jeton renvoyée à l'interface.
|
|
type Info struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Client string `json:"client,omitempty"`
|
|
Created time.Time `json:"created"`
|
|
LastUsed time.Time `json:"lastUsed,omitzero"`
|
|
}
|
|
|
|
func (t *Token) info() Info {
|
|
return Info{ID: t.ID, Name: t.Name, Kind: t.Kind, Client: t.Client, Created: t.Created, LastUsed: t.LastUsed}
|
|
}
|
|
|
|
type fileData struct {
|
|
Key string `json:"key"` // clé HMAC (identifiants de clients OAuth, formulaires)
|
|
Tokens []*Token `json:"tokens"`
|
|
}
|
|
|
|
// Store conserve les jetons dans dir/.tokens.json.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
file string
|
|
key []byte
|
|
tokens []*Token
|
|
now func() time.Time
|
|
}
|
|
|
|
// Open charge (ou crée) le fichier des jetons de dir.
|
|
func Open(dir string) (*Store, error) {
|
|
s := &Store{file: filepath.Join(dir, fileName), now: time.Now}
|
|
b, err := os.ReadFile(s.file)
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
case err != nil:
|
|
return nil, err
|
|
default:
|
|
var d fileData
|
|
if err := json.Unmarshal(b, &d); err != nil {
|
|
return nil, errors.New(s.file + " invalide : " + err.Error())
|
|
}
|
|
s.tokens = d.Tokens
|
|
s.key, _ = hex.DecodeString(d.Key)
|
|
}
|
|
if len(s.key) < 32 {
|
|
s.key = make([]byte, 32)
|
|
if _, err := rand.Read(s.key); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.save(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Key renvoie la clé HMAC propre au serveur.
|
|
func (s *Store) Key() []byte { return s.key }
|
|
|
|
func (s *Store) save() error {
|
|
b, err := json.MarshalIndent(fileData{Key: hex.EncodeToString(s.key), Tokens: s.tokens}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.file + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.file)
|
|
}
|
|
|
|
func random(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func hash(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func cleanName(name string) (string, error) {
|
|
name = strings.Join(strings.Fields(name), " ")
|
|
if name == "" || utf8.RuneCountInString(name) > MaxNameLen {
|
|
return "", ErrInvalid
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// purge retire les autorisations OAuth inactives et, au-delà de max jetons
|
|
// d'un type pour un utilisateur, les moins récemment utilisés.
|
|
func (s *Store) purge(userID, kind string, max int) {
|
|
now := s.now()
|
|
keep := s.tokens[:0]
|
|
var mine []*Token
|
|
for _, t := range s.tokens {
|
|
if t.Kind == KindOAuth && now.Sub(lastActivity(t)) > IdleTTL {
|
|
continue
|
|
}
|
|
keep = append(keep, t)
|
|
if t.UserID == userID && t.Kind == kind {
|
|
mine = append(mine, t)
|
|
}
|
|
}
|
|
s.tokens = keep
|
|
if len(mine) < max {
|
|
return
|
|
}
|
|
sort.Slice(mine, func(i, j int) bool { return lastActivity(mine[i]).Before(lastActivity(mine[j])) })
|
|
drop := map[*Token]bool{}
|
|
for _, t := range mine[:len(mine)-max+1] {
|
|
drop[t] = true
|
|
}
|
|
keep = s.tokens[:0]
|
|
for _, t := range s.tokens {
|
|
if !drop[t] {
|
|
keep = append(keep, t)
|
|
}
|
|
}
|
|
s.tokens = keep
|
|
}
|
|
|
|
func lastActivity(t *Token) time.Time {
|
|
if t.LastUsed.After(t.Created) {
|
|
return t.LastUsed
|
|
}
|
|
return t.Created
|
|
}
|
|
|
|
// Create crée un jeton personnel pour l'utilisateur ; le jeton en clair n'est
|
|
// renvoyé qu'ici.
|
|
func (s *Store) Create(userID string, u *auth.User, name string) (string, Info, error) {
|
|
name, err := cleanName(name)
|
|
if err != nil {
|
|
return "", Info{}, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
n := 0
|
|
for _, t := range s.tokens {
|
|
if t.UserID == userID && t.Kind == KindAPI {
|
|
n++
|
|
}
|
|
}
|
|
if n >= maxAPIPerUser {
|
|
return "", Info{}, ErrTooMany
|
|
}
|
|
secret := apiPrefix + random(32)
|
|
t := &Token{ID: random(9), Name: name, Kind: KindAPI, UserID: userID, User: u, Hash: hash(secret), Created: s.now().UTC()}
|
|
s.tokens = append(s.tokens, t)
|
|
if err := s.save(); err != nil {
|
|
s.tokens = s.tokens[:len(s.tokens)-1]
|
|
return "", Info{}, err
|
|
}
|
|
return secret, t.info(), nil
|
|
}
|
|
|
|
// Grant enregistre une autorisation OAuth et renvoie ses jetons.
|
|
func (s *Store) Grant(userID string, u *auth.User, name, clientID, client string) (access, refresh string, err error) {
|
|
if name, err = cleanName(name); err != nil {
|
|
name = "Client OAuth"
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.purge(userID, KindOAuth, maxOAuthPerUser)
|
|
access, refresh = accessPrefix+random(32), refreshPrefix+random(32)
|
|
now := s.now().UTC()
|
|
s.tokens = append(s.tokens, &Token{
|
|
ID: random(9), Name: name, Kind: KindOAuth, UserID: userID, User: u,
|
|
Hash: hash(access), Expires: now.Add(AccessTTL), RefreshHash: hash(refresh),
|
|
ClientID: clientID, Client: client, Created: now, LastUsed: now,
|
|
})
|
|
return access, refresh, s.save()
|
|
}
|
|
|
|
// Refresh renouvelle les jetons d'une autorisation OAuth (rotation : l'ancien
|
|
// jeton de rafraîchissement n'est plus valable).
|
|
func (s *Store) Refresh(refresh, clientID string) (access, newRefresh string, err error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
h := hash(refresh)
|
|
for _, t := range s.tokens {
|
|
if t.Kind != KindOAuth || t.RefreshHash != h {
|
|
continue
|
|
}
|
|
if t.ClientID != clientID || s.now().Sub(lastActivity(t)) > IdleTTL {
|
|
return "", "", ErrNotFound
|
|
}
|
|
access, newRefresh = accessPrefix+random(32), refreshPrefix+random(32)
|
|
t.Hash, t.RefreshHash = hash(access), hash(newRefresh)
|
|
t.Expires = s.now().UTC().Add(AccessTTL)
|
|
t.LastUsed = s.now().UTC()
|
|
return access, newRefresh, s.save()
|
|
}
|
|
return "", "", ErrNotFound
|
|
}
|
|
|
|
// Lookup renvoie le jeton correspondant à secret s'il est valide.
|
|
func (s *Store) Lookup(secret string) (Token, bool) {
|
|
if !strings.HasPrefix(secret, apiPrefix) && !strings.HasPrefix(secret, accessPrefix) {
|
|
return Token{}, false
|
|
}
|
|
h := hash(secret)
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
now := s.now()
|
|
for _, t := range s.tokens {
|
|
if t.Hash != h {
|
|
continue
|
|
}
|
|
if t.Kind == KindOAuth && now.After(t.Expires) {
|
|
return Token{}, false
|
|
}
|
|
if now.Sub(t.LastUsed) > touchEvery {
|
|
t.LastUsed = now.UTC()
|
|
_ = s.save()
|
|
}
|
|
return *t, true
|
|
}
|
|
return Token{}, false
|
|
}
|
|
|
|
// List renvoie les jetons de l'utilisateur, les plus récents d'abord.
|
|
func (s *Store) List(userID string) []Info {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []Info{}
|
|
for _, t := range s.tokens {
|
|
if t.UserID == userID {
|
|
out = append(out, t.info())
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
|
|
return out
|
|
}
|
|
|
|
// Revoke supprime un jeton de l'utilisateur.
|
|
func (s *Store) Revoke(userID, id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i, t := range s.tokens {
|
|
if t.ID == id && t.UserID == userID {
|
|
s.tokens = append(s.tokens[:i], s.tokens[i+1:]...)
|
|
return s.save()
|
|
}
|
|
}
|
|
return ErrNotFound
|
|
}
|