generated from jric11/baseProject
252 lines
17 KiB
PHP
252 lines
17 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
use OTPHP\TOTP;
|
||
|
|
|
||
|
|
/** Authentication factors belong to the account, never to a selected tenant. */
|
||
|
|
class MfaService extends Base {
|
||
|
|
|
||
|
|
public const RECENT_SECONDS = 300;
|
||
|
|
|
||
|
|
private function key(): string {
|
||
|
|
$encoded = getenv('HCR_MFA_KEY');
|
||
|
|
// Local development servers do not necessarily inherit shell variables.
|
||
|
|
// Keep their persistent secret outside the application and its releases.
|
||
|
|
if ($encoded === false) {
|
||
|
|
$settings = $this->getSettings();
|
||
|
|
$file = getenv('HCR_MFA_KEY_FILE');
|
||
|
|
if ($file === false)
|
||
|
|
$file = (string) ($settings->MfaKeyFile ?? '');
|
||
|
|
if ((string) $settings->Environment === 'Development' && $file !== '') {
|
||
|
|
$resolved = realpath($file);
|
||
|
|
$root = realpath(__DIR__ . '/..');
|
||
|
|
$normalize = static fn($path) => strtolower(str_replace('\\', '/', $path));
|
||
|
|
if ($resolved === false || !is_file($resolved) || !is_readable($resolved) || filesize($resolved) > 128 || str_starts_with($normalize($resolved), $normalize($root) . '/'))
|
||
|
|
throw new RuntimeException('Development MFA key must be a readable file outside the application directory.');
|
||
|
|
$encoded = trim(file_get_contents($resolved));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$key = base64_decode($encoded === false ? '' : $encoded, true);
|
||
|
|
if ($key === false || strlen($key) !== 32)
|
||
|
|
throw new RuntimeException('Configure HCR_MFA_KEY with a base64-encoded random 32-byte key.');
|
||
|
|
return $key;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function sealSecret(string $secret, int $user): string {
|
||
|
|
$iv = random_bytes(12);
|
||
|
|
$tag = '';
|
||
|
|
$cipher = openssl_encrypt($secret, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag, (string) $user);
|
||
|
|
if ($cipher === false)
|
||
|
|
throw new RuntimeException('Cannot protect authenticator secret.');
|
||
|
|
return base64_encode($iv . $tag . $cipher);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function decrypt(string $cipher, int $user): string {
|
||
|
|
$raw = base64_decode($cipher, true);
|
||
|
|
if ($raw === false || strlen($raw) < 29)
|
||
|
|
throw new RuntimeException('Invalid authenticator secret.');
|
||
|
|
$secret = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), (string) $user);
|
||
|
|
if ($secret === false)
|
||
|
|
throw new RuntimeException('Cannot unlock authenticator secret.');return $secret;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function enrolled(int $user): bool {
|
||
|
|
$stmt = $this->connect()->prepare('SELECT 1 FROM user_mfa WHERE user_serial=?');
|
||
|
|
$stmt->execute([$user]);
|
||
|
|
return (bool) $stmt->fetchColumn();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function required(int $user): bool {
|
||
|
|
return (new TenantAccess())->managesAny() || $this->enrolled($user);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function verified(int $user): bool {
|
||
|
|
return ($_SESSION['mfa_user'] ?? 0) === $user;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function limited(int $user): void {
|
||
|
|
$auth = new AuthService();
|
||
|
|
if (!$auth->allowAttempt('mfa-account', (string) $user, 10, 300) || !$auth->allowAttempt('mfa-ip', $_SERVER['REMOTE_ADDR'] ?? '', 40, 300))
|
||
|
|
throw new AppException('Too many authentication attempts. Try again later.', 429);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function counter(string $secret, string $code, int $last): ?int {
|
||
|
|
if (!preg_match('/\A[0-9]{6}\z/', $code))
|
||
|
|
return null;
|
||
|
|
$otp = TOTP::createFromSecret($secret, new MfaClock());
|
||
|
|
$now = intdiv(time(), 30);
|
||
|
|
foreach ([$now, $now - 1, $now + 1] as $counter)
|
||
|
|
if ($counter > $last && hash_equals($otp->at($counter * 30), $code))
|
||
|
|
return $counter;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** All factor mutations use the account-then-factor lock order. */
|
||
|
|
private function account(PDO $db,int $user,string $password): ?array {
|
||
|
|
$stmt=$db->prepare('SELECT user_serial,user_email,user_password_hash,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
|
||
|
|
$stmt->execute([$user]);$account=$stmt->fetch();
|
||
|
|
if(!$account||$account['user_status']!=='active'||($_SESSION[self::USER_SERIAL]??0)!==$user
|
||
|
|
||($_SESSION['auth_version']??-1)!==(int)$account['user_auth_version']
|
||
|
|
||!password_verify($password,$account['user_password_hash']))return null;
|
||
|
|
return $account;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function factor(PDO $db,int $user): ?array {
|
||
|
|
$stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);return $stmt->fetch()?:null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function consume(PDO $db,int $user,array $factor,string $code): ?string {
|
||
|
|
$code=trim($code);$counter=self::counter($this->decrypt($factor['secret_cipher'],$user),$code,(int)$factor['last_counter']);
|
||
|
|
if($counter!==null){
|
||
|
|
$stmt=$db->prepare('UPDATE user_mfa SET last_counter=? WHERE user_serial=?');$stmt->execute([$counter,$user]);return 'totp';
|
||
|
|
}
|
||
|
|
$code=strtolower($code);if(!preg_match('/\A[a-f0-9]{32}\z/',$code))return null;
|
||
|
|
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=? AND code_hash=?');$stmt->execute([$user,hash('sha256',$code)]);
|
||
|
|
return $stmt->rowCount()===1?'recovery':null;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function notify(array $account,string $event): void {
|
||
|
|
if(!(new MfaSecurityNotifications())->send($account,$event))$_SESSION['mfa_notification_warning']=true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function verify(int $user,string $code,string $password): bool {
|
||
|
|
$this->limited($user);
|
||
|
|
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
|
||
|
|
$account=$this->account($db,$user,$password);if(!$account)return null;
|
||
|
|
$factor=$this->factor($db,$user);if(!$factor)return null;
|
||
|
|
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
|
||
|
|
Audit::required($db,$user,$kind==='recovery'?'used MFA recovery code':'verified authenticator');
|
||
|
|
return ['account'=>$account,'kind'=>$kind];
|
||
|
|
});
|
||
|
|
if(!$result)return false;
|
||
|
|
if($result['kind']==='recovery')$this->notify($result['account'],'recovery_used');
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function recoveryCodes(PDO $db,int $user): array {
|
||
|
|
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]);
|
||
|
|
$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$codes=[];
|
||
|
|
for($i=0;$i<10;$i++){$code=bin2hex(random_bytes(16));$codes[]=$code;$stmt->execute([$user,hash('sha256',$code)]);}
|
||
|
|
return $codes;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function enroll(int $user,string $secret,string $code,string $password): array {
|
||
|
|
$this->limited($user);$pending=$_SESSION['mfa_pending']??[];
|
||
|
|
if(($pending['user']??0)!==$user||($pending['expires']??0)<time()||!hash_equals($pending['secret']??'',$secret))throw new AppException('Authenticator setup expired. Reload the page.',409);
|
||
|
|
$counter=self::counter($secret,trim($code),-1);if($counter===null)throw new AppException('Enter the current authenticator code.');
|
||
|
|
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$secret,$counter,$password){
|
||
|
|
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
|
||
|
|
if($this->factor($db,$user))throw new AppException('Authenticator is already enrolled.',409);
|
||
|
|
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher,last_counter) VALUES(?,?,?)');$stmt->execute([$user,$this->sealSecret($secret,$user),$counter]);
|
||
|
|
$codes=$this->recoveryCodes($db,$user);
|
||
|
|
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
|
||
|
|
Audit::required($db,$user,'enrolled authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
|
||
|
|
});
|
||
|
|
unset($_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);$_SESSION['auth_version']=$result['version'];
|
||
|
|
$this->notify($result['account'],'enrolled');return $result['codes'];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Prove the old factor, but keep it and the remaining recovery codes active. */
|
||
|
|
public function beginReplacement(int $user,string $code,string $password): bool {
|
||
|
|
$this->limited($user);
|
||
|
|
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
|
||
|
|
$account=$this->account($db,$user,$password);if(!$account)return null;
|
||
|
|
$factor=$this->factor($db,$user);if(!$factor)return null;
|
||
|
|
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
|
||
|
|
if($kind==='recovery')Audit::required($db,$user,'used MFA recovery code');
|
||
|
|
Audit::required($db,$user,'requested authenticator replacement');
|
||
|
|
return ['account'=>$account,'kind'=>$kind,'factor'=>hash('sha256',$factor['secret_cipher'])];
|
||
|
|
});
|
||
|
|
if(!$result)return false;
|
||
|
|
$_SESSION['mfa_replacement']=['user'=>$user,'version'=>(int)$result['account']['user_auth_version'],'factor'=>$result['factor'],
|
||
|
|
'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
|
||
|
|
$this->mark($user);
|
||
|
|
$this->notify($result['account'],$result['kind']==='recovery'?'replacement_started_recovery':'replacement_started');return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function replacement(int $user): ?array {
|
||
|
|
$pending=$_SESSION['mfa_replacement']??null;
|
||
|
|
if(!$pending)return null;
|
||
|
|
if(($pending['user']??0)!==$user||($pending['version']??-1)!==($_SESSION['auth_version']??-2)||($pending['expires']??0)<time()){
|
||
|
|
unset($_SESSION['mfa_replacement']);return null;
|
||
|
|
}
|
||
|
|
return $pending;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function completeReplacement(int $user,string $code,string $password): array {
|
||
|
|
$this->limited($user);$pending=$this->replacement($user);
|
||
|
|
if(!$pending)throw new AppException('Replacement setup expired. Your existing authenticator is still active.',409);
|
||
|
|
$counter=self::counter($pending['secret'],trim($code),-1);if($counter===null)throw new AppException('Enter the code from your new authenticator.');
|
||
|
|
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$password,$pending,$counter){
|
||
|
|
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
|
||
|
|
$factor=$this->factor($db,$user);
|
||
|
|
if(!$factor||!hash_equals($pending['factor'],hash('sha256',$factor['secret_cipher'])))throw new AppException('Authenticator changed. Start replacement again.',409);
|
||
|
|
$stmt=$db->prepare('UPDATE user_mfa SET secret_cipher=?,last_counter=? WHERE user_serial=?');$stmt->execute([$this->sealSecret($pending['secret'],$user),$counter,$user]);
|
||
|
|
$codes=$this->recoveryCodes($db,$user);
|
||
|
|
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
|
||
|
|
Audit::required($db,$user,'replaced authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
|
||
|
|
});
|
||
|
|
unset($_SESSION['mfa_replacement'],$_SESSION['mfa_pending']);$_SESSION['auth_version']=$result['version'];$this->mark($user);
|
||
|
|
$this->notify($result['account'],'replaced');return $result['codes'];
|
||
|
|
}
|
||
|
|
|
||
|
|
private function mark(int $user): void {
|
||
|
|
session_regenerate_id(true);$_SESSION['mfa_user']=$user;$_SESSION['recent_auth']=time();$_SESSION['csrf_token']=bin2hex(random_bytes(32));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function gate(array $user,string $action): bool {
|
||
|
|
$id=(int)$user['user_serial'];
|
||
|
|
if($action!=='security'&&(!$this->required($id)||$this->verified($id)))return false;
|
||
|
|
if(!empty($_SESSION[self::AJAX])&&$action!=='security')throw new AppException('Complete administrator authentication from the home page.',403);
|
||
|
|
$this->page($user);return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function page(array $user): void {
|
||
|
|
$id=(int)$user['user_serial'];$error='';$codes=[];$enrolled=$this->enrolled($id);
|
||
|
|
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
|
||
|
|
$this->requireCsrf();
|
||
|
|
try{
|
||
|
|
$action=$_POST['factor_action']??'';
|
||
|
|
if($action==='cancelReplacement'){
|
||
|
|
unset($_SESSION['mfa_replacement']);header('Location: main.php?Xaction=security',true,303);return;
|
||
|
|
}elseif($action==='confirmReplacement'){
|
||
|
|
$codes=$this->completeReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??'');
|
||
|
|
}elseif(!$enrolled){
|
||
|
|
$codes=$this->enroll($id,$_SESSION['mfa_pending']['secret']??'',$_POST['mfa_code']??'',$_POST['current_password']??'');$enrolled=true;$this->mark($id);
|
||
|
|
}elseif($action==='replace'){
|
||
|
|
if(!$this->beginReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
|
||
|
|
header('Location: main.php?Xaction=security',true,303);return;
|
||
|
|
}else{
|
||
|
|
if(!$this->verify($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
|
||
|
|
$this->mark($id);header('Location: main.php',true,303);return;
|
||
|
|
}
|
||
|
|
}catch(AppException $errorObject){http_response_code($errorObject->status);$error=$errorObject->getMessage();}
|
||
|
|
}
|
||
|
|
$pending=$this->replacement($id);$h=static fn($value)=>htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');
|
||
|
|
$html='<main class="container py-3"><h1>Account security</h1><p role="alert">'.$h($error).'</p>'.MfaSecurityNotifications::notice();
|
||
|
|
if($codes){
|
||
|
|
$html.='<h2>Save your recovery codes</h2><p>These single-use codes are shown only now. Store them securely away from your authenticator. You will need one if your device is lost.</p><pre>'.implode("\n",$codes).'</pre><a href="main.php">I have saved my recovery codes</a>';
|
||
|
|
}else{
|
||
|
|
if(!$enrolled||$pending){
|
||
|
|
if(!$pending){
|
||
|
|
$this->key();$initial=$_SESSION['mfa_pending']??[];
|
||
|
|
if(($initial['user']??0)!==$id||($initial['expires']??0)<time())$_SESSION['mfa_pending']=['user'=>$id,'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
|
||
|
|
}
|
||
|
|
$secret=$pending['secret']??$_SESSION['mfa_pending']['secret'];$qr=MfaSetupQr::dataUri($secret,$user['user_email']);
|
||
|
|
if($pending)$html.='<h2>Confirm your new authenticator</h2><p>Your existing authenticator remains active until you confirm this replacement. This setup expires after ten minutes.</p>';
|
||
|
|
$html.='<p>Scan this QR code with your authenticator app, then enter its six-digit code below.</p><img class="mfa-setup-qr" src="'.$qr.'" alt="Scan to set up your authenticator" width="320" height="320" style="max-width:100%;height:auto"><p>If you cannot scan it, use this setup key:</p><pre>'.$h($secret).'</pre><p>Account: '.$h($user['user_email']).'. Six digits, 30-second period, SHA-1.</p>';
|
||
|
|
}else $html.='<p>Enter your password and an authenticator code or a single-use recovery code.</p>';
|
||
|
|
$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'">';
|
||
|
|
if($pending)$html.='<input type="hidden" name="factor_action" value="confirmReplacement">';
|
||
|
|
$html.='<label>Current password<input class="form-control" type="password" name="current_password" autocomplete="current-password" required></label><label>Authentication code<input class="form-control" name="mfa_code" autocomplete="one-time-code" maxlength="32" required></label><button class="btn btn-primary">'.($pending?'Confirm replacement':'Verify').'</button>';
|
||
|
|
if($enrolled&&!$pending)$html.='<button class="btn btn-outline-warning" name="factor_action" value="replace">Replace lost authenticator</button><p>Replacement requires your password and a current authenticator or unused recovery code. Your old factor remains active until the new one is confirmed.</p>';
|
||
|
|
$html.='</form>';
|
||
|
|
if($pending)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'"><button class="btn btn-outline-secondary" name="factor_action" value="cancelReplacement">Cancel replacement</button></form>';
|
||
|
|
}
|
||
|
|
$this->displayContent($html.'</main>');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function requireRecent(): void {
|
||
|
|
if(($_SESSION['recent_auth']??0)<time()-self::RECENT_SECONDS)throw new AppException('Open Account security from the home page, confirm your password and authenticator code, then submit the change again.',403);
|
||
|
|
}
|
||
|
|
}
|