aboutsummaryrefslogtreecommitdiff
path: root/lib/models/TFASecret.php
blob: 081849bcb88e80d086c7dfec1979d0b5b3e86f87 (plain)
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
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
<?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 alias column for user_id
 * @property string $user_id database column
 * @property string $secret database column
 * @property int $confirmed database column
 * @property string $type database column
 * @property int $mkdate database column
 * @property int $chdate database column
 * @property SimpleORMapCollection<TFAToken> $tokens has_many TFAToken
 * @property User $user belongs_to User
 */
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' => 10,
            'period' => 30,
        ],
        '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 = null;
        }

        if ($this->type === 'email') {
            $timestamp ??= time();

            // Test for "window" number of "period" (this will ensure that old
            // tokens are validated correctly)
            $period = self::TYPES[$this->type]['period'];

            $i = 0;
            do {
                $verified = $this->getTOTP()->verify($token, $timestamp - $i * $period, $window);
                $i += 1;
            } while (!$verified && $i < self::TYPES[$this->type]['window']);
        } else {
            $verified = $this->getTOTP()->verify($token, $timestamp, $window);
        }

        if (!$verified) {
            return false;
        }

        if (!$this->confirmed) {
            $this->confirmed = true;
            $this->store();
        }

        if (!$allow_reuse) {
            TFAToken::create([
                'user_id' => $this->user_id,
                'token'   => $token,
            ]);
        }

        return true;
    }

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