mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
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>
262 lines
7.5 KiB
Go
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)
|
|
}
|