Skip to content
Snippets Groups Projects
Commit 7f3f9d77 authored by Hahn Axel (hahn)'s avatar Hahn Axel (hahn)
Browse files

Initial commit

parents
No related branches found
No related tags found
No related merge requests found
# MFA Client
PHP class to enable multi factor authentication for a webapp.
Related projects:
* MFA server 🌐 <https://git-repo.iml.unibe.ch/iml-open-source/mfa-server>
## Reqirements
* PHP 8 (up to PHP 8.4)
* PHP application with a simple user based protection eg. basic authentication.
* A running Mfa server instance
## Installation
### Get source
Go to the web application vendor directory.
Clone this repository.
```txt
git clone <repo url>
```
### Configuration
The files in the subdir `src`:
```txt
cd mfa-client/src
```
Copy mfaconfig.php.dist to mfaconfig.php.
Open the mfa server admin and create a new web app. You get an id and a secret for your aplication.
In the config enter the url of api, id and secret.
```php
<?php
return [
"api" => "https://mfa.example.com/api/",
"appid" => "c1cabd22fbdb698861ad08b27de7399a",
"shared_secret" => "p9wjjXSewZq0VkM1t5Sm3ZbI4ATEVetU",
"debug" => false,
];
```
## Enable MFA
### Activate MFA after logon
This step depends on your code. You need to find a good place to embed the MFA process.
```php
<?php
...
// enable MFA:
include "<APPROOT>/vendor/mfa-client/src/mfa-ensure.php";
...
```
### Give access to user settings on mfa server
If a user is logged in and solves a mfa challenge then he jumps back to theapplication.
You should offer a link to the user that jumps to the mfa server to edit his own settings there.
A good place is the user profile page in your app.
**📌 Example**:
```php
<?php
...
// load class
require "<APPROOT>/vendor/mfa-client/mfaclient.class.php";
// initialize client
$oMfa = new mfaclient();
// $oMfa->debug(true);
// set the user
$oMfa->setUser($this->getUserid());
// show a button; set a complete url where to jump back
echo $oMfa->getButtonSetup(
"<button>MFA settings</button>",
"https://myapp.example.com/profile"
);
...
```
<?php
/**
* mfa-ensure.php
*
* @author Axel Hahn <axel.hahn@unibe>
* @package IML-Appmonitor
*
*/
if(!($_SERVER['REMOTE_USER']??false)){
return true;
}
$aConfig = @include "mfaconfig.php";
if(!($aConfig['api']??false)){
return true;
}
require_once __DIR__.'/mfaclient.class.php';
$mfa = new mfaclient($aConfig, ($_SERVER['REMOTE_USER']??''));
$mfa->debug($aConfig['debug']??false);
$iHttpStatus=$mfa->ensure();
<?php
class mfaclient
{
protected array $aConfig = [];
// protected string $sSessionvarname = "mfaclient";
// protected array $aLastRequest = [];
protected string $sUser = "";
protected bool $bDebug = false;
/**
* Intialize mfa client - optional set config and user
*
* @see setConfig
* @see setUser
*
* @param array $aConfig optional: configuration with app id and base url
* @param string $sUser optional: user id that was logged in
*/
public function __construct(array $aConfig = [], string $sUser = "")
{
$this->loadConfig();
if ($aConfig) {
$this->setConfig($aConfig);
}
if ($sUser) {
$this->setUser($sUser);
}
}
// ----------------------------------------------------------------------
// private methods
// ----------------------------------------------------------------------
/**
* Make an http get request and return the response body
* it is called by _makeRequest
* $aRequest contains subkeys
* - url relative urr; part behind api base url
* - method one of GET|POST|PUT|DELETE
* - postdata for POST only
* - ignore-ssl-error flag: if true it willignores ssl verifiction (not recommended)
* - user, password authentication with "user:password"
*
* @param array $aRequest array with request data
* @param integer $iTimeout timeout in seconds
* @return array ... with subkeys "header" and "body" - or "error" if something went wrong
*/
protected function _httpRequest(array $aRequest = [], int $iTimeout = 5): array
{
if (!function_exists("curl_init")) {
die("ERROR: PHP CURL module is not installed.");
}
// $aConfig = $this->getConfig();
$ch = curl_init($aRequest['url']);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $aRequest['method']);
if ($aRequest['method'] === 'POST') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $aRequest['postdata']);
}
// if ($aConfig['user']) {
// curl_setopt($ch, CURLOPT_USERPWD, $aConfig['user'] . ':' . $aConfig['password']);
// }
// if (isset($aConfig['ignore-ssl-error']) && $aConfig['ignore-ssl-error']) {
// curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
// curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
// }
curl_setopt($ch, CURLOPT_TIMEOUT, $iTimeout);
curl_setopt($ch, CURLOPT_USERAGENT, 'IML MFA client' . __CLASS__);
curl_setopt($ch, CURLOPT_HEADER, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
// $this->log(__METHOD__ . "Start request $sAwxApiUrl");
$res = curl_exec($ch);
if (!$res) {
$iErrorCode = curl_errno($ch);
$sErrorMsg = curl_error($ch);
curl_close($ch);
return [
'error' => "Failed to fetch $aRequest[url] - curl error #$iErrorCode: $sErrorMsg"
];
}
$aReturn = ['info' => curl_getinfo($ch)];
$aReturn = [];
curl_close($ch);
$sHeader = substr($res, 0, curl_getinfo($ch)['header_size']);
$aReturn['header'] = explode("\n", $sHeader);
$aReturn['body'] = str_replace($sHeader, "", $res);
// print_r($aReturn);
return $aReturn;
}
/**
* Generate a HMAC key
*
* @param string $sMethod http method, eg POST
* @param string $sRequest request path
* @param string $sTimestamp timestamp
* @return string
*/
protected function _getToken(string $sMethod, string $sRequest, string $sTimestamp): string
{
return base64_encode(hash_hmac(
"sha1",
"{$sMethod}\n{$sRequest}\n{$sTimestamp}",
$this->aConfig['shared_secret']
));
}
/**
* Make an api call to mfa server
*
* @param string $sAction name of action; one of checks|urls|logout
* @return array of request and response
*/
protected function _api(string $sAction): array
{
// $sTimestamp = date("r");
$sTimestamp = microtime(true);
$sUrl = $this->aConfig['api'] . "/";
$sRequest = parse_url($sUrl, PHP_URL_PATH) . '' . parse_url($sUrl, PHP_URL_QUERY);
$aRequest = [
"url" => $sUrl,
"method" => "POST",
"postdata" => [
"action" => $sAction,
"username" => $this->sUser,
"request" => $sRequest,
"timestamp" => $sTimestamp,
"appid" => $this->aConfig['appid'],
"token" => $this->_getToken("POST", $sRequest, $sTimestamp),
// don't set client ip if gateway ip is needed
"ip" => $this->getClientIp(),
"useragent" => $_SERVER['HTTP_USER_AGENT'] ?? '',
]
];
$aReturn['request'] = $aRequest;
$aReturn = [
'request' => $aRequest,
'response' => $this->_httpRequest($aRequest),
];
return $aReturn;
}
/**
* Generate html code for jump form.
* With it a user can jump from current app to mfa server to setup mfa
* methods or solve a challenge
*
* @param string $sUrl url to jump (mfa server setup page or page to solve challenge)
* @param string $sSubmit html code for a submit button
* @param string $sBackUrl url to return from mfa server to the application
* @param string $sFormId form id
* @return string
*/
public function jumpform(string $sUrl, string $sSubmit = '<button>Follow me</button>', string $sBackUrl = '', string $sFormId = ''): string
{
// $sTimestamp = date("r");
$sTimestamp = microtime(true); // microtime to have more uniqueness on milliseconds
$sRequest = parse_url($sUrl, PHP_URL_PATH) . '?' . parse_url($sUrl, PHP_URL_QUERY);
$sBackUrl = $sBackUrl ?: "http"
. ""
. "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"
;
$sFormId = $sFormId ?: "mfa-form";
$sReturn = "<form method=\"POST\" id=\"$sFormId\" action=\"$sUrl\">
<input type=\"hidden\" name=\"username\" value=\"" . $this->sUser . "\">
<input type=\"hidden\" name=\"appid\" value=\"" . $this->aConfig['appid'] . "\">
<input type=\"hidden\" name=\"ip\" value=\"" . $this->getClientIp() . "\">
<input type=\"hidden\" name=\"request\" value=\"$sRequest\">
<input type=\"hidden\" name=\"timestamp\" value=\"$sTimestamp\">
<input type=\"hidden\" name=\"token\" value=\"" . $this->_getToken("POST", $sRequest, $sTimestamp) . "\">
<input type=\"hidden\" name=\"backurl\" value=\"" . $sBackUrl . "\">
$sSubmit
</form>
";
$this->_wd(__METHOD__ . '<br>Html code of form with jump button:<pre>' . htmlentities($sReturn) . '</pre>');
return $sReturn;
}
/**
* Initiate a smooth direct jump from current app to mfa server to setup mfa
* methods or solve a challenge.
* It uses jumpform() to render a form and adds a javascript for an automatic
* submit.
*
* @see jumpform()
*
* @param string $sUrl url to jump (mfa server setup page or page to solve challenge)
* @param string $sSubmit html code for a submit button
* @param string $sBackUrl url to return from mfa server to the application
* @return string
*/
protected function _jump(string $sUrl, string $sSubmit = '<button>Follow me</button>', string $sBackUrl = ''): string
{
$sFormId = "form-" . md5($sBackUrl);
return $this->jumpform($sUrl, $sSubmit, $sBackUrl, $sFormId)
. ($this->bDebug
? ''
: "<script>
window.onload = function() {
document.getElementById('$sFormId').submit();
}
</script>"
)
;
}
/**
* Write dubug output.
* Debug mode must be enabled first.
* $o->debug(true);
*
* @see debug()
*
* @param string $sMessage
* @return void
*/
protected function _wd(string $sMessage): void
{
if ($this->bDebug) {
echo __CLASS__ . " - DEBUG: $sMessage<br>\n";
}
}
// ----------------------------------------------------------------------
// setters
// ----------------------------------------------------------------------
/**
* Enable or disable debugging
*
* @param bool $bDebug flag: new value for debugging; true = debug enabled
* @return void
*/
public function debug(bool $bDebug): void
{
$this->bDebug = $bDebug;
}
/**
* Load configuration file from current directory
* @return void
*/
public function loadConfig(): void
{
$sCfgfile= __DIR__ . '/mfaconfig.php';
if (file_exists($sCfgfile)) {
$aTmp = include $sCfgfile;
$this->aConfig = $aTmp??[];
$this->setUser($aTmp['user']??'');
}
}
/**
* Apply a given config with app id and base url
*
* @param array $aConfig configuration with app id and base url
* @return void
*/
public function setConfig(array $aConfig): void
{
$this->aConfig = $aConfig;
}
/**
* Set a user id that is logged in
*
* @param string $sUser user id of current user
* @return void
*/
public function setUser(string $sUser)
{
$this->sUser = $sUser;
}
/**
* Logout
* @return void
*/
public function logout()
{
unset($_SESSION['mfa']['user']);
}
// ----------------------------------------------------------------------
// mfa actions
// ----------------------------------------------------------------------
/**
* Show html message and abort to prevent visibility of the app without
* solved mfa
*
* @param int $iHttpStatus http statuscode to set
* @param string $sHtmlcode http body to show
* @return never
*/
public function showHtml(int $iHttpStatus, string $sHtmlcode)
{
if ($this->bDebug) {
echo "Remark: Cannot set http status [$iHttpStatus] because of debug output<hr>";
} else {
http_response_code($iHttpStatus);
}
die('<!doctype html><html>
<head><title>MFA server message</title>
<style>
body{background:#f0f5f8; color: #335; font-size: 1.2em; font-family: Arial, Helvetica, sans-serif;}
a{color: #44c;}
button{border-color: 2px solid #ccc ; border-radius: 0.5em; padding: 0.7em;}
div{background:#fff; border-radius: 1em; box-shadow: 0 0 1em #ccc; margin: 4em auto; max-width: 600px; padding: 2em;}
h1{margin: 0 0 1em;;}
</style></head>
<body><div>' . $sHtmlcode . '</div></body>
</html>');
}
/**
* Check MFA server api about user status
*
* @return array
*/
public function check(): array
{
return $this->_api("check");
}
/**
* Check if MFA login is needed and jump to its url
* @return int
*/
public function ensure(): int
{
if (!isset($_SESSION) || !count($_SESSION)) {
session_start();
}
if (($_SESSION['mfa']['user'] ?? '') == $this->sUser) {
return 200;
} else {
$this->logout();
}
$aMfaReturn = $this->check();
$this->_wd(__METHOD__ . "<br>Http request to mfa api<pre>" . print_r($aMfaReturn, 1) . "</pre>");
$aBody = json_decode($aMfaReturn['response']['body'] ?? '', 1);
$iHttpStatus = $aBody['status'] ?? -1;
if ($iHttpStatus == 401) {
$this->showHtml(
$iHttpStatus,
"<h1>MFA server</h1>"
. "⚠️ " . $aBody['message'] . '<br><br>'
. $this->_jump($aBody['url'], '<button>Follow me</button>', )
);
}
if ($iHttpStatus != 200) {
$this->showHtml(
$iHttpStatus,
"<h1>MFA server - Error $iHttpStatus</h1>"
. "❌ <strong>" . ($aBody['error'] ?? 'Invalid API response') . "</strong><br>"
. ($aBody['message'] ?? 'No valid JSON response was sent back.') . '<br>'
. ($aMfaReturn['response']['header'][0] ?? '') . '<br>'
. (($aMfaReturn['response']['error'] ?? '') ? '<br><strong>Curl error:</strong><br>' . $aMfaReturn['response']['error'] . '<br>' : '')
. '<br><br><a href="">Try again</a>'
//.'<br><pre>'.print_r($aMfaReturn, 1).'</pre>'
);
}
$_SESSION['mfa']['user'] = $this->sUser;
session_write_close();
return $iHttpStatus;
}
/**
* Get an html button to open mfa setup page
*
* @param string $sSubmitBtn
* @return void
*/
public function getButtonSetup(string $sSubmitBtn = '<button>MFA Setup</button>', $sBackUrl = ''): string
{
$aBody = json_decode($this->_api("urls")['response']['body'], 1);
// print_r($aBody);
$sUrl = $aBody['setup'] ?? '';
if ($sUrl) {
$sBackUrl = $sBackUrl ?: $_SERVER['HTTP_REFERER'];
return $this->jumpform($sUrl, $sSubmitBtn, $sBackUrl);
} else {
return $aBody['message']??'';
}
}
/**
* Open User settings to setup mfa methods
*
* @param string $sUrl
* @param string $sSubmitBtn
* @return void
*/
public function openSetup(string $sUrl = '', string $sSubmitBtn = '<button>MFA Setup</button>', $sBackUrl = '')
{
if (!$sUrl) {
$aBody = json_decode($this->_api("urls")['response']['body'], 1);
$sUrl = $aBody['setup'] ?? '';
}
if ($sUrl) {
$sBackUrl = $sBackUrl ?: $_SERVER['HTTP_REFERER'];
$this->_jump($sUrl, $sSubmitBtn, $sBackUrl);
}
}
/**
* Get IP of current client (to be sent to MFA server)
* @return string
*/
public function getClientIp(): string
{
$ipaddress = '';
foreach([
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
'REMOTE_HOST'
] as $sKey){
if (getenv($sKey))
$ipaddress = getenv($sKey);
}
if (!$ipaddress) {
$ipaddress = 'UNKNOWN';
}
return $ipaddress;
}
/**
* get list of urls from MFA server
*
* @return array
*/
public function getUrls(): array
{
return $this->_api("urls");
}
}
<?php
return [
"api" => "http://127.0.0.1/api",
"appid" => "1d1c2c903eec9b58d2d7542a87eae7a1",
"shared_secret" => "8iPDtY4LDOnU9UU9oxI5q2eLX4Sx6tmP",
"user"=> $_SERVER['REMOTE_USER']??'',
"debug" => false,
];
\ No newline at end of file
<?php
return [
"api" => "https://mfa.example.com/api/",
"appid" => "c1cabd22fbdb698861ad08b27de7399a",
"shared_secret" => "p9wjjXSewZq0VkM1t5Sm3ZbI4ATEVetU",
"debug" => false,
];
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment