NEXUS-1: Initial commit of Nexus

This commit is contained in:
Blake Ridgway
2026-07-14 05:53:25 -05:00
commit 16fe2bf928
22 changed files with 1739 additions and 0 deletions

126
internal/auth/jwt.go Normal file
View File

@@ -0,0 +1,126 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"git.arcline.it/ArclineIT/nexus/internal/config"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Claims represents the custom JWT claims for Nexus.
type Claims struct {
jwt.RegisteredClaims
Email string `json:"email"`
TokenType string `json:"token_type"` // "access" or "refresh"
}
// TokenPair holds both access and refresh tokens.
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` // seconds until access token expires
TokenType string `json:"token_type"` // always "Bearer"
}
// GenerateTokenPair creates a new access + refresh token pair for a user.
func GenerateTokenPair(cfg *config.Config, userID uuid.UUID, email string) (*TokenPair, error) {
now := time.Now()
accessClaims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: cfg.Auth.Issuer,
Subject: userID.String(),
Audience: jwt.ClaimStrings{cfg.Auth.Audience},
ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Auth.AccessTokenDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: uuid.New().String(),
},
Email: email,
TokenType: "access",
}
accessToken, err := signToken(cfg, accessClaims)
if err != nil {
return nil, fmt.Errorf("signing access token: %w", err)
}
refreshClaims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: cfg.Auth.Issuer,
Subject: userID.String(),
Audience: jwt.ClaimStrings{cfg.Auth.Audience},
ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Auth.RefreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: uuid.New().String(),
},
Email: email,
TokenType: "refresh",
}
refreshToken, err := signToken(cfg, refreshClaims)
if err != nil {
return nil, fmt.Errorf("signing refresh token: %w", err)
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(cfg.Auth.AccessTokenDuration.Seconds()),
TokenType: "Bearer",
}, nil
}
// ValidateToken parses and validates a JWT token string.
func ValidateToken(cfg *config.Config, tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(cfg.Auth.JWTSecret), nil
},
)
if err != nil {
return nil, fmt.Errorf("parsing token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
// HashToken returns the SHA-256 hash of a token string (for storage).
func HashToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
// GenerateSecureToken creates a cryptographically random token.
func GenerateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generating random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
func signToken(cfg *config.Config, claims Claims) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(cfg.Auth.JWTSecret))
if err != nil {
return "", fmt.Errorf("signing token: %w", err)
}
return signed, nil
}

96
internal/config/config.go Normal file
View File

@@ -0,0 +1,96 @@
package config
import (
"fmt"
"os"
"time"
)
// Config holds all configuration for the Nexus Control Panel.
type Config struct {
Server ServerConfig
Database DatabaseConfig
Auth AuthConfig
Logging LoggingConfig
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Host string
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
// DatabaseConfig holds database connection settings.
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
// DSN returns the PostgreSQL connection string.
func (d DatabaseConfig) DSN() string {
return fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.Password, d.Name, d.SSLMode,
)
}
// AuthConfig holds authentication settings.
type AuthConfig struct {
JWTSecret string
AccessTokenDuration time.Duration
RefreshTokenDuration time.Duration
Issuer string
Audience string
}
// LoggingConfig holds logging settings.
type LoggingConfig struct {
Level string
Format string // "json" or "text"
}
// Load reads configuration from environment variables with sensible defaults.
func Load() *Config {
return &Config{
Server: ServerConfig{
Host: getEnv("NEXUS_HOST", "0.0.0.0"),
Port: getEnv("NEXUS_PORT", "8080"),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
ShutdownTimeout: 15 * time.Second,
},
Database: DatabaseConfig{
Host: getEnv("NEXUS_DB_HOST", "localhost"),
Port: getEnv("NEXUS_DB_PORT", "5432"),
User: getEnv("NEXUS_DB_USER", "nexus"),
Password: getEnv("NEXUS_DB_PASSWORD", ""),
Name: getEnv("NEXUS_DB_NAME", "nexus"),
SSLMode: getEnv("NEXUS_DB_SSLMODE", "disable"),
},
Auth: AuthConfig{
JWTSecret: getEnv("NEXUS_JWT_SECRET", "change-me-in-production"),
AccessTokenDuration: 15 * time.Minute,
RefreshTokenDuration: 7 * 24 * time.Hour,
Issuer: getEnv("NEXUS_AUTH_ISSUER", "nexus.arcline.it"),
Audience: getEnv("NEXUS_AUTH_AUDIENCE", "nexus.arcline.it"),
},
Logging: LoggingConfig{
Level: getEnv("NEXUS_LOG_LEVEL", "info"),
Format: getEnv("NEXUS_LOG_FORMAT", "json"),
},
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

117
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,117 @@
package handler
import (
"encoding/json"
"net/http"
"git.arcline.it/ArclineIT/nexus/internal/auth"
"git.arcline.it/ArclineIT/nexus/internal/config"
"github.com/google/uuid"
)
// AuthHandler handles authentication endpoints.
type AuthHandler struct {
cfg *config.Config
}
// NewAuthHandler creates a new AuthHandler.
func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{cfg: cfg}
}
// LoginRequest is the expected body for POST /auth/login.
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Login handles POST /auth/login.
// TODO: validate credentials against database.
func (h *AuthHandler) Login() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "email and password are required")
return
}
// TODO: verify password, look up user from DB
// For now, generate a token pair with a placeholder user ID
userID := uuid.New()
tokens, err := auth.GenerateTokenPair(h.cfg, userID, req.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate tokens")
return
}
respondJSON(w, http.StatusOK, tokens)
}
}
// RefreshRequest is the expected body for POST /auth/refresh.
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Refresh handles POST /auth/refresh.
func (h *AuthHandler) Refresh() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.RefreshToken == "" {
respondError(w, http.StatusBadRequest, "refresh_token is required")
return
}
// Validate the refresh token
claims, err := auth.ValidateToken(h.cfg, req.RefreshToken)
if err != nil {
respondError(w, http.StatusUnauthorized, "invalid or expired refresh token")
return
}
if claims.TokenType != "refresh" {
respondError(w, http.StatusUnauthorized, "token is not a refresh token")
return
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
respondError(w, http.StatusInternalServerError, "invalid user ID in token")
return
}
tokens, err := auth.GenerateTokenPair(h.cfg, userID, claims.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate tokens")
return
}
respondJSON(w, http.StatusOK, tokens)
}
}
// Me handles GET /auth/me — returns the authenticated user's info.
func (h *AuthHandler) Me() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// The Authenticate middleware has already injected user info into context.
userID := r.Context().Value("user_id")
userEmail := r.Context().Value("user_email")
respondJSON(w, http.StatusOK, map[string]any{
"id": userID,
"email": userEmail,
})
}
}

View File

@@ -0,0 +1,13 @@
package handler
import "net/http"
// Ready handles GET /health and GET /ready.
func Ready() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{
"status": "healthy",
})
}
}

View File

@@ -0,0 +1,23 @@
package handler
import (
"encoding/json"
"net/http"
)
// respondJSON writes a JSON response with the given status code.
func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, `{"error":"failed to encode response"}`, http.StatusInternalServerError)
}
}
}
// respondError writes a JSON error response.
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}

View File

@@ -0,0 +1,43 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Control Panel</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>⚡</text></svg>">
<style>
@keyframes fade-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="text-4xl mb-2"></div>
<h1 class="text-2xl font-bold text-white tracking-tight">Nexus</h1>
<p class="text-slate-400 text-sm mt-1">Control Panel</p>
</div>
<!-- Card -->
<div class="bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl p-8">
{{template "content" .}}
</div>
<!-- Footer -->
<p class="text-center text-slate-500 text-xs mt-6">
Arcline Platform &middot; Single Sign-On
</p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,58 @@
{{define "content"}}
<div id="auth-form">
<h2 class="text-xl font-semibold text-white mb-2">Reset your password</h2>
<p class="text-slate-400 text-sm mb-6">Enter your email address and we'll send you a link to reset your password.</p>
{{if .Success}}
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Success}}
</div>
<p class="text-center text-slate-400 text-sm mt-6">
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Back to sign in</a>
</p>
{{else}}
{{if .Error}}
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Error}}
</div>
{{end}}
<form
hx-post="/forgot-password"
hx-target="#auth-form"
hx-swap="outerHTML"
hx-disabled-elt="button[type=submit], input"
class="space-y-5"
>
<div>
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
<input
type="email"
id="email"
name="email"
value="{{.Email}}"
required
autofocus
autocomplete="email"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="you@example.com"
>
</div>
<button
type="submit"
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="htmx-indicator">Send reset link</span>
<span class="htmx-request hidden">Sending...</span>
</button>
</form>
<p class="text-center text-slate-400 text-sm mt-6">
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Back to sign in</a>
</p>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,79 @@
{{define "content"}}
<div id="auth-form">
<h2 class="text-xl font-semibold text-white mb-6">Sign in to your account</h2>
{{if .Error}}
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Error}}
</div>
{{end}}
{{if .Success}}
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Success}}
</div>
{{end}}
<form
hx-post="/login"
hx-target="#auth-form"
hx-swap="outerHTML"
hx-disabled-elt="button[type=submit], input"
class="space-y-5"
>
<div>
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
<input
type="email"
id="email"
name="email"
value="{{.Email}}"
required
autofocus
autocomplete="email"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="you@example.com"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="••••••••"
>
</div>
<div class="flex items-center justify-between text-sm">
<label class="flex items-center text-slate-400">
<input
type="checkbox"
name="remember"
class="rounded border-slate-600 bg-slate-700 text-indigo-500 focus:ring-indigo-500 mr-2"
>
Remember me
</label>
<a href="/forgot-password" class="text-indigo-400 hover:text-indigo-300 transition-colors">Forgot password?</a>
</div>
<button
type="submit"
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="htmx-indicator">Sign in</span>
<span class="htmx-request hidden">Signing in...</span>
</button>
</form>
<p class="text-center text-slate-400 text-sm mt-6">
Don't have an account?
<a href="/signup" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Create one</a>
</p>
</div>
{{end}}

View File

@@ -0,0 +1,68 @@
{{define "content"}}
<div id="auth-form">
<h2 class="text-xl font-semibold text-white mb-6">Set a new password</h2>
{{if .Error}}
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Error}}
</div>
{{end}}
{{if .Success}}
<div class="bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Success}}
</div>
<p class="text-center text-slate-400 text-sm mt-6">
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Sign in with your new password</a>
</p>
{{else}}
<form
hx-post="/reset-password"
hx-target="#auth-form"
hx-swap="outerHTML"
hx-disabled-elt="button[type=submit], input"
class="space-y-5"
>
<input type="hidden" name="token" value="{{.Token}}">
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">New password</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
autofocus
autocomplete="new-password"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="At least 8 characters"
>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-slate-300 mb-1.5">Confirm new password</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="Repeat your password"
>
</div>
<button
type="submit"
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="htmx-indicator">Reset password</span>
<span class="htmx-request hidden">Resetting...</span>
</button>
</form>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,90 @@
{{define "content"}}
<div id="auth-form">
<h2 class="text-xl font-semibold text-white mb-6">Create your account</h2>
{{if .Error}}
<div class="bg-red-500/10 border border-red-500/30 text-red-400 rounded-lg px-4 py-3 mb-6 text-sm animate-fade-in" role="alert">
{{.Error}}
</div>
{{end}}
<form
hx-post="/signup"
hx-target="#auth-form"
hx-swap="outerHTML"
hx-disabled-elt="button[type=submit], input"
class="space-y-5"
>
<div>
<label for="display_name" class="block text-sm font-medium text-slate-300 mb-1.5">Full name</label>
<input
type="text"
id="display_name"
name="display_name"
value="{{.DisplayName}}"
required
autofocus
autocomplete="name"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="Jane Smith"
>
</div>
<div>
<label for="email" class="block text-sm font-medium text-slate-300 mb-1.5">Email address</label>
<input
type="email"
id="email"
name="email"
value="{{.Email}}"
required
autocomplete="email"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="you@example.com"
>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-300 mb-1.5">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="At least 8 characters"
>
</div>
<div>
<label for="password_confirm" class="block text-sm font-medium text-slate-300 mb-1.5">Confirm password</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
placeholder="Repeat your password"
>
</div>
<button
type="submit"
class="w-full py-2.5 px-4 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-slate-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="htmx-indicator">Create account</span>
<span class="htmx-request hidden">Creating account...</span>
</button>
</form>
<p class="text-center text-slate-400 text-sm mt-6">
Already have an account?
<a href="/login" class="text-indigo-400 hover:text-indigo-300 font-medium transition-colors">Sign in</a>
</p>
</div>
{{end}}

390
internal/handler/webui.go Normal file
View File

@@ -0,0 +1,390 @@
package handler
import (
"embed"
"html/template"
"net/http"
"strings"
"git.arcline.it/ArclineIT/nexus/internal/auth"
"git.arcline.it/ArclineIT/nexus/internal/config"
"github.com/google/uuid"
)
//go:embed templates
var templateFS embed.FS
// UITemplateData holds data passed to UI templates.
type UITemplateData struct {
Error string
Success string
Email string
DisplayName string
Token string
}
// UIHandler serves the web UI pages and handles HTMX form submissions.
type UIHandler struct {
cfg *config.Config
templates *template.Template
}
// NewUIHandler creates a new UIHandler.
func NewUIHandler(cfg *config.Config) (*UIHandler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
}
return &UIHandler{cfg: cfg, templates: tmpl}, nil
}
// ServePage renders a full page (base + named template).
func (h *UIHandler) ServePage(w http.ResponseWriter, data any, templateName string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "failed to render page", http.StatusInternalServerError)
}
}
// ServeFragment renders only the content fragment (for HTMX swaps).
func (h *UIHandler) ServeFragment(w http.ResponseWriter, data any, templateName string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, templateName, data); err != nil {
http.Error(w, "failed to render fragment", http.StatusInternalServerError)
}
}
// Root redirects / to /login.
func (h *UIHandler) Root() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
// ---------------------------------------------------------------------------
// Login
// ---------------------------------------------------------------------------
// LoginPage serves GET /login.
func (h *UIHandler) LoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &UITemplateData{}
// Check for success message from signup
if msg := r.URL.Query().Get("registered"); msg == "1" {
data.Success = "Account created successfully. Please sign in."
}
if msg := r.URL.Query().Get("reset"); msg == "1" {
data.Success = "Password reset successfully. Please sign in."
}
h.ServePage(w, data, "login")
}
}
// LoginSubmit handles POST /login (HTMX form submission).
func (h *UIHandler) LoginSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
data := &UITemplateData{Email: email}
if email == "" || password == "" {
data.Error = "Email and password are required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "login")
return
}
// TODO: validate credentials against database
// For now, accept any credentials and generate tokens
userID := uuid.New()
tokens, err := auth.GenerateTokenPair(h.cfg, userID, email)
if err != nil {
data.Error = "Something went wrong. Please try again."
w.WriteHeader(http.StatusInternalServerError)
h.ServeFragment(w, data, "login")
return
}
// Set access token as a cookie
http.SetCookie(w, &http.Cookie{
Name: "nexus_access_token",
Value: tokens.AccessToken,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: int(h.cfg.Auth.AccessTokenDuration.Seconds()),
})
// Set refresh token as a cookie
http.SetCookie(w, &http.Cookie{
Name: "nexus_refresh_token",
Value: tokens.RefreshToken,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteLaxMode,
MaxAge: int(h.cfg.Auth.RefreshTokenDuration.Seconds()),
})
// Tell HTMX to redirect to the dashboard
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
}
// ---------------------------------------------------------------------------
// Signup
// ---------------------------------------------------------------------------
// SignupPage serves GET /signup.
func (h *UIHandler) SignupPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.ServePage(w, &UITemplateData{}, "signup")
}
}
// SignupSubmit handles POST /signup (HTMX form submission).
func (h *UIHandler) SignupSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
displayName := strings.TrimSpace(r.FormValue("display_name"))
email := strings.TrimSpace(r.FormValue("email"))
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
data := &UITemplateData{
Email: email,
DisplayName: displayName,
}
// Validate
if displayName == "" {
data.Error = "Full name is required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "signup")
return
}
if email == "" {
data.Error = "Email address is required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "signup")
return
}
if password == "" {
data.Error = "Password is required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "signup")
return
}
if len(password) < 8 {
data.Error = "Password must be at least 8 characters."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "signup")
return
}
if password != passwordConfirm {
data.Error = "Passwords do not match."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "signup")
return
}
// TODO: check if email already exists in database
// TODO: hash password with bcrypt and store user
// Redirect to login with success message
w.Header().Set("HX-Redirect", "/login?registered=1")
w.WriteHeader(http.StatusOK)
}
}
// ---------------------------------------------------------------------------
// Forgot Password
// ---------------------------------------------------------------------------
// ForgotPasswordPage serves GET /forgot-password.
func (h *UIHandler) ForgotPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.ServePage(w, &UITemplateData{}, "forgot-password")
}
}
// ForgotPasswordSubmit handles POST /forgot-password (HTMX form submission).
func (h *UIHandler) ForgotPasswordSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(r.FormValue("email"))
data := &UITemplateData{Email: email}
if email == "" {
data.Error = "Email address is required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "forgot-password")
return
}
// TODO: look up user in database, generate reset token, send email
// For now, always show success to prevent email enumeration
data.Success = "If an account exists for " + email + ", you will receive a password reset link shortly."
h.ServeFragment(w, data, "forgot-password")
}
}
// ---------------------------------------------------------------------------
// Reset Password
// ---------------------------------------------------------------------------
// ResetPasswordPage serves GET /reset-password.
func (h *UIHandler) ResetPasswordPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
data := &UITemplateData{Token: token}
if token == "" {
data.Error = "Invalid or missing reset token."
h.ServePage(w, data, "reset-password")
return
}
// TODO: validate reset token exists and hasn't expired
h.ServePage(w, data, "reset-password")
}
}
// ResetPasswordSubmit handles POST /reset-password (HTMX form submission).
func (h *UIHandler) ResetPasswordSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.FormValue("token")
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
data := &UITemplateData{Token: token}
if token == "" {
data.Error = "Invalid or missing reset token."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "reset-password")
return
}
if password == "" {
data.Error = "Password is required."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "reset-password")
return
}
if len(password) < 8 {
data.Error = "Password must be at least 8 characters."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "reset-password")
return
}
if password != passwordConfirm {
data.Error = "Passwords do not match."
w.WriteHeader(http.StatusBadRequest)
h.ServeFragment(w, data, "reset-password")
return
}
// TODO: validate reset token, look up user, hash new password, save
// Redirect to login with success message
w.Header().Set("HX-Redirect", "/login?reset=1")
w.WriteHeader(http.StatusOK)
}
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
// DashboardPage serves GET /dashboard — simple placeholder for now.
func (h *UIHandler) DashboardPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := r.Context().Value("user_id").(string)
userEmail, _ := r.Context().Value("user_email").(string)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Simple inline dashboard — can be moved to a template later
w.Write([]byte(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='28' font-size='28'>⚡</text></svg>">
</head>
<body class="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<nav class="border-b border-slate-700/50 bg-slate-800/50 backdrop-blur-sm">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">⚡</span>
<span class="text-white font-semibold text-lg">Nexus</span>
</div>
<div class="flex items-center gap-4">
<span class="text-slate-400 text-sm">` + userEmail + `</span>
<form hx-post="/logout" hx-target="body" class="inline">
<button type="submit" class="text-slate-400 hover:text-white text-sm transition-colors">Sign out</button>
</form>
</div>
</div>
</nav>
<main class="max-w-6xl mx-auto px-4 py-12">
<div class="bg-slate-800/50 backdrop-blur-sm border border-slate-700/50 rounded-xl shadow-2xl p-8">
<h2 class="text-2xl font-bold text-white mb-2">Welcome back</h2>
<p class="text-slate-400 mb-6">You are signed in as <span class="text-white font-medium">` + userEmail + `</span>.</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50">
<div class="text-slate-400 text-sm mb-1">User ID</div>
<div class="text-white font-mono text-sm break-all">` + userID + `</div>
</div>
<div class="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50">
<div class="text-slate-400 text-sm mb-1">Connected Apps</div>
<div class="text-white text-sm">None yet</div>
</div>
</div>
</div>
</main>
</body>
</html>`))
}
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
// Logout handles POST /logout — clears auth cookies and redirects to login.
func (h *UIHandler) Logout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Clear cookies
http.SetCookie(w, &http.Cookie{
Name: "nexus_access_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "nexus_refresh_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusOK)
}
}

