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