From 16fe2bf9281a300e6dcb17777fef0a2cf6d2100d Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Tue, 14 Jul 2026 05:53:25 -0500 Subject: [PATCH] NEXUS-1: Initial commit of Nexus --- .gitignore | 43 ++ Makefile | 24 ++ README.md | 71 ++++ TODO.md | 55 +++ cmd/server/main.go | 124 ++++++ go.mod | 8 + go.sum | 4 + internal/auth/jwt.go | 126 ++++++ internal/config/config.go | 96 +++++ internal/handler/auth.go | 117 ++++++ internal/handler/health.go | 13 + internal/handler/response.go | 23 ++ internal/handler/templates/base.html | 43 ++ .../handler/templates/forgot-password.html | 58 +++ internal/handler/templates/login.html | 79 ++++ .../handler/templates/reset-password.html | 68 +++ internal/handler/templates/signup.html | 90 ++++ internal/handler/webui.go | 390 ++++++++++++++++++ internal/middleware/middleware.go | 186 +++++++++ internal/models/session.go | 49 +++ internal/models/user.go | 56 +++ main.go | 16 + 22 files changed, 1739 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/jwt.go create mode 100644 internal/config/config.go create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/response.go create mode 100644 internal/handler/templates/base.html create mode 100644 internal/handler/templates/forgot-password.html create mode 100644 internal/handler/templates/login.html create mode 100644 internal/handler/templates/reset-password.html create mode 100644 internal/handler/templates/signup.html create mode 100644 internal/handler/webui.go create mode 100644 internal/middleware/middleware.go create mode 100644 internal/models/session.go create mode 100644 internal/models/user.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..215ff64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Build output +bin/ + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Test coverage +coverage.out +coverage.html + +# Dependency directories +vendor/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Go workspace +go.work +go.work.sum + +# Temporary files +tmp/ +temp/ + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad9efed --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: build run test lint clean + +APP_NAME := nexus +BUILD_DIR := bin + +build: + go build -o $(BUILD_DIR)/$(APP_NAME) . + +run: build + ./$(BUILD_DIR)/$(APP_NAME) + +test: + go test -v -race -count=1 ./... + +lint: + go vet ./... + +# run with live reload (requires air: go install github.com/air-verse/air@latest) +dev: + air + +clean: + rm -rf $(BUILD_DIR) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5823fb1 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Nexus Control Panel + +**Your central command. One login, every tool.** + +Nexus is the identity hub for the Arcline platform. It acts as the single source of truth for users, roles, sessions, and SSO integrations — connecting Portal, Billing, Git, Monitoring, and future tools through one unified login. + +## Quick Start + +```bash +make run +``` + +The server starts on `http://0.0.0.0:8080` by default. + +## Endpoints + +| Method | Path | Auth | Description | +|--------|-----------------|----------|--------------------------| +| GET | `/health` | Public | Health check | +| GET | `/ready` | Public | Readiness probe | +| POST | `/auth/login` | Public | Authenticate, get tokens | +| POST | `/auth/refresh` | Public | Refresh access token | +| GET | `/auth/me` | Bearer | Current user info | + +## Configuration + +All config is via environment variables: + +| Variable | Default | Description | +|-------------------------|-------------------------|---------------------------| +| `NEXUS_HOST` | `0.0.0.0` | Server host | +| `NEXUS_PORT` | `8080` | Server port | +| `NEXUS_DB_HOST` | `localhost` | PostgreSQL host | +| `NEXUS_DB_PORT` | `5432` | PostgreSQL port | +| `NEXUS_DB_USER` | `nexus` | PostgreSQL user | +| `NEXUS_DB_PASSWORD` | *(required)* | PostgreSQL password | +| `NEXUS_DB_NAME` | `nexus` | PostgreSQL database | +| `NEXUS_DB_SSLMODE` | `disable` | PostgreSQL SSL mode | +| `NEXUS_JWT_SECRET` | `change-me-in-production` | HMAC secret for JWT | +| `NEXUS_LOG_LEVEL` | `info` | debug, info, warn, error | +| `NEXUS_LOG_FORMAT` | `json` | json or text | + +## Project Structure + +``` +cmd/server/ — Server bootstrap, graceful shutdown +internal/ + auth/ — JWT generation, validation, token hashing + config/ — Environment-based configuration + db/ — Database layer (planned) + handler/ — HTTP request handlers + middleware/ — Logging, CORS, auth, recovery + models/ — Domain types (User, Session, ConnectedApp, etc.) +``` + +## Development + +```bash +# Run with Go +go run . + +# Run with live reload +make dev + +# Tests +make test + +# Lint +make lint +``` + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cbe7b6b --- /dev/null +++ b/TODO.md @@ -0,0 +1,55 @@ +# Nexus Control Panel — TODO + +**Product name:** Nexus Control Panel +**Subdomain:** `nexus.arcline.it` +**Tagline:** "Your central command. One login, every tool." + +## Naming & Branding +- [ ] Confirm "Nexus Control Panel" clears trademark/legal check +- [ ] Register/reserve `nexus.arcline.it` subdomain and DNS entry +- [ ] Lock tagline copy across marketing site, login page, and docs +- [ ] Design logo/wordmark reflecting the "hub" metaphor +- [ ] Define brand voice guidelines tied to the "central command" concept + +## Infrastructure & Identity Core +- [ ] Stand up identity service as the single source of truth +- [ ] Provision `nexus.arcline.it` with TLS certs +- [ ] Design core data model: users, roles, sessions, connected apps +- [ ] Implement session management / token issuance (JWT or equivalent) +- [ ] Set up audit logging for login events across connected tools + +## Tool Integrations (the "spokes" of the hub) +- [ ] **Portal** — connect via SSO, map roles/permissions +- [ ] **Billing** — connect via SSO, map roles/permissions +- [ ] **Git** — connect via SSO, map roles/permissions +- [ ] **Monitoring** — connect via SSO, map roles/permissions +- [ ] Define standard integration pattern/spec so future tools plug in consistently + +## Third-Party SSO (future-proofing per the "Nexus" metaphor) +- [ ] Research Google Workspace SSO integration requirements +- [ ] Research Microsoft Entra ID (Azure AD) integration requirements +- [ ] Design abstraction layer so external IdPs can plug in without core rework +- [ ] Prioritize/schedule third-party SSO for a post-launch phase + +## UI / UX +- [ ] Design login/landing page reflecting "central command" positioning +- [ ] Build dashboard showing all connected tools (Portal, Billing, Git, Monitoring) +- [ ] Add per-user access/permissions view +- [ ] Add admin view for managing connected apps and users + +## Security +- [ ] Define password/MFA policy +- [ ] Pen-test the identity core before launch +- [ ] Set up rate limiting and anomaly detection on login endpoint +- [ ] Document token expiry/refresh and revocation flows + +## Launch +- [ ] Internal beta with Portal + Billing only +- [ ] Roll out Git + Monitoring integrations +- [ ] Publish tagline/messaging externally +- [ ] Announce `nexus.arcline.it` as the official login hub + +## Documentation +- [ ] Write internal integration guide (how to plug a new tool into Nexus) +- [ ] Write end-user help doc ("What is Nexus Control Panel?") +- [ ] Write admin guide for managing users/roles diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..05a40a3 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,124 @@ +package server + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "git.arcline.it/ArclineIT/nexus/internal/config" + "git.arcline.it/ArclineIT/nexus/internal/handler" + "git.arcline.it/ArclineIT/nexus/internal/middleware" +) + +// Run starts the Nexus HTTP server and blocks until shutdown. +func Run() error { + cfg := config.Load() + + logger := setupLogger(cfg) + + authHandler := handler.NewAuthHandler(cfg) + + uiHandler, err := handler.NewUIHandler(cfg) + if err != nil { + return fmt.Errorf("initializing UI handler: %w", err) + } + + mux := http.NewServeMux() + + // Public API routes + mux.HandleFunc("GET /health", handler.Ready()) + mux.HandleFunc("GET /ready", handler.Ready()) + mux.HandleFunc("POST /auth/login", authHandler.Login()) + mux.HandleFunc("POST /auth/refresh", authHandler.Refresh()) + + // Protected API routes (Bearer token) + mux.Handle("GET /auth/me", middleware.Authenticate(cfg)(authHandler.Me())) + + // Public web UI routes + mux.HandleFunc("GET /{$}", uiHandler.Root()) + mux.HandleFunc("GET /login", uiHandler.LoginPage()) + mux.HandleFunc("POST /login", uiHandler.LoginSubmit()) + mux.HandleFunc("GET /signup", uiHandler.SignupPage()) + mux.HandleFunc("POST /signup", uiHandler.SignupSubmit()) + mux.HandleFunc("GET /forgot-password", uiHandler.ForgotPasswordPage()) + mux.HandleFunc("POST /forgot-password", uiHandler.ForgotPasswordSubmit()) + mux.HandleFunc("GET /reset-password", uiHandler.ResetPasswordPage()) + mux.HandleFunc("POST /reset-password", uiHandler.ResetPasswordSubmit()) + + // Protected web UI routes (cookie or Bearer) + mux.Handle("GET /dashboard", middleware.WebAuth(cfg)(uiHandler.DashboardPage())) + mux.Handle("POST /logout", middleware.WebAuth(cfg)(uiHandler.Logout())) + + // Apply global middleware — outermost first + var h http.Handler = mux + h = middleware.CORS(h) + h = middleware.RequestID(h) + h = middleware.Logger(logger)(h) + h = middleware.Recoverer(logger)(h) + + addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: h, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + } + + // Graceful shutdown + errCh := make(chan error, 1) + go func() { + logger.Info("nexus control panel starting", slog.String("addr", addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-quit: + logger.Info("shutting down", slog.String("signal", sig.String())) + case err := <-errCh: + logger.Error("server error", slog.Any("error", err)) + } + + ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + return fmt.Errorf("shutting down server: %w", err) + } + + logger.Info("server stopped gracefully") + return nil +} + +func setupLogger(cfg *config.Config) *slog.Logger { + var level slog.Level + switch cfg.Logging.Level { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{Level: level} + + var h slog.Handler + if cfg.Logging.Format == "json" { + h = slog.NewJSONHandler(os.Stdout, opts) + } else { + h = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(h) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5e8940b --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.arcline.it/ArclineIT/nexus + +go 1.22 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be3c485 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..9462cd4 --- /dev/null +++ b/internal/auth/jwt.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9fde6cd --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..a6b6b55 --- /dev/null +++ b/internal/handler/auth.go @@ -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, + }) + } +} + diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..97ccc9e --- /dev/null +++ b/internal/handler/health.go @@ -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", + }) + } +} + diff --git a/internal/handler/response.go b/internal/handler/response.go new file mode 100644 index 0000000..b2ca0f8 --- /dev/null +++ b/internal/handler/response.go @@ -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}) +} + diff --git a/internal/handler/templates/base.html b/internal/handler/templates/base.html new file mode 100644 index 0000000..bcc0fac --- /dev/null +++ b/internal/handler/templates/base.html @@ -0,0 +1,43 @@ +{{define "base"}} + + + + + + Nexus Control Panel + + + + + + +
+ +
+
+

