1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
|
<?php
use OTPHP\TOTP;
/**
* Model for a two factor authentication secret.
*
* @author Jan-Hendrik Willms <tleilax+studip@gmail.com>
* @license GPL2 or any later version
* @since Stud.IP 4.4
*
* @property string $id
* @property string $user_id
* @property string $secret
* @property string $type
* @property bool $confirmed
* @property int $mkdate
* @property int $chdate
*
* @property User $user
* @property TFAToken[]|SimpleORMapCollection $tokens
*/
class TFASecret extends SimpleORMap
{
// Possible authentication types (email may require more tokens in a short
// period of time with a larger window to accept them).
const TYPES = [
'email' => [
'window' => 60,
'period' => 5,
],
'app' => [
'window' => 1,
'period' => 30,
],
];
/**
* Configures the model.
*
* @param array $config Configuration
*/
protected static function configure($config = [])
{
$config['db_table'] = 'users_tfa';
$config['belongs_to']['user'] = [
'class_name' => User::class,
];
$config['has_many']['tokens'] = [
'class_name' => TFAToken::class,
'on_delete' => 'delete',
];
parent::configure($config);
}
/**
* Returns the duration in seconds for which a token is valid.
*
* @param string $type Type of token
* @return int duration in seconds
*/
public static function getValidationDuration($type)
{
if (!isset(self::TYPES[$type])) {
throw new InvalidArgumentException("Unknown tfa type {$type}");
}
$t = self::TYPES[$type];
return $t['window'] * $t['period'];
}
/**
* Returns the greatest validity duration for all defined types.
*
* @return int
*/
public static function getGreatestValidityDuration(): int
{
$validity_duration = 0;
foreach (self::TYPES as $type) {
$duration = $type['window'] * $type['period'];
if ($duration > $validity_duration) {
$validity_duration = $duration;
}
}
return $validity_duration;
}
/**
* Overwrites the SORM setNew() method. This will create the secret string.
*
* @param boolean $is_new State of "new"
* @todo is there a more sorm way by using registered_callbacks?
*/
public function setNew($is_new)
{
if ($is_new) {
if (!$this->isNew()) {
return true;
}
$this->secret = TOTP::create()->getSecret();
$this->confirmed = false;
}
return parent::setNew($is_new);
}
/**
* Overwrite the restore method. This ensures access to token is only valid
* by root accounts and the owner of the secret.
*
* @return mixed
* @todo is there a more sorm way by using registered_callbacks?
*/
public function restore()
{
$result = parent::restore();
if ($result && !$this->mayAccess()) {
throw new AccessDeniedException('You are not allowed to access this secret');
}
return $result;
}
/**
* Returns whether the current user may access this object.
*
* @return bool
*/
private function mayAccess()
{
return $this->user_id
&& (
$this->user_id === User::findCurrent()->id
|| $GLOBALS['user']->perms === 'root'
);
}
/**
* Returns a token for a given timeslice.
*
* @param int $timestamp Timeslice (optional, defaults to now)
* @return string token
*/
public function getToken($timestamp = null)
{
return $this->getTOTP()->at($timestamp ?? time());
}
/**
* Validates a 2fa token against the secret. This will create the token
* again on server side and checks if it matches.
*
* Tokens may be reused if you allow it. This is used for validation tokens
* stored in a cookie or session. If tokens are not allowed to be reused,
* they are stored in the database to prevent replay attacks.
*
* @param string $token Token to check
* @param int $timestamp Timeslice for the token (optional, defaults
* to now)
* @param boolean $allow_reuse Allow reuse of the token
*
* @return bool
*/
public function validateToken($token, $timestamp = null, $allow_reuse = false)
{
if (!$token || !ctype_digit($token)) {
return false;
}
if (!$allow_reuse && TFAToken::exists([$this->user_id, $token])) {
return false;
}
$window = self::TYPES[$this->type]['window'];
if ($allow_reuse) {
$window = 0;
}
if ($this->getTOTP()->verify($token, $timestamp, $window)) {
if (!$this->confirmed) {
$this->confirmed = true;
$this->store();
}
if (!$allow_reuse) {
TFAToken::create([
'user_id' => $this->user_id,
'token' => $token,
]);
}
return true;
}
return false;
}
/**
* Returns a totp object used for validation/creation of tokens.
* @return TOTP
*/
private function getTOTP(): TOTP
{
$totp = TOTP::create(
$this->secret,
self::TYPES[$this->type]['period']
);
$totp->setLabel($this->user->email);
return $totp;
}
/**
* Returns the provisioning uri for this secret. Used in the qr code for
* apps.
* @return string
*/
public function getProvisioningUri()
{
$totp = $this->getTOTP();
$totp->setIssuer(Config::get()->UNI_NAME_CLEAN);
return $totp->getProvisioningUri();
}
}
|