package auth import ( "encoding/json" "fmt" "os" "path/filepath" "sync" "time" "golang.org/x/crypto/bcrypt" ) type User struct { Username string `json:"username"` Password string `json:"-"` // bcrypt hash,不序列化到前端 Hash string `json:"hash,omitempty"` // 持久化用 Role string `json:"role"` Status string `json:"status,omitempty"` // active / banned,对标 SyncTV UserStatus CreatedAt int64 `json:"created_at"` } type UserStore struct { mu sync.RWMutex users map[string]*User filePath string } func NewUserStore(filePath string) (*UserStore, error) { s := &UserStore{ users: make(map[string]*User), filePath: filePath, } if filePath != "" { if err := s.load(); err != nil { if !os.IsNotExist(err) { return nil, fmt.Errorf("load users: %w", err) } } } return s, nil } func (s *UserStore) BootstrapAdmin(username, password string) error { if username == "" || password == "" { return fmt.Errorf("bootstrap admin requires user and password") } if len(password) < 8 { return fmt.Errorf("bootstrap admin password too short (min 8)") } if _, exists := s.users[username]; exists { return nil } u, err := s.Create(username, password, RoleAdmin) if err != nil { return err } _ = u return nil } func (s *UserStore) Get(username string) (*User, bool) { s.mu.RLock() defer s.mu.RUnlock() u, ok := s.users[username] if !ok { return nil, false } cp := *u return &cp, true } func (s *UserStore) List() []*User { s.mu.RLock() defer s.mu.RUnlock() out := make([]*User, 0, len(s.users)) for _, u := range s.users { out = append(out, &User{Username: u.Username, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt}) } return out } func (s *UserStore) Create(username, password, role string) (*User, error) { if username == "" || password == "" { return nil, fmt.Errorf("username and password required") } if role == "" { role = RoleViewer } if !isValidRole(role) { return nil, fmt.Errorf("invalid role %q", role) } if len(password) < 6 { return nil, fmt.Errorf("password too short (min 6)") } s.mu.Lock() defer s.mu.Unlock() if _, exists := s.users[username]; exists { return nil, fmt.Errorf("user %q already exists", username) } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } u := &User{ Username: username, Password: string(hash), Hash: string(hash), Role: role, Status: StatusActive, CreatedAt: time.Now().Unix(), } s.users[username] = u _ = s.saveLocked() cp := *u cp.Password = "" cp.Hash = "" return &cp, nil } func (s *UserStore) Verify(username, password string) (*User, bool) { s.mu.RLock() u, ok := s.users[username] s.mu.RUnlock() if !ok { return nil, false } if u.Status == StatusBanned { return nil, false } if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil { return nil, false } cp := *u return &cp, true } func (s *UserStore) UpdateRole(username, role string) error { if !isValidRole(role) { return fmt.Errorf("invalid role %q", role) } s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[username] if !ok { return fmt.Errorf("user %q not found", username) } if u.Status == StatusBanned { return fmt.Errorf("user %q is banned", username) } u.Role = role return s.saveLocked() } func (s *UserStore) BanUser(username string) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[username] if !ok { return fmt.Errorf("user %q not found", username) } u.Status = StatusBanned return s.saveLocked() } func (s *UserStore) UnbanUser(username string) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[username] if !ok { return fmt.Errorf("user %q not found", username) } u.Status = StatusActive return s.saveLocked() } func (s *UserStore) IsBanned(username string) bool { s.mu.RLock() defer s.mu.RUnlock() u, ok := s.users[username] if !ok { return false } return u.Status == StatusBanned } func (s *UserStore) load() error { data, err := os.ReadFile(s.filePath) if err != nil { return err } var list []*User if err := json.Unmarshal(data, &list); err != nil { return err } for _, u := range list { if u.Password == "" && u.Hash != "" { u.Password = u.Hash } if u.Hash == "" && u.Password != "" { u.Hash = u.Password } if u.Status == "" { u.Status = StatusActive } if u.Status != StatusBanned { u.Status = StatusActive } s.users[u.Username] = u } return nil } func (s *UserStore) save() error { s.mu.RLock() defer s.mu.RUnlock() return s.saveLocked() } func (s *UserStore) saveLocked() error { if s.filePath == "" { return nil } if err := os.MkdirAll(filepath.Dir(s.filePath), 0755); err != nil { return err } list := make([]*User, 0, len(s.users)) for _, u := range s.users { if u.Hash == "" { u.Hash = u.Password } if u.Status == "" { u.Status = StatusActive } list = append(list, u) } data, _ := json.MarshalIndent(list, "", " ") return os.WriteFile(s.filePath, data, 0644) } const ( RoleRoot = "root" RoleAdmin = "admin" RolePublisher = "publisher" RoleViewer = "viewer" RoleGuest = "guest" StatusActive = "active" StatusBanned = "banned" ) func isValidRole(r string) bool { switch r { case RoleRoot, RoleAdmin, RolePublisher, RoleViewer: return true default: return false } } func AllRoles() []string { return []string{RoleRoot, RoleAdmin, RolePublisher, RoleViewer} } func IsAdminOrAbove(role string) bool { return role == RoleRoot || role == RoleAdmin } func CanManage(actorRole, targetRole string) bool { switch actorRole { case RoleRoot: return true case RoleAdmin: return targetRole == RoleViewer || targetRole == RolePublisher default: return false } } func NormalizeRole(r string) string { switch r { case RoleRoot, RoleAdmin, RolePublisher, RoleViewer: return r case "user": return RoleViewer default: return RoleViewer } }