2023-12-23 18:05:52 +01:00
< ? php
2023-12-23 22:43:31 +01:00
2023-12-23 18:05:52 +01:00
use Endroid\QrCode\Builder\Builder ;
use Endroid\QrCode\Encoding\Encoding ;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh ;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin ;
use Endroid\QrCode\Writer\PngWriter ;
2023-12-23 22:49:57 +01:00
use OTPHP\TOTP ;
2023-12-23 18:05:52 +01:00
class TotpPf
{
private string $key_table ;
private string $table ;
private Login $login ;
public function __construct ( string $tableName )
{
$ok = [ 'mailbox' , 'admin' ];
if ( ! in_array ( $tableName , $ok )) {
throw new \InvalidArgumentException ( " Unsupported tableName for TOTP: " . $tableName );
}
$this -> table = $tableName ;
$this -> key_table = table_by_key ( $tableName );
$this -> login = new Login ( $tableName );
}
/**
* @ param string username to generate a code for
*
* @ return Array
* string TOTP_secret empty if NULL ,
* string & $qr_code for returning base64 - encoded qr - code
*
* @ throws \Exception if invalid user , or db update fails .
*/
public function generate ( string $username ) : array
{
$totp = TOTP :: create ();
$totp -> setLabel ( $username );
$totp -> setIssuer ( 'Postfix Admin' );
2023-12-23 22:43:31 +01:00
if ( Config :: read ( 'logo_url' )) {
2023-12-23 18:05:52 +01:00
$totp -> setParameter ( 'image' , Config :: read ( 'logo_url' ));
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
$QR_content = $totp -> getProvisioningUri ();
$pTOTP_secret = $totp -> getSecret ();
unset ( $totp );
$QRresult = Builder :: create ()
-> writer ( new PngWriter ())
-> writerOptions ([])
-> data ( $QR_content )
-> encoding ( new Encoding ( 'UTF-8' ))
-> errorCorrectionLevel ( new ErrorCorrectionLevelHigh ())
-> size ( 300 )
-> margin ( 10 )
-> roundBlockSizeMode ( new RoundBlockSizeModeMargin ())
-> validateResult ( false )
-> build ();
$qr_code = base64_encode ( $QRresult -> getString ());
2023-12-23 22:43:31 +01:00
return array ( $pTOTP_secret , $qr_code );
2023-12-23 18:05:52 +01:00
}
/**
* @ param string $username
*
* @ return boolean
*/
2023-12-23 22:49:57 +01:00
public function usesTOTP ( string $username ) : bool
2023-12-23 18:05:52 +01:00
{
2023-12-23 22:43:31 +01:00
if ( ! ( Config :: read ( 'totp' ) == 'YES' )) {
2023-12-23 18:05:52 +01:00
return false ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
$sql = " SELECT totp_secret FROM { $this -> table } WHERE username = :username AND active = :active " ;
$active = db_get_boolean ( true );
$values = [
'username' => $username ,
'active' => $active ,
];
$result = db_query_one ( $sql , $values );
2024-01-05 20:31:54 +01:00
if ( is_array ( $result ) && isset ( $result [ 'totp_secret' ]) && ! empty ( $result [ 'totp_secret' ])) {
2023-12-23 18:05:52 +01:00
return true ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
return false ;
}
/**
* @ param string $username
* @ param string $code
*
* @ return boolean
*/
2023-12-23 22:49:57 +01:00
public function checkUserTOTP ( string $username , string $code ) : bool
2023-12-23 18:05:52 +01:00
{
$sql = " SELECT totp_secret FROM { $this -> table } WHERE username = :username AND active = :active " ;
$active = db_get_boolean ( true );
$values = [
'username' => $username ,
'active' => $active ,
];
$result = db_query_one ( $sql , $values );
2023-12-23 22:43:31 +01:00
if ( ! is_array ( $result ) || ! isset ( $result [ 'totp_secret' ])) {
2023-12-23 18:05:52 +01:00
return false ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
return $this -> checkTOTP ( $result [ 'totp_secret' ], $username , $code );
}
/**
* @ param string $secret
* @ param string $username
* @ param string $code
*
* @ return boolean
*/
2023-12-23 22:49:57 +01:00
public function checkTOTP ( string $secret , string $username , string $code ) : bool
2023-12-23 18:05:52 +01:00
{
$totp = TOTP :: create ( $secret );
2023-12-23 22:49:57 +01:00
if ( $totp -> now () == $code ) {
2023-12-23 18:05:52 +01:00
return true ;
2023-12-23 22:43:31 +01:00
} else {
2023-12-23 18:05:52 +01:00
return false ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
}
/**
* @ param string $username
* @ param string $password
*
* @ return string TOTP_secret , empty if NULL
* @ throws \Exception if invalid user , or db update fails .
*/
2023-12-23 22:49:57 +01:00
public function getTOTP_secret ( string $username , string $password ) : string
2023-12-23 18:05:52 +01:00
{
if ( ! $this -> login -> login ( $username , $password )) {
throw new \Exception ( Config :: Lang ( 'pPassword_password_current_text_error' ));
}
$sql = " SELECT totp_secret FROM { $this -> table } WHERE username = :username AND active = :active " ;
$active = db_get_boolean ( true );
$values = [
'username' => $username ,
'active' => $active ,
];
$result = db_query_one ( $sql , $values );
if ( is_array ( $result ) && isset ( $result [ 'totp_secret' ])) {
return $result [ 'totp_secret' ];
} else {
return '' ;
}
}
/**
* @ param string $username
* @ param string $TOTP_secret
* @ param string $password
*
* @ return boolean true on success ; false on failure
* @ throws \Exception if invalid user , or db update fails .
*/
2023-12-23 22:49:57 +01:00
public function changeTOTP_secret ( string $username , string $TOTP_secret , string $password ) : bool
2023-12-23 18:05:52 +01:00
{
list ( /*NULL*/ , $domain ) = explode ( '@' , $username );
if ( ! $this -> login -> login ( $username , $password )) {
throw new \Exception ( Config :: Lang ( 'pPassword_password_current_text_error' ));
}
$set = array (
2023-12-23 22:49:57 +01:00
'totp_secret' => $TOTP_secret ,
);
2023-12-23 18:05:52 +01:00
$result = db_update ( $this -> table , 'username' , $username , $set );
if ( $result != 1 ) {
db_log ( $domain , 'edit_password' , " FAILURE: " . $username );
throw new \Exception ( Config :: lang ( 'pEdit_mailbox_result_error' ));
}
$cmd_pw = Config :: read ( 'mailbox_post_TOTP_change_secret_script' );
if ( empty ( $cmd_pw )) {
return true ;
}
$warnmsg_pw = Config :: Lang ( 'mailbox_post_TOTP_change_failed' );
// If we have a mailbox_postpassword_script (dovecot only?)
// Use proc_open call to avoid safe_mode problems and to prevent showing plain password in process table
$spec = array (
2023-12-23 22:49:57 +01:00
0 => array ( " pipe " , " r " ), // stdin
1 => array ( " pipe " , " w " ), // stdout
);
2023-12-23 18:05:52 +01:00
$cmdarg1 = escapeshellarg ( $username );
$cmdarg2 = escapeshellarg ( $domain );
$command = " $cmd_pw $cmdarg1 $cmdarg2 2>&1 " ;
$proc = proc_open ( $command , $spec , $pipes );
if ( ! $proc ) {
throw new \Exception ( " can't proc_open $cmd_pw " );
}
// Write secret through pipe to command stdin.
2023-12-23 22:49:57 +01:00
fwrite ( $pipes [ 0 ], $TOTP_secret . " \0 " , 1 + strlen ( $TOTP_secret ));
2023-12-23 18:05:52 +01:00
$output = stream_get_contents ( $pipes [ 1 ]);
fclose ( $pipes [ 0 ]);
fclose ( $pipes [ 1 ]);
$retval = proc_close ( $proc );
if ( 0 != $retval ) {
error_log ( " Running $command yielded return value= $retval , output was: " . json_encode ( $output ));
throw new \Exception ( $warnmsg_pw );
}
return true ;
}
/**
* @ param string $username
* @ param string $password
* @ param string $Exception_ip
* @ param string $Exception_user
* @ param string $Exception_desc
*
* @ return boolean true on success ; false on failure
* @ throws \Exception if invalid user , or db update fails .
*/
public function addException ( string $username , string $password , string $Exception_ip , string $Exception_user , string $Exception_desc ) : bool
{
$error = 0 ;
list ( $local_part , $domain ) = explode ( '@' , $username );
2023-12-23 22:43:31 +01:00
if ( ! $this -> login -> login ( $username , $password )) {
2023-12-23 18:05:52 +01:00
throw new \Exception ( Config :: Lang ( 'pPassword_password_current_text_error' ));
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
2023-12-23 22:43:31 +01:00
if ( authentication_has_role ( 'admin' )) {
2023-12-23 18:05:52 +01:00
$admin = 1 ;
2023-12-23 22:43:31 +01:00
} elseif ( authentication_has_role ( 'global-admin' )) {
2023-12-23 18:05:52 +01:00
$admin = 2 ;
2023-12-23 22:43:31 +01:00
} else {
2023-12-23 18:05:52 +01:00
$admin = 0 ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
if ( empty ( $Exception_ip )) {
$error += 1 ;
flash_error ( Config :: Lang ( 'pException_ip_empty_error' ));
}
if ( empty ( $Exception_desc )) {
$error += 1 ;
flash_error ( Config :: Lang ( 'pException_desc_empty_error' ));
}
2023-12-23 22:49:57 +01:00
if ( ! $admin && strpos ( $Exception_user , '@' ) == false ) {
2023-12-23 18:05:52 +01:00
$error += 1 ;
flash_error ( Config :: Lang ( 'pException_user_entire_domain_error' ));
}
2023-12-23 22:49:57 +01:00
if ( ! ( $admin == 2 ) && $Exception_user == null ) {
2023-12-23 18:05:52 +01:00
$error += 1 ;
flash_error ( Config :: Lang ( 'pException_user_global_error' ));
}
2023-12-23 22:49:57 +01:00
$values = [ 'ip' => $Exception_ip , 'username' => $Exception_user , 'description' => $Exception_desc ];
2023-12-23 18:05:52 +01:00
2023-12-23 22:43:31 +01:00
if ( ! $error ) {
2023-12-23 18:05:52 +01:00
// OK to insert/replace.
// As PostgeSQL lacks REPLACE we first check and delete any previous rows matching this ip and user
2023-12-23 22:43:31 +01:00
$exists = db_query_all ( 'SELECT id FROM totp_exception_address WHERE ip = :ip AND username = :username' ,
[ 'ip' => $Exception_ip , 'username' => $Exception_user ]);
if ( isset ( $exists [ 0 ])) {
foreach ( $exists as $x ) {
db_delete ( 'totp_exception_address' , 'id' , $x [ 'id' ]);
}
}
$result = db_insert ( 'totp_exception_address' , $values , array ());
2023-12-23 18:05:52 +01:00
}
if ( $result != 1 ) {
db_log ( $domain , 'add_totp_exception' , " FAILURE: " . $username );
throw new \Exception ( Config :: lang ( 'pEdit_totp_exception_result_error' ));
}
$cmd_pw = Config :: read ( 'mailbox_post_totp_exception_add_script' );
if ( empty ( $cmd_pw )) {
return true ;
}
$warnmsg_pw = Config :: Lang ( 'mailbox_post_totp_exception_add_failed' );
// If we have a mailbox_postpassword_script (dovecot only?)
// Use proc_open call to avoid safe_mode problems and to prevent showing plain password in process table
$spec = array (
2023-12-23 22:49:57 +01:00
0 => array ( " pipe " , " r " ), // stdin
1 => array ( " pipe " , " w " ), // stdout
);
2023-12-23 18:05:52 +01:00
$cmdarg1 = escapeshellarg ( $username );
$cmdarg2 = escapeshellarg ( $Exception_ip );
$command = " $cmd_pw $cmdarg1 $cmdarg2 2>&1 " ;
$proc = proc_open ( $command , $spec , $pipes );
if ( ! $proc ) {
throw new \Exception ( " can't proc_open $cmd_pw " );
}
fclose ( $pipes [ 0 ]);
fclose ( $pipes [ 1 ]);
$retval = proc_close ( $proc );
if ( 0 != $retval ) {
error_log ( " Running $command yielded return value= $retval , output was: " . json_encode ( $retval ));
throw new \Exception ( $warnmsg_pw );
}
return true ;
}
/**
* @ param string $username
* @ param int $Exception_id
*
* @ return boolean true on success ; false on failure
* @ throws \Exception if invalid user , or db update fails .
*/
2023-12-23 22:49:57 +01:00
public function deleteException ( string $username , int $id ) : bool
2023-12-23 18:05:52 +01:00
{
2023-12-23 22:49:57 +01:00
$exception = $this -> getException ( $id );
2023-12-23 18:05:52 +01:00
$error = 0 ;
2023-12-27 17:16:45 +01:00
if ( ! is_array ( $exception )) {
throw new \InvalidArgumentException ( " Invalid exception - does id: $id exist? " );
}
2023-12-23 22:49:57 +01:00
if ( strpos ( $exception [ 'username' ], '@' )) {
2023-12-23 18:05:52 +01:00
list ( $Exception_local_part , $Exception_domain ) = explode ( '@' , $exception [ 'username' ]);
2023-12-23 22:43:31 +01:00
} else {
2023-12-23 18:05:52 +01:00
$Exception_domain = $exception [ 'username' ];
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
2023-12-23 22:55:50 +01:00
$admin = 0 ;
2023-12-23 22:43:31 +01:00
if ( authentication_has_role ( 'global-admin' )) {
2023-12-23 18:05:52 +01:00
$admin = 2 ;
2023-12-23 22:43:31 +01:00
} elseif ( authentication_has_role ( 'admin' )) {
2023-12-23 18:05:52 +01:00
$admin = 1 ;
2023-12-23 22:43:31 +01:00
}
2023-12-23 18:05:52 +01:00
2023-12-23 22:55:50 +01:00
/**
* @ todo rewrite these checks so it 's more obvious which is being applied for a global admin, a domain admin or a ' normal ' user .
* having $admin = 0 | 1 | 2 isn ' t intuitive , is it ?
*/
2023-12-23 22:49:57 +01:00
if ( ! $admin && strpos ( $exception [ 'username' ], '@' ) !== false ) {
2023-12-23 18:05:52 +01:00
throw new \Exception ( Config :: Lang ( 'pException_user_entire_domain_error' ));
}
2023-12-23 22:49:57 +01:00
if ( ! ( $admin == 2 ) && $exception [ 'username' ] == null ) {
2023-12-23 18:05:52 +01:00
throw new \Exception ( Config :: Lang ( 'pException_user_global_error' ));
}
2023-12-23 22:55:50 +01:00
/**
* @ todo Check we are only allowing someone to delete their own exception , and not someone else ' s .
*/
2023-12-23 18:05:52 +01:00
$result = db_delete ( 'totp_exception_address' , 'id' , $exception [ 'id' ]);
if ( $result != 1 ) {
db_log ( $Exception_domain , 'pViewlog_action_delete_totp_exception' , " FAILURE: " . $username );
throw new \Exception ( Config :: lang ( 'pEdit_totp_exception_result_error' ));
}
$cmd_pw = Config :: read ( 'mailbox_post_totp_exception_delete_script' );
if ( empty ( $cmd_pw )) {
return true ;
}
$warnmsg_pw = Config :: Lang ( 'mailbox_post_totp_exception_delete_failed' );
// If we have a mailbox_postpassword_script (dovecot only?)
// Use proc_open call to avoid safe_mode problems and to prevent showing plain password in process table
$spec = array (
2023-12-23 22:49:57 +01:00
0 => array ( " pipe " , " r " ), // stdin
1 => array ( " pipe " , " w " ), // stdout
);
2023-12-23 18:05:52 +01:00
$cmdarg1 = escapeshellarg ( $username );
$cmdarg2 = escapeshellarg ( $exception [ 'ip' ]);
$command = " $cmd_pw $cmdarg1 $cmdarg2 2>&1 " ;
$proc = proc_open ( $command , $spec , $pipes );
if ( ! $proc ) {
throw new \Exception ( " can't proc_open $cmd_pw " );
}
// Write secret through pipe to command stdin.
$output = stream_get_contents ( $pipes [ 1 ]);
fclose ( $pipes [ 0 ]);
fclose ( $pipes [ 1 ]);
$retval = proc_close ( $proc );
if ( 0 != $retval ) {
error_log ( " Running $command yielded return value= $retval , output was: " . json_encode ( $output ));
throw new \Exception ( $warnmsg_pw );
}
return true ;
}
/**
2023-12-23 22:43:31 +01:00
* @ return array of all exceptions
2023-12-23 18:05:52 +01:00
*/
public function getAllExceptions () : array
{
return db_query_all ( " SELECT * FROM totp_exception_address " );
}
/**
* @ param string $username
*
* @ return array of exceptions acting on this username
*/
2023-12-23 22:49:57 +01:00
public function getExceptionsFor ( string $username ) : array
2023-12-23 18:05:52 +01:00
{
list ( $local_part , $domain ) = explode ( '@' , $username );
2023-12-23 22:49:57 +01:00
return db_query_all ( " SELECT * FROM totp_exception_address WHERE username = :username OR username = :domain OR username IS NULL " , [ 'username' => $username , 'domain' => $domain ]);
2023-12-23 18:05:52 +01:00
}
/**
* @ param int $id
*
* @ return array the exception with this id
*/
2023-12-27 17:16:45 +01:00
public function getException ( int $id ) : ? array
2023-12-23 18:05:52 +01:00
{
2023-12-23 22:47:57 +01:00
return db_query_one ( " SELECT * FROM totp_exception_address WHERE id=:id " , [ 'id' => $id ]);
2023-12-23 18:05:52 +01:00
}
}
/* vim: set expandtab softtabstop=4 tabstop=4 shiftwidth=4: */