From daedff2880bda36fa53e1a13374fb51833fcd7a1 Mon Sep 17 00:00:00 2001 From: MEHMETKECECI Date: Mon, 16 Feb 2026 16:45:04 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/middlewares/auth_middleware.go | 34 +++- svc/middlewares/authz_v2.go | 39 +++- svc/routes/first_password_change.go | 257 ++++++++++++++++++++------- svc/routes/login.go | 32 ++-- ui/src/pages/FirstPasswordChange.vue | 47 +++-- ui/src/services/api.js | 14 +- 6 files changed, 310 insertions(+), 113 deletions(-) diff --git a/svc/middlewares/auth_middleware.go b/svc/middlewares/auth_middleware.go index ff37dfa..1177f2e 100644 --- a/svc/middlewares/auth_middleware.go +++ b/svc/middlewares/auth_middleware.go @@ -10,29 +10,49 @@ import ( 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) + log.Printf( + "AUTH_MIDDLEWARE 401 reason=missing_authorization_header method=%s path=%s", + r.Method, + r.URL.Path, + ) + http.Error(w, "unauthorized: authorization header missing", http.StatusUnauthorized) return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + log.Printf( + "AUTH_MIDDLEWARE 401 reason=invalid_authorization_format method=%s path=%s raw=%q", + r.Method, + r.URL.Path, + authHeader, + ) + http.Error(w, "unauthorized: invalid authorization format", http.StatusUnauthorized) return } claims, err := auth.ValidateToken(parts[1]) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + log.Printf( + "AUTH_MIDDLEWARE 401 reason=token_validation_failed method=%s path=%s err=%v", + r.Method, + r.URL.Path, + err, + ) + http.Error(w, "unauthorized: token validation failed", 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) + log.Printf( + "AUTH_MIDDLEWARE PASS user=%d role=%s method=%s path=%s", + claims.ID, + claims.RoleCode, + r.Method, + r.URL.Path, + ) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/svc/middlewares/authz_v2.go b/svc/middlewares/authz_v2.go index 4d347dd..57cbb3c 100644 --- a/svc/middlewares/authz_v2.go +++ b/svc/middlewares/authz_v2.go @@ -859,7 +859,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { // ===================================================== claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) + log.Printf( + "AUTHZ_BY_ROUTE 401 reason=claims_missing method=%s path=%s", + r.Method, + r.URL.Path, + ) + http.Error(w, "unauthorized: token missing or invalid", http.StatusUnauthorized) return } @@ -873,7 +878,7 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { r.Method, r.URL.Path, ) - http.Error(w, "route not resolved", 403) + http.Error(w, "route not resolved", http.StatusForbidden) return } @@ -881,7 +886,14 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { if err != nil { log.Printf("❌ AUTHZ: path template error: %v", err) - http.Error(w, "route template error", 403) + http.Error(w, "route template error", http.StatusForbidden) + return + } + + // Password change must be reachable for every authenticated user. + // This avoids permission deadlocks during forced first-password flow. + if pathTemplate == "/api/password/change" { + next.ServeHTTP(w, r) return } @@ -908,7 +920,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { pathTemplate, ) - http.Error(w, "route permission not found", 403) + if pathTemplate == "/api/password/change" { + http.Error(w, "password change route permission not found", http.StatusForbidden) + return + } + + http.Error(w, "route permission not found", http.StatusForbidden) return } @@ -935,7 +952,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { err, ) - http.Error(w, "forbidden", 403) + if pathTemplate == "/api/password/change" { + http.Error(w, "password change permission check failed", http.StatusForbidden) + return + } + + http.Error(w, "forbidden", http.StatusForbidden) return } @@ -948,7 +970,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler { action, ) - http.Error(w, "forbidden", 403) + if pathTemplate == "/api/password/change" { + http.Error(w, "password change forbidden: permission denied", http.StatusForbidden) + return + } + + http.Error(w, "forbidden", http.StatusForbidden) return } diff --git a/svc/routes/first_password_change.go b/svc/routes/first_password_change.go index 8530e65..fe34279 100644 --- a/svc/routes/first_password_change.go +++ b/svc/routes/first_password_change.go @@ -6,10 +6,13 @@ import ( "bssapp-backend/internal/security" "bssapp-backend/models" "bssapp-backend/repository" + "bssapp-backend/services" "database/sql" "encoding/json" + "errors" "log" "net/http" + "strings" "time" "golang.org/x/crypto/bcrypt" @@ -17,21 +20,19 @@ import ( func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - // -------------------------------------------------- - // 1️⃣ JWT CLAIMS - // -------------------------------------------------- claims, ok := auth.GetClaimsFromContext(r.Context()) if !ok || claims == nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) + log.Printf( + "FIRST_PASSWORD_CHANGE 401 reason=claims_missing method=%s path=%s", + r.Method, + r.URL.Path, + ) + http.Error(w, "unauthorized: token missing or invalid", http.StatusUnauthorized) return } - // -------------------------------------------------- - // 2️⃣ PAYLOAD - // -------------------------------------------------- var req struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` @@ -42,48 +43,75 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { return } + req.CurrentPassword = strings.TrimSpace(req.CurrentPassword) + req.NewPassword = strings.TrimSpace(req.NewPassword) if req.CurrentPassword == "" || req.NewPassword == "" { http.Error(w, "password fields required", http.StatusUnprocessableEntity) return } - // -------------------------------------------------- - // 3️⃣ LOAD USER (mk_dfusr) - // -------------------------------------------------- - var currentHash string - err := db.QueryRow(` - SELECT password_hash - FROM mk_dfusr - WHERE id = $1 - `, claims.ID).Scan(¤tHash) + mkRepo := repository.NewMkUserRepository(db) + legacyRepo := repository.NewUserRepository(db) - if err != nil || currentHash == "" { - http.Error(w, "user not found", http.StatusUnauthorized) + mkUser, mkErr := mkRepo.GetByID(claims.ID) + hasMkUser := mkErr == nil + if mkErr != nil && !errors.Is(mkErr, repository.ErrMkUserNotFound) { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=mk_lookup_failed user_id=%d err=%v", + claims.ID, + mkErr, + ) + http.Error(w, "user lookup failed", http.StatusInternalServerError) return } - // -------------------------------------------------- - // 4️⃣ CURRENT PASSWORD CHECK - // -------------------------------------------------- - if bcrypt.CompareHashAndPassword( - []byte(currentHash), - []byte(req.CurrentPassword), - ) != nil { - http.Error(w, "mevcut şifre hatalı", http.StatusUnauthorized) - return + var legacyUser *models.User + + // If user already exists in mk_dfusr with hash, verify against mk hash. + // Otherwise verify against legacy dfusr password before migration. + if hasMkUser && strings.TrimSpace(mkUser.PasswordHash) != "" { + if bcrypt.CompareHashAndPassword( + []byte(mkUser.PasswordHash), + []byte(req.CurrentPassword), + ) != nil { + log.Printf( + "FIRST_PASSWORD_CHANGE 401 reason=current_password_mismatch_mk user_id=%d username=%s", + claims.ID, + claims.Username, + ) + http.Error(w, "mevcut sifre hatali", http.StatusUnauthorized) + return + } + } else { + var err error + legacyUser, err = legacyRepo.GetLegacyUserForLogin(claims.Username) + if err != nil || legacyUser == nil || !legacyUser.IsActive || int64(legacyUser.ID) != claims.ID { + log.Printf( + "FIRST_PASSWORD_CHANGE 401 reason=legacy_user_not_found user_id=%d username=%s err=%v", + claims.ID, + claims.Username, + err, + ) + http.Error(w, "unauthorized: user not found", http.StatusUnauthorized) + return + } + + if !services.CheckPasswordWithLegacy(legacyUser, req.CurrentPassword) { + log.Printf( + "FIRST_PASSWORD_CHANGE 401 reason=current_password_mismatch_legacy user_id=%d username=%s", + claims.ID, + claims.Username, + ) + http.Error(w, "mevcut sifre hatali", http.StatusUnauthorized) + return + } } - // -------------------------------------------------- - // 5️⃣ PASSWORD POLICY - // -------------------------------------------------- if err := security.ValidatePassword(req.NewPassword); err != nil { http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - // -------------------------------------------------- - // 6️⃣ HASH NEW PASSWORD - // -------------------------------------------------- hash, err := bcrypt.GenerateFromPassword( []byte(req.NewPassword), bcrypt.DefaultCost, @@ -93,39 +121,133 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { return } - // -------------------------------------------------- - // 7️⃣ UPDATE mk_dfusr - // -------------------------------------------------- - _, err = db.Exec(` - UPDATE mk_dfusr - SET - password_hash = $1, - force_password_change = false, - password_updated_at = NOW(), - updated_at = NOW() - WHERE id = $2 - `, string(hash), claims.ID) - + tx, err := db.Begin() if err != nil { - http.Error(w, "password update failed", http.StatusInternalServerError) + http.Error(w, "transaction error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + migratedFromLegacy := false + + if hasMkUser { + res, err := tx.Exec(` + UPDATE mk_dfusr + SET + password_hash = $1, + force_password_change = false, + password_updated_at = NOW(), + updated_at = NOW() + WHERE id = $2 + `, string(hash), claims.ID) + if err != nil { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=password_update_failed user_id=%d err=%v", + claims.ID, + err, + ) + http.Error(w, "password update failed", http.StatusInternalServerError) + return + } + + affected, _ := res.RowsAffected() + if affected == 0 { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=password_update_no_rows user_id=%d", + claims.ID, + ) + http.Error(w, "password update failed", http.StatusInternalServerError) + return + } + } else { + if legacyUser == nil { + // Defensive fallback, should not happen. + legacyUser, err = legacyRepo.GetLegacyUserForLogin(claims.Username) + if err != nil || legacyUser == nil || int64(legacyUser.ID) != claims.ID { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=legacy_reload_failed user_id=%d username=%s err=%v", + claims.ID, + claims.Username, + err, + ) + http.Error(w, "legacy user reload failed", http.StatusInternalServerError) + return + } + } + + _, err = tx.Exec(` + INSERT INTO mk_dfusr ( + id, + username, + email, + full_name, + mobile, + address, + is_active, + password_hash, + force_password_change, + password_updated_at, + created_at, + updated_at + ) + VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,false,NOW(),NOW(),NOW() + ) + ON CONFLICT (id) + DO UPDATE SET + username = EXCLUDED.username, + email = EXCLUDED.email, + full_name = EXCLUDED.full_name, + mobile = EXCLUDED.mobile, + address = EXCLUDED.address, + is_active = EXCLUDED.is_active, + password_hash = EXCLUDED.password_hash, + force_password_change = false, + password_updated_at = NOW(), + updated_at = NOW() + `, + int64(legacyUser.ID), + strings.TrimSpace(legacyUser.Username), + strings.TrimSpace(legacyUser.Email), + strings.TrimSpace(legacyUser.FullName), + strings.TrimSpace(legacyUser.Mobile), + strings.TrimSpace(legacyUser.Address), + legacyUser.IsActive, + string(hash), + ) + if err != nil { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=legacy_migration_failed user_id=%d username=%s err=%v", + claims.ID, + claims.Username, + err, + ) + http.Error(w, "legacy migration failed", http.StatusInternalServerError) + return + } + + migratedFromLegacy = true + } + + if err := tx.Commit(); err != nil { + log.Printf( + "FIRST_PASSWORD_CHANGE 500 reason=tx_commit_failed user_id=%d err=%v", + claims.ID, + err, + ) + http.Error(w, "commit failed", http.StatusInternalServerError) return } - // -------------------------------------------------- - // 8️⃣ REFRESH TOKEN REVOKE - // -------------------------------------------------- - _ = repository. - NewRefreshTokenRepository(db). - RevokeAllForUser(claims.ID) + _ = repository.NewRefreshTokenRepository(db).RevokeAllForUser(claims.ID) - // -------------------------------------------------- - // 9️⃣ NEW JWT (TEK DOĞRU YOL) - // -------------------------------------------------- newClaims := auth.BuildClaimsFromUser( &models.MkUser{ ID: claims.ID, Username: claims.Username, + RoleID: claims.RoleID, RoleCode: claims.RoleCode, + DepartmentCodes: claims.DepartmentCodes, V3Username: claims.V3Username, V3UserGroup: claims.V3UserGroup, SessionID: claims.SessionID, @@ -144,18 +266,16 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { return } - // -------------------------------------------------- - // 🔟 AUDIT - // -------------------------------------------------- + source := "mk_password_update" + if migratedFromLegacy { + source = "legacy_migration_completed" + } auditlog.ForcePasswordChangeCompleted( r.Context(), claims.ID, - "self_change", + source, ) - // -------------------------------------------------- - // 1️⃣1️⃣ RESPONSE - // -------------------------------------------------- _ = json.NewEncoder(w).Encode(map[string]any{ "token": newToken, "user": map[string]any{ @@ -163,7 +283,14 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { "username": claims.Username, "force_password_change": false, }, + "migrated_from_legacy": migratedFromLegacy, }) - log.Printf("✅ FIRST-PASS claims user=%d role=%s", claims.ID, claims.RoleCode) + + log.Printf( + "FIRST_PASSWORD_CHANGE 200 user=%d role=%s migrated_from_legacy=%v", + claims.ID, + claims.RoleCode, + migratedFromLegacy, + ) } } diff --git a/svc/routes/login.go b/svc/routes/login.go index 89685f8..e05530c 100644 --- a/svc/routes/login.go +++ b/svc/routes/login.go @@ -133,31 +133,23 @@ func LoginHandler(db *sql.DB) http.HandlerFunc { } // ================================================== - // 3️⃣ MIGRATION (dfusr → mk_dfusr) + // 3️⃣ LEGACY SESSION (PENDING MIGRATION) + // - mk_dfusr migration is completed in /api/password/change // ================================================== - newHash, err := bcrypt.GenerateFromPassword( - []byte(pass), - bcrypt.DefaultCost, - ) - if err != nil { - http.Error(w, "Şifre üretilemedi", http.StatusInternalServerError) - return + mkUser = &models.MkUser{ + ID: int64(legacyUser.ID), + Username: legacyUser.Username, + Email: legacyUser.Email, + IsActive: legacyUser.IsActive, + RoleID: int64(legacyUser.RoleID), + RoleCode: legacyUser.RoleCode, + ForcePasswordChange: true, } - mkUser, err = mkRepo.CreateFromLegacy(legacyUser, string(newHash)) - if err != nil { - log.Println("❌ CREATE_FROM_LEGACY FAILED:", err) - http.Error(w, "Kullanıcı migrate edilemedi", http.StatusInternalServerError) - return - } - - // 🔥 KRİTİK: TOKEN GUARD İÇİN GARANTİ - mkUser.ForcePasswordChange = true - auditlog.Write(auditlog.ActivityLog{ - ActionType: "LEGACY_USER_MIGRATED", + ActionType: "LEGACY_USER_LOGIN_PENDING_MIGRATION", ActionCategory: "security", - Description: "dfusr -> mk_dfusr on login", + Description: "legacy login ok, first password change required", IsSuccess: true, }) diff --git a/ui/src/pages/FirstPasswordChange.vue b/ui/src/pages/FirstPasswordChange.vue index 854dfbe..f0867e6 100644 --- a/ui/src/pages/FirstPasswordChange.vue +++ b/ui/src/pages/FirstPasswordChange.vue @@ -57,7 +57,7 @@ - - diff --git a/ui/src/services/api.js b/ui/src/services/api.js index 61a92a5..85e1216 100644 --- a/ui/src/services/api.js +++ b/ui/src/services/api.js @@ -1,9 +1,7 @@ -// src/services/api.js import axios from 'axios' import qs from 'qs' import { useAuthStore } from 'stores/authStore' -// 🔥 ENV YOK export const API_BASE_URL = '/api' const api = axios.create({ @@ -38,17 +36,22 @@ api.interceptors.response.use( r => r, async (error) => { const status = error?.response?.status + const requestUrl = String(error?.config?.url || '') const hasBlob = typeof Blob !== 'undefined' && error?.response?.data instanceof Blob + const isPasswordChangeRequest = + requestUrl.startsWith('/password/change') || + requestUrl.startsWith('/me/password') if ((status >= 500 || hasBlob) && error) { const method = String(error?.config?.method || 'GET').toUpperCase() - const url = error?.config?.url || '' const detail = await extractApiErrorDetail(error) error.parsedMessage = detail - console.error(`❌ API ${status || '-'} ${method} ${url}: ${detail}`) + console.error(`API ${status || '-'} ${method} ${requestUrl}: ${detail}`) } - if (error?.response?.status === 401 && !isLoggingOut) { + // Password change endpoints may return 401 for business reasons + // (for example current password mismatch). Keep session in that case. + if (status === 401 && !isPasswordChangeRequest && !isLoggingOut) { isLoggingOut = true try { useAuthStore().clearSession() @@ -56,6 +59,7 @@ api.interceptors.response.use( isLoggingOut = false } } + return Promise.reject(error) } )