Add user database migration, core reusable components, and layout structure
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user