151 lines
3.0 KiB
Go
151 lines
3.0 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yourorg/ntfywui/internal/security"
|
|
)
|
|
|
|
type Role string
|
|
|
|
const (
|
|
RoleViewer Role = "viewer"
|
|
RoleOperator Role = "operator"
|
|
RoleAdmin Role = "admin"
|
|
)
|
|
|
|
type Admin struct {
|
|
Username string `json:"username"`
|
|
Role Role `json:"role"`
|
|
PassHash string `json:"pass_hash"`
|
|
TOTPSecret string `json:"totp_secret,omitempty"` // base32, optional
|
|
Disabled bool `json:"disabled"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
}
|
|
|
|
type AdminStore struct {
|
|
mu sync.Mutex
|
|
path string
|
|
admin map[string]Admin
|
|
}
|
|
|
|
func NewAdminStore(path string) (*AdminStore, error) {
|
|
s := &AdminStore{path: path, admin: map[string]Admin{}}
|
|
if err := s.load(); err != nil {
|
|
// If file doesn't exist, start empty
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *AdminStore) load() error {
|
|
b, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var m map[string]Admin
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return err
|
|
}
|
|
s.admin = m
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminStore) saveLocked() error {
|
|
tmp := s.path + ".tmp"
|
|
b, err := json.MarshalIndent(s.admin, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func (s *AdminStore) EnsureBootstrap(username, password string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.admin[username]; ok {
|
|
return false, nil
|
|
}
|
|
salt := make([]byte, 16)
|
|
_, _ = randRead(salt)
|
|
hash := security.HashPasswordPBKDF2(password, salt, 120_000)
|
|
s.admin[username] = Admin{
|
|
Username: username,
|
|
Role: RoleAdmin,
|
|
PassHash: hash,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
return true, s.saveLocked()
|
|
}
|
|
|
|
func (s *AdminStore) List() []Admin {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]Admin, 0, len(s.admin))
|
|
for _, a := range s.admin {
|
|
out = append(out, a)
|
|
}
|
|
// stable sort not necessary for UI
|
|
return out
|
|
}
|
|
|
|
func (s *AdminStore) Get(username string) (Admin, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
a, ok := s.admin[username]
|
|
return a, ok
|
|
}
|
|
|
|
func (s *AdminStore) Set(a Admin) error {
|
|
if a.Username == "" {
|
|
return errors.New("username required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.admin[a.Username] = a
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *AdminStore) Delete(username string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.admin, username)
|
|
return s.saveLocked()
|
|
}
|
|
|
|
func (s *AdminStore) Authenticate(username, password, totp string) (Admin, bool) {
|
|
s.mu.Lock()
|
|
a, ok := s.admin[username]
|
|
s.mu.Unlock()
|
|
if !ok || a.Disabled {
|
|
return Admin{}, false
|
|
}
|
|
okpw, _ := security.VerifyPasswordPBKDF2(password, a.PassHash)
|
|
if !okpw {
|
|
return Admin{}, false
|
|
}
|
|
if a.TOTPSecret != "" {
|
|
if totp == "" {
|
|
return Admin{}, false
|
|
}
|
|
if !security.VerifyTOTP(a.TOTPSecret, totp, time.Now()) {
|
|
return Admin{}, false
|
|
}
|
|
}
|
|
return a, true
|
|
}
|