Skip to content

Commit 6f121b7

Browse files
committed
feat: base-domain auth restriction, email templates, structured logging, and UI improvements
- Restrict public auth endpoints to base domain only (baseOnlyMiddleware) - Add HTML email templates (login alert, MFA code, password reset, verify email) - Add structured logging (log/slog) across MFA, auth, email, and password reset flows - Add logger package with configurable level/format and OTel-ready FromContext() - Add OpenTelemetry metrics middleware with Prometheus /metrics endpoint - Add health check endpoints (/healthz, /readyz) on top-level mux - Add Valkey (Redis) cache client for session management - Add projects page, org switcher, token management dialog in UI - Add auth page redirect for subdomain auth flows - Fix SMTP_HOST for Docker Compose (use service name, not localhost) - Add CWE/CVE display and CVSS badge to finding detail page
1 parent 58f9c8e commit 6f121b7

35 files changed

Lines changed: 1564 additions & 173 deletions

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,17 @@ services:
6464
AEGIS_BIND: "0.0.0.0"
6565
AEGIS_PORT: "8080"
6666
AEGIS_ALLOWED_ORIGINS: ${AEGIS_ALLOWED_ORIGINS:-http://localhost:8080}
67+
AEGIS_BASE_DOMAIN: ${AEGIS_BASE_DOMAIN:-}
6768
# SMTP — defaults to MailDev for local dev
6869
SMTP_HOST: ${SMTP_HOST:-maildev}
6970
SMTP_PORT: ${SMTP_PORT:-1025}
7071
SMTP_USERNAME: ${SMTP_USERNAME:-}
7172
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
7273
SMTP_FROM: ${SMTP_FROM:-noreply@aegis.local}
7374
SMTP_TLS: ${SMTP_TLS:-false}
75+
# Logging
76+
LOG_LEVEL: ${LOG_LEVEL:-info}
77+
LOG_FORMAT: ${LOG_FORMAT:-text}
7478
depends_on:
7579
postgres:
7680
condition: service_healthy

server/cmd/aegis-server/main.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ package main
1313
import (
1414
"context"
1515
"fmt"
16-
"log"
16+
"log/slog"
1717
"net/http"
1818
"os"
1919
"os/signal"
@@ -25,12 +25,14 @@ import (
2525
"github.com/pixelvide/aegis/server/internal/cache"
2626
"github.com/pixelvide/aegis/server/internal/config"
2727
"github.com/pixelvide/aegis/server/internal/email"
28+
"github.com/pixelvide/aegis/server/internal/logger"
2829
"github.com/pixelvide/aegis/server/internal/store"
2930
)
3031

3132
func main() {
3233
if err := run(); err != nil {
33-
log.Fatalf("aegis-server: %v", err)
34+
slog.Error("server fatal error", "error", err)
35+
os.Exit(1)
3436
}
3537
}
3638

@@ -40,6 +42,10 @@ func run() error {
4042
return fmt.Errorf("config: %w", err)
4143
}
4244

45+
// Initialize structured logger first — all subsequent code uses slog
46+
logger.Init(cfg.LogLevel, cfg.LogFormat)
47+
slog.Info("configuration loaded", "log_level", cfg.LogLevel, "log_format", cfg.LogFormat)
48+
4349
// Open PostgreSQL connection pool
4450
if cfg.DatabaseURL == "" {
4551
return fmt.Errorf("DATABASE_URL is required (e.g. postgres://aegis:aegis@localhost:5432/aegis?sslmode=disable)")
@@ -50,21 +56,21 @@ func run() error {
5056
return fmt.Errorf("postgres: %w", err)
5157
}
5258
defer db.Close()
53-
log.Printf("📦 Connected to PostgreSQL")
59+
slog.Info("connected to PostgreSQL", "component", "database")
5460

5561
// Initialize common schema (users, orgs, memberships)
5662
common, err := store.NewCommonStore(db)
5763
if err != nil {
5864
return fmt.Errorf("common store: %w", err)
5965
}
60-
log.Printf("📋 Common schema ready")
66+
slog.Info("common schema ready", "component", "store")
6167

6268
// Initialize auth service (JWT + password hashing)
6369
authSvc, err := auth.New()
6470
if err != nil {
6571
return fmt.Errorf("auth: %w", err)
6672
}
67-
log.Printf("🔐 Auth service ready")
73+
slog.Info("auth service ready", "component", "auth")
6874

6975
// Initialize email service (SMTP)
7076
emailSvc := email.New(cfg.SMTP)
@@ -74,20 +80,21 @@ func run() error {
7480
if cfg.ValkeyURL != "" {
7581
cacheClient, err = cache.New(cfg.ValkeyURL)
7682
if err != nil {
77-
log.Printf("⚠️ Valkey unavailable (%v) — falling back to DB-only session checks", err)
83+
slog.Warn("valkey unavailable, falling back to DB-only session checks",
84+
"error", err, "component", "cache")
7885
cacheClient = nil
7986
} else {
8087
defer cacheClient.Close()
81-
log.Printf("⚡ Valkey connected (%s)", cfg.ValkeyURL)
88+
slog.Info("valkey connected", "addr", cfg.ValkeyURL, "component", "cache")
8289
}
8390
} else {
84-
log.Printf("⚡ Valkey not configuredusing DB-only session checks")
91+
slog.Info("valkey not configured, using DB-only session checks", "component", "cache")
8592
}
8693

8794
// Initialize OTel metrics with Prometheus exporter
8895
metrics, metricsHandler, metricsShutdown := api.InitMetrics(db)
8996
defer metricsShutdown(context.Background())
90-
log.Printf("📊 Metrics ready (Prometheus at /metrics)")
97+
slog.Info("metrics ready", "endpoint", "/metrics", "component", "otel")
9198

9299
// Create API server
93100
apiSrv := api.New(common, authSvc, emailSvc, cacheClient, cfg)
@@ -103,8 +110,9 @@ func run() error {
103110
mux.Handle("GET /metrics", metricsHandler)
104111
mux.Handle("/", uiHandler())
105112

106-
// Wrap with metrics middleware
107-
handler := metrics.Middleware(mux)
113+
// Wrap with auth page redirect (302 for /login etc. on non-base-domain)
114+
// then metrics middleware
115+
handler := metrics.Middleware(api.AuthPageRedirect(cfg)(mux))
108116

109117
// HTTP server
110118
httpServer := &http.Server{
@@ -120,7 +128,7 @@ func run() error {
120128
// Graceful shutdown
121129
errCh := make(chan error, 1)
122130
go func() {
123-
log.Printf("🛡️ Aegis server listening on %s", cfg.Addr())
131+
slog.Info("server listening", "addr", cfg.Addr())
124132
errCh <- httpServer.ListenAndServe()
125133
}()
126134

@@ -129,7 +137,7 @@ func run() error {
129137

130138
select {
131139
case sig := <-quit:
132-
log.Printf("Received %s, shutting down...", sig)
140+
slog.Info("received signal, shutting down", "signal", sig.String())
133141
case err := <-errCh:
134142
return err
135143
}

server/cmd/aegis-server/ui.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ package main
33
import (
44
"embed"
55
"io/fs"
6-
"log"
6+
"log/slog"
77
"net/http"
8+
"os"
89
"strings"
910
)
1011

@@ -18,7 +19,8 @@ func uiHandler() http.Handler {
1819
// Strip the "ui" prefix from the embedded filesystem
1920
sub, err := fs.Sub(uiFS, "ui")
2021
if err != nil {
21-
log.Fatalf("ui embed: %v", err)
22+
slog.Error("ui embed failed", "error", err)
23+
os.Exit(1)
2224
}
2325

2426
fileServer := http.FileServer(http.FS(sub))

server/internal/api/auth.go

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import (
66
"crypto/sha256"
77
"encoding/hex"
88
"fmt"
9+
"log/slog"
910
"net/http"
1011
"net/mail"
1112
"strings"
1213
"time"
1314
"unicode"
1415

1516
authpkg "github.com/pixelvide/aegis/server/internal/auth"
17+
"github.com/pixelvide/aegis/server/internal/config"
18+
"github.com/pixelvide/aegis/server/internal/email/templates"
1619
"github.com/pixelvide/aegis/server/internal/middleware"
1720
"github.com/pixelvide/aegis/server/internal/models"
1821
"github.com/pixelvide/aegis/server/internal/store"
@@ -88,9 +91,11 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
8891
PasswordHash: hash,
8992
}
9093
if err := s.common.CreateUser(r.Context(), user); err != nil {
94+
slog.Error("failed to create user", "email", req.Email, "error", err)
9195
writeError(w, http.StatusInternalServerError, "failed to create user")
9296
return
9397
}
98+
slog.Info("user registered", "user_id", user.ID, "email", req.Email)
9499

95100
// Create primary email entry + send verification
96101
primaryEmail, err := s.common.AddPrimaryUserEmail(r.Context(), user.ID, req.Email)
@@ -128,7 +133,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
128133
}
129134

130135
s.createSession(r, user.ID, jti)
131-
setAuthCookie(w, token)
136+
setAuthCookie(w, token, s.config)
132137

133138
writeJSON(w, http.StatusCreated, map[string]any{
134139
"user": user,
@@ -215,7 +220,7 @@ issueToken:
215220
}
216221

217222
s.createSession(r, user.ID, jti)
218-
setAuthCookie(w, token)
223+
setAuthCookie(w, token, s.config)
219224

220225
// Send login notification email
221226
ip := extractIP(r)
@@ -242,6 +247,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
242247
SameSite: http.SameSiteLaxMode,
243248
Path: "/",
244249
MaxAge: -1, // delete immediately
250+
Domain: cookieDomain(s.config),
245251
})
246252
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
247253
}
@@ -270,18 +276,32 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
270276

271277
// ─── Helpers ────────────────────────────────────────────────────────────────
272278

273-
func setAuthCookie(w http.ResponseWriter, token string) {
279+
func setAuthCookie(w http.ResponseWriter, token string, cfg *config.Config) {
274280
http.SetCookie(w, &http.Cookie{
275281
Name: middleware.CookieName,
276282
Value: token,
277283
HttpOnly: true,
278-
// Secure: true, // enable in production with HTTPS
279284
SameSite: http.SameSiteLaxMode,
280285
Path: "/",
281286
MaxAge: 86400, // 24 hours
287+
// When BaseDomain is set, scope cookie to parent domain for cross-subdomain sharing
288+
Domain: cookieDomain(cfg),
289+
// TODO(security): Enable Secure: true in production with HTTPS
290+
// TODO(security): Consider __Secure- cookie prefix when Secure is enabled
282291
})
283292
}
284293

294+
// cookieDomain returns the domain to set on auth cookies.
295+
// When BaseDomain is set, returns ".aegis.io" (leading dot) so the cookie
296+
// is shared across all subdomains. When empty, returns "" (browser defaults
297+
// to the exact host — standard dev mode behavior).
298+
func cookieDomain(cfg *config.Config) string {
299+
if cfg.BaseDomain != "" {
300+
return "." + cfg.BaseDomain
301+
}
302+
return ""
303+
}
304+
285305
// createSession creates a session row for the given user and JTI.
286306
func (s *Server) createSession(r *http.Request, userID, jti string) {
287307
ua := r.UserAgent()
@@ -373,24 +393,12 @@ func parseUserAgent(ua string) (browser, os, deviceType string) {
373393
}
374394

375395
// sendLoginNotification sends an email alert about a new login.
376-
func (s *Server) sendLoginNotification(email, name, ip, browser, os, deviceType string) {
396+
func (s *Server) sendLoginNotification(emailAddr, name, ip, browser, os, deviceType string) {
377397
loginTime := time.Now().UTC().Format("Jan 02, 2006 at 15:04 UTC")
378-
subject := "Aegis — New sign-in to your account"
379-
body := fmt.Sprintf(`<h2>New Sign-In Detected</h2>
380-
<p>Hi %s,</p>
381-
<p>We noticed a new sign-in to your Aegis account:</p>
382-
<table style="border-collapse:collapse;margin:16px 0;">
383-
<tr><td style="padding:4px 16px 4px 0;color:#666;">Browser</td><td style="padding:4px 0;">%s</td></tr>
384-
<tr><td style="padding:4px 16px 4px 0;color:#666;">Operating System</td><td style="padding:4px 0;">%s</td></tr>
385-
<tr><td style="padding:4px 16px 4px 0;color:#666;">Device</td><td style="padding:4px 0;">%s</td></tr>
386-
<tr><td style="padding:4px 16px 4px 0;color:#666;">IP Address</td><td style="padding:4px 0;">%s</td></tr>
387-
<tr><td style="padding:4px 16px 4px 0;color:#666;">Time</td><td style="padding:4px 0;">%s</td></tr>
388-
</table>
389-
<p>If this was you, no action is needed.</p>
390-
<p>If you don't recognize this activity, please <strong>change your password immediately</strong> and review your active sessions.</p>
391-
<p style="color:#666;font-size:12px;">Aegis Security Platform</p>
392-
`, name, browser, os, deviceType, ip, loginTime)
393-
_ = s.email.Send(email, subject, body)
398+
subject, body := templates.LoginAlert(name, ip, browser, os, deviceType, loginTime)
399+
if err := s.email.Send(emailAddr, subject, body); err != nil {
400+
slog.Error("failed to send login notification email", "email", emailAddr, "error", err)
401+
}
394402
}
395403

396404
// maskEmail masks an email for privacy (e.g., "j***@example.com").
@@ -435,19 +443,22 @@ func (s *Server) sendVerificationEmail(userID, emailID, emailAddr string) {
435443

436444
tokenBytes := make([]byte, 32)
437445
if _, err := rand.Read(tokenBytes); err != nil {
446+
slog.Error("failed to generate verification token", "user_id", userID, "email", emailAddr, "error", err)
438447
return
439448
}
440449
token := hex.EncodeToString(tokenBytes)
441450
tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(token)))
442451
expiresAt := time.Now().UTC().Add(24 * time.Hour)
443452

444453
if err := s.common.CreatePasswordResetToken(ctx, userID, tokenHash, expiresAt); err != nil {
454+
slog.Error("failed to create verification token", "user_id", userID, "email", emailAddr, "error", err)
445455
return
446456
}
447457

448458
verifyURL := fmt.Sprintf("%s/verify-email?token=%s&email_id=%s", s.config.BaseURL, token, emailID)
449-
subject := "Verify your email address"
450-
body := fmt.Sprintf("Click the following link to verify your email:\n\n%s\n\nThis link expires in 24 hours.", verifyURL)
459+
subject, body := templates.VerifyEmail(verifyURL)
451460

452-
_ = s.email.Send(emailAddr, subject, body)
461+
if err := s.email.Send(emailAddr, subject, body); err != nil {
462+
slog.Error("failed to send verification email", "user_id", userID, "email", emailAddr, "error", err)
463+
}
453464
}

server/internal/api/metrics.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package api
33
import (
44
"context"
55
"database/sql"
6-
"log"
6+
"log/slog"
77
"net/http"
88
"strconv"
99
"time"
@@ -31,7 +31,7 @@ func InitMetrics(db *sql.DB) (*Metrics, http.Handler, func(context.Context) erro
3131
prometheus.WithNamespace("aegis"),
3232
)
3333
if err != nil {
34-
log.Printf("⚠️ Failed to create Prometheus exporter: %v — metrics disabled", err)
34+
slog.Warn("failed to create Prometheus exporter, metrics disabled", "error", err)
3535
return nil, http.NotFoundHandler(), func(context.Context) error { return nil }
3636
}
3737

@@ -115,15 +115,35 @@ func (m *Metrics) Middleware(next http.Handler) http.Handler {
115115

116116
next.ServeHTTP(rw, r)
117117

118-
duration := time.Since(start).Seconds()
118+
duration := time.Since(start)
119+
durationSec := duration.Seconds()
119120
attrs := []attribute.KeyValue{
120121
attribute.String("method", r.Method),
121122
attribute.String("path", routePattern(r)),
122123
attribute.String("status", strconv.Itoa(rw.statusCode)),
123124
}
124125

125126
m.requestsTotal.Add(r.Context(), 1, otelmetric.WithAttributes(attrs...))
126-
m.requestDuration.Record(r.Context(), duration, otelmetric.WithAttributes(attrs...))
127+
m.requestDuration.Record(r.Context(), durationSec, otelmetric.WithAttributes(attrs...))
128+
129+
// Structured request logging — skip noisy health/metrics endpoints
130+
path := r.URL.Path
131+
if path != "/healthz" && path != "/readyz" && path != "/metrics" {
132+
logLevel := slog.LevelDebug
133+
if rw.statusCode >= 500 {
134+
logLevel = slog.LevelError
135+
} else if rw.statusCode >= 400 {
136+
logLevel = slog.LevelWarn
137+
}
138+
139+
slog.LogAttrs(r.Context(), logLevel, "http request",
140+
slog.String("method", r.Method),
141+
slog.String("path", path),
142+
slog.Int("status", rw.statusCode),
143+
slog.Duration("duration", duration),
144+
slog.String("ip", r.RemoteAddr),
145+
)
146+
}
127147
})
128148
}
129149

0 commit comments

Comments
 (0)