the browser-facing portion of osu!
at master 4.3 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Models; 7 8use DB; 9use Exception; 10 11/** 12 * @property \Carbon\Carbon $created_date 13 * @property int $failed_attempts 14 * @property string $failed_ids 15 * @property int $ip 16 * @property \Carbon\Carbon|null $last_attempt 17 * @property int $total_attempts 18 * @property int $unique_ids 19 */ 20class LoginAttempt extends Model 21{ 22 protected $table = 'osu_login_attempts'; 23 protected $primaryKey = 'ip'; 24 public $incrementing = false; 25 public $timestamps = false; 26 27 public static function appendFailedIds($user, $state) 28 { 29 $userId = static::getUserId($user); 30 $newFailedId = DB::getPdo()->quote("{$userId}($state)"); 31 32 return DB::raw("CONCAT(failed_ids, ',', {$newFailedId})"); 33 } 34 35 public static function findOrDefault($ip) 36 { 37 try { 38 return static::find($ip) ?? static::create([ 39 'ip' => $ip, 40 'failed_ids' => '', 41 'unique_ids' => 0, 42 'failed_attempts' => 0, 43 'total_attempts' => 0, 44 ]); 45 } catch (Exception $e) { 46 if (is_sql_unique_exception($e)) { 47 return static::findOrDefault($ip); 48 } 49 50 throw $e; 51 } 52 } 53 54 public static function getUserId($user) 55 { 56 return optional($user)->getKey() ?? 0; 57 } 58 59 public static function hashInvalidPassword($password) 60 { 61 // The goal is just to allow same password (or other passwords 62 // with colliding hash) to be excluded from being counted as additional 63 // attempt. 64 return substr(sha1('osu_unique_'.md5($password)), 8, 12); 65 } 66 67 public static function isLocked($ip) 68 { 69 $record = static::find($ip); 70 71 if ($record === null) { 72 return false; 73 } 74 75 if ($record->unique_ids > 50) { 76 return true; 77 } 78 79 return $record->failed_attempts > $GLOBALS['cfg']['osu']['user']['max_login_attempts']; 80 } 81 82 public static function logAttempt($ip, $user, $type, $password = null) 83 { 84 $state = $type; 85 86 if ($password !== null) { 87 $state .= ':'.static::hashInvalidPassword($password); 88 } 89 90 $record = static::findOrDefault($ip); 91 92 if ($record->containsUser($user, $state)) { 93 return; 94 } 95 96 $updates = [ 97 'failed_ids' => static::appendFailedIds($user, $state), 98 'total_attempts' => db_unsigned_increment('total_attempts', 1), 99 'last_attempt' => DB::raw('CURRENT_TIMESTAMP'), 100 ]; 101 102 $isUserVerification = $type === 'verify'; 103 $userRecorded = $record->containsUser($user); 104 $newRecord = $record->failed_attempts === 0; 105 106 if (!$userRecorded) { 107 $updates['unique_ids'] = db_unsigned_increment('unique_ids', 1); 108 } 109 110 if (!$isUserVerification) { 111 if ($userRecorded || $newRecord) { 112 $updates['failed_attempts'] = db_unsigned_increment('failed_attempts', 1); 113 } else { 114 $updates['failed_attempts'] = DB::raw('GREATEST(1, LEAST(20000, failed_attempts)) * 3'); 115 } 116 } 117 118 static::where('ip', $ip)->update($updates); 119 } 120 121 public static function logLoggedIn($ip, $user) 122 { 123 $record = static::findOrDefault($ip); 124 125 $updates = []; 126 127 if ($record->failed_attempts > 0 && !$record->containsUser($user, 'success')) { 128 $updates['failed_attempts'] = db_unsigned_increment('failed_attempts', -1); 129 } 130 131 if (!$record->containsUser($user)) { 132 $updates['unique_ids'] = db_unsigned_increment('unique_ids', 1); 133 } 134 135 $updates['failed_ids'] = static::appendFailedIds($user, 'success'); 136 137 static::where('ip', $ip)->update($updates); 138 } 139 140 public function containsUser($user, $state = null) 141 { 142 $key = ','.static::getUserId($user).'('; 143 144 if ($state !== null) { 145 $key .= $state; 146 147 if (!ends_with($state, ':')) { 148 $key .= ')'; 149 } 150 } 151 152 return strpos($this->failed_ids, $key) !== false; 153 } 154}