Net::OpenID::Server pour héberger son propre fournisseur OpenID
Ayant eu besoin d'un identifiant OpenID, je me suis plongé dans la description du protocole et ai implémenté mon fournisseur OpenID. Certes, il y a phpMyID, mais celui-ci est abandonné depuis 2 ans… À l'inverse, Net::OpenID::Server vient d'être repris par Robert Norris qui lui a insufflé un second souffle, avec une nouvelle version qui sera prochainement disponible.
1. Aperçu d'OpenID
Une page intéressante : http://wiki.openid.net/w/page/12995171/Introduction.
Le but d'OpenID est de pouvoir affirmer « tel utilisateur contrôle telle URL sur le Web ». Pour cela, l'utilisateur donne cette URL comme identifiant à quelqu'un qui cherche à l'identifier, et le site découvre à travers cette URL l'adresse d'un serveur pouvant attester que l'utilisateur est bien propriétaire de cette URL.
Le serveur d'identité n'affirme pas que l'utilisateur est digne de confiance (ou n'est pas un robot, ou…), juste que l'utilisateur est bien la personne qui contrôle l'URL donnée.
Des extensions à OpenID 2.0 permettent également de partager des choses telles que le pseudo, le nom réel, la date de naissance, l'adresse, etc.
1.1. Les acteurs
- l'utilisateur ;
- le consommateur (RP, le relying party, le service qui veut vérifier l'identité de l'utilisateur) ;
- le fournisseur d'identité (OP, l'OpenID provider, le service qui assure au RP l'identité de l'utilisateur.
Un utilisateur dispose d'un identifiant, qui dans le cas général est
l'adresse d'une page HTML qui contient un élément LINK
redirigeant
vers un OP.
1.2. Authentification en mode « sans état »
Dans les schémas suivants, les messages échangés sont un peu simplifiés, mais l'essentiel est là.
L'utilisateur donne comme identifiant l'adresse d'une page Web qu'il
contrôle. Cette page Web contient deux éléments LINK
importants :
l'un vers un OP, et le deuxième (le openid.delegate
) est l'identité
qui sera attestée par l'OP. L'identifiant untel.example.net
donné
par l'utilisateur ne sera pas utilisé dans la suite des échanges.
Cette indirection permet de changer de fournisseur d'identité en
changeant l'élément openid.server
sur sa page Web. Par contre, les
OP n'accepteront d'identifier que les adresses qu'ils contrôlent (en
soi ceci n'est pas liée au protocole), d'où la nécessité de déléguer
l'identifiant avec openid.delegate
(qui changera certainement quand
on changera d'OP).
Afin d'éviter les attaques de rejeu, le paramètre return_to
précise
un nonce dans l'adresse. Si l'adresse ainsi formée est
siteweb.example.com?nonce=_123
, elle apparaîtra dans l'adresse de
redirection sous la forme siteweb.example.com%3Fnonce%3D_123
. Cet
encodage d'adresse dans les paramètres d'une autre adresse apparaîtra
à plusieurs endroits.
Après que l'utilisateur s'est identifié auprès de l'OP, l'OP ajouté à
l'adresse return_to
plusieurs paramètres, à savoir le mode (id_res
comme indirect response), l'identity de l'utilisateur, et surtout
une signature (sig
, une signature HMAC) et un descripteur « opaque »
(assoc_handle
). Ce dernier descripteur est l'adresse d'une page Web
à laquelle on peut POSTer
les renseignements obtenus, dans notre cas
l'adresse return_to
(qui contient toujours le nonce !) et
l'identité. L'OP vérifie la signature, et le cas échéant répond par
is_valid:true
.
NB : dans tous les échanges qui sont signés avec un paramètre sig
,
il y a en plus un paramètre signed
qui précise quels sont les champs
qui sont couverts par la signature.
1.3. check_setup
et check_immediate
check_immediate
est utilisé pour éviter d'avoir à renvoyer
l'utilisateur vers le site de l'OP. Si l'OP est en mesure d'affirmer
immédiatement l'utilisateur est bien le propriétaire de l'URL, tout se
passe bien (sans interaction entre l'utilisateur et son OP). Sinon,
l'OP répond avec un user_setup_url
, qui est typiquement affiché dans
une popup pour que l'utilisateur s'authentifie auprès de son OP. Ceci
semble être utilisé principalement par les sites en AJAX.
1.4. Authentification avec état
Ce mode permet de partager à l'avance un secret partagé entre un RP et l'OP. Lorsqu'un RP doit identifier un utilisateur qui utilise un OP qui n'a encore jamais été vu, le RP contacte l'OP pour échanger des clefs.
La première partie du flux OpenID reste la même. La différence est lors du retour de l'utilisateur sur le RP : l'OP et le RP ayant partagés un secret, le RP peut vérifier l'authenticité du message de retour sans avoir à contacter de nouveau l'OP. Ceci économise une requête HTTP entre le RP et l'OP (et donc la bande passante et le délai associés), et permet de déporter le travail de vérification de la signature du côté du RP. On remarquera que l'état est conservé du côté du RP : c'est surtout l'OP qui profite de cette modification du flux.
Ensuite, le même assoc_handle
peut être utilisé pour authentifier
tous les messages des utilisateurs utilisant le même OP. La durée de
14 jours dans le schéma plus haut correspond à l'implémentation de
Net::OpenID::Server, ce n'est pas spécifié par le protocole.
Les OP doivent refuser d'authentifier des assoc_handle
venant de
transactions avec état dans une requête check_authentication
(fin de
transaction sans état), afin d'éviter certaines attaques.
session_type
peut être vide, dans ce cas le secret partagé peut être
échangé en clair. Fortement déconseillé.
1.5. Changements introduits par OpenID 2.0
Tous les échanges ajoutent un paramètre
ns:http://specs.openid.net/auth/2.0
.
openid.server
et openid.delegate
deviennent openid2.provider
et
opened2.local_id
.
Identifiants d'OP [XXX: ?].
Utilisation de SHA256 pour les signatures (HMAC-SHA256) et pour l'échange de secrets partagés (DH-SHA256).
Introduction d'extensions ; celle qui est mentionnée dans la norme est SREG, qui permet également de partager des informations sur l'utilisateur telles que le pseudo, l'âge, l'adresse, etc., tout en permettant à l'utilisateur de contrôler quel RP a accès à quelles informations.
Prise en compte de la différence entre le claimed_id
et l'identity
(dans le premier schéma, il s'agirait de untel.example.net
et de
example.org/openid/untel
). [XXX: à creuser.]
Envoi d'un user_setup_url
lorsque l'OP refuse / n'est pas capable de
valider une requête check_authentication
, afin de permettre à
l'utilisateur de finir la transaction.
Ajout d'un paramètre op_endpoint
(qui n'existait pas auparavant, qui
est obligatoire en 2.0). Ce paramètre est l'URL de l'OP.
Le nonce n'était en réalité pas obligatoire avec OpenID 1.1 ; il le devient en 2.0.
Et d'autres modifications mineures.
2. Fonctionnement de Net::OpenID::Server
2.1. Version à utiliser
La version qui est actuellement dans les ports FreeBSD
(security/p5-Net-OpenID-Server
) est la version 1.02. Après quelques
heures à tester (devant une nouvelle technologie, j'ai tendance à plus
souvent remettre en cause l'interface chaise-clavier que les
outils…), il y a (au moins) un bug dans cette version qui l'empêche
de parler OpenID 2.0, qui est maintenant corrigé. Du coup, je décide
de passer à la version 1.030099, qui deviendra dans un futur proche la
version 1.50. J'ai eu également quelques comportements bizarres avec
mod_perl2
, qui ont disparus en fonctionnant en simple script CGI ;
cette nouvelle version promet de mieux fonctionner avec mod_perl2
.
J'ai à disposition les ports de la version 1.030099.
La version 1.02 (que je déconseille) utilise Crypt::DH, qui demande en
premier Math::BigInt::GMP, qui n'est pas dans les dépendances par
défaut sous FreeBSD. Pour éviter un avertissement à chaque exécution
du script, on installe le port math/p5-Math-BigInt-GMP
.
2.2. Script simple
La dernière version de Net::OpenID::Server contient un exemple : server.cgi. Le numéro de version explicite pour la version de Net::OpenID::Server nécessaire est à respecter (il y a eu des évolutions récentes, lorsque Robert Norris a repris le rôle de mainteneur).
Quatre actions possibles :
- endpoint, qui est l'interface pour le RP ;
- setup, qui permt à l'utilisateur de dire s'il fait confiance à la RP ;
- login, la cible du formulaire du setup ;
- user, qui implémente les URLs d'identité, les renseignements qui sont donnés à la RP.
Chaque action est implémentée par une méthode handle_$action
.
Page de test d'authentification d'OpenID : http://www.openid-ldap.org/test.php. Malheureusement, les erreurs remontées par php-openid ne sont pas très détaillées… Il y a aussi http://test-id.net, qui fait des tests beaucoup plus poussés.
Vous devez pouvoir coller ce script dans un dossier qui permet
l'exécution de scripts CGI et donner comme identifiant OpenID
http://example.org/server.cgi?action=user
. Le mot de passe demandé
n'étant pas encore vérifié, vous pouvez entrer n'importe quoi. Après
succès, vous serez identifié comme
http://example.org/server.cgi?action=user;user=pseudo
.
Dans handle_endpoint
et dans _identity_url
, lors de la
construction d'adresses, j'ai utilisé ";" plutôt que "&" pour séparer
les arguments. Sans cela, le document XRDS généré n'est pas valide,
les "&" n'étant pas échappés.
2.3. Modifier server_secret
Lorsque le serveur est initialisé avec Net::OpenID::Server->new()
,
il faut modifier le sub server_secret
. Si la valeur par défaut
n'est pas modifiée, il devient trivial de créer des authentifications
que votre nouvel OP validera. Il n'est pas obligatoire d'avoir une
valeur qui dépend du temps ; je ne sais pas si cela apporte
grand'chose.
2.4. Personnaliser l'identité retournée
Puisque l'on déploie notre propre OP, on peut en profiter pour avoir,
dans un premier temps, une plus belle identité. L'identité sous
laquelle le RP nous voit est celle qui est renvoyée par le sub
_identity_url
. Ce sub est également passé au constructeur
Net::OpenID::Server->new
.
Attention : l'URL renvoyée par _identity_url
doit être canonique,
avec un http://
au début et un / à la fin. Sans cela, la page de
test de OpenID-LDAP renvoie un simple No matching endpoint found
after discovering %s. Je ne sais pas au juste si cette exigence vient
de php-openid ou du protocole.
Lorsque _identity_url
est appelé, son deuxième argument est
l'utilisateur courant. Dans un OP personnel avec un seul utilisateur,
on pourra renvoyer une chaîne statique. Sinon, le nom d'utilisateur
vient du formulaire de l'action setup
(et, dans le cas de l'action
user
, du paramètre user
). Attention, si _identity_url
est
modifié de façon un peu subtile, is_identity
aura peut-être besoin
d'être adapté en conséquence.
2.5. Ne pas être ouvert à tous
Pour l'exemple, handle_login
accepte toutes les combinaisons de nom
d'utilisateur et de mot de passe. On modifiera rapidement cela pour
que tout le monde ne puisse pas prétendre contrôler notre identité…
À l'inverse, on peut décider de ne pas faire confiance au RP, et lui refuser l'accès à nos identifiants. Cela peut donner quelque chose comme :
sub handle_setup { my ($base, $params) = @_; my $openid = _get_openid_context($base, $params); my $cancel_url = $openid->cancel_return_url("return_to" => $params->{return_to}); my $html = join("\n", "<form method='get' action='$base'>", (map { defined $params->{$_} ? "<input type='hidden' name='$_' value='" . $params->{$_} . "'>" : '' } keys %$params), "username: <input type='text' name='user'><br>", "password: <input type='password' name='pass'><br>", "<input type='hidden' name='action' value='login'>", "<input type='submit' value='login'>", "</form>", "<p><a href='$cancel_url'>I don't trust <tt>", $params->{realm} || $params->{trust_root}, "</tt></a>.</p>", ); return [ 200, [ 'Content-Type' => 'text/html', ], [ $html ] ]; } sub handle_login { my ($base, $params) = @_; my $user = delete $params->{user}; my $pass = delete $params->{pass}; # now we know who the user is we can construct their identity url and # include it in the parameters $params->{identity} = _identity_url($base, $user); # get the Net::OpenID::Server object configured the way we want it. this # time we include the user as well my $openid = _get_openid_context($base, $params, $user); my $return_url; if (valid_passwd($user, $pass)) { # the password entered is not correct. we could redirect the # user to the setup form instead my %cancel_params = ("return_to" => $params->{return_to}); $return_url = $openid->cancel_return_url(%cancel_params); } else { # otherwise, the user trusts the RP $return_url = $openid->signed_return_url(%$params); } return [ 301, [ 'Location' => $return_url, ], [ ] ]; }
Écrire une version de valid_passwd
qui est adaptée à ses besoins est
laissé en exercice au lecteur. De la même façon, le formulaire imprime
les paramètres tels qu'ils sont donnés par le site distant ; les
échapper (avec CGI::escapeHTML
) serait probablement une bonne idée.
Noter dans handle_login
l'utilisation de delete
pour accéder aux
champs définis par nous. Les diverses méthodes de Net::OpenID::Server
ont tendance à vérifier qu'on ne leur passe pas d'arguments inconnus
dans les invocations. Il faut donc supprimer ces champs
supplémentaires.
2.6. Utiliser SREG pour fournir quelques renseignements
Il y a du code pour gérer cette extension dans
Net::OpenID::Extensions::SimpleRegistration. Malheureusement, si
l'infrastructure pour utiliser SREG depuis un RP est en place dans
Net::OpenID::Consumer, elle ne semble pas l'être dans
Net::OpenID::Server. On peut quand même jouer avec l'ajout de
paramètres dans additional_fields
, avec quelque chose comme :
sub handle_login { # ... # add some information about ourselves $params->{'additional_fields'}{'sreg.nickname'} = "Fred"; $return_url = $openid->signed_return_url(%$params);
C'est là qu'il devrait y avoir le plus de travail à faire du côté de
handle_setup
pour préremplir les paramètres demandés (accessibles
avec $params->{'sreg.required'}
et $params->{'sreg.optional'}
),
tout en laissant à l'utilisateur la possibilité de les modifier ou de
ne pas les envoyer.
On notera que malgré le nom, ce n'est pas une erreur que de renvoyer
au RP une réponse qui ne contient pas les champs qui sont dans
sreg.required
. La liste des champs qui peuvent être demandés à
l'utilisateur est fixée par la norme.