This commit is contained in:
2026-02-11 17:46:22 +03:00
commit eacfacb13b
266 changed files with 51337 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
package middlewares
import (
"bssapp-backend/auth"
"net/http"
"time"
"bssapp-backend/internal/auditlog"
)
type ResponseWriter struct {
http.ResponseWriter
status int
}
func NewResponseWriter(w http.ResponseWriter) *ResponseWriter {
return &ResponseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
}
func (rw *ResponseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *ResponseWriter) Status() int { return rw.status }
func Audit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := NewResponseWriter(w)
next.ServeHTTP(rw, r)
// ✅ AuthMiddleware sonrası burada claims VAR
var dfusrID int64
var username, roleCode string
if claims, ok := auth.GetClaimsFromContext(r.Context()); ok && claims != nil {
dfusrID = int64(claims.ID)
username = claims.Username
roleCode = claims.RoleCode // tokenda varsa
}
entry := auditlog.ActivityLog{
DfUsrID: dfusrID,
Username: username,
RoleCode: roleCode,
ActionType: "route_access",
ActionCategory: "nav",
ActionTarget: r.URL.Path,
Description: r.Method + " " + r.URL.Path,
IpAddress: r.RemoteAddr,
UserAgent: r.UserAgent(),
SessionID: "",
RequestStartedAt: start,
RequestFinishedAt: time.Now(),
DurationMs: int(time.Since(start).Milliseconds()),
HttpStatus: rw.Status(),
IsSuccess: rw.Status() < 400,
}
auditlog.Write(entry)
})
}

View File

