OpenXE/classes/Modules/Api/Auth/DigestAuth.php

375 lines
11 KiB
PHP
Raw Normal View History

2021-05-21 08:49:41 +02:00
<?php
namespace Xentral\Modules\Api\Auth;
use Xentral\Components\Database\Database;
use Xentral\Components\Http\Request;
use Xentral\Modules\Api\Error\ApiError;
use Xentral\Modules\Api\Exception\AuthorizationErrorException;
class DigestAuth
{
/** @var Database $db */
protected $db;
/** @var Request $request */
protected $request;
/** @var bool $isAuthenticated Authentifizierung erfolgreich? */
protected $isAuthenticated = false;
/** @var bool $checkNonceCount Soll der NonceCount geprüft werden? */
protected $checkNonceCount = false;
/** @var int $nonceMaxAge Maximales Alter in Sekunden (86400 = 24 Stunden) */
protected $nonceMaxAge = 86400;
/** @var string $realm */
protected $realm = 'Xentral-API';
/** @var string $nonce Server-Nonce */
protected $nonce;
/** @var string $opaque */
protected $opaque;
/** @var array $digestParts Header-Bestandteile für Digest-Authentifizierung */
protected $digestParts;
/** @var int|null $apiAccountId */
protected $apiAccountId;
/**
* @param Database $db
* @param Request $request
*/
public function __construct($db, $request)
{
$this->db = $db;
$this->request = $request;
// 30 Tage alte Serverkey löschen
if (mt_rand(0, 99) === 0) {
$this->db->exec('DELETE FROM `api_keys` WHERE zeitstempel < DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)');
}
}
/**
* @return void
*/
public function checkLogin()
{
$authHeader = $this->getAuthorizationRequestHeader();
if (!$authHeader) {
throw new AuthorizationErrorException(
'Unauthorized. You need to login.',
ApiError::CODE_UNAUTHORIZED
);
}
if (stripos($authHeader, 'digest ') !== 0) {
throw new AuthorizationErrorException(
'Authorization type not allowed.',
ApiError::CODE_AUTH_TYPE_NOT_ALLOWED
);
}
$digestHeader = $this->getDigestRequestHeader();
if (!$digestHeader) {
throw new AuthorizationErrorException(
'Unauthorized. You need to login.',
ApiError::CODE_UNAUTHORIZED
);
}
// Parameter für Authentifizierung extrahieren
$this->digestParts = $this->parseDigest($digestHeader);
// Benötigte Teile im Digest-Header fehlen
if ($this->digestParts === false) {
throw new AuthorizationErrorException(
'Authorization failure',
ApiError::CODE_DIGEST_HEADER_INCOMPLETE
);
}
// Benutzername wurde leer eingegeben
if (empty($this->digestParts['username'])) {
throw new AuthorizationErrorException(
'Authorization failure. Username is empty.',
ApiError::CODE_AUTH_USERNAME_EMPTY
);
}
// Alle aktiven API-Zugänge aus DB laden
$apiAccounts = $this->db->fetchAll(
'SELECT a.remotedomain as appname, a.initkey, a.id FROM api_account AS a WHERE a.aktiv = 1'
);
if (empty($apiAccounts)) {
throw new AuthorizationErrorException(
'Authorization failure. API Account not existing.',
ApiError::CODE_API_ACCOUNT_MISSING
);
}
foreach ($apiAccounts as $account) {
$validUser = $account['appname'];
$validPass = $account['initkey'];
// Username im Header stimmt nicht mit Account überein
if ($validUser !== $this->digestParts['username']) {
continue; // Nächsten Account probieren
}
// Digest-Algo validieren
if (!$this->validateDigestLogin($validUser, $validPass)) {
continue; // Mit nächsten Account weitermachen
// @todo API-Accounts mit gleichen Usernamen verhindern?
//throw new AuthorizationErrorException(
//'Validation failure. Digest not valid.',
// ApiError::CODE_DIGEST_VALIDDATION_FAILED
//);
}
// Key-Details aus DB laden
$keyDetails = $this->getKeyDetails($this->digestParts['nonce'], $this->digestParts['opaque']);
// Authentifizierung war gültig; Serverkeys sind aber abgelaufen, oder Client hat sich die Keys ausgedacht
if (!$keyDetails) {
$this->nonce = $this->opaque = null;
throw new AuthorizationErrorException(
'Authorization failure. Nonce is invalid or expired.',
ApiError::CODE_DIGEST_NONCE_INVALID
);
}
// Serverkeys sind abgelaufen (aber noch vorhanden in DB)
if ($keyDetails['age'] > $this->nonceMaxAge) {
$this->nonce = $this->opaque = null;
throw new AuthorizationErrorException(
'Authorization failure. Nonce is expired.',
ApiError::CODE_DIGEST_NONCE_EXPIRED
);
}
// NonceCount prüfen?
if ($this->checkNonceCount) {
// NonceCount zu Hexadezimal wandeln
$nonceCountHex = dechex($keyDetails['nonce_count_decimal']);
$this->digestParts['nc'] = ltrim($this->digestParts['nc'], '0');
// NonceCount stimmt nicht überein
if ($this->digestParts['nc'] !== $nonceCountHex) {
throw new AuthorizationErrorException(
'Authorization failure. Nonce count doesn\'t match.',
ApiError::CODE_DIGEST_NC_NOT_MATCHING
);
}
}
// NonceCount in DB hochzählen
$this->incrementNonceCount($this->digestParts['nonce']);
// Wenn bis hierhin kein Fehler passiert ist, passt alles.
// Serverkeys sind noch gültig
$this->isAuthenticated = true;
$this->apiAccountId = (int)$account['id'];
return;
}
// Alle Accounts durchprobiert > Kein Erfolg
throw new AuthorizationErrorException(
'Authorization failure. API Account invalid.',
ApiError::CODE_API_ACCOUNT_INVALID
);
}
/**
* @return bool
*/
public function isAuthenticated()
{
return $this->isAuthenticated;
}
/**
* @return int|null
*/
public function getApiAccountId()
{
return $this->apiAccountId;
}
/**
* Header-String generieren den der Client zum Authentifizieren benötigt
*
* @return string
*/
public function generateAuthenticationString()
{
// Neue Server-Key generieren
if (!$this->nonce && !$this->opaque) {
$this->createServerKeys();
}
return sprintf(
'Digest realm="%s",qop="auth",nonce="%s",opaque="%s"',
$this->realm, $this->nonce, $this->opaque
);
}
/**
* @param string $nonce
* @param string $opaque
*
* @return array|bool
*/
protected function getKeyDetails($nonce, $opaque)
{
if (empty($nonce) || empty($opaque)) {
return false;
}
$keyDetails = $this->db->fetchAll(
'SELECT k.nonce_count, k.zeitstempel FROM api_keys AS k '.
'WHERE k.nonce = :nonce AND k.opaque = :opaque',
array('nonce' => $nonce, 'opaque' => $opaque)
);
if (count($keyDetails) === 0) {
return false;
}
return array(
'nonce_count_decimal' => (int)$keyDetails[0]['nonce_count'],
'age' => time() - strtotime($keyDetails[0]['zeitstempel']),
);
}
/**
* @param string $username
* @param string $password
*
* @return bool Digest-Auth valide?
*/
protected function validateDigestLogin($username, $password)
{
// Based on all the info we gathered we can figure out what the response should be
$A1 = md5("{$username}:{$this->realm}:{$password}");
$A2 = md5("{$this->request->getMethod()}:".stripslashes($this->request->getRequestUri()));
// Im 'auth-int' Modus muss zusätzlich der Request-Body validiert werden
if ($this->digestParts['qop'] === 'auth-int') {
$A2 = md5("{$this->request->getMethod()}:".stripslashes($this->request->getRequestUri()).":{$this->request->getContent()}");
}
$validResponse = md5("{$A1}:{$this->digestParts['nonce']}:{$this->digestParts['nc']}:{$this->digestParts['cnonce']}:{$this->digestParts['qop']}:{$A2}");
return ($this->digestParts['response'] === $validResponse);
}
/**
* @param string $nonce
*/
protected function incrementNonceCount($nonce)
{
$this->db->perform(
'UPDATE api_keys SET nonce_count = nonce_count + 1 WHERE nonce = :nonce',
array('nonce' => $nonce)
);
}
/**
* Neue Server-Keys (Nonce und Opaque) generieren und in DB ablegen
*/
protected function createServerKeys()
{
$this->nonce = md5(uniqid('', true));
$this->opaque = md5(uniqid('', true));
// Neue Keys in Datenbank speichern
$this->db->perform(
'INSERT INTO api_keys (id, nonce, opaque) VALUES (NULL, :nonce, :opaque)',
array('nonce' => $this->nonce, 'opaque' => $this->opaque)
);
}
/**
* This function returns the digest header
*
* @return string|false
*/
protected function getDigestRequestHeader()
{
$authHeader = $this->getAuthorizationRequestHeader();
if (stripos($authHeader, 'digest ') === 0) {
return substr_replace($authHeader, '', 0, 7);
}
return false;
}
/**
* Einzelnen Request-Header auslesen
*
* @param string $type z.B. "Authorization" oder "Content-Type"
*
* @return string|false
*/
protected function getRequestHeader($type)
{
if ($this->request->header->has($type)) {
return $this->request->header->get($type);
}
return false;
}
/**
* @return string|false
*/
protected function getAuthorizationRequestHeader()
{
return $this->getRequestHeader('Authorization');
}
/**
* Digest-Header in einzelne Bestandteile zerlegen, und prüfen ob alle benötigten Teile vorhanden sind.
*
* @param string $digest
*
* @return array|false Einzelne Bestandteile als Array, oder false wenn Teile fehlen
*/
protected function parseDigest($digest)
{
$neededParts = array(
'nonce' => false,
'opaque' => false,
'nc' => false,
'cnonce' => false,
'qop' => false,
'username' => false,
'uri' => false,
'response' => false,
);
$data = array();
// Beispiel: username="Test", realm="API", nonce="5b308bec108f0", uri="/api/addresses", qop=auth, nc=00000029, ...
$parts = explode(',', $digest);
foreach ($parts as $part) {
$atoms = explode('=', $part, 2);
if (count($atoms) !== 2) {
continue;
}
$key = trim($atoms[0], ' ');
$val = trim($atoms[1], '"');
$data[$key] = $val;
unset($neededParts[$key]);
}
return empty($neededParts) ? $data : false;
}
}