live-sfu-demo/internal/auth/store.go

285 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}