mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-09-04 23:17:02 +03:00
Add sso-auth plugin: transparent SSO via reverse proxy header
Authenticates users via a trusted reverse proxy that sets an HTTP header with the authenticated user's e-mail address (e.g. Authelia, Caddy, Traefik, Nginx auth_request). Unlike the existing proxy-auth plugin, no master IMAP user is required. Users enter their own IMAP password once; it is stored encrypted and reused for all future logins. Features: - Works with any standard IMAP server - Credentials stored encrypted per-user (APP_SALT + SSO email) - Session identity tracking via cookie to handle user switches - Loop-guard cookie prevents redirect loops on failed auto-login - Additional accounts (e.g. Gmail) continue to work normally - Configurable header name and redirect URL Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c154d23cfe
commit
7cb59eb627
2 changed files with 434 additions and 0 deletions
125
plugins/sso-auth/README.md
Normal file
125
plugins/sso-auth/README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# SnappyMail SSO Auth
|
||||||
|
|
||||||
|
Transparent single sign-on for SnappyMail via a trusted reverse proxy that sets an HTTP header with the authenticated user's e-mail address.
|
||||||
|
|
||||||
|
Works with any forward-auth proxy: **Authelia**, **Caddy**, **Traefik**, **Nginx** (auth_request), or any other solution that sets a `Remote-Email`-style header after authentication.
|
||||||
|
|
||||||
|
## How it differs from `proxy-auth`
|
||||||
|
|
||||||
|
| | `proxy-auth` | `sso-auth` |
|
||||||
|
|---|---|---|
|
||||||
|
| IMAP credentials | Requires a master user account on the IMAP server | Uses each user's own credentials |
|
||||||
|
| IMAP server requirement | Must support master user / impersonation | Any standard IMAP server |
|
||||||
|
| First login | Fully automatic | User enters own password once |
|
||||||
|
| Subsequent logins | Fully automatic | Fully automatic |
|
||||||
|
|
||||||
|
Use `sso-auth` when you cannot or do not want to configure a master user on your IMAP server.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → Reverse Proxy → SnappyMail
|
||||||
|
↓
|
||||||
|
Sets Remote-Email header
|
||||||
|
↓
|
||||||
|
Plugin reads header
|
||||||
|
↓
|
||||||
|
Credentials stored? ──yes──→ Auto-login (no dialog)
|
||||||
|
↓ no
|
||||||
|
Show login form (e-mail pre-filled)
|
||||||
|
↓
|
||||||
|
User enters IMAP password
|
||||||
|
↓
|
||||||
|
Credentials stored encrypted
|
||||||
|
↓
|
||||||
|
Future visits: fully automatic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Reverse proxy
|
||||||
|
|
||||||
|
Configure your proxy to authenticate the user and set the `Remote-Email` header. Example for **Authelia** with Nginx Proxy Manager:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
auth_request /internal/authelia/authz;
|
||||||
|
auth_request_set $redirection_url $upstream_http_location;
|
||||||
|
auth_request_set $email $upstream_http_remote_email;
|
||||||
|
|
||||||
|
# Return JSON for XHR requests when session expires, redirect for page navigation
|
||||||
|
error_page 401 =200 @authelia_error;
|
||||||
|
location @authelia_error {
|
||||||
|
default_type application/json;
|
||||||
|
if ($request_method = POST) {
|
||||||
|
return 200 '{"Result":true}';
|
||||||
|
}
|
||||||
|
return 302 $redirection_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header Remote-Email $email;
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note on `{"Result":true}` for POST:** When a SnappyMail XHR request arrives with an
|
||||||
|
> expired proxy session, returning this response causes SnappyMail to call
|
||||||
|
> `location.reload()`, which triggers a full page reload. The page load is then
|
||||||
|
> intercepted by the proxy (401 → redirect to login). This avoids error dialogs in the UI.
|
||||||
|
|
||||||
|
### 2. SnappyMail — application.ini
|
||||||
|
|
||||||
|
If your proxy redirects to SnappyMail from a different (sub)domain, allow the
|
||||||
|
`Sec-Fetch-Site: same-site` header so SnappyMail accepts the final `/?sso&hash=` request:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[security]
|
||||||
|
secfetch_allow = "site=same-site"
|
||||||
|
```
|
||||||
|
|
||||||
|
For logout, set a custom logout link pointing to your SSO provider:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[labs]
|
||||||
|
custom_logout_link = "https://auth.example.com/logout?rd=https://mail.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Activate the plugin
|
||||||
|
|
||||||
|
In the SnappyMail admin panel → Extensions → enable **SSO Auth**.
|
||||||
|
|
||||||
|
Configure:
|
||||||
|
|
||||||
|
| Setting | Description | Example |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| **Redirect URL** | Where to redirect when the SSO header is absent. Leave empty to show the normal login dialog. | `https://auth.example.com` |
|
||||||
|
| **Header name** | HTTP header set by the proxy with the user's e-mail address. | `Remote-Email` |
|
||||||
|
|
||||||
|
## Credential storage
|
||||||
|
|
||||||
|
Each user's IMAP password is stored encrypted in:
|
||||||
|
|
||||||
|
```
|
||||||
|
APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The password is encrypted with `APP_SALT + SSO-email` using SnappyMail's built-in
|
||||||
|
`SnappyMail\Crypt` class. No plaintext credentials are stored.
|
||||||
|
|
||||||
|
## Additional accounts
|
||||||
|
|
||||||
|
Users can add extra IMAP accounts (e.g. a Gmail account) via the standard SnappyMail
|
||||||
|
account switcher. The plugin does not interfere with additional account management.
|
||||||
|
|
||||||
|
## Session tracking
|
||||||
|
|
||||||
|
The plugin sets a `_sso_user` cookie (SHA-1 of the SSO e-mail) to track which identity
|
||||||
|
owns the current session. If the proxy reports a different user on the next page load
|
||||||
|
(e.g. after an overnight session where another family member logged in), the old session
|
||||||
|
is cleared and the new user is logged in automatically.
|
||||||
|
|
||||||
|
## Loop protection
|
||||||
|
|
||||||
|
A `_sso_pending` cookie (120 s TTL) is set immediately before the `/?sso&hash=` redirect.
|
||||||
|
If auto-login fails (e.g. wrong stored password), the cookie prevents an infinite redirect
|
||||||
|
loop and the login form is shown instead so the user can re-enter their password.
|
||||||
309
plugins/sso-auth/index.php
Normal file
309
plugins/sso-auth/index.php
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnappyMail SSO Auth Plugin
|
||||||
|
*
|
||||||
|
* Authenticates users transparently via a trusted reverse proxy that sets an
|
||||||
|
* HTTP header containing the authenticated user's e-mail address (e.g. Authelia,
|
||||||
|
* Caddy, Traefik, or any other forward-auth proxy).
|
||||||
|
*
|
||||||
|
* No master IMAP user required. First-time users enter their own IMAP password
|
||||||
|
* once; it is stored encrypted and reused for all future logins.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Reverse proxy authenticates the user and sets the configured header.
|
||||||
|
* 2. Plugin reads the header and looks up stored IMAP credentials.
|
||||||
|
* 3. If credentials exist → create SSO hash → auto-login (no dialog shown).
|
||||||
|
* 4. If no credentials yet → show login form (e-mail pre-filled, password required).
|
||||||
|
* 5. After successful IMAP login → credentials are stored encrypted for next time.
|
||||||
|
*
|
||||||
|
* Storage: APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
use RainLoop\Plugins\AbstractPlugin;
|
||||||
|
use RainLoop\Plugins\Property;
|
||||||
|
use RainLoop\Enumerations\PluginPropertyType;
|
||||||
|
|
||||||
|
class SsoAuthPlugin extends AbstractPlugin
|
||||||
|
{
|
||||||
|
const
|
||||||
|
NAME = 'SSO Auth',
|
||||||
|
AUTHOR = 'Markus Mauch',
|
||||||
|
URL = 'https://github.com/markusmauch',
|
||||||
|
VERSION = '1.0.0',
|
||||||
|
RELEASE = '2026-03-23',
|
||||||
|
REQUIRED = '2.36.0',
|
||||||
|
CATEGORY = 'Security',
|
||||||
|
LICENSE = 'MIT',
|
||||||
|
DESCRIPTION = 'Authenticates users via a trusted reverse proxy header (e.g. Authelia, Caddy, Traefik). '
|
||||||
|
. 'No master IMAP credentials required. IMAP password is entered once and stored encrypted.';
|
||||||
|
|
||||||
|
/** Cookie name used to break redirect loops on failed auto-login */
|
||||||
|
const COOKIE_SSO_PENDING = '_sso_pending';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie that records which SSO identity owns the current SnappyMail session.
|
||||||
|
* Value: SHA-1 of the SSO e-mail, so the actual address is not exposed in the cookie.
|
||||||
|
* Set on every successful SSO login; cleared on logout.
|
||||||
|
*/
|
||||||
|
const COOKIE_SSO_USER = '_sso_user';
|
||||||
|
|
||||||
|
public function Init(): void
|
||||||
|
{
|
||||||
|
$this->addHook('filter.http-paths', 'onFilterHttpPaths');
|
||||||
|
$this->addHook('login.credentials.step-2', 'onLoginCredentialsStep2');
|
||||||
|
$this->addHook('login.success', 'onLoginSuccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configMapping(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Property::NewInstance('redirect_url')
|
||||||
|
->SetType(PluginPropertyType::STRING)
|
||||||
|
->SetLabel('Redirect URL')
|
||||||
|
->SetDescription(
|
||||||
|
'Where to redirect when the SSO header is absent (e.g. your SSO login page). '
|
||||||
|
. 'Leave empty to show the normal SnappyMail login dialog instead.'
|
||||||
|
)
|
||||||
|
->SetDefaultValue(''),
|
||||||
|
|
||||||
|
Property::NewInstance('header_name')
|
||||||
|
->SetType(PluginPropertyType::STRING)
|
||||||
|
->SetLabel('Header name')
|
||||||
|
->SetDescription(
|
||||||
|
'HTTP header set by the reverse proxy containing the authenticated user\'s e-mail address. '
|
||||||
|
. 'Default: Remote-Email'
|
||||||
|
)
|
||||||
|
->SetDefaultValue('Remote-Email'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Hooks
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: filter.http-paths
|
||||||
|
*
|
||||||
|
* Fires very early in every request, before routing. We intercept
|
||||||
|
* index-page requests that are not yet authenticated:
|
||||||
|
*
|
||||||
|
* • No SSO header → redirect to configured URL (or fall through to login dialog)
|
||||||
|
* • SSO header + credentials stored → create SSO hash, redirect to /?sso
|
||||||
|
* • SSO header + no credentials → fall through to login dialog
|
||||||
|
*
|
||||||
|
* @param array $aPaths URL path segments (passed by reference from Service::Handle)
|
||||||
|
*/
|
||||||
|
public function onFilterHttpPaths(array &$aPaths): void
|
||||||
|
{
|
||||||
|
// ── Skip non-index paths (sso, json, mailto, admin, …) ───────────────
|
||||||
|
$sPath = !empty($aPaths[0]) ? strtolower($aPaths[0]) : '';
|
||||||
|
if ($sPath && 'index' !== $sPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skip admin panel requests ────────────────────────────────────────
|
||||||
|
$sAdminKey = \RainLoop\Api::Config()->Get('admin_panel', 'key', '') ?: 'admin';
|
||||||
|
$sQS = trim($_SERVER['QUERY_STRING'] ?? '');
|
||||||
|
if (str_starts_with($sQS, $sAdminKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read SSO e-mail header ───────────────────────────────────────────
|
||||||
|
$sSsoEmail = $this->getSsoEmail();
|
||||||
|
|
||||||
|
// ── Skip if the same SSO identity already owns this session ──────────
|
||||||
|
// We track the active SSO identity via a cookie (_sso_user = sha1 of
|
||||||
|
// SSO e-mail). If an account is active AND the cookie matches the
|
||||||
|
// current SSO e-mail the user is simply browsing — leave the session alone.
|
||||||
|
// If the cookie is missing or belongs to a different SSO identity we
|
||||||
|
// fall through and force a re-login (e.g. overnight session where the
|
||||||
|
// proxy now reports a different user).
|
||||||
|
if (\RainLoop\Api::Actions()->getAccountFromToken(false)) {
|
||||||
|
$sSsoUserHash = $_COOKIE[self::COOKIE_SSO_USER] ?? '';
|
||||||
|
if ($sSsoEmail && $sSsoUserHash === sha1($sSsoEmail)) {
|
||||||
|
return; // Same SSO identity → keep session
|
||||||
|
}
|
||||||
|
// Different SSO identity: force re-login.
|
||||||
|
// Clear stale session cookie so SnappyMail accepts the new login.
|
||||||
|
\SnappyMail\Cookies::clear(\RainLoop\Utils::SESSION_TOKEN);
|
||||||
|
\SnappyMail\Cookies::clear(\RainLoop\Actions::AUTH_ADDITIONAL_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$sSsoEmail) {
|
||||||
|
// No header → redirect to configured SSO login page (if set)
|
||||||
|
$sUrl = trim($this->Config()->Get('plugin', 'redirect_url', ''));
|
||||||
|
if ($sUrl) {
|
||||||
|
\MailSo\Base\Http::Location($sUrl);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return; // No redirect URL configured: show normal login dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── If the previous auto-login attempt failed, show the login form ───
|
||||||
|
// (the _sso_pending cookie is set just before we redirect to /?sso)
|
||||||
|
if (!empty($_COOKIE[self::COOKIE_SSO_PENDING])) {
|
||||||
|
$this->clearPendingCookie();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load stored primary IMAP account ────────────────────────────────
|
||||||
|
$sJson = $this->loadPrimaryAccount($sSsoEmail);
|
||||||
|
if (!$sJson) {
|
||||||
|
// First visit: no stored account → show login dialog
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aAccount = json_decode($sJson, true);
|
||||||
|
if (empty($aAccount['email']) || empty($aAccount['pass_enc'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sPassword = \SnappyMail\Crypt::DecryptFromJSON(
|
||||||
|
$aAccount['pass_enc'],
|
||||||
|
APP_SALT . $sSsoEmail
|
||||||
|
);
|
||||||
|
if (!$sPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create a one-time SSO hash and redirect ──────────────────────────
|
||||||
|
$sSsoHash = \RainLoop\Api::CreateUserSsoHash($aAccount['email'], $sPassword);
|
||||||
|
if ($sSsoHash) {
|
||||||
|
// Record the SSO identity for this session before the auto-login redirect
|
||||||
|
$this->setSsoUserCookie($sSsoEmail);
|
||||||
|
// Mark that an auto-login attempt is in flight (breaks loops on failure)
|
||||||
|
setcookie(self::COOKIE_SSO_PENDING, '1', time() + 120, '/', '', true, true);
|
||||||
|
\MailSo\Base\Http::Location('/?sso&hash=' . urlencode($sSsoHash));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: login.credentials.step-2
|
||||||
|
*
|
||||||
|
* Fires when the login form is submitted (via DoLogin XHR).
|
||||||
|
* We force the e-mail to the SSO identity so the user only needs
|
||||||
|
* to enter the IMAP password.
|
||||||
|
*
|
||||||
|
* @param string $sEmail (by reference)
|
||||||
|
* @param string $sPassword (by reference, not modified here)
|
||||||
|
*/
|
||||||
|
public function onLoginCredentialsStep2(string &$sEmail, string &$sPassword): void
|
||||||
|
{
|
||||||
|
$sSsoEmail = $this->getSsoEmail();
|
||||||
|
if (!$sSsoEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only override the email for the primary (first) login.
|
||||||
|
// When a user adds an additional account, a primary account is already
|
||||||
|
// active — in that case we must not touch the typed email.
|
||||||
|
if (\RainLoop\Api::Actions()->getAccountFromToken(false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Override whatever the user typed with the proxy-verified identity
|
||||||
|
$sEmail = $sSsoEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook: login.success
|
||||||
|
*
|
||||||
|
* Fires after a successful IMAP connect + login.
|
||||||
|
* We persist the credentials (encrypted) for future auto-logins.
|
||||||
|
*/
|
||||||
|
public function onLoginSuccess(\RainLoop\Model\Account $oAccount): void
|
||||||
|
{
|
||||||
|
$sSsoEmail = $this->getSsoEmail();
|
||||||
|
if (!$sSsoEmail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sPassword = $oAccount->ImapPass();
|
||||||
|
if (!$sPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sEncPass = \SnappyMail\Crypt::EncryptToJSON($sPassword, APP_SALT . $sSsoEmail);
|
||||||
|
if (!$sEncPass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$aData = [
|
||||||
|
'email' => $oAccount->Email(),
|
||||||
|
'imap_user' => $oAccount->ImapUser(),
|
||||||
|
'pass_enc' => $sEncPass,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->storePrimaryAccount($sSsoEmail, json_encode($aData));
|
||||||
|
|
||||||
|
// Record which SSO identity owns this session so we can detect a
|
||||||
|
// user-switch on the next request (see onFilterHttpPaths).
|
||||||
|
$this->setSsoUserCookie($sSsoEmail);
|
||||||
|
|
||||||
|
// Clear the loop-guard cookie (auto-login now has fresh credentials)
|
||||||
|
$this->clearPendingCookie();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the SSO e-mail from the configured header, lowercased and trimmed. */
|
||||||
|
private function getSsoEmail(): string
|
||||||
|
{
|
||||||
|
$sHeader = trim($this->Config()->Get('plugin', 'header_name', 'Remote-Email'));
|
||||||
|
// Convert "Remote-Email" → "HTTP_REMOTE_EMAIL" (PHP $_SERVER key format)
|
||||||
|
$sKey = 'HTTP_' . strtoupper(str_replace('-', '_', $sHeader));
|
||||||
|
return strtolower(trim($_SERVER[$sKey] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the storage file path for the given SSO e-mail.
|
||||||
|
* Path: APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json
|
||||||
|
*
|
||||||
|
* @param bool $bMkDir Create the directory if it does not exist
|
||||||
|
*/
|
||||||
|
private function getSsoStoragePath(string $sSsoEmail, bool $bMkDir = false): string
|
||||||
|
{
|
||||||
|
// Keep printable chars that are safe in directory names; replace others with _
|
||||||
|
$sSanitized = preg_replace('/[^a-zA-Z0-9._@\-]/', '_', $sSsoEmail);
|
||||||
|
$sDir = APP_PRIVATE_DATA . 'storage/_sso_/' . $sSanitized . '/';
|
||||||
|
if ($bMkDir && !is_dir($sDir)) {
|
||||||
|
mkdir($sDir, 0700, true);
|
||||||
|
}
|
||||||
|
return $sDir . 'primary.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and returns the raw JSON string for the primary account, or null. */
|
||||||
|
private function loadPrimaryAccount(string $sSsoEmail): ?string
|
||||||
|
{
|
||||||
|
$sPath = $this->getSsoStoragePath($sSsoEmail);
|
||||||
|
if (is_readable($sPath)) {
|
||||||
|
$sData = file_get_contents($sPath);
|
||||||
|
return ($sData !== false && $sData !== '') ? $sData : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the raw JSON string for the primary account. */
|
||||||
|
private function storePrimaryAccount(string $sSsoEmail, string $sJson): bool
|
||||||
|
{
|
||||||
|
$sPath = $this->getSsoStoragePath($sSsoEmail, true);
|
||||||
|
return file_put_contents($sPath, $sJson, LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expires the loop-guard cookie. */
|
||||||
|
private function clearPendingCookie(): void
|
||||||
|
{
|
||||||
|
setcookie(self::COOKIE_SSO_PENDING, '', time() - 3600, '/', '', true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the SSO-user cookie to the SHA-1 of the given e-mail.
|
||||||
|
* Lasts for 30 days; refreshed on every auto-login.
|
||||||
|
*/
|
||||||
|
private function setSsoUserCookie(string $sSsoEmail): void
|
||||||
|
{
|
||||||
|
setcookie(self::COOKIE_SSO_USER, sha1($sSsoEmail), time() + 86400 * 30, '/', '', true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue