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 }