/ 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();