Merge pull request #5253 from rabuzarus/20180616_-_magic_auth_test_2

Port hubzillas OpenWebAuth - remote authentification
This commit is contained in:
Hypolite Petovan 2018-06-20 17:46:04 -04:00 committed by GitHub
commit a5550b4702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1196 additions and 50 deletions

View file

@ -41,7 +41,7 @@ define('FRIENDICA_PLATFORM', 'Friendica');
define('FRIENDICA_CODENAME', 'The Tazmans Flax-lily'); define('FRIENDICA_CODENAME', 'The Tazmans Flax-lily');
define('FRIENDICA_VERSION', '2018.08-dev'); define('FRIENDICA_VERSION', '2018.08-dev');
define('DFRN_PROTOCOL_VERSION', '2.23'); define('DFRN_PROTOCOL_VERSION', '2.23');
define('DB_UPDATE_VERSION', 1268); define('DB_UPDATE_VERSION', 1269);
define('NEW_UPDATE_ROUTINE_VERSION', 1170); define('NEW_UPDATE_ROUTINE_VERSION', 1170);
/** /**

View file

@ -1,6 +1,6 @@
-- ------------------------------------------ -- ------------------------------------------
-- Friendica 2018.08-dev (The Tazmans Flax-lily) -- Friendica 2018.08-dev (The Tazmans Flax-lily)
-- DB_UPDATE_VERSION 1268 -- DB_UPDATE_VERSION 1269
-- ------------------------------------------ -- ------------------------------------------
@ -375,7 +375,7 @@ CREATE TABLE IF NOT EXISTS `group` (
CREATE TABLE IF NOT EXISTS `group_member` ( CREATE TABLE IF NOT EXISTS `group_member` (
`id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID', `id` int unsigned NOT NULL auto_increment COMMENT 'sequential ID',
`gid` int unsigned NOT NULL DEFAULT 0 COMMENT 'groups.id of the associated group', `gid` int unsigned NOT NULL DEFAULT 0 COMMENT 'groups.id of the associated group',
`contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id of the member assigned to the associated group', `contact-id` int unsigned NOT NULL DEFAULT 0 COMMENT 'contact.id of the member assigned to the associated group',
PRIMARY KEY(`id`), PRIMARY KEY(`id`),
INDEX `contactid` (`contact-id`), INDEX `contactid` (`contact-id`),
UNIQUE INDEX `gid_contactid` (`gid`,`contact-id`) UNIQUE INDEX `gid_contactid` (`gid`,`contact-id`)
@ -1084,6 +1084,19 @@ CREATE TABLE IF NOT EXISTS `user-item` (
PRIMARY KEY(`uid`,`iid`) PRIMARY KEY(`uid`,`iid`)
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific item data'; ) DEFAULT COLLATE utf8mb4_general_ci COMMENT='User specific item data';
--
-- TABLE openwebauth-token
--
CREATE TABLE IF NOT EXISTS `openwebauth-token` (
`id` int(10) NOT NULL auto_increment COMMENT 'sequential ID',
`uid` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'User id',
`type` varchar(32) DEFAULT '' COMMENT 'Verify type',
`token` varchar(255) DEFAULT '' COMMENT 'A generated token',
`meta` varchar(255) DEFAULT '' COMMENT '',
`created` datetime NOT NULL DEFAULT '0001-01-01 00:00:00' COMMENT 'datetime of creation',
PRIMARY KEY(`id`)
) DEFAULT COLLATE utf8mb4_general_ci COMMENT='Store OpenWebAuth token to verify contacts';
-- --
-- TABLE worker-ipc -- TABLE worker-ipc
-- --

View file

@ -357,6 +357,12 @@ Hook data:
'item' => item array (input) 'item' => item array (input)
'html' => converted item body (input/output) 'html' => converted item body (input/output)
### 'magic_auth_success'
Called when a magic-auth was successful.
Hook data:
'visitor' => array with the contact record of the visitor
'url' => the query string
Current JavaScript hooks Current JavaScript hooks
------------- -------------
@ -557,6 +563,7 @@ Here is a complete list of all hook callbacks with file locations (as of 01-Apr-
Addon::callHooks('profile_sidebar', $arr); Addon::callHooks('profile_sidebar', $arr);
Addon::callHooks('profile_tabs', $arr); Addon::callHooks('profile_tabs', $arr);
Addon::callHooks('zrl_init', $arr); Addon::callHooks('zrl_init', $arr);
Addon::callHooks('magic_auth_success', $arr);
### src/Model/Event.php ### src/Model/Event.php

View file

@ -121,25 +121,35 @@ if ((x($_SESSION, 'language')) && ($_SESSION['language'] !== $lang)) {
L10n::loadTranslationTable($lang); L10n::loadTranslationTable($lang);
} }
if ((x($_GET, 'zrl')) && $a->mode == App::MODE_NORMAL) { if ((x($_GET,'zrl')) && $a->mode == App::MODE_NORMAL) {
// Only continue when the given profile link seems valid $a->query_string = Profile::stripZrls($a->query_string);
// Valid profile links contain a path with "/profile/" and no query parameters if (!local_user()) {
if ((parse_url($_GET['zrl'], PHP_URL_QUERY) == "") // Only continue when the given profile link seems valid
&& strstr(parse_url($_GET['zrl'], PHP_URL_PATH), "/profile/") // Valid profile links contain a path with "/profile/" and no query parameters
) { if ((parse_url($_GET['zrl'], PHP_URL_QUERY) == "") &&
$_SESSION['my_url'] = $_GET['zrl']; strstr(parse_url($_GET['zrl'], PHP_URL_PATH), "/profile/")) {
$a->query_string = preg_replace('/[\?&]zrl=(.*?)([\?&]|$)/is', '', $a->query_string); if ($_SESSION["visitor_home"] != $_GET["zrl"]) {
Profile::zrlInit($a); $_SESSION['my_url'] = $_GET['zrl'];
} else { $_SESSION['authenticated'] = 0;
// Someone came with an invalid parameter, maybe as a DDoS attempt }
// We simply stop processing here Profile::zrlInit($a);
logger("Invalid ZRL parameter ".$_GET['zrl'], LOGGER_DEBUG); } else {
header('HTTP/1.1 403 Forbidden'); // Someone came with an invalid parameter, maybe as a DDoS attempt
echo "<h1>403 Forbidden</h1>"; // We simply stop processing here
killme(); logger("Invalid ZRL parameter " . $_GET['zrl'], LOGGER_DEBUG);
header('HTTP/1.1 403 Forbidden');
echo "<h1>403 Forbidden</h1>";
killme();
}
} }
} }
if ((x($_GET,'owt')) && $a->mode == App::MODE_NORMAL) {
$token = $_GET['owt'];
$a->query_string = Profile::stripQueryParam($a->query_string, 'owt');
Profile::openWebAuthInit($token);
}
/** /**
* For Mozilla auth manager - still needs sorting, and this might conflict with LRDD header. * For Mozilla auth manager - still needs sorting, and this might conflict with LRDD header.
* Apache/PHP lumps the Link: headers into one - and other services might not be able to parse it * Apache/PHP lumps the Link: headers into one - and other services might not be able to parse it

View file

@ -66,20 +66,23 @@ function xrd_json($a, $uri, $alias, $profile_url, $r)
header("Content-type: application/json; charset=utf-8"); header("Content-type: application/json; charset=utf-8");
$json = ['subject' => $uri, $json = ['subject' => $uri,
'aliases' => [$alias, $profile_url], 'aliases' => [$alias, $profile_url],
'links' => [['rel' => NAMESPACE_DFRN, 'href' => $profile_url], 'links' => [
['rel' => NAMESPACE_FEED, 'type' => 'application/atom+xml', 'href' => System::baseUrl().'/dfrn_poll/'.$r['nickname']], ['rel' => NAMESPACE_DFRN, 'href' => $profile_url],
['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $profile_url], ['rel' => NAMESPACE_FEED, 'type' => 'application/atom+xml', 'href' => System::baseUrl().'/dfrn_poll/'.$r['nickname']],
['rel' => 'http://microformats.org/profile/hcard', 'type' => 'text/html', 'href' => System::baseUrl().'/hcard/'.$r['nickname']], ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $profile_url],
['rel' => NAMESPACE_POCO, 'href' => System::baseUrl().'/poco/'.$r['nickname']], ['rel' => 'http://microformats.org/profile/hcard', 'type' => 'text/html', 'href' => System::baseUrl().'/hcard/'.$r['nickname']],
['rel' => 'http://webfinger.net/rel/avatar', 'type' => 'image/jpeg', 'href' => System::baseUrl().'/photo/profile/'.$r['uid'].'.jpg'], ['rel' => NAMESPACE_POCO, 'href' => System::baseUrl().'/poco/'.$r['nickname']],
['rel' => 'http://joindiaspora.com/seed_location', 'type' => 'text/html', 'href' => System::baseUrl()], ['rel' => 'http://webfinger.net/rel/avatar', 'type' => 'image/jpeg', 'href' => System::baseUrl().'/photo/profile/'.$r['uid'].'.jpg'],
['rel' => 'salmon', 'href' => System::baseUrl().'/salmon/'.$r['nickname']], ['rel' => 'http://joindiaspora.com/seed_location', 'type' => 'text/html', 'href' => System::baseUrl()],
['rel' => 'http://salmon-protocol.org/ns/salmon-replies', 'href' => System::baseUrl().'/salmon/'.$r['nickname']], ['rel' => 'salmon', 'href' => System::baseUrl().'/salmon/'.$r['nickname']],
['rel' => 'http://salmon-protocol.org/ns/salmon-mention', 'href' => System::baseUrl().'/salmon/'.$r['nickname'].'/mention'], ['rel' => 'http://salmon-protocol.org/ns/salmon-replies', 'href' => System::baseUrl().'/salmon/'.$r['nickname']],
['rel' => 'http://ostatus.org/schema/1.0/subscribe', 'template' => System::baseUrl().'/follow?url={uri}'], ['rel' => 'http://salmon-protocol.org/ns/salmon-mention', 'href' => System::baseUrl().'/salmon/'.$r['nickname'].'/mention'],
['rel' => 'magic-public-key', 'href' => 'data:application/magic-public-key,'.$salmon_key] ['rel' => 'http://ostatus.org/schema/1.0/subscribe', 'template' => System::baseUrl().'/follow?url={uri}'],
]]; ['rel' => 'magic-public-key', 'href' => 'data:application/magic-public-key,'.$salmon_key],
['rel' => 'http://purl.org/openwebauth/v1', 'type' => 'application/x-dfrn+json', 'href' => System::baseUrl().'/owa']
]
];
echo json_encode($json); echo json_encode($json);
killme(); killme();
} }
@ -102,10 +105,11 @@ function xrd_xml($a, $uri, $alias, $profile_url, $r)
'$atom' => System::baseUrl() . '/dfrn_poll/' . $r['nickname'], '$atom' => System::baseUrl() . '/dfrn_poll/' . $r['nickname'],
'$poco_url' => System::baseUrl() . '/poco/' . $r['nickname'], '$poco_url' => System::baseUrl() . '/poco/' . $r['nickname'],
'$photo' => System::baseUrl() . '/photo/profile/' . $r['uid'] . '.jpg', '$photo' => System::baseUrl() . '/photo/profile/' . $r['uid'] . '.jpg',
'$baseurl' => System::baseUrl(), '$baseurl' => System::baseUrl(),
'$salmon' => System::baseUrl() . '/salmon/' . $r['nickname'], '$salmon' => System::baseUrl() . '/salmon/' . $r['nickname'],
'$salmen' => System::baseUrl() . '/salmon/' . $r['nickname'] . '/mention', '$salmen' => System::baseUrl() . '/salmon/' . $r['nickname'] . '/mention',
'$subscribe' => System::baseUrl() . '/follow?url={uri}', '$subscribe' => System::baseUrl() . '/follow?url={uri}',
'$openwebauth' => System::baseUrl() . '/owa',
'$modexp' => 'data:application/magic-public-key,' . $salmon_key] '$modexp' => 'data:application/magic-public-key,' . $salmon_key]
); );

View file

@ -163,17 +163,17 @@ EOT;
} }
/** /**
* @brief Encodes content to json * @brief Encodes content to json.
* *
* This function encodes an array to json format * This function encodes an array to json format
* and adds an application/json HTTP header to the output. * and adds an application/json HTTP header to the output.
* After finishing the process is getting killed. * After finishing the process is getting killed.
* *
* @param array $x The input content * @param array $x The input content.
* @param string $content_type Type of the input (Default: 'application/json').
*/ */
public static function jsonExit($x) public static function jsonExit($x, $content_type = 'application/json') {
{ header("Content-type: $content_type");
header("content-type: application/json");
echo json_encode($x); echo json_encode($x);
killme(); killme();
} }

