mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
- Replaced `TokenRecord` with `coreauth.Auth` for centralized and consistent authentication data structures. - Migrated `TokenStore` interface to `coreauth.Store` for alignment with core CLIProxy authentication. - Updated related login methods, token persistence logic, and file storage handling to use the new `coreauth.Auth` model.
328 lines
7.7 KiB
Go
328 lines
7.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
)
|
|
|
|
// FileTokenStore persists token records and auth metadata using the filesystem as backing storage.
|
|
type FileTokenStore struct {
|
|
mu sync.Mutex
|
|
dirLock sync.RWMutex
|
|
baseDir string
|
|
}
|
|
|
|
// NewFileTokenStore creates a token store that saves credentials to disk through the
|
|
// TokenStorage implementation embedded in the token record.
|
|
func NewFileTokenStore() *FileTokenStore {
|
|
return &FileTokenStore{}
|
|
}
|
|
|
|
// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
|
|
func (s *FileTokenStore) SetBaseDir(dir string) {
|
|
s.dirLock.Lock()
|
|
s.baseDir = strings.TrimSpace(dir)
|
|
s.dirLock.Unlock()
|
|
}
|
|
|
|
// Save persists token storage and metadata to the resolved auth file path.
|
|
func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
|
if auth == nil {
|
|
return "", fmt.Errorf("auth filestore: auth is nil")
|
|
}
|
|
|
|
path, err := s.resolveAuthPath(auth)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if path == "" {
|
|
return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
|
|
}
|
|
|
|
if auth.Disabled {
|
|
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
|
|
}
|
|
|
|
switch {
|
|
case auth.Storage != nil:
|
|
if err = auth.Storage.SaveTokenToFile(path); err != nil {
|
|
return "", err
|
|
}
|
|
case auth.Metadata != nil:
|
|
raw, errMarshal := json.Marshal(auth.Metadata)
|
|
if errMarshal != nil {
|
|
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
|
}
|
|
if existing, errRead := os.ReadFile(path); errRead == nil {
|
|
if jsonEqual(existing, raw) {
|
|
return path, nil
|
|
}
|
|
} else if errRead != nil && !os.IsNotExist(errRead) {
|
|
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
|
return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
|
|
}
|
|
if errRename := os.Rename(tmp, path); errRename != nil {
|
|
return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
|
|
}
|
|
default:
|
|
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
|
}
|
|
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
auth.Attributes["path"] = path
|
|
|
|
if strings.TrimSpace(auth.FileName) == "" {
|
|
auth.FileName = auth.ID
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
// List enumerates all auth JSON files under the configured directory.
|
|
func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
|
|
dir := s.baseDirSnapshot()
|
|
if dir == "" {
|
|
return nil, fmt.Errorf("auth filestore: directory not configured")
|
|
}
|
|
entries := make([]*cliproxyauth.Auth, 0)
|
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
|
|
return nil
|
|
}
|
|
auth, err := s.readAuthFile(path, dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if auth != nil {
|
|
entries = append(entries, auth)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// Delete removes the auth file.
|
|
func (s *FileTokenStore) Delete(ctx context.Context, id string) error {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return fmt.Errorf("auth filestore: id is empty")
|
|
}
|
|
path, err := s.resolveDeletePath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("auth filestore: delete failed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileTokenStore) resolveDeletePath(id string) (string, error) {
|
|
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
|
|
return id, nil
|
|
}
|
|
dir := s.baseDirSnapshot()
|
|
if dir == "" {
|
|
return "", fmt.Errorf("auth filestore: directory not configured")
|
|
}
|
|
return filepath.Join(dir, id), nil
|
|
}
|
|
|
|
func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read file: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, nil
|
|
}
|
|
metadata := make(map[string]any)
|
|
if err = json.Unmarshal(data, &metadata); err != nil {
|
|
return nil, fmt.Errorf("unmarshal auth json: %w", err)
|
|
}
|
|
provider, _ := metadata["type"].(string)
|
|
if provider == "" {
|
|
provider = "unknown"
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat file: %w", err)
|
|
}
|
|
id := s.idFor(path, baseDir)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: id,
|
|
Provider: provider,
|
|
FileName: id,
|
|
Label: s.labelFor(metadata),
|
|
Status: cliproxyauth.StatusActive,
|
|
Attributes: map[string]string{"path": path},
|
|
Metadata: metadata,
|
|
CreatedAt: info.ModTime(),
|
|
UpdatedAt: info.ModTime(),
|
|
LastRefreshedAt: time.Time{},
|
|
NextRefreshAfter: time.Time{},
|
|
}
|
|
if email, ok := metadata["email"].(string); ok && email != "" {
|
|
auth.Attributes["email"] = email
|
|
}
|
|
return auth, nil
|
|
}
|
|
|
|
func (s *FileTokenStore) idFor(path, baseDir string) string {
|
|
if baseDir == "" {
|
|
return path
|
|
}
|
|
rel, err := filepath.Rel(baseDir, path)
|
|
if err != nil {
|
|
return path
|
|
}
|
|
return rel
|
|
}
|
|
|
|
func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
|
if auth == nil {
|
|
return "", fmt.Errorf("auth filestore: auth is nil")
|
|
}
|
|
if auth.Attributes != nil {
|
|
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
|
|
return p, nil
|
|
}
|
|
}
|
|
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
|
|
if filepath.IsAbs(fileName) {
|
|
return fileName, nil
|
|
}
|
|
if dir := s.baseDirSnapshot(); dir != "" {
|
|
return filepath.Join(dir, fileName), nil
|
|
}
|
|
return fileName, nil
|
|
}
|
|
if auth.ID == "" {
|
|
return "", fmt.Errorf("auth filestore: missing id")
|
|
}
|
|
if filepath.IsAbs(auth.ID) {
|
|
return auth.ID, nil
|
|
}
|
|
dir := s.baseDirSnapshot()
|
|
if dir == "" {
|
|
return "", fmt.Errorf("auth filestore: directory not configured")
|
|
}
|
|
return filepath.Join(dir, auth.ID), nil
|
|
}
|
|
|
|
func (s *FileTokenStore) labelFor(metadata map[string]any) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
if v, ok := metadata["label"].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
if v, ok := metadata["email"].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
if project, ok := metadata["project_id"].(string); ok && project != "" {
|
|
return project
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *FileTokenStore) baseDirSnapshot() string {
|
|
s.dirLock.RLock()
|
|
defer s.dirLock.RUnlock()
|
|
return s.baseDir
|
|
}
|
|
|
|
func jsonEqual(a, b []byte) bool {
|
|
var objA any
|
|
var objB any
|
|
if err := json.Unmarshal(a, &objA); err != nil {
|
|
return false
|
|
}
|
|
if err := json.Unmarshal(b, &objB); err != nil {
|
|
return false
|
|
}
|
|
return deepEqualJSON(objA, objB)
|
|
}
|
|
|
|
func deepEqualJSON(a, b any) bool {
|
|
switch valA := a.(type) {
|
|
case map[string]any:
|
|
valB, ok := b.(map[string]any)
|
|
if !ok || len(valA) != len(valB) {
|
|
return false
|
|
}
|
|
for key, subA := range valA {
|
|
subB, ok1 := valB[key]
|
|
if !ok1 || !deepEqualJSON(subA, subB) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case []any:
|
|
sliceB, ok := b.([]any)
|
|
if !ok || len(valA) != len(sliceB) {
|
|
return false
|
|
}
|
|
for i := range valA {
|
|
if !deepEqualJSON(valA[i], sliceB[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case float64:
|
|
valB, ok := b.(float64)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return valA == valB
|
|
case string:
|
|
valB, ok := b.(string)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return valA == valB
|
|
case bool:
|
|
valB, ok := b.(bool)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return valA == valB
|
|
case nil:
|
|
return b == nil
|
|
default:
|
|
return false
|
|
}
|
|
}
|