Add user database migration, core reusable components, and layout structure

This commit is contained in:
2026-05-16 18:54:23 -04:00
parent c7df396a83
commit e132c7a580
33 changed files with 2348 additions and 398 deletions
+104 -1
View File
@@ -1,7 +1,110 @@
package handlers
import "net/http"
import (
"net/http"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"workorders/internal/config"
"workorders/internal/model"
)
type AuthHandler struct {
db *sqlx.DB
cfg *config.Config
}
func NewAuthHandler(db *sqlx.DB, cfg *config.Config) *AuthHandler {
return &AuthHandler{db: db, cfg: cfg}
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(r, &body); err != nil || body.Username == "" || body.Password == "" {
respondError(w, http.StatusBadRequest, "username and password required")
return
}
var user model.User
err := h.db.Get(&user,
`SELECT * FROM users WHERE (username=@p1 OR email=@p1) AND active=1`,
body.Username)
if err != nil {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, err := generateToken(user, h.cfg.JWTSecret)
if err != nil {
respondError(w, http.StatusInternalServerError, "token generation failed")
return
}
h.db.Exec(`UPDATE users SET last_login=GETUTCDATE() WHERE id=@p1`, user.ID)
respond(w, http.StatusOK, map[string]any{
"token": token,
"user": publicUser(user),
})
}
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
user := userFromCtx(r)
var dbUser model.User
if err := h.db.Get(&dbUser, `SELECT * FROM users WHERE id=@p1 AND active=1`, user.UserID); err != nil {
respondError(w, http.StatusUnauthorized, "user not found")
return
}
token, err := generateToken(dbUser, h.cfg.JWTSecret)
if err != nil {
respondError(w, http.StatusInternalServerError, "token generation failed")
return
}
respond(w, http.StatusOK, map[string]any{"token": token})
}
func Me(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, userFromCtx(r))
}
func generateToken(user model.User, secret string) (string, error) {
claims := model.LocalClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: strconv.Itoa(user.ID),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(8 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserID: user.ID,
Username: user.Username,
Email: user.Email,
DisplayName: user.DisplayName,
Role: user.Role,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func publicUser(u model.User) map[string]any {
return map[string]any{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"display_name": u.DisplayName,
"role": u.Role,
"avatar_url": u.AvatarURL,
}
}
+48 -147
View File
@@ -2,162 +2,68 @@ package api
import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"workorders/internal/model"
)
// jwksCache caches the public keys from Keycloak
type jwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
fetchAt time.Time
url string
var roleLevel = map[string]int{
"admin": 4, "dispatcher": 3, "field_tech": 2, "viewer": 1,
}
var cache = &jwksCache{}
func InitJWKS(url string) {
cache.url = url
if err := cache.refresh(); err != nil {
log.Printf("JWKS initial fetch warning: %v (will retry per-request)", err)
}
}
func (c *jwksCache) refresh() error {
resp, err := http.Get(c.url) //nolint:gosec
if err != nil {
return fmt.Errorf("fetch JWKS: %w", err)
}
defer resp.Body.Close()
var jwks struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
return fmt.Errorf("decode JWKS: %w", err)
}
keys := make(map[string]*rsa.PublicKey, len(jwks.Keys))
for _, k := range jwks.Keys {
if k.Kty != "RSA" {
continue
}
pub, err := rsaPublicKey(k.N, k.E)
if err != nil {
continue
}
keys[k.Kid] = pub
}
c.mu.Lock()
c.keys = keys
c.fetchAt = time.Now()
c.mu.Unlock()
return nil
}
func (c *jwksCache) get(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
key, ok := c.keys[kid]
stale := time.Since(c.fetchAt) > 10*time.Minute
c.mu.RUnlock()
if ok && !stale {
return key, nil
}
if err := c.refresh(); err != nil {
if ok {
return key, nil // use stale key if refresh fails
}
return nil, err
}
c.mu.RLock()
key, ok = c.keys[kid]
c.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("key %q not found", kid)
}
return key, nil
}
func rsaPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
}
// OIDCAuth validates a Keycloak-issued JWT in the Authorization header.
func OIDCAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
raw := strings.TrimPrefix(auth, "Bearer ")
token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
// JWTAuth validates a locally-issued HMAC-signed JWT in the Authorization header.
func JWTAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
kid, _ := t.Header["kid"].(string)
return cache.get(kid)
}, jwt.WithExpirationRequired())
raw := strings.TrimPrefix(auth, "Bearer ")
if err != nil || !token.Valid {
jsonError(w, "invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
jsonError(w, "invalid claims", http.StatusUnauthorized)
return
}
user := model.UserClaims{
Sub: stringClaim(claims, "sub"),
Email: stringClaim(claims, "email"),
Name: stringClaim(claims, "name"),
}
if ra, ok := claims["realm_access"].(map[string]any); ok {
if roles, ok := ra["roles"].([]any); ok {
for _, r := range roles {
if s, ok := r.(string); ok {
user.Roles = append(user.Roles, s)
}
var claims model.LocalClaims
_, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
}
}
return []byte(secret), nil
}, jwt.WithExpirationRequired())
ctx := context.WithValue(r.Context(), model.CtxUserKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
if err != nil {
jsonError(w, "invalid token", http.StatusUnauthorized)
return
}
user := model.UserClaims{
UserID: claims.UserID,
Username: claims.Username,
Email: claims.Email,
DisplayName: claims.DisplayName,
Role: claims.Role,
}
ctx := context.WithValue(r.Context(), model.CtxUserKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireRole blocks requests where the user's role is below the required level.
func RequireRole(minRole string) func(http.Handler) http.Handler {
minLevel := roleLevel[minRole]
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromCtx(r)
if roleLevel[user.Role] >= minLevel {
next.ServeHTTP(w, r)
return
}
jsonError(w, "forbidden", http.StatusForbidden)
})
}
}
func CORS(next http.Handler) http.Handler {
@@ -173,11 +79,6 @@ func CORS(next http.Handler) http.Handler {
})
}
func stringClaim(c jwt.MapClaims, key string) string {
v, _ := c[key].(string)
return v
}
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
+9 -4
View File
@@ -20,9 +20,16 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
// Serve frontend static files
r.Handle("/*", http.FileServer(http.Dir("./web")))
// Protected API
// Public auth routes
auth := handlers.NewAuthHandler(db, cfg)
r.Post("/api/auth/login", auth.Login)
// Protected API routes
r.Group(func(r chi.Router) {
r.Use(OIDCAuth)
r.Use(JWTAuth(cfg.JWTSecret))
r.Post("/api/auth/refresh", auth.Refresh)
r.Get("/api/auth/me", handlers.Me)
wo := handlers.NewWorkOrderHandler(db, cfg)
r.Get("/api/work-orders", wo.List)
@@ -59,8 +66,6 @@ func NewRouter(cfg *config.Config, db *sqlx.DB) http.Handler {
r.Get("/api/registry/vehicles", reg.Vehicles)
r.Get("/api/registry/equipment", reg.Equipment)
r.Get("/api/registry/materials", reg.Materials)
r.Get("/api/me", handlers.Me)
})
return r
+14 -15
View File
@@ -3,25 +3,24 @@ package config
import "os"
type Config struct {
Addr string
DBDSN string
JWTSecret string
UploadPath string
BaseURL string
OIDCIssuer string
JWKSUrl string
AppEnv string
Addr string
DBDSN string
JWTSecret string
AdminPassword string
UploadPath string
BaseURL string
AppEnv string
}
func Load() *Config {
return &Config{
Addr: env("ADDR", ":8080"),
DBDSN: env("DB_DSN", ""),
UploadPath: env("UPLOAD_PATH", "./uploads"),
BaseURL: env("BASE_URL", "http://localhost:8080"),
OIDCIssuer: env("OIDC_ISSUER", "http://localhost:8180/realms/workorders"),
JWKSUrl: env("JWKS_URL", "http://localhost:8180/realms/workorders/protocol/openid-connect/certs"),
AppEnv: env("APP_ENV", "development"),
Addr: env("ADDR", ":9080"),
DBDSN: env("DB_DSN", ""),
JWTSecret: env("JWT_SECRET", "change-me-in-production"),
AdminPassword: env("ADMIN_PASSWORD", "admin123"),
UploadPath: env("UPLOAD_PATH", "./uploads"),
BaseURL: env("BASE_URL", "http://localhost:9080"),
AppEnv: env("APP_ENV", "development"),
}
}
+45 -8
View File
@@ -1,6 +1,12 @@
package model
import "time"
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// ── Work Order ────────────────────────────────────────────────────────────────
type WorkOrder struct {
ID int `db:"id" json:"id"`
@@ -41,6 +47,8 @@ type WorkOrderListItem struct {
PhotoCount int `db:"photo_count" json:"photo_count"`
}
// ── Steps / Resources / Attachments / Accounting ─────────────────────────────
type Step struct {
ID int `db:"id" json:"id"`
WOID int `db:"wo_id" json:"wo_id"`
@@ -90,6 +98,8 @@ type AccountingCode struct {
Description string `db:"description" json:"description"`
}
// ── Resource Registry ─────────────────────────────────────────────────────────
type RegistryPerson struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
@@ -106,10 +116,10 @@ type RegistryVehicle struct {
}
type RegistryEquipment struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
AssetTag string `db:"asset_tag" json:"asset_tag"`
Category string `db:"category" json:"category"`
Category string `db:"category" json:"category"`
}
type RegistryMaterial struct {
@@ -119,11 +129,38 @@ type RegistryMaterial struct {
PartNumber string `db:"part_number" json:"part_number"`
}
// ── Users & Auth ──────────────────────────────────────────────────────────────
type User struct {
ID int `db:"id" json:"id"`
Username string `db:"username" json:"username"`
Email string `db:"email" json:"email"`
DisplayName string `db:"display_name" json:"display_name"`
PasswordHash string `db:"password_hash" json:"-"`
Role string `db:"role" json:"role"`
AvatarURL string `db:"avatar_url" json:"avatar_url,omitempty"`
Active bool `db:"active" json:"active"`
LastLogin *time.Time `db:"last_login" json:"last_login"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// LocalClaims is the JWT payload for locally-issued tokens.
type LocalClaims struct {
jwt.RegisteredClaims
UserID int `json:"uid"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"name"`
Role string `json:"role"`
}
// UserClaims is stored in request context after token validation.
type UserClaims struct {
Sub string
Email string
Name string
Roles []string
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
}
type CtxKey string
@@ -0,0 +1,16 @@
CREATE TABLE users (
id INT IDENTITY PRIMARY KEY,
username NVARCHAR(100) NOT NULL UNIQUE,
email NVARCHAR(200) NOT NULL UNIQUE,
display_name NVARCHAR(200),
password_hash NVARCHAR(200) NOT NULL,
role NVARCHAR(30) NOT NULL DEFAULT 'viewer',
avatar_url NVARCHAR(500),
active BIT NOT NULL DEFAULT 1,
last_login DATETIME2,
created_at DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
CONSTRAINT chk_user_role CHECK (role IN ('admin','dispatcher','field_tech','viewer'))
);
CREATE INDEX ix_users_username ON users (username);
CREATE INDEX ix_users_email ON users (email);