Passed
Push — master ( 83b9c8...e35820 )
by Tim
02:49 queued 36s
created

OTP::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 15
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 11
nc 1
nop 2
dl 0
loc 15
rs 9.9
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * An authentication processing filter that allows you to use your yubikey as an OTP second factor.
5
 *
6
 * @package SimpleSAML\Module\yubikey
7
 */
8
9
declare(strict_types=1);
10
11
namespace SimpleSAML\Module\yubikey\Auth\Process;
12
13
use Exception;
14
use InvalidArgumentException;
15
use SimpleSAML\Auth;
16
use SimpleSAML\Configuration;
17
use SimpleSAML\Logger;
18
use SimpleSAML\Module;
19
use SimpleSAML\Session;
20
use SimpleSAML\Utils;
21
22
class OTP extends Auth\ProcessingFilter
23
{
24
    /**
25
     * The API client identifier.
26
     *
27
     * @var string
28
     */
29
    private string $apiClient;
30
31
    /**
32
     * The API key to use.
33
     *
34
     * @var string
35
     */
36
    private string $apiKey;
37
38
    /**
39
     * An array of hosts to be used for API calls.
40
     *
41
     * Defaults to:
42
     * - api.yubico.com
43
     * - api2.yubico.com
44
     * - api3.yubico.com
45
     * - api4.yubico.com
46
     * - api5.yubico.com
47
     *
48
     * @var array
49
     */
50
    private array $apiHosts;
51
52
    /**
53
     * Whether to abort authentication if no yubikey is known for the user or not.
54
     *
55
     * @var bool
56
     */
57
    private bool $abortIfMissing;
58
59
    /**
60
     * The name of the attribute containing the yubikey ID.
61
     *
62
     * Defaults to "yubikey".
63
     *
64
     * @var string
65
     */
66
    private string $keyIdAttr;
67
68
    /**
69
     * The name of the attribute that expresses successful authentication with the yubikey.
70
     *
71
     * Defaults to "eduPersonAssurance".
72
     *
73
     * @var string
74
     */
75
    private string $assuranceAttr;
76
77
    /**
78
     * The value of the "assurance" attribute that conveys successful authentication with a yubikey.
79
     *
80
     * Defaults to "OTP".
81
     *
82
     * @var string
83
     */
84
    private string $assuranceValue;
85
86
    /**
87
     * Whether to remember a previous authentication or keep asking.
88
     *
89
     * @TODO Not yet implemented
90
     *
91
     * @var boolean
92
     */
93
    private bool $remember;
94
95
    /**
96
     * The auth source associated with this authproc.
97
     *
98
     * @var string
99
     */
100
    private string $authid;
101
102
103
    /**
104
     * OTP constructor.
105
     *
106
     * @param array $config The configuration of this authproc.
107
     * @param mixed $reserved
108
     *
109
     * @throws \SimpleSAML\Error\CriticalConfigurationError in case the configuration is wrong.
110
     */
111
    public function __construct(array $config, $reserved)
112
    {
113
        parent::__construct($config, $reserved);
114
115
        $cfg = Configuration::loadFromArray($config, 'yubikey:OTP');
116
        $this->apiClient = $cfg->getString('api_client_id');
117
        $this->apiKey = $cfg->getString('api_key');
118
        $this->abortIfMissing = $cfg->getOptionalBoolean('abort_if_missing', false);
119
        $this->keyIdAttr = $cfg->getOptionalString('key_id_attribute', 'yubikey');
120
        $this->assuranceAttr = $cfg->getOptionalString('assurance_attribute', 'eduPersonAssurance');
121
        $this->assuranceValue = $cfg->getOptionalString('assurance_value', 'OTP');
122
        $this->apiHosts = $cfg->getOptionalArrayize('api_hosts', [
123
            'api.yubico.com',
124
        ]);
125
        $this->remember = $cfg->getOptionalBoolean('just_once', true);
126
    }
127
128
129
    /**
130
     * Run the filter.
131
     *
132
     * @param array $state
133
     *
134
     * @throws \Exception if there is no yubikey ID and we are told to abort in such case.
135
     */
136
    public function process(array &$state): void
137
    {
138
        $session = Session::getSessionFromRequest();
139
        $this->authid = $state['Source']['auth'];
140
        $key_id = $session->getData('yubikey:auth', $this->authid);
141
        $attrs = &$state['Attributes'];
142
143
        // missing attribute, yubikey auth required
144
        if ($this->abortIfMissing && !array_key_exists($this->keyIdAttr, $attrs)) {
145
            // TODO: display an error page instead of an exception
146
            throw new Exception('Missing key ID.');
147
        }
148
149
        // missing attribute, but not required
150
        if (!array_key_exists($this->keyIdAttr, $attrs)) {
151
            // nothing we can do here
152
            return;
153
        }
154
155
        // check for previous auth
156
        if (!is_null($key_id) && in_array($key_id, $attrs[$this->keyIdAttr], true)) {
157
            // we were already authenticated using a valid yubikey
158
            Logger::info('Reusing previous YubiKey authentication with key "' . $key_id . '".');
159
            return;
160
        }
161
162
        $state['yubikey:otp'] = [
163
            'apiClient' => $this->apiClient,
164
            'apiKey' => $this->apiKey,
165
            'assuranceAttribute' => $this->assuranceAttr,
166
            'assuranceValue' => $this->assuranceValue,
167
            'apiHosts' => $this->apiHosts,
168
            'keyIDs' => $attrs[$this->keyIdAttr],
169
            'authID' => $this->authid,
170
            'self' => $this,
171
        ];
172
173
        Logger::debug('Initiating YubiKey authentication.');
174
175
        $sid = Auth\State::saveState($state, 'yubikey:otp:init');
176
        $url = Module::getModuleURL('yubikey/otp');
177
178
        $httpUtils = new Utils\HTTP();
179
        $httpUtils->redirectTrustedURL($url, ['StateId' => $sid]);
180
    }
181
182
183
    /**
184
     * Perform OTP authentication given the current state and a one time password obtained from a yubikey.
185
     *
186
     * @param array $state The state array in the "yubikey:otp:init" stage.
187
     * @param string $otp A one time password generated by a yubikey.
188
     * @return boolean True if authentication succeeded and the key belongs to the user, false otherwise.
189
     *
190
     * @throws \InvalidArgumentException if the state array is not in a valid stage or the given OTP has incorrect
191
     * length.
192
     */
193
    public static function authenticate(
194
        array &$state,
195
        #[\SensitiveParameter]
196
        string $otp,
197
    ): bool {
198
        // validate the state array we're given
199
        if (
200
            !array_key_exists(Auth\State::STAGE, $state)
201
            || $state[Auth\State::STAGE] !== 'yubikey:otp:init'
202
        ) {
203
            throw new InvalidArgumentException("There was an unexpected error while trying to verify your YubiKey.");
204
        }
205
        $cfg = $state['yubikey:otp'];
206
207
        // validate the OTP we are given
208
        $otplen = strlen($otp);
209
        if ($otplen < 32 || $otplen > 48) {
210
            throw new InvalidArgumentException(
211
                "The one time password generated by your YubiKey is not valid. Please make"
212
                . " sure to use your YubiKey. You don't have to type anything manually.",
213
            );
214
        }
215
        $otp = strtolower($otp);
216
217
        // obtain the identity of the yubikey
218
        $kid = substr($otp, 0, -32);
219
        Logger::debug('Verifying Yubikey ID "' . $kid . '"');
220
221
        // verify the OTP against the API
222
        $api = new \Yubikey\Validate($cfg['apiKey'], $cfg['apiClient']);
223
        $api->setHosts($cfg['apiHosts']);
224
        $resp = $api->check($otp, true);
225
226
        // verify the identity corresponds to this user
227
        if (!in_array($kid, $cfg['keyIDs'], true)) {
228
            Logger::warning('The YubiKey "' . $kid . '" is not valid for this user.');
229
            Logger::stats('yubikey:otp: invalid YubiKey.');
230
            return false;
231
        }
232
233
        // check if the response is successful
234
        if ($resp->success()) {
235
            $state['Attributes'][$state['yubikey:otp']['assuranceAttribute']][] =
236
                $state['yubikey:otp']['assuranceValue'];
237
238
            // keep authentication data in the session
239
            $session = Session::getSessionFromRequest();
240
            $session->setData('yubikey:auth', $cfg['authID'], $kid);
241
            $session->registerLogoutHandler(
242
                $cfg['authID'],
243
                $cfg['self'],
244
                'logoutHandler',
245
            );
246
            Logger::info('Successful authentication with YubiKey "' . $kid . '".');
247
            return true;
248
        }
249
        Logger::warning('Couldn\'t successfully authenticate YubiKey "' . $kid . '".');
250
        return false;
251
    }
252
253
254
    /**
255
     * A logout handler that makes sure to remove the key from the session, so that the user is asked for the key again
256
     * in case of a re-authentication with this very same session.
257
     */
258
    public function logoutHandler(): void
259
    {
260
        $session = Session::getSessionFromRequest();
261
        $keyid = $session->getData('yubikey:auth', $this->authid);
262
        Logger::info('Removing valid YubiKey authentication with key "' . $keyid . '".');
263
        $session->deleteData('yubikey:auth', $this->authid);
264
    }
265
}
266