<?php
|
namespace JVBase\managers;
|
|
use WP_User;
|
use WP_Error;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* Magic Link Authentication Manager
|
*
|
* Handles passwordless authentication via email magic links.
|
* Can be used for referral signups, password resets, or general login.
|
*/
|
class MagicLinkManager
|
{
|
protected CacheManager $cache;
|
protected EmailManager $email;
|
|
// Token settings
|
protected int $token_expiry = 900; // 15 minutes in seconds
|
protected int $rate_limit_window = 3600; // 1 hour
|
protected int $max_attempts_per_hour = 5;
|
|
// Link types - allows different flows for different purposes
|
const TYPE_LOGIN = 'login';
|
const TYPE_SIGNUP = 'signup';
|
const TYPE_REFERRAL = 'referral';
|
const TYPE_RESET = 'reset';
|
|
public function __construct()
|
{
|
$this->cache = new CacheManager('magic_links', $this->token_expiry);
|
$this->email = new EmailManager();
|
|
// Hook into WordPress auth flow
|
add_action('template_redirect', [$this, 'handleMagicLinkClick']);
|
add_action('wp_login_failed', [$this, 'handleFailedLogin']);
|
|
// Add magic link option to login page
|
add_action('login_form', [$this, 'addMagicLinkOption']);
|
add_filter('authenticate', [$this, 'blockStandardAuth'], 30, 3);
|
}
|
|
/**
|
* Generate and send a magic link
|
*
|
* @param string $email User's email address
|
* @param string $type Type of magic link (login, signup, referral, reset)
|
* @param array $context Additional context (referral_code, redirect_url, etc.)
|
* @return true|WP_Error
|
*/
|
public function sendMagicLink(string $email, string $type = self::TYPE_LOGIN, array $context = [])
|
{
|
// Validate email
|
$email = sanitize_email($email);
|
if (!is_email($email)) {
|
return new WP_Error('invalid_email', 'Invalid email address');
|
}
|
|
// Check rate limiting
|
$rate_check = $this->checkRateLimit($email);
|
if (is_wp_error($rate_check)) {
|
return $rate_check;
|
}
|
|
// Handle different link types
|
switch ($type) {
|
case self::TYPE_LOGIN:
|
return $this->sendLoginLink($email, $context);
|
|
case self::TYPE_SIGNUP:
|
return $this->sendSignupLink($email, $context);
|
|
case self::TYPE_REFERRAL:
|
return $this->sendReferralLink($email, $context);
|
|
case self::TYPE_RESET:
|
return $this->sendResetLink($email, $context);
|
|
default:
|
return new WP_Error('invalid_type', 'Invalid magic link type');
|
}
|
}
|
|
/**
|
* Send login magic link to existing user
|
*/
|
protected function sendLoginLink(string $email, array $context): bool|WP_Error
|
{
|
// Check if user exists
|
$user = get_user_by('email', $email);
|
if (!$user) {
|
return new WP_Error('user_not_found', 'No account found with this email');
|
}
|
|
// Generate token
|
$token = $this->generateToken($email, self::TYPE_LOGIN, [
|
'user_id' => $user->ID
|
]);
|
|
// Build magic link URL
|
$magic_url = add_query_arg([
|
'magic_token' => $token,
|
'email' => urlencode($email),
|
'action' => 'magic_login'
|
], home_url('/'));
|
|
// Add redirect if specified
|
if (!empty($context['redirect_to'])) {
|
$magic_url = add_query_arg('redirect_to', urlencode($context['redirect_to']), $magic_url);
|
}
|
|
// Send email
|
$subject = 'Sign in to ' . get_bloginfo('name');
|
$message = $this->getLoginEmailTemplate($user->display_name, $magic_url);
|
|
$sent = $this->email->sendEmail($email, $subject, $message, 'Log in to '. get_bloginfo('name'));
|
|
return $sent ? true : new WP_Error('email_failed', 'Failed to send magic link');
|
}
|
|
/**
|
* Send signup magic link for new user registration
|
*/
|
protected function sendSignupLink(string $email, array $context):bool|WP_Error
|
{
|
// Check if user already exists
|
if (email_exists($email)) {
|
return $this->sendLoginLink($email, $context);
|
}
|
|
// Generate token with signup data
|
$token_data = [
|
'name' => $context['name'] ?? '',
|
'role' => $context['role'] ?? 'subscriber',
|
'meta' => $context['meta'] ?? []
|
];
|
|
$token = $this->generateToken($email, self::TYPE_SIGNUP, $token_data);
|
|
// Build signup completion URL
|
$magic_url = add_query_arg([
|
'magic_token' => $token,
|
'email' => urlencode($email),
|
'action' => 'magic_signup'
|
], home_url('/'));
|
|
// Send welcome email
|
$subject = 'Complete your ' . get_bloginfo('name') . ' registration';
|
$message = $this->getSignupEmailTemplate($context['name'] ?? '', $magic_url);
|
|
$sent = $this->email->sendEmail($email, $subject, $message, 'Confirm Your Account');
|
|
return $sent ? true : new WP_Error('email_failed', 'Failed to send signup link');
|
}
|
|
/**
|
* Send referral signup link
|
*/
|
protected function sendReferralLink(string $email, array $context):bool|WP_Error
|
{
|
// Check if user already exists
|
if (email_exists($email)) {
|
return new WP_Error('user_exists', 'This person already has an account');
|
}
|
|
// Validate referral code
|
if (empty($context['referral_code'])) {
|
return new WP_Error('missing_referral_code', 'Referral code is required');
|
}
|
|
// Get referrer info for personalized email
|
$referrer_name = $context['referrer_name'] ?? 'A friend';
|
|
// Generate token with referral context
|
$token_data = [
|
'name' => $context['name'] ?? '',
|
'referral_code' => $context['referral_code'],
|
'referrer_id' => $context['referrer_id'] ?? 0
|
];
|
|
$token = $this->generateToken($email, self::TYPE_REFERRAL, $token_data);
|
|
// Build referral signup URL
|
$magic_url = add_query_arg([
|
'magic_token' => $token,
|
'email' => urlencode($email),
|
'action' => 'magic_referral'
|
], home_url('/'));
|
|
// Send personalized referral email
|
$subject = $referrer_name . ' invited you to ' . get_bloginfo('name');
|
$message = $this->getReferralEmailTemplate(
|
$context['name'] ?? '',
|
$referrer_name,
|
$magic_url,
|
$context['reward_text'] ?? ''
|
);
|
|
$sent = $this->email->sendEmail($email, $subject, $message, $referrer_name.' invites you to see the difference at Legacy');
|
|
return $sent ? true : new WP_Error('email_failed', 'Failed to send referral invitation');
|
}
|
|
/**
|
* Send password reset magic link
|
*/
|
protected function sendResetLink(string $email, array $context):bool|WP_Error
|
{
|
$user = get_user_by('email', $email);
|
if (!$user) {
|
// Return success even if user doesn't exist (security best practice)
|
return true;
|
}
|
|
$token = $this->generateToken($email, self::TYPE_RESET, [
|
'user_id' => $user->ID
|
]);
|
|
$magic_url = add_query_arg([
|
'magic_token' => $token,
|
'email' => urlencode($email),
|
'action' => 'magic_reset'
|
], home_url('/'));
|
|
$subject = 'Reset your password';
|
$message = $this->getResetEmailTemplate($user->display_name, $magic_url);
|
|
$sent = $this->email->sendEmail($email, $subject, $message);
|
|
return $sent ? true : new WP_Error('email_failed', 'Failed to send reset link');
|
}
|
|
/**
|
* Handle magic link click
|
*/
|
public function handleMagicLinkClick(): void
|
{
|
// Check if this is a magic link request
|
if (!isset($_GET['action']) || !isset($_GET['magic_token']) || !isset($_GET['email'])) {
|
return;
|
}
|
|
$action = sanitize_text_field($_GET['action']);
|
$token = sanitize_text_field($_GET['magic_token']);
|
$email = sanitize_email($_GET['email']);
|
|
// Only handle magic link actions
|
if (!in_array($action, ['magic_login', 'magic_signup', 'magic_referral', 'magic_reset'])) {
|
return;
|
}
|
|
// Verify token
|
$token_data = $this->verifyToken($token, $email);
|
|
if (is_wp_error($token_data)) {
|
$this->handleInvalidToken($token_data);
|
return;
|
}
|
|
// Handle different action types
|
switch ($action) {
|
case 'magic_login':
|
$this->processLogin($token_data);
|
break;
|
|
case 'magic_signup':
|
$this->processSignup($token_data);
|
break;
|
|
case 'magic_referral':
|
$this->processReferralSignup($token_data);
|
break;
|
|
case 'magic_reset':
|
$this->processPasswordReset($token_data);
|
break;
|
}
|
}
|
|
/**
|
* Process login via magic link
|
*/
|
protected function processLogin(array $token_data): void
|
{
|
$user = get_user_by('ID', $token_data['user_id']);
|
|
if (!$user) {
|
wp_die('Invalid user');
|
}
|
|
// Log the user in
|
wp_clear_auth_cookie();
|
wp_set_current_user($user->ID);
|
wp_set_auth_cookie($user->ID, true);
|
|
// Trigger login action
|
do_action('wp_login', $user->user_login, $user);
|
|
// Determine redirect
|
$redirect = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/dash');
|
|
// Redirect
|
wp_safe_redirect($redirect);
|
exit;
|
}
|
|
/**
|
* Process signup via magic link
|
*/
|
protected function processSignup(array $token_data): void
|
{
|
// Create the user account
|
$user_id = wp_create_user(
|
$token_data['email'],
|
wp_generate_password(20, true, true), // Random password
|
$token_data['email']
|
);
|
|
if (is_wp_error($user_id)) {
|
wp_die('Failed to create account: ' . $user_id->get_error_message());
|
}
|
|
// Set role
|
$user = get_user_by('ID', $user_id);
|
$user->set_role($token_data['role']);
|
|
// Update display name if provided
|
if (!empty($token_data['name'])) {
|
wp_update_user([
|
'ID' => $user_id,
|
'display_name' => $token_data['name'],
|
'first_name' => $token_data['name']
|
]);
|
}
|
|
// Save any additional meta
|
if (!empty($token_data['meta'])) {
|
foreach ($token_data['meta'] as $key => $value) {
|
update_user_meta($user_id, BASE . $key, $value);
|
}
|
}
|
|
// Log the user in
|
wp_set_current_user($user_id);
|
wp_set_auth_cookie($user_id, true);
|
|
// Trigger registration actions
|
do_action('user_register', $user_id);
|
do_action('wp_login', $user->user_login, $user);
|
|
// Redirect to welcome page or dashboard
|
wp_safe_redirect(home_url('/dash?welcome=1'));
|
exit;
|
}
|
|
/**
|
* Process referral signup via magic link
|
*/
|
protected function processReferralSignup(array $token_data): void
|
{
|
// Create user account
|
$user_id = wp_create_user(
|
$token_data['email'],
|
wp_generate_password(20, true, true),
|
$token_data['email']
|
);
|
|
if (is_wp_error($user_id)) {
|
wp_die('Failed to create account: ' . $user_id->get_error_message());
|
}
|
|
// Update user info
|
if (!empty($token_data['name'])) {
|
wp_update_user([
|
'ID' => $user_id,
|
'display_name' => $token_data['name'],
|
'first_name' => $token_data['name']
|
]);
|
}
|
|
// Store referral code in session for ReferralManager to pick up
|
if (session_status() === PHP_SESSION_NONE) {
|
session_start();
|
}
|
$_SESSION[BASE . 'referral_code'] = $token_data['referral_code'];
|
setcookie(BASE . 'referral_code', $token_data['referral_code'], time() + (30 * DAY_IN_SECONDS), '/');
|
|
// Process referral (this will be picked up by ReferralManager::processReferral)
|
do_action('user_register', $user_id);
|
|
// Log the user in
|
wp_set_current_user($user_id);
|
wp_set_auth_cookie($user_id, true);
|
do_action('wp_login', get_user_by('ID', $user_id)->user_login, get_user_by('ID', $user_id));
|
|
// Redirect with referral welcome message
|
wp_safe_redirect(home_url('/dash?referral_welcome=1'));
|
exit;
|
}
|
|
/**
|
* Process password reset
|
*/
|
protected function processPasswordReset(array $token_data): void
|
{
|
// Redirect to password reset form with token
|
wp_safe_redirect(add_query_arg([
|
'action' => 'rp',
|
'key' => $token_data['token'], // Could use magic token or generate WP reset key
|
'login' => $token_data['email']
|
], wp_login_url()));
|
exit;
|
}
|
|
/**
|
* Generate a secure token
|
*/
|
protected function generateToken(string $email, string $type, array $data): string
|
{
|
// Create unique token
|
$token = wp_generate_password(64, false, false);
|
|
// Store token data in transient
|
$token_data = [
|
'email' => $email,
|
'type' => $type,
|
'created_at' => time(),
|
'expires_at' => time() + $this->token_expiry,
|
'data' => $data
|
];
|
|
$cache_key = 'magic_token_' . $token;
|
set_transient($cache_key, $token_data, $this->token_expiry);
|
|
// Also index by email for rate limiting
|
$this->recordTokenGeneration($email);
|
|
return $token;
|
}
|
|
/**
|
* Verify a magic link token
|
*/
|
protected function verifyToken(string $token, string $email)
|
{
|
// Retrieve token data
|
$cache_key = 'magic_token_' . $token;
|
$token_data = get_transient($cache_key);
|
|
if (!$token_data) {
|
return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
|
}
|
|
// Verify email matches
|
if ($token_data['email'] !== $email) {
|
return new WP_Error('invalid_token', 'Invalid magic link');
|
}
|
|
// Check expiration
|
if (time() > $token_data['expires_at']) {
|
delete_transient($cache_key);
|
return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
|
}
|
|
// Token is valid - delete it (single use)
|
delete_transient($cache_key);
|
|
// Return merged data
|
return array_merge($token_data['data'], [
|
'email' => $token_data['email'],
|
'type' => $token_data['type']
|
]);
|
}
|
|
/**
|
* Rate limiting for magic link generation
|
*/
|
protected function checkRateLimit(string $email):bool|WP_Error
|
{
|
$limit_key = 'magic_link_limit_' . md5($email);
|
$attempts = (int) get_transient($limit_key);
|
|
if ($attempts >= $this->max_attempts_per_hour) {
|
return new WP_Error(
|
'rate_limit_exceeded',
|
'Too many login attempts. Please try again in an hour.'
|
);
|
}
|
|
return true;
|
}
|
|
/**
|
* Record token generation for rate limiting
|
*/
|
protected function recordTokenGeneration(string $email): void
|
{
|
$limit_key = 'magic_link_limit_' . md5($email);
|
$attempts = (int) get_transient($limit_key);
|
set_transient($limit_key, $attempts + 1, $this->rate_limit_window);
|
}
|
|
/**
|
* Handle invalid/expired tokens
|
*/
|
protected function handleInvalidToken(WP_Error $error): void
|
{
|
wp_die(
|
$error->get_error_message(),
|
'Invalid Link',
|
[
|
'response' => 400,
|
'back_link' => true
|
]
|
);
|
}
|
|
/**
|
* Add "Send me a magic link" option to login form
|
*/
|
public function addMagicLinkOption(): void
|
{
|
?>
|
<p class="magic-link-option">
|
<a href="#" id="use-magic-link">Send me a login link instead</a>
|
</p>
|
<script>
|
document.getElementById('use-magic-link')?.addEventListener('click', function(e) {
|
e.preventDefault();
|
const email = document.getElementById('user_login')?.value;
|
|
if (!email) {
|
alert('Please enter your email address first');
|
return;
|
}
|
|
// Send magic link request
|
fetch('<?php echo rest_url(BASE . '/v1/magic-link/send'); ?>', {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
},
|
body: JSON.stringify({
|
email: email,
|
type: 'login'
|
})
|
})
|
.then(r => r.json())
|
.then(data => {
|
if (data.success) {
|
alert('Check your email! We sent you a login link.');
|
} else {
|
alert(data.message || 'Failed to send link');
|
}
|
});
|
});
|
</script>
|
<?php
|
}
|
|
/**
|
* Optionally block standard password auth for certain users
|
*/
|
public function blockStandardAuth($user, $username, $password)
|
{
|
// Only block if user has magic-link-only flag
|
if ($user instanceof WP_User) {
|
$magic_only = get_user_meta($user->ID, BASE . 'magic_link_only', true);
|
if ($magic_only) {
|
return new WP_Error('magic_link_required', 'Please use the login link sent to your email');
|
}
|
}
|
|
return $user;
|
}
|
|
// ========================================
|
// EMAIL TEMPLATES
|
// ========================================
|
|
protected function getLoginEmailTemplate(string $name, string $magic_url): string
|
{
|
$content = '<h2>Hey ' . esc_html($name) . '!</h2>';
|
$content .= '<p>Click the button below to sign in to your account. This link expires in 15 minutes.</p>';
|
$content .= '<p style="text-align: center; margin: 30px 0;">';
|
$content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Sign In</a>';
|
$content .= '</p>';
|
$content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email.</p>';
|
|
return $content;
|
}
|
|
protected function getSignupEmailTemplate(string $name, string $magic_url): string
|
{
|
$content = '<h2>Welcome' . ($name ? ', ' . esc_html($name) : '') . '!</h2>';
|
$content .= '<p>You\'re almost there! Click the button below to complete your registration and access your account.</p>';
|
$content .= '<p style="text-align: center; margin: 30px 0;">';
|
$content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Complete Registration</a>';
|
$content .= '</p>';
|
$content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
|
|
return $content;
|
}
|
|
protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text): string
|
{
|
$content = '<h2>Hey' . ($name ? ' ' . esc_html($name) : '') . '!</h2>';
|
$content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . ' and invited you to join!</p>';
|
|
if ($reward_text) {
|
$content .= '<div style="background: #e7f5ff; padding: 20px; border-radius: 8px; margin: 20px 0;">';
|
$content .= '<h3 style="margin-top: 0;">🎉 Special Offer</h3>';
|
$content .= '<p>' . esc_html($reward_text) . '</p>';
|
$content .= '</div>';
|
}
|
|
$content .= '<p style="text-align: center; margin: 30px 0;">';
|
$content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Accept Invitation</a>';
|
$content .= '</p>';
|
$content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
|
|
return $content;
|
}
|
|
protected function getResetEmailTemplate(string $name, string $magic_url): string
|
{
|
$content = '<h2>Reset Your Password</h2>';
|
$content .= '<p>Hey ' . esc_html($name) . ', we received a request to reset your password.</p>';
|
$content .= '<p style="text-align: center; margin: 30px 0;">';
|
$content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reset Password</a>';
|
$content .= '</p>';
|
$content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email.</p>';
|
|
return $content;
|
}
|
}
|
|
new MagicLinkManager();
|