View File

@@ -0,0 +1,186 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"git.arcline.it/ArclineIT/nexus/internal/auth"
"git.arcline.it/ArclineIT/nexus/internal/config"
"github.com/google/uuid"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UserEmailKey contextKey = "user_email"
RequestIDKey contextKey = "request_id"
)
// RequestID injects a unique ID into every request for tracing.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.New().String()
}
ctx := context.WithValue(r.Context(), RequestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logger logs every HTTP request with structured fields.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", wrapped.statusCode),
slog.Duration("duration", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
)
})
}
}
// Recoverer catches panics and returns a 500 response.
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic recovered",
slog.Any("panic", rec),
slog.String("path", r.URL.Path),
)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// CORS sets permissive CORS headers for development.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// Authenticate validates the JWT Bearer token and injects user info into context.
func Authenticate(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
if token == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
claims, err := auth.ValidateToken(cfg, token)
if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, claims.Subject)
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// WebAuth validates the JWT from a cookie or Bearer header and injects user
// info into context. If the token is missing or invalid, it redirects to
// /login instead of returning a JSON error — suitable for browser-based flows.
func WebAuth(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := ""
// Check cookie first, then fall back to Bearer header
if cookie, err := r.Cookie("nexus_access_token"); err == nil && cookie.Value != "" {
token = cookie.Value
} else {
token = extractBearerToken(r)
}
if token == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
claims, err := auth.ValidateToken(cfg, token)
if err != nil {
// Clear invalid cookies
clearAuthCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, claims.Subject)
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// extractBearerToken pulls a Bearer token from the Authorization header.
func extractBearerToken(r *http.Request) string {
header := r.Header.Get("Authorization")
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return parts[1]
}
// clearAuthCookies removes Nexus auth cookies from the response.
func clearAuthCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "nexus_access_token",
Value: "",
Path: "/",
MaxAge: -1,
})
http.SetCookie(w, &http.Cookie{
Name: "nexus_refresh_token",
Value: "",
Path: "/",
MaxAge: -1,
})
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

View File

@@ -0,0 +1,49 @@
package models
import (
"time"
"github.com/google/uuid"
)
// Session represents an authenticated user session.
type Session struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
TokenHash string `json:"-"`
RefreshToken string `json:"-"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ExpiresAt time.Time `json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ConnectedApp represents an external tool integrated via SSO.
// These are the "spokes" of the Nexus hub.
type ConnectedApp struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description,omitempty"`
HomepageURL string `json:"homepage_url"`
SSOCallbackURL string `json:"sso_callback_url"`
ClientID string `json:"client_id"`
ClientSecretHash string `json:"-"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AuditLog records a security-relevant event for compliance and debugging.
type AuditLog struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id,omitempty"`
Action string `json:"action"`
Resource string `json:"resource"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Metadata string `json:"metadata,omitempty"` // JSON-encoded extra context
CreatedAt time.Time `json:"created_at"`
}

56
internal/models/user.go Normal file
View File

@@ -0,0 +1,56 @@
package models
import (
"time"
"github.com/google/uuid"
)
// User represents an identity in the Nexus Control Panel.
// It is the single source of truth for all connected tools.
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"` // never serialized
MFAEnabled bool `json:"mfa_enabled"`
MFASecret string `json:"-"` // never serialized
EmailVerified bool `json:"email_verified"`
Active bool `json:"active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Role represents a named set of permissions.
type Role struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserRole associates a user with a role.
type UserRole struct {
UserID uuid.UUID `json:"user_id"`
RoleID uuid.UUID `json:"role_id"`
CreatedAt time.Time `json:"created_at"`
}
// Permission defines a granular action that can be allowed or denied.
type Permission struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Resource string `json:"resource"`
Action string `json:"action"`
Description string `json:"description,omitempty"`
}
// RolePermission associates a role with a permission.
type RolePermission struct {
RoleID uuid.UUID `json:"role_id"`
PermissionID uuid.UUID `json:"permission_id"`
CreatedAt time.Time `json:"created_at"`
}