aboutsummaryrefslogtreecommitdiff
path: root/lib/models/TFASecret.php
blob: 8b9f86c1219c2abf7c7db810feb1ea1b1abeb3c2 (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
<?php
use OTPHP\TOTP;
use ParagonIE\ConstantTime\Base32;

/**
 * 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
 */
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,
        ],
    ];

    /**
     * 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'];
    }

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

    /**
     * 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;
            }
            $this->secret    = (new TOTP())->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($this->secret)->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()
    {
        return new TOTP(
            $this->user->email,
            $this->secret,
            self::TYPES[$this->type]['period']
        );
    }

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