feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+225
View File
@@ -0,0 +1,225 @@
package auth
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/havline/havline/internal/validate"
)
const sessionTTL = 7 * 24 * time.Hour
var ErrUnauthorized = errors.New("unauthorized")
var ErrNotInitialized = errors.New("not initialized")
type Service struct {
db *sql.DB
}
func New(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
return count > 0, err
}
const defaultAdminUsername = "admin"
func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
initialized, err := s.IsInitialized(ctx)
if err != nil {
return err
}
if initialized {
return nil
}
password := strings.TrimSpace(os.Getenv("HAVLINE_INITIAL_ADMIN_PASSWORD"))
generated := false
if password == "" {
var err error
password, err = generatePassword(16)
if err != nil {
return err
}
generated = true
}
if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
return err
}
if generated {
log.Info(
"首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
"module", "SYSTEM",
"username", defaultAdminUsername,
"password", password,
)
} else {
log.Info(
"首次启动已使用 HAVLINE_INITIAL_ADMIN_PASSWORD 创建管理员账户,请在登录后尽快修改密码",
"module", "SYSTEM",
"username", defaultAdminUsername,
"password_source", "environment",
)
}
return nil
}
func (s *Service) Setup(ctx context.Context, username, password string) error {
if err := validate.Username(username); err != nil {
return err
}
if err := validate.Password(password); err != nil {
return err
}
initialized, err := s.IsInitialized(ctx)
if err != nil {
return err
}
if initialized {
return fmt.Errorf("管理员已初始化")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
return err
}
func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
var adminID int
var hash string
err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrUnauthorized
}
return "", err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return "", ErrUnauthorized
}
return s.createSession(ctx, adminID)
}
func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
if sessionID == "" {
return 0, ErrUnauthorized
}
var adminID int
err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrUnauthorized
}
return 0, err
}
return adminID, nil
}
func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword, keepSessionID string) error {
if err := validate.Password(newPassword); err != nil {
return err
}
var hash string
err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
if err != nil {
return err
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
return fmt.Errorf("当前密码不正确")
}
newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID); err != nil {
return err
}
// 改密后失效该账号的其他会话(保留当前这次,否则用户会被自己踢下线)
_, err = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE admin_id = ? AND id <> ?`, adminID, keepSessionID)
return err
}
func (s *Service) Logout(ctx context.Context, sessionID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
return err
}
func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return ErrUnauthorized
}
var expiresAt string
err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrUnauthorized
}
return err
}
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil {
return ErrUnauthorized
}
if time.Now().After(expires) {
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
return ErrUnauthorized
}
return nil
}
func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
id, err := randomToken(32)
if err != nil {
return "", err
}
expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
if err != nil {
return "", err
}
return id, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func generatePassword(length int) (string, error) {
const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
if length < 8 {
length = 8
}
out := make([]byte, length)
randBytes := make([]byte, length)
if _, err := rand.Read(randBytes); err != nil {
return "", err
}
for i := range out {
out[i] = letters[int(randBytes[i])%len(letters)]
}
return string(out), nil
}
+64
View File
@@ -0,0 +1,64 @@
package auth
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/havline/havline/internal/db"
)
func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := New(conn)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("first bootstrap: %v", err)
}
initialized, err := svc.IsInitialized(context.Background())
if err != nil || !initialized {
t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
}
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("second bootstrap: %v", err)
}
if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
t.Fatalf("expected unauthorized for wrong password, got %v", err)
}
}
func TestEnsureDefaultAdminUsesEnvPassword(t *testing.T) {
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
defer conn.Close()
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Setenv("HAVLINE_INITIAL_ADMIN_PASSWORD", "env-password-123")
svc := New(conn)
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
t.Fatalf("bootstrap with env password: %v", err)
}
if _, err := svc.Login(context.Background(), "admin", "env-password-123"); err != nil {
t.Fatalf("expected env password login to succeed, got %v", err)
}
}
+166
View File
@@ -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[:])
}
+118
View File
@@ -0,0 +1,118 @@
package auth
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/havline/havline/internal/db"
)
func newTestService(t *testing.T) *Service {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "havline.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)
}
}