diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4374b5a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/YLing2024/davbox + +go 1.26.0 + +require ( + golang.org/x/crypto v0.57.0 + golang.org/x/net v0.59.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..96b8224 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= diff --git a/internal/account/account.go b/internal/account/account.go new file mode 100644 index 0000000..c260e3c --- /dev/null +++ b/internal/account/account.go @@ -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) +} diff --git a/internal/account/account_test.go b/internal/account/account_test.go new file mode 100644 index 0000000..27c985b --- /dev/null +++ b/internal/account/account_test.go @@ -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)) + } +} diff --git a/internal/auth/admin.go b/internal/auth/admin.go new file mode 100644 index 0000000..3c4aaf7 --- /dev/null +++ b/internal/auth/admin.go @@ -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) +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..a505833 --- /dev/null +++ b/internal/auth/session.go @@ -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 +} diff --git a/internal/randstr/randstr.go b/internal/randstr/randstr.go new file mode 100644 index 0000000..6384050 --- /dev/null +++ b/internal/randstr/randstr.go @@ -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 +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go new file mode 100644 index 0000000..018458a --- /dev/null +++ b/internal/usage/usage.go @@ -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 +}