Scout/internal/users/store.go
2026-08-30 23:27:30 +08:00

452 lines
11 KiB
Go

package users
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/mail"
"os"
"strings"
"sync"
"time"
)
type Role string
const (
RoleUser Role = "user"
RoleDeveloper Role = "developer"
RoleAdmin Role = "admin"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role Role `json:"role"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PasswordHash string `json:"-"`
}
type persistedUser struct {
User
PasswordHash string `json:"password_hash"`
}
type database struct {
Users []persistedUser `json:"users"`
}
type session struct {
UserID string
ExpiresAt time.Time
}
type Store struct {
path string
mu sync.RWMutex
users map[string]persistedUser
sessions map[string]session
}
var (
emailPattern = mail.Address{}
allowedRoles = map[Role]bool{RoleUser: true, RoleDeveloper: true, RoleAdmin: true}
passwordIterations = 150000
)
func New(path string) (*Store, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("用户数据文件不能为空")
}
store := &Store{path: path, users: make(map[string]persistedUser), sessions: make(map[string]session)}
if err := store.load(); err != nil {
return nil, err
}
return store, nil
}
func ValidateEmail(value string) error {
value = strings.TrimSpace(value)
if value == "" || len(value) > 254 {
return errors.New("邮箱地址无效")
}
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value || !strings.Contains(value, "@") {
return errors.New("邮箱地址无效")
}
return nil
}
func ValidatePassword(value string) error {
if len(value) < 8 || len(value) > 128 {
return errors.New("密码长度必须为 8 到 128 个字符")
}
return nil
}
func (s *Store) Register(email, name, password string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name)
if err := ValidateEmail(email); err != nil {
return User{}, err
}
if err := ValidatePassword(password); err != nil {
return User{}, err
}
if len(name) > 80 {
return User{}, errors.New("用户名长度不能超过 80 个字符")
}
if name == "" {
name = strings.Split(email, "@")[0]
}
salt, err := randomBytes(16)
if err != nil {
return User{}, fmt.Errorf("生成密码盐失败: %w", err)
}
stored := persistedUser{User: User{ID: newID(), Email: email, Name: name, Role: RoleUser, Active: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, PasswordHash: encodePassword(password, salt)}
s.mu.Lock()
defer s.mu.Unlock()
for _, existing := range s.users {
if existing.Email == email {
return User{}, errors.New("该邮箱已经注册")
}
}
s.users[stored.ID] = stored
if err := s.saveLocked(); err != nil {
delete(s.users, stored.ID)
return User{}, err
}
return stored.User, nil
}
func (s *Store) EnsureAdmin(email, name, password string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" && password == "" {
return User{}, nil
}
if email == "" || password == "" {
return User{}, errors.New("管理员邮箱和密码必须同时配置")
}
if err := ValidateEmail(email); err != nil {
return User{}, err
}
if err := ValidatePassword(password); err != nil {
return User{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
for id, existing := range s.users {
if existing.Email == email {
if existing.User.Role != RoleAdmin || !existing.User.Active {
existing.User.Role = RoleAdmin
existing.User.Active = true
existing.User.UpdatedAt = time.Now().UTC()
s.users[id] = existing
if err := s.saveLocked(); err != nil {
return User{}, err
}
}
return existing.User, nil
}
}
salt, err := randomBytes(16)
if err != nil {
return User{}, fmt.Errorf("生成管理员密码盐失败: %w", err)
}
stored := persistedUser{User: User{ID: newID(), Email: email, Name: strings.TrimSpace(name), Role: RoleAdmin, Active: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}, PasswordHash: encodePassword(password, salt)}
if stored.Name == "" {
stored.Name = "管理员"
}
s.users[stored.ID] = stored
if err := s.saveLocked(); err != nil {
delete(s.users, stored.ID)
return User{}, err
}
return stored.User, nil
}
func (s *Store) Authenticate(email, password string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.RLock()
defer s.mu.RUnlock()
for _, candidate := range s.users {
if candidate.Email != email {
continue
}
if !candidate.User.Active || !verifyPassword(password, candidate.PasswordHash) {
return User{}, errors.New("邮箱或密码错误")
}
return candidate.User, nil
}
return User{}, errors.New("邮箱或密码错误")
}
func (s *Store) CreateSession(userID string, duration time.Duration) (string, time.Time, error) {
if duration <= 0 {
duration = 24 * time.Hour
}
if _, ok := s.Get(userID); !ok {
return "", time.Time{}, errors.New("用户不存在")
}
tokenBytes, err := randomBytes(32)
if err != nil {
return "", time.Time{}, fmt.Errorf("生成会话令牌失败: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
expiresAt := time.Now().UTC().Add(duration)
s.mu.Lock()
s.sessions[token] = session{UserID: userID, ExpiresAt: expiresAt}
s.mu.Unlock()
return token, expiresAt, nil
}
func (s *Store) UserBySession(token string) (User, bool) {
token = strings.TrimSpace(token)
if token == "" {
return User{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
current, ok := s.sessions[token]
if !ok {
return User{}, false
}
if time.Now().UTC().After(current.ExpiresAt) {
delete(s.sessions, token)
return User{}, false
}
stored, ok := s.users[current.UserID]
if !ok || !stored.User.Active {
return User{}, false
}
return stored.User, true
}
func (s *Store) RevokeSession(token string) {
s.mu.Lock()
delete(s.sessions, strings.TrimSpace(token))
s.mu.Unlock()
}
func (s *Store) Get(id string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
stored, ok := s.users[id]
return stored.User, ok
}
func (s *Store) List() []User {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]User, 0, len(s.users))
for _, stored := range s.users {
result = append(result, stored.User)
}
return result
}
func (s *Store) ListByRole(role Role) []User {
users := s.List()
result := make([]User, 0)
for _, user := range users {
if user.Role == role && user.Active {
result = append(result, user)
}
}
return result
}
func (s *Store) Update(id string, role Role, active bool) (User, error) {
if !allowedRoles[role] {
return User{}, errors.New("用户角色无效")
}
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.users[id]
if !ok {
return User{}, errors.New("用户不存在")
}
stored.User.Role = role
stored.User.Active = active
stored.User.UpdatedAt = time.Now().UTC()
s.users[id] = stored
if err := s.saveLocked(); err != nil {
return User{}, err
}
return stored.User, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("读取用户数据失败: %w", err)
}
var stored database
if err := json.Unmarshal(data, &stored); err != nil {
return fmt.Errorf("解析用户数据失败: %w", err)
}
for _, user := range stored.Users {
if user.User.ID != "" && user.User.Email != "" {
s.users[user.User.ID] = user
}
}
return nil
}
func (s *Store) saveLocked() error {
if err := os.MkdirAll(filepathDir(s.path), 0o755); err != nil {
return fmt.Errorf("创建用户数据目录失败: %w", err)
}
stored := database{Users: make([]persistedUser, 0, len(s.users))}
for _, user := range s.users {
stored.Users = append(stored.Users, user)
}
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("编码用户数据失败: %w", err)
}
temp, err := os.CreateTemp(filepathDir(s.path), ".users-*")
if err != nil {
return fmt.Errorf("创建用户临时文件失败: %w", err)
}
tempName := temp.Name()
defer os.Remove(tempName)
if _, err := temp.Write(data); err != nil {
temp.Close()
return fmt.Errorf("写入用户数据失败: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("关闭用户临时文件失败: %w", err)
}
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("保存用户数据失败: %w", err)
}
return nil
}
func encodePassword(password string, salt []byte) string {
key := derive(password, salt)
return fmt.Sprintf("$scout$1$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key))
}
func verifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "scout" || parts[2] != "1" {
return false
}
var iterations int
if _, err := fmt.Sscanf(parts[3], "%d", &iterations); err != nil || iterations < 100000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
actual := deriveWithIterations(password, salt, iterations)
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func derive(password string, salt []byte) []byte {
return deriveWithIterations(password, salt, passwordIterations)
}
func deriveWithIterations(password string, salt []byte, iterations int) []byte {
result := make([]byte, 32)
mac := hmac.New(sha256.New, []byte(password))
mac.Write(salt)
mac.Write([]byte{0, 0, 0, 1})
previous := mac.Sum(nil)
copy(result, previous)
for index := 1; index < iterations; index++ {
mac = hmac.New(sha256.New, []byte(password))
mac.Write(previous)
previous = mac.Sum(nil)
for offset := range result {
result[offset] ^= previous[offset]
}
}
return result
}
func randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
_, err := rand.Read(value)
return value, err
}
func newID() string {
value, err := randomBytes(16)
if err != nil {
return fmt.Sprintf("user-%d", time.Now().UnixNano())
}
return base64.RawURLEncoding.EncodeToString(value)
}
func filepathDir(path string) string {
index := strings.LastIndexAny(path, `/\\`)
if index < 0 {
return "."
}
return path[:index]
}
func sanitizeUser(user User) User {
user.PasswordHash = ""
return user
}
func (s *Store) SetPassword(id, password string) error {
if err := ValidatePassword(password); err != nil {
return err
}
salt, err := randomBytes(16)
if err != nil {
return fmt.Errorf("生成密码盐失败: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.users[id]
if !ok {
return errors.New("用户不存在")
}
stored.PasswordHash = encodePassword(password, salt)
stored.User.UpdatedAt = time.Now().UTC()
s.users[id] = stored
if err := s.saveLocked(); err != nil {
return err
}
for token, current := range s.sessions {
if current.UserID == id {
delete(s.sessions, token)
}
}
return nil
}
func (s *Store) FindByEmail(email string) (User, bool) {
email = strings.ToLower(strings.TrimSpace(email))
s.mu.RLock()
defer s.mu.RUnlock()
for _, stored := range s.users {
if stored.Email == email {
return stored.User, true
}
}
return User{}, false
}