OTP::process()   B
last analyzed

Complexity

Conditions 6
Paths 4

Size

Total Lines 44
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 25
nc 4
nop 1
dl 0
loc 44
rs 8.8977
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<string>
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
     * The auth source associated with this authproc.
88
     *
89
     * @var string
90
     */
91
    private string $authid;
92
93
94
    /**
95
     * OTP constructor.
96
     *
97
     * @param array<mixed> $config The configuration of this authproc.
98
     * @param mixed $reserved
99
     *
100
     * @throws \SimpleSAML\Error\CriticalConfigurationError in case the configuration is wrong.
101
     */
102
    public function __construct(array $config, $reserved)
103
    {
104
        parent::__construct($config, $reserved);
105
106
        $cfg = Configuration::loadFromArray($config, 'yubikey:OTP');
107
        $this->apiClient = $cfg->getString('api_client_id');
108
        $this->apiKey = $cfg->getString('api_key');
109
        $this->abortIfMissing = $cfg->getOptionalBoolean('abort_if_missing', false);
110
        $this->keyIdAttr = $cfg->getOptionalString('key_id_attribute', 'yubikey');
111
        $this->assuranceAttr = $cfg->getOptionalString('assurance_attribute', 'eduPersonAssurance');
112
        $this->assuranceValue = $cfg->getOptionalString('assurance_value', 'OTP');
113
        $this->apiHosts = $cfg->getOptionalArrayize('api_hosts', [
114
            'api.yubico.com',
115
        ]);
116
    }
117
118
119
    /**
120
     * Run the filter.
121
     *
122
     * @param array<mixed> $state
123
     *
124
     * @throws \Exception if there is no yubikey ID and we are told to abort in such case.
125
     */
126
    public function process(array &$state): void
127
    {
128
        $session = Session::getSessionFromRequest();
129
        $this->authid = $state['Source']['auth'];
130
        $key_id = $session->getData('yubikey:auth', $this->authid);
131
        $attrs = &$state['Attributes'];
132
133
        // missing attribute, yubikey auth required
134
        if ($this->abortIfMissing && !array_key_exists($this->keyIdAttr, $attrs)) {
135
            // TODO: display an error page instead of an exception
136
            throw new Exception('Missing key ID.');
137
        }
138
139
        // missing attribute, but not required
140
        if (!array_key_exists($this->keyIdAttr, $attrs)) {
141
            // nothing we can do here
142
            return;
143
        }
144
145
        // check for previous auth
146
        if (!is_null($key_id) && in_array($key_id, $attrs[$this->keyIdAttr], true)) {
147
            // we were already authenticated using a valid yubikey
148
            Logger::info('Reusing previous YubiKey authentication with key "' . $key_id . '".');
149
            return;
150
        }
151
152
        $state['yubikey:otp'] = [
153
            'apiClient' => $this->apiClient,
154
            'apiKey' => $this->apiKey,
155
            'assuranceAttribute' => $this->assuranceAttr,
156
            'assuranceValue' => $this->assuranceValue,
157
            'apiHosts' => $this->apiHosts,
158
            'keyIDs' => $attrs[$this->keyIdAttr],
159
            'authID' => $this->authid,
160
            'self' => $this,
161
        ];
162
163
        Logger::debug('Initiating YubiKey authentication.');
164
165
        $sid = Auth\State::saveState($state, 'yubikey:otp:init');
166
        $url = Module::getModuleURL('yubikey/otp');
167
168
        $httpUtils = new Utils\HTTP();
169
        $httpUtils->redirectTrustedURL($url, ['StateId' => $sid]);
170
    }
171
172
173
    /**
174
     * Perform OTP authentication given the current state and a one time password obtained from a yubikey.
175
     *
176
     * @param array<mixed> $state The state array in the "yubikey:otp:init" stage.
177
     * @param string $otp A one time password generated by a yubikey.
178
     * @return boolean True if authentication succeeded and the key belongs to the user, false otherwise.
179
     *
180
     * @throws \InvalidArgumentException if the state array is not in a valid stage or the given OTP has incorrect
181
     * length.
182
     */
183
    public static function authenticate(
184
        array &$state,
185
        #[\SensitiveParameter]
186
        string $otp,
187
    ): bool {
188
        // validate the state array we're given
189
        if (
190
            !array_key_exists(Auth\State::STAGE, $state)
191
            || $state[Auth\State::STAGE] !== 'yubikey:otp:init'
192
        ) {
193
            throw new InvalidArgumentException("There was an unexpected error while trying to verify your YubiKey.");
194
        }
195
        $cfg = $state['yubikey:otp'];
196
197
        // validate the OTP we are given
198
        $otplen = strlen($otp);
199
        if ($otplen < 32 || $otplen > 48) {
200
            throw new InvalidArgumentException(
201
                "The one time password generated by your YubiKey is not valid. Please make"
202
                . " sure to use your YubiKey. You don't have to type anything manually.",
203
            );
204
        }
205
        $otp = strtolower($otp);
206
207
        // obtain the identity of the yubikey
208
        $kid = substr($otp, 0, -32);
209
        Logger::debug('Verifying Yubikey ID "' . $kid . '"');
210
211
        // verify the OTP against the API
212
        $api = new \Yubikey\Validate($cfg['apiKey'], $cfg['apiClient']);
213
        $api->setHosts($cfg['apiHosts']);
214
        $resp = $api->check($otp, true);
215
216
        // verify the identity corresponds to this user
217
        if (!in_array($kid, $cfg['keyIDs'], true)) {
218
            Logger::warning('The YubiKey "' . $kid . '" is not valid for this user.');
219
            Logger::stats('yubikey:otp: invalid YubiKey.');
220
            return false;
221
        }
222
223
        // check if the response is successful
224
        if ($resp->success()) {
225
            $state['Attributes'][$state['yubikey:otp']['assuranceAttribute']][] =
226
                $state['yubikey:otp']['assuranceValue'];
227
228
            // keep authentication data in the session
229
            $session = Session::getSessionFromRequest();
230
            $session->setData('yubikey:auth', $cfg['authID'], $kid);
231
            $session->registerLogoutHandler(
232
                $cfg['authID'],
233
                $cfg['self'],
234
                'logoutHandler',
235
            );
236
            Logger::info('Successful authentication with YubiKey "' . $kid . '".');
237
            return true;
238
        }
239
        Logger::warning('Couldn\'t successfully authenticate YubiKey "' . $kid . '".');
240
        return false;
241
    }
242
243
244
    /**
245
     * A logout handler that makes sure to remove the key from the session, so that the user is asked for the key again
246
     * in case of a re-authentication with this very same session.
247
     */
248
    public function logoutHandler(): void
249
    {
250
        $session = Session::getSessionFromRequest();
251
        $keyid = $session->getData('yubikey:auth', $this->authid);
252
        Logger::info('Removing valid YubiKey authentication with key "' . $keyid . '".');
253
        $session->deleteData('yubikey:auth', $this->authid);
254
    }
255
}
256