client side of distributed subscription almost complete

darcs-hash:20080527114219-84dde-784ddf4d4650c17bc7a1e3e01219c6948dfc9b3d.gz
This commit is contained in:
Evan Prodromou 2008-05-27 07:42:19 -04:00
parent 47f030ef65
commit 90b4873a00
13 changed files with 600 additions and 18 deletions

View File

@ -22,6 +22,13 @@ if (!defined('LACONICA')) { exit(1); }
class AccesstokenAction extends Action { class AccesstokenAction extends Action {
function handle($args) { function handle($args) {
parent::handle($args); parent::handle($args);
common_server_error(_t('Not yet implemented.')); try {
$req = OAuthRequest::from_request();
$server = common_oauth_server();
$token = $server->fetch_access_token($req);
print $token;
} catch (OAuthException $e) {
common_server_error($e->getMessage());
}
} }
} }

View File

@ -0,0 +1,221 @@
<?php
/*
* Laconica - a distributed open-source microblogging tool
* Copyright (C) 2008, Controlez-Vous, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
if (!defined('LACONICA')) { exit(1); }
require_once(INSTALLDIR.'/lib/omb.php');
require_once('Auth/Yadis/Yadis.php');
class FinishremotesubscribeAction extends Action {
function handle($args) {
parent::handle($args);
if (common_logged_in()) {
common_user_error(_t('You can use the local subscription!'));
return;
}
$nonce = $this->trimmed('nonce');
if (!$omb) {
common_user_error(_t('No nonce returned!'));
return;
}
$omb = $_SESSION[$nonce];
if (!$omb) {
common_user_error(_t('Not expecting this response!'));
return;
}
$req = OAuthRequest::from_request();
$token = $req->get_parameter('oauth_token');
# I think this is the success metric
if ($token != $omb['token']) {
common_user_error(_t('Not authorized.'));
return;
}
$version = $req->get_parameter('omb_version');
if ($version != OMB_VERSION_01) {
common_user_error(_t('Unknown version of OMB protocol.'));
return;
}
$nickname = $req->get_parameter('omb_listener_nickname');
if (!$nickname) {
common_user_error(_t('No nickname provided by remote server.'));
return;
}
$profile_url = $req->get_parameter('omb_listener_profile');
if (!$profile_url) {
common_user_error(_t('No profile URL returned by server.'));
return;
}
if (!Validate::uri($profile_url, array('allowed_schemes' => array('http', 'https')))) {
common_user_error(_t('Invalid profile URL returned by server.'));
return;
}
$user = User::staticGet('uri', $omb['listenee']);
if (!$user) {
common_user_error(_t('User being listened to doesn\'t exist.'));
return;
}
$fullname = $req->get_parameter('omb_listener_fullname');
$homepage = $req->get_parameter('omb_listener_homepage');
$bio = $req->get_parameter('omb_listener_bio');
$location = $req->get_parameter('omb_listener_location');
$avatar_url = $req->get_parameter('omb_listener_avatar');
list($newtok, $newsecret) = $this->access_token($omb);
if (!$newtok || !$newsecret) {
common_user_error(_t('Couldn\'t convert request tokens to access tokens.'));
return;
}
# XXX: possible attack point; subscribe and return someone else's profile URI
$remote = Remote_profile::staticGet('uri', $omb['listener']);
if ($remote) {
$exists = true;
$profile = Profile::staticGet($remote->id);
$orig_remote = clone($remote);
$orig_profile = clone($profile);
# XXX: compare current postNotice and updateProfile URLs to the ones
# stored in the DB to avoid (possibly...) above attack
} else {
$exists = false;
$remote = new Remote_profile();
$remote->uri = $omb['listener'];
$profile = new Profile();
}
$profile->nickname = $nickname;
$profile->profileurl = $profile_url;
if ($fullname) {
$profile->fullname = $fullname;
}
if ($homepage) {
$profile->homepage = $homepage;
}
if ($bio) {
$profile->bio = $bio;
}
if ($location) {
$profile->location = $location;
}
if ($exists) {
$profile->update($orig_profile);
} else {
$profile->created = DB_DataObject_Cast::dateTime(); # current time
$id = $profile->insert();
$remote->id = $id;
}
if ($avatar_url) {
$this->add_avatar($avatar_url);
}
$remote->postnoticeurl = $omb[OMB_ENDPOINT_POSTNOTICE];
$remote->updateprofileurl = $omb[OMB_ENDPOINT_UPDATEPROFILE];
if ($exists) {
$remote->update($orig_remote);
} else {
$remote->created = DB_DataObject_Cast::dateTime(); # current time
$remote->insert;
}
$sub = new Subscription();
$sub->subscriber = $remote->id;
$sub->subscribed = $user->id;
$sub->token = $newtok;
$sub->secret = $newsecret;
$sub->created = DB_DataObject_Cast::dateTime(); # current time
if (!$sub->insert()) {
common_user_error(_t('Couldn\'t insert new subscription.'));
return;
}
# Clear the data
unset($_SESSION[$nonce]);
# If we show subscriptions in reverse chron order, this should
# show up close to the top of the page
common_redirect(common_local_url('subscribed', array('nickname' =>
$user->nickname)));
}
function access_token($omb) {
$con = omb_oauth_consumer();
$tok = new OAuthToken($omb['token'], $omb['secret']);
$url = $omb[OAUTH_ENDPOINT_ACCESS][0];
# XXX: Is this the right thing to do? Strip off GET params and make them
# POST params? Seems wrong to me.
$parsed = parse_url($url);
$params = array();
parse_str($parsed['query'], $params);
$req = OAuthRequest::from_consumer_and_token($con, $tok, "POST", $url, $params);
$req->set_parameter('omb_version', OMB_VERSION_01);
# XXX: test to see if endpoint accepts this signature method
$req->sign_request(omb_hmac_sha1(), $con, NULL);
# We re-use this tool's fetcher, since it's pretty good
$fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
$result = $fetcher->post($req->get_normalized_http_url(),
$req->to_postdata());
if ($result->status != 200) {
return NULL;
}
parse_str($result->body, $return);
return array($return['oauth_token'], $return['oauth_token_secret']);
}
}

248
actions/remotesubscribe.php Normal file
View File

@ -0,0 +1,248 @@
<?php
/*
* Laconica - a distributed open-source microblogging tool
* Copyright (C) 2008, Controlez-Vous, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
if (!defined('LACONICA')) { exit(1); }
require_once(INSTALLDIR.'/lib/omb.php');
require_once('Auth/Yadis/Yadis.php');
class RemotesubscribeAction extends Action {
function handle($args) {
parent::handle($args);
if (common_logged_in()) {
common_user_error(_t('You can use the local subscription!'));
return;
}
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$this->remote_subscription();
} else {
$this->show_form();
}
}
function show_form($err=NULL) {
common_show_header(_t('Remote subscribe'));
if ($err) {
common_element('div', 'error', $err);
}
common_element_start('form', array('id' => 'remotesubscribe', 'method' => 'POST',
'action' => common_local_url('remotesubscribe')));
common_input('profile', _t('Profile URL'));
common_submit('submit', _t('Subscribe'));
common_element_end('form');
}
function remote_subscription() {
$user = $this->get_user();
if (!$user) {
$this->show_form(_t('No such user!'));
return;
}
$profile = $this->trimmed('profile');
if (!$profile) {
$this->show_form(_t('No such user!'));
return;
}
if (!Validate::uri($profile, array('allowed_schemes' => array('http', 'https')))) {
$this->show_form(_t('Invalid profile URL (bad format)'));
return;
}
$fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
$yadis = Auth_Yadis_Yadis::discover($profile, $fetcher);
if (!$yadis) {
$this->show_form(_t('Not a valid profile URL (no YADIS document).'));
return;
}
$omb = $this->getOmb($yadis);
if (!$omb) {
$this->show_form(_t('Not a valid profile URL (incorrect services).'));
return;
}
list($token, $secret) = $this->request_token($omb);
if (!$token || !$secret) {
$this->show_form(_t('Couldn\'t get a request token.'));
return;
}
$this->request_authorization($user, $omb, $token, $secret);
}
function get_user() {
$user = NULL;
$nickname = $this->trimmed('nickname');
if ($nickname) {
$user = User::staticGet('nickname', $nickname);
}
return $user;
}
function getOmb($yadis) {
static $endpoints = array(OMB_ENDPOINT_UPDATEPROFILE, OMB_ENDPOINT_POSTNOTICE,
OAUTH_ENDPOINT_REQUEST, OAUTH_ENDPOINT_AUTHORIZE,
OAUTH_ENDPOINT_ACCESS);
$omb = array();
$services = $yadis->services(); # ordered by priority
foreach ($services as $service) {
$types = $service->matchTypes($endpoints);
foreach ($types as $type) {
# We take the first one, since it's the highest priority
if (!array_key_exists($type, $omb)) {
# URIs is an array, priority-ordered
$omb[$type] = $service->getURIs();
# Special handling for request
if ($type == OAUTH_ENDPOINT_REQUEST) {
$nodes = $service->getElements('LocalID');
if (!$nodes) {
# error
return NULL;
}
$omb['listener'] = $service->parser->content($nodes[0]);
}
}
}
}
foreach ($endpoints as $ep) {
if (!array_key_exists($ep, $omb)) {
return NULL;
}
}
if (!array_key_exists('listener', $omb)) {
return NULL;
}
return $omb;
}
function request_token($omb) {
$con = omb_oauth_consumer();
$url = $omb[OAUTH_ENDPOINT_REQUEST][0];
# XXX: Is this the right thing to do? Strip off GET params and make them
# POST params? Seems wrong to me.
$parsed = parse_url($url);
$params = array();
parse_str($parsed['query'], $params);
$req = OAuthRequest::from_consumer_and_token($con, NULL, "POST", $url, $params);
$req->set_parameter('omb_listener', $omb['listener']);
$req->set_parameter('omb_version', OMB_VERSION_01);
# XXX: test to see if endpoint accepts this signature method
$req->sign_request(omb_hmac_sha1(), $con, NULL);
# We re-use this tool's fetcher, since it's pretty good
$fetcher = Auth_Yadis_Yadis::getHTTPFetcher();
$result = $fetcher->post($req->get_normalized_http_url(),
$req->to_postdata());
if ($result->status != 200) {
return NULL;
}
parse_str($result->body, $return);
return array($return['oauth_token'], $return['oauth_token_secret']);
}
function request_authorization($user, $omb, $token, $secret) {
global $config; # for license URL
$con = omb_oauth_consumer();
$tok = new OAuthToken($token, $secret);
$url = $omb[OAUTH_ENDPOINT_AUTHORIZE][0];
# XXX: Is this the right thing to do? Strip off GET params and make them
# POST params? Seems wrong to me.
$parsed = parse_url($url);
$params = array();
parse_str($parsed['query'], $params);
$req = OAuthRequest::from_consumer_and_token($con, $tok, 'GET', $url, $params);
# We send over a ton of information. This lets the other
# server store info about our user, and it lets the current
# user decide if they really want to authorize the subscription.
$req->set_parameter('omb_version', OMB_VERSION_01);
$req->set_parameter('omb_listener', $omb['listener']);
$req->set_parameter('omb_listenee', $user->uri);
$req->set_parameter('omb_listenee_profile', common_profile_url($user->nickname));
$req->set_parameter('omb_listenee_nickname', $user->nickname);
$req->set_parameter('omb_listenee_license', $config['license']['url']);
$profile = $user->getProfile();
if ($profile->fullname) {
$req->set_parameter('omb_listenee_fullname', $profile->fullname);
}
if ($profile->homepage) {
$req->set_parameter('omb_listenee_homepage', $profile->homepage);
}
if ($profile->bio) {
$req->set_parameter('omb_listenee_bio', $profile->bio);
}
if ($profile->location) {
$req->set_parameter('omb_listenee_location', $profile->location);
}
$avatar = $profile->getAvatar(AVATAR_PROFILE_SIZE);
if ($avatar) {
$req->set_parameter('omb_listenee_avatar', $avatar->url);
}
$nonce = $this->make_nonce();
$req->set_parameter('oauth_callback', common_local_url('finishremotesubscribe',
array('nonce' => $nonce)));
# XXX: test to see if endpoint accepts this signature method
$req->sign_request(omb_hmac_sha1(), $con, $tok);
# store all our info here
$omb['listenee'] = $user->nickname;
$omb['token'] = $token;
$omb['secret'] = $secret;
$_SESSION[$nonce] = $omb;
# Redirect to authorization service
common_redirect($req->to_url());
return;
}
}

View File

@ -19,9 +19,18 @@
if (!defined('LACONICA')) { exit(1); } if (!defined('LACONICA')) { exit(1); }
require_once(INSTALLDIR.'/lib/omb.php');
class RequesttokenAction extends Action { class RequesttokenAction extends Action {
function handle($args) { function handle($args) {
parent::handle($args); parent::handle($args);
common_server_error(_t('Not yet implemented.')); try {
$req = OAuthRequest::from_request();
$server = common_oauth_server();
$token = $server->fetch_request_token($req);
print $token;
} catch (OAuthException $e) {
common_server_error($e->getMessage());
}
} }
} }

View File

@ -68,6 +68,10 @@ class ShowstreamAction extends StreamAction {
$user->nickname)), $user->nickname)),
'type' => 'application/rss+xml', 'type' => 'application/rss+xml',
'title' => _t('Notice feed for ') . $user->nickname)); 'title' => _t('Notice feed for ') . $user->nickname));
# for remote subscriptions etc.
common_element('meta', array('http-equiv' => 'X-XRDS-Location',
'content' => common_local_url('xrds', array('nickname' =>
$user->nickname))));
} }
function no_such_user() { function no_such_user() {

View File

@ -22,6 +22,60 @@ if (!defined('LACONICA')) { exit(1); }
class UserauthorizationAction extends Action { class UserauthorizationAction extends Action {
function handle($args) { function handle($args) {
parent::handle($args); parent::handle($args);
common_server_error(_t('Not yet implemented.'));
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$this->send_authorization();
} else {
try {
$req = $this->get_request();
$server = common_oauth_server();
list($consumer, $token) = $server->verify_request($req);
} catch (OAuthException $e) {
$this->clear_request();
common_server_error($e->getMessage());
return;
}
if (common_logged_in()) {
$this->show_form($req);
} else {
common_return_to(common_local_url('userauthorization'));
common_redirect(common_local_url('login'));
}
}
}
function store_request($req) {
common_ensure_session();
$_SESSION['userauthorizationrequest'] = $req;
}
function get_request() {
common_ensure_session();
$req = $_SESSION['userauthorizationrequest'];
if (!$req) {
# XXX: may have an uncaught exception
$req = OAuthRequest::from_request();
$this->store_request($req);
}
return $req;
}
function show_form($req) {
common_show_header(_t('Authorize subscription'));
common_show_footer();
}
function send_authorization() {
$req = $this->get_request();
if (!$req) {
common_user_error(_t('No authorization request!'));
return;
}
if ($this->boolean('authorize')) {
}
} }
} }

View File

@ -107,7 +107,9 @@ class XrdsAction extends Action {
function show_service($type, $uri, $params=NULL, $sigs=NULL, $localId=NULL) { function show_service($type, $uri, $params=NULL, $sigs=NULL, $localId=NULL) {
common_element_start('Service'); common_element_start('Service');
if ($uri) {
common_element('URI', NULL, $uri); common_element('URI', NULL, $uri);
}
common_element('Type', NULL, $type); common_element('Type', NULL, $type);
if ($params) { if ($params) {
foreach ($params as $param) { foreach ($params as $param) {

View File

@ -56,6 +56,7 @@ create table subscription (
subscriber integer not null comment 'profile listening', subscriber integer not null comment 'profile listening',
subscribed integer not null comment 'profile being listened to', subscribed integer not null comment 'profile being listened to',
token varchar(255) comment 'authorization token', token varchar(255) comment 'authorization token',
secret varchar(255) comment 'token secret',
created datetime not null comment 'date this record was created', created datetime not null comment 'date this record was created',
modified timestamp comment 'date this record was modified', modified timestamp comment 'date this record was modified',

View File

@ -9,5 +9,6 @@ This package requires PHP 5.x and the following PHP Pear libraries:
use the openidenabled.com libraries for OpenID auth sometime in the use the openidenabled.com libraries for OpenID auth sometime in the
future. Note that this is no longer distributed separately; it's only future. Note that this is no longer distributed separately; it's only
in the openidenabled.com OpenID PHP tarball. in the openidenabled.com OpenID PHP tarball.
- OAuth.php from http://oauth.googlecode.com/svn/code/php/

View File

@ -53,12 +53,18 @@
+ public stream link in top menu + public stream link in top menu
+ dump, fix, undump database + dump, fix, undump database
+ release 0.2 + release 0.2
- YADIS document link on showstream + YADIS document link on showstream
- YADIS document + YADIS document
- subscribe remote - subscribe remote
- add subscriber remote - add subscriber remote
- send remote notice - server side of user authorization
- server side of request token
- server side of access token
- OAuth store
- log of consumers who ask for access
- receive remote notice - receive remote notice
- send remote notice
- subscribe form for not-logged-in users on showstream
- pretty URLs - pretty URLs
- doc action - doc action
- about doc - about doc
@ -76,6 +82,7 @@
- add a next page link to public - add a next page link to public
- add a next page link to all - add a next page link to all
- AGPL notification - AGPL notification
- Check licenses of all libraries for compatibility
- gettext - gettext
- release 0.3 - release 0.3
- license per notice - license per notice

View File

@ -60,7 +60,7 @@ notice URI
Initiation Initiation
========== ==========
The user submits their profile URL [*] to the remote service somehow -- The user submits their profile URL [*]_ to the remote service somehow --
for example, with an HTML form on the remote service's Web site. for example, with an HTML form on the remote service's Web site.
.. [*] For OAuth Discovery, this is the "protected resource". It may .. [*] For OAuth Discovery, this is the "protected resource". It may
@ -96,11 +96,12 @@ Authorization
The remote service must go through the OAuth 1.0 dance to get The remote service must go through the OAuth 1.0 dance to get
authorization to post notices and update profiles. authorization to post notices and update profiles.
In all OAuth, the consumer key should be blank (''), unless the remote In all OAuth, the consumer key should be the root URL for the
server and local service have negotiated another key. Such negotiation microblogging service, if available. The secret should be the blank
is out-of-scope for this document, and we assume an "open" network of string (''), unless the remote server and local service have negotiated
microblogging services. But if you want to have that kind of network, another key. Such negotiation is out-of-scope for this document, and we
do it with this key. assume an "open" network of microblogging services. But if you want to
have that kind of network, do it with this key.
The remote service MUST do OAuth for every new listener, regardless of The remote service MUST do OAuth for every new listener, regardless of
whether they've already received authorization for posting to the whether they've already received authorization for posting to the
@ -253,17 +254,17 @@ The local service makes no guarantees about the delivery of the notice
to anyone. to anyone.
The remote service SHOULD NOT send a message with the same notice URL The remote service SHOULD NOT send a message with the same notice URL
to the same postNotice URL more than once. [2]_ If the request returns to the same postNotice URL more than once. [*]_ If the request returns
a 403 Unauthorized message, the remote service SHOULD NOT post a 403 Unauthorized message, the remote service SHOULD NOT post
messages to the same URL again with the same listenee, until another messages to the same URL again with the same listenee, until another
listener has gone through the OAuth dance. [3]_ listener has gone through the OAuth dance. [*]_
.. [2] A half-assed optimization. A local service may have a lot of .. [*] A half-assed optimization. A local service may have a lot of
listeners listening to the same listenee. It would be pointless to listeners listening to the same listenee. It would be pointless to
have the remote service post the same notice 100 times to the same have the remote service post the same notice 100 times to the same
service. However, if the local service wants fine-grained control, service. However, if the local service wants fine-grained control,
it can have a different postNotice URL for each listener. it can have a different postNotice URL for each listener.
.. [3] If there's one postNotice URL per listener, the 403 message .. [*] If there's one postNotice URL per listener, the 403 message
means the listener has told the local service not to allow posting means the listener has told the local service not to allow posting
any more ("unsubscribed"). If there's one postNotice URL per local any more ("unsubscribed"). If there's one postNotice URL per local
service, it means that the count of listeners has dropped to 0. service, it means that the count of listeners has dropped to 0.

View File

@ -19,11 +19,15 @@
if (!defined('LACONICA')) { exit(1); } if (!defined('LACONICA')) { exit(1); }
require_once('OAuth.php');
define('OAUTH_NAMESPACE', 'http://oauth.net/core/1.0/'); define('OAUTH_NAMESPACE', 'http://oauth.net/core/1.0/');
define('OMB_NAMESPACE', 'http://openmicroblogging.org/protocol/0.1'); define('OMB_NAMESPACE', 'http://openmicroblogging.org/protocol/0.1');
define('OMB_VERSION_01', 'http://openmicroblogging.org/protocol/0.1');
define('OAUTH_DISCOVERY', 'http://oauth.net/discovery/1.0'); define('OAUTH_DISCOVERY', 'http://oauth.net/discovery/1.0');
define('OMB_ENDPOINT_UPDATEPROFILE', OMB_NAMESPACE.'updateProfile'); define('OMB_ENDPOINT_UPDATEPROFILE', OMB_NAMESPACE.'updateProfile');
define('OMB_ENDPOINT_POSTNOTICE', OMB_NAMESPACE.'postNotice');
define('OAUTH_ENDPOINT_REQUEST', OAUTH_NAMESPACE.'endpoint/request'); define('OAUTH_ENDPOINT_REQUEST', OAUTH_NAMESPACE.'endpoint/request');
define('OAUTH_ENDPOINT_AUTHORIZE', OAUTH_NAMESPACE.'endpoint/authorize'); define('OAUTH_ENDPOINT_AUTHORIZE', OAUTH_NAMESPACE.'endpoint/authorize');
define('OAUTH_ENDPOINT_ACCESS', OAUTH_NAMESPACE.'endpoint/access'); define('OAUTH_ENDPOINT_ACCESS', OAUTH_NAMESPACE.'endpoint/access');
@ -32,3 +36,18 @@ define('OAUTH_AUTH_HEADER', OAUTH_NAMESPACE.'parameters/auth-header');
define('OAUTH_POST_BODY', OAUTH_NAMESPACE.'parameters/post-body'); define('OAUTH_POST_BODY', OAUTH_NAMESPACE.'parameters/post-body');
define('OAUTH_HMAC_SHA1', OAUTH_NAMESPACE.'signature/HMAC-SHA1'); define('OAUTH_HMAC_SHA1', OAUTH_NAMESPACE.'signature/HMAC-SHA1');
function omb_oauth_consumer() {
static $con = null;
if (!$con) {
$con = new OAuthConsumer(common_root_url(), '');
}
return $con;
}
function omb_hmac_sha1() {
static $hmac_method = NULL;
if (!$hmac_method) {
$hmac_method = new OAuthSignatureMethod_HMAC_SHA1();
}
return $hmac_method;
}

View File

@ -439,6 +439,14 @@ function common_mint_tag($extra) {
$config['tag']['date'].':'.$config['tag']['prefix'].$extra; $config['tag']['date'].':'.$config['tag']['prefix'].$extra;
} }
# Should make up a reasonable root URL
function common_root_url() {
global $config;
$pathpart = ($config['site']['path']) ? $config['site']['path']."/" : '';
return "http://".$config['site']['server'].'/'.$pathpart;
}
// XXX: set up gettext // XXX: set up gettext
function _t($str) { function _t($str) {