http://qs321.pair.com?node_id=602005

Introduction

This is a stream of consciousness meditation concerning single sign-on.

The other day, I was clicking around on http://rt.cpan.org/, when I noticed this "Have a Bitcard account? Log in with it now" link on their mainpage. I created a bitcard account and signed in. It seemed pretty slick, so I began to wonder how hard it would be to support. The answer is: not very hard, only seconds required — if you're already acclimated.

Bitcard

I located Authen::Bitcard, which is apparently directly descended from Authen::TypeKey. It turns out it's really fun and easy to support either on your site. I will spare you many of the details, although I was prepared to post a complete example — it seems that I'd want multiple levels of readmore tags before I did that.

This was how I ended up instantiating Authen::Bitcard. The $assigned_bitcard_token is assigned to you by Bjo/rn's site, it's not something you make up. The $bitcard_pubkey_cache is either a filename or a CODE ref — however, if you go that route, be prepared to create Math::BigInts by hand and populate some hashkeys in the $bc object directly. I didn't care for that option and stuck with the file. It still costs you an LWP hit to the site to see if the public key has changed since you last created a $bc object, but it only downloads the key if it has changed.

my $bc = Authen::Bitcard->new; $bc->token($assigned_bitcard_token); $bc->info_optional([qw(name username email)]); $bc->expires( 86400*30 ); $bc->key_cache($bitcard_pubkey_cache);

Next, you simply need to interact with the $bc object to authenticate users. Notice that name, username, and email are all optional? That's one of my favorite parts of these systems. You can authenticate without identifying yourself.

print $cgi->p("Hi there..."); print $cgi->p("Please ", $cgi->a({ href=>$bc->login_url({r=>$cgi->u +rl}) }, "login") . ".");

I have chosen CGI::Session to keep track of my registration data (if available) and my authentication data. I call my session object $ses so it's the same number of characters as $cgi. Here is, pretty much all the logic you'd need to authenticate bidcard users...

$ses->expire("+1y"); # most of the session lasts forever my $dood = $ses->param("dood"); my $user = $ses->param("user"); if( not $user and (my $bcd = $ses->param("bc_data")) ) { $user = $bc->verify( $bcd ); # we only verify the bc_data every h +our or so } if( not $user and (my $f = $cgi->param("bc_fields")) and ($user = $bc- +>verify($cgi)) ) { # This line is stolen from Authen::Bitcard directly (minus a few b +ytes)... # Why don't they export this? my %data = map { $_ => $cgi->param($_) } split(/,/, $f), 'bc_sig'; $ses->param( bc_data => \%data ); # so we can verify above $dood = $user->{username} || $user->{name} || $user->{email} || "Incognito #" . $user->{id}; $ses->param( dood => $dood ); $ses->flush; print $cgi->redirect($cgi->url); exit 0; } if( $cgi->param("lo") or ($user and not $dood) ) { $ses->clear([qw(user bc_data)]); print $cgi->redirect($cgi->url); exit 0; } if( $user and not $ses->param("user") ) { $ses->param( user => $user ); $ses->expire( user => "+1h" ); # this means every hour we'll verif +y(bc_data) }

I was insanely happy with my cute little test app using the logic above. It only took a couple minutes to figure out and it meant I could create sites for which people would not need to create logins. They could just try out my junk and I don't even need to send them an email. (What does sending an email prove anyway?)

Huzzah! I learned about single sign-on. I was reminded vaguely of MS Passport — which nobody used because ... well, duh. But why was I surprised that others were tackling this same problem?

OpenID

Instantaneously, upon showing my friends the coolness of Bitcard, they start whining, "OpenID has already won!!1 Even Digg Supports it OMG!" I don't read digg, but they don't support OpenID. They're about to apparently, but they definitely don't yet. It seems everyone is about to (if they don't already). Just check out my cred at jyte! (That was intended as a joke, but since I have so many thumbs up, who's laughing now!?!)

