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