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à.

openid-flux.png
Figure 1 : Flux OpenID en mode sans état

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.

openid-associate.png
Figure 2 : Échange de clefs entre le RP et l'OP

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.

Auteur : Frédéric Perrin

Date : samedi 1 janvier 2011, modifié le samedi 5 février 2011

Sauf mention contraire, les textes de ce site sont sous licence Creative Common BY-SA.

Ce site est produit avec des logiciels libres 100% élevés au grain et en plein air.