Accueil > OpenID Connect OAuth Server par DnC > Développer > Ressources pour les développeurs > Intégrer OpenID Connect dans vos applications

Nous avons donné ici une démarche pour intégrer OAuthSD dans une application de façon élémentaire. Cette rubrique présente des informations plus techniques à l’intention des développeurs.

API HTTP REST

  (publié initialement le jeudi 7 mars 2019) par DnC

OAuthSD expose une API HTTP REST qui permet d’automatiser certaines tâches autour du serveur d’authentification. Une application d’administration (supervision) peut ainsi contrôler les données du serveur.
L’API s’appuie sur HTTPFoundation de Symphony. Les objets sont manipulés au format Collection+JSON ou sous un format json simple.

L’API répond à une unique action « http.api » qui va gérer trois paramètres :

- un format : c’est le nom de l’API réellement implémentée : json, collectionjson.
- un type (ou une collection) : le type des données qu’on veut utiliser : auteurs, users, clients ...
- une ressource : l’identifiant unique d’un contenu (un nombre entier).

Ces trois paramètres sont tout simplement ajoutés à la suite dans l’URL, séparés par des « / ». Seul le premier est toujours obligatoire, les autres sont ajoutés suivant ce que l’on veut manipuler. On se retrouve alors avec trois familles d’Url de requêtes.

Exemple de requête d’action :

https://oa.dnc.global/web/http.api/collectionjson/users/3

La deuxième forme peut également être complétée par des paramètres d’URL. Exemple :

Exemple de requête avec paramètres :

https://oa.dnc.global/web/http.api/collectionjson/users/?offset=10&length=10

L’API permet d’effectuer les opérations CRUD [1] sur une table de la base de données en fonction de la méthode de la requête HTTP GET, POST, PUT ou DELETE, si toutefois les permissions sont accordées.

L’API s’efforce de répondre à la spécification de sémantique HTTP 1.1.

Types

Pour s’interfacer avec une application de supervision, OAuth SD expose les types :
- oidc_logs,
- users,
- clients
- auteurs.
D’une façon générale, les types correspondent aux tables éponymes.
Sauf exception (voir la constante de configuration API_HTTP_CHAMPS_SENSIBLES), tous les champs d’une table peuvent être manipulés.

Formats

Dans le cadre d’OAuthSD, les objets sont manipulés aux formats suivants :

collectionjson
Ce format est assez lourd, retournant les données sous forme d’arrays associatifs et d’objets, ce qui permet de nombreuses applications. Voir : Collection+JSON.

Exemple : la requête :

https://oa.dnc.global/web/http.api/collectionjson/oidc_logs/?offset=3000&length=2

