From 90b4873a00b0d8b4249a323fc84a7460024f491b Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Tue, 27 May 2008 07:42:19 -0400 Subject: [PATCH] client side of distributed subscription almost complete darcs-hash:20080527114219-84dde-784ddf4d4650c17bc7a1e3e01219c6948dfc9b3d.gz --- actions/accesstoken.php | 9 +- actions/finishremotesubscribe.php | 221 ++++++++++++++++++++++++++ actions/remotesubscribe.php | 248 ++++++++++++++++++++++++++++++ actions/requesttoken.php | 11 +- actions/showstream.php | 4 + actions/userauthorization.php | 56 ++++++- actions/xrds.php | 4 +- db/stoica.sql | 1 + doc/README | 3 +- doc/TODO | 13 +- doc/openmicroblogging.txt | 21 +-- lib/omb.php | 19 +++ lib/util.php | 8 + 13 files changed, 600 insertions(+), 18 deletions(-) create mode 100644 actions/finishremotesubscribe.php create mode 100644 actions/remotesubscribe.php diff --git a/actions/accesstoken.php b/actions/accesstoken.php index e28a933454..6bb0e1561b 100644 --- a/actions/accesstoken.php +++ b/actions/accesstoken.php @@ -22,6 +22,13 @@ if (!defined('LACONICA')) { exit(1); } class AccesstokenAction extends Action { function 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()); + } } } diff --git a/actions/finishremotesubscribe.php b/actions/finishremotesubscribe.php new file mode 100644 index 0000000000..b5093263e1 --- /dev/null +++ b/actions/finishremotesubscribe.php @@ -0,0 +1,221 @@ +. + */ + +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']); + } +} \ No newline at end of file diff --git a/actions/remotesubscribe.php b/actions/remotesubscribe.php new file mode 100644 index 0000000000..68f5f0fc34 --- /dev/null +++ b/actions/remotesubscribe.php @@ -0,0 +1,248 @@ +. + */ + +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; + } +} \ No newline at end of file diff --git a/actions/requesttoken.php b/actions/requesttoken.php index 731d260ffd..92b4c42342 100644 --- a/actions/requesttoken.php +++ b/actions/requesttoken.php @@ -19,9 +19,18 @@ if (!defined('LACONICA')) { exit(1); } +require_once(INSTALLDIR.'/lib/omb.php'); + class RequesttokenAction extends Action { function 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()); + } } } diff --git a/actions/showstream.php b/actions/showstream.php index 3de9a6e23b..7ac036de63 100644 --- a/actions/showstream.php +++ b/actions/showstream.php @@ -68,6 +68,10 @@ class ShowstreamAction extends StreamAction { $user->nickname)), 'type' => 'application/rss+xml', '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() { diff --git a/actions/userauthorization.php b/actions/userauthorization.php index 5b8a8bdc80..cc7ec85a51 100644 --- a/actions/userauthorization.php +++ b/actions/userauthorization.php @@ -22,6 +22,60 @@ if (!defined('LACONICA')) { exit(1); } class UserauthorizationAction extends Action { function 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')) { + + } } } diff --git a/actions/xrds.php b/actions/xrds.php index 6b4c3cdf51..d59928e91c 100644 --- a/actions/xrds.php +++ b/actions/xrds.php @@ -107,7 +107,9 @@ class XrdsAction extends Action { function show_service($type, $uri, $params=NULL, $sigs=NULL, $localId=NULL) { common_element_start('Service'); - common_element('URI', NULL, $uri); + if ($uri) { + common_element('URI', NULL, $uri); + } common_element('Type', NULL, $type); if ($params) { foreach ($params as $param) { diff --git a/db/stoica.sql b/db/stoica.sql index e9aa1f998e..6a460b350d 100644 --- a/db/stoica.sql +++ b/db/stoica.sql @@ -56,6 +56,7 @@ create table subscription ( subscriber integer not null comment 'profile listening', subscribed integer not null comment 'profile being listened to', token varchar(255) comment 'authorization token', + secret varchar(255) comment 'token secret', created datetime not null comment 'date this record was created', modified timestamp comment 'date this record was modified', diff --git a/doc/README b/doc/README index 7bde309169..4190b637af 100644 --- a/doc/README +++ b/doc/README @@ -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 future. Note that this is no longer distributed separately; it's only in the openidenabled.com OpenID PHP tarball. - +- OAuth.php from http://oauth.googlecode.com/svn/code/php/ + diff --git a/doc/TODO b/doc/TODO index c5f692b43b..9428bc0d90 100644 --- a/doc/TODO +++ b/doc/TODO @@ -53,12 +53,18 @@ + public stream link in top menu + dump, fix, undump database + release 0.2 -- YADIS document link on showstream -- YADIS document ++ YADIS document link on showstream ++ YADIS document - subscribe 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 +- send remote notice +- subscribe form for not-logged-in users on showstream - pretty URLs - doc action - about doc @@ -76,6 +82,7 @@ - add a next page link to public - add a next page link to all - AGPL notification +- Check licenses of all libraries for compatibility - gettext - release 0.3 - license per notice diff --git a/doc/openmicroblogging.txt b/doc/openmicroblogging.txt index 6fd3b7cecf..097291640d 100644 --- a/doc/openmicroblogging.txt +++ b/doc/openmicroblogging.txt @@ -60,7 +60,7 @@ notice URI 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 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 authorization to post notices and update profiles. -In all OAuth, the consumer key should be blank (''), unless the remote -server and local service have negotiated another key. Such negotiation -is out-of-scope for this document, and we assume an "open" network of -microblogging services. But if you want to have that kind of network, -do it with this key. +In all OAuth, the consumer key should be the root URL for the +microblogging service, if available. The secret should be the blank +string (''), unless the remote server and local service have negotiated +another key. Such negotiation is out-of-scope for this document, and we +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 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. 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 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 have the remote service post the same notice 100 times to the same service. However, if the local service wants fine-grained control, 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 any more ("unsubscribed"). If there's one postNotice URL per local service, it means that the count of listeners has dropped to 0. diff --git a/lib/omb.php b/lib/omb.php index 0267ae5970..b68d08abf9 100644 --- a/lib/omb.php +++ b/lib/omb.php @@ -19,11 +19,15 @@ if (!defined('LACONICA')) { exit(1); } +require_once('OAuth.php'); + define('OAUTH_NAMESPACE', 'http://oauth.net/core/1.0/'); 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('OMB_ENDPOINT_UPDATEPROFILE', OMB_NAMESPACE.'updateProfile'); +define('OMB_ENDPOINT_POSTNOTICE', OMB_NAMESPACE.'postNotice'); define('OAUTH_ENDPOINT_REQUEST', OAUTH_NAMESPACE.'endpoint/request'); define('OAUTH_ENDPOINT_AUTHORIZE', OAUTH_NAMESPACE.'endpoint/authorize'); 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_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; +} \ No newline at end of file diff --git a/lib/util.php b/lib/util.php index 64fa230d20..31a2cbd4f7 100644 --- a/lib/util.php +++ b/lib/util.php @@ -439,6 +439,14 @@ function common_mint_tag($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 function _t($str) {