285 lines
5.9 KiB
Go
285 lines
5.9 KiB
Go
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
|
||
}
|
||
}
|