// 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" "errors" "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" } // 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 } // 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", }) } // 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 } // 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 }