<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\registrar\Registrar;
|
use JVBase\rest\Rest;
|
use JVBase\rest\Route;
|
use JVBase\base\Site;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use WP_User;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Login Routes
|
*
|
* Handles all authentication-related endpoints with session security
|
*/
|
class LoginRoutes extends Rest
|
{
|
protected ?string $requestId = null;
|
protected bool $hasMagicLink = false;
|
|
public function __construct()
|
{
|
$this->cacheName = 'auth';
|
$this->cacheTtl = WEEK_IN_SECONDS;
|
|
parent::__construct();
|
|
$this->hasMagicLink = Site::has('magicLink');
|
}
|
|
public function registerRoutes(): void
|
{
|
// Auth status endpoint
|
Route::for('auth/status')
|
->get([$this, 'getAuthStatus'])
|
->auth('public')
|
->rateLimit()
|
->register();
|
|
// Standard login
|
Route::for('auth/login')
|
->post([$this, 'handleLogin'])
|
->args([
|
'user_email' => 'email|required',
|
'user_password' => 'string|required',
|
'remember_me' => 'boolean',
|
'redirect_to' => 'string',
|
])
|
->auth('public')
|
->rateLimit(5, 300)
|
->register();
|
|
// User registration
|
Route::for('auth/register')
|
->post([$this, 'handleRegister'])
|
->args([
|
'user_name' => 'string|required',
|
'user_email' => 'email|required',
|
'user_select' => 'string',
|
'referral_code' => 'string',
|
'redirect_to' => 'string',
|
])
|
->auth('public')
|
->rateLimit(3, 3600)
|
->register();
|
|
// Request password reset
|
Route::for('auth/lostpassword')
|
->post([$this, 'handleLostPassword'])
|
->args([
|
'user_email' => 'email|required',
|
])
|
->auth('public')
|
->rateLimit(3, 3600)
|
->register();
|
|
// Reset password with token
|
Route::for('auth/resetpass')
|
->post([$this, 'handleResetPassword'])
|
->args([
|
'key' => 'string|required',
|
'login' => 'string|required',
|
'pass1' => 'string|required',
|
'pass2' => 'string|required',
|
])
|
->auth('public')
|
->rateLimit(5, 300)
|
->register();
|
|
// Magic link endpoint
|
if ($this->hasMagicLink) {
|
Route::for('auth/magic')
|
->post([$this, 'handleMagicLink'])
|
->args([
|
'user_email' => 'email|required',
|
'type' => 'string|enum:login,signup,referral',
|
'redirect_to' => 'string',
|
])
|
->auth('public')
|
->rateLimit(5, 3600)
|
->register();
|
}
|
|
// Logout endpoint
|
Route::for('auth/logout')
|
->post([$this, 'handleLogout'])
|
->auth('logged_in')
|
->rateLimit(10)
|
->register();
|
}
|
|
/**
|
* Get authentication status
|
*/
|
public function getAuthStatus(WP_REST_Request $request): WP_REST_Response
|
{
|
$data = $this->buildAuth();
|
$response = $this->success($data);
|
|
// Add caching headers
|
$response->header('Cache-Control', 'private, max-age=300'); // 5 minutes
|
$response->header('Vary', 'Cookie'); // Important for nginx
|
|
return $response;
|
}
|
|
/**
|
* Handle standard login
|
*/
|
public function handleLogin(WP_REST_Request $request): WP_REST_Response
|
{
|
$email = sanitize_email($request->get_param('user_email'));
|
$password = $request->get_param('user_password');
|
$remember = (bool) $request->get_param('remember_me');
|
$redirect_to = $request->get_param('redirect_to');
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
|
return $this->error(
|
'Security verification failed. Please try again.',
|
'turnstile_failed',
|
403
|
);
|
}
|
|
// Attempt authentication
|
$user = wp_authenticate($email, $password);
|
|
if (is_wp_error($user)) {
|
$this->auditLog('login_failed', [
|
'email' => $email,
|
'error' => $user->get_error_code(),
|
]);
|
|
return $this->error(
|
$this->getLoginErrorMessage($user),
|
$user->get_error_code(),
|
401
|
);
|
}
|
|
// Set auth cookie
|
wp_clear_auth_cookie();
|
wp_set_current_user($user->ID);
|
wp_set_auth_cookie($user->ID, $remember);
|
|
// Store session fingerprint
|
$this->storeSessionFingerprint($user->ID, $request);
|
|
do_action('wp_login', $user->user_login, $user);
|
|
$this->auditLog('login_success', [
|
'user_id' => $user->ID,
|
'email' => $email,
|
]);
|
|
// Get redirect URL
|
$redirect = $this->getRedirectUrl($user, $redirect_to);
|
|
// Return auth data for frontend
|
return $this->success([
|
'message' => 'Login successful',
|
'redirect' => $redirect,
|
'auth' => $this->buildAuth($user->ID)
|
]);
|
}
|
|
/**
|
* Handle user registration
|
*/
|
public function handleRegister(WP_REST_Request $request): WP_REST_Response
|
{
|
$email = sanitize_email($request->get_param('user_email'));
|
$name = sanitize_text_field($request->get_param('user_name'));
|
$user_select = sanitize_text_field($request->get_param('user_select') ?? 'subscriber');
|
$referral_code = sanitize_text_field($request->get_param('referral_code') ?? '');
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
|
return $this->error(
|
'Security verification failed. Please try again.',
|
'turnstile_failed',
|
403
|
);
|
}
|
|
// Check if email already exists
|
if (email_exists($email)) {
|
return $this->error(
|
'An account with this email already exists.',
|
'email_exists',
|
400,
|
'user_email'
|
);
|
}
|
|
// Validate role selection
|
$role = $this->validateUserRole($user_select);
|
if (is_wp_error($role)) {
|
return $this->error(
|
$role->get_error_message(),
|
'invalid_role',
|
400,
|
'user_select'
|
);
|
}
|
|
// Create user account
|
$user_id = wp_create_user(
|
$email,
|
wp_generate_password(20, true, true),
|
$email
|
);
|
|
if (is_wp_error($user_id)) {
|
$this->logError('Registration failed', [
|
'email' => $email,
|
'error' => $user_id->get_error_message(),
|
]);
|
|
return $this->error(
|
'Failed to create account. Please try again.',
|
'registration_failed',
|
500
|
);
|
}
|
|
// Set user details
|
$user = get_user_by('ID', $user_id);
|
$user->set_role($role);
|
|
wp_update_user([
|
'ID' => $user_id,
|
'display_name' => $name,
|
'first_name' => $name,
|
]);
|
|
// Process referral code if provided
|
if (!empty($referral_code) && Site::has('referrals')) {
|
$this->processReferralCode($user_id, $referral_code);
|
}
|
|
// Process additional registration fields
|
$this->processRegistrationFields($user_id, $request->get_params());
|
|
do_action('user_register', $user_id, $request->get_params());
|
|
// Send magic link for email verification
|
if ($this->hasMagicLink) {
|
JVB()->magicLink()->sendMagicLink($email, 'signup', [
|
'name' => $name,
|
'role' => $role,
|
]);
|
}
|
|
$this->auditLog('registration_success', [
|
'user_id' => $user_id,
|
'email' => $email,
|
'role' => $role,
|
]);
|
|
return $this->success([
|
'message' => 'Registration successful! Check your email to complete setup.',
|
'title' => 'Success!',
|
'description' => [
|
'See your email for next steps',
|
'(Check your spam folder if you cannot find it after a couple minutes.)'
|
]
|
]);
|
}
|
|
/**
|
* Handle lost password request
|
*/
|
public function handleLostPassword(WP_REST_Request $request): WP_REST_Response
|
{
|
$email = sanitize_email($request->get_param('user_email'));
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
|
return $this->error(
|
'Security verification failed. Please try again.',
|
'turnstile_failed',
|
403
|
);
|
}
|
|
// Check if user exists
|
$user = get_user_by('email', $email);
|
if (!$user) {
|
// Don't reveal if email exists for security
|
return $this->success([
|
'message' => 'If that email address is in our system, we\'ve sent a password reset link.',
|
'title' => 'Success!',
|
'description' => ['Check your email for reset instructions']
|
]);
|
}
|
|
// Use magic link if available, otherwise standard WP reset
|
if ($this->hasMagicLink) {
|
$result = JVB()->magicLink()->sendMagicLink($email, 'reset');
|
|
if (is_wp_error($result)) {
|
$this->logError('Magic link send failed', [
|
'email' => $email,
|
'error' => $result->get_error_message(),
|
]);
|
}
|
} else {
|
// Standard WordPress password reset
|
$key = get_password_reset_key($user);
|
if (is_wp_error($key)) {
|
$this->logError('Reset key generation failed', [
|
'email' => $email,
|
'error' => $key->get_error_message(),
|
]);
|
} else {
|
$success = JVB()->email()->sendPasswordResetEmail($user, $key);
|
}
|
}
|
|
$this->auditLog('password_reset_requested', [
|
'user_id' => $user->ID,
|
'email' => $email,
|
]);
|
|
return $this->success([
|
'message' => 'Check your email for reset instructions.',
|
'title' => 'Success!',
|
'description' => ['Check your email for reset instructions']
|
]);
|
}
|
|
/**
|
* Handle password reset with token
|
*/
|
public function handleResetPassword(WP_REST_Request $request): WP_REST_Response
|
{
|
$key = sanitize_text_field($request->get_param('key'));
|
$login = sanitize_text_field($request->get_param('login'));
|
$pass1 = $request->get_param('pass1');
|
$pass2 = $request->get_param('pass2');
|
|
// Verify passwords match
|
if ($pass1 !== $pass2) {
|
return $this->error(
|
'Passwords do not match.',
|
'password_mismatch',
|
400,
|
'pass2'
|
);
|
}
|
|
// Validate password strength
|
if (strlen($pass1) < 8) {
|
return $this->error(
|
'Password must be at least 8 characters.',
|
'password_weak',
|
400,
|
'pass1'
|
);
|
}
|
|
// Verify reset key
|
$user = check_password_reset_key($key, $login);
|
if (is_wp_error($user)) {
|
return $this->error(
|
'Invalid or expired reset link.',
|
'invalid_key',
|
400
|
);
|
}
|
|
// Reset password
|
reset_password($user, $pass1);
|
|
$this->auditLog('password_reset_completed', [
|
'user_id' => $user->ID,
|
]);
|
|
return $this->success([
|
'message' => 'Password reset successful! You can now log in.',
|
'redirect' => wp_login_url(),
|
]);
|
}
|
|
/**
|
* Handle magic link request
|
*/
|
public function handleMagicLink(WP_REST_Request $request): WP_REST_Response
|
{
|
if (!$this->hasMagicLink) {
|
return $this->error(
|
'Magic link authentication is not enabled.',
|
'feature_disabled',
|
400
|
);
|
}
|
|
$email = sanitize_email($request->get_param('user_email'));
|
$type = sanitize_text_field($request->get_param('type') ?? 'login');
|
$redirect_to = $request->get_param('redirect_to');
|
|
// Verify Turnstile
|
if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
|
return $this->error(
|
'Security verification failed. Please try again.',
|
'turnstile_failed',
|
403
|
);
|
}
|
|
$context = [];
|
if ($redirect_to) {
|
$context['redirect_to'] = esc_url_raw($redirect_to);
|
}
|
|
// Send magic link
|
$result = JVB()->magicLink()->sendMagicLink($email, $type, $context);
|
|
if (is_wp_error($result)) {
|
$this->logError('Magic link send failed', [
|
'email' => $email,
|
'type' => $type,
|
'error' => $result->get_error_message(),
|
]);
|
|
return $this->error(
|
$result->get_error_message(),
|
$result->get_error_code(),
|
400
|
);
|
}
|
|
$this->auditLog('magic_link_sent', [
|
'email' => $email,
|
'type' => $type,
|
]);
|
|
return $this->success([
|
'message' => 'Check your email for a magic link to sign in!',
|
'title' => 'Success!',
|
'description' => [
|
'We\'ve sent you an email with a magic link.',
|
'Click it to sign in instantly!',
|
]
|
]);
|
}
|
|
/**
|
* Handle logout
|
*/
|
public function handleLogout(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = get_current_user_id();
|
|
// Clear session fingerprint
|
$this->clearSessionFingerprint($user_id);
|
|
wp_logout();
|
|
$this->auditLog('logout', [
|
'user_id' => $user_id,
|
]);
|
|
return $this->success([
|
'message' => 'Logged out successfully',
|
'redirect' => home_url('/login/'),
|
]);
|
}
|
|
/************************************************************
|
* SESSION FINGERPRINTING
|
*
|
* Detects session hijacking by validating that session hasn't
|
* moved to a different device/network. Uses IP class (not full IP)
|
* to allow mobile network changes without breaking sessions.
|
************************************************************/
|
|
/**
|
* Store session fingerprint for hijacking detection
|
*/
|
protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
|
{
|
if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
|
return;
|
}
|
|
$fingerprint = $this->generateSessionFingerprint($request);
|
update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
|
update_user_meta($user_id, BASE . 'session_timestamp', time());
|
}
|
|
/**
|
* Generate session fingerprint for hijacking detection
|
*/
|
protected function generateSessionFingerprint(WP_REST_Request $request): string
|
{
|
return hash('sha256', implode('|', [
|
$request->get_header('User-Agent') ?? '',
|
// Use IP class instead of full IP to allow for mobile network changes
|
$this->getIPClass(
|
$request->get_header('X-Forwarded-For')
|
?: $request->get_header('X-Real-IP')
|
?: $_SERVER['REMOTE_ADDR'] ?? ''
|
)
|
]));
|
}
|
|
/**
|
* Get IP class (first 3 octets) for session validation
|
* Allows for minor IP changes (common with mobile networks)
|
*/
|
protected function getIPClass(string $ip): string
|
{
|
$parts = explode('.', $ip);
|
return implode('.', array_slice($parts, 0, 3));
|
}
|
|
/**
|
* Validate session fingerprint against stored value
|
*/
|
protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
|
{
|
// Only enforce if enabled in config
|
if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
|
return true;
|
}
|
|
$stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
|
$current = $this->generateSessionFingerprint($request);
|
|
if (empty($stored)) {
|
// First request - store fingerprint
|
update_user_meta($user_id, BASE . 'session_fingerprint', $current);
|
update_user_meta($user_id, BASE . 'session_timestamp', time());
|
return true;
|
}
|
|
// Compare using timing-safe comparison
|
return hash_equals($stored, $current);
|
}
|
|
/**
|
* Clear session fingerprint (call on logout)
|
*/
|
protected function clearSessionFingerprint(int $user_id): void
|
{
|
delete_user_meta($user_id, BASE . 'session_fingerprint');
|
delete_user_meta($user_id, BASE . 'session_timestamp');
|
}
|
|
/**************************************************************
|
* HELPERS
|
**************************************************************/
|
|
/**
|
* Get redirect URL after login
|
*/
|
protected function getRedirectUrl(WP_User $user, ?string $redirect_to = null): string
|
{
|
// Use provided redirect if safe
|
if ($redirect_to && wp_validate_redirect($redirect_to, false)) {
|
return esc_url_raw($redirect_to);
|
}
|
|
// Default redirect based on user capability
|
if (user_can($user, 'manage_options')) {
|
return admin_url();
|
}
|
|
if (isOurPeople($user->ID)) {
|
return home_url('/dash/');
|
}
|
|
return home_url();
|
}
|
|
/**
|
* Get user-friendly login error message
|
*/
|
protected function getLoginErrorMessage(WP_Error $error): string
|
{
|
$code = $error->get_error_code();
|
|
$messages = [
|
'invalid_email' => 'Invalid email address.',
|
'invalid_username' => 'Invalid email address.',
|
'incorrect_password' => 'Incorrect password.',
|
'empty_password' => 'Please enter your password.',
|
'empty_username' => 'Please enter your email address.',
|
];
|
|
return $messages[$code] ?? 'Login failed. Please check your credentials.';
|
}
|
|
/**
|
* Validate user role selection during registration
|
*/
|
protected function validateUserRole(string $user_select): string|WP_Error
|
{
|
// Default to subscriber
|
if (empty($user_select) || $user_select === 'subscriber') {
|
return 'subscriber';
|
}
|
|
// Check if role is valid and can register
|
$registrar = Registrar::getInstance($user_select);
|
|
if (!$registrar) {
|
return new WP_Error('invalid_role', 'Invalid role selected.');
|
}
|
|
if (!($registrar->hasFeature('can_register') ?? false)) {
|
return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.');
|
}
|
|
return $registrar->getBased();
|
}
|
|
/**
|
* Process referral code during registration
|
*/
|
protected function processReferralCode(int $user_id, string $referral_code): void
|
{
|
if (!Site::has('referrals')) {
|
return;
|
}
|
|
try {
|
JVB()->referrals()->processReferralCode($user_id, $referral_code);
|
} catch (\Exception $e) {
|
$this->logError('Referral processing failed', [
|
'user_id' => $user_id,
|
'code' => $referral_code,
|
'error' => $e->getMessage(),
|
], 'warning');
|
}
|
}
|
|
/**
|
* Process additional registration fields
|
*/
|
protected function processRegistrationFields(int $user_id, array $data): void
|
{
|
// Get registration form configuration
|
$form_fields = get_option(BASE . 'registration_form_fields', []);
|
|
foreach ($form_fields as $field_name => $field_config) {
|
// Skip system fields
|
if (in_array($field_name, ['user_name', 'user_email', 'user_select', 'referral_code'])) {
|
continue;
|
}
|
|
// Save field value if present
|
if (isset($data[$field_name]) && !empty($data[$field_name])) {
|
$value = $data[$field_name];
|
|
// Sanitize based on field type
|
if (isset($field_config['type'])) {
|
$value = $this->sanitizeFieldValue($value, $field_config['type']);
|
}
|
|
update_user_meta($user_id, BASE . $field_name, $value);
|
}
|
}
|
}
|
|
/**
|
* Sanitize field value based on type
|
*/
|
protected function sanitizeFieldValue(mixed $value, string $type):string|int
|
{
|
switch ($type) {
|
case 'email':
|
return sanitize_email($value);
|
case 'url':
|
return esc_url_raw($value);
|
case 'textarea':
|
return sanitize_textarea_field($value);
|
case 'number':
|
return absint($value);
|
default:
|
return sanitize_text_field($value);
|
}
|
}
|
|
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)
|
];
|
}
|
|
return [
|
'authenticated' => false,
|
'user' => false,
|
'nonces' => [
|
'wp_rest' => wp_create_nonce('wp_rest')
|
]
|
];
|
}
|
protected function getUserNonces(int $userID):array {
|
$nonces = [
|
'wp_rest' => wp_create_nonce('wp_rest'),
|
];
|
if (Site::has('dashboard')) {
|
$nonces['dash'] = wp_create_nonce('dash-'.$userID);
|
}
|
if (Site::has('favourites')) {
|
$nonces['favourites'] = wp_create_nonce('favourites-'.$userID);
|
}
|
if (!empty(Registrar::getFeatured('karma'))) {
|
$nonces['votes'] = wp_create_nonce('votes-'.$userID);
|
}
|
if (Site::has('notifications')) {
|
$nonces['notifications'] = wp_create_nonce('notifications-'.$userID);
|
}
|
return $nonces;
|
}
|
}
|