NextNVR/auth.go

253 lines
7 KiB
Go

// NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT
//
// auth.go — Session-based authentication with bcrypt password hashing.
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionStore holds authenticated sessions in memory.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
// Session represents an authenticated user session.
type Session struct {
Username string
Role string // "master" or "viewer"
Expires time.Time
}
// NewSessionStore creates a new in-memory session store.
func NewSessionStore() *SessionStore {
s := &SessionStore{sessions: make(map[string]*Session)}
go s.cleanupLoop()
return s
}
// Create generates a new session token and stores it.
func (s *SessionStore) Create(username, role string) string {
s.mu.Lock()
defer s.mu.Unlock()
token := generateToken()
s.sessions[token] = &Session{
Username: username,
Role: role,
Expires: time.Now().Add(24 * time.Hour),
}
return token
}
// Get returns a session by token, or nil if expired/missing.
func (s *SessionStore) Get(token string) *Session {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[token]
if !ok || time.Now().After(sess.Expires) {
return nil
}
return sess
}
// Delete removes a session (logout).
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
// cleanupLoop removes expired sessions every hour.
func (s *SessionStore) cleanupLoop() {
ticker := time.NewTicker(1 * time.Hour)
for range ticker.C {
s.mu.Lock()
now := time.Now()
for token, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, token)
}
}
s.mu.Unlock()
}
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// ── Handlers ──
// handleLogin processes POST /api/login.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid request"})
return
}
auth := s.appConfig.Auth
// Check master credentials.
if auth.Master.Username != "" && req.Username == auth.Master.Username {
if err := bcrypt.CompareHashAndPassword([]byte(auth.Master.Password), []byte(req.Password)); err == nil {
token := s.sessions.Create(req.Username, "master")
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "master"}})
return
}
}
// Check viewer credentials.
if auth.Viewer.Enabled && auth.Viewer.Username != "" && req.Username == auth.Viewer.Username {
if err := bcrypt.CompareHashAndPassword([]byte(auth.Viewer.Password), []byte(req.Password)); err == nil {
token := s.sessions.Create(req.Username, "viewer")
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": "viewer"}})
return
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "invalid credentials"})
}
// handleLogout processes POST /api/logout.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session"); err == nil {
s.sessions.Delete(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "logged out"})
}
// handleSession returns the current session info.
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}})
return
}
sess := s.sessions.Get(cookie.Value)
if sess == nil {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": ""}})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{"role": sess.Role, "username": sess.Username}})
}
// ── Middleware ──
// authRequired redirects to /login if no valid session.
func (s *Server) authRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow login page and API, static assets. Everything else requires auth.
path := r.URL.Path
if path == "/login" || path == "/login.html" ||
strings.HasPrefix(path, "/api/login") ||
strings.HasPrefix(path, "/api/logout") ||
strings.HasPrefix(path, "/api/session") {
next.ServeHTTP(w, r)
return
}
// If auth is disabled, allow all.
if !s.appConfig.Auth.Enabled {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie("session")
if err != nil {
// Redirect API calls with 401, page requests to /login.
if strings.HasPrefix(path, "/api/") {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "authentication required"})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
sess := s.sessions.Get(cookie.Value)
if sess == nil {
if strings.HasPrefix(path, "/api/") {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Error: "session expired"})
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
// Inject role into request context.
r.Header.Set("X-NextNVR-Role", sess.Role)
next.ServeHTTP(w, r)
})
}
// viewerOnly restricts access to live wall only (port 8090).
func (s *Server) viewerOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Allow live wall assets and recordings.
if path == "/" || path == "/index.html" ||
strings.HasPrefix(path, "/app.js") ||
strings.HasPrefix(path, "/style.css") ||
strings.HasPrefix(path, "/recordings/") ||
strings.HasPrefix(path, "/go2rtc/") ||
strings.HasPrefix(path, "/stream/") ||
strings.HasPrefix(path, "/api/cameras") ||
strings.HasPrefix(path, "/api/status") {
r.Header.Set("X-NextNVR-Role", "viewer")
next.ServeHTTP(w, r)
return
}
// Block everything else (settings, playback API, config).
jsonResponse(w, http.StatusForbidden, APIResponse{Error: "viewer access only"})
})
}
// HashPassword returns a bcrypt hash of the password.
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}