// Package wsauth provides authentication and session-token management for // the web service. Protocol surface (challenge nonce + SHA256-based response // and SHA256(password+salt) hashes) is kept compatible with the existing // browser front-end and users.json format. Internal token representation is // deliberately different from the C++ counterpart — opaque random hex strings // keyed into an in-memory map — to avoid leaking the proprietary token format. package wsauth import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "sort" "sync" "time" ) // Default knobs. Override via SetDefaults at startup if needed. const ( DefaultTokenExpire = 24 * time.Hour nonceBytes = 16 // 32 hex chars tokenBytes = 32 // 64 hex chars saltBytes = 8 // 16 hex chars ) // ErrInvalidToken is returned when a token is unknown or expired. var ErrInvalidToken = errors.New("invalid or expired token") // User is the credentials record for one web account. type User struct { Username string PasswordHash string // SHA256(password+salt) in lowercase hex Salt string // empty for admin (matches C++ convention) Role string // "admin" or "viewer" // AllowedGroups restricts which device groups this user can view. Empty // slice = no device access (admin role is treated as "all" elsewhere // without consulting this list). Matches the C++ WebUser.allowed_groups // field one-for-one so users.json is interchangeable between the two // servers. AllowedGroups []string } // Session is the authenticated state attached to a valid token. type Session struct { Username string Role string ExpiresAt time.Time } // Authenticator owns the user table and the active token map. It is safe to // use from multiple goroutines. type Authenticator struct { mu sync.RWMutex users map[string]*User // username -> user tokens map[string]*Session // token -> session tokenExpire time.Duration // usersFile is the persistence target for non-admin accounts (admin // lives in env/master-password only and is never written out, matching // the C++ SaveUsers behavior). Empty disables persistence. usersFile string } // New returns an empty Authenticator. Call AddUser to populate. func New() *Authenticator { return &Authenticator{ users: make(map[string]*User), tokens: make(map[string]*Session), tokenExpire: DefaultTokenExpire, } } // SetTokenExpire overrides the default session lifetime. func (a *Authenticator) SetTokenExpire(d time.Duration) { if d <= 0 { return } a.mu.Lock() a.tokenExpire = d a.mu.Unlock() } // AddUser registers a user. PasswordHash should already be // SHA256(password+salt) in lowercase hex; pass empty Salt to mirror the // admin-style "no salt" convention used by the C++ side. func (a *Authenticator) AddUser(u User) { if u.Username == "" { return } a.mu.Lock() a.users[u.Username] = &u a.mu.Unlock() } // AddAdminFromPlainPassword is a convenience for the bootstrap admin: salt is // empty (matching the C++ admin record), hash is SHA256(password). func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) { a.AddUser(User{ Username: username, PasswordHash: ComputeSHA256(plainPassword), Salt: "", Role: "admin", }) } // CreateUser registers a new account, persists the user table, and returns // nil on success. Mirrors the C++ CWebService::CreateUser semantics: // - role must be "admin" or "viewer" // - "admin" is reserved for the bootstrap account and cannot be created // via this API // - duplicate usernames are rejected // // Password is hashed with a fresh per-user salt before being stored. func (a *Authenticator) CreateUser(username, plainPassword, role string, allowedGroups []string) error { if username == "" || plainPassword == "" { return errors.New("username and password required") } if username == "admin" { return errors.New("'admin' is reserved") } if role != "admin" && role != "viewer" { return errors.New("role must be 'admin' or 'viewer'") } salt, err := NewSalt() if err != nil { return err } u := User{ Username: username, PasswordHash: HashPassword(plainPassword, salt), Salt: salt, Role: role, AllowedGroups: append([]string(nil), allowedGroups...), } a.mu.Lock() if _, exists := a.users[username]; exists { a.mu.Unlock() return errors.New("user already exists") } a.users[username] = &u path := a.usersFile snapshot := a.snapshotPersistableLocked() a.mu.Unlock() if path != "" { return writeUsersFile(path, snapshot) } return nil } // DeleteUser removes a user account and persists the change. The bootstrap // admin (always Role=="admin" with empty Salt) is protected: deleting it // would lock everyone out of the UI with no way back in. func (a *Authenticator) DeleteUser(username string) error { if username == "" || username == "admin" { return errors.New("cannot delete admin") } a.mu.Lock() if _, ok := a.users[username]; !ok { a.mu.Unlock() return errors.New("user not found") } delete(a.users, username) // Also drop any live sessions belonging to that user so they don't // outlive their account. for tok, s := range a.tokens { if s.Username == username { delete(a.tokens, tok) } } path := a.usersFile snapshot := a.snapshotPersistableLocked() a.mu.Unlock() if path != "" { return writeUsersFile(path, snapshot) } return nil } // ListUsers returns a stable, copied snapshot of all users in name order. // PasswordHash and Salt are zeroed in the returned records so callers can // JSON-marshal the result without leaking credentials. func (a *Authenticator) ListUsers() []User { a.mu.RLock() defer a.mu.RUnlock() out := make([]User, 0, len(a.users)) for _, u := range a.users { c := *u c.PasswordHash = "" c.Salt = "" out = append(out, c) } sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) return out } // GetSalt returns the per-user salt. If the user does not exist, returns ("", false). // Note: the C++ admin uses an empty salt — that is still considered "found" // and the empty string is returned with ok=true. func (a *Authenticator) GetSalt(username string) (string, bool) { a.mu.RLock() u, ok := a.users[username] a.mu.RUnlock() if !ok { return "", false } return u.Salt, true } // VerifyLogin checks a challenge-response login. The browser sends // response = SHA256(passwordHash + nonce). On success the function mints a // new session token, stores it, and returns (token, role, nil). func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) { a.mu.RLock() u, ok := a.users[username] expire := a.tokenExpire a.mu.RUnlock() if !ok { return "", "", errors.New("invalid credentials") } expected := ComputeSHA256(u.PasswordHash + nonce) if response != expected { return "", "", errors.New("invalid credentials") } token, err = randomHex(tokenBytes) if err != nil { return "", "", err } a.mu.Lock() a.tokens[token] = &Session{ Username: username, Role: u.Role, ExpiresAt: time.Now().Add(expire), } a.mu.Unlock() return token, u.Role, nil } // usersFileEntry is the on-disk shape of one record in users.json. Field // names match the C++ WebService SaveUsers output exactly so the two // servers can share the file. type usersFileEntry struct { Username string `json:"username"` PasswordHash string `json:"password_hash"` Salt string `json:"salt"` Role string `json:"role"` AllowedGroups []string `json:"allowed_groups"` } type usersFile struct { Users []usersFileEntry `json:"users"` } // SetUsersFile points the authenticator at a JSON file used to persist // non-admin accounts and loads any existing entries. Calling it again with // a different path is allowed but the previous file is not re-read on a // nil-arg call — initialize once at startup. Missing files are not an // error; they're treated as an empty user table. func (a *Authenticator) SetUsersFile(path string) error { a.mu.Lock() a.usersFile = path a.mu.Unlock() if path == "" { return nil } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } if len(data) == 0 { return nil } var f usersFile if err := json.Unmarshal(data, &f); err != nil { return err } a.mu.Lock() defer a.mu.Unlock() for _, e := range f.Users { // Skip admin: it's always sourced from env/master password and // already injected via AddAdminFromPlainPassword. if e.Username == "" || e.Username == "admin" { continue } if e.PasswordHash == "" { continue } role := e.Role if role == "" { role = "viewer" } a.users[e.Username] = &User{ Username: e.Username, PasswordHash: e.PasswordHash, Salt: e.Salt, Role: role, AllowedGroups: append([]string(nil), e.AllowedGroups...), } } return nil } // snapshotPersistableLocked builds the on-disk slice while a.mu is held by // the caller. Admin records are excluded — they live in env, not in the file. func (a *Authenticator) snapshotPersistableLocked() []usersFileEntry { out := make([]usersFileEntry, 0, len(a.users)) for _, u := range a.users { if u.Username == "admin" { continue } groups := append([]string{}, u.AllowedGroups...) out = append(out, usersFileEntry{ Username: u.Username, PasswordHash: u.PasswordHash, Salt: u.Salt, Role: u.Role, AllowedGroups: groups, }) } sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) return out } // writeUsersFile writes the user list atomically: encode to a temp file in // the same directory, fsync, rename — so a crash mid-write can't leave the // service starting up with a half-written users.json next time. func writeUsersFile(path string, entries []usersFileEntry) error { data, err := json.MarshalIndent(usersFile{Users: entries}, "", " ") if err != nil { return err } dir := filepath.Dir(path) if dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } tmp, err := os.CreateTemp(dir, "users-*.json.tmp") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.Write(data); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return err } if err := tmp.Sync(); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return err } if err := os.Rename(tmpName, path); err != nil { _ = os.Remove(tmpName) return err } return nil } // ValidateToken returns the session for a token or ErrInvalidToken. Expired // tokens are removed lazily as they are looked up. func (a *Authenticator) ValidateToken(token string) (*Session, error) { a.mu.RLock() s, ok := a.tokens[token] a.mu.RUnlock() if !ok { return nil, ErrInvalidToken } if time.Now().After(s.ExpiresAt) { a.mu.Lock() delete(a.tokens, token) a.mu.Unlock() return nil, ErrInvalidToken } return s, nil } // RevokeToken removes a token from the active set. No-op for unknown tokens. func (a *Authenticator) RevokeToken(token string) { a.mu.Lock() delete(a.tokens, token) a.mu.Unlock() } // NewNonce returns a fresh challenge nonce (hex string). Each WS connection // should receive exactly one nonce, consumed by a single login attempt. func NewNonce() (string, error) { return randomHex(nonceBytes) } // NewSalt returns a fresh per-user salt (hex string). func NewSalt() (string, error) { return randomHex(saltBytes) } // ComputeSHA256 returns the lowercase-hex SHA256 of s. func ComputeSHA256(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } // HashPassword computes the stored hash for a (password, salt) pair using // the same scheme as the existing C++ users.json: SHA256(password + salt). func HashPassword(password, salt string) string { return ComputeSHA256(password + salt) } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil }