<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\managers\EmailManager;
|
use JVBase\managers\LoginManager;
|
use JVBase\managers\MagicLinkManager;
|
use JVBase\rest\RestRouteManager;
|
use JVBase\utility\Features;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class LoginRoutes extends RestRouteManager
|
{
|
protected EmailManager $emailManager;
|
protected MagicLinkManager $magic_link;
|
protected LoginManager $loginManager;
|
protected ?string $requestId = null;
|
|
public function __construct()
|
{
|
$this->cache_name = 'auth';
|
$this->cache_ttl = WEEK_IN_SECONDS;
|
$this->emailManager = new EmailManager();
|
if (Features::forSite()->has('magicLink')) {
|
$this->magic_link = new MagicLinkManager();
|
}
|
|
parent::__construct();
|
}
|
|
public function registerRoutes(): void
|
{
|
// Login endpoint
|
register_rest_route($this->namespace, '/auth/login', [
|
'methods' => 'POST',
|
'callback' => [$this, 'handleLogin'],
|
'permission_callback' => [$this, 'checkRateLimit'],
|
'args' => [
|
'user_email' => [
|
'required' => true,
|
'type' => 'string',
|
'sanitize_callback' => 'sanitize_email'
|
],
|
'user_password' => [
|
'required' => true,
|
'type' => 'string'
|
],
|
'remember_me' => [
|
'required' => false,
|
'type' => 'boolean',
|
'default' => false
|
]
|
]
|
]);
|
|
// 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', [
|
'method' => '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
|
{
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
|
return $this->error('Security verification failed', 'turnstile_failed', 403);
|
}
|
|
$username = $request->get_param('user_email');
|
$password = $request->get_param('user_password');
|
$remember = (bool)$request->get_param('remember_me');
|
|
// Check for account lockout
|
$lockout = $this->checkAccountLockout($username);
|
if (is_wp_error($lockout)) {
|
return $this->error(
|
$lockout->get_error_message(),
|
'account_locked',
|
429
|
);
|
}
|
|
// Attempt login
|
$user = wp_signon([
|
'user_login' => $username,
|
'user_email' => $username,
|
'user_password' => $password,
|
'remember' => $remember
|
], false);
|
|
if (is_wp_error($user)) {
|
// Track failed attempt
|
$this->trackFailedLogin($username);
|
|
return $this->error(
|
'Invalid username or password',
|
'login_failed',
|
401
|
);
|
}
|
|
// 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);
|
|
// Store session fingerprint for hijacking protection
|
$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 $this->success([
|
'message' => 'Login successful',
|
'user' => $this->formatUserData($user),
|
'redirect' => $this->getLoginRedirect($user)
|
]);
|
}
|
|
/**
|
* 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'
|
]);
|
}
|
|
/**
|
* Get current authentication status
|
*/
|
public function getAuthStatus(WP_REST_Request $request): WP_REST_Response
|
{
|
if (!is_user_logged_in()) {
|
return $this->success([
|
'authenticated' => false
|
]);
|
}
|
|
$user = wp_get_current_user();
|
|
return $this->success([
|
'authenticated' => true,
|
'user' => $this->formatUserData($user)
|
]);
|
}
|
|
/**
|
* 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);
|
|
// 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'] ?? '');
|
$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');
|
}
|
|
// Spam prevention
|
if ($user_type === 'subscriber' && count(JVB_USER) > 0) {
|
$registerable = array_filter(JVB_USER, fn($config) => $config['can_register'] ?? false);
|
if (!empty($registerable)) {
|
return $this->error('Please select a valid account type', 'invalid_user_type', 400, 'user_select');
|
}
|
}
|
|
// 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');
|
}
|
|
// 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
|
);
|
}
|
|
// Create user
|
$user_id = wp_create_user($email, wp_generate_password(), $email);
|
|
if (is_wp_error($user_id)) {
|
return $this->error($user_id->get_error_message(), 'user_creation_failed', 500);
|
}
|
|
// Update user data
|
wp_update_user([
|
'ID' => $user_id,
|
'display_name' => $name,
|
'first_name' => strtok($name,' ')
|
]);
|
|
// Set role
|
$user = new WP_User($user_id);
|
if ($user_type === 'subscriber') {
|
$user->set_role('subscriber');
|
} else {
|
$role = JVB_USER[$user_type]['role'] ?? 'subscriber';
|
$user->set_role($role);
|
|
// 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);
|
}
|
|
// Save additional fields
|
update_user_meta($user_id, BASE . 'user_type', $user_type);
|
|
// 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));
|
}
|
|
// Handle token handlers
|
do_action('jvbUserRegistered', $user_id, $email, $data);
|
|
|
return $this->success([
|
'message' => 'Registration successful! Check your email.',
|
'user_id' => $user_id
|
]);
|
}
|
/**************************************************************
|
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
|
]
|
];
|
}
|
|
/**
|
* Get login redirect URL based on user role
|
*/
|
protected function getLoginRedirect(WP_User $user): string
|
{
|
if (user_can($user, 'manage_options')) {
|
return admin_url();
|
}
|
|
// Redirect to dashboard for members
|
if (function_exists('isOurPeople') && isOurPeople()) {
|
return home_url('/dash');
|
}
|
|
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;
|
}
|
|
protected function verifyTurnstile(string $token): bool
|
{
|
if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
|
return true;
|
}
|
|
if (empty($token)) {
|
return false;
|
}
|
|
return JVB()->connect('cloudflare')->verifyTurnstile($token);
|
}
|
|
/**
|
* 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);
|
}
|
|
|
}
|