- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回 - agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编 - 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值 - 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知 - API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// API Token 给无法保存会话 cookie 的调用方(脚本、HomeAssistant、手机快捷指令)用。
|
||||
//
|
||||
// 明文只在创建响应里返回一次,库里只存 SHA-256:令牌由 crypto/rand 生成、熵足够高,
|
||||
// 不像人类密码那样需要 bcrypt 这类慢哈希(慢哈希在这里只会拖慢每个 API 请求)。
|
||||
const (
|
||||
TokenScopeRead = "read"
|
||||
TokenScopeWrite = "write"
|
||||
|
||||
// lastUsedWriteInterval 限制回写频率:单连接 SQLite 上每个请求都写一次不划算,
|
||||
// 同一分钟内重复使用不再写。
|
||||
lastUsedWriteInterval = time.Minute
|
||||
|
||||
maxTokenNameLength = 64
|
||||
)
|
||||
|
||||
type APIToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreatedAPIToken 只在创建响应里出现:Token 是明文,之后无法再取回。
|
||||
type CreatedAPIToken struct {
|
||||
APIToken
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAPITokens(ctx context.Context) ([]APIToken, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tokens := make([]APIToken, 0, 8)
|
||||
for rows.Next() {
|
||||
var token APIToken
|
||||
if err := rows.Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAPIToken 新建令牌并返回一次明文;expiresAt 为零值表示永不过期。
|
||||
func (s *Service) CreateAPIToken(ctx context.Context, name, scope string, expiresAt time.Time) (CreatedAPIToken, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return CreatedAPIToken{}, fmt.Errorf("请填写令牌名称")
|
||||
}
|
||||
if len([]rune(name)) > maxTokenNameLength {
|
||||
return CreatedAPIToken{}, fmt.Errorf("令牌名称最长 %d 个字", maxTokenNameLength)
|
||||
}
|
||||
if scope != TokenScopeRead && scope != TokenScopeWrite {
|
||||
return CreatedAPIToken{}, fmt.Errorf("无效的权限范围")
|
||||
}
|
||||
|
||||
plain, err := randomToken(32)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
expires := ""
|
||||
if !expiresAt.IsZero() {
|
||||
expires = expiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO api_tokens(name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
name, hashToken(plain), scope, expires)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
token, err := s.apiTokenByID(ctx, id)
|
||||
if err != nil {
|
||||
return CreatedAPIToken{}, err
|
||||
}
|
||||
return CreatedAPIToken{APIToken: token, Token: plain}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAPIToken(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("令牌不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAPIToken 校验明文令牌并返回权限范围;校验通过后限频回写最后使用时间。
|
||||
func (s *Service) ValidateAPIToken(ctx context.Context, plain string) (string, error) {
|
||||
plain = strings.TrimSpace(plain)
|
||||
if plain == "" {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
scope string
|
||||
expiresAt string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, scope, COALESCE(expires_at, '') FROM api_tokens WHERE token_hash = ?`, hashToken(plain)).
|
||||
Scan(&id, &scope, &expiresAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if expiresAt != "" {
|
||||
expires, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(expires) {
|
||||
return "", ErrUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-lastUsedWriteInterval).Format(time.RFC3339)
|
||||
// 回写失败不影响本次调用:last_used_at 只是审计信息
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE api_tokens SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`,
|
||||
now.Format(time.RFC3339), id, cutoff)
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (s *Service) apiTokenByID(ctx context.Context, id int64) (APIToken, error) {
|
||||
var token APIToken
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, scope, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), created_at
|
||||
FROM api_tokens WHERE id = ?`, id).
|
||||
Scan(&token.ID, &token.Name, &token.Scope, &token.ExpiresAt, &token.LastUsedAt, &token.CreatedAt)
|
||||
return token, err
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return New(conn)
|
||||
}
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.CreateAPIToken(ctx, "homeassistant", TokenScopeRead, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Token == "" || created.Scope != TokenScopeRead || created.ID == 0 {
|
||||
t.Fatalf("unexpected created token: %+v", created)
|
||||
}
|
||||
|
||||
// 库里只存 SHA-256:明文不落盘
|
||||
var stored string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT token_hash FROM api_tokens WHERE id = ?`, created.ID).Scan(&stored); err != nil {
|
||||
t.Fatalf("read hash: %v", err)
|
||||
}
|
||||
if stored == created.Token || len(stored) != 64 {
|
||||
t.Fatalf("令牌应以 SHA-256 存储,实际 %q", stored)
|
||||
}
|
||||
|
||||
scope, err := svc.ValidateAPIToken(ctx, created.Token)
|
||||
if err != nil || scope != TokenScopeRead {
|
||||
t.Fatalf("validate: scope=%q err=%v", scope, err)
|
||||
}
|
||||
|
||||
var lastUsed string
|
||||
if err := svc.db.QueryRowContext(ctx, `SELECT COALESCE(last_used_at, '') FROM api_tokens WHERE id = ?`, created.ID).Scan(&lastUsed); err != nil {
|
||||
t.Fatalf("read last_used_at: %v", err)
|
||||
}
|
||||
if lastUsed == "" {
|
||||
t.Fatal("校验通过后应回写 last_used_at")
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateAPIToken(ctx, "not-a-real-token"); err != ErrUnauthorized {
|
||||
t.Fatalf("未知令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, created.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("删除后令牌应失效,实际 %v", err)
|
||||
}
|
||||
if err := svc.DeleteAPIToken(ctx, created.ID); err == nil {
|
||||
t.Fatal("重复删除应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenExpiry(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
expired, err := svc.CreateAPIToken(ctx, "old-script", TokenScopeWrite, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, expired.Token); err != ErrUnauthorized {
|
||||
t.Fatalf("过期令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenRejectsBadInput(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, " ", TokenScopeRead, time.Time{}); err == nil {
|
||||
t.Fatal("空名称应报错")
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "bad-scope", "admin", time.Time{}); err == nil {
|
||||
t.Fatal("未知权限范围应报错")
|
||||
}
|
||||
if _, err := svc.ValidateAPIToken(ctx, " "); err != ErrUnauthorized {
|
||||
t.Fatalf("空令牌应拒绝,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAPITokensReturnsNewestFirst(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := svc.CreateAPIToken(ctx, "first", TokenScopeRead, time.Time{}); err != nil {
|
||||
t.Fatalf("create first: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateAPIToken(ctx, "second", TokenScopeWrite, time.Time{}); err != nil {
|
||||
t.Fatalf("create second: %v", err)
|
||||
}
|
||||
tokens, err := svc.ListAPITokens(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tokens) != 2 || tokens[0].Name != "second" {
|
||||
t.Fatalf("应按 id 倒序返回:%+v", tokens)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user