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}