diff --git a/admin/index.php b/admin/index.php index ad2b68d..ffd542c 100644 --- a/admin/index.php +++ b/admin/index.php @@ -85,5 +85,47 @@ $user = \S::get_session( 'user', true ); \admin\Site::update(); \admin\Site::special_actions(); +$domain = preg_replace( '/^www\./', '', $_SERVER['SERVER_NAME'] ); +$cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain ); + +if ( isset( $_COOKIE[$cookie_name] ) && !isset( $_SESSION['user'] ) ) +{ + $payload = base64_decode($_COOKIE[$cookie_name]); + if ($payload !== false && strpos($payload, '.') !== false) + { + list($json, $sig) = explode('.', $payload, 2); + $expected_sig = hash_hmac('sha256', $json, \admin\Site::APP_SECRET_KEY); + + if (hash_equals($expected_sig, $sig)) + { + $data = json_decode($json, true); + if ($data && isset($data['login']) && isset($data['ts'])) + { + // Sprawdź czy cookie nie wygasło (14 dni) + if ((time() - $data['ts']) < (86400 * 14)) + { + $user_data = $mdb->get('pp_users', '*', ['AND' => ['login' => $data['login'], 'status' => 1]]); + if ($user_data) + { + \S::set_session('user', \admin\factory\Users::details($data['login'])); + $redirect = $_SERVER['REQUEST_URI'] ?: '/admin/articles/view_list/'; + header('Location: ' . $redirect); + exit; + } + } + } + } + } + // Jeśli coś poszło nie tak, usuń nieprawidłowe cookie + setcookie($cookie_name, '', [ + 'expires' => time() - 86400, + 'path' => '/', + 'domain' => $domain, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); +} + echo \admin\view\Page::show(); ?> \ No newline at end of file diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php index f0ca1bb..d5c794e 100644 --- a/autoload/admin/class.Site.php +++ b/autoload/admin/class.Site.php @@ -3,33 +3,183 @@ namespace admin; class Site { + // define APP_SECRET_KEY + const APP_SECRET_KEY = 'c3cb2537d25c0efc9e573d059d79c3b8'; + + static public function finalize_admin_login( array $user, string $domain, string $cookie_name, bool $remember = false ) { + + \S::set_session( 'user', $user ); + \S::delete_session( 'twofa_pending' ); + + if ( $remember ) { + $payloadArr = [ + 'login' => $user['login'], + 'ts' => time() + ]; + + $json = json_encode($payloadArr, JSON_UNESCAPED_SLASHES); + $sig = hash_hmac('sha256', $json, self::APP_SECRET_KEY); + $payload = base64_encode($json . '.' . $sig); + + setcookie( $cookie_name, $payload, [ + 'expires' => time() + (86400 * 14), + 'path' => '/', + 'domain' => $domain, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + } + } + public static function special_actions() { - $sa = \S::get( 's-action' ); + $sa = \S::get('s-action'); + $domain = preg_replace('/^www\./', '', $_SERVER['SERVER_NAME']); + $cookie_name = 'admin_remember_' . str_replace( '.', '-', $domain ); - switch ( $sa ) + switch ($sa) { case 'user-logon': + { + $login = \S::get('login'); + $pass = \S::get('password'); - $result = \admin\factory\Users::logon( \S::get( 'login' ), \S::get( 'password' ) ); + $result = \admin\factory\Users::logon($login, $pass); if ( $result == 1 ) - \S::set_session( 'user', \admin\factory\Users::details( \S::get( 'login' ) ) ); + { + $user = \admin\factory\Users::details($login); + + if ( $user['twofa_enabled'] == 1 ) + { + \S::set_session( 'twofa_pending', [ + 'uid' => (int)$user['id'], + 'login' => $login, + 'remember' => (bool)\S::get('remember'), + 'started' => time(), + ] ); + + if ( !\admin\factory\Users::send_twofa_code( (int)$user['id'] ) ) + { + \S::alert('Nie udało się wysłać kodu 2FA. Spróbuj ponownie.'); + \S::delete_session('twofa_pending'); + header('Location: /admin/'); + exit; + } + + header('Location: /admin/user/twofa/'); + exit; + } + else + { + $user = \admin\factory\Users::details($login); + + self::finalize_admin_login( + $user, + $domain, + $cookie_name, + (bool)\S::get('remember') + ); + + header('Location: /admin/articles/view_list/'); + exit; + } + } else { - if ( $result == -1 ) - \S::alert( 'Z powodu nieudanych 5 prób logowania Twoje konto zostało zablokowane.' ); + if ($result == -1) + { + \S::alert('Z powodu 5 nieudanych prób Twoje konto zostało zablokowane.'); + } else - \S::alert( 'Podane hasło jest nieprawidłowe, lub brak użytkownika o podanym loginie.' ); + { + \S::alert('Podane hasło jest nieprawidłowe lub użytkownik nie istnieje.'); + } + header('Location: /admin/'); + exit; } - header( 'Location: /admin/dashboard/main_view/' ); + } + break; + + case 'user-2fa-verify': + { + $pending = \S::get_session('twofa_pending'); + if ( !$pending || empty( $pending['uid'] ) ) { + \S::alert('Sesja 2FA wygasła. Zaloguj się ponownie.'); + header('Location: /admin/'); + exit; + } + + $code = trim((string)\S::get('twofa')); + if (!preg_match('/^\d{6}$/', $code)) + { + \S::alert('Nieprawidłowy format kodu.'); + header('Location: /admin/user/twofa/'); + exit; + } + + $ok = \admin\factory\Users::verify_twofa_code((int)$pending['uid'], $code); + if (!$ok) + { + \S::alert('Błędny lub wygasły kod.'); + header('Location: /admin/user/twofa/'); + exit; + } + + // 2FA OK — finalna sesja + $user = \admin\factory\Users::details($pending['login']); + + self::finalize_admin_login( + $user, + $domain, + $cookie_name, + $pending['remember'] ? true : false + ); + + header('Location: /admin/articles/view_list/'); exit; + } + break; + + case 'user-2fa-resend': + { + $pending = \S::get_session('twofa_pending'); + if (!$pending || empty($pending['uid'])) + { + \S::alert('Sesja 2FA wygasła. Zaloguj się ponownie.'); + header('Location: /admin/'); + exit; + } + + if (!\admin\factory\Users::send_twofa_code((int)$pending['uid'], true)) + { + \S::alert('Kod można wysłać ponownie po krótkiej przerwie.'); + } + else + { + \S::alert('Nowy kod został wysłany.'); + } + header('Location: /admin/user/twofa/'); + exit; + } break; case 'user-logout': + { + setcookie($cookie_name, "", [ + 'expires' => time() - 86400, + 'path' => '/', + 'domain' => $domain, + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + \S::delete_session('twofa_pending'); session_destroy(); - header( 'Location: /admin/' ); + header('Location: /admin/'); exit; + } break; } } diff --git a/autoload/admin/controls/class.Users.php b/autoload/admin/controls/class.Users.php index 45e6994..7a86b6e 100644 --- a/autoload/admin/controls/class.Users.php +++ b/autoload/admin/controls/class.Users.php @@ -15,7 +15,7 @@ class Users { $values = json_decode( \S::get( 'values' ), true ); - $response = \admin\factory\Users::user_save( $values['id'], $values['login'], $values['status'], $values['password'], $values['password_re'], $values['admin'] ); + $response = \admin\factory\Users::user_save( $values['id'], $values['login'], $values['status'], $values['password'], $values['password_re'], $values['admin'], $values['twofa_enabled'], $values['twofa_email'] ); echo json_encode( $response ); exit; } @@ -33,5 +33,11 @@ class Users { return \admin\view\Users::users_list(); } + + static public function twofa() { + return \Tpl::view( 'site/unlogged', [ + 'content' => \Tpl::view( 'users/user-2fa' ) + ] ); + } } ?> diff --git a/autoload/admin/factory/class.Users.php b/autoload/admin/factory/class.Users.php index a010e17..c8cc151 100644 --- a/autoload/admin/factory/class.Users.php +++ b/autoload/admin/factory/class.Users.php @@ -1,39 +1,141 @@ = 5) + { + return false; // zbyt wiele prób + } + + // sprawdź ważność + if (empty($user['twofa_expires_at']) || time() > strtotime($user['twofa_expires_at'])) + { + // wyczyść po wygaśnięciu + self::update_by_id($userId, [ + 'twofa_code_hash' => null, + 'twofa_expires_at' => null, + ]); + return false; + } + + $ok = (!empty($user['twofa_code_hash']) && password_verify($code, $user['twofa_code_hash'])); + if ($ok) + { + // sukces: czyścimy wszystko + self::update_by_id($userId, [ + 'twofa_code_hash' => null, + 'twofa_expires_at' => null, + 'twofa_sent_at' => null, + 'twofa_failed_attempts' => 0, + 'last_logged' => date('Y-m-d H:i:s'), + ]); + return true; + } + + // zła próba — inkrementacja + self::update_by_id($userId, [ + 'twofa_failed_attempts' => (int)$user['twofa_failed_attempts'] + 1, + 'last_error_logged' => date('Y-m-d H:i:s'), + ]); + return false; + } + + static public function get_by_id(int $userId): ?array { + global $mdb; + return $mdb->get('pp_users', '*', ['id' => $userId]) ?: null; + } + + static public function update_by_id(int $userId, array $data): bool { + global $mdb; + return (bool)$mdb->update('pp_users', $data, ['id' => $userId]); + } + + static public function send_twofa_code(int $userId, bool $resend = false): bool { + $user = self::get_by_id($userId); + + if ( !$user ) + return false; + + if ( (int)$user['twofa_enabled'] !== 1 ) + return false; + + $to = $user['twofa_email'] ?: $user['login']; + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) + return false; + + if ( $resend && !empty( $user['twofa_sent_at'] ) ) { + $last = strtotime($user['twofa_sent_at']); + if ($last && (time() - $last) < 30) + return false; + } + + $code = random_int(100000, 999999); + $hash = password_hash((string)$code, PASSWORD_DEFAULT); + + self::update_by_id( $userId, [ + 'twofa_code_hash' => $hash, + 'twofa_expires_at' => date('Y-m-d H:i:s', time() + 10 * 60), // 10 minut + 'twofa_sent_at' => date('Y-m-d H:i:s'), + 'twofa_failed_attempts' => 0, + ] ); + + $subject = 'Twój kod logowania 2FA'; + $body = "Twój kod logowania do panelu administratora: {$code}. Kod jest ważny przez 10 minut. Jeśli to nie Ty inicjowałeś logowanie – zignoruj tę wiadomość i poinformuj administratora."; + + $sent = \S::send_email($to, $subject, $body); + + if (!$sent) { + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-type: text/plain; charset=UTF-8\r\n"; + $headers .= "From: no-reply@" . ($_SERVER['HTTP_HOST'] ?? 'localhost') . "\r\n"; + $encodedSubject = mb_encode_mimeheader($subject, 'UTF-8'); + + $sent = mail($to, $encodedSubject, $body, $headers); + } + + return $sent; + } + public static function user_delete( $user_id ) { global $mdb; return $mdb -> delete( 'pp_users', [ 'id' => (int)$user_id ] ); } - + public static function user_details( $user_id ) { global $mdb; return $mdb -> get( 'pp_users', '*', [ 'id' => (int)$user_id ] ); } - - public static function user_save( $user_id = '', $login, $status, $password, $password_re, $admin ) + + public static function user_save( $user_id = '', $login, $status, $password, $password_re, $admin, $twofa_enabled = 0, $twofa_email = '' ) { global $mdb, $lang, $config; - + if ( !$user_id ) - { + { if ( strlen( $password ) < 5 ) return $response = [ 'status' => 'error', 'msg' => 'Podane hasło jest zbyt krótkie.' ]; - + if ( $password != $password_re ) return $response = [ 'status' => 'error', 'msg' => 'Podane hasła są różne' ]; - + if ( $mdb -> insert( 'pp_users', [ 'login' => $login, 'status' => $status == 'on' ? 1 : 0, 'admin' => $admin, - 'password' => md5( $password ) - ] ) ) - { + 'password' => md5( $password ), + 'twofa_enabled' => $twofa_enabled == 'on' ? 1 : 0, + 'twofa_email' => $twofa_email + ] ) ) + { return $response = [ 'status' => 'ok', 'msg' => 'Użytkownik został zapisany.' ]; } } @@ -41,49 +143,51 @@ class Users { if ( $password and strlen( $password ) < 5 ) return $response = [ 'status' => 'error', 'msg' => 'Podane hasło jest zbyt krótkie.' ]; - + if ( $password and $password != $password_re ) return $response = [ 'status' => 'error', 'msg' => 'Podane hasła są różne' ]; - + if ( $password ) $mdb -> update( 'pp_users', [ - 'password' => md5( $password ) - ], [ - 'id' => (int)$user_id - ] ); - + 'password' => md5( $password ) + ], [ + 'id' => (int)$user_id + ] ); + $mdb -> update( 'pp_users', [ - 'login' => $login, - 'admin' => $admin, - 'status' => $status == 'on' ? 1 : 0 - ], [ - 'id' => (int)$user_id - ] ); - - return $response = [ 'status' => 'ok', 'msg' => 'Uzytkownik został zapisany.' ]; + 'login' => $login, + 'admin' => $admin, + 'status' => $status == 'on' ? 1 : 0, + 'twofa_enabled' => $twofa_enabled == 'on' ? 1 : 0, + 'twofa_email' => $twofa_email + ], [ + 'id' => (int)$user_id + ] ); + + return $response = [ 'status' => 'ok', 'msg' => 'Uzytkownik został zapisany.' ]; } } - + public static function check_login( $login, $user_id ) { global $mdb; - + if ( $mdb -> get( 'pp_users', 'login', [ 'AND' => [ 'login' => $login, 'id[!]' => (int)$user_id ] ] ) ) return $response = [ 'status' => 'error', 'msg' => 'Podany login jest już zajęty.' ]; - + return $response = [ 'status' => 'ok' ]; } - + public static function logon( $login, $password ) { global $mdb; - + if ( !$mdb -> get( 'pp_users', '*', [ 'login' => $login ] ) ) return 0; - + if ( !$mdb -> get( 'pp_users', '*', [ 'AND' => [ 'login' => $login, 'status' => 1, 'error_logged_count[<]' => 5 ] ] ) ) return -1; - + if ( $mdb -> get( 'pp_users', '*', [ 'AND' => [ 'login' => $login, 'status' => 1, 'password' => md5( $password ) ] ] ) ) { $mdb -> update( 'pp_users', [ 'last_logged' => date( 'Y-m-d H:i:s' ), 'error_logged_count' => 0 ], [ 'login' => $login ] ); @@ -100,7 +204,7 @@ class Users } return 0; } - + public static function details( $login ) { global $mdb; diff --git a/autoload/admin/view/class.Page.php b/autoload/admin/view/class.Page.php index 23bcbce..c96583a 100644 --- a/autoload/admin/view/class.Page.php +++ b/autoload/admin/view/class.Page.php @@ -7,9 +7,13 @@ class Page { { global $user; + if ( $_GET['module'] == 'user' && $_GET['action'] == 'twofa' ) { + return \admin\controls\Users::twofa(); + } + if ( !$user || !$user['admin'] ) return \admin\view\Users::login_form(); - + $tpl = new \Tpl; $tpl -> content = \admin\Site::route(); return $tpl -> render( 'site/main-layout' );