feat(core): Go 模块与账号、认证、用量基础包
- account: accounts.json 原子读写、常量时间口令校验、增删改查与用户名校验 - auth: HMAC 会话签名 + 管理员 bcrypt 凭据初始化 - randstr: 口令生成;usage: 目录用量统计 - 单测覆盖口令对错、账号禁用、只读、路径逃逸、持久化与 0600 权限
This commit is contained in:
1 parent
0c5d160bd2
commit
78673558c8
8 files changed
+613
No files matched your search
@@ -0,0 +1,256 @@
|
||||
// Package account 定义账号模型,负责 accounts.json 的读写与口令校验。
|
||||
package account
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/YLing2024/davbox/internal/randstr"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound 表示账号不存在。
|
||||
ErrNotFound = errors.New("账号不存在")
|
||||
// ErrDuplicate 表示账号名已被占用。
|
||||
ErrDuplicate = errors.New("账号已存在")
|
||||
// ErrInvalidName 表示账号名不合法或属于保留字。
|
||||
ErrInvalidName = errors.New("账号名不合法")
|
||||
)
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// 这些前缀与路由、静态资源冲突,不允许作为账号名。
|
||||
var reserved = map[string]bool{
|
||||
"admin": true,
|
||||
"api": true,
|
||||
"assets": true,
|
||||
}
|
||||
|
||||
// Account 是一条账号记录。口令明文保存,见 docs/SPEC.md §2 的说明。
|
||||
type Account struct {
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
Root string `json:"root"`
|
||||
Readonly bool `json:"readonly"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Patch 描述一次局部更新,nil 字段表示不改动。
|
||||
type Patch struct {
|
||||
Readonly *bool
|
||||
Disabled *bool
|
||||
Note *string
|
||||
}
|
||||
|
||||
// Store 是内存中的账号表,写入时以临时文件加 Rename 原子替换。
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
dataDir string
|
||||
accounts []*Account
|
||||
}
|
||||
|
||||
// Open 读取(必要时创建)dataDir 下的 accounts.json。
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Join(dataDir, "data"), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{
|
||||
path: filepath.Join(dataDir, "accounts.json"),
|
||||
dataDir: dataDir,
|
||||
accounts: []*Account{},
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &s.accounts); err != nil {
|
||||
return nil, fmt.Errorf("解析 accounts.json: %w", err)
|
||||
}
|
||||
}
|
||||
for _, a := range s.accounts {
|
||||
if a.Root == "" {
|
||||
a.Root = s.defaultRoot(a.User)
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) defaultRoot(user string) string {
|
||||
return filepath.Join(s.dataDir, "data", user)
|
||||
}
|
||||
|
||||
// DefaultRoot 返回账号未显式配置 root 时的默认目录。
|
||||
func (s *Store) DefaultRoot(user string) string {
|
||||
return s.defaultRoot(user)
|
||||
}
|
||||
|
||||
func (s *Store) findLocked(user string) (*Account, int) {
|
||||
for i, a := range s.accounts {
|
||||
if a.User == user {
|
||||
return a, i
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
// List 返回账号表的副本,按用户名排序。
|
||||
func (s *Store) List() []Account {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Account, 0, len(s.accounts))
|
||||
for _, a := range s.accounts {
|
||||
out = append(out, *a)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get 返回单个账号的副本。
|
||||
func (s *Store) Get(user string) (Account, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a, _ := s.findLocked(user)
|
||||
if a == nil {
|
||||
return Account{}, false
|
||||
}
|
||||
return *a, true
|
||||
}
|
||||
|
||||
// Authenticate 用常量时间比较校验口令;停用账号一律失败。
|
||||
func (s *Store) Authenticate(user, pass string) (Account, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a, _ := s.findLocked(user)
|
||||
if a == nil || a.Disabled {
|
||||
return Account{}, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(pass), []byte(a.Pass)) != 1 {
|
||||
return Account{}, false
|
||||
}
|
||||
return *a, true
|
||||
}
|
||||
|
||||
// Create 新建账号:校验名称、生成 20 位口令、创建根目录。
|
||||
func (s *Store) Create(user string, readonly bool, note string) (Account, string, error) {
|
||||
if !nameRe.MatchString(user) || reserved[user] {
|
||||
return Account{}, "", ErrInvalidName
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, i := s.findLocked(user); i >= 0 {
|
||||
return Account{}, "", ErrDuplicate
|
||||
}
|
||||
pass, err := randstr.Generate(20)
|
||||
if err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
root := s.defaultRoot(user)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
a := &Account{User: user, Pass: pass, Root: root, Readonly: readonly, Note: note}
|
||||
s.accounts = append(s.accounts, a)
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return Account{}, "", err
|
||||
}
|
||||
return *a, pass, nil
|
||||
}
|
||||
|
||||
// Rotate 重新生成口令并落盘。
|
||||
func (s *Store) Rotate(user string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a, i := s.findLocked(user)
|
||||
if i < 0 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
pass, err := randstr.Generate(20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.Pass = pass
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pass, nil
|
||||
}
|
||||
|
||||
// Update 局部更新账号。
|
||||
func (s *Store) Update(user string, p Patch) (Account, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
a, i := s.findLocked(user)
|
||||
if i < 0 {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if p.Readonly != nil {
|
||||
a.Readonly = *p.Readonly
|
||||
}
|
||||
if p.Disabled != nil {
|
||||
a.Disabled = *p.Disabled
|
||||
}
|
||||
if p.Note != nil {
|
||||
a.Note = *p.Note
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return *a, nil
|
||||
}
|
||||
|
||||
// Delete 从账号表移除条目,磁盘目录保持不动。
|
||||
func (s *Store) Delete(user string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, i := s.findLocked(user)
|
||||
if i < 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
s.accounts = append(s.accounts[:i], s.accounts[i+1:]...)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
if s.accounts == nil {
|
||||
s.accounts = []*Account{}
|
||||
}
|
||||
buf, err := json.MarshalIndent(s.accounts, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".accounts-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if _, err := tmp.Write(buf); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, s.path)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthenticateCorrectAndWrong(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Create("app1", false, "测试"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, ok := s.Get("app1")
|
||||
if !ok {
|
||||
t.Fatal("账号应存在")
|
||||
}
|
||||
|
||||
if _, ok := s.Authenticate("app1", a.Pass); !ok {
|
||||
t.Fatal("正确口令应当通过")
|
||||
}
|
||||
if _, ok := s.Authenticate("app1", "wrong-password"); ok {
|
||||
t.Fatal("错误口令不应通过")
|
||||
}
|
||||
if _, ok := s.Authenticate("nobody", a.Pass); ok {
|
||||
t.Fatal("不存在的账号不应通过")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledAccountRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, _ := Open(dir)
|
||||
_, _, _ = s.Create("app1", false, "")
|
||||
a, _ := s.Get("app1")
|
||||
|
||||
disabled := true
|
||||
if _, err := s.Update("app1", Patch{Disabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.Authenticate("app1", a.Pass); ok {
|
||||
t.Fatal("停用账号不应通过认证")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRotateDelete(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, _ := Open(dir)
|
||||
|
||||
if _, _, err := s.Create("app1", false, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Create("app1", false, ""); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("重名应返回 ErrDuplicate,实际 %v", err)
|
||||
}
|
||||
if _, _, err := s.Create("Bad_Name", false, ""); !errors.Is(err, ErrInvalidName) {
|
||||
t.Fatalf("非法名应返回 ErrInvalidName,实际 %v", err)
|
||||
}
|
||||
if _, _, err := s.Create("admin", false, ""); !errors.Is(err, ErrInvalidName) {
|
||||
t.Fatalf("保留名应返回 ErrInvalidName,实际 %v", err)
|
||||
}
|
||||
|
||||
old, _ := s.Get("app1")
|
||||
newPass, err := s.Rotate("app1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if newPass == "" || newPass == old.Pass {
|
||||
t.Fatal("换口令应生成新口令")
|
||||
}
|
||||
if _, ok := s.Authenticate("app1", old.Pass); ok {
|
||||
t.Fatal("旧口令应失效")
|
||||
}
|
||||
if _, ok := s.Authenticate("app1", newPass); !ok {
|
||||
t.Fatal("新口令应可用")
|
||||
}
|
||||
|
||||
if err := s.Delete("app1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := s.Get("app1"); ok {
|
||||
t.Fatal("删除后不应还能查到")
|
||||
}
|
||||
// 目录保留
|
||||
if _, err := os.Stat(filepath.Join(dir, "data", "app1")); err != nil {
|
||||
t.Fatalf("删除账号后目录应保留: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, _ := Open(dir)
|
||||
_, pass, _ := s.Create("app1", true, "备注")
|
||||
|
||||
// 重新打开,确认落盘并能读回。
|
||||
reopened, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, ok := reopened.Get("app1")
|
||||
if !ok {
|
||||
t.Fatal("重新加载后账号应存在")
|
||||
}
|
||||
if a.Pass != pass || !a.Readonly || a.Note != "备注" {
|
||||
t.Fatalf("字段不一致: %+v", a)
|
||||
}
|
||||
|
||||
// 落盘文件权限应为 0600。
|
||||
info, err := os.Stat(filepath.Join(dir, "accounts.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("accounts.json 权限应为 0600,实际 %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCreatesEmptyArray(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Open(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "accounts.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != "[]\n" {
|
||||
t.Fatalf("首次启动应写入空数组,实际 %q", string(raw))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/YLing2024/davbox/internal/randstr"
|
||||
)
|
||||
|
||||
type adminFile struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// LoadOrCreateSecret 读取或首次生成 32 字节 cookie 签名密钥。
|
||||
func LoadOrCreateSecret(dataDir string) ([]byte, error) {
|
||||
path := filepath.Join(dataDir, "secret.key")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err == nil && len(raw) >= 32 {
|
||||
return raw, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// LoadOrCreateAdmin 读取或首次创建管理员口令。首次创建时生成 24 位口令,
|
||||
// 写入 bcrypt 哈希,明文只落到 admin-password.txt 并在 stdout 打印一次。
|
||||
func LoadOrCreateAdmin(dataDir string) ([]byte, error) {
|
||||
path := filepath.Join(dataDir, "admin.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var af adminFile
|
||||
if err := json.Unmarshal(raw, &af); err != nil {
|
||||
return nil, fmt.Errorf("解析 admin.json: %w", err)
|
||||
}
|
||||
if af.Hash == "" {
|
||||
return nil, errors.New("admin.json 缺少 hash 字段")
|
||||
}
|
||||
return []byte(af.Hash), nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pass, err := randstr.Generate(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf, err := json.MarshalIndent(adminFile{Hash: string(hash)}, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = append(buf, '\n')
|
||||
if err := writeFile0600(path, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeFile0600(filepath.Join(dataDir, "admin-password.txt"), []byte(pass+"\n")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("管理员初始口令(仅本次打印,请自行保存):%s\n", pass)
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
func writeFile0600(path string, data []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Chmod(0o600)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Package auth 提供会话 cookie 的签名/校验,以及管理员凭据的初始化。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session 是 cookie 里承载的最小信息。
|
||||
type Session struct {
|
||||
Kind string `json:"k"`
|
||||
User string `json:"u,omitempty"`
|
||||
Exp int64 `json:"e"`
|
||||
}
|
||||
|
||||
// Signer 用 HMAC-SHA256 对会话载荷签名。
|
||||
type Signer struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewSigner 用 secret.key 构造签名器。
|
||||
func NewSigner(key []byte) *Signer {
|
||||
return &Signer{key: key}
|
||||
}
|
||||
|
||||
// Issue 生成 kind/user 对应的 cookie 值,ttl 之后过期。
|
||||
func (s *Signer) Issue(kind, user string, ttl time.Duration) string {
|
||||
sess := Session{Kind: kind, User: user, Exp: time.Now().Add(ttl).Unix()}
|
||||
payload, _ := json.Marshal(sess)
|
||||
body := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return body + "." + s.sign(body)
|
||||
}
|
||||
|
||||
func (s *Signer) sign(body string) string {
|
||||
m := hmac.New(sha256.New, s.key)
|
||||
m.Write([]byte(body))
|
||||
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify 校验签名与有效期,返回会话内容。
|
||||
func (s *Signer) Verify(token string) (Session, bool) {
|
||||
i := strings.IndexByte(token, '.')
|
||||
if i < 0 {
|
||||
return Session{}, false
|
||||
}
|
||||
body, sig := token[:i], token[i+1:]
|
||||
if !hmac.Equal([]byte(sig), []byte(s.sign(body))) {
|
||||
return Session{}, false
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(body)
|
||||
if err != nil {
|
||||
return Session{}, false
|
||||
}
|
||||
var sess Session
|
||||
if err := json.Unmarshal(payload, &sess); err != nil {
|
||||
return Session{}, false
|
||||
}
|
||||
if sess.Exp < time.Now().Unix() {
|
||||
return Session{}, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package randstr 生成不依赖第三方库的随机字符串。
|
||||
package randstr
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// 去掉了容易混淆的 0/O/1/l/I。
|
||||
const charset = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// Generate 返回 n 个字符的随机口令。
|
||||
func Generate(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
max := big.NewInt(int64(len(charset)))
|
||||
for i := range buf {
|
||||
// 每个字符独立取一个均匀随机的下标,避免取模偏置。
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf[i] = charset[idx.Int64()]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package usage 统计账号根目录的磁盘占用。
|
||||
package usage
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Dir 遍历 root,返回常规文件的总字节数与文件数。读不到的条目直接跳过。
|
||||
func Dir(root string) (bytes int64, files int64, err error) {
|
||||
err = filepath.WalkDir(root, func(_ string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
// 权限或竞态导致的读取失败不影响整体统计。
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
return nil
|
||||
}
|
||||
bytes += info.Size()
|
||||
files++
|
||||
return nil
|
||||
})
|
||||
return bytes, files, err
|
||||
}
|
||||
Reference in new issue
Block a user