src/CSRF.php source

1 <?php
2
3 namespace mini;
4
5 /**
6 * WordPress-inspired nonce (CSRF) token system
7 *
8 * Tokens are self-contained with action, timestamp, and IP address,
9 * signed with HMAC using session ID, user agent, and application salt.
10 *
11 * Usage:
12 * $nonce = new CSRF('delete-post');
13 * render('form.php', ['nonce' => $nonce]);
14 *
15 * // In template:
16 * <form method="post">
17 * <?= $nonce ?>
18 * ...
19 * </form>
20 *
21 * // Verify:
22 * $nonce = new CSRF('delete-post');
23 * if ($nonce->verify($_POST['__nonce__'])) {
24 * // Process form
25 * }
26 */
27 class CSRF
28 {
29 private string $action;
30 private string $fieldName;
31 private ?string $token = null;
32
33 /**
34 * Create a CSRF token for a specific action
35 *
36 * @param string $action Action name (e.g., 'delete-post', 'update-settings')
37 * @param string $fieldName HTML field name (default: '__nonce__')
38 */
39 public function __construct(string $action, string $fieldName = '__nonce__')
40 {
41 $this->action = $action;
42 $this->fieldName = $fieldName;
43 }
44
45 /**
46 * Build signature key from hard-to-guess components
47 *
48 * Includes application salt, session ID, and user agent to make
49 * tokens difficult to forge even if attacker knows the action.
50 */
51 private function buildSignatureKey(): string
52 {
53 $hardToGuess = Mini::$mini->salt;
54
55 // Include session ID if available
56 $sessionName = session_name() ?: '';
57 if ($sessionName && isset($_COOKIE[$sessionName])) {
58 $hardToGuess .= $_COOKIE[$sessionName];
59 }
60
61 // Include user agent for browser fingerprinting
62 $hardToGuess .= $_SERVER['HTTP_USER_AGENT'] ?? '';
63
64 return $hardToGuess;
65 }
66
67 /**
68 * Generate a new token with current timestamp and IP
69 */
70 private function generateToken(): string
71 {
72 $data = implode('|', [
73 $this->action,
74 (string) microtime(true),
75 $_SERVER['REMOTE_ADDR'] ?? ''
76 ]);
77
78 $signature = hash_hmac('sha256', $data, $this->buildSignatureKey());
79 $token = $data . '|' . $signature;
80
81 return base64_encode($token);
82 }
83
84 /**
85 * Get the token string (lazy generation)
86 */
87 public function getToken(): string
88 {
89 if ($this->token === null) {
90 $this->token = $this->generateToken();
91 }
92 return $this->token;
93 }
94
95 /**
96 * Verify a token
97 *
98 * @param string|null $token Token to verify (typically from $_POST)
99 * @param float $maxAge Maximum age in seconds (default: 86400 = 24 hours)
100 * @return bool True if valid and not expired
101 */
102 public function verify(?string $token, float $maxAge = 86400): bool
103 {
104 if ($token === null || $token === '') {
105 return false;
106 }
107
108 // Decode token
109 $decoded = base64_decode($token, true);
110 if ($decoded === false) {
111 return false;
112 }
113
114 // Split into parts: action|time|ip|signature
115 $parts = explode('|', $decoded);
116 if (count($parts) !== 4) {
117 return false;
118 }
119
120 [$action, $time, $ip, $signature] = $parts;
121
122 // Verify action matches
123 if ($action !== $this->action) {
124 return false;
125 }
126
127 // Verify not expired
128 $age = microtime(true) - (float) $time;
129 if ($age > $maxAge || $age < 0) {
130 return false;
131 }
132
133 // Verify IP matches (if IP was recorded)
134 $currentIp = $_SERVER['REMOTE_ADDR'] ?? '';
135 if ($ip !== '' && $ip !== $currentIp) {
136 return false;
137 }
138
139 // Verify signature using same key derivation
140 $data = implode('|', [$action, $time, $ip]);
141 $expectedSignature = hash_hmac('sha256', $data, $this->buildSignatureKey());
142
143 return hash_equals($expectedSignature, $signature);
144 }
145
146 /**
147 * Output hidden input field
148 */
149 public function __toString(): string
150 {
151 return sprintf(
152 '<input type="hidden" name="%s" value="%s">',
153 htmlspecialchars($this->fieldName, ENT_QUOTES, 'UTF-8'),
154 htmlspecialchars($this->getToken(), ENT_QUOTES, 'UTF-8')
155 );
156 }
157 }
158