init
This commit is contained in:
150
internal/store/admin.go
Normal file
150
internal/store/admin.go
Normal file
@@ -0,0 +1,150 @@
|
||||
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
|
||||
}
|
||||
100
internal/store/audit.go
Normal file
100
internal/store/audit.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditEvent struct {
|
||||
Time string `json:"time"`
|
||||
Actor string `json:"actor"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
UA string `json:"ua,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
fh *os.File
|
||||
}
|
||||
|
||||
func NewAuditLog(path string) (*AuditLog, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuditLog{path: path, fh: f}, nil
|
||||
}
|
||||
|
||||
func (a *AuditLog) Close() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fh != nil {
|
||||
return a.fh.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AuditLog) Append(ev AuditEvent) {
|
||||
ev.Time = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fh == nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(ev)
|
||||
_, _ = a.fh.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
func (a *AuditLog) Tail(max int) ([]AuditEvent, error) {
|
||||
// Simple tail by reading whole file (OK for small audit logs).
|
||||
// You can replace with a smarter tail if needed.
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.fh == nil {
|
||||
return nil, nil
|
||||
}
|
||||
_ = a.fh.Sync()
|
||||
b, err := os.ReadFile(a.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := splitLines(b)
|
||||
if max > 0 && len(lines) > max {
|
||||
lines = lines[len(lines)-max:]
|
||||
}
|
||||
out := make([]AuditEvent, 0, len(lines))
|
||||
for _, ln := range lines {
|
||||
var ev AuditEvent
|
||||
if json.Unmarshal(ln, &ev) == nil {
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitLines(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
start := 0
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == '\n' {
|
||||
if i > start {
|
||||
out = append(out, b[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(b) {
|
||||
out = append(out, b[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
5
internal/store/rand.go
Normal file
5
internal/store/rand.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package store
|
||||
|
||||
import "crypto/rand"
|
||||
|
||||
func randRead(b []byte) (int, error) { return rand.Read(b) }
|
||||
Reference in New Issue
Block a user