Files
huntingclubroster/classes/MfaService.php
T

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);
}
}