@@ -0,0 +1,39 @@
package middlewares
import (
"bssapp-backend/auth"
"database/sql"
"log"
"net/http"
"strings"
)
func AuthMiddleware(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := auth.ValidateToken(parts[1])
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 🔥 BU SATIR ŞART
ctx := auth.WithClaims(r.Context(), claims)
log.Printf("🔐 AUTH CTX SET user=%d role=%s", claims.ID, claims.RoleCode)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

961
svc/middlewares/authz_v2.go Normal file
View File

@@ -0,0 +1,961 @@
package middlewares
import (
"bssapp-backend/internal/authz"
"bssapp-backend/permissions"
"bytes"
"database/sql"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"bssapp-backend/auth"
"github.com/gorilla/mux"
)
/*
AuthzGuardV2
- module+action role permission check (mk_sys_role_permissions)
- optional scope checks (department / piyasa) via intersection:
user_allowed ∩ role_allowed
- cache with TTL
Expected:
- AuthMiddleware runs before this and sets JWT claims in context.
- claims should contain RoleID and UserID.
*/
// =====================================================
// 🔧 CONFIG / CONSTANTS
// =====================================================
const (
defaultPermTTL = 60 * time.Second
defaultScopeTTL = 30 * time.Second
maxBodyRead = 1 << 20 // 1MB
)
// =====================================================
// 🧠 CACHE
// =====================================================
type cacheItem struct {
val any
expires time.Time
}
type ttlCache struct {
mu sync.RWMutex
ttl time.Duration
m map[string]cacheItem
}
// =====================================================
// 🌍 GLOBAL SCOPE CACHE (for invalidation)
// =====================================================
var globalScopeCache *ttlCache
func newTTLCache(ttl time.Duration) *ttlCache {
return &ttlCache{
ttl: ttl,
m: make(map[string]cacheItem),
}
}
func (c *ttlCache) get(key string) (any, bool) {
now := time.Now()
c.mu.RLock()
item, ok := c.m[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if now.After(item.expires) {
// lazy delete
c.mu.Lock()
delete(c.m, key)
c.mu.Unlock()
return nil, false
}
return item.val, true
}
func (c *ttlCache) set(key string, val any) {
c.mu.Lock()
c.m[key] = cacheItem{val: val, expires: time.Now().Add(c.ttl)}
c.mu.Unlock()
}
// =====================================================
// ✅ MAIN MIDDLEWARE
// =====================================================
type AuthzV2Options struct {
// If true, scope checks are attempted when scope can be extracted.
EnableScopeChecks bool
// If true, when scope is required but cannot be extracted, deny.
// If false, when scope cannot be extracted, scope check is skipped.
StrictScope bool
// Override TTLs (optional)
PermTTL time.Duration
ScopeTTL time.Duration
// Custom extractors (optional). If nil, built-in extractors are used.
ExtractDepartmentCodes func(r *http.Request) []string
ExtractPiyasaCodes func(r *http.Request) []string
// Decide whether this request should be treated as scope-sensitive.
// If nil, built-in heuristic is used.
IsScopeSensitive func(module string, r *http.Request) bool
}
func AuthzGuardV2(pg *sql.DB, module string, action string) func(http.Handler) http.Handler {
return AuthzGuardV2WithOptions(pg, module, action, AuthzV2Options{
EnableScopeChecks: true,
StrictScope: false,
})
}
func AuthzGuardV2WithOptions(pg *sql.DB, module string, action string, opt AuthzV2Options) func(http.Handler) http.Handler {
permTTL := opt.PermTTL
if permTTL <= 0 {
permTTL = defaultPermTTL
}
scopeTTL := opt.ScopeTTL
if scopeTTL <= 0 {
scopeTTL = defaultScopeTTL
}
permCache := newTTLCache(permTTL)
if globalScopeCache == nil {
globalScopeCache = newTTLCache(scopeTTL)
}
scopeCache := globalScopeCache
// default extractors
extractDept := opt.ExtractDepartmentCodes
if extractDept == nil {
extractDept = defaultExtractDepartmentCodes
}
extractPiy := opt.ExtractPiyasaCodes
if extractPiy == nil {
extractPiy = defaultExtractPiyasaCodes
}
isScopeSensitive := opt.IsScopeSensitive
if isScopeSensitive == nil {
isScopeSensitive = defaultIsScopeSensitive
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// OPTIONS passthrough
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", 401)
return
}
userID := claims.ID
roleCode := claims.RoleCode
// ADMIN BYPASS
if claims.IsAdmin() {
next.ServeHTTP(w, r)
return
}
// resolve role_id from role_code
roleID, err := cachedRoleID(pg, permCache, roleCode)
if err != nil {
log.Println("❌ role resolve error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// --------------------------------------------------
// 🔐 PERMISSION RESOLUTION (USER > ROLE > DENY)
// --------------------------------------------------
permRepo := permissions.NewPermissionRepository(pg)
allowed := false
resolved := false // karar verildi mi?
// --------------------------------------------------
// 1⃣ USER OVERRIDE (ÖNCELİK)
// --------------------------------------------------
overrides, err := permRepo.GetUserOverrides(userID)
if err != nil {
log.Println("❌ override load error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
for _, o := range overrides {
if o.Module == module &&
o.Action == action {
log.Printf(
"🔁 USER OVERRIDE → %s:%s = %v",
module,
action,
o.Allowed,
)
allowed = o.Allowed
resolved = true
break
}
}
// --------------------------------------------------
// 2⃣ ROLE + DEPARTMENT (NEW SYSTEM)
// --------------------------------------------------
if !resolved {
deptCodes := claims.DepartmentCodes
roleDeptAllowed, err := permRepo.ResolvePermissionChain(
userID,
roleID,
deptCodes,
module,
action,
)
if err != nil {
log.Println("❌ perm resolve error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if roleDeptAllowed {
log.Printf("🆕 ROLE+DEPT → %s:%s = true", module, action)
allowed = true
resolved = true
} else {
log.Printf("🆕 ROLE+DEPT → %s:%s = false (try legacy)", module, action)
}
}
// --------------------------------------------------
// 3⃣ ROLE ONLY (LEGACY FALLBACK)
// --------------------------------------------------
if !resolved {
legacyAllowed, err := cachedRolePermission(
pg,
permCache,
roleID,
module,
action,
)
if err != nil {
log.Println("❌ legacy perm error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
log.Printf("🕰️ LEGACY ROLE → %s:%s = %v", module, action, legacyAllowed)
allowed = legacyAllowed
resolved = true
}
// --------------------------------------------------
// 3⃣ FINAL DECISION
// --------------------------------------------------
if !allowed {
log.Printf(
"⛔ ACCESS DENIED user=%d %s:%s path=%s",
claims.ID,
module,
action,
r.URL.Path,
)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
log.Printf(
"✅ ACCESS OK user=%d %s:%s %s",
claims.ID,
module,
action,
r.URL.Path,
)
// --------------------------------------------------
// 4⃣ OPTIONAL SCOPE CHECKS (FINAL - SECURE)
// --------------------------------------------------
if opt.EnableScopeChecks && isScopeSensitive(module, r) {
// 🔹 Requestten gelenler
reqDepts := normalizeCodes(extractDept(r))
reqPiy := normalizeCodes(extractPiy(r))
ctx := r.Context()
// 🔹 USER PIYASA (DBden)
userPiy, err := authz.GetUserPiyasaCodes(pg, int(userID))
if err != nil {
log.Println("❌ piyasa load error:", err)
http.Error(w, "forbidden", 403)
return
}
userPiy = normalizeCodes(userPiy)
// ------------------------------------------------
// ✅ PIYASA INTERSECTION
// ------------------------------------------------
var effectivePiy []string
switch {
case len(reqPiy) > 0 && len(userPiy) > 0:
effectivePiy = intersect(reqPiy, userPiy)
case len(reqPiy) > 0 && len(userPiy) == 0:
// user piyasa tanımlı değilse request'e güvenme → boş kalsın (StrictScope varsa deny)
effectivePiy = nil
case len(reqPiy) == 0 && len(userPiy) > 0:
effectivePiy = userPiy
}
if len(reqPiy) > 0 && len(effectivePiy) == 0 {
// request piyasa istiyor ama user scope karşılamıyor
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ------------------------------------------------
// ✅ CONTEXTE YAZ
// ------------------------------------------------
if len(reqDepts) > 0 {
ctx = authz.WithDeptCodes(ctx, reqDepts)
}
if len(effectivePiy) > 0 {
ctx = authz.WithPiyasaCodes(ctx, effectivePiy)
}
r = r.WithContext(ctx)
// ------------------------------------------------
// ❗ STRICT MODE
// ------------------------------------------------
if len(reqDepts) == 0 && len(effectivePiy) == 0 {
if opt.StrictScope {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
return
}
// ------------------------------------------------
// 🔍 DEPARTMENT CHECK
// ------------------------------------------------
if len(reqDepts) > 0 {
okDept, err := cachedDeptIntersectionAny(
pg,
scopeCache,
userID,
roleID,
reqDepts,
)
if err != nil {
log.Println("❌ dept scope error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !okDept {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
// ------------------------------------------------
// 🔍 PIYASA CHECK
// ------------------------------------------------
if len(effectivePiy) > 0 {
okPiy, err := cachedPiyasaIntersectionAny(
pg,
scopeCache,
userID,
roleID,
effectivePiy,
)
if err != nil {
log.Println("❌ piyasa scope error:", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if !okPiy {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
}
}
// --------------------------------------------------
// ✅ ALLOW
// --------------------------------------------------
next.ServeHTTP(w, r)
})
}
}
// =====================================================
// 🔐 PERMISSION CHECK (mk_sys_role_permissions)
// =====================================================
func cachedRolePermission(pg *sql.DB, c *ttlCache, roleID int64, module, action string) (bool, error) {
key := "perm|" + itoa(roleID) + "|" + module + "|" + action
if v, ok := c.get(key); ok {
return v.(bool), nil
}
var allowed bool
err := pg.QueryRow(`
SELECT allowed
FROM mk_sys_role_permissions
WHERE role_id = $1 AND module_code = $2 AND action = $3
`, roleID, module, action).Scan(&allowed)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
c.set(key, allowed)
return allowed, nil
}
// =====================================================
// 🧩 SCOPE INTERSECTION
// user scope ∩ role scope
// =====================================================
func cachedDeptIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, deptCodes []string) (bool, error) {
// cache by exact request list (sorted would be ideal; normalizeCodes already stabilizes somewhat)
key := "deptAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(deptCodes, ",")
if v, ok := c.get(key); ok {
return v.(bool), nil
}
// ANY match: if request wants multiple codes, allow if at least one is in intersection
// Intersection query:
// user departments: dfusr_dprt -> mk_dprt(code)
// role allowed: dfrole_dprt -> mk_dprt(id) OR directly by id
okAny := false
// We do it in a single query with ANY($3)
var dummy int
err := pg.QueryRow(`
SELECT 1
FROM dfusr_dprt ud
JOIN mk_dprt d ON d.id = ud.dprt_id
JOIN dfrole_dprt rd ON rd.dprt_id = ud.dprt_id
WHERE ud.dfusr_id = $1
AND rd.dfrole_id = $2
AND ud.is_active = true
AND rd.is_allowed = true
AND d.code = ANY($3)
LIMIT 1
`, userID, roleID, pqArray(deptCodes)).Scan(&dummy)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
okAny = true
c.set(key, okAny)
return okAny, nil
}
func cachedPiyasaIntersectionAny(pg *sql.DB, c *ttlCache, userID, roleID int64, piyasaCodes []string) (bool, error) {
key := "piyAny|" + itoa(userID) + "|" + itoa(roleID) + "|" + strings.Join(piyasaCodes, ",")
if v, ok := c.get(key); ok {
return v.(bool), nil
}
var dummy int
err := pg.QueryRow(`
SELECT 1
FROM dfusr_piyasa up
WHERE up.dfusr_id = $1
AND up.is_allowed = true
AND up.piyasa_code = ANY($2)
LIMIT 1
`, userID, pqArray(piyasaCodes)).Scan(&dummy)
if err == sql.ErrNoRows {
c.set(key, false)
return false, nil
}
if err != nil {
return false, err
}
c.set(key, true)
return true, nil
}
// =====================================================
// 🧲 DEFAULT SCOPE DETECTION
// =====================================================
// defaultIsScopeSensitive decides whether this module likely needs dept/piyasa checks.
// You can tighten/extend later.
func defaultIsScopeSensitive(module string, r *http.Request) bool {
switch module {
case "order", "customer", "report", "finance":
return true
default:
return false
}
}
// =====================================================
// 🔎 DEFAULT EXTRACTORS
// =====================================================
// We try to extract scope from:
// - query params: dprt, dprt_code, department, department_code, piyasa, piyasa_code, market
// - headers: X-Department, X-Piyasa
// - json body fields: department / department_code / dprt_code, piyasa / piyasa_code / market_code
// (body read is safe: we re-inject the body)
func defaultExtractDepartmentCodes(r *http.Request) []string {
var out []string
// query params
for _, k := range []string{"dprt", "dprt_code", "department", "department_code"} {
out = append(out, splitCSV(r.URL.Query().Get(k))...)
}
// headers
out = append(out, splitCSV(r.Header.Get("X-Department"))...)
// JSON body (if any)
out = append(out, extractFromJSONBody(r, []string{
"department", "department_code", "dprt", "dprt_code",
})...)
return out
}
func defaultExtractPiyasaCodes(r *http.Request) []string {
var out []string
for _, k := range []string{"piyasa", "piyasa_code", "market", "market_code"} {
out = append(out, splitCSV(r.URL.Query().Get(k))...)
}
out = append(out, splitCSV(r.Header.Get("X-Piyasa"))...)
out = append(out, extractFromJSONBody(r, []string{
"piyasa", "piyasa_code", "market", "market_code", "customer_attribute",
})...)
return out
}
func extractFromJSONBody(r *http.Request, keys []string) []string {
// Only for methods that might have body
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch:
default:
return nil
}
// read body (and restore)
raw, err := readBodyAndRestore(r, maxBodyRead)
if err != nil || len(raw) == 0 {
return nil
}
// try parse object
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return nil
}
var out []string
for _, k := range keys {
if v, ok := obj[k]; ok {
switch t := v.(type) {
case string:
out = append(out, splitCSV(t)...)
case []any:
for _, it := range t {
if s, ok := it.(string); ok {
out = append(out, splitCSV(s)...)
}
}
}
}
}
return out
}
func readBodyAndRestore(r *http.Request, limit int64) ([]byte, error) {
if r.Body == nil {
return nil, nil
}
// Read with limit
raw, err := io.ReadAll(io.LimitReader(r.Body, limit))
if err != nil {
return nil, err
}
// restore
r.Body = io.NopCloser(bytes.NewBuffer(raw))
return raw, nil
}
// =====================================================
// 🧼 HELPERS
// =====================================================
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func normalizeCodes(in []string) []string {
if len(in) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// pqArray: minimal adapter to pass []string as Postgres array.
// If you already use lib/pq, replace this with pq.Array.
// Here we use a simple JSON array -> Postgres can cast text[] from ARRAY[]? Not directly.
// So: YOU SHOULD USE lib/pq in your project. If it's already there, change pqArray() to pq.Array(slice).
//
// For now, we implement as a driver.Value using "{A,B}" format (Postgres text[] literal).
type pgTextArray string
func (a pgTextArray) Value() (any, error) { return string(a), nil }
func pqArray(ss []string) any {
// produce "{A,B,C}" as text[] literal
// escape quotes minimally
if len(ss) == 0 {
return pgTextArray("{}")
}
var b strings.Builder
b.WriteString("{")
for i, s := range ss {
if i > 0 {
b.WriteString(",")
}
s = strings.ReplaceAll(s, `"`, `\"`)
b.WriteString(`"`)
b.WriteString(s)
b.WriteString(`"`)
}
b.WriteString("}")
return pgTextArray(b.String())
}
func itoa(n int64) string {
return strconv.FormatInt(n, 10)
}
// isolate strconv usage without importing it globally in this snippet
func strconvFormatInt(n int64) string {
// local minimal
// NOTE: in real code just import strconv and use strconv.FormatInt(n, 10)
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [32]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + (n % 10))
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// optional: allow passing scope explicitly from handlers via context (advanced use)
type scopeKey string
// =====================================================
// 🔍 ROLE RESOLVER (code -> id) WITH CACHE
// =====================================================
func cachedRoleID(pg *sql.DB, c *ttlCache, roleCode string) (int64, error) {
key := "role|" + strings.ToLower(roleCode)
if v, ok := c.get(key); ok {
return v.(int64), nil
}
var id int64
err := pg.QueryRow(`
SELECT id
FROM dfrole
WHERE LOWER(code) = LOWER($1)
`, roleCode).Scan(&id)
if err != nil {
return 0, err
}
c.set(key, id)
return id, nil
}
// =====================================================
// 🧹 CACHE INVALIDATION (ADMIN)
// =====================================================
func ClearAuthzScopeCacheForUser(userID int64) {
// NOTE: this clears ALL scope cache.
// Simple & safe. Optimize later if needed.
if globalScopeCache != nil {
globalScopeCache.mu.Lock()
defer globalScopeCache.mu.Unlock()
for k := range globalScopeCache.m {
if strings.Contains(k, "|"+itoa(userID)+"|") {
delete(globalScopeCache.m, k)
}
}
}
}
// intersect: A ∩ B
func intersect(a, b []string) []string {
set := make(map[string]struct{}, len(a))
for _, v := range a {
set[v] = struct{}{}
}
var out []string
for _, v := range b {
if _, ok := set[v]; ok {
out = append(out, v)
}
}
return out
}
func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// =====================================================
// 0⃣ OPTIONS → PASS (CORS preflight)
// =====================================================
if r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
// =====================================================
// 1⃣ AUTH
// =====================================================
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// =====================================================
// 2⃣ REAL ROUTE TEMPLATE ( /api/users/{id} )
// =====================================================
route := mux.CurrentRoute(r)
if route == nil {
log.Printf("❌ AUTHZ: route not resolved: %s %s",
r.Method, r.URL.Path,
)
http.Error(w, "route not resolved", 403)
return
}
pathTemplate, err := route.GetPathTemplate()
if err != nil {
log.Printf("❌ AUTHZ: path template error: %v", err)
http.Error(w, "route template error", 403)
return
}
// =====================================================
// 3⃣ ROUTE LOOKUP (path + method)
// =====================================================
var module, action string
err = pg.QueryRow(`
SELECT module_code, action
FROM mk_sys_routes
WHERE path = $1
AND method = $2
`,
pathTemplate,
r.Method,
).Scan(&module, &action)
if err != nil {
log.Printf(
"❌ AUTHZ: route not registered: %s %s",
r.Method,
pathTemplate,
)
http.Error(w, "route permission not found", 403)
return
}
// =====================================================
// 4⃣ PERMISSION RESOLVE
// =====================================================
repo := permissions.NewPermissionRepository(pg)
allowed, err := repo.ResolvePermissionChain(
int64(claims.ID),
int64(claims.RoleID),
claims.DepartmentCodes,
module,
action,
)
if err != nil {
log.Printf(
"❌ AUTHZ: resolve error user=%d %s:%s err=%v",
claims.ID,
module,
action,
err,
)
http.Error(w, "forbidden", 403)
return
}
if !allowed {
log.Printf(
"⛔ AUTHZ: denied user=%d %s:%s",
claims.ID,
module,
action,
)
http.Error(w, "forbidden", 403)
return
}
// =====================================================
// 5⃣ PASS
// =====================================================
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,24 @@
package middlewares
import (
"bssapp-backend/auth"
"bssapp-backend/models"
"bssapp-backend/utils"
"net/http"
)
func CurrentUser(r *http.Request) (*models.User, bool) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
return nil, false
}
user := utils.UserFromClaims(claims)
if user == nil {
return nil, false
}
return user, true
}

View File

@@ -0,0 +1,66 @@
package middlewares
import (
"bssapp-backend/auth"
"database/sql"
"log"
"net/http"
"strings"
)
// 🔓 force_password_change=true iken izinli endpoint prefixleri
var passwordChangeAllowlist = []string{
"/api/password/change",
"/api/password/reset",
"/api/password/reset/validate",
"/api/auth/refresh",
}
func ForcePasswordChangeGuard(db *sql.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil {
log.Println("❌ FPC GUARD: claims NOT FOUND")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
log.Printf(
"🛡️ FPC GUARD user=%s force=%v path=%s",
claims.Username,
claims.ForcePasswordChange,
r.URL.Path,
)
// 🔓 Şifre değişimi zorunlu DEĞİL → serbest
if !claims.ForcePasswordChange {
next.ServeHTTP(w, r)
return
}
// 🔐 Şifre değişimi ZORUNLU → allowlist kontrolü
for _, allowed := range passwordChangeAllowlist {
if strings.HasPrefix(r.URL.Path, allowed) {
log.Printf(
"✅ FPC GUARD PASS user=%s path=%s",
claims.Username,
r.URL.Path,
)
next.ServeHTTP(w, r)
return
}
}
// ⛔ Zorunlu ama yanlış endpoint
log.Printf(
"⛔ FPC GUARD BLOCK user=%s path=%s",
claims.Username,
r.URL.Path,
)
http.Error(w, "password change required", http.StatusUnauthorized)
})
}
}

View File

@@ -0,0 +1,58 @@
package middlewares
import (
"bssapp-backend/auth"
"log"
"net/http"
"strings"
)
var publicPaths = []string{
"/api/auth/login",
"/api/auth/refresh",
"/api/password/forgot",
"/api/password/reset",
}
func GlobalAuthMiddleware(db any, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// PUBLIC ROUTES
for _, p := range publicPaths {
if strings.HasPrefix(path, p) {
next.ServeHTTP(w, r)
return
}
}
// JWT
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims, err := auth.ValidateToken(parts[1])
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := auth.WithClaims(r.Context(), claims)
log.Printf("🔐 GLOBAL AUTH user=%d role=%s",
claims.ID,
claims.RoleCode,
)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,56 @@
package middlewares
import (
"bssapp-backend/auth"
"bssapp-backend/ctxkeys"
"context"
)
func getClaims(ctx context.Context) *auth.Claims {
if v := ctx.Value(ctxkeys.UserContextKey); v != nil {
if c, ok := v.(*auth.Claims); ok {
return c
}
}
return nil
}
// --------------------------------------------------
// 🔐 SESSION
// --------------------------------------------------
func GetSessionID(ctx context.Context) string {
if c := getClaims(ctx); c != nil {
return c.SessionID
}
return ""
}
// --------------------------------------------------
// 🔑 USER ID (mk_dfusr.id)
// --------------------------------------------------
func GetUserID(ctx context.Context) int64 {
if c := getClaims(ctx); c != nil {
return c.ID
}
return 0
}
// --------------------------------------------------
// 👤 USERNAME
// --------------------------------------------------
func GetUsername(ctx context.Context) string {
if c := getClaims(ctx); c != nil {
return c.Username
}
return ""
}
// --------------------------------------------------
// 🧩 ROLE
// --------------------------------------------------
func GetRoleCode(ctx context.Context) string {
if c := getClaims(ctx); c != nil && c.RoleCode != "" {
return c.RoleCode
}
return "public"
}

View File

@@ -0,0 +1,59 @@
package middlewares
import (
"net"
"net/http"
"sync"
"time"
)
type rateEntry struct {
Count int
ExpiresAt time.Time
}
var (
rateMu sync.Mutex
rateDB = make(map[string]*rateEntry)
)
func RateLimit(keyFn func(*http.Request) string, limit int, window time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := keyFn(r)
now := time.Now()
rateMu.Lock()
e, ok := rateDB[key]
if !ok || now.After(e.ExpiresAt) {
e = &rateEntry{
Count: 0,
ExpiresAt: now.Add(window),
}
rateDB[key] = e
}
e.Count++
if e.Count > limit {
rateMu.Unlock()
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
rateMu.Unlock()
next.ServeHTTP(w, r)
})
}
}
// helpers
func RateByIP(r *http.Request) string {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return "ip:" + ip
}
func RateByUser(r *http.Request) string {
return "user:" + r.URL.Path // id pathten okunabilir
}

View File

@@ -0,0 +1,108 @@
package middlewares
import (
"bssapp-backend/auth"
"bssapp-backend/internal/auditlog"
"log"
"net"
"net/http"
"time"
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{
ResponseWriter: w,
status: 200,
}
// ---------- CLAIMS ----------
claims, _ := auth.GetClaimsFromContext(r.Context())
// ---------- IP ----------
ip := r.RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
// ---------- UA ----------
ua := r.UserAgent()
// ---------- SESSION ----------
sessionID := ""
if claims != nil {
sessionID = claims.SessionID
}
hasAuth := r.Header.Get("Authorization") != ""
log.Printf("➡️ %s %s | auth=%v", r.Method, r.URL.Path, hasAuth)
// ---------- RUN ----------
next.ServeHTTP(sw, r)
finish := time.Now()
dur := int(finish.Sub(start).Milliseconds())
log.Printf("⬅️ %s %s | status=%d | %s", r.Method, r.URL.Path, sw.status, time.Since(start))
// ---------- AUDIT (route_access) ----------
al := auditlog.ActivityLog{
ActionType: "route_access",
ActionCategory: "nav",
ActionTarget: r.URL.Path,
Description: r.Method + " " + r.URL.Path,
IpAddress: ip,
UserAgent: ua,
SessionID: sessionID,
RequestStartedAt: start,
RequestFinishedAt: finish,
DurationMs: dur,
HttpStatus: sw.status,
IsSuccess: sw.status < 400,
}
// ---------- CLAIMS → LOG ----------
if claims != nil {
al.Username = claims.Username
al.RoleCode = claims.RoleCode
al.DfUsrID = int64(claims.ID)
// Eğer claims içinde UUID varsa ekle (sende varsa aç)
// al.UserID = claims.UserUUID
} else {
al.RoleCode = "public"
}
// ---------- ERROR ----------
if sw.status >= 400 {
al.ErrorMessage = http.StatusText(sw.status)
}
// ✅ ESKİ: auditlog.Write(al)
// ✅ YENİ:
auditlog.Enqueue(r.Context(), al)
if claims == nil {
log.Println("⚠️ LOGGER: claims is NIL")
} else {
log.Printf("✅ LOGGER CLAIMS user=%s role=%s id=%d", claims.Username, claims.RoleCode, claims.ID)
}
})
}