generated from jric11/baseProject
46 lines
5.2 KiB
PHP
46 lines
5.2 KiB
PHP
|
|
<?php
|
||
|
|
if(PHP_SAPI!=='cli')exit;
|
||
|
|
require __DIR__.'/../classes/Base.php';
|
||
|
|
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
|
||
|
|
ob_start();session_start();$db=(new Base())->connect();$checks=0;
|
||
|
|
function notificationCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
|
||
|
|
class CapturedMfaNotice extends MfaSecurityNotifications {
|
||
|
|
public array $messages=[];public bool $works=true;public bool $throws=false;
|
||
|
|
protected function deliver(string $to,string $subject,string $body):bool {
|
||
|
|
if($this->throws)throw new RuntimeException('Test delivery failure');
|
||
|
|
$this->messages[]=compact('to','subject','body');return $this->works;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$notice=new CapturedMfaNotice();$account=['user_serial'=>1,'user_email'=>'admin+security@example.test'];
|
||
|
|
foreach(['enrolled','replacement_started','replacement_started_recovery','replaced','recovery_used'] as $event){
|
||
|
|
notificationCheck($notice->send($account,$event),'Security notice supports event '.$event);
|
||
|
|
$message=end($notice->messages);notificationCheck($message['to']===$account['user_email']&&str_contains($message['body'],'Time (UTC)')&&str_contains($message['body'],'did not authorize'),'Notice uses stored address, time and unauthorized-activity instructions');
|
||
|
|
}
|
||
|
|
notificationCheck(!str_contains(implode('',array_column($notice->messages,'body')),'otpauth://')&&!str_contains(implode('',array_column($notice->messages,'body')),'secret='),'Security notices contain no provisioning links or setup secrets');
|
||
|
|
$notice->send(['user_serial'=>1,'user_email'=>'<img src=x onerror=alert(1)>'],'enrolled');notificationCheck(!str_contains(end($notice->messages)['body'],'<img'),'Account text is escaped in security email');
|
||
|
|
$notice->works=false;notificationCheck(!$notice->send($account,'enrolled'),'Delivery failure is reported to the caller');$notice->throws=true;notificationCheck(!$notice->send($account,'replaced'),'Provider exceptions cannot undo committed security actions');
|
||
|
|
try{$notice->send($account,'untrusted-event');throw new RuntimeException('Unknown event accepted');}catch(LogicException $e){notificationCheck(true,'Only reviewed security event types can be sent');}
|
||
|
|
$_SESSION['mfa_notification_warning']=true;notificationCheck(str_contains(MfaSecurityNotifications::notice(),'notification email could not be sent')&&MfaSecurityNotifications::notice()==='','Notification failure warning is visible and consumed once');
|
||
|
|
class CapturedMfaEvents extends MfaService {
|
||
|
|
public array $events=[];
|
||
|
|
protected function notify(array $account,string $event):void {
|
||
|
|
if($this->connect()->inTransaction())throw new RuntimeException('Notification attempted before commit');
|
||
|
|
$this->events[]=$event;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$auth=new AuthService();$auth->login('admin@example.test','Admin test password 123!');$mfa=new CapturedMfaEvents();$user=1;
|
||
|
|
$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();$_SESSION['mfa_pending']=['user'=>1,'secret'=>$secret,'expires'=>time()+600];
|
||
|
|
$codes=$mfa->enroll($user,$secret,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');
|
||
|
|
notificationCheck($mfa->events===['enrolled'],'Enrollment notification runs once after transaction commit');
|
||
|
|
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'wrong')&&$before===$mfa->events,'Failed password creates no recovery notification');
|
||
|
|
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
|
||
|
|
try{$mfa->verify(1,$codes[0],'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back recovery does not send a false security notice');}$db->exec('DROP TRIGGER reject_notice_audit');
|
||
|
|
notificationCheck($mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===['enrolled','recovery_used'],'Committed recovery sends exactly one notice');
|
||
|
|
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===$before,'Replayed recovery sends no duplicate notice');
|
||
|
|
$mfa->beginReplacement(1,$codes[1],'Admin test password 123!');notificationCheck(end($mfa->events)==='replacement_started_recovery','Recovery-authorized replacement sends one combined security notice');
|
||
|
|
$secret=$_SESSION['mfa_replacement']['secret'];$before=$mfa->events;
|
||
|
|
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
|
||
|
|
try{$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back replacement sends no completion notice');}$db->exec('DROP TRIGGER reject_notice_audit');
|
||
|
|
$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');notificationCheck(count($mfa->events)===4&&end($mfa->events)==='replaced','Successful replacement sends one completion notice after commit');
|
||
|
|
echo "$checks MFA notification checks passed.\n";ob_end_flush();
|