feat(app): add account and config components
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user