Files
ggjuku/internal/app/account_store.go
T
2026-09-30 10:18:32 +08:00

304 lines
9.4 KiB
Go

package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
const accountLifetime = 30 * 24 * time.Hour
const accountLimit = 1000
const accountSessionLimit = 16
var errAccountStore = errors.New("账号数据无法读取,原文件已保留,请检查数据目录或恢复备份")
type accountRecord struct {
OnlineOnly bool `json:"onlineOnly,omitempty"`
Sources []string `json:"sources,omitempty"`
Admin bool `json:"admin,omitempty"`
RequirePasswordChange bool `json:"requirePasswordChange,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
Salt string `json:"salt"`
PasswordHash string `json:"passwordHash"`
Iterations int `json:"iterations"`
Algorithm string `json:"algorithm"`
CreatedAt time.Time `json:"createdAt"`
}
type accountPublic struct {
OnlineOnly bool `json:"onlineOnly"`
Sources []string `json:"sources"`
Admin bool `json:"admin"`
RequirePasswordChange bool `json:"requirePasswordChange"`
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
}
func (account accountRecord) public() accountPublic {
return accountPublic{OnlineOnly: account.onlineOnly(), Sources: account.effectiveSources(), Username: account.Username, CreatedAt: account.CreatedAt, Admin: account.Admin, RequirePasswordChange: account.RequirePasswordChange}
}
func (account accountRecord) viewerID() string {
return viewerID("account:" + account.ID)
}
type accountSession struct {
Account string `json:"account"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type accountGuestImport struct {
Account string `json:"account"`
Completed bool `json:"completed"`
}
type accountState struct {
RequireLogin bool `json:"requireLogin"`
AllowRegistration bool `json:"allowRegistration"`
Version int `json:"version"`
Accounts map[string]accountRecord `json:"accounts"`
Sessions map[string]accountSession `json:"sessions"`
GuestImports map[string]accountGuestImport `json:"guestImports,omitempty"`
}
type accountAttempt struct {
count int
until time.Time
}
type accountStore struct {
mu sync.Mutex
path string
state accountState
err error
attempts map[string]accountAttempt
hashing chan struct{}
}
func validAccountHex(value string, size int) bool {
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == size && hex.EncodeToString(decoded) == value
}
func (manager *viewerManager) accountStore() *accountStore {
manager.accountsOnce.Do(func() {
store := &accountStore{path: filepath.Join(manager.directory, "accounts.json"),
state: accountState{Version: 1, AllowRegistration: true, Accounts: map[string]accountRecord{}, Sessions: map[string]accountSession{}, GuestImports: map[string]accountGuestImport{}},
attempts: map[string]accountAttempt{}, hashing: make(chan struct{}, 2)}
store.err = store.load()
manager.accounts = store
})
return manager.accounts
}
func (store *accountStore) load() error {
file, err := os.Open(store.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return errAccountStore
}
defer file.Close()
state := accountState{AllowRegistration: true}
decoder := json.NewDecoder(io.LimitReader(file, 16<<20))
if decoder.Decode(&state) != nil || decoder.Decode(new(any)) != io.EOF || state.Version != 1 || state.Accounts == nil || len(state.Accounts) > accountLimit || len(state.Sessions) > accountLimit*accountSessionLimit {
return errAccountStore
}
identifiers := make(map[string]bool, len(state.Accounts))
adminCount := 0
for name, account := range state.Accounts {
key, _, nameErr := normalizeAccountUsername(account.Username)
if nameErr != nil || key != name || !validAccountHex(account.ID, 32) || identifiers[account.ID] || !validAccountHex(account.Salt, 16) || !validAccountHex(account.PasswordHash, 32) || account.Algorithm != "pbkdf2-sha256" || account.Iterations < accountPasswordIterations || account.Iterations > 2000000 || account.CreatedAt.IsZero() {
return errAccountStore
}
sources, sourceErr := normalizeAccountSources(account.Sources)
if sourceErr != nil {
return errAccountStore
}
account.Sources = sources
state.Accounts[name] = account
if account.Admin {
adminCount++
}
identifiers[account.ID] = true
}
if adminCount > 1 {
return errAccountStore
}
for token, session := range state.Sessions {
if !validAccountHex(token, 32) || state.Accounts[session.Account].ID == "" || session.CreatedAt.IsZero() || !session.ExpiresAt.After(session.CreatedAt) {
return errAccountStore
}
}
for guest, imported := range state.GuestImports {
if !validAccountHex(guest, 32) || state.Accounts[imported.Account].ID == "" {
return errAccountStore
}
}
if state.Sessions == nil {
state.Sessions = map[string]accountSession{}
}
if state.GuestImports == nil {
state.GuestImports = map[string]accountGuestImport{}
}
store.state = state
return nil
}
func (store *accountStore) snapshotLocked() accountState {
state := accountState{Version: 1, RequireLogin: store.state.RequireLogin, AllowRegistration: store.state.AllowRegistration, Accounts: make(map[string]accountRecord, len(store.state.Accounts)), Sessions: make(map[string]accountSession, len(store.state.Sessions)), GuestImports: make(map[string]accountGuestImport, len(store.state.GuestImports))}
for key, value := range store.state.Accounts {
if value.Sources != nil {
value.Sources = append([]string{}, value.Sources...)
}
state.Accounts[key] = value
}
for key, value := range store.state.Sessions {
state.Sessions[key] = value
}
for key, value := range store.state.GuestImports {
state.GuestImports[key] = value
}
return state
}
func (store *accountStore) saveLocked(state accountState) error {
if store.err != nil {
return store.err
}
data, err := json.Marshal(state)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(store.path), ".accounts-*.json")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
if closeErr := file.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
if err = os.Rename(file.Name(), store.path); err != nil {
return err
}
store.state = state
return nil
}
func accountTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (manager *viewerManager) accountCookieName() string { return manager.cookie + "_account" }
func (store *accountStore) resolve(token string) (accountRecord, string, bool) {
if !validAccountHex(token, 32) {
return accountRecord{}, "", false
}
store.mu.Lock()
defer store.mu.Unlock()
session, found := store.state.Sessions[accountTokenHash(token)]
if !found || !session.ExpiresAt.After(time.Now()) {
return accountRecord{}, "", false
}
account, found := store.state.Accounts[session.Account]
return account, session.Account, found && store.err == nil
}
func (manager *viewerManager) requestAccount(request *http.Request) (accountRecord, string, bool, error) {
cookie, err := request.Cookie(manager.accountCookieName())
if err != nil || cookie.Value == "" {
return accountRecord{}, "", false, nil
}
store := manager.accountStore()
if store.err != nil {
return accountRecord{}, "", true, store.err
}
account, name, valid := store.resolve(cookie.Value)
if !valid {
return accountRecord{}, "", true, nil
}
return account, name, true, nil
}
func (store *accountStore) allowAttempt(name, remote string, now time.Time) bool {
ip, _, err := net.SplitHostPort(remote)
if err != nil {
ip = remote
}
store.mu.Lock()
defer store.mu.Unlock()
for key, attempt := range store.attempts {
if !attempt.until.After(now) {
delete(store.attempts, key)
}
}
keys := []string{"name:" + name, "ip:" + ip}
for index, key := range keys {
limit := 10
if index == 1 {
limit = 60
}
if store.attempts[key].count >= limit || len(store.attempts) >= 4096 && store.attempts[key].count == 0 {
return false
}
}
for _, key := range keys {
attempt := store.attempts[key]
if attempt.count == 0 {
attempt.until = now.Add(time.Minute)
}
attempt.count++
store.attempts[key] = attempt
}
return true
}
func (state *accountState) addSession(name string, now time.Time) (string, error) {
token := randomHex(32)
if token == "" {
return "", errors.New("无法创建登录会话,请重试")
}
var current []string
for key, session := range state.Sessions {
if !session.ExpiresAt.After(now) {
delete(state.Sessions, key)
continue
}
if session.Account == name {
current = append(current, key)
}
}
sort.Slice(current, func(i, j int) bool {
return state.Sessions[current[i]].CreatedAt.Before(state.Sessions[current[j]].CreatedAt)
})
for len(current) >= accountSessionLimit {
delete(state.Sessions, current[0])
current = current[1:]
}
state.Sessions[accountTokenHash(token)] = accountSession{Account: name, CreatedAt: now, ExpiresAt: now.Add(accountLifetime)}
return token, nil
}