Initial xentral_oss_20.3.c9ffacf
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user