// 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, and recordings. 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") || strings.HasPrefix(path, "/recordings/") || strings.HasPrefix(path, "/go2rtc/") || strings.HasPrefix(path, "/stream/") { 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 }