Nexus

+

Control Panel

+
+ + +
+ {{template "content" .}} +
+ + +

+ Arcline Platform · Single Sign-On +

+
+ + +{{end}} + diff --git a/internal/handler/templates/forgot-password.html b/internal/handler/templates/forgot-password.html new file mode 100644 index 0000000..661e70a --- /dev/null +++ b/internal/handler/templates/forgot-password.html @@ -0,0 +1,58 @@ +{{define "content"}} +
+

Reset your password

+

Enter your email address and we'll send you a link to reset your password.

+ + {{if .Success}} + + +

+ Back to sign in +

+ {{else}} + {{if .Error}} + + {{end}} + +
+
+ + +
+ + +
+ +

+ Back to sign in +

+ {{end}} +
+{{end}} + diff --git a/internal/handler/templates/login.html b/internal/handler/templates/login.html new file mode 100644 index 0000000..6ca27c2 --- /dev/null +++ b/internal/handler/templates/login.html @@ -0,0 +1,79 @@ +{{define "content"}} +
+

Sign in to your account

+ + {{if .Error}} + + {{end}} + + {{if .Success}} + + {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + Forgot password? +
+ + +
+ +

+ Don't have an account? + Create one +

+
+{{end}} + diff --git a/internal/handler/templates/reset-password.html b/internal/handler/templates/reset-password.html new file mode 100644 index 0000000..e429ebb --- /dev/null +++ b/internal/handler/templates/reset-password.html @@ -0,0 +1,68 @@ +{{define "content"}} +
+

Set a new password

+ + {{if .Error}} + + {{end}} + + {{if .Success}} + +

+ Sign in with your new password +

+ {{else}} +
+ + +
+ + +
+ +
+ + +
+ + +
+ {{end}} +
+{{end}} + diff --git a/internal/handler/templates/signup.html b/internal/handler/templates/signup.html new file mode 100644 index 0000000..294c7fc --- /dev/null +++ b/internal/handler/templates/signup.html @@ -0,0 +1,90 @@ +{{define "content"}} +
+

Create your account

+ + {{if .Error}} + + {{end}} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +

+ Already have an account? + Sign in +

+
+{{end}} + diff --git a/internal/handler/webui.go b/internal/handler/webui.go new file mode 100644 index 0000000..9211beb --- /dev/null +++ b/internal/handler/webui.go @@ -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(` + + + + + Nexus — Dashboard + + + + + + +
+
+

Welcome back

+

You are signed in as ` + userEmail + `.

+
+
+
User ID
+
` + userID + `
+
+
+
Connected Apps
+
None yet
+
+
+
+
+ +`)) + } +} + +// --------------------------------------------------------------------------- +// 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) + } +} + diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..052271b --- /dev/null +++ b/internal/middleware/middleware.go @@ -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) +} + diff --git a/internal/models/session.go b/internal/models/session.go new file mode 100644 index 0000000..0717bc4 --- /dev/null +++ b/internal/models/session.go @@ -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"` +} + diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..0525b2a --- /dev/null +++ b/internal/models/user.go @@ -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"` +} + diff --git a/main.go b/main.go new file mode 100644 index 0000000..7cef84a --- /dev/null +++ b/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "fmt" + "os" + + "git.arcline.it/ArclineIT/nexus/cmd/server" +) + +func main() { + if err := server.Run(); err != nil { + fmt.Fprintf(os.Stderr, "nexus: %v\n", err) + os.Exit(1) + } +} +