Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-02-16 16:45:04 +03:00
parent 54182e97c5
commit daedff2880
6 changed files with 310 additions and 113 deletions

View File

@@ -10,29 +10,49 @@ import (
func AuthMiddleware(db *sql.DB, next http.Handler) http.Handler { func AuthMiddleware(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { 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 return
} }
parts := strings.SplitN(authHeader, " ", 2) parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" { 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 return
} }
claims, err := auth.ValidateToken(parts[1]) claims, err := auth.ValidateToken(parts[1])
if err != nil { 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 return
} }
// 🔥 BU SATIR ŞART
ctx := auth.WithClaims(r.Context(), claims) ctx := auth.WithClaims(r.Context(), claims)
log.Printf(
log.Printf("🔐 AUTH CTX SET user=%d role=%s", claims.ID, claims.RoleCode) "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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@@ -859,7 +859,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
// ===================================================== // =====================================================
claims, ok := auth.GetClaimsFromContext(r.Context()) claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil { 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 return
} }
@@ -873,7 +878,7 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
r.Method, r.URL.Path, r.Method, r.URL.Path,
) )
http.Error(w, "route not resolved", 403) http.Error(w, "route not resolved", http.StatusForbidden)
return return
} }
@@ -881,7 +886,14 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
if err != nil { if err != nil {
log.Printf("❌ AUTHZ: path template error: %v", err) 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 return
} }
@@ -908,7 +920,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
pathTemplate, 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 return
} }
@@ -935,7 +952,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
err, 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 return
} }
@@ -948,7 +970,12 @@ func AuthzGuardByRoute(pg *sql.DB) func(http.Handler) http.Handler {
action, 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 return
} }

View File

@@ -6,10 +6,13 @@ import (
"bssapp-backend/internal/security" "bssapp-backend/internal/security"
"bssapp-backend/models" "bssapp-backend/models"
"bssapp-backend/repository" "bssapp-backend/repository"
"bssapp-backend/services"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -17,21 +20,19 @@ import (
func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc { func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
// --------------------------------------------------
// 1⃣ JWT CLAIMS
// --------------------------------------------------
claims, ok := auth.GetClaimsFromContext(r.Context()) claims, ok := auth.GetClaimsFromContext(r.Context())
if !ok || claims == nil { 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 return
} }
// --------------------------------------------------
// 2⃣ PAYLOAD
// --------------------------------------------------
var req struct { var req struct {
CurrentPassword string `json:"current_password"` CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"` NewPassword string `json:"new_password"`
@@ -42,48 +43,75 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc {
return return
} }
req.CurrentPassword = strings.TrimSpace(req.CurrentPassword)
req.NewPassword = strings.TrimSpace(req.NewPassword)
if req.CurrentPassword == "" || req.NewPassword == "" { if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "password fields required", http.StatusUnprocessableEntity) http.Error(w, "password fields required", http.StatusUnprocessableEntity)
return return
} }
// -------------------------------------------------- mkRepo := repository.NewMkUserRepository(db)
// 3⃣ LOAD USER (mk_dfusr) legacyRepo := repository.NewUserRepository(db)
// --------------------------------------------------
var currentHash string
err := db.QueryRow(`
SELECT password_hash
FROM mk_dfusr
WHERE id = $1
`, claims.ID).Scan(&currentHash)
if err != nil || currentHash == "" { mkUser, mkErr := mkRepo.GetByID(claims.ID)
http.Error(w, "user not found", http.StatusUnauthorized) 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 return
} }
// -------------------------------------------------- var legacyUser *models.User
// 4⃣ CURRENT PASSWORD CHECK
// -------------------------------------------------- // If user already exists in mk_dfusr with hash, verify against mk hash.
if bcrypt.CompareHashAndPassword( // Otherwise verify against legacy dfusr password before migration.
[]byte(currentHash), if hasMkUser && strings.TrimSpace(mkUser.PasswordHash) != "" {
[]byte(req.CurrentPassword), if bcrypt.CompareHashAndPassword(
) != nil { []byte(mkUser.PasswordHash),
http.Error(w, "mevcut şifre hatalı", http.StatusUnauthorized) []byte(req.CurrentPassword),
return ) != 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 { if err := security.ValidatePassword(req.NewPassword); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity) http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return return
} }
// --------------------------------------------------
// 6⃣ HASH NEW PASSWORD
// --------------------------------------------------
hash, err := bcrypt.GenerateFromPassword( hash, err := bcrypt.GenerateFromPassword(
[]byte(req.NewPassword), []byte(req.NewPassword),
bcrypt.DefaultCost, bcrypt.DefaultCost,
@@ -93,39 +121,133 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc {
return return
} }
// -------------------------------------------------- tx, err := db.Begin()
// 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)
if err != nil { 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 return
} }
// -------------------------------------------------- _ = repository.NewRefreshTokenRepository(db).RevokeAllForUser(claims.ID)
// 8⃣ REFRESH TOKEN REVOKE
// --------------------------------------------------
_ = repository.
NewRefreshTokenRepository(db).
RevokeAllForUser(claims.ID)
// --------------------------------------------------
// 9⃣ NEW JWT (TEK DOĞRU YOL)
// --------------------------------------------------
newClaims := auth.BuildClaimsFromUser( newClaims := auth.BuildClaimsFromUser(
&models.MkUser{ &models.MkUser{
ID: claims.ID, ID: claims.ID,
Username: claims.Username, Username: claims.Username,
RoleID: claims.RoleID,
RoleCode: claims.RoleCode, RoleCode: claims.RoleCode,
DepartmentCodes: claims.DepartmentCodes,
V3Username: claims.V3Username, V3Username: claims.V3Username,
V3UserGroup: claims.V3UserGroup, V3UserGroup: claims.V3UserGroup,
SessionID: claims.SessionID, SessionID: claims.SessionID,
@@ -144,18 +266,16 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc {
return return
} }
// -------------------------------------------------- source := "mk_password_update"
// 🔟 AUDIT if migratedFromLegacy {
// -------------------------------------------------- source = "legacy_migration_completed"
}
auditlog.ForcePasswordChangeCompleted( auditlog.ForcePasswordChangeCompleted(
r.Context(), r.Context(),
claims.ID, claims.ID,
"self_change", source,
) )
// --------------------------------------------------
// 1⃣1⃣ RESPONSE
// --------------------------------------------------
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"token": newToken, "token": newToken,
"user": map[string]any{ "user": map[string]any{
@@ -163,7 +283,14 @@ func FirstPasswordChangeHandler(db *sql.DB) http.HandlerFunc {
"username": claims.Username, "username": claims.Username,
"force_password_change": false, "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,
)
} }
} }