OpenID is probably better than Bitcard/TypeKey because it's decentralized but it does have it's own problems. First of all, it's somewhat more complicated than Bitcard. Just check out this simple diagram to see what I mean. (It's really not as bad as it looks at first glance.)

The second problem is letting unknown jerks (that's a safe assumption) on the internet tell your LWP::UserAgent where to go. Mr. Fitzpatrick was kind enough to handle the difficulties of that mess for us in his LWPx::ParanoidAgent — if you dare trust it. Personally, I was even more paranoid than the Paranoid Agent in my OpenID tests, but you may be more trusting than me — or are not responsible for the security of dozens of related (or otherwise neighboring) servers and websites.

There are a couple choices for OpenID in Perl. I chose Net::OpenID::Consumer for my testing. In order to get started, I needed to pull in a few other things too though. By the way, it's no coincidence that the documentation for Net::OpenID::Consumer tries to sell you on LWPx::ParanoidAgent; it's the same author!

use Net::OpenID::Consumer; use Digest::SHA1 qw(sha1); use LWPx::ParanoidAgent; use Cache::File; use DBM::Deep; use URI; # for port parsing use Time::HiRes qw(time);

Nonce Sense

First of all, you need a $nonce that jerks can't predict, but that doesn't change during the different stages of a login. You can use a simple secret, but the docs warn you away from this. If you think about it, a million node botnet could probably figure out your secret if it sucks, so it's probably better to shake it up a little.

I chose to use Digest::SHA1, a secret mixed with some weakly random things (aka random key mashing) and the username like so:

my $nonce_pattern = q(%s%d%d%s my secret code words here) . $0; my $nonce = $ses->param("nonce") || sha1(sprintf($nonce_pattern, time, (stat $0)[9], -s _, $clai +m));

(NOTE: I sometimes make things more complicated than they really need to be though. It's make just as much sense to set consumer_secret => sub { sprintf($nonce_pattern, shift) } since the module passes you the session time if you set the secret to a coderef instead.)

Then, after considering the security of the aforementioned Paranoid Agent a little, I chose this setup.

my $ua = LWPx::ParanoidAgent->new; $ua->timeout(5); $ua->blocked_hosts(sub { my $host = shift; return 1 if $host =~ $my_local_networks; # some precompiled regula +r... return 0 if $host =~ m/^[\w\d\-_]+\.myopenid\.com\//; return 0 if $host =~ m/^[\w\d_.-]+\.(?:com|net|org)(?:\/\??[\w\d_& +=;%-]+)?\z/; return 0 if $host =~ m/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/ and not $host =~m/^(?:10\.|192\.168|172\.16|127\.)/; return 1; });

I'm not sure it's necessary to match and block 10. and 192.168. as I believe that's default, but you're better safe than sorry. I also restrict the range of identity servers quite a bit (org/net/com). What good is an authenticated identity if I don't know where it's coming from? Maybe just as good as if they were all from http://www.myopenid.com/ I suppose — since it's likely that nefarious individuals could just make as many accounts as they like there. Nevertheless, this way makes me feel a little more comfortable. (I can always relax the rules later.)

Also, using URI, I reject any claimed identity with an $uri->port below 1024 that isn't 80 or 443. I also reject any claimed identity with an @ in the hostname. My thinking being that it'd make using your site as a password cracker proxy more difficult — but, I suspect they'll try anyway. I also reject any $uri->path =~ m(\.\./).

It doesn't appear that you can reject based on the $uri with LWPx::ParanoidAgent directly, but you certainly can filter the $claim and then pass $uri->canonical to the Agent; which, is precisely what I did.

Anyway, now we have enough pieces to construct our OpenID Consumer. The required_root here is the path under which our return(s) to the site must fall.

my $csr = Net::OpenID::Consumer->new( consumer_secret => $nonce, args => $cgi, ua => $ua, required_root => $this_domain, cache => Cache::File->new(cache_root=>"$session_director +y/csr.cache/"));

OpenID Authentication Logic

First things first, we send the user to the claimed URI along with our $nonce (crammed in an HMAC-SHA1 apparently). Although I know little about the actual protocol of OpenID, this will let us verify the returned auth msg later. The trusted_root here is the domain and path you're asking the user to trust. Some OpenID servers will ask the user to confirm it in some way. I'm not completely clear on the details, but the important part is that it's the URL you're asking them to trust ... it can actually contain globs and things.

if ( $claim and not $cgi->param("checked") ) { if( my $ci = $csr->claimed_identity($claim) ) { my $check_url = $ci->check_url( return_to => $this_domain . "myscript.pl?claim=$claim;che +cked=1", trust_root => $this_domain, ); $ses->param( nonce => $nonce ); # keep this for when they come + back print $cgi->redirect( $check_url ); exit 0; } }

If the user is not already logged in and registered at your site, they'll get re-redirected back to you for further url-ification. In my setup, I respectfully request any information they're willing to share with me. There doesn't seem to be an interface in Net::OpenID::Consumer for this, but it's simple enough to add. This data request is known as simple registration.

elsif( my $setup_url = $csr->user_setup_url ) { # We only get here if we're not already logged into myopenid... print $cgi->redirect( $setup_url . '&openid.sreg.optional=' . 'email,nickname,fullname,dob,gender,postcode,country,languag +e,timezone' ); exit 0; }

Once they log in and release their data to you, they'll get re-re-redirected back to you, along with any simple registration data — iff they haven't already released that data to you. In the case of http://www.myopenid.com/, you can [allow once], [allow forever] and [deny] the login/registration process. If you allow forever, the sreg data will only be released the first time. However, each time you allow once it'll re-release the sreg data. Is that behavior standard? I certainly don't know, but I use DBM::Deep to grab the sreg data while I can.

elsif( (my $vfid = $csr->verified_identity) or (my $user = $ses->param +("user")) ) { my $dbm = DBM::Deep->new(file=>$dbm_file, locking => 1, autoflush +=> 1); if( $vfid ) { my $url = $vfid->url; my $max = 20; for my $p ( grep {m/^openid\.sreg\./} $cgi->param ) { if( $p =~ m/\.([^.]+)\z/ ) { my $k = $1; my $v = substr $cgi->param($p), 0, 1024; warn "adding $k=$v to $url"; $dbm->{$url}{$k} = $v; } last if (--$max)<1; } $user = { url => $url, disp => $dbm->{$url}->{nickname} || $dbm->{$url}->{fullnam +e} || $vfid->display, time => time, }; $ses->param(user=>$user); $ses->flush; } elsif( $cgi->param("lo") ) { $ses->clear([qw(user nonce)]); # kill the nonce so they get a +new one next time $ses->flush; print $cgi->redirect( $cgi->url ); exit 0; } my $url = $user->{url}; print $ses->header; print $cgi->start_html({title=>"openid test"}); print $cgi->h3("Hi there $user->{disp}!"); print $cgi->p("We've come a long way I think, no?", $cgi->a({href= +>"?lo=1"}, "logout")); print $cgi->p("reg params:", $cgi->ul( map {$cgi->li("$_: " . $dbm->{$url}{$_}) } keys %{ $ +dbm->{$url} } )) if %{ $dbm->{$url} } exit 0; }

Concluding Thoughts

OpenID is a lot more complicated, but it has "already won" the single sign-on, um, contest. Apparently it's everywhere already and, in truth, if you look around you'll see it all over the place. I can't help but wonder what other choices there are. Bitcard is pretty neat and much simpler to implement, but I do see the inherent value of decentralizing the identity servers.

The next site I make that requires a login is going to use one of these methods for sure (probably OpenID). There's really no point in emailing a user and designing the login logic when stuff like this is available.

What other methods are out there? What are you using? ... and most importantly, what did I miss above?

-Paul