167 lines
4.8 KiB
Go
167 lines
4.8 KiB
Go
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[:])
|
||
|
|
}
|