Problem: You want to create a system whereby when a user authenticates to example.com
, they're also automatically logged in at foo.com
, bar.com
, baz.com
, and any other domains that you decide to add to the list at a later date.
Okay, great, that seems straightforward, except there's a complication: The Same Origin Policy prevents you from getting/setting cookies on domains other than the one you control. (Unless your users' browser foolishly disables the Same Origin Policy like Comodo did).
Let's narrow it down a little bit further: Unlike a situation where e.g. "Login with Facebook" would be appropriate, you control all of the domains. They just happen to be different, so the Same Origin Policy kicks in. For simplicity, feel free to assume they're on the same server and application codebase, but you have a multi-site architecture in place where some of the sites have a different domain name.
Let's work around this limitation with as few moving parts as possible.
Before we go any further, first make sure that OAuth2 or SAML don't already solve your exact use case (particularly if you want users' consent or you don't already control every domain in scope of single sign-on). Generally: follow the standards before blog posts.
That said, OAuth2 and SAML are trying to solve the 99% problem, instead of the 50% problem.
Is there a way to solve this problem, securely, without dealing with OAuth, SAML, LDAP, JSONP (or JavaScript in general)? It turns out: Yes.
We propose a simpler solution (which may NOT be right for you). Instead of mucking with HTTP headers and various types of tokens, or having to implement and secure a server-side XML parser (at minimum, with libxml_disable_entity_loader()
), we propose a solution that only requires:
- Halite, our user-friendly libsodium wrapper
- A simple REST API endpoint that accepts Ed25519-signed JSON messages and optionally serves an image
Secure Automatic Login with Multiple Domains using Libsodium
First, you need a secure login system. This means:
- Securely handling your users' passwords
- If you need it, make sure your "remember me" (persistent authentication) cookies are securely implemented
We recommend not writing it yourself to anyone who can't, off the top of their heads (i.e. with no reference materials), explain what N
, p
, and r
mean in terms of scrypt. (Or in other words, isn't a cryptography engineering and secure software development expert.)
Easy mode: Use Gatekeeper.
Got it implemented? Good, here's where the fun part begins.
The Setup and Design Phase
First, we need to create what Halite calls a SignatureKeyPair
. You can either generate one randomly and store it for long-term use, or (if you prefer) derive it from a password and salt. Since we're going for simplicity, we'll opt for the first approach:
<?php
use \ParagonIE\Halite\KeyFactory;
// Do this once:
$sign_keypair = KeyFactory::generateSignatureKeyPair();
$sign_secretkey = $sign_keypair->getSecretKey();
$sign_publickey = $sign_keypair->getPublicKey();
KeyFactory::save($sign_secretkey, '/path/to/secretkey');
KeyFactory::save($sign_publickey, '/path/to/publickey');
// To load one of the keys at run-time, use the appropriately named method:
$sign_secretkey = KeyFactory::loadSignatureSecretKey('/path/to/secretkey');
$sign_publickey = KeyFactory::loadSignaturePublicKey('/path/to/publickey');
For every domain that needs to support transparent automatic authentication, we are going to:
- Define a special URL endpoint (e.g.
/halite_auto_login
) - Make sure every endpoint has access to your public key.
- Make sure every endpoint has access to the same data persistence layer, e.g. relational database. Otherwise, an OAuth workflow would make more sense.
- (Optional) Share a few "status" icons, e.g. (from the Silk icon set by Mark James), across each domain.
The API Endpoint
First thing's first, make sure session_start()
is invoked (and you're implementing secure PHP sessions).
We should expect three GET parameters, only two of them are required:
- A
payload
, which is a hex-encoded JSON message. - A
signature
, which is a hex-encoded string that authenticates thepayload
. - (Optional) UUID that references the particular
SignaturePublicKey
, in case multiple sites can issue the automatic logins (this is purely an optimization). You can omit this if you only have one keypair.
Handling the payload is straightforward:
<?php
use \ParagonIE\Halite\Asymmetric\{
Crypto as AsymmetricCrypto,
SignaturePublicKey
};
class YourAPIController
{
// ... snip ...
/**
* Handle the payload
*
* @param string $payload (hex-encoded)
* @param string $signature (hex-encoded)
* @param SignaturePublicKey $publicKey
* @return array (Note: you probably want to create an object in real life)
* @throws InvalidPayloadException
*/
protected function handlePayload(
string $payload,
string $signature,
SignaturePublicKey $publicKey
): array {
$payload = \Sodium\hex2bin($payload);
if ($payload === false) {
throw new InvalidPayloadException("Payload must be hex characters");
}
if (!AsymmetricCrypto::verify($payload, $publicKey, $signature)) {
throw new InvalidPayloadException("Invalid signature or payload");
}
$unloaded = json_decode($payload, true);
if ($unloaded === false) {
// This is, with overwhelming certainty, a sender error.
throw new InvalidPayloadException("Invalid payload (valid signature)");
}
return $unloaded;
}
// ... snip ...
}
The payload, once verified, should contain at least a token/nonce and an expiration date/time (which should be no more than 60 seconds unless you're anticipating especially slow clients), which you will then use with a database lookup to authenticate the user. Only use each token once.
If an unused and not expired nonce/token is provided, grab the user ID for that token from the database and set the appropriate session variable to that value.
If you want to be fancy, send a Content-Type
header and display an appropriate icon to the user.
The Automatic Login
First, validate the user's credentials (username, passphrase, MFA token, etc.) as normal. Upon successful login:
- Generate a nonce for every other domain.
- Associate each nonce with that user account in the database.
- Create and sign the payload (nonce, expiration time).
- Insert an image with the payload into the HTML document.
For example:
<?php
use \ParagonIE\Halite\Asymmetric\Crypto as AsymmetricCrypto;
class YourLoginController
{
// ...
protected function autoLoginHTMLCode(string $domain, int $userID): string
{
$nonce = base64_encode(random_bytes(32));
$expires = (new DateTime('now'))
->add(new DateInterval('PT05M')) // 5 Minutes
->format('Y-m-d\TH:i:s');
$stmt = $this->db->prepare(
"INSERT INTO cross_site_logins (domain, user, nonce, expires) VALUES (?, ?, ?, ?);"
);
$stmt->execute([$domain, $userID, $nonce, $expires]);
$payload = json_encode([
'nonce' => $nonce,
'expires' => $expires
]);
$signature = AsymmetricCrypto::sign($payload, $this->signatureSecretKey);
return '<img src="https://'.$domain.'/halite_auto_login?' . http_build_query([
'payload' => \Sodium\bin2hex($payload),
'signature' => $signature
]).'" />';
}
// ...
}
This will create an image tag that will send a cross-domain HTTP request. If it succeeds, it will return an image indicating a success status. If it doesn't, it will return a different image.
Security Considerations
- The first thing your auto-login API handler should do is verify the Ed25519 signature.
- Only allow a nonce to be used for a given domain.
- After a successful automatic login, delete the nonce from the database. Only allow them to succeed once, to prevent replay attacks.
- Expiration times should, ideally, be less than one minute. However, if your end users have a slow or unreliable Internet connection, this could create problems.
The system above is only secure if the following assumptions hold true:
- The system's random number generator (used by
random_bytes()
) is secure. - The signing key used to sign requests to the API is unknown to attackers.
- All systems are using HTTPS in the most secure configuration possible (i.e. TLS 1.2 with AES-GCM or ChaCha20-Poly1305 and 2048-bit RSA or 256-bit ECDSA certificates with HSTS and HPKP). This is a prerequisite in order for PHP sessions to be secure.
Even if an attacker can arbitrarily insert valid "nonces" into the database for a valid user, without the signing key, they can't bypass the signature verification logic. However, if an attacker can arbitrarily alter database records, they can probably just reset arbitrary users' passwords so you have bigger problems.