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 {
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))
})

View File

@@ -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
}

View File

@@ -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(&currentHash)
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,
)
}
}

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(
[]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,
})