/ auth.php
auth.php
1 <?php 2 3 define('IS_4CHANNEL', preg_match('/(^|\.)4channel.org$/', $_SERVER['HTTP_HOST'])); 4 5 if (IS_4CHANNEL) { 6 define('THIS_DOMAIN', '4channel.org'); 7 define('OTHER_DOMAIN', '4chan.org'); 8 } 9 else { 10 define('THIS_DOMAIN', '4chan.org'); 11 define('OTHER_DOMAIN', '4channel.org'); 12 } 13 14 define('PASS_TIMEOUT', 900); // 15 minutes 15 define('LOGIN_FAIL_HOURLY', 5); 16 17 require_once 'lib/db.php'; 18 require_once 'lib/geoip2.php'; 19 20 class App { 21 protected 22 // Routes 23 $actions = array( 24 'index' 25 ), 26 27 $is_xhr = false 28 ; 29 30 const VIEW_TPL = 'views/pass_auth.tpl.php'; 31 32 const PASS_TABLE = 'pass_users'; 33 34 const 35 AUTH_NO = 0, 36 AUTH_SUCCESS = 1, 37 AUTH_YES = 2, 38 AUTH_ERROR = -1, 39 AUTH_OUT = 4 40 ; 41 42 const 43 ERR_BAD_REQUEST = 'Bad Request.', 44 ERR_GENERIC = 'Internal Server Error (%s)', 45 ERR_FLOOD = 'You have to wait a while before attempting this again.', 46 ERR_EMPTY_FIELD = 'You have left one or more fields blank.', 47 ERR_TOKEN_LEN = 'Your Token must be exactly 10 characters.', 48 ERR_DB = 'We are currently having database issues. Please try again later.', 49 ERR_BAD_AUTH = 'Incorrect Token or PIN.', 50 ERR_IN_USE = 'This Pass is already in use by another IP. Please wait %s and re-authorize by visiting this page again to change IPs.', 51 ERR_EXPIRED = 'This Pass has expired. Please visit <a href="https://www.4chan.org/pass.php?renew=%s">this page</a> to renew it.', // status 1 52 ERR_REFUNDED = 'This Pass has been refunded and disabled. You cannot use it anymore.', // status 2 53 ERR_DISPUTED = 'This Pass has a disputed payment. You cannot use it until the dispute is resolved.', // status 3 54 ERR_REVOKED_SPAM = 'This Pass has been revoked due to spamming, which is a violation of the <a href="https://www.4chan.org/pass#termsofuse">Terms of Use</a>.', // status 4 55 ERR_REVOKED_ILLEGAL = 'This Pass has been revoked due to illegal content being posted, which is a violaton of the <a href="https://www.4chan.org/pass#termsofuse">Terms of Use</a>.' // status 5 56 ; 57 58 private function error($msg) { 59 $this->renderResponse(self::AUTH_ERROR, $msg); 60 } 61 62 private function renderResponse($status, $msg = null) { 63 if ($this->is_xhr) { 64 header('Content-type: application/json'); 65 echo json_encode(array('status' => $status, 'message' => $msg)); 66 } 67 else { 68 $this->auth_status = $status; 69 $this->message = $msg; 70 require_once(self::VIEW_TPL); 71 } 72 die(); 73 } 74 75 private function pretty_duration($sec) { 76 $duration = ''; 77 78 $hours = (int)($sec / 3600); 79 $minutes = (int)($sec / 60); 80 81 if ($hours) { 82 $duration .= str_pad($hours, 2, '0', STR_PAD_LEFT) . ' hour'; 83 84 if ($hours != 1) { 85 $duration .= 's'; 86 } 87 88 $duration .= ' '; 89 } 90 91 if ($minutes) { 92 $minutes = (int)(($sec / 60) % 60); 93 94 $duration .= str_pad($minutes, 2, '0', STR_PAD_LEFT). ' minute'; 95 96 if ($minutes != 1) { 97 $duration .= 's'; 98 } 99 } 100 101 $seconds = intval($sec % 60); 102 103 return $duration; 104 } 105 106 private function get_csrf_token() { 107 return bin2hex(openssl_random_pseudo_bytes(16)); 108 } 109 110 private function validate_referer() { 111 if (!isset($_SERVER['HTTP_REFERER']) || $_SERVER['HTTP_REFERER'] === '') { 112 return; 113 } 114 115 if (!preg_match('/^https:\/\/sys\.(4chan|4channel)\.org(\/|$)/', $_SERVER['HTTP_REFERER'])) { 116 $this->error(self::ERR_BAD_REQUEST); 117 } 118 } 119 120 private function validate_csrf() { 121 if (!isset($_COOKIE['csrf']) || !isset($_POST['csrf']) 122 || $_COOKIE['csrf'] === '' || $_POST['csrf'] === '') { 123 $this->error(self::ERR_BAD_REQUEST); 124 } 125 126 if ($_COOKIE['csrf'] !== $_POST['csrf']) { 127 $this->error(self::ERR_BAD_REQUEST); 128 } 129 } 130 131 private function validate_auth_flood($long_ip) { 132 if (!$long_ip) { 133 return; 134 } 135 136 $query = "SELECT COUNT(ip) FROM user_actions WHERE ip = $long_ip AND action = 'fail_pass_auth' AND time >= DATE_SUB(NOW(), INTERVAL 1 HOUR)"; 137 138 $res = mysql_global_call($query); 139 140 if (!$res) { 141 return; 142 } 143 144 $count = (int)mysql_fetch_row($res)[0]; 145 146 if ($count >= LOGIN_FAIL_HOURLY) { 147 $this->error(self::ERR_FLOOD); 148 } 149 } 150 151 private function register_auth_failure($long_ip) { 152 if (!$long_ip) { 153 return; 154 } 155 156 $query = "INSERT INTO user_actions (ip, board, action, time) VALUES(%d, '', 'fail_pass_auth', NOW())"; 157 $res = mysql_global_call($query, $long_ip); 158 } 159 160 private function convert_new_pass_status($user_hash, $hashed_pin) { 161 $table = self::PASS_TABLE; 162 163 $query = "UPDATE $table SET pin = '%s', status = 0 WHERE user_hash = '%s' AND status = 6 LIMIT 1"; 164 165 mysql_global_call($query, $hashed_pin, $user_hash); 166 167 $this->set_cookie('pass_email', '', -1); 168 } 169 170 private function convert_delayed_pass_status($user_hash, $hashed_pin) { 171 $table = self::PASS_TABLE; 172 173 $query = "UPDATE $table SET pin = '%s', status = 0, expiration_date = NOW() + INTERVAL 1 YEAR WHERE user_hash = '%s' AND status = 7 LIMIT 1"; 174 175 mysql_global_call($query, $hashed_pin, $user_hash); 176 } 177 178 private function set_cookie($name, $value, $ttl, $secure = false, $http_only = false) { 179 $name = rawurlencode($name); 180 $value = rawurlencode($value); 181 182 $domain = '.' . THIS_DOMAIN; 183 184 $flags = array(); 185 186 if ($secure) { 187 $flags[] = 'Secure'; 188 } 189 190 if ($http_only) { 191 $flags[] = 'HttpOnly'; 192 } 193 194 if (!empty($flags)) { 195 $flags = '; ' . implode('; ', $flags); 196 } 197 else { 198 $flags = ''; 199 } 200 201 if ($ttl !== 0) { 202 $max_age = " Max-Age=$ttl;"; 203 } 204 else { 205 $max_age = ''; 206 } 207 208 header("Set-Cookie: $name=$value; Path=/;$max_age Domain=$domain; SameSite=None$flags", false); 209 } 210 211 private function clear_cookies() { 212 $cookie_time = -3600; 213 $this->set_cookie('pass_id', '', $cookie_time, true, true); 214 $this->set_cookie('pass_enabled', '', $cookie_time, true); 215 } 216 217 private function get_random_base64bytes($length = 64) { 218 $data = openssl_random_pseudo_bytes($length); 219 220 return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); 221 } 222 223 private function get_salt() { 224 $salt = file_get_contents('/www/keys/2014_admin.salt'); 225 226 if (!$salt) { 227 $this->error(sprintf(self::ERR_GENERIC, 'gs')); 228 } 229 230 return $salt; 231 } 232 233 /** 234 * Login 235 */ 236 private function authenticate() { 237 $this->validate_referer(); 238 239 $table = self::PASS_TABLE; 240 241 $time_now = time(); 242 243 // Token 244 if (!isset($_POST['id']) || $_POST['id'] === '') { 245 $this->error(self::ERR_EMPTY_FIELD); 246 } 247 248 if (strlen($_POST['id']) != 10) { 249 $this->error(self::ERR_TOKEN_LEN); 250 } 251 252 $id = $_POST['id']; 253 254 // Pin 255 if (!isset($_POST['pin']) || $_POST['pin'] === '') { 256 $this->error(self::ERR_EMPTY_FIELD); 257 } 258 259 $pin = $_POST['pin']; 260 261 // --- 262 263 $ip = $_SERVER['REMOTE_ADDR']; 264 $long_ip = ip2long($ip); 265 266 $this->validate_auth_flood($long_ip); 267 268 // --- 269 270 $plain_pin = $pin; 271 $pin = crypt($pin, substr($id, 4, 9)); 272 273 $query = "SELECT * FROM $table WHERE user_hash = '%s' AND (pin = '%s' OR pin = '%s') LIMIT 1"; 274 275 $res = mysql_global_call($query, $id, $pin, $plain_pin); 276 277 if (!$res) { 278 $this->error(self::ERR_DB); 279 } 280 281 if (mysql_num_rows($res) !== 1) { 282 $this->register_auth_failure($long_ip); 283 $this->error(self::ERR_BAD_AUTH); 284 } 285 286 $pass = mysql_fetch_assoc($res); 287 288 if (!$pass) { 289 $this->error(sprintf(self::ERR_GENERIC, 'mfa1')); 290 } 291 292 $last_used = strtotime($pass['last_used']); 293 294 $last_ip_mask = ip2long($pass['last_ip']) & (~65535); 295 296 $ip_mask = $long_ip & (~65535); 297 298 if ($last_ip_mask !== 0 && ($time_now - $last_used) < PASS_TIMEOUT && $last_ip_mask != $ip_mask) { 299 $remaining = $this->pretty_duration(PASS_TIMEOUT - ($time_now - $last_used)); 300 $this->error(sprintf(self::ERR_IN_USE, $remaining)); 301 } 302 303 switch ($pass['status']){ 304 case 0: 305 break; 306 307 case 1: 308 $this->clear_cookies(); 309 $this->error(sprintf(self::ERR_EXPIRED, $pass['pending_id'])); 310 break; 311 312 case 2: 313 $this->clear_cookies(); 314 $this->error(self::ERR_REFUNDED); 315 break; 316 317 case 3: 318 $this->clear_cookies(); 319 $this->error(self::ERR_DISPUTED); 320 break; 321 322 case 4: 323 $this->clear_cookies(); 324 $this->error(self::ERR_REVOKED_SPAM); 325 break; 326 327 case 5: 328 $this->clear_cookies(); 329 $this->error(self::ERR_REVOKED_ILLEGAL); 330 break; 331 332 case 6: 333 $this->convert_new_pass_status($pass['user_hash'], $pin); 334 break; 335 336 case 7: 337 $this->convert_delayed_pass_status($pass['user_hash'], $pin); 338 break; 339 } 340 341 // Update country 342 $geo_data = GeoIP2::get_country($ip); 343 344 if ($geo_data && isset($geo_data['country_code'])) { 345 $country_code = mysql_real_escape_string($geo_data['country_code']); 346 } 347 else { 348 $country_code = 'XX'; 349 } 350 351 $update_country = ", last_country = '$country_code'"; 352 353 $query = "UPDATE $table SET last_ip = '%s', last_used = NOW() $update_country WHERE user_hash = '%s' AND last_ip != '%s' AND status = 0 LIMIT 1"; 354 355 mysql_global_call($query, $ip, $id, $ip); 356 357 // Update session id 358 if (!$pass['session_id']) { 359 $pass_session = $this->get_random_base64bytes(32); 360 361 if (!$pass_session) { 362 $this->error(sprintf(self::ERR_GENERIC, 'grb')); 363 } 364 365 $query = "UPDATE $table SET session_id = '$pass_session' WHERE user_hash = '%s' AND status = 0 LIMIT 1"; 366 367 mysql_global_call($query, $id); 368 } 369 else { 370 $pass_session = $pass['session_id']; 371 } 372 373 $admin_salt = $this->get_salt(); 374 375 $hashed_pass_session = substr(hash('sha256', $pass_session . $admin_salt), 0, 32); 376 377 if (!$hashed_pass_session) { 378 $this->error(sprintf(self::ERR_GENERIC, 'hps')); 379 } 380 381 if (isset($_POST['long_login'])) { 382 $cookie_time = 31556900; 383 } 384 else { 385 $cookie_time = 86400; 386 } 387 388 $this->set_cookie('pass_id', "$id.$hashed_pass_session", $cookie_time, true, true); 389 $this->set_cookie('pass_enabled', '1', $cookie_time, true); 390 391 $this->renderResponse(self::AUTH_SUCCESS); 392 } 393 394 /** 395 * Index 396 */ 397 public function index() { 398 if ($_SERVER['REQUEST_METHOD'] == 'POST') { 399 if (isset($_POST['logout'])) { 400 $this->validate_referer(); 401 $this->clear_cookies(); 402 $this->renderResponse(self::AUTH_OUT); 403 } 404 else { 405 return $this->authenticate(); 406 } 407 } 408 409 if (isset($_COOKIE['pass_enabled'])) { 410 $this->renderResponse(self::AUTH_YES); 411 } 412 else { 413 $this->renderResponse(self::AUTH_NO); 414 } 415 } 416 417 /** 418 * Main 419 */ 420 public function run() { 421 $method = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET; 422 423 if (isset($method['action'])) { 424 $action = $method['action']; 425 } 426 else { 427 $action = 'index'; 428 } 429 430 if (in_array($action, $this->actions)) { 431 if (isset($method['xhr'])) { 432 /* 433 if (isset($_SERVER['HTTP_ORIGIN']) && preg_match('/^https:\/\/sys\.(4chan|4channel)\.org$/', $_SERVER['HTTP_ORIGIN'])) { 434 header('Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN']); 435 header('Access-Control-Allow-Methods: OPTIONS, POST'); 436 header('Access-Control-Allow-Credentials: true'); 437 } 438 */ 439 $this->is_xhr = true; 440 } 441 442 $this->$action(); 443 } 444 else { 445 $this->error('Bad request'); 446 } 447 } 448 } 449 450 $ctrl = new App(); 451 $ctrl->run();