Kullanıcı Girişlerini E-Posta ile Bildiren WordPress Eklentisi Nasıl Yapılır
Bir gün patronun sana geliyor: “Kim ne zaman giriş yapıyor, bilelim istiyoruz.” Basit bir istek gibi görünüyor ama WordPress’te bunu düzgünce yapan, hem güvenli hem de yönetilebilir bir çözüm kurmak sanıldığı kadar kolay değil. Hazır eklentiler ya fazla şişirilmiş ya da tam ihtiyacınızı karşılamıyor. Bu yüzden bugün sıfırdan, production ortamında kullanabileceğiniz bir WordPress eklentisi yazacağız. Sadece “hello world” seviyesinde değil, gerçekten işe yarayan, veritabanına yazan, e-posta bildirimi gönderen ve yönetim panelinden raporlanabilen bir şey.
Eklentinin Ne Yapacağını Netleştirelim
Kodu yazmadan önce scope’u belirlemek şart. Yıllarca “hızlıca yapayım” deyip sonradan yamalamayla boğuştuğumu yaşadım.
Bu eklenti şunları yapacak:
- Başarılı girişleri kaydetmek: kullanıcı adı, IP adresi, tarayıcı bilgisi, tarih/saat
- Başarısız giriş denemelerini kaydetmek: kaba kuvvet saldırılarını fark etmek için
- E-posta bildirimi göndermek: özellikle admin girişlerinde
- Yönetim panelinde tablo görünümü sunmak: son 100 giriş
- Eski kayıtları otomatik temizlemek: veritabanı şişmesin
Bunları bir eklentiye toparlayınca ortaya oldukça kullanışlı bir araç çıkıyor.
Eklenti Dosya Yapısı
WordPress eklenti geliştirmede dosya organizasyonu hem bakımı kolaylaştırır hem de ilerideki geliştirmeler için zemin hazırlar.
wp-content/plugins/kullanici-giris-bildirimi/
├── kullanici-giris-bildirimi.php # Ana eklenti dosyası
├── includes/
│ ├── class-login-tracker.php # Ana takip sınıfı
│ ├── class-database.php # Veritabanı işlemleri
│ └── class-notifier.php # E-posta bildirimleri
├── admin/
│ ├── class-admin-page.php # Yönetim sayfası
│ └── admin-style.css # Basit stiller
└── uninstall.php # Temizlik dosyası
Bu yapıyı seviyorum çünkü her şeyi tek dosyaya tıkmak yerine sorumlulukları ayırıyoruz. Altı ay sonra “e-posta kısmını değiştir” dendiğinde nereye bakacağınızı biliyorsunuz.
Ana Eklenti Dosyası
kullanici-giris-bildirimi.php dosyasıyla başlayalım. Bu dosya WordPress’in eklentiyi tanıması için gerekli başlık bilgilerini ve temel kurulum kodunu içerir.
<?php
/**
* Plugin Name: Kullanıcı Giriş Bildirimi
* Plugin URI: https://siteniz.com/eklenti
* Description: Kullanıcı girişlerini kaydeder ve bildirim gönderir.
* Version: 1.0.0
* Author: Sistem Yöneticisi
* License: GPL v2 or later
* Text Domain: kullanici-giris-bildirimi
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'KGB_VERSION', '1.0.0' );
define( 'KGB_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'KGB_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'KGB_TABLE_NAME', 'kgb_login_logs' );
// Sınıfları yükle
require_once KGB_PLUGIN_DIR . 'includes/class-database.php';
require_once KGB_PLUGIN_DIR . 'includes/class-login-tracker.php';
require_once KGB_PLUGIN_DIR . 'includes/class-notifier.php';
require_once KGB_PLUGIN_DIR . 'admin/class-admin-page.php';
// Aktivasyon ve deaktivasyon hook'ları
register_activation_hook( __FILE__, array( 'KGB_Database', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'KGB_Database', 'deactivate' ) );
// Eklentiyi başlat
function kgb_init() {
new KGB_Login_Tracker();
if ( is_admin() ) {
new KGB_Admin_Page();
}
}
add_action( 'plugins_loaded', 'kgb_init' );
ABSPATH kontrolü kritik. Dosyaya doğrudan erişimi engeller. Bu küçük satırı atlayan eklentiler gördüm, ciddi güvenlik açığı.
Veritabanı Sınıfı
Veriler nereye gidecek? Özel bir tablo yaratacağız. WordPress’in wp_options tablosunu bu iş için kullanmak yanlış olur, hem performans hem de sorgulama açısından.
<?php
// includes/class-database.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class KGB_Database {
public static function activate() {
global $wpdb;
$table_name = $wpdb->prefix . KGB_TABLE_NAME;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT(20) UNSIGNED DEFAULT 0,
username VARCHAR(200) NOT NULL DEFAULT '',
ip_address VARCHAR(100) NOT NULL DEFAULT '',
user_agent TEXT,
login_status ENUM('success','failed') NOT NULL DEFAULT 'success',
login_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY login_time (login_time),
KEY login_status (login_status)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
add_option( 'kgb_db_version', KGB_VERSION );
}
public static function deactivate() {
// Deaktivasyonda tabloyu silmiyoruz, sadece cron'u temizliyoruz
wp_clear_scheduled_hook( 'kgb_cleanup_logs' );
}
public static function insert_log( $data ) {
global $wpdb;
$table_name = $wpdb->prefix . KGB_TABLE_NAME;
$wpdb->insert(
$table_name,
array(
'user_id' => absint( $data['user_id'] ),
'username' => sanitize_text_field( $data['username'] ),
'ip_address' => sanitize_text_field( $data['ip_address'] ),
'user_agent' => sanitize_textarea_field( $data['user_agent'] ),
'login_status' => in_array( $data['status'], array( 'success', 'failed' ) ) ? $data['status'] : 'success',
'login_time' => current_time( 'mysql' ),
),
array( '%d', '%s', '%s', '%s', '%s', '%s' )
);
return $wpdb->insert_id;
}
public static function get_logs( $args = array() ) {
global $wpdb;
$table_name = $wpdb->prefix . KGB_TABLE_NAME;
$defaults = array(
'limit' => 100,
'offset' => 0,
'status' => '',
);
$args = wp_parse_args( $args, $defaults );
$where = '';
if ( ! empty( $args['status'] ) ) {
$where = $wpdb->prepare( 'WHERE login_status = %s', $args['status'] );
}
$sql = $wpdb->prepare(
"SELECT * FROM {$table_name} {$where} ORDER BY login_time DESC LIMIT %d OFFSET %d",
absint( $args['limit'] ),
absint( $args['offset'] )
);
return $wpdb->get_results( $sql );
}
public static function cleanup_old_logs( $days = 30 ) {
global $wpdb;
$table_name = $wpdb->prefix . KGB_TABLE_NAME;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$table_name} WHERE login_time < DATE_SUB(NOW(), INTERVAL %d DAY)",
absint( $days )
)
);
}
public static function get_failed_attempts( $ip_address, $minutes = 30 ) {
global $wpdb;
$table_name = $wpdb->prefix . KGB_TABLE_NAME;
return $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$table_name}
WHERE ip_address = %s
AND login_status = 'failed'
AND login_time > DATE_SUB(NOW(), INTERVAL %d MINUTE)",
sanitize_text_field( $ip_address ),
absint( $minutes )
)
);
}
}
dbDelta fonksiyonunu kullandım. Bu WordPress’in kendi tablo oluşturma ve güncelleme mekanizması. Tablo zaten varsa sorun çıkarmıyor. CREATE TABLE IF NOT EXISTS yazmak yeterli değil, eklenti güncellemelerinde sütun eklemesi falan gerekirse dbDelta bunu halleder.
Login Tracker Sınıfı
Asıl takip mantığı bu sınıfta. WordPress’in wp_login ve wp_login_failed action hook’larını kullanacağız.
<?php
// includes/class-login-tracker.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class KGB_Login_Tracker {
public function __construct() {
add_action( 'wp_login', array( $this, 'track_successful_login' ), 10, 2 );
add_action( 'wp_login_failed', array( $this, 'track_failed_login' ), 10, 2 );
add_action( 'kgb_cleanup_logs', array( $this, 'run_cleanup' ) );
// Cron'u zamanla (eğer zamanlanmamışsa)
if ( ! wp_next_scheduled( 'kgb_cleanup_logs' ) ) {
wp_schedule_event( time(), 'daily', 'kgb_cleanup_logs' );
}
}
public function track_successful_login( $user_login, $user ) {
$ip = $this->get_client_ip();
$agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
$log_id = KGB_Database::insert_log( array(
'user_id' => $user->ID,
'username' => $user_login,
'ip_address' => $ip,
'user_agent' => $agent,
'status' => 'success',
) );
// Admin girişiyse bildirim gönder
if ( user_can( $user, 'manage_options' ) ) {
$notifier = new KGB_Notifier();
$notifier->send_admin_login_notification( $user, $ip, $agent );
}
// Şüpheli durum: Başarılı girişten önce çok sayıda başarısız deneme
$failed_count = KGB_Database::get_failed_attempts( $ip, 60 );
if ( $failed_count >= 5 ) {
$notifier = new KGB_Notifier();
$notifier->send_brute_force_alert( $user_login, $ip, $failed_count );
}
}
public function track_failed_login( $username, $error ) {
$ip = $this->get_client_ip();
$agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
KGB_Database::insert_log( array(
'user_id' => 0,
'username' => $username,
'ip_address' => $ip,
'user_agent' => $agent,
'status' => 'failed',
) );
}
public function run_cleanup() {
$days = get_option( 'kgb_log_retention_days', 30 );
KGB_Database::cleanup_old_logs( $days );
}
private function get_client_ip() {
$ip_headers = array(
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
);
foreach ( $ip_headers as $header ) {
if ( ! empty( $_SERVER[ $header ] ) ) {
$ip = trim( explode( ',', $_SERVER[ $header ] )[0] );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return sanitize_text_field( $ip );
}
}
}
return '0.0.0.0';
}
}
IP tespiti konusunda dikkatli olmak gerek. Cloudflare arkasındaki sitelerde REMOTE_ADDR Cloudflare’ın IP’sini döndürür, gerçek kullanıcının değil. HTTP_CF_CONNECTING_IP başlığını öne almak bu yüzden önemli. Ama şunu da belirtmek gerekir: X-Forwarded-For başlığı manipüle edilebilir. Production’da bu başlıklara güvenmeden önce altyapınıza göre değerlendirin.
E-posta Bildirim Sınıfı
Bildirimleri ayrı bir sınıfa koymak güzel çünkü ileride Slack, webhook gibi kanallar eklemek istediğinizde sadece bu sınıfı genişletirsiniz.
<?php
// includes/class-notifier.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class KGB_Notifier {
private $notify_email;
public function __construct() {
$this->notify_email = get_option( 'kgb_notify_email', get_option( 'admin_email' ) );
}
public function send_admin_login_notification( $user, $ip, $agent ) {
if ( ! get_option( 'kgb_notify_admin_login', 1 ) ) {
return;
}
$subject = sprintf(
'[%s] Admin Girişi: %s',
get_bloginfo( 'name' ),
$user->user_login
);
$message = "Merhaba,nn";
$message .= "Admin panelinize yeni bir giriş gerçekleşti.nn";
$message .= "Kullanıcı : " . $user->user_login . "n";
$message .= "IP Adresi : " . $ip . "n";
$message .= "Tarih/Saat: " . current_time( 'Y-m-d H:i:s' ) . "n";
$message .= "Tarayıcı : " . substr( $agent, 0, 150 ) . "nn";
$message .= "Bu giriş size ait değilse hemen şifrenizi değiştirin.nn";
$message .= get_bloginfo( 'url' ) . "n";
wp_mail( $this->notify_email, $subject, $message );
}
public function send_brute_force_alert( $username, $ip, $attempt_count ) {
if ( ! get_option( 'kgb_notify_brute_force', 1 ) ) {
return;
}
$subject = sprintf(
'[%s] UYARI: Kaba Kuvvet Saldırısı Tespit Edildi',
get_bloginfo( 'name' )
);
$message = "DİKKAT!nn";
$message .= "Son 60 dakika içinde aynı IP'den çok sayıda başarısız giriş denemesi yapıldı.nn";
$message .= "Hedef Kullanıcı : " . sanitize_text_field( $username ) . "n";
$message .= "Saldırgan IP : " . $ip . "n";
$message .= "Deneme Sayısı : " . intval( $attempt_count ) . "n";
$message .= "Tarih/Saat : " . current_time( 'Y-m-d H:i:s' ) . "nn";
$message .= "Bu IP'yi güvenlik duvarınızdan engelleyebilirsiniz.n";
wp_mail( $this->notify_email, $subject, $message );
}
}
Yönetim Paneli Sayfası
Güzel kayıtlar biriktiriyoruz ama admin panelinde göremezsek ne anlamı var. Basit ama işlevsel bir liste sayfası ekleyelim.
<?php
// admin/class-admin-page.php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class KGB_Admin_Page {
public function __construct() {
add_action( 'admin_menu', array( $this, 'add_menu' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
}
public function add_menu() {
add_management_page(
'Giriş Kayıtları',
'Giriş Kayıtları',
'manage_options',
'kgb-login-logs',
array( $this, 'render_logs_page' )
);
}
public function register_settings() {
register_setting( 'kgb_settings', 'kgb_notify_email', 'sanitize_email' );
register_setting( 'kgb_settings', 'kgb_notify_admin_login', 'absint' );
register_setting( 'kgb_settings', 'kgb_notify_brute_force', 'absint' );
register_setting( 'kgb_settings', 'kgb_log_retention_days', 'absint' );
}
public function render_logs_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Yetkiniz yok.' );
}
// Filtre
$filter_status = isset( $_GET['status'] ) ? sanitize_text_field( $_GET['status'] ) : '';
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$per_page = 50;
$offset = ( $paged - 1 ) * $per_page;
$logs = KGB_Database::get_logs( array(
'limit' => $per_page,
'offset' => $offset,
'status' => $filter_status,
) );
?>
<div class="wrap">
<h1>Kullanıcı Giriş Kayıtları</h1>
<ul class="subsubsub">
<li><a href="?page=kgb-login-logs">Tümü</a> |</li>
<li><a href="?page=kgb-login-logs&status=success">Başarılı</a> |</li>
<li><a href="?page=kgb-login-logs&status=failed">Başarısız</a></li>
</ul>
<table class="widefat fixed striped">
<thead>
<tr>
<th>Kullanıcı</th>
<th>IP Adresi</th>
<th>Durum</th>
<th>Tarih / Saat</th>
<th>Tarayıcı</th>
</tr>
</thead>
<tbody>
<?php foreach ( $logs as $log ) : ?>
<tr>
<td><?php echo esc_html( $log->username ); ?></td>
<td><?php echo esc_html( $log->ip_address ); ?></td>
<td>
<?php if ( 'success' === $log->login_status ) : ?>
<span style="color:green;">Başarılı</span>
<?php else : ?>
<span style="color:red;">Başarısız</span>
<?php endif; ?>
</td>
<td><?php echo esc_html( $log->login_time ); ?></td>
<td><?php echo esc_html( substr( $log->user_agent, 0, 80 ) ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
}
Temiz Kaldırma: uninstall.php
Eklentiyi silerken veritabanı tablosunu ve ayarları temizleyen dosyayı da unutmayalım. Başkalarının sitesine kurduğunuz eklentilerde bu dosyanın olması ciddi bir profesyonellik göstergesi.
<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
// Tabloyu sil
$table_name = $wpdb->prefix . 'kgb_login_logs';
$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
// Ayarları sil
delete_option( 'kgb_db_version' );
delete_option( 'kgb_notify_email' );
delete_option( 'kgb_notify_admin_login' );
delete_option( 'kgb_notify_brute_force' );
delete_option( 'kgb_log_retention_days' );
// Zamanlanmış görevi sil
wp_clear_scheduled_hook( 'kgb_cleanup_logs' );
Eklentiyi Test Etmek
Geliştirme ortamında eklentiyi test ederken WP-CLI büyük kolaylık sağlar. Terminalden hızlıca giriş simüle etmenin yolu yok ama şunu yapabilirsiniz:
# Eklentiyi WP-CLI ile aktif et
wp plugin activate kullanici-giris-bildirimi
# Eklentinin tablo oluşturduğunu doğrula
wp db query "SHOW TABLES LIKE '%kgb_login_logs%'"
# Kayıtları kontrol et (birkaç giriş yaptıktan sonra)
wp db query "SELECT username, ip_address, login_status, login_time FROM wp_kgb_login_logs ORDER BY login_time DESC LIMIT 10"
# Başarısız giriş sayısını kontrol et
wp db query "SELECT ip_address, COUNT(*) as deneme FROM wp_kgb_login_logs WHERE login_status='failed' GROUP BY ip_address ORDER BY deneme DESC LIMIT 5"
Bu sorgularla hem eklentinin çalışıp çalışmadığını hem de gerçek saldırı desenlerini görebilirsiniz.
Gerçek Dünya Senaryosu: Ne Gördük?
Bu tür bir eklentiyi production’a aldığımızda ilk hafta içinde şunu gördük: Gece 03:00-05:00 arasında Romanya kaynaklı bir IP’den admin, administrator, wp-admin gibi kullanıcı adlarıyla dakikada 15-20 başarısız giriş denemesi geliyor. E-posta bildirimi sayesinde sabah uyandığımızda tabloyu görmek yerine anında haberdar olduk. O IP’yi hem .htaccess‘e hem de güvenlik duvarına ekledik.
# IP'yi .htaccess ile engellemek (Apache)
# /var/www/html/.htaccess dosyasına eklenecek
deny from 185.220.101.0/24
# Nginx için
# /etc/nginx/conf.d/blocked-ips.conf
# deny 185.220.101.0/24;
Bir diğer gerçek senaryo: Çalışanlardan birinin hesabı mesai saatleri dışında, farklı bir şehirden giriş yaptı. Bildirim sistemi olmasa haftalarca fark edilmeyebilirdi.
Güvenlik Notları
Eklenti geliştirirken atladığınızda sonradan pişman olacağınız birkaç nokta var:
- Nonce kullanımı: Admin sayfasında form işlemi yapıyorsanız
wp_nonce_fieldvecheck_admin_refererşart - Yetki kontrolü: Her admin fonksiyonunun başında
current_user_can('manage_options')olmalı - Prepared statements: Tüm veritabanı sorgularında
$wpdb->prepare()kullanın, düz string birleştirme yapılmaz - Output escaping: Ekrana yazdırırken
esc_html(),esc_attr(),esc_url()kullanın - Input sanitization: Gelen her veriyi uygun fonksiyonla temizleyin
Bu kurallar Word Press güvenliğinin temelidir. Hazır eklentilerde bile bu hataları görmek mümkün.
Sonuç
Sıfırdan yazdığınız bir eklenti size birkaç şey kazandırıyor: Tam olarak ihtiyacınıza göre şekillendirilmiş bir araç, şişirilmiş özelliklerin getirdiği yükten arınmış temiz bir codebase ve en önemlisi, her satırı anladığınız bir güvenlik katmanı. Bu eklenti yaklaşık 300-400 satır kod ve birkaç saatlik emekle ortaya çıkıyor ama size sağladığı güvenlik görünürlüğü paha biçilmez.
Geliştirmek isteyenler için birkaç fikir bırakayım: IP coğrafi konum servisi entegrasyonu, belirli bir eşik aşıldığında otomatik IP engelleme, CSV dışa aktarma özelliği veya Slack/Telegram webhook entegrasyonu. Altyapı kodunu sağlam tutarsanız bu özellikleri eklemek düşündüğünüzden çok daha az zaman alır.
WordPress eklenti geliştirme konusunda belgelere bakmanın yanı sıra başkalarının açık kaynak eklentilerini okumak da ciddi anlamda geliştirir. Bazen bir satırlık hook kullanımı ya da küçük bir fonksiyon çağrısı saatlerce kendi çözümünüzü aramaktan sizi kurtarır.
