Feature(Go): Web auth, WebSocket signaling and live device list (Phase 3)
This commit is contained in:
192
server/go/wsauth/wsauth.go
Normal file
192
server/go/wsauth/wsauth.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user