218 lines
11 KiB
PHP
218 lines
11 KiB
PHP
<?php
|
||
/**
|
||
* register.php — New user registration
|
||
*
|
||
* Called by: registration page AJAX on form submission.
|
||
* Input: JSON body decoded from $_POST['json']:
|
||
* name, surname, username, email, password, confirm_password
|
||
*
|
||
* Creates a new user account in status='pending' (email not yet verified)
|
||
* and sends a 30-day email verification link. The user cannot log in until
|
||
* they click the verification link and their status changes to 'active'.
|
||
*
|
||
* Full flow:
|
||
* 1. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||
* 2. Decode and sanitise input fields (trim, lowercase username/email).
|
||
* 3. Required field validation — all 6 fields must be non-empty.
|
||
* 4. Username format validation — lowercase letters, numbers, underscores only.
|
||
* 5. Email format validation — PHP's FILTER_VALIDATE_EMAIL.
|
||
* 6. Password match check — $password must equal $confirm_password.
|
||
* 7. Duplicate username check — 409 if already taken.
|
||
* 8. Duplicate email check — 409 if already registered.
|
||
* 9. Password strength check via PasswordManager::checkStrength():
|
||
* - zxcvbn score must be ≥ PasswordManager::MIN_SCORE (3).
|
||
* - User's own name, surname, username, email passed as penalty inputs.
|
||
* - 422 if too weak, with the first actionable zxcvbn suggestion.
|
||
* 10. Hash password with PASSWORD_BCRYPT.
|
||
* 11. Generate a 64-hex-char verification token (32 random bytes).
|
||
* 12. INSERT user row with status='pending' and the verification token.
|
||
* 13. Build absolute verify URL: <base_url>/login/verify.php?token=<token>
|
||
* 14. Send verification email via system SMTP ($SMTP from config.php).
|
||
* If mailer fails, it exits internally with its own error JSON.
|
||
* 15. Return { success: 1, message: "Account created! Please check your email..." }
|
||
*
|
||
* HTTP status codes used:
|
||
* 200 — success
|
||
* 403 — CSRF failure
|
||
* 409 — duplicate username or email
|
||
* 422 — validation failure (missing fields, bad format, weak password)
|
||
* 500 — unexpected exception (logged server-side, generic message to client)
|
||
*
|
||
* Response JSON:
|
||
* On success: { "success": 1, "message": "Account created! Please check your email to verify your account." }
|
||
* On failure: { "success": 0, "message": "<reason>" }
|
||
*/
|
||
|
||
require '../../../session.php';
|
||
require '../../../config.php';
|
||
require '../../../dbconn.php';
|
||
require '../../../assets/utils/db_helpers.php';
|
||
require '../../../assets/utils/classes/PasswordManager.php';
|
||
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
$answer = ['success' => 0, 'message' => ''];
|
||
|
||
// ── Step 1: CSRF check ────────────────────────────────────────────────────────
|
||
// All POST requests must include a valid X-CSRF-Token header matching the token
|
||
// stored in session. This prevents cross-site request forgery on the register form.
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
|
||
http_response_code(403);
|
||
$answer['message'] = 'Invalid request.';
|
||
exit(json_encode($answer));
|
||
}
|
||
}
|
||
|
||
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
|
||
|
||
try {
|
||
|
||
// ── Step 2: Sanitise input ────────────────────────────────────────────────
|
||
$name = trim($data['name'] ?? '');
|
||
$surname = trim($data['surname'] ?? '');
|
||
$username = strtolower(trim($data['username'] ?? ''));
|
||
$email = strtolower(trim($data['email'] ?? ''));
|
||
$password = $data['password'] ?? '';
|
||
$confirm = $data['confirm_password'] ?? '';
|
||
|
||
// ── Step 3: Required field validation ────────────────────────────────────
|
||
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
|
||
$answer['message'] = 'All fields are required.';
|
||
http_response_code(422);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 4: Username format validation ───────────────────────────────────
|
||
// Restricts usernames to URL-safe characters — prevents injection via
|
||
// username in any context where it appears in a URL or query.
|
||
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
|
||
$answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
|
||
http_response_code(422);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 5: Email format validation ──────────────────────────────────────
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
$answer['message'] = 'Invalid email address.';
|
||
http_response_code(422);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 6: Password match check ─────────────────────────────────────────
|
||
if ($password !== $confirm) {
|
||
$answer['message'] = 'Passwords do not match.';
|
||
http_response_code(422);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 7: Duplicate username check ─────────────────────────────────────
|
||
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
|
||
$sth->execute([':u' => $username]);
|
||
db_check($sth, $answer);
|
||
if ($sth->fetchColumn()) {
|
||
$answer['message'] = 'Username is already taken.';
|
||
http_response_code(409);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 8: Duplicate email check ────────────────────────────────────────
|
||
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
|
||
$sth->execute([':e' => $email]);
|
||
db_check($sth, $answer);
|
||
if ($sth->fetchColumn()) {
|
||
$answer['message'] = 'An account with that email already exists.';
|
||
http_response_code(409);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 9: Password strength check via PasswordManager ──────────────────
|
||
// Passes user's own personal data as penalty inputs so zxcvbn penalises
|
||
// passwords that contain the user's name, username, or email.
|
||
$pm = new PasswordManager($pdo1, $include_url);
|
||
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
|
||
if ($result['score'] < PasswordManager::MIN_SCORE) {
|
||
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
|
||
$answer['message'] = 'Password is too weak. ' . $msg;
|
||
http_response_code(422);
|
||
exit(json_encode($answer));
|
||
}
|
||
|
||
// ── Step 10–11: Hash password and generate verification token ─────────────
|
||
$hashed = password_hash($password, PASSWORD_BCRYPT);
|
||
$token = bin2hex(random_bytes(32)); // 64-char hex token
|
||
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||
|
||
// ── Step 12: Insert user with status='pending' ────────────────────────────
|
||
// status='pending' means the account exists but cannot log in until the
|
||
// email is verified. login_otp.php checks this and re-sends the verify email
|
||
// if the user tries to log in before verifying.
|
||
$sth = $pdo1->prepare("
|
||
INSERT INTO user
|
||
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
|
||
VALUES
|
||
(:username, :name, :surname, :email, :password, 'pending', '', :token, :expires)
|
||
");
|
||
$sth->execute([
|
||
':username' => $username,
|
||
':name' => $name,
|
||
':surname' => $surname,
|
||
':email' => $email,
|
||
':password' => $hashed,
|
||
':token' => $token,
|
||
':expires' => $expires_at,
|
||
]);
|
||
db_check($sth, $answer);
|
||
|
||
// ── Step 13: Build absolute verify URL ───────────────────────────────────
|
||
// $server_url is the app's root path from config.php (e.g. '/wms').
|
||
// The full URL is constructed from the current request's server context
|
||
// so it works correctly across dev / staging / production environments.
|
||
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||
. '://' . $_SERVER['HTTP_HOST']
|
||
. rtrim($server_url, '/');
|
||
$verify_url = $base_url . '/login/verify.php?token=' . $token;
|
||
|
||
// ── Step 14: Send verification email via system SMTP ─────────────────────
|
||
// Uses $SMTP from config.php (system-level, not company SMTP) because the
|
||
// user does not have a company yet at registration time.
|
||
// If the mailer fails it exits internally with its own error JSON response.
|
||
require_once '../../../assets/utils/module/mailer.php';
|
||
|
||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||
$mailer->send_email([
|
||
'company_id' => 0,
|
||
'smtp' => $SMTP,
|
||
'to' => $email,
|
||
'subject' => 'Verify your email — WMS',
|
||
'message' => implode("\n", [
|
||
"Hi {$name},",
|
||
"",
|
||
"Thanks for registering. Please verify your email address by clicking the button below:",
|
||
"",
|
||
"<a href=\"{$verify_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Verify Email Address</a>",
|
||
"",
|
||
"Or copy and paste this link into your browser:",
|
||
"<a href=\"{$verify_url}\">{$verify_url}</a>",
|
||
"",
|
||
"This link will expire in 30 days.",
|
||
"",
|
||
"If you did not create an account, you can ignore this email.",
|
||
]),
|
||
'channel_name' => 'WMS',
|
||
'key' => $pinkey,
|
||
]);
|
||
|
||
// ── Step 15: Respond ──────────────────────────────────────────────────────
|
||
$answer['success'] = 1;
|
||
$answer['message'] = 'Account created! Please check your email to verify your account.';
|
||
|
||
} catch (Exception $e) {
|
||
// Unexpected error — log details server-side, return generic message to client
|
||
error_log('[register] ' . $e->getMessage());
|
||
$answer['message'] = 'Registration failed. Please try again.';
|
||
http_response_code(500);
|
||
}
|
||
|
||
exit(json_encode($answer)); |