KaiRo bug 412 - Use composer to load oauth2-server-php and doctrine DBAL
[authserver.git] / app / authutils.php-class
... / ...
CommitLineData
1<?php
2/* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
4 * You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6class AuthUtils {
7 // KaiRo.at authentication utilities PHP class
8 // This class contains helper functions for the authentication system.
9 //
10 // function __construct($settings, $db)
11 // CONSTRUCTOR
12 // Settings are an associative array with a numeric pwd_cost field and an array pwd_nonces field.
13 // The DB is a PDO object.
14 //
15 // public $db
16 // A PDO database object for interaction.
17 //
18 // public $running_on_localhost
19 // A boolean telling if the system is running on localhost (where https is not required).
20 //
21 // public $client_reg_email_whitelist
22 // An array of emails that are whitelisted for registering clients.
23 //
24 // private $pwd_cost
25 // The cost parameter for use with PHP password_hash function.
26 //
27 // private $pwd_nonces
28 // The array of nonces to use for "peppering" passwords. For new hashes, the last one of those will be used.
29 // Generate a nonce with this command: |openssl rand -base64 48|
30 //
31 // function log($code, $additional_info)
32 // Log an entry for admin purposes, with a code and some additional info.
33 //
34 // function checkForSecureConnection()
35 // Check is the connection is secure and return an array of error messages (empty if it's secure).
36 //
37 // function sendSecurityHeaders()
38 // Rend HTTP headers for improving security.
39 //
40 // function initSession()
41 // Initialize a session. Returns an associative array of all the DB fields of the session.
42 //
43 // function getLoginSession($user)
44 // Return an associative array of a session with the given user logged in (new if user changed compared to given previous session, otherwise updated variant of that previous session).
45 //
46 // function setRedirect($session, $redirect)
47 // Set a redirect on the session for performing later. Returns true if a redirect was saved, otherwise false.
48 //
49 // function doRedirectIfSet($session)
50 // If the session has a redirect set, perform it. Returns true if a redirect was performed, otherwise false.
51 //
52 // function resetRedirect($session)
53 // If the session has a redirect set, remove it. Returns true if a redirect was removed, otherwise false.
54 //
55 // function getDomainBaseURL()
56 // Get the base URL of the current domain, e.g. 'https://example.com'.
57 //
58 // function checkPasswordConstraints($new_password, $user_email)
59 // Check password constraints and return an array of error messages (empty if all constraints are met).
60 //
61 // function createSessionKey()
62 // Return a random session key.
63 //
64 // function createVerificationCode()
65 // Return a random acount/email verification code.
66 //
67 // function createClientSecret()
68 // Return a random client secret.
69 //
70 // function createTimeCode($session, [$offset], [$validity_minutes])
71 // Return a time-based code based on the key and ID of the given session.
72 // An offset can be given to create a specific code for verification, otherwise and offset will be generated.
73 // Also, an amount of minutes for the code to stay valid can be handed over, by default 10 minutes will be used.
74 //
75 // function verifyTimeCode($timecode_to_verify, $session, [$validity_minutes])
76 // Verify a given time-based code and return true if it's valid or false if it's not.
77 // See createTimeCode() documentation for the session and validity paramerters.
78 //
79 // function pwdHash($new_password)
80 // Return a hash for the given password.
81 //
82 // function pwdVerify($password_to_verify, $user)
83 // Return true if the password verifies against the pwdhash field of the user, false if not.
84 //
85 // function pwdNeedsRehash($user)
86 // Return true if the pwdhash field of the user uses an outdated standard and needs to be rehashed.
87 //
88 // function negotiateLocale($supportedLanguages)
89 // Return the language to use out of the given array of supported locales, via netotiation based on the HTTP Accept-Language header.
90 //
91 // function getGroupedEmails($group_id, [$exclude_email])
92 // Return all emails grouped in the specified group ID, optionally exclude a specific email (e.g. because you only want non-current entries)
93 //
94 // function initHTMLDocument($titletext, [$headlinetext]) {
95 // initialize the HTML document for the auth system, with some elements we always use, esp. all the scripts and stylesheet.
96 // Sets the title of the document to the given title, the main headline will be the same as the title if not set explicitly.
97 // Returns an associative array with the following elements: 'document', 'html', 'head', 'title', 'body'.
98 //
99 // function appendLoginForm($dom_element, $session, $user, [$addfields])
100 // Append a login form for the given session to the given DOM element, possibly prefilling the email from the given user info array.
101 // The optional $addfields parameter is an array of name=>value pairs of hidden fields to add to the form.
102
103 function __construct($settings, $db) {
104 // *** constructor ***
105 $this->db = $db;
106 $this->db->exec("SET time_zone='+00:00';"); // Execute directly on PDO object, set session to UTC to make our gmdate() values match correctly.
107 // For debugging, potentially add |robert\.box\.kairo\.at to that regex temporarily.
108 $this->running_on_localhost = preg_match('/^((.+\.)?localhost|127\.0\.0\.\d+)$/', $_SERVER['SERVER_NAME']);
109 if (array_key_exists('pwd_cost', $settings)) {
110 $this->pwd_cost = $settings['pwd_cost'];
111 }
112 if (array_key_exists('pwd_nonces', $settings)) {
113 $this->pwd_nonces = $settings['pwd_nonces'];
114 }
115 }
116
117 public $db = null;
118 public $running_on_localhost = false;
119 public $client_reg_email_whitelist = array('kairo@kairo.at', 'com@kairo.at');
120 private $pwd_cost = 10;
121 private $pwd_nonces = array();
122
123 function log($code, $info) {
124 $result = $this->db->prepare('INSERT INTO `auth_log` (`code`, `info`, `ip_addr`) VALUES (:code, :info, :ipaddr);');
125 if (!$result->execute(array(':code' => $code, ':info' => $info, ':ipaddr' => $_SERVER['REMOTE_ADDR']))) {
126 // print($result->errorInfo()[2]);
127 }
128 }
129
130 function checkForSecureConnection() {
131 $errors = array();
132 if (($_SERVER['SERVER_PORT'] != 443) && !$this->running_on_localhost) {
133 $errors[] = _('You are not accessing this site on a secure connection, so authentication doesn\'t work.');
134 }
135 return $errors;
136 }
137
138 function sendSecurityHeaders() {
139 // Send various headers that we want to have for security resons, mostly as recommended by https://observatory.mozilla.org/
140
141 // CSP - see https://wiki.mozilla.org/Security/Guidelines/Web_Security#Content_Security_Policy
142 // Disable unsafe inline/eval, only allow loading of resources (images, fonts, scripts, etc.) from ourselves; also disable framing.
143 header('Content-Security-Policy: default-src \'none\';img-src \'self\'; script-src \'self\'; style-src \'self\'; frame-ancestors \'none\'');
144
145 // X-Content-Type-Options - see https://wiki.mozilla.org/Security/Guidelines/Web_Security#X-Content-Type-Options
146 // Prevent browsers from incorrectly detecting non-scripts as scripts
147 header('X-Content-Type-Options: nosniff');
148
149 // X-Frame-Options (for older browsers) - see https://wiki.mozilla.org/Security/Guidelines/Web_Security#X-Frame-Options
150 // Block site from being framed
151 header('X-Frame-Options: DENY');
152
153 // X-XSS-Protection (for older browsers) - see https://wiki.mozilla.org/Security/Guidelines/Web_Security#X-XSS-Protection
154 // Block pages from loading when they detect reflected XSS attacks
155 header('X-XSS-Protection: 1; mode=block');
156 }
157
158 function initSession() {
159 $session = null;
160 if (strlen(@$_COOKIE['sessionkey'])) {
161 // Fetch the session - or at least try to.
162 $result = $this->db->prepare('SELECT * FROM `auth_sessions` WHERE `sesskey` = :sesskey AND `time_expire` > :expire;');
163 $result->execute(array(':sesskey' => $_COOKIE['sessionkey'], ':expire' => gmdate('Y-m-d H:i:s')));
164 $row = $result->fetch(PDO::FETCH_ASSOC);
165 if ($row) {
166 $session = $row;
167 }
168 }
169 if (is_null($session)) {
170 // Create new session and set cookie.
171 $sesskey = $this->createSessionKey();
172 setcookie('sessionkey', $sesskey, 0, "", "", !$this->running_on_localhost, true); // Last two params are secure and httponly, secure is not set on localhost.
173 $result = $this->db->prepare('INSERT INTO `auth_sessions` (`sesskey`, `time_expire`) VALUES (:sesskey, :expire);');
174 $result->execute(array(':sesskey' => $sesskey, ':expire' => gmdate('Y-m-d H:i:s', strtotime('+5 minutes'))));
175 // After insert, actually fetch the session row from the DB so we have all values.
176 $result = $this->db->prepare('SELECT * FROM auth_sessions WHERE `sesskey` = :sesskey AND `time_expire` > :expire;');
177 $result->execute(array(':sesskey' => $sesskey, ':expire' => gmdate('Y-m-d H:i:s')));
178 $row = $result->fetch(PDO::FETCH_ASSOC);
179 if ($row) {
180 $session = $row;
181 }
182 else {
183 $this->log('session_create_failure', 'key: '.$sesskey);
184 }
185 }
186 return $session;
187 }
188
189 function getLoginSession($userid, $prev_session) {
190 $session = $prev_session;
191 $sesskey = $this->createSessionKey();
192 setcookie('sessionkey', $sesskey, 0, "", "", !$this->running_on_localhost, true); // Last two params are secure and httponly, secure is not set on localhost.
193 // If the previous session has a user set, create a new one - otherwise take existing session entry.
194 if (intval($session['user'])) {
195 $result = $this->db->prepare('INSERT INTO `auth_sessions` (`sesskey`, `time_expire`, `user`, `logged_in`) VALUES (:sesskey, :expire, :userid, TRUE);');
196 $result->execute(array(':sesskey' => $sesskey, ':userid' => $userid, ':expire' => gmdate('Y-m-d H:i:s', strtotime('+1 day'))));
197 // After insert, actually fetch the session row from the DB so we have all values.
198 $result = $this->db->prepare('SELECT * FROM auth_sessions WHERE `sesskey` = :sesskey AND `time_expire` > :expire;');
199 $result->execute(array(':sesskey' => $sesskey, ':expire' => gmdate('Y-m-d H:i:s')));
200 $row = $result->fetch(PDO::FETCH_ASSOC);
201 if ($row) {
202 $session = $row;
203 }
204 else {
205 $utils->log('create_session_failure', 'at login, prev session: '.$session['id'].', new user: '.$userid);
206 $errors[] = _('The session system is not working.').' '._('Please <a href="https://www.kairo.at/contact">contact KaiRo.at</a> and tell the team about this.');
207 }
208 }
209 else {
210 $result = $this->db->prepare('UPDATE `auth_sessions` SET `sesskey` = :sesskey, `user` = :userid, `logged_in` = TRUE, `time_expire` = :expire WHERE `id` = :sessid;');
211 if (!$result->execute(array(':sesskey' => $sesskey, ':userid' => $userid, ':expire' => gmdate('Y-m-d H:i:s', strtotime('+1 day')), ':sessid' => $session['id']))) {
212 $utils->log('login_failure', 'session: '.$session['id'].', user: '.$userid);
213 $errors[] = _('Login failed unexpectedly.').' '._('Please <a href="https://www.kairo.at/contact">contact KaiRo.at</a> and tell the team about this.');
214 }
215 else {
216 // After update, actually fetch the session row from the DB so we have all values.
217 $result = $this->db->prepare('SELECT * FROM auth_sessions WHERE `sesskey` = :sesskey AND `time_expire` > :expire;');
218 $result->execute(array(':sesskey' => $sesskey, ':expire' => gmdate('Y-m-d H:i:s')));
219 $row = $result->fetch(PDO::FETCH_ASSOC);
220 if ($row) {
221 $session = $row;
222 }
223 }
224 }
225 return $session;
226 }
227
228 function setRedirect($session, $redirect) {
229 $success = false;
230 // Save the request in the session so we can get back to fulfilling it if one of the links is clicked.
231 $result = $this->db->prepare('UPDATE `auth_sessions` SET `saved_redirect` = :redir WHERE `id` = :sessid;');
232 if (!$result->execute(array(':redir' => $redirect, ':sessid' => $session['id']))) {
233 $this->log('redir_save_failure', 'session: '.$session['id'].', redirect: '.$redirect);
234 }
235 else {
236 $success = true;
237 }
238 return $success;
239 }
240
241 function doRedirectIfSet($session) {
242 $success = false;
243 // If the session has a redirect set, make sure it's performed.
244 if (strlen(@$session['saved_redirect'])) {
245 // Remove redirect.
246 $result = $this->db->prepare('UPDATE `auth_sessions` SET `saved_redirect` = :redir WHERE `id` = :sessid;');
247 if (!$result->execute(array(':redir' => '', ':sessid' => $session['id']))) {
248 $this->log('redir_save_failure', 'session: '.$session['id'].', redirect: (empty)');
249 }
250 else {
251 $success = true;
252 }
253 header('Location: '.$this->getDomainBaseURL().$session['saved_redirect']);
254 }
255 return $success;
256 }
257
258 function resetRedirect($session) {
259 $success = false;
260 // If the session has a redirect set, remove it.
261 if (strlen(@$session['saved_redirect'])) {
262 $result = $this->db->prepare('UPDATE `auth_sessions` SET `saved_redirect` = :redir WHERE `id` = :sessid;');
263 if (!$result->execute(array(':redir' => '', ':sessid' => $session['id']))) {
264 $this->log('redir_save_failure', 'session: '.$session['id'].', redirect: (empty)');
265 }
266 else {
267 $success = true;
268 }
269 }
270 return $success;
271 }
272
273 function getDomainBaseURL() {
274 return ($this->running_on_localhost?'http':'https').'://'.$_SERVER['SERVER_NAME'];
275 }
276
277 function checkPasswordConstraints($new_password, $user_email) {
278 $errors = array();
279 if ($new_password != trim($new_password)) {
280 $errors[] = _('Password must not start or end with a whitespace character like a space.');
281 }
282 if (strlen($new_password) < 8) { $errors[] = sprintf(_('Password too short (min. %s characters).'), 8); }
283 if (strlen($new_password) > 70) { $errors[] = sprintf(_('Password too long (max. %s characters).'), 70); }
284 if ((strtolower($new_password) == strtolower($user_email)) ||
285 in_array(strtolower($new_password), preg_split("/[@\.]+/", strtolower($user_email)))) {
286 $errors[] = _('The passwort can not be equal to your email or any part of it.');
287 }
288 if ((strlen($new_password) < 15) && (preg_match('/^[a-zA-Z]+$/', $new_password))) {
289 $errors[] = sprintf(_('Your password must use characters other than normal letters or contain least %s characters.'), 15);
290 }
291 if (preg_match('/^\d+$/', $new_password)) {
292 $errors[] = sprintf(_('Your password cannot consist only of numbers.'), 15);
293 }
294 if (strlen(count_chars($new_password, 3)) < 5) {
295 $errors[] = sprintf(_('Password does have to contain at least %s different characters.'), 5);
296 }
297 return $errors;
298 }
299
300 function createSessionKey() {
301 return bin2hex(openssl_random_pseudo_bytes(512 / 8)); // Get 512 bits of randomness (128 byte hex string).
302 }
303
304 function createVerificationCode() {
305 return bin2hex(openssl_random_pseudo_bytes(512 / 8)); // Get 512 bits of randomness (128 byte hex string).
306 }
307
308 function createClientSecret() {
309 return bin2hex(openssl_random_pseudo_bytes(160 / 8)); // Get 160 bits of randomness (40 byte hex string).
310 }
311
312 function createTimeCode($session, $offset = null, $validity_minutes = 10) {
313 // Matches TOTP algorithms, see https://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm
314 $valid_seconds = intval($validity_minutes) * 60;
315 if ($valid_seconds < 60) { $valid_seconds = 60; }
316 $code_digits = 8;
317 $time = time();
318 $rest = is_null($offset)?($time % $valid_seconds):intval($offset); // T0, will be sent as part of code to make it valid for the full duration.
319 $counter = floor(($time - $rest) / $valid_seconds);
320 $hmac = mhash(MHASH_SHA1, $counter, $session['id'].$session['sesskey']);
321 $offset = hexdec(substr(bin2hex(substr($hmac, -1)), -1)); // Get the last 4 bits as a number.
322 $totp = hexdec(bin2hex(substr($hmac, $offset, 4))) & 0x7FFFFFFF; // Take 4 bytes at the offset, discard highest bit.
323 $totp_value = sprintf('%0'.$code_digits.'d', substr($totp, -$code_digits));
324 return $rest.'.'.$totp_value;
325 }
326
327 function verifyTimeCode($timecode_to_verify, $session, $validity_minutes = 10) {
328 if (preg_match('/^(\d+)\.\d+$/', $timecode_to_verify, $regs)) {
329 return ($timecode_to_verify === $this->createTimeCode($session, $regs[1], $validity_minutes));
330 }
331 return false;
332 }
333
334 function pwdHash($new_password) {
335 $hash_prefix = '';
336 if (count($this->pwd_nonces)) {
337 $new_password .= $this->pwd_nonces[count($this->pwd_nonces) - 1];
338 $hash_prefix = (count($this->pwd_nonces) - 1).'|';
339 }
340 return $hash_prefix.password_hash($new_password, PASSWORD_DEFAULT, array('cost' => $this->pwd_cost));
341 }
342
343 function pwdVerify($password_to_verify, $userdata) {
344 $pwdhash = $userdata['pwdhash'];
345 if (preg_match('/^(\d+)\|(.+)$/', $userdata['pwdhash'], $regs)) {
346 $password_to_verify .= $this->pwd_nonces[$regs[1]];
347 $pwdhash = $regs[2];
348 }
349 return password_verify($password_to_verify, $pwdhash);
350 }
351
352 function pwdNeedsRehash($userdata) {
353 $nonceid = -1;
354 $pwdhash = $userdata['pwdhash'];
355 if (preg_match('/^(\d+)\|(.+)$/', $userdata['pwdhash'], $regs)) {
356 $nonceid = $regs[1];
357 $pwdhash = $regs[2];
358 }
359 if ($nonceid == count($this->pwd_nonces) - 1) {
360 return password_needs_rehash($pwdhash, PASSWORD_DEFAULT, array('cost' => $this->pwd_cost));
361 }
362 else {
363 return true;
364 }
365 }
366
367 function negotiateLocale($supportedLanguages) {
368 $nlocale = $supportedLanguages[0];
369 $headers = getAllHeaders();
370 $accLcomp = explode(',', @$headers['Accept-Language']);
371 $accLang = array();
372 foreach ($accLcomp as $lcomp) {
373 if (strlen($lcomp)) {
374 $ldef = explode(';', $lcomp);
375 $accLang[$ldef[0]] = (float)((strpos(@$ldef[1],'q=')===0)?substr($ldef[1],2):1);
376 }
377 }
378 if (count($accLang)) {
379 $pLang = ''; $pLang_q = 0;
380 foreach ($supportedLanguages as $wantedLang) {
381 if (isset($accLang[$wantedLang]) && ($accLang[$wantedLang] > $pLang_q)) {
382 $pLang = $wantedLang;
383 $pLang_q = $accLang[$wantedLang];
384 }
385 }
386 if (strlen($pLang)) { $nlocale = $pLang; }
387 }
388 return $nlocale;
389 }
390
391 function getGroupedEmails($group_id, $exclude_email = '') {
392 $emails = array();
393 if (intval($group_id)) {
394 $result = $this->db->prepare('SELECT `email` FROM `auth_users` WHERE `group_id` = :groupid AND `status` = \'ok\' AND `email` != :excludemail ORDER BY `email` ASC;');
395 $result->execute(array(':groupid' => $group_id, ':excludemail' => $exclude_email));
396 foreach ($result->fetchAll(PDO::FETCH_ASSOC) as $row) {
397 $emails[] = $row['email'];
398 }
399 }
400 return $emails;
401 }
402
403 function initHTMLDocument($titletext, $headlinetext = null) {
404 global $settings;
405 if (is_null($headlinetext)) { $headlinetext = $titletext; }
406 // Start HTML document as a DOM object.
407 extract(ExtendedDocument::initHTML5()); // sets $document, $html, $head, $title, $body
408 $document->formatOutput = true; // we want a nice output
409
410 $style = $head->appendElement('link');
411 $style->setAttribute('rel', 'stylesheet');
412 $style->setAttribute('href', 'authsystem.css');
413 $head->appendJSFile('authsystem.js');
414 if ($settings['piwik_enabled']) {
415 $head->setAttribute('data-piwiksite', $settings['piwik_site_id']);
416 $head->setAttribute('data-piwikurl', $settings['piwik_url']);
417 $head->appendJSFile('piwik.js', true, true);
418 }
419 $title->appendText($titletext);
420 $h1 = $body->appendElement('h1', $headlinetext);
421
422 if ($settings['piwik_enabled']) {
423 // Piwik noscript element
424 $noscript = $body->appendElement('noscript');
425 $para = $noscript->appendElement('p');
426 $img = $para->appendImage($settings['piwik_url'].'piwik.php?idsite='.$settings['piwik_site_id']);
427 $img->setAttribute('style', 'border:0;');
428 }
429
430 // Make the document not be scaled on mobile devices.
431 $vpmeta = $head->appendElement('meta');
432 $vpmeta->setAttribute('name', 'viewport');
433 $vpmeta->setAttribute('content', 'width=device-width, height=device-height');
434
435 $para = $body->appendElement('p', _('This login system does not work without JavaScript. Please activate JavaScript for this site to log in.'));
436 $para->setAttribute('id', 'jswarning');
437 $para->setAttribute('class', 'warn');
438
439 return array('document' => $document,
440 'html' => $html,
441 'head' => $head,
442 'title' => $title,
443 'body' => $body);
444 }
445
446 function appendLoginForm($dom_element, $session, $user, $addfields = array()) {
447 $form = $dom_element->appendForm('./', 'POST', 'loginform');
448 $form->setAttribute('id', 'loginform');
449 $form->setAttribute('class', 'loginarea hidden');
450 $ulist = $form->appendElement('ul');
451 $ulist->setAttribute('class', 'flat login');
452 $litem = $ulist->appendElement('li');
453 $inptxt = $litem->appendInputEmail('email', 30, 20, 'login_email', (intval(@$user['id'])?$user['email']:''));
454 $inptxt->setAttribute('autocomplete', 'email');
455 $inptxt->setAttribute('required', '');
456 $inptxt->setAttribute('placeholder', _('Email'));
457 $inptxt->setAttribute('class', 'login');
458 $litem = $ulist->appendElement('li');
459 $inptxt = $litem->appendInputPassword('pwd', 20, 20, 'login_pwd', '');
460 $inptxt->setAttribute('required', '');
461 $inptxt->setAttribute('placeholder', _('Password'));
462 $inptxt->setAttribute('class', 'login');
463 $litem = $ulist->appendElement('li');
464 $litem->appendLink('./?reset', _('Forgot password?'));
465 /*
466 $litem = $ulist->appendElement('li');
467 $cbox = $litem->appendInputCheckbox('remember', 'login_remember', 'true', false);
468 $cbox->setAttribute('class', 'logincheck');
469 $label = $litem->appendLabel('login_remember', _('Remember me'));
470 $label->setAttribute('id', 'rememprompt');
471 $label->setAttribute('class', 'loginprompt');
472 */
473 $litem = $ulist->appendElement('li');
474 $litem->appendInputHidden('tcode', $this->createTimeCode($session));
475 foreach ($addfields as $fname => $fvalue) {
476 $litem->appendInputHidden($fname, $fvalue);
477 }
478 $submit = $litem->appendInputSubmit(_('Log in / Register'));
479 $submit->setAttribute('class', 'loginbutton');
480 }
481}
482?>