View File

@@ -133,31 +133,23 @@ func LoginHandler(db *sql.DB) http.HandlerFunc {
} }
// ================================================== // ==================================================
// 3MIGRATION (dfusr → mk_dfusr) // 3LEGACY SESSION (PENDING MIGRATION)
// - mk_dfusr migration is completed in /api/password/change
// ================================================== // ==================================================
newHash, err := bcrypt.GenerateFromPassword( mkUser = &models.MkUser{
[]byte(pass), ID: int64(legacyUser.ID),
bcrypt.DefaultCost, Username: legacyUser.Username,
) Email: legacyUser.Email,
if err != nil { IsActive: legacyUser.IsActive,
http.Error(w, "Şifre üretilemedi", http.StatusInternalServerError) RoleID: int64(legacyUser.RoleID),
return 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{ auditlog.Write(auditlog.ActivityLog{
ActionType: "LEGACY_USER_MIGRATED", ActionType: "LEGACY_USER_LOGIN_PENDING_MIGRATION",
ActionCategory: "security", ActionCategory: "security",
Description: "dfusr -> mk_dfusr on login", Description: "legacy login ok, first password change required",
IsSuccess: true, IsSuccess: true,
}) })

View File

@@ -57,7 +57,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import api from 'src/services/api' import api, { extractApiErrorDetail } from 'src/services/api'
import { useAuthStore } from 'stores/authStore.js' import { useAuthStore } from 'stores/authStore.js'
const router = useRouter() const router = useRouter()
@@ -69,6 +69,33 @@ const newPassword2 = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
function resolveFirstPasswordError(status, detail) {
const text = String(detail || '').trim()
const lower = text.toLowerCase()
if (status === 401) {
if (lower.includes('mevcut sifre') || lower.includes('current password')) {
return 'Mevcut şifreyi yanlış girdiniz.'
}
if (lower.includes('token') || lower.includes('authorization')) {
return 'Oturum doğrulanamadı. Lütfen tekrar giriş yapın.'
}
return text || 'Kimlik doğrulama hatası (401). Lütfen tekrar giriş yapın.'
}
if (status === 403) {
if (lower.includes('permission')) {
return 'Şifre değiştirme yetkiniz yok (403). Sistem yöneticinize başvurun.'
}
return text || 'Bu işlem için yetkiniz yok (403).'
}
return text || 'Şifre güncellenemedi'
}
async function submit () { async function submit () {
error.value = '' error.value = ''
@@ -84,25 +111,25 @@ async function submit () {
loading.value = true loading.value = true
try { try {
// 🔐 TOKEN interceptor ile otomatik
await api.post('/password/change', { await api.post('/password/change', {
current_password: currentPassword.value, current_password: currentPassword.value,
new_password: newPassword.value new_password: newPassword.value
}) })
// Şifre değişimi sonrası tekrar giriş zorunlu
auth.clearSession() auth.clearSession()
router.replace('/login') router.replace('/login')
} catch (e) { } catch (e) {
error.value = const status = e?.response?.status
e?.data?.message || const detail = await extractApiErrorDetail(e)
e?.message ||
'Şifre güncellenemedi' console.error('FIRST_PASSWORD_CHANGE failed', {
status,
detail
})
error.value = resolveFirstPasswordError(status, detail)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
</script> </script>

View File

@@ -1,9 +1,7 @@
// src/services/api.js
import axios from 'axios' import axios from 'axios'
import qs from 'qs' import qs from 'qs'
import { useAuthStore } from 'stores/authStore' import { useAuthStore } from 'stores/authStore'
// 🔥 ENV YOK
export const API_BASE_URL = '/api' export const API_BASE_URL = '/api'
const api = axios.create({ const api = axios.create({
@@ -38,17 +36,22 @@ api.interceptors.response.use(
r => r, r => r,
async (error) => { async (error) => {
const status = error?.response?.status const status = error?.response?.status
const requestUrl = String(error?.config?.url || '')
const hasBlob = typeof Blob !== 'undefined' && error?.response?.data instanceof Blob 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) { if ((status >= 500 || hasBlob) && error) {
const method = String(error?.config?.method || 'GET').toUpperCase() const method = String(error?.config?.method || 'GET').toUpperCase()
const url = error?.config?.url || ''
const detail = await extractApiErrorDetail(error) const detail = await extractApiErrorDetail(error)
error.parsedMessage = detail 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 isLoggingOut = true
try { try {
useAuthStore().clearSession() useAuthStore().clearSession()
@@ -56,6 +59,7 @@ api.interceptors.response.use(
isLoggingOut = false isLoggingOut = false
} }
} }
return Promise.reject(error) return Promise.reject(error)
} }
) )