468 lines
14 KiB
Go
468 lines
14 KiB
Go
// 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.
|
|
// Unlike legacy convention, the admin record is given a real per-instance
|
|
// salt — exposing an empty salt for admin while everyone else has a real
|
|
// 16-hex one would let an unauthenticated probe distinguish admin from
|
|
// other accounts via /get_salt alone. The cost is a tiny break in
|
|
// users.json schema compat: admin is never persisted to users.json
|
|
// anyway (snapshotPersistableLocked excludes it), so this is in-memory
|
|
// only.
|
|
func (a *Authenticator) AddAdminFromPlainPassword(username, plainPassword string) {
|
|
salt, err := NewSalt()
|
|
if err != nil {
|
|
// Fall back to deterministic salt derived from the password hash
|
|
// rather than empty — preserves the uniform-shape property even
|
|
// if crypto/rand briefly errors at startup.
|
|
salt = ComputeSHA256(plainPassword)[:saltBytes*2]
|
|
}
|
|
a.AddUser(User{
|
|
Username: username,
|
|
PasswordHash: HashPassword(plainPassword, salt),
|
|
Salt: 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 for an existing user, or a
|
|
// deterministic 16-hex pseudo-salt for an unknown user. The ok flag
|
|
// reports which case occurred, so callers can decide whether to update
|
|
// rate-limit / audit state — but the returned salt itself is shaped
|
|
// identically (16 hex chars) in both cases, defeating the user-existence
|
|
// probe an attacker would otherwise mount via /get_salt.
|
|
//
|
|
// The pseudo-salt is derived from a server-instance secret (the admin
|
|
// password hash, taken at first call) mixed with the username, so the
|
|
// same unknown user always sees the same fake salt across requests.
|
|
// Without this, an attacker could fingerprint the "fake-salt branch"
|
|
// by submitting the same username twice and watching for differences.
|
|
func (a *Authenticator) GetSalt(username string) (string, bool) {
|
|
a.mu.RLock()
|
|
u, ok := a.users[username]
|
|
a.mu.RUnlock()
|
|
if ok {
|
|
return u.Salt, true
|
|
}
|
|
return a.fakeSalt(username), false
|
|
}
|
|
|
|
// fakeSalt derives a deterministic 16-hex value for unknown usernames.
|
|
// The secret pepper is the bootstrap admin's password hash — present as
|
|
// long as the server has any admin, deterministic per deployment, never
|
|
// transmitted. Reveals nothing useful to an attacker even if reverse-
|
|
// engineered: the only thing they can do with it is reproduce the fake
|
|
// salt, which they already see in the response.
|
|
func (a *Authenticator) fakeSalt(username string) string {
|
|
a.mu.RLock()
|
|
pepper := ""
|
|
if admin, ok := a.users["admin"]; ok {
|
|
pepper = admin.PasswordHash
|
|
}
|
|
a.mu.RUnlock()
|
|
digest := ComputeSHA256("yama-fake-salt|" + pepper + "|" + username)
|
|
return digest[:saltBytes*2]
|
|
}
|
|
|
|
// 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
|
|
}
|