retourne :

  1. {
  2.   "collection": {
  3.     "version": "1.0",
  4.     "href": "https://oa.dnc.global/web/http.api/collectionjson/oidc_logs/?offset=3000&length=2",
  5.     "items": [
  6.       {
  7.         "href": "https://oa.dnc.global/web/http.api/collectionjson/",
  8.         "links": [
  9.           {
  10.             "rel": "edit",
  11.             "href": "https://oa.dnc.global/web/http.api/collectionjson/"
  12.           },
  13.           {
  14.             "rel": "alternate",
  15.             "type": "text/html",
  16.             "href": "https://oa.dnc.global/web/urls_propres2_dist"
  17.           }
  18.         ],
  19.         "data": [
  20.           {
  21.             "name": "id_oidc_log",
  22.             "value": "3400"
  23.           },
  24.           {
  25.             "name": "remote_addr",
  26.             "value": "173.208.157.186"
  27.           },
  28.           {
  29.             "name": "state",
  30.             "value": ""
  31.           },
  32.           {
  33.             "name": "client_id",
  34.             "value": "testopenid4"
  35.           },
  36.           {
  37.             "name": "user_id",
  38.             "value": "Unk"
  39.           },
  40.           {
  41.             "name": "datetime",
  42.             "value": "2019-03-06 03:04:09"
  43.           },
  44.           {
  45.             "name": "origin",
  46.             "value": "Authorize"
  47.           },
  48.           {
  49.             "name": "message",
  50.             "value": "2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin\n\n"
  51.           },
  52.           {
  53.             "name": "level",
  54.             "value": "1"
  55.           },
  56.           {
  57.             "name": "weight",
  58.             "value": "0"
  59.           },
  60.           {
  61.             "name": "errnum",
  62.             "value": "1"
  63.           }
  64.         ]
  65.       },
  66.       {
  67.         "href": "https://oa.dnc.global/web/http.api/collectionjson/",
  68.         "links": [
  69.           {
  70.             "rel": "edit",
  71.             "href": "https://oa.dnc.global/web/http.api/collectionjson/"
  72.           },
  73.           {
  74.             "rel": "alternate",
  75.             "type": "text/html",
  76.             "href": "https://oa.dnc.global/web/urls_propres2_dist"
  77.           }
  78.         ],
  79.         "data": [
  80.           {
  81.             "name": "id_oidc_log",
  82.             "value": "3401"
  83.           },
  84.           {
  85.             "name": "remote_addr",
  86.             "value": "173.208.157.186"
  87.           },
  88.           {
  89.             "name": "state",
  90.             "value": "fc06ce8d03c189c7af1167d2e16e4a9a"
  91.           },
  92.           {
  93.             "name": "client_id",
  94.             "value": "testopenid4"
  95.           },
  96.           {
  97.             "name": "user_id",
  98.             "value": "Unk"
  99.           },
  100.           {
  101.             "name": "datetime",
  102.             "value": "2019-03-06 03:04:09"
  103.           },
  104.           {
  105.             "name": "origin",
  106.             "value": "Authorize"
  107.           },
  108.           {
  109.             "name": "message",
  110.             "value": "2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin authentification for client = testopenid4\n\n"
  111.           },
  112.           {
  113.             "name": "level",
  114.             "value": "1"
  115.           },
  116.           {
  117.             "name": "weight",
  118.             "value": "1"
  119.           },
  120.           {
  121.             "name": "errnum",
  122.             "value": "110"
  123.           }
  124.         ]
  125.       }
  126.     ],
  127.     "objects": [
  128.       {
  129.         "id_oidc_log": "3400",
  130.         "remote_addr": "173.208.157.186",
  131.         "state": "",
  132.         "client_id": "testopenid4",
  133.         "user_id": "Unk",
  134.         "datetime": "2019-03-06 03:04:09",
  135.         "origin": "Authorize",
  136.         "message": "2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin\n\n",
  137.         "level": "1",
  138.         "weight": "0",
  139.         "errnum": "1"
  140.       },
  141.       {
  142.         "id_oidc_log": "3401",
  143.         "remote_addr": "173.208.157.186",
  144.         "state": "fc06ce8d03c189c7af1167d2e16e4a9a",
  145.         "client_id": "testopenid4",
  146.         "user_id": "Unk",
  147.         "datetime": "2019-03-06 03:04:09",
  148.         "origin": "Authorize",
  149.         "message": "2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin authentification for client = testopenid4\n\n",
  150.         "level": "1",
  151.         "weight": "1",
  152.         "errnum": "110"
  153.       }
  154.     ]
  155.   }
  156. }

Télécharger

Format json
Retourne un simple array. Exemple : la requête :

https://oa.dnc.global/web/http.api/json/oidc_logs/?offset=3000&length=2

retourne les mêmes éléments sous une forme très compacte :

  1. [
  2. {"id_oidc_log":"3400","remote_addr":"173.208.157.186","state":"","client_id":"testopenid4","user_id":"Unk","datetime":"2019-03-06 03:04:09","origin":"Authorize","message":"2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin","level":"1","weight":"0","errnum":"1"}
  3. ,
  4. {"id_oidc_log":"3401","remote_addr":"173.208.157.186","state":"fc06ce8d03c189c7af1167d2e16e4a9a","client_id":"testopenid4","user_id":"Unk","datetime":"2019-03-06 03:04:09","origin":"Authorize","message":"2019-03-06 03:04:09 - 173.208.157.186 - Information : Authorize : Begin authentification for client = testopenid4",
  5. "level":"1","weight":"1","errnum":"110"}
  6. ]

Télécharger

Le format collectionjson est intéressant en lecture. En écriture, on préférera le format json qui suffit à décrire les objets à enregistrer.

Actions

Suivant le paradigme HTTP Rest, les actions découlent de la méthode HTTP utilisée pour appeler l’API : get, post, put ou delete.

Formes générales des requêtes GET

