feat: add session-based authentication to the web interface and API routes
Build and Push / build (nucleus, amd64, linux) (push) Successful in 16s

This commit is contained in:
2026-07-17 01:33:26 +01:00
parent b5a7cfc776
commit 8a35a5b791
9 changed files with 245 additions and 10 deletions
+9
View File
@@ -1,9 +1,14 @@
<div align="center">
<img src="frontend/public/favicon.svg" alt="Nucleus" width="64" />
# Nucleus - Vulnerability Scan Orchestrator # Nucleus - Vulnerability Scan Orchestrator
Nucleus is a single, self-contained Go binary that acts as an orchestration manager for scheduled [Nuclei](https://github.com/projectdiscovery/nuclei) vulnerability scans. Nucleus is a single, self-contained Go binary that acts as an orchestration manager for scheduled [Nuclei](https://github.com/projectdiscovery/nuclei) vulnerability scans.
It serves an embedded Vue 3 SPA web dashboard, reads/writes scan states to a local SQLite database, and automatically dispatches beautifully formatted HTML email reports via SMTP when scans discover vulnerabilities. It serves an embedded Vue 3 SPA web dashboard, reads/writes scan states to a local SQLite database, and automatically dispatches beautifully formatted HTML email reports via SMTP when scans discover vulnerabilities.
</div>
## Prerequisites ## Prerequisites
- **Go 1.22+** - **Go 1.22+**
- **Node.js & npm** (for building the frontend) - **Node.js & npm** (for building the frontend)
@@ -65,6 +70,10 @@ Environment="SMTP_TO=admin@example.com"
# Environment="SMTP_USER=username" # Environment="SMTP_USER=username"
# Environment="SMTP_PASS=password" # Environment="SMTP_PASS=password"
# Optional: Add basic authentication to protect the web dashboard
# Environment="WEB_USER=admin"
# Environment="WEB_PASS=supersecret"
Restart=always Restart=always
RestartSec=5 RestartSec=5
+13 -1
View File
@@ -6,9 +6,10 @@
<img src="/favicon.svg" alt="Nucleus Logo" class="w-8 h-8 drop-shadow-md" /> <img src="/favicon.svg" alt="Nucleus Logo" class="w-8 h-8 drop-shadow-md" />
<h1 class="text-xl font-bold text-heading">Nucleus</h1> <h1 class="text-xl font-bold text-heading">Nucleus</h1>
</div> </div>
<nav class="flex space-x-4"> <nav class="flex space-x-4 items-center" v-if="$route.path !== '/login'">
<router-link to="/" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Targets</router-link> <router-link to="/" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Targets</router-link>
<router-link to="/scans" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Scans History</router-link> <router-link to="/scans" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Scans History</router-link>
<button @click="logout" class="ml-4 px-3 py-1.5 border border-border-subtle rounded-md text-sm font-medium text-body hover:text-red-400 hover:border-red-400/50 transition-colors hover:cursor-pointer">Logout</button>
</nav> </nav>
</div> </div>
</header> </header>
@@ -20,3 +21,14 @@
</main> </main>
</div> </div>
</template> </template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
router.push('/login')
}
</script>
@@ -69,6 +69,7 @@ const findings = ref([])
const loadFindings = async () => { const loadFindings = async () => {
try { try {
const res = await fetch(`/api/scans/${props.id}/findings`) const res = await fetch(`/api/scans/${props.id}/findings`)
if (res.status === 401) { window.location.href = '/login'; return }
findings.value = await res.json() || [] findings.value = await res.json() || []
} catch (e) { } catch (e) {
console.error(e) console.error(e)
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="flex items-center justify-center min-h-[70vh]">
<div class="w-full max-w-md bg-surface p-8 rounded-xl shadow-lg border border-border-subtle backdrop-blur-sm">
<div class="flex justify-center mb-6">
<img src="/favicon.svg" alt="Nucleus Logo" class="w-16 h-16 drop-shadow-md" />
</div>
<h2 class="text-2xl font-bold text-center text-heading mb-6">Sign In to Nucleus</h2>
<form @submit.prevent="login" class="space-y-4">
<div v-if="error" class="p-3 bg-red-500/20 border border-red-500/50 text-red-400 rounded-lg text-sm text-center">
{{ error }}
</div>
<div>
<label class="block text-sm font-medium text-body mb-1">Username</label>
<input v-model="username" type="text" required class="w-full bg-bg border border-border-subtle rounded-lg px-4 py-2 text-heading focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition-all">
</div>
<div>
<label class="block text-sm font-medium text-body mb-1">Password</label>
<input v-model="password" type="password" required class="w-full bg-bg border border-border-subtle rounded-lg px-4 py-2 text-heading focus:ring-2 focus:ring-accent focus:border-transparent outline-none transition-all">
</div>
<button type="submit" class="w-full bg-accent hover:bg-accent/80 text-bg font-bold py-2 px-4 rounded-lg shadow-md hover:shadow-lg transition-all hover:cursor-pointer mt-2">
Login
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const login = async () => {
error.value = ''
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value })
})
if (res.ok) {
router.push('/')
} else {
error.value = 'Invalid username or password'
}
} catch (e) {
error.value = 'Network error'
}
}
</script>
+1
View File
@@ -75,6 +75,7 @@ let interval = null
const loadScans = async () => { const loadScans = async () => {
try { try {
const res = await fetch('/api/scans') const res = await fetch('/api/scans')
if (res.status === 401) { window.location.href = '/login'; return }
scans.value = await res.json() || [] scans.value = await res.json() || []
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -84,6 +84,7 @@ const form = ref({ name: '', address: '', schedule: 'manual', customSchedule: ''
const loadTargets = async () => { const loadTargets = async () => {
try { try {
const res = await fetch('/api/targets') const res = await fetch('/api/targets')
if (res.status === 401) { window.location.href = '/login'; return }
targets.value = await res.json() || [] targets.value = await res.json() || []
} catch (e) { } catch (e) {
console.error(e) console.error(e)
+23 -3
View File
@@ -2,14 +2,34 @@ import { createRouter, createWebHistory } from 'vue-router'
import TargetManager from './components/TargetManager.vue' import TargetManager from './components/TargetManager.vue'
import ScansHistory from './components/ScansHistory.vue' import ScansHistory from './components/ScansHistory.vue'
import FindingsInspector from './components/FindingsInspector.vue' import FindingsInspector from './components/FindingsInspector.vue'
import Login from './components/Login.vue'
const routes = [ const routes = [
{ path: '/', component: TargetManager }, { path: '/login', component: Login },
{ path: '/scans', component: ScansHistory }, { path: '/', component: TargetManager, meta: { requiresAuth: true } },
{ path: '/scans/:id', component: FindingsInspector, props: true } { path: '/scans', component: ScansHistory, meta: { requiresAuth: true } },
{ path: '/scans/:id', component: FindingsInspector, props: true, meta: { requiresAuth: true } }
] ]
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes
}) })
router.beforeEach(async (to, from, next) => {
if (to.meta.requiresAuth) {
try {
const res = await fetch('/api/auth/status')
const status = await res.json()
if (status.auth_required && !status.authenticated) {
next('/login')
} else {
next()
}
} catch (e) {
next('/login')
}
} else {
next()
}
})
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"sync"
"time"
)
var (
currentSession string
sessionMu sync.Mutex
)
func generateSession() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user := os.Getenv("WEB_USER")
pass := os.Getenv("WEB_PASS")
if user == "" || pass == "" {
next(w, r)
return
}
cookie, err := r.Cookie("nucleus_session")
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
sessionMu.Lock()
valid := currentSession != "" && currentSession == cookie.Value
sessionMu.Unlock()
if !valid {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func LoginHandler(w http.ResponseWriter, r *http.Request) {
user := os.Getenv("WEB_USER")
pass := os.Getenv("WEB_PASS")
if user == "" || pass == "" {
w.WriteHeader(http.StatusOK)
return
}
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if creds.Username != user || creds.Password != pass {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
sessionMu.Lock()
currentSession = generateSession()
token := currentSession
sessionMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "nucleus_session",
Value: token,
Path: "/",
Expires: time.Now().Add(24 * time.Hour),
HttpOnly: true,
})
w.WriteHeader(http.StatusOK)
}
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
sessionMu.Lock()
currentSession = ""
sessionMu.Unlock()
http.SetCookie(w, &http.Cookie{
Name: "nucleus_session",
Value: "",
Path: "/",
Expires: time.Now().Add(-1 * time.Hour),
HttpOnly: true,
})
w.WriteHeader(http.StatusOK)
}
func StatusHandler(w http.ResponseWriter, r *http.Request) {
user := os.Getenv("WEB_USER")
pass := os.Getenv("WEB_PASS")
if user == "" || pass == "" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"auth_required": false, "authenticated": true}`))
return
}
w.Header().Set("Content-Type", "application/json")
cookie, err := r.Cookie("nucleus_session")
if err != nil {
w.Write([]byte(`{"auth_required": true, "authenticated": false}`))
return
}
sessionMu.Lock()
valid := currentSession != "" && currentSession == cookie.Value
sessionMu.Unlock()
if valid {
w.Write([]byte(`{"auth_required": true, "authenticated": true}`))
} else {
w.Write([]byte(`{"auth_required": true, "authenticated": false}`))
}
}
+10 -6
View File
@@ -12,12 +12,16 @@ import (
) )
func RegisterRoutes(mux *http.ServeMux) { func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/targets", getTargets) mux.HandleFunc("POST /api/auth/login", LoginHandler)
mux.HandleFunc("POST /api/targets", createTarget) mux.HandleFunc("POST /api/auth/logout", LogoutHandler)
mux.HandleFunc("DELETE /api/targets/{id}", deleteTarget) mux.HandleFunc("GET /api/auth/status", StatusHandler)
mux.HandleFunc("POST /api/targets/{id}/scan", triggerScan)
mux.HandleFunc("GET /api/scans", getScans) mux.HandleFunc("GET /api/targets", AuthMiddleware(getTargets))
mux.HandleFunc("GET /api/scans/{id}/findings", getFindings) mux.HandleFunc("POST /api/targets", AuthMiddleware(createTarget))
mux.HandleFunc("DELETE /api/targets/{id}", AuthMiddleware(deleteTarget))
mux.HandleFunc("POST /api/targets/{id}/scan", AuthMiddleware(triggerScan))
mux.HandleFunc("GET /api/scans", AuthMiddleware(getScans))
mux.HandleFunc("GET /api/scans/{id}/findings", AuthMiddleware(getFindings))
} }
func getTargets(w http.ResponseWriter, r *http.Request) { func getTargets(w http.ResponseWriter, r *http.Request) {