Files
havline-native/app-src/internal/auth/token.go
T

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[:])
}