223 lines
4.7 KiB
Go
223 lines
4.7 KiB
Go
package auth
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// User 表示系统用户
|
||
type User struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"-"` // bcrypt hash,不序列化到前端
|
||
Hash string `json:"hash,omitempty"` // 持久化用
|
||
Role string `json:"role"`
|
||
CreatedAt int64 `json:"created_at"`
|
||
}
|
||
|
||
// UserStore 负责用户持久化与校验,内存为主,可选落盘到 JSON 文件
|
||
type UserStore struct {
|
||
mu sync.RWMutex
|
||
users map[string]*User
|
||
filePath string
|
||
}
|
||
|
||
// NewUserStore 创建用户存储,若 filePath 非空则尝试从文件加载;若文件不存在则预置种子用户
|
||
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)
|
||
}
|
||
s.seed()
|
||
_ = s.save()
|
||
} else if len(s.users) == 0 {
|
||
s.seed()
|
||
_ = s.save()
|
||
}
|
||
} else {
|
||
s.seed()
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
func (s *UserStore) seed() {
|
||
seeds := []struct {
|
||
username string
|
||
password string
|
||
role string
|
||
}{
|
||
{"admin", "Admin123!", RoleAdmin},
|
||
{"publisher", "Publisher123!", RolePublisher},
|
||
{"viewer", "Viewer123!", RoleViewer},
|
||
}
|
||
for _, u := range seeds {
|
||
hash, _ := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
|
||
s.users[u.username] = &User{
|
||
Username: u.username,
|
||
Password: string(hash),
|
||
Hash: string(hash),
|
||
Role: u.role,
|
||
CreatedAt: time.Now().Unix(),
|
||
}
|
||
}
|
||
}
|
||
|
||
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, 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,
|
||
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 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)
|
||
}
|
||
u.Role = role
|
||
return s.saveLocked()
|
||
}
|
||
|
||
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
|
||
}
|
||
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
|
||
}
|
||
list = append(list, u)
|
||
}
|
||
data, _ := json.MarshalIndent(list, "", " ")
|
||
return os.WriteFile(s.filePath, data, 0644)
|
||
}
|
||
|
||
const (
|
||
RoleAdmin = "admin"
|
||
RolePublisher = "publisher"
|
||
RoleViewer = "viewer"
|
||
RoleGuest = "guest"
|
||
)
|
||
|
||
func isValidRole(r string) bool {
|
||
switch r {
|
||
case RoleAdmin, RolePublisher, RoleViewer:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func AllRoles() []string { return []string{RoleAdmin, RolePublisher, RoleViewer} }
|