Files
Tomas DvorakandDevin f3143af351 feat(api): phase 5 — alerts, agent depth, subdomains, auth, boards
P5b: notifier transport registry (webhook/slack/discord/telegram/
gotify/ntfy/smtp via net/smtp), per-monitor+domain alert rules
(failsBeforeDown, latencyWarn, certDays, mute).

P5c: per-container cpu/mem, optional SMART/ZFS/GPU exec-or-skip
collectors, crt.sh subdomain discovery with daily sweep.

P5d: opt-in auth (bcrypt, sqlite sessions, 7d rotating cookie, login
rate limit, public-route bypass) and multiple boards (boards table,
sections.board_id, /api/boards CRUD, slug routing).

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-22 12:49:44 +02:00

262 lines
7.5 KiB
Go

package api
// Opt-in local auth — disabled by default; enabling creates the first admin.
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
// agent ingest, metrics) bypass the middleware.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "dash_session"
sessionTTL = 7 * 24 * time.Hour
// Rotate when less than half the TTL remains.
sessionRotate = sessionTTL / 2
)
func (s *Server) authEnabled() bool {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
return false
}
var on bool
_ = json.Unmarshal([]byte(raw), &on)
return on
}
func (s *Server) userCount() int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
// ---------- sessions ----------
func (s *Server) createSession(c *gin.Context, userID string) {
var tok [32]byte
_, _ = rand.Read(tok[:])
token := hex.EncodeToString(tok[:])
exp := time.Now().Add(sessionTTL)
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: exp,
HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
// Returns "" when absent/expired.
func (s *Server) sessionUser(c *gin.Context) string {
ck, err := c.Cookie(sessionCookie)
if err != nil || ck == "" {
return ""
}
var userID, expires string
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
if err != nil {
return ""
}
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
if err != nil || time.Now().After(exp) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
return ""
}
if time.Until(exp) < sessionRotate {
// Rotate: drop the old token, issue a fresh one.
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
s.createSession(c, userID)
}
return userID
}
// ---------- rate limiting (login only, in-memory per-IP) ----------
var loginMu sync.Mutex
var loginHits = map[string][]time.Time{}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
cut := time.Now().Add(-time.Minute)
kept := loginHits[ip][:0]
for _, t := range loginHits[ip] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= 5 {
loginHits[ip] = kept
return false
}
loginHits[ip] = append(kept, time.Now())
return true
}
// ---------- middleware ----------
// publicAPI lists /api paths reachable without a session. Auth endpoints must
// stay open (login itself); push/ingest/badge/metrics/public status carry
// their own tokens or are intentionally public.
func publicAPI(path string) bool {
switch {
case strings.HasPrefix(path, "/api/auth/"),
strings.HasPrefix(path, "/api/push/"),
path == "/api/systems/ingest",
strings.HasPrefix(path, "/api/badge/"),
path == "/api/metrics",
strings.HasPrefix(path, "/api/healthz"),
strings.HasSuffix(path, "/public"):
return true
}
return false
}
// requireAuth gates every /api route when the auth_enabled setting is on.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
c.Next()
return
}
if s.sessionUser(c) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
c.Next()
}
}
// ---------- handlers ----------
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// sessionInfo reports auth state for the frontend shell — always public so
// the UI can decide between the board and the login screen.
func (s *Server) authSession(c *gin.Context) {
on := s.authEnabled()
out := gin.H{"enabled": on, "authed": false, "setup": false}
if !on {
c.JSON(http.StatusOK, out)
return
}
out["setup"] = s.userCount() == 0
if uid := s.sessionUser(c); uid != "" {
var name string
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
out["authed"] = true
out["username"] = name
}
c.JSON(http.StatusOK, out)
}
// setup creates the first user — only while auth is on and no users exist.
func (s *Server) authSetup(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
if s.userCount() > 0 {
fail(c, http.StatusConflict, "admin user already exists")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil ||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(c, http.StatusInternalServerError, "hash failed")
return
}
uid := newID("u")
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
fail(c, http.StatusConflict, "username taken")
return
}
s.createSession(c, uid)
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
}
func (s *Server) authLogin(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
ip := c.ClientIP()
if !loginAllowed(ip) {
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
var uid, hash string
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
if err != nil {
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
}
// CompareHashAndPassword is constant-time; err != nil covers both
// unknown user (dummy hash above) and wrong password.
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
s.createSession(c, uid)
c.JSON(http.StatusOK, gin.H{"username": in.Username})
}
func (s *Server) authLogout(c *gin.Context) {
if ck, err := c.Cookie(sessionCookie); err == nil {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})
c.Status(http.StatusNoContent)
}
// authDisable turns auth off and wipes users+sessions. Requires a session —
// you can't switch the lock off from outside.
func (s *Server) authDisable(c *gin.Context) {
if s.sessionUser(c) == "" {
fail(c, http.StatusUnauthorized, "authentication required")
return
}
_, _ = s.db.Exec(`DELETE FROM sessions`)
_, _ = s.db.Exec(`DELETE FROM users`)
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
s.log.Error("auth disable", zap.Error(err))
}
c.Status(http.StatusNoContent)
}