View file

@ -1818,6 +1818,20 @@ class DBStructure
"PRIMARY" => ["uid", "iid"], "PRIMARY" => ["uid", "iid"],
] ]
]; ];
$database["openwebauth-token"] = [
"comment" => "Store OpenWebAuth token to verify contacts",
"fields" => [
"id" => ["type" => "int(10)", "not null" => "1", "extra" => "auto_increment", "primary" => "1", "comment" => "sequential ID"],
"uid" => ["type" => "int(10) unsigned", "not null" => "1", "default" => "0", "relation" => ["user" => "uid"], "comment" => "User id"],
"type" => ["type" => "varchar(32)", "not_null", "default" => "", "comment" => "Verify type"],
"token" => ["type" => "varchar(255)", "not_null" => "1", "default" => "", "comment" => "A generated token"],
"meta" => ["type" => "varchar(255)", "not_null" => "1", "default" => "", "comment" => ""],
"created" => ["type" => "datetime", "not null" => "1", "default" => NULL_DATE, "comment" => "datetime of creation"],
],
"indexes" => [
"PRIMARY" => ["id"],
]
];
$database["worker-ipc"] = [ $database["worker-ipc"] = [
"comment" => "Inter process communication between the frontend and the worker", "comment" => "Inter process communication between the frontend and the worker",
"fields" => [ "fields" => [

View file

@ -0,0 +1,73 @@
<?php
/**
* @file src/Model/OpenWebAuthToken.php
*/
namespace Friendica\Model;
use Friendica\Database\DBM;
use Friendica\Util\DateTimeFormat;
use dba;
/**
* Methods to deal with entries of the 'openwebauth-token' table.
*/
class OpenWebAuthToken
{
/**
* Create an entry in the 'openwebauth-token' table.
*
* @param string $type Verify type.
* @param int $uid The user ID.
* @param string $token
* @param string $meta
*
* @return boolean
*/
public static function create($type, $uid, $token, $meta)
{
$fields = [
"type" => $type,
"uid" => $uid,
"token" => $token,
"meta" => $meta,
"created" => DateTimeFormat::utcNow()
];
return dba::insert("openwebauth-token", $fields);
}
/**
* Get the "meta" field of an entry in the openwebauth-token table.
*
* @param string $type Verify type.
* @param int $uid The user ID.
* @param string $token
*
* @return string|boolean The meta enry or false if not found.
*/
public static function getMeta($type, $uid, $token)
{
$condition = ["type" => $type, "uid" => $uid, "token" => $token];
$entry = dba::selectFirst("openwebauth-token", ["id", "meta"], $condition);
if (DBM::is_result($entry)) {
dba::delete("openwebauth-token", ["id" => $entry["id"]]);
return $entry["meta"];
}
return false;
}
/**
* Purge entries of a verify-type older than interval.
*
* @param string $type Verify type.
* @param string $interval SQL compatible time interval
*/
public static function purge($type, $interval)
{
$condition = ["`type` = ? AND `created` < ?", $type, DateTimeFormat::utcNow() . " - INTERVAL " . $interval];
dba::delete("openwebauth-token", $condition);
}
}

View file

@ -17,6 +17,7 @@ use Friendica\Core\System;
use Friendica\Core\Worker; use Friendica\Core\Worker;
use Friendica\Database\DBM; use Friendica\Database\DBM;
use Friendica\Model\Contact; use Friendica\Model\Contact;
use Friendica\Model\OpenWebAuthToken;
use Friendica\Protocol\Diaspora; use Friendica\Protocol\Diaspora;
use Friendica\Util\DateTimeFormat; use Friendica\Util\DateTimeFormat;
use Friendica\Util\Network; use Friendica\Util\Network;
@ -978,27 +979,128 @@ class Profile
return null; return null;
} }
/**
* Process the 'zrl' parameter and initiate the remote authentication.
*
* This method checks if the visitor has a public contact entry and
* redirects the visitor to his/her instance to start the magic auth (Authentication)
* process.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/channel.php
*
* @param App $a Application instance.
*/
public static function zrlInit(App $a) public static function zrlInit(App $a)
{ {
$my_url = self::getMyURL(); $my_url = self::getMyURL();
$my_url = Network::isUrlValid($my_url); $my_url = Network::isUrlValid($my_url);
if ($my_url) { if ($my_url) {
// Is it a DDoS attempt? if (!local_user()) {
// The check fetches the cached value from gprobe to reduce the load for this system // Is it a DDoS attempt?
$urlparts = parse_url($my_url); // The check fetches the cached value from gprobe to reduce the load for this system
$urlparts = parse_url($my_url);
$result = Cache::get('gprobe:' . $urlparts['host']); $result = Cache::get('gprobe:' . $urlparts['host']);
if ((!is_null($result)) && (in_array($result['network'], [NETWORK_FEED, NETWORK_PHANTOM]))) { if ((!is_null($result)) && (in_array($result['network'], [NETWORK_FEED, NETWORK_PHANTOM]))) {
logger('DDoS attempt detected for ' . $urlparts['host'] . ' by ' . $_SERVER['REMOTE_ADDR'] . '. server data: ' . print_r($_SERVER, true), LOGGER_DEBUG); logger('DDoS attempt detected for ' . $urlparts['host'] . ' by ' . $_SERVER['REMOTE_ADDR'] . '. server data: ' . print_r($_SERVER, true), LOGGER_DEBUG);
return; return;
}
Worker::add(PRIORITY_LOW, 'GProbe', $my_url);
$arr = ['zrl' => $my_url, 'url' => $a->cmd];
Addon::callHooks('zrl_init', $arr);
// Try to find the public contact entry of the visitor.
$cid = Contact::getIdForURL($my_url);
if (!$cid) {
logger('No contact record found for ' . $my_url, LOGGER_DEBUG);
return;
}
$contact = dba::selectFirst('contact',['id', 'url'], ['id' => $cid]);
if (DBM::is_result($contact) && remote_user() && remote_user() === $contact['id']) {
// The visitor is already authenticated.
return;
}
logger('Not authenticated. Invoking reverse magic-auth for ' . $my_url, LOGGER_DEBUG);
// Try to avoid recursion - but send them home to do a proper magic auth.
$query = str_replace(array('?zrl=', '&zid='), array('?rzrl=', '&rzrl='), $a->query_string);
// The other instance needs to know where to redirect.
$dest = urlencode(System::baseUrl() . '/' . $query);
// We need to extract the basebath from the profile url
// to redirect the visitors '/magic' module.
// Note: We should have the basepath of a contact also in the contact table.
$urlarr = explode('/profile/', $contact['url']);
$basepath = $urlarr[0];
if ($basepath != System::baseUrl() && !strstr($dest, '/magic') && !strstr($dest, '/rmagic')) {
goaway($basepath . '/magic' . '?f=&owa=1&dest=' . $dest);
}
} }
Worker::add(PRIORITY_LOW, 'GProbe', $my_url);
$arr = ['zrl' => $my_url, 'url' => $a->cmd];
Addon::callHooks('zrl_init', $arr);
} }
} }
/**
* OpenWebAuth authentication.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/zid.php
*
* @param string $token
*/
public static function openWebAuthInit($token)
{
$a = get_app();
// Clean old OpenWebAuthToken entries.
OpenWebAuthToken::purge('owt', '3 MINUTE');
// Check if the token we got is the same one
// we have stored in the database.
$visitor_handle = OpenWebAuthToken::getMeta('owt', 0, $token);
if($visitor_handle === false) {
return;
}
// Try to find the public contact entry of the visitor.
$cid = Contact::getIdForURL($visitor_handle);
if(!$cid) {
logger('owt: unable to finger ' . $visitor_handle, LOGGER_DEBUG);
return;
}
$visitor = dba::selectFirst('contact', [], ['id' => $cid]);
// Authenticate the visitor.
$_SESSION['authenticated'] = 1;
$_SESSION['visitor_id'] = $visitor['id'];
$_SESSION['visitor_handle'] = $visitor['addr'];
$_SESSION['visitor_home'] = $visitor['url'];
$arr = [
'visitor' => $visitor,
'url' => $a->query_string
];
/**
* @hooks magic_auth_success
* Called when a magic-auth was successful.
* * \e array \b visitor
* * \e string \b url
*/
Addon::callHooks('magic_auth_success', $arr);
$a->contact = $arr['visitor'];
info(L10n::t('OpenWebAuth: %1$s welcomes %2$s', $a->get_hostname(), $visitor['name']));
logger('OpenWebAuth: auth success from ' . $visitor['addr'], LOGGER_DEBUG);
}
public static function zrl($s, $force = false) public static function zrl($s, $force = false)
{ {
if (!strlen($s)) { if (!strlen($s)) {
@ -1042,4 +1144,26 @@ class Profile
return $uid; return $uid;
} }
/**
* Stip zrl parameter from a string.
*
* @param string $s The input string.
* @return string The zrl.
*/
public static function stripZrls($s)
{
return preg_replace('/[\?&]zrl=(.*?)([\?&]|$)/is', '', $s);
}
/**
* Stip query parameter from a string.
*
* @param string $s The input string.
* @return string The query parameter.
*/
public static function stripQueryParam($s, $param)
{
return preg_replace('/[\?&]' . $param . '=(.*?)(&|$)/ism', '$2', $s);
}
} }

121
src/Module/Magic.php Normal file
View file

@ -0,0 +1,121 @@
<?php
/**
* @file src/Module/Magic.php
*/
namespace Friendica\Module;
use Friendica\BaseModule;
use Friendica\Model\Contact;
use Friendica\Util\HTTPSignature;
use Friendica\Util\Network;
use dba;
/**
* Magic Auth (remote authentication) module.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/Zotlabs/Module/Magic.php
*/
class Magic extends BaseModule
{
public static function init()
{
$a = self::getApp();
$ret = ['success' => false, 'url' => '', 'message' => ''];
logger('magic mdule: invoked', LOGGER_DEBUG);
logger('args: ' . print_r($_REQUEST, true), LOGGER_DATA);
$addr = ((x($_REQUEST, 'addr')) ? $_REQUEST['addr'] : '');
$dest = ((x($_REQUEST, 'dest')) ? $_REQUEST['dest'] : '');
$test = ((x($_REQUEST, 'test')) ? intval($_REQUEST['test']) : 0);
$owa = ((x($_REQUEST, 'owa')) ? intval($_REQUEST['owa']) : 0);
// NOTE: I guess $dest isn't just the profile url (could be also
// other profile pages e.g. photo). We need to find a solution
// to be able to redirct to other pages than the contact profile.
$cid = Contact::getIdForURL($dest);
if (!$cid && !empty($addr)) {
$cid = Contact::getIdForURL($addr);
}
if (!$cid) {
logger('No contact record found: ' . print_r($_REQUEST, true), LOGGER_DEBUG);
goaway($dest);
}
$contact = dba::selectFirst('contact', ['id', 'nurl', 'url'], ['id' => $cid]);
// Redirect if the contact is already authenticated on this site.
if (array_key_exists('id', $a->contact) && strpos($contact['nurl'], normalise_link(self::getApp()->get_baseurl())) !== false) {
if($test) {
$ret['success'] = true;
$ret['message'] .= 'Local site - you are already authenticated.' . EOL;
return $ret;
}
logger('Contact is already authenticated', LOGGER_DEBUG);
goaway($dest);
}
if (local_user()) {
$user = $a->user;
// OpenWebAuth
if ($owa) {
// Extract the basepath
// NOTE: we need another solution because this does only work
// for friendica contacts :-/ . We should have the basepath
// of a contact also in the contact table.
$exp = explode('/profile/', $contact['url']);
$basepath = $exp[0];
$headers = [];
$headers['Accept'] = 'application/x-dfrn+json';
$headers['X-Open-Web-Auth'] = random_string();
// Create a header that is signed with the local users private key.
$headers = HTTPSignature::createSig(
'',
$headers,
$user['prvkey'],
'acct:' . $user['nickname'] . '@' . $a->get_hostname() . ($a->path ? '/' . $a->path : ''),
false,
true,
'sha512'
);
// Try to get an authentication token from the other instance.
$x = Network::curl($basepath . '/owa', false, $redirects, ['headers' => $headers]);
if ($x['success']) {
$j = json_decode($x['body'], true);
if ($j['success']) {
$token = '';
if ($j['encrypted_token']) {
// The token is encrypted. If the local user is really the one the other instance
// thinks he/she is, the token can be decrypted with the local users public key.
openssl_private_decrypt(base64url_decode($j['encrypted_token']), $token, $user['prvkey']);
} else {
$token = $j['token'];
}
$x = strpbrk($dest, '?&');
$args = (($x) ? '&owt=' . $token : '?f=&owt=' . $token);
goaway($dest . $args);
}
}
goaway($dest);
}
}
if($test) {
$ret['message'] = 'Not authenticated or invalid arguments' . EOL;
return $ret;
}
goaway($dest);
}
}

91
src/Module/Owa.php Normal file
View file

@ -0,0 +1,91 @@
<?php
/**
* @file src/Module/Owa.php
*/
namespace Friendica\Module;
use Friendica\BaseModule;
use Friendica\Core\System;
use Friendica\Database\DBM;
use Friendica\Model\Contact;
use Friendica\Model\OpenWebAuthToken;
use Friendica\Util\HTTPSignature;
use dba;
/**
* @brief OpenWebAuth verifier and token generator
*
* See https://macgirvin.com/wiki/mike/OpenWebAuth/Home
* Requests to this endpoint should be signed using HTTP Signatures
* using the 'Authorization: Signature' authentication method
* If the signature verifies a token is returned.
*
* This token may be exchanged for an authenticated cookie.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/Zotlabs/Module/Owa.php
*/
class Owa extends BaseModule
{
public static function init()
{
$ret = [ 'success' => false ];
foreach (['REDIRECT_REMOTE_USER', 'HTTP_AUTHORIZATION'] as $head) {
if (array_key_exists($head, $_SERVER) && substr(trim($_SERVER[$head]), 0, 9) === 'Signature') {
if ($head !== 'HTTP_AUTHORIZATION') {
$_SERVER['HTTP_AUTHORIZATION'] = $_SERVER[$head];
continue;
}
$sigblock = HTTPSignature::parseSigheader($_SERVER[$head]);
if ($sigblock) {
$keyId = $sigblock['keyId'];
if ($keyId) {
// Try to find the public contact entry of the handle.
$handle = str_replace('acct:', '', $keyId);
$cid = Contact::getIdForURL($handle);
$fields = ['id', 'url', 'addr', 'pubkey'];
$condition = ['id' => $cid];
$contact = dba::selectFirst('contact', $fields, $condition);
if (DBM::is_result($contact)) {
// Try to verify the signed header with the public key of the contact record
// we have found.
$verified = HTTPSignature::verify('', $contact['pubkey']);
if ($verified && $verified['header_signed'] && $verified['header_valid']) {
logger('OWA header: ' . print_r($verified, true), LOGGER_DATA);
logger('OWA success: ' . $contact['addr'], LOGGER_DATA);
$ret['success'] = true;
$token = random_string(32);
// Store the generated token in the databe.
OpenWebAuthToken::create('owt', 0, $token, $contact['addr']);
$result = '';
// Encrypt the token with the public contacts publik key.
// Only the specific public contact will be able to encrypt it.
// At a later time, we will compare weather the token we're getting
// is really the same token we have stored in the database.
openssl_public_encrypt($token, $result, $contact['pubkey']);
$ret['encrypted_token'] = base64url_encode($result);
} else {
logger('OWA fail: ' . $contact['id'] . ' ' . $contact['addr'] . ' ' . $contact['url'], LOGGER_DEBUG);
}
} else {
logger('Contact not found: ' . $handle, LOGGER_DEBUG);
}
}
}
}
}
System::jsonExit($ret, 'application/x-dfrn+json');
}
}

View file

@ -4,6 +4,7 @@
*/ */
namespace Friendica\Util; namespace Friendica\Util;
use Friendica\Core\Addon;
use Friendica\Core\Config; use Friendica\Core\Config;
use ASN_BASE; use ASN_BASE;
use ASNValue; use ASNValue;
@ -246,4 +247,232 @@ class Crypto
return $response; return $response;
} }
/**
* Encrypt a string with 'aes-256-cbc' cipher method.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $key The key used for encryption.
* @param string $iv A non-NULL Initialization Vector.
*
* @return string|boolean Encrypted string or false on failure.
*/
private static function encryptAES256CBC($data, $key, $iv)
{
return openssl_encrypt($data, 'aes-256-cbc', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0"));
}
/**
* Decrypt a string with 'aes-256-cbc' cipher method.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $key The key used for decryption.
* @param string $iv A non-NULL Initialization Vector.
*
* @return string|boolean Decrypted string or false on failure.
*/
private static function decryptAES256CBC($data, $key, $iv)
{
return openssl_decrypt($data, 'aes-256-cbc', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0"));
}
/**
* Encrypt a string with 'aes-256-ctr' cipher method.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $key The key used for encryption.
* @param string $iv A non-NULL Initialization Vector.
*
* @return string|boolean Encrypted string or false on failure.
*/
private static function encryptAES256CTR($data, $key, $iv)
{
$key = substr($key, 0, 32);
$iv = substr($iv, 0, 16);
return openssl_encrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0"));
}
/**
* Decrypt a string with 'aes-256-ctr' cipher method.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $key The key used for decryption.
* @param string $iv A non-NULL Initialization Vector.
*
* @return string|boolean Decrypted string or false on failure.
*/
private static function decryptAES256CTR($data, $key, $iv)
{
$key = substr($key, 0, 32);
$iv = substr($iv, 0, 16);
return openssl_decrypt($data, 'aes-256-ctr', str_pad($key, 32, "\0"), OPENSSL_RAW_DATA, str_pad($iv, 16, "\0"));
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $pubkey The public key.
* @param string $alg The algorithm used for encryption.
*
* @return array
*/
public static function encapsulate($data, $pubkey, $alg = 'aes256cbc')
{
if ($alg === 'aes256cbc') {
return self::encapsulateAes($data, $pubkey);
}
return self::encapsulateOther($data, $pubkey, $alg);
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param type $data
* @param type $pubkey The public key.
* @param type $alg The algorithm used for encryption.
*
* @return array
*/
private static function encapsulateOther($data, $pubkey, $alg)
{
if (!$pubkey) {
logger('no key. data: '.$data);
}
$fn = 'encrypt' . strtoupper($alg);
if (method_exists(__CLASS__, $fn)) {
$result = ['encrypted' => true];
$key = random_bytes(256);
$iv = random_bytes(256);
$result['data'] = base64url_encode(self::$fn($data, $key, $iv), true);
// log the offending call so we can track it down
if (!openssl_public_encrypt($key, $k, $pubkey)) {
$x = debug_backtrace();
logger('RSA failed. ' . print_r($x[0], true));
}
$result['alg'] = $alg;
$result['key'] = base64url_encode($k, true);
openssl_public_encrypt($iv, $i, $pubkey);
$result['iv'] = base64url_encode($i, true);
return $result;
} else {
$x = ['data' => $data, 'pubkey' => $pubkey, 'alg' => $alg, 'result' => $data];
Addon::callHooks('other_encapsulate', $x);
return $x['result'];
}
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $pubkey
*
* @return array
*/
private static function encapsulateAes($data, $pubkey)
{
if (!$pubkey) {
logger('aes_encapsulate: no key. data: ' . $data);
}
$key = random_bytes(32);
$iv = random_bytes(16);
$result = ['encrypted' => true];
$result['data'] = base64url_encode(self::encryptAES256CBC($data, $key, $iv), true);
// log the offending call so we can track it down
if (!openssl_public_encrypt($key, $k, $pubkey)) {
$x = debug_backtrace();
logger('aes_encapsulate: RSA failed. ' . print_r($x[0], true));
}
$result['alg'] = 'aes256cbc';
$result['key'] = base64url_encode($k, true);
openssl_public_encrypt($iv, $i, $pubkey);
$result['iv'] = base64url_encode($i, true);
return $result;
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $prvkey The private key used for decryption.
*
* @return string|boolean The decrypted string or false on failure.
*/
public static function unencapsulate($data, $prvkey)
{
if (!$data) {
return;
}
$alg = ((array_key_exists('alg', $data)) ? $data['alg'] : 'aes256cbc');
if ($alg === 'aes256cbc') {
return self::encapsulateAes($data, $prvkey);
}
return self::encapsulateOther($data, $prvkey, $alg);
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param string $data
* @param string $prvkey The private key used for decryption.
* @param string $alg
*
* @return string|boolean The decrypted string or false on failure.
*/
private static function unencapsulateOther($data, $prvkey, $alg)
{
$fn = 'decrypt' . strtoupper($alg);
if (method_exists(__CLASS__, $fn)) {
openssl_private_decrypt(base64url_decode($data['key']), $k, $prvkey);
openssl_private_decrypt(base64url_decode($data['iv']), $i, $prvkey);
return self::$fn(base64url_decode($data['data']), $k, $i);
} else {
$x = ['data' => $data, 'prvkey' => $prvkey, 'alg' => $alg, 'result' => $data];
Addon::callHooks('other_unencapsulate', $x);
return $x['result'];
}
}
/**
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/include/crypto.php
*
* @param array $data
* @param string $prvkey The private key used for decryption.
*
* @return string|boolean The decrypted string or false on failure.
*/
private static function unencapsulateAes($data, $prvkey)
{
openssl_private_decrypt(base64url_decode($data['key']), $k, $prvkey);
openssl_private_decrypt(base64url_decode($data['iv']), $i, $prvkey);
return self::decryptAES256CBC(base64url_decode($data['data']), $k, $i);
}
} }

48
src/Util/HTTPHeaders.php Normal file
View file

@ -0,0 +1,48 @@
<?php
/**
* @file src/Util/HTTPHeaders.php
*/
namespace Friendica\Util;
/**
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/Zotlabs/Web/HTTPHeaders.php
*/
class HTTPHeaders
{
private $in_progress = [];
private $parsed = [];
function __construct($headers)
{
$lines = explode("\n", str_replace("\r", '', $headers));
if ($lines) {
foreach ($lines as $line) {
if (preg_match('/^\s+/', $line, $matches) && trim($line)) {
if (!empty($this->in_progress['k'])) {
$this->in_progress['v'] .= ' ' . ltrim($line);
continue;
}
} else {
if (!empty($this->in_progress['k'])) {
$this->parsed[] = [$this->in_progress['k'] => $this->in_progress['v']];
$this->in_progress = [];
}
$this->in_progress['k'] = strtolower(substr($line, 0, strpos($line, ':')));
$this->in_progress['v'] = ltrim(substr($line, strpos($line, ':') + 1));
}
}
if (!empty($this->in_progress['k'])) {
$this->parsed[$this->in_progress['k']] = $this->in_progress['v'];
$this->in_progress = [];
}
}
}
function fetch()
{
return $this->parsed;
}
}

409
src/Util/HTTPSignature.php Normal file
View file

@ -0,0 +1,409 @@
<?php
/**
* @file src/Util/HTTPSignature.php
*/
namespace Friendica\Util;
use Friendica\Core\Config;
use Friendica\Database\DBM;
use Friendica\Util\Crypto;
use Friendica\Util\HTTPHeaders;
use dba;
/**
* @brief Implements HTTP Signatures per draft-cavage-http-signatures-07.
*
* Ported from Hubzilla: https://framagit.org/hubzilla/core/blob/master/Zotlabs/Web/HTTPSig.php
*
* @see https://tools.ietf.org/html/draft-cavage-http-signatures-07
*/
class HTTPSignature
{
/**
* @brief RFC5843
*
* Disabled until Friendica's ActivityPub implementation
* is ready.
*
* @see https://tools.ietf.org/html/rfc5843
*
* @param string $body The value to create the digest for
* @param boolean $set (optional, default true)
* If set send a Digest HTTP header
*
* @return string The generated digest of $body
*/
// public static function generateDigest($body, $set = true)
// {
// $digest = base64_encode(hash('sha256', $body, true));
//
// if($set) {
// header('Digest: SHA-256=' . $digest);
// }
// return $digest;
// }
// See draft-cavage-http-signatures-08
public static function verify($data, $key = '')
{
$body = $data;
$headers = null;
$spoofable = false;
$result = [
'signer' => '',
'header_signed' => false,
'header_valid' => false,
'content_signed' => false,
'content_valid' => false
];
// Decide if $data arrived via controller submission or curl.
if (is_array($data) && $data['header']) {
if (!$data['success']) {
return $result;
}
$h = new HTTPHeaders($data['header']);
$headers = $h->fetch();
$body = $data['body'];
} else {
$headers = [];
$headers['(request-target)'] = strtolower($_SERVER['REQUEST_METHOD']).' '.$_SERVER['REQUEST_URI'];
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$field = str_replace('_', '-', strtolower(substr($k, 5)));
$headers[$field] = $v;
}
}
}
$sig_block = null;
if (array_key_exists('signature', $headers)) {
$sig_block = self::parseSigheader($headers['signature']);
} elseif (array_key_exists('authorization', $headers)) {
$sig_block = self::parseSigheader($headers['authorization']);
}
if (!$sig_block) {
logger('no signature provided.');
return $result;
}
// Warning: This log statement includes binary data
// logger('sig_block: ' . print_r($sig_block,true), LOGGER_DATA);
$result['header_signed'] = true;
$signed_headers = $sig_block['headers'];
if (!$signed_headers) {
$signed_headers = ['date'];
}
$signed_data = '';
foreach ($signed_headers as $h) {
if (array_key_exists($h, $headers)) {
$signed_data .= $h . ': ' . $headers[$h] . "\n";
}
if (strpos($h, '.')) {
$spoofable = true;
}
}
$signed_data = rtrim($signed_data, "\n");
$algorithm = null;
if ($sig_block['algorithm'] === 'rsa-sha256') {
$algorithm = 'sha256';
}
if ($sig_block['algorithm'] === 'rsa-sha512') {
$algorithm = 'sha512';
}
if ($key && function_exists($key)) {
$result['signer'] = $sig_block['keyId'];
$key = $key($sig_block['keyId']);
}
// We don't use Activity Pub at the moment.
// if (!$key) {
// $result['signer'] = $sig_block['keyId'];
// $key = self::getActivitypubKey($sig_block['keyId']);
// }
if (!$key) {
return $result;
}
$x = Crypto::rsaVerify($signed_data, $sig_block['signature'], $key, $algorithm);
logger('verified: ' . $x, LOGGER_DEBUG);
if (!$x) {
return $result;
}
if (!$spoofable) {
$result['header_valid'] = true;
}
if (in_array('digest', $signed_headers)) {
$result['content_signed'] = true;
$digest = explode('=', $headers['digest']);
if ($digest[0] === 'SHA-256') {
$hashalg = 'sha256';
}
if ($digest[0] === 'SHA-512') {
$hashalg = 'sha512';
}
// The explode operation will have stripped the '=' padding, so compare against unpadded base64.
if (rtrim(base64_encode(hash($hashalg, $body, true)), '=') === $digest[1]) {
$result['content_valid'] = true;
}
}
logger('Content_Valid: ' . $result['content_valid']);
return $result;
}
/**
* Fetch the public key for Activity Pub contact.
*
* @param string|int The identifier (contact addr or contact ID).
* @return string|boolean The public key or false on failure.
*/
private static function getActivitypubKey($id)
{
if (strpos($id, 'acct:') === 0) {
$contact = dba::selectFirst('contact', ['pubkey'], ['uid' => 0, 'addr' => str_replace('acct:', '', $id)]);
} else {
$contact = dba::selectFirst('contact', ['pubkey'], ['id' => $id, 'network' => 'activitypub']);
}
if (DBM::is_result($contact)) {
return $contact['pubkey'];
}
if(function_exists('as_fetch')) {
$r = as_fetch($id);
}
if ($r) {
$j = json_decode($r, true);
if (array_key_exists('publicKey', $j) && array_key_exists('publicKeyPem', $j['publicKey'])) {
if ((array_key_exists('id', $j['publicKey']) && $j['publicKey']['id'] !== $id) && $j['id'] !== $id) {
return false;
}
return $j['publicKey']['publicKeyPem'];
}
}
return false;
}
/**
* @brief
*
* @param string $request
* @param array $head
* @param string $prvkey
* @param string $keyid (optional, default 'Key')
* @param boolean $send_headers (optional, default false)
* If set send a HTTP header
* @param boolean $auth (optional, default false)
* @param string $alg (optional, default 'sha256')
* @param string $crypt_key (optional, default null)
* @param string $crypt_algo (optional, default 'aes256ctr')
*
* @return array
*/
public static function createSig($request, $head, $prvkey, $keyid = 'Key', $send_headers = false, $auth = false, $alg = 'sha256', $crypt_key = null, $crypt_algo = 'aes256ctr')
{
$return_headers = [];
if ($alg === 'sha256') {
$algorithm = 'rsa-sha256';
}
if ($alg === 'sha512') {
$algorithm = 'rsa-sha512';
}
$x = self::sign($request, $head, $prvkey, $alg);
$headerval = 'keyId="' . $keyid . '",algorithm="' . $algorithm
. '",headers="' . $x['headers'] . '",signature="' . $x['signature'] . '"';
if ($crypt_key) {
$x = Crypto::encapsulate($headerval, $crypt_key, $crypt_algo);
$headerval = 'iv="' . $x['iv'] . '",key="' . $x['key'] . '",alg="' . $x['alg'] . '",data="' . $x['data'] . '"';
}
if ($auth) {
$sighead = 'Authorization: Signature ' . $headerval;
} else {
$sighead = 'Signature: ' . $headerval;
}
if ($head) {
foreach ($head as $k => $v) {
if ($send_headers) {
// This is for ActivityPub implementation.
// Since the Activity Pub implementation isn't
// ready at the moment, we comment it out.
// header($k . ': ' . $v);
} else {
$return_headers[] = $k . ': ' . $v;
}
}
}
if ($send_headers) {
// This is for ActivityPub implementation.
// Since the Activity Pub implementation isn't
// ready at the moment, we comment it out.
// header($sighead);
} else {
$return_headers[] = $sighead;
}
return $return_headers;
}
/**
* @brief
*
* @param string $request
* @param array $head
* @param string $prvkey
* @param string $alg (optional) default 'sha256'
*
* @return array
*/
private static function sign($request, $head, $prvkey, $alg = 'sha256')
{
$ret = [];
$headers = '';
$fields = '';
if ($request) {
$headers = '(request-target)' . ': ' . trim($request) . "\n";
$fields = '(request-target)';
}
if ($head) {
foreach ($head as $k => $v) {
$headers .= strtolower($k) . ': ' . trim($v) . "\n";
if ($fields) {
$fields .= ' ';
}
$fields .= strtolower($k);
}
// strip the trailing linefeed
$headers = rtrim($headers, "\n");
}
$sig = base64_encode(Crypto::rsaSign($headers, $prvkey, $alg));
$ret['headers'] = $fields;
$ret['signature'] = $sig;
return $ret;
}
/**
* @brief
*
* @param string $header
* @return array associate array with
* - \e string \b keyID
* - \e string \b algorithm
* - \e array \b headers
* - \e string \b signature
*/
public static function parseSigheader($header)
{
$ret = [];
$matches = [];
// if the header is encrypted, decrypt with (default) site private key and continue
if (preg_match('/iv="(.*?)"/ism', $header, $matches)) {
$header = self::decryptSigheader($header);
}
if (preg_match('/keyId="(.*?)"/ism', $header, $matches)) {
$ret['keyId'] = $matches[1];
}
if (preg_match('/algorithm="(.*?)"/ism', $header, $matches)) {
$ret['algorithm'] = $matches[1];
}
if (preg_match('/headers="(.*?)"/ism', $header, $matches)) {
$ret['headers'] = explode(' ', $matches[1]);
}
if (preg_match('/signature="(.*?)"/ism', $header, $matches)) {
$ret['signature'] = base64_decode(preg_replace('/\s+/', '', $matches[1]));
}
if (($ret['signature']) && ($ret['algorithm']) && (!$ret['headers'])) {
$ret['headers'] = ['date'];
}
return $ret;
}
/**
* @brief
*
* @param string $header
* @param string $prvkey (optional), if not set use site private key
*
* @return array|string associative array, empty string if failue
* - \e string \b iv
* - \e string \b key
* - \e string \b alg
* - \e string \b data
*/
private static function decryptSigheader($header, $prvkey = null)
{
$iv = $key = $alg = $data = null;
if (!$prvkey) {
$prvkey = Config::get('system', 'prvkey');
}
$matches = [];
if (preg_match('/iv="(.*?)"/ism', $header, $matches)) {
$iv = $matches[1];
}
if (preg_match('/key="(.*?)"/ism', $header, $matches)) {
$key = $matches[1];
}
if (preg_match('/alg="(.*?)"/ism', $header, $matches)) {
$alg = $matches[1];
}
if (preg_match('/data="(.*?)"/ism', $header, $matches)) {
$data = $matches[1];
}
if ($iv && $key && $alg && $data) {
return Crypto::unencapsulate(['iv' => $iv, 'key' => $key, 'alg' => $alg, 'data' => $data], $prvkey);
}
return '';
}
}

View file

@ -33,4 +33,7 @@
template="{{$subscribe}}" /> template="{{$subscribe}}" />
<Link rel="magic-public-key" <Link rel="magic-public-key"
href="{{$modexp}}" /> href="{{$modexp}}" />
<Link rel="http://purl.org/openwebauth/v1"
type="application/x-dfrn+json"
href="{{$openwebauth}}" />
</XRD> </XRD>