NEXUS-1: Initial commit of Nexus
This commit is contained in:
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -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/
|
||||||
|
|
||||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -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)
|
||||||
|
|
||||||
71
README.md
Normal file
71
README.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
55
TODO.md
Normal file
55
TODO.md
Normal file
@@ -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
|
||||||
124
cmd/server/main.go
Normal file
124
cmd/server/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||||
126
internal/auth/jwt.go
Normal file
126
internal/auth/jwt.go
Normal 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
96
internal/config/config.go
Normal 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
117
internal/handler/auth.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
internal/handler/health.go
Normal file
13
internal/handler/health.go
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
23
internal/handler/response.go
Normal file
23
internal/handler/response.go
Normal 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})
|
||||||
|
}
|
||||||
|
|
||||||
43
internal/handler/templates/base.html
Normal file
43
internal/handler/templates/base.html
Normal 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 · Single Sign-On
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
58
internal/handler/templates/forgot-password.html
Normal file
58
internal/handler/templates/forgot-password.html
Normal 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}}
|
||||||
|
|
||||||
79
internal/handler/templates/login.html
Normal file
79
internal/handler/templates/login.html
Normal 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}}
|
||||||
|
|
||||||
68
internal/handler/templates/reset-password.html
Normal file
68
internal/handler/templates/reset-password.html
Normal 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}}
|
||||||
|
|
||||||
90
internal/handler/templates/signup.html
Normal file
90
internal/handler/templates/signup.html
Normal 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
390
internal/handler/webui.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
186
internal/middleware/middleware.go
Normal file
186
internal/middleware/middleware.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
49
internal/models/session.go
Normal file
49
internal/models/session.go
Normal 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
56
internal/models/user.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user