- Afficher la liste des collections disponibles sous le format indiqué :

https://oa.dnc.global/web/http.api/<format>

- Liste le contenu de la collection "type" :

https://oa.dnc.global/web/http.api/<format>/<type>

- Liste les éléments sélectionnés à l’aide des paramètres d’URL indiqués :

https://oa.dnc.global/web/http.api/<format>/<type>?<paramètres>

- Accès à une ressource précise :

https://oa.dnc.global/web/http.api/collectionjson/<type>/NN

NN est la valeur de l’identifiant (champ id_type, clé unique) de l’enregistrement dans la table.

Requêtes avec POST, PUT et DELETE

Ces requêtes doivent être générées programmatiquement : par exemple avec cURL côté serveur ou en Javascript côté client. On trouve couramment des bibliothèque exposant des fonctions de ce type :

- post_collection($requete, $reponse)
Créer une nouvelle ressource dans la collection (créer un nouvel enregistrement dans la table).

- put_ressource($requete, $reponse)
Ce cas est sensiblement le même que le précédent, à ceci près que l’on travaille sur la modification d’une ressource précise.

- delete_ressource($requete, $reponse)
Supprimer définitivement une ressource.

Pour la transmission des données aux requêtes avec POST et PUT, deux formats sont possibles :
- application/json :

  1. $data = array(
  2.     'client_id' => 'testtest',
  3.     'client_secret' => 'TheBigSecret',
  4.     'redirect_uri' => 'https://oa.dnc.global/web/?action=oauth',
  5.     'grant_types' => 'authorization_code',
  6.     'scope' => 'openid sli',
  7. );
  8.  
  9. $h = curl_init("https://oa.dnc.global/web/http.api/json/clients/");
  10. curl_setopt($h, CURLOPT_RETURNTRANSFER, true);
  11. curl_setopt($h, CURLOPT_TIMEOUT, 10);
  12. curl_setopt($h, CURLOPT_POST, true);
  13. $data_string = json_encode($data);  
  14. curl_setopt($h, CURLOPT_POSTFIELDS, $data_string);
  15. curl_setopt($h, CURLOPT_HTTPHEADER, array(                                                                          
  16.     'Content-Type: application/json',                                                                                
  17.     'Content-Length: ' . strlen($data_string),
  18.     'Accept: application/json')                                                    
  19. );

Télécharger

- application/x-www-form-urlencoded :

  1. ...
  2. curl_setopt($h, CURLOPT_HTTPHEADER, array(
  3.     'Content-Type: application/x-www-form-urlencoded',
  4.      'Accept: application/json')
  5. );  
  6. curl_setopt($h, CURLOPT_POSTFIELDS, http_build_query($data));

Télécharger

Actions possibles

Toutes les combinaisons format/type/action ne sont pas disponibles.

En premier lieu, l’accès à l’API est subordonné à l’authentification de l’application cliente et de l’utilisateur final par OAuthSD.

Ensuite, toutes les combinaisons type/action ne sont pas autorisées. Des droits sont définis pour chaque action en fonction du profil de l’auteur (administrateur d’applications) authentifié.

Enfin, certaines actions ne sont pas (ou pas encore) implémentées.

Protection des données

Les données très sensibles (conditionnant la sécurité de l’authentification) sont protégées, elles ne peuvent être vues ni a fortiori modifiées à l’aide de l’API.

Dans l’état actuel, les champs suivants, définis par la constante API_HTTP_CHAMPS_SENSIBLES, ne sont pas transmis : client_secret, client_ip, password’.

clients (Applications clientes)

Requêtes GET

Le type clients permet de nombreuses requêtes GET avec les formats collectionjson et json, notamment avec les paramètres offset et length. La requête suivante est pratique pour générer des tableaux paginés :

https://oa.dnc.global/web/http.api/collectionjson/clients/?offset=100&length=10

Pour assurer la sécurité de l’authentification :
- les donnée sensibles "client_secret" et "client_ip" ne seront pas retournées,
- les paires de clés publiques/privées associées ne peuvent être atteintes,

Edition : requêtes POST, PUT, DELETE

Exemple d’écriture d’un nouvel enregistrement en PHP avec cURL. Les données sont transmises au format application/x-www-form-urlencoded [2] :

  1. $data = array(
  2.     'client_id' => 'testtest',
  3.     'client_secret' => 'TheBigSecret',
  4.     'redirect_uri' => 'https://oa.dnc.global/web/?action=oauth',
  5.     'grant_types' => 'authorization_code',
  6.     'scope' => 'openid sli'
  7. );
  8.  
  9. $h = curl_init("https://oa.dnc.global/web/http.api/json/clients/");
  10. curl_setopt($h, CURLOPT_RETURNTRANSFER, true);
  11. curl_setopt($h, CURLOPT_TIMEOUT, 10);
  12. curl_setopt($h, CURLOPT_POST, true);
  13. curl_setopt($h, CURLOPT_HTTPHEADER, array('
  14.    Content-Type: application/x-www-form-urlencoded'),
  15.     'Accept: application/json'
  16. );  
  17. curl_setopt($h, CURLOPT_POSTFIELDS, http_build_query($data));
  18.  
  19. $res = curl_exec($h);
  20. ...

Télécharger

En HTML cela équivaudrait à :

  1. <?php
  2. $data = array(
  3.     'client_id' => 'testtest',
  4.     'client_secret' => 'TheBigSecret',
  5.     'redirect_uri' => 'https://oidc.dnc.global/web/?action=oauth',
  6.     'grant_types' => 'authorization_code',
  7.     'scope' => 'openid sli'
  8. );
  9. ?>
  10.  
  11. <form id="test_api_post" name="test_api_post" method="post" action="https://oidc.dnc.global/web/http.api/json/clients/">
  12. <input type="hidden" name="DBGSESSID" value="435347910947900005@127.0.0.1;d=1">
  13. <?php foreach ( $data as $name => $value ) { ?>
  14.     <input type="hidden" id="<?php echo $name;?>" name="<?php echo $name;?>" value="<?php echo $value;?>">
  15. <?php } ?>
  16.     <input type="submit">
  17. </form>

Télécharger

En cas de succès, la table clients est mise à jour et l’API retourne dans le corps de la réponse un array Json avec le client_id et l’id_client (index unique de la table clients) :

  1. {"
  2.    client_id":"testtest",
  3.     "id_client":43
  4. }

Télécharger

En cas de duplication, la réponse suivante est retournée :

  1. {
  2.   "collection": {
  3.     "version": "1.0",
  4.     "href": "https://oa.dnc.global/web/http.api/json/clients/",
  5.     "error": {
  6.       "title": "Erreur",
  7.       "code": 500
  8.     },
  9.     "errors": [
  10.       {
  11.         "title": "Erreur SQL 1062 Duplicate entry 'testtest' for key 'client_id'",
  12.         "code": 500
  13.       }
  14.     ]
  15.   }
  16. }

Télécharger

users (Utilisateurs finaux)

Le type users permet de nombreuses requêtes GET avec les formats collectionjson et json, notamment avec les paramètres offset et length. La requête suivante est pratique pour générer des tableaux paginés :

https://oa.dnc.global/web/http.api/collectionjson/users/?offset=100&length=10

Notez que la donnée sensible "password" ne sera pas retournée.

Enregistrement d’un utilisateur final sur le serveur OIDC

Le type users permet l’écriture (requêtes POST, PUT et DELETE).

Pour fonctionner, le contrôleur Authorize du serveur serveur OIDC n’a besoin que d’un username (ou d’un e-mail) et d’un mot de passe. Le contrôleur exploite également le contenu des autres champs fournis s’ils existent dans la table users et ne sont pas nuls.
En particulier, le champ scope peut être utilisé pour transmettre des autorisations propres à l’utilisateur.

Pour assurer la sécurité de l’authentification :
- lors de l’écriture, le secret client transmis en clair est enregistré sous forme de password_hash avec l’algorithme PASSWORD_BCRYPT.

auteurs (Administrateurs d’applications ou "superviseurs")

oidc_logs (événements)

Le type oidc_logs n’autorise que les requêtes suivantes, sous les formats json et collectionjson :

- Les événements étant triés par date décroissante, retourne length evenements depuis le rang 0 (donc les derniers événements) :

https://.../http.api/<format>/oidc_logs/?length=<nombre>

- Les événements étant triés par date décroissante, retourne length evenements depuis le rang offset :

https://.../http.api/<format>/oidc_logs/?offset=<rang>&length=<nombre>

- Les événements étant triés par date décroissante, retourne length événements autour du plus proche du temps indiqué par le timeserial ts :

https://.../http.api/<format>/oidc_logs/?ts=<timeserial>&length=<nombre>

- Retourne les évènements entre les timeserials tmin et tmax :

https://.../http.api/<format>/oidc_logs/?tmin=<timeserial>&tmax=<timeserial>

Notes :
- Il est interdit de lister l’ensemble de la collection.
- length est limité à 10000, sans génération d’erreur.

Sécurisation de l’accès à l’API

Deux configurations sont envisagées :
- Le serveur qui porte l’API est accessible uniquement par des applications de confiance à travers un canal sûr (par exemple au sein d’un réseau local d’entreprise bien isolé). Dans ce cas, la constante API_HTTP_AUTHENTICATE est fixée à ’false’ et des vérifications simples sont effectuées sur l’IP et/ou le domaine du client origine de la requête. Les constantes API_HTTP_CLIENT_IP et API_HTTP_CLIENT_HOST définissent les valeurs attendues.
- Le serveur se trouve sur un réseau public. Dans ce cas, la constante API_HTTP_AUTHENTICATE est fixée à ’true’ et l’application cliente doit s’authentifier avec le protocole OAuth 2.0. Pour ce faire, l’application cliente devra être enregistrée sur ce ce serveur OAuthSD avec les flux Authorization Code et Client Credentials. Notez que les contraintes définies par les constantes API_HTTP_CLIENT_IP et API_HTTP_CLIENT_HOST s’appliquent également.

Lorsqu’une application cliente veut accéder à l’API au nom d’un utilisateur final :
- l’application authentifie son utilisateur final avec le flux Authorization Code,
- si l’utilisateur final est authentifié, et seulement dans ce cas, l’application demande une autorisation d’accès en son nom propre avec le flux Client Credentials, puis passe le jeton d’accès obtenu dans l’appel à l’API,
- l’API vérifie le jeton d’accès par introspection.

En fait, l’API peut aussi bien valider un jeton d’accès qu’un jeton d’identification. L’application cliente peut donc s’authentifier directement avec le jeton d’identité obtenu avec le flux Authorization Code.

Lorsqu’une application cliente veut accéder à l’API en son nom propre :
- l’application demande une autorisation d’accès avec le flux Client Credentials, puis passe le jeton d’accès obtenu dans l’appel à l’API,
- l’API vérifie le jeton d’accès par introspection.

Le jeton peut être passé à l’API sous le nom ’token’ par les méthode POST ou GET, mais il est recommandé d’utiliser la méthode Auth Bearer.

Options

Les options suivantes sont définies dans le fichier collectionjsonoauthsd_options.php :

/* Cette API permet l’accès à la plupart des données manipulées par SPIP, les
types correspondant aux tables éponymes de la base de données. Cependant, autant
pour des raisons pratiques que de sécurité, les types servis sont limités à ceux
qui seront définis ici.
Si la chaine est ’’, tous les types seront autorisés.
Attention : le type est au singulier !*/
define(’API_HTTP_TYPES_AUTORISES’,’oidc_log,user,client,auteur,credential’) ;

/* S’agissant d’un serveur d’authentification, certaines données sont particulièrement
sensibles et doivent rester sanctuarisées.*/
define(’API_HTTP_CHAMPS_SENSIBLES’,’client_secret,client_ip,password’) ;

/* Si l’API doit être accessible autrement que par un client de confiance par
un canal sûr, il faut authentifier l’origine de la requête.
Cette constante peut aussi être fixée à false pendant le développement */
define(’API_HTTP_AUTHENTICATE’, false) ;

/* Si API_HTTP_AUTHENTICATE est fixé à true, l’authentification du client requiert
un jeton d’identité valide. Celui-ci contient la déclaration sub égale à l’id du
client (client_id). Si la constante suivante est non nulle, sub devra être égal à
la valeur indiquée. */
define(’API_HTTP_CLIENT_ID’, ’’) ;

/* Si API_HTTP_AUTHENTICATE a été fixé à false, il convient d’appliquer tout ou
partie des vérifications suivantes : */
define(’API_HTTP_CLIENT_IP’, ’’) ; // Vérifier que la requête vient de l’IP indiquée si non nulle
define(’API_HTTP_CLIENT_HOST’, ’’) ; // Vérifier que la requête vient du host indiqué si non nul

/* Si la requête ne définit pas le paramètre limit, les requêtes acceptant le
paramètre limit utiliseront la valeur suivante par défaut :*/
define (’API_DEFAULT_LENGTH’, 100) ;

/* Nombre maximum de lignes que peut retourner une requête (dans le cas où le paramètre
limit n’est pas requis, comme quand la requête n’accepte que les paramètres tmin et/ou tmax) :*/
define (’API_MAX_ITEMS_RETURNED’, 1000) ;

Utilisation de l’API par OAuthSD lui-même

OAuthSD utilise l’API notamment pour :
- La présentation des événements : charger les données de la table oidc_logs avec Ajax pour les présenter en scrolling continu. Voir le tableau de la page Statistiques.
- Permettre la supervision du serveur par une application extérieure.

Stylage des formulaires d’identification

  publié le par DnC

OAuthSD offre deux moyens d’apdater les formulaires à la charte graphique d’une organisation :
- my.css féfinit les styles propres à une organisation, qui s’appliqueront à tous les clients inscrits sur le serveur,
- le champ css de la table Clients sera éventuellement utilisé pour attribuer un stylage particulier à une application.

Structure générale des formulaires

Le corps des formulaires présente la structure HTML suivante :

  1. ...
  2. <div id="page" class="(le nom de la méthode)">    
  3.        <div id="top"></div>
  4.         <div id="container">  
  5.             <h3 class="head-title"> ... </h3>
  6.             ...
  7.             <div class="error"> ... </div>
  8.             ...
  9.         </div>
  10.     <div id="bottom">
  11.         ...
  12.     </div>
  13. </div>

Télécharger

Adaptation de la charte graphique

Styles par défaut

Par défaut, les styles des formulaires d’identification sont définis dans la section head de chacun d’eux.

Fichier my.css

S’il existe un fichier /my.css à la racine du serveur, celui-ci est adopté comme feuille de style.

Ce fichier pourra être utilisé pour insérer un logo de l’organisation. Le champ #top est sans doute bien adapté à recevoir une image en background.

Champ css de la table Clients

Qu’il s’agisse de la feuille de style ou du défaut, les styles peuvent être complétés ou surchargés par le contenu du champ css de la table clients. Dans l’exemple ci-dessous, la variable $thecss contient les données de ce champ.

Ces styles intervenant en final, ils l’emportent sur les précédents.

Exemple

Voici par exemple les définitions de styles du formulaire de login :

PHP

  1. // Styles
  2.  
  3. $thecss = ( empty($data['css'])? '' : htmlspecialchars($data['css']) );
  4.  
  5. if ( file_exists('my.css') ) { //[dnc37]
  6.     $style = '<link rel="stylesheet" type="text/css" href="my.css">" .';
  7.     if ( !empty($thecss) ) $style .= $thecss;
  8. } else {
  9.     $style = '
  10.    <style>
  11.    body {font-family: "Century Gothic", Helvetica, Arial, sans-serif !important;}
  12.    #page {margin-left: auto; margin-right: auto; max-width: 360px; padding: 1.5em; border: solid 1px grey; border-radius: 10px; box-shadow: 10px 5px 5px silver;}
  13.    .head-title {text-align: center; background-color: gray; color: white; padding: 0.5em;}
  14.    .error {text_align: center; color : red; background-color : white; padding: 6px;}
  15.    .bouton {margin:15px; cursor:pointer;}
  16.    .tfacode {text-align: center;}
  17.    #champ_login {box-shadow: 10px 5px 5px silver; margin-bottom: 10px;}
  18.    #champ_password {border: none;};
  19.    #btn_reset {float: left;}
  20.    #btn_submit {float: right;}
  21.    #submit {width:100px; font-size: 1.1em;}
  22.    #nologin {clear: both; margin-bottom: 0.5em;}
  23.    #ghostkeys {height: 120px; text-align: center;}
  24.    #ghostkeys > img {box-shadow: 10px 5px 5px silver;}
  25.    #bottom {color : grey; font-size: .8em;}
  26.    #bottom a {color : grey;}
  27.    ' . $thecss . '
  28.    </style>
  29.    ';    
  30.     }

Télécharger

Formulaire d’identification avec GhostKeys