PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / GuestVerificationTokenService.php

GuestVerificationTokenService.php in Yatra – Travel Booking & Tour Operator Software 3.0.10, at app/Services/GuestVerificationTokenService.php

158 lines 5.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 /**
8 * Mint + verify magic-link tokens for guest-email verification.
9 *
10 * Each token encodes (booking_id, email_hash, expiry) signed with
11 * an HMAC over a wp_salt('auth')-derived key. Validation rejects
12 * tokens that:
13 * - have an invalid HMAC (forged or tampered with)
14 * - have expired
15 * - don't match the booking's stored contact email
16 *
17 * Why not just `wp_create_nonce`: nonces are short-lived (~24h) and
18 * scoped to a `(user_id, action)` pair. Guest verification needs
19 * (a) per-booking specificity so a token for booking A can't
20 * verify booking B, (b) a customizable expiry window (operators
21 * may want 1h or 7d), and (c) stateless verification — no DB read
22 * to validate the signature itself.
23 *
24 * Format: `<booking_id>.<expiry_ts>.<email_prefix>.<hmac_hex>`
25 * - `booking_id` lets the verify endpoint look up the booking row
26 * without exposing customer info in the URL.
27 * - `expiry_ts` is Unix seconds (so we can validate without a DB
28 * round-trip to read a per-booking expiry column).
29 * - `email_prefix` is the first 8 hex chars of sha256(email).
30 * Lets verify() bind the token to a specific email even though
31 * we look up the booking by id. Without this, an attacker who
32 * intercepted one verification URL could potentially craft
33 * verification for a different email associated with the same
34 * booking (e.g. an admin-edited address).
35 * - `hmac_hex` is the truncated HMAC-SHA256 over the three parts.
36 *
37 * All four parts are URL-safe (digits + dot separator + hex).
38 *
39 * @package Yatra\Services
40 * @since 3.0.5
41 */
42 final class GuestVerificationTokenService
43 {
44 /**
45 * Default expiry window — 48 hours. Operators can change via
46 * the `yatra_guest_verification_ttl_seconds` filter (e.g. tighten
47 * to 1h for high-security setups, loosen to 7d for slow-deciding
48 * customers).
49 */
50 private const DEFAULT_TTL_SECONDS = 48 * 3600;
51
52 /**
53 * Mint a token for the given booking + email. Returns a URL-safe
54 * string the caller embeds in the verification magic link.
55 */
56 public static function mint(int $bookingId, string $email, ?int $ttlSeconds = null): string
57 {
58 $ttl = $ttlSeconds !== null && $ttlSeconds > 0
59 ? $ttlSeconds
60 : (int) apply_filters('yatra_guest_verification_ttl_seconds', self::DEFAULT_TTL_SECONDS);
61
62 $expiry = time() + $ttl;
63 $emailPrefix = self::emailPrefix($email);
64
65 $payload = $bookingId . '.' . $expiry . '.' . $emailPrefix;
66 $hmac = self::hmac($payload);
67
68 return $payload . '.' . $hmac;
69 }
70
71 /**
72 * Verify a token against a booking row.
73 *
74 * Returns a structured result so callers can distinguish
75 * "expired" from "tampered with" from "wrong booking" — useful
76 * for clear UX messaging on the verify page.
77 *
78 * @return array{ok: bool, reason?: string, booking_id?: int}
79 */
80 public static function verify(string $token, string $expectedEmail): array
81 {
82 if ($token === '') {
83 return ['ok' => false, 'reason' => 'empty_token'];
84 }
85 $parts = explode('.', $token);
86 if (\count($parts) !== 4) {
87 return ['ok' => false, 'reason' => 'malformed_token'];
88 }
89 [$bookingIdStr, $expiryStr, $emailPrefix, $providedHmac] = $parts;
90
91 if (!ctype_digit($bookingIdStr) || !ctype_digit($expiryStr)) {
92 return ['ok' => false, 'reason' => 'malformed_token'];
93 }
94 $bookingId = (int) $bookingIdStr;
95 $expiry = (int) $expiryStr;
96
97 // HMAC check FIRST — every other check below leaks no info
98 // about the booking if the signature is invalid.
99 $payload = $bookingId . '.' . $expiry . '.' . $emailPrefix;
100 $expectedHmac = self::hmac($payload);
101 if (!hash_equals($expectedHmac, $providedHmac)) {
102 return ['ok' => false, 'reason' => 'invalid_signature'];
103 }
104
105 if (time() >= $expiry) {
106 return ['ok' => false, 'reason' => 'expired', 'booking_id' => $bookingId];
107 }
108
109 if ($expectedEmail !== '' && self::emailPrefix($expectedEmail) !== $emailPrefix) {
110 // The booking's contact email changed since the token
111 // was minted. Reject — the operator (or attacker) edited
112 // the address; the original recipient is no longer the
113 // person being asked to verify.
114 return ['ok' => false, 'reason' => 'email_changed', 'booking_id' => $bookingId];
115 }
116
117 return ['ok' => true, 'booking_id' => $bookingId];
118 }
119
120 /**
121 * Build the absolute verification URL the email's magic-link
122 * button points at. Goes through the REST API namespace so it's
123 * available even before WP rewrites are flushed on a fresh
124 * install.
125 */
126 public static function buildVerifyUrl(int $bookingId, string $email, ?int $ttlSeconds = null): string
127 {
128 $token = self::mint($bookingId, $email, $ttlSeconds);
129 return add_query_arg(
130 ['token' => $token],
131 rest_url('yatra/v1/booking/verify-email')
132 );
133 }
134
135 /**
136 * Truncated sha256 of the email — 8 hex chars (32 bits) is
137 * enough to bind a token to a specific address without leaking
138 * the address itself. Collision risk is irrelevant because we
139 * still confirm full equality of `expectedEmail` against the
140 * booking row inside verify().
141 */
142 private static function emailPrefix(string $email): string
143 {
144 return substr(hash('sha256', strtolower(trim($email))), 0, 8);
145 }
146
147 /**
148 * HMAC-SHA256 truncated to 16 hex chars (64 bits). Enough to
149 * prevent forgery while keeping the URL short. The key derives
150 * from `wp_salt('auth')` so it rotates with WP_AUTH_KEY changes.
151 */
152 private static function hmac(string $payload): string
153 {
154 $key = wp_salt('auth') . '|yatra_guest_email_verification';
155 return substr(hash_hmac('sha256', $payload, $key), 0, 16);
156 }
157 }
158