264 lines
7.9 KiB
Go
264 lines
7.9 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 when viewer port is disabled, or to live wall only.
|
|
func (s *Server) viewerOnly(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Check if viewer port is enabled in config.
|
|
s.mu.RLock()
|
|
enabled := s.appConfig.Server.ViewerPort != "" && s.appConfig.Server.ViewerPort != ":0"
|
|
s.mu.RUnlock()
|
|
|
|
if !enabled {
|
|
msg := `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NextNVR</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#0d1117;color:#c9d1d9;margin:0;text-align:center}h2{font-size:24px}p{color:#8b949e;margin-top:8px}a{color:#58a6ff;text-decoration:none;font-size:14px;margin-top:16px;display:inline-block;padding:10px 24px;background:#21262d;border-radius:6px}</style></head><body><div><h2>🔒 Local View Disabled</h2><p>The owner has not enabled local network access.</p><a href="http://192.168.1.11:8080">Go to admin login →</a></div></body></html>`
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(msg))
|
|
return
|
|
}
|
|
|
|
path := r.URL.Path
|
|
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
|
|
}
|
|
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
|
|
}
|