<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\rest\RestRouteManager;
|
use JVBase\utility\Features;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use WP_Session_Tokens;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class LoginRoutes extends RestRouteManager
|
{
|
protected ?string $requestId = null;
|
|
public function __construct()
|
{
|
$this->cache_name = 'auth';
|
$this->cache_ttl = WEEK_IN_SECONDS;
|
|
parent::__construct();
|
}
|
|
public function registerRoutes(): void
|
{
|
// Login endpoint
|
register_rest_route($this->namespace, '/auth/login', [
|
'methods' => 'POST',
|
'callback' => [$this, 'handleLogin'],
|
'permission_callback' => [$this, 'checkRateLimit']
|
]);
|
|
// Logout endpoint
|
register_rest_route($this->namespace, '/auth/logout', [
|
'methods' => 'POST',
|
'callback' => [$this, 'handleLogout'],
|
'permission_callback' => 'is_user_logged_in'
|
]);
|
|
// Check auth status
|
register_rest_route($this->namespace, '/auth/status', [
|
'methods' => 'GET',
|
'callback' => [$this, 'getAuthStatus'],
|
'permission_callback' => '__return_true'
|
]);
|
|
// Request password reset
|
register_rest_route($this->namespace, '/auth/lostpassword', [
|
'methods' => 'POST',
|
'callback' => [$this, 'requestPasswordReset'],
|
'permission_callback' => [$this, 'checkRateLimit'],
|
'args' => [
|
'user_email' => [
|
'required' => true,
|
'type' => 'string',
|
'format' => 'email',
|
'sanitize_callback' => 'sanitize_email'
|
]
|
]
|
]);
|
|
// Complete password reset (with token)
|
register_rest_route($this->namespace, '/auth/reset-password', [
|
'methods' => 'POST',
|
'callback' => [$this, 'resetPassword'],
|
'permission_callback' => [$this, 'checkRateLimit'],
|
'args' => [
|
'token' => [
|
'required' => true,
|
'type' => 'string',
|
'sanitize_callback' => 'sanitize_text_field'
|
],
|
'user_email' => [
|
'required' => true,
|
'type' => 'string',
|
'format' => 'email',
|
'sanitize_callback' => 'sanitize_email'
|
],
|
'password' => [
|
'required' => true,
|
'type' => 'string'
|
]
|
]
|
]);
|
|
register_rest_route($this->namespace, '/auth/register', [
|
'methods' => 'POST',
|
'callback' => [$this, 'handleRegister'],
|
'permission_callback' => [$this, 'checkRateLimit'],
|
]);
|
|
// Refresh session
|
register_rest_route($this->namespace, '/auth/refresh', [
|
'methods' => 'POST',
|
'callback' => [$this, 'refreshSession'],
|
'permission_callback' => 'is_user_logged_in'
|
]);
|
|
register_rest_route($this->namespace, '/auth/email', [
|
'methods' => 'POST',
|
'callback' => [$this, 'checkEmailExists'],
|
'permission_callback' => [$this, 'checkRateLimit'],
|
]);
|
}
|
|
public function handleLogin(WP_REST_Request $request): WP_REST_Response
|
{
|
$data = $request->get_params();
|
error_log('Data: '.print_r($data, true));
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
|
return $this->error('Security verification failed', 'turnstile_failed', 403);
|
}
|
|
$username = sanitize_email($data['user_email'] ?? '');
|
$password = $data['user_password'] ?? '';
|
$remember = (bool)($data['remember_me'] ?? false);
|
|
// Check for account lockout
|
$lockout = $this->checkAccountLockout($username);
|
if (is_wp_error($lockout)) {
|
return $this->error(
|
$lockout->get_error_message(),
|
'account_locked',
|
429
|
);
|
}
|
return $this->login($username, $password, $remember, $request);
|
}
|
|
public function login(string $username, string $password, bool $remember, ?WP_REST_Request $request = null):WP_REST_Response|bool
|
{
|
// Attempt login
|
$user = wp_signon([
|
'user_login' => $username,
|
'user_password' => $password,
|
'remember' => $remember
|
], is_ssl());
|
|
|
if (is_wp_error($user)) {
|
// Track failed attempt
|
$this->trackFailedLogin($username);
|
|
return ($request) ? $this->error(
|
'Invalid username or password',
|
'login_failed',
|
401
|
) : false;
|
}
|
// Clear failed attempts on success
|
$this->clearFailedAttempts($username);
|
|
// Set auth cookie with remember me flag
|
wp_set_current_user($user->ID);
|
wp_set_auth_cookie($user->ID, $remember, is_ssl());
|
|
|
|
// Store session fingerprint for hijacking protection
|
if ($request) {
|
$this->storeSessionFingerprint($user->ID, $request);
|
}
|
|
// Trigger WordPress login action
|
do_action('wp_login', $user->user_login, $user);
|
|
// Audit log
|
$this->auditLog('user_login', [
|
'user_id' => $user->ID,
|
'remember' => $remember
|
]);
|
|
return ($request) ? $this->success([
|
'message' => 'Login successful',
|
'user' => $this->formatUserData($user),
|
'redirect' => $this->getRedirect($user, $request->get_param('redirect_to')),
|
'auth' => $this->buildAuth($user->ID)
|
]) : true;
|
}
|
|
protected function getUserNonces(int $userID):array {
|
$nonces = [
|
'wp_rest' => wp_create_nonce('wp_rest'),
|
];
|
if (Features::forSite()->has('dashboard')) {
|
$nonces['dash'] = wp_create_nonce('dash-'.$userID);
|
}
|
if (Features::forSite()->has('favourites')) {
|
$nonces['favourites'] = wp_create_nonce('favourites-'.$userID);
|
}
|
if (Features::anyContentHas('karma') ||
|
Features::anyTaxonomyHas('karma') ||
|
Features::anyUserHas('karma')) {
|
$nonces['votes'] = wp_create_nonce('votes-'.$userID);
|
}
|
if (Features::forSite()->has('notifications')) {
|
$nonces['notifications'] = wp_create_nonce('notifications-'.$userID);
|
}
|
return $nonces;
|
}
|
|
/**
|
* Handle logout request
|
*/
|
public function handleLogout(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = get_current_user_id();
|
|
// Clear session fingerprint
|
$this->clearSessionFingerprint($user_id);
|
|
// Audit log
|
$this->auditLog('user_logout', ['user_id' => $user_id]);
|
|
// WordPress logout
|
wp_logout();
|
|
return $this->success([
|
'message' => 'Logged out successfully',
|
'redirect' => $this->getRedirect(get_userdata($user_id), $request->get_param('redirect_to'), 'logout')
|
]);
|
}
|
|
protected function buildAuth(?int $user = null): array
|
{
|
if (is_user_logged_in()) {
|
$user = ($user) ?: get_current_user_id();
|
return [
|
'authenticated' => true,
|
'user' => $user,
|
'nonces' => $this->getUserNonces($user),
|
'session_id' => $this->getSessionId($user)
|
];
|
}
|
|
return [
|
'authenticated' => false,
|
'currentUser' => false,
|
'nonces' => [
|
'wp_rest' => wp_create_nonce('wp_rest')
|
],
|
'session_id' => null
|
];
|
}
|
|
/**
|
* Get unique session identifier that changes on login/logout
|
*/
|
protected function getSessionId(int $user_id): string
|
{
|
$token = wp_get_session_token(); // Current session token
|
|
if (!$token) {
|
// Fallback to a hash based on user ID and current timestamp
|
// This will be replaced once the session token is available
|
return md5($user_id . time());
|
}
|
|
return md5($token);
|
}
|
|
/**
|
* Get current authentication status
|
*/
|
public function getAuthStatus(WP_REST_Request $request): WP_REST_Response
|
{
|
|
$responseData = $this->buildAuth();
|
|
$response = $this->success($responseData);
|
|
// Add caching headers
|
$response->header('Cache-Control', 'private, max-age=300'); // 5 minutes
|
$response->header('Vary', 'Cookie'); // Important for nginx
|
|
return $response;
|
}
|
|
/**
|
* Request password reset
|
*/
|
public function requestPasswordReset(WP_REST_Request $request): WP_REST_Response
|
{
|
$email = $request->get_param('user_email')??'';
|
$email = sanitize_email($email);
|
|
$token = $request->get_param('cf-turnstile-response')??'';
|
if (!$this->verifyTurnstile($token)){
|
return $this->error('Security verification failed', 'turnstile_failed', 403);
|
}
|
|
// Use WordPress's built-in function
|
$result = retrieve_password($email);
|
|
// Log but don't expose
|
if (is_wp_error($result)) {
|
$this->auditLog('password_reset_failed', [
|
'email' => $email,
|
'reason' => $result->get_error_code()
|
]);
|
} else {
|
$this->auditLog('password_reset_sent', [
|
'email' => $email
|
]);
|
}
|
|
return $this->success([
|
'message' => 'If an account exists with this email, you will receive a password reset link.'
|
]);
|
}
|
|
/**
|
* Complete password reset with token
|
*/
|
public function resetPassword(WP_REST_Request $request): WP_REST_Response
|
{
|
$key = sanitize_text_field($request->get_param('key'));
|
$login = sanitize_text_field($request->get_param('login'));
|
$password = $request->get_param('password');
|
|
if (empty($key) || empty($login)) {
|
return $this->error('Invalid reset link', 'invalid_key', 400);
|
}
|
|
// Use WordPress's native key verification
|
$user = check_password_reset_key($key, $login);
|
|
if (is_wp_error($user)) {
|
return $this->error(
|
'Invalid or expired reset link',
|
'invalid_token',
|
400
|
);
|
}
|
|
// Validate password strength
|
$validation = $this->validatePassword($password);
|
if (is_wp_error($validation)) {
|
return $this->error(
|
$validation->get_error_message(),
|
'weak_password',
|
400
|
);
|
}
|
|
// Reset the password
|
reset_password($user, $password);
|
|
// Log them in
|
wp_set_current_user($user->ID);
|
wp_set_auth_cookie($user->ID, true);
|
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
session_regenerate_id(true);
|
}
|
// Store session fingerprint
|
$this->storeSessionFingerprint($user->ID, $request);
|
|
// Audit log
|
$this->auditLog('password_reset_complete', [
|
'user_id' => $user->ID
|
]);
|
|
return $this->success([
|
'message' => 'Password reset successfully',
|
'redirect' => home_url('/dash')
|
]);
|
}
|
|
/**
|
* Refresh session (extends session duration)
|
*/
|
public function refreshSession(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = get_current_user_id();
|
|
// Validate session fingerprint
|
if (!$this->validateSessionFingerprint($user_id, $request)) {
|
wp_logout();
|
return $this->unauthorized('Session validation failed');
|
}
|
|
// Refresh auth cookie
|
wp_set_auth_cookie($user_id, true);
|
|
return $this->success([
|
'message' => 'Session refreshed'
|
]);
|
}
|
|
|
public function handleRegister(WP_REST_Request $request): WP_REST_Response
|
{
|
$data = $request->get_json_params();
|
|
// Duplicate submission check
|
if (!$this->checkRequestId($data['request_id'] ?? '')) {
|
return $this->error('Duplicate request detected', 'duplicate_request', 409);
|
}
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) {
|
return $this->error('Security verification failed', 'turnstile_failed', 403);
|
}
|
|
$name = sanitize_text_field($data['name'] ?? '');
|
$email = sanitize_email($data['email'] ?? '');
|
$referral_code = $request->get_param('referral_code')??'';
|
$user_type = sanitize_text_field($data['user_select'] ?? 'subscriber');
|
|
// Validate fields
|
if (empty($name)) {
|
return $this->error('Name is required', 'missing_name', 400, 'name');
|
}
|
|
if (empty($email)) {
|
return $this->error('Email is required', 'missing_email', 400, 'email');
|
}
|
|
// Check if role can register
|
if ($user_type !== 'subscriber') {
|
if (!isset(JVB_USER[$user_type]) || empty(JVB_USER[$user_type]['can_register'])) {
|
return $this->error('Invalid account type', 'invalid_user_type', 400, 'user_select');
|
}
|
}
|
|
// Check if email exists
|
if (email_exists($email)) {
|
return $this->error('Email already registered', 'duplicate_email', 400, 'email');
|
}
|
|
// Validate referral code if provided
|
$referrer_id = null;
|
if ($referral_code) {
|
$code = strtoupper(sanitize_text_field($referral_code));
|
$referrer = JVB()->referrals()->getUserByReferralCode($code);
|
|
if (!$referrer) {
|
return $this->error('Invalid referral code', 'invalid_code', 400);
|
}
|
|
$referrer_id = $referrer->ID;
|
}
|
|
// Allow WP plugins to add registration errors
|
$errors = new WP_Error();
|
$errors = apply_filters('registration_errors', $errors, $email, $email);
|
|
if ($errors->has_errors()) {
|
return $this->error(
|
$errors->get_error_message(),
|
$errors->get_error_code(),
|
400
|
);
|
}
|
|
// Update user data
|
$role = ($referrer_id) ? get_option(BASE . 'referral_role', BASE . 'client') : jvbCheckBase($user_type);
|
$userData = [
|
'user_login' => $email,
|
'user_email' => $email,
|
'display_name' => $name,
|
'first_name' => strtok($name, ' '),
|
'role' => $role
|
];
|
|
// Add password if provided, otherwise generate one
|
$password = $request->get_param('password');
|
if ($password) {
|
$userData['user_pass'] = $password;
|
} else {
|
$userData['user_pass'] = wp_generate_password(20, true, true);
|
}
|
|
$user_id = wp_insert_user($userData);
|
|
if (is_wp_error($user_id)) {
|
return $this->error(
|
$user_id->get_error_message(),
|
'registration_failed',
|
500
|
);
|
}
|
|
// Process referral if code was provided
|
if ($referrer_id) {
|
update_user_meta($user_id, BASE . 'pending_referral_code', $referral_code);
|
}
|
|
// Set role
|
$user = get_userdata($user_id);
|
if ($user_type !== 'subscriber') {
|
|
// Check if needs approval
|
if (Features::forMembership()->has('memberVerified') &&
|
in_array($role, JVB_MEMBERSHIP['memberVerified'] ?? [])) {
|
$user->add_cap('skip_moderation', false);
|
update_user_meta($user_id, BASE . 'pending_approval', true);
|
}
|
}
|
|
if (Features::forUser($user_type)->has('namedDirectory')) {
|
$upload_dir = wp_upload_dir();
|
$user_directory = $user_type.'/'.$user_id;
|
$target_dir = $upload_dir['basedir'].'/'.$user_directory;
|
|
wp_mkdir_p($target_dir);
|
}
|
|
// Process additional fields from form
|
foreach ($data as $key => $value) {
|
if (in_array($key, ['name', 'email', 'action', 'request_id', 'user_select', 'cf-turnstile-response'])) {
|
continue;
|
}
|
update_user_meta($user_id, BASE . $key, sanitize_text_field($value));
|
}
|
|
$redirect = $this->getRedirect($user, $request->get_param('redirect_to')??get_home_url(null,'/dash'), 'register');
|
|
// Handle token handlers
|
do_action('jvbUserRegistered', $user_id, $email, $data);
|
$magic_link_result = JVB()->magicLink()?->sendMagicLink(
|
$email,
|
'login',
|
[
|
'user_id' => $user_id,
|
'redirect' => $redirect
|
]
|
);
|
|
if (is_wp_error($magic_link_result)) {
|
return $this->error(
|
'Account created but failed to send verification email. Please use password reset.',
|
'magic_link_failed',
|
500
|
);
|
}
|
|
return $this->success([
|
'message' => 'Registration successful! Check your email.',
|
'user_id' => $user_id,
|
'redirect' => $redirect
|
]);
|
}
|
/**************************************************************
|
HELPERS
|
**************************************************************/
|
/**
|
* Format user data for response
|
*/
|
protected function formatUserData(WP_User $user): array
|
{
|
return [
|
'id' => $user->ID,
|
'username' => $user->user_login,
|
'email' => $user->user_email,
|
'display_name' => $user->display_name,
|
'roles' => $user->roles,
|
'capabilities' => [
|
'manage_options' => user_can($user, 'manage_options'),
|
'skip_moderation' => user_can($user, 'skip_moderation'),
|
// Add other relevant capabilities
|
]
|
];
|
}
|
|
protected function getRedirect(WP_User $user, ?string $url=null, string $context = 'login'):string
|
{
|
if (!empty($url)) {
|
$url = sanitize_url($url);
|
if (wp_validate_redirect($url)) {
|
return $url;
|
}
|
}
|
|
// Redirect to custom dashboard for members
|
if (function_exists('isOurPeople') && isOurPeople()) {
|
return home_url('/dash');
|
}
|
|
// Admins can go to wp-admin if they want (but only if not using custom dashboard)
|
if (user_can($user, 'manage_options')) {
|
return admin_url();
|
}
|
|
$custom_redirect = get_option(BASE . 'after_'.$context.'_redirect');
|
if ($custom_redirect) {
|
return $custom_redirect;
|
}
|
|
return home_url();
|
}
|
|
/**
|
* Validate password strength
|
*/
|
protected function validatePassword(string $password): bool|WP_Error
|
{
|
if (strlen($password) < 8) {
|
return new WP_Error(
|
'weak_password',
|
'Password must be at least 8 characters long'
|
);
|
}
|
|
// Add additional strength requirements as needed
|
// - Must contain uppercase
|
// - Must contain number
|
// - Must contain special character
|
|
return true;
|
}
|
|
/**
|
* Check if account is locked out due to failed attempts
|
*/
|
protected function checkAccountLockout(string $username): bool|WP_Error
|
{
|
if (!defined('JVB_MAX_LOGIN_ATTEMPTS')) {
|
return true;
|
}
|
|
$cache_key = 'login_attempts_' . md5($username);
|
$attempts = $this->cache->get($cache_key);
|
|
if (!$attempts || !isset($attempts['count'])) {
|
return true;
|
}
|
|
if ($attempts['count'] >= JVB_MAX_LOGIN_ATTEMPTS) {
|
$lockout_duration = defined('JVB_LOCKOUT_DURATION')
|
? JVB_LOCKOUT_DURATION
|
: 15 * MINUTE_IN_SECONDS;
|
|
$time_remaining = $lockout_duration - (time() - $attempts['timestamp']);
|
|
if ($time_remaining > 0) {
|
return new WP_Error(
|
'account_locked',
|
sprintf(
|
'Too many failed login attempts. Please try again in %d minutes.',
|
ceil($time_remaining / 60)
|
)
|
);
|
}
|
|
// Lockout expired - clear attempts
|
$this->cache->delete($cache_key);
|
return true;
|
}
|
|
return true;
|
}
|
|
/**
|
* Track failed login attempt
|
*/
|
protected function trackFailedLogin(string $username): void
|
{
|
$cache_key = 'login_attempts_' . md5($username);
|
$attempts = $this->cache->get($cache_key) ?: [
|
'count' => 0,
|
'timestamp' => time()
|
];
|
|
$attempts['count']++;
|
$attempts['timestamp'] = time();
|
|
$lockout_duration = defined('JVB_LOCKOUT_DURATION')
|
? JVB_LOCKOUT_DURATION
|
: 15 * MINUTE_IN_SECONDS;
|
|
$this->cache->set($cache_key, $attempts, $lockout_duration);
|
|
// Audit log
|
$this->auditLog('failed_login', [
|
'username' => $username,
|
'attempts' => $attempts['count']
|
]);
|
}
|
|
/**
|
* Clear failed login attempts on successful login
|
*/
|
protected function clearFailedAttempts(string $username): void
|
{
|
$cache_key = 'login_attempts_' . md5($username);
|
$this->cache->delete($cache_key);
|
}
|
|
|
|
|
|
|
|
|
|
|
public function checkEmailExists(WP_REST_Request $request): WP_REST_Response
|
{
|
$data = $request->get_json_params();
|
$email = sanitize_email($data['email'] ?? '');
|
|
return $this->success([
|
'exists' => is_email($email) && email_exists($email)
|
]);
|
}
|
/***********************************************************************
|
* HELPER METHODS
|
***********************************************************************/
|
|
protected function checkRequestId(string $request_id): bool
|
{
|
if (empty($request_id)) {
|
return true;
|
}
|
|
$cache_key = 'request_' . $request_id;
|
if (get_transient($cache_key)) {
|
return false;
|
}
|
|
set_transient($cache_key, true, 60);
|
return true;
|
}
|
|
|
/**
|
* Helper to return error response
|
*/
|
protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
|
{
|
if ($this->requestId) {
|
delete_transient('request_'.$this->requestId);
|
$this->requestId = null;
|
}
|
return parent::error($message, $code, $status, $field);
|
}
|
|
|
}
|