Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ curl -X DELETE "https://gate.example.com/api/v1/route-auth?route_id=1" \
### Important Notes

- SMTP configuration required for Email & Code and email alerts
- **No address enumeration (Email & Code):** asking for a code answers the same for every address — status, text ("Falls diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code geschickt." / "If this address has access to this page, we have sent you a code."), and at least 300 ms. The mail goes out in the background, so its latency or a failed delivery (activity `route_auth_otp_failed`) never shows in the answer. Only the route's own address gets a code (case and surrounding spaces do not matter). Entering a code after a wrong address fails exactly like a wrong code ("Der Code stimmt nicht oder ist abgelaufen …"). "Code erneut senden" of the second factor goes to the address of the password step.
- Failed route logins are logged as `route_auth_failed` (warning) with route, masked address (`s***@example.com`), IP and step (`password`, `code_request`, `code`; `unknownUser: true` for an address without access) — never a password or code.
- TOTP codes tracked in-memory to prevent replay (90s expiry)
- Route-auth CSRF key derived from app secret via HMAC (domain-bound)
- Lockout is email-based (not IP-based) to prevent IP rotation bypass
Expand Down Expand Up @@ -575,6 +577,7 @@ A failed login with a name or e-mail address that matches **no account** counts
| GateControl login (also from the portal) | weight of the setting | 1 | 1 |
| App sign-in (password) | weight of the setting | 1 | 1 |
| Route login (password step) | weight of the setting — an e-mail address other than the route's own account | 1 | 1 (code step) |
| Route login (e-mail code) | weight of the setting — a code requested for an address other than the route's own | — | 1 (wrong or expired code) |

- "Account exists" means: the name is a GateControl user name, or a directory account resolves it (Windows name, `DOMAIN\name`, UPN, e-mail). A disabled account counts as existing.
- One attempt counts once: typing the e-mail address of an existing account no longer adds a second point for the account's own name.
Expand Down
12 changes: 9 additions & 3 deletions docs/guide/07-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,17 @@ failures of one address over every entry point together and blocks it after
setting **"Unknown user names count"**, **triple** by default. With 10
failures the 4th attempt with an unknown name already blocks. This applies to
the GateControl login, the login from the portal, the sign-in in the apps and
the logins of protected routes. A wrong password of an existing account and a
wrong second factor count once.
the logins of protected routes – there also when someone asks for an e-mail
code for an address that has no access to the route. A wrong password of an
existing account, a wrong second factor and a wrong or expired e-mail code
count once. The log shows such failures of a route as `route_auth_failed`
with the route, the masked address (`s***@example.com`) and the IP.

Whoever signs in sees no difference: the answer is the same for unknown and
known names, so nobody can find out which accounts exist. If you often mistype
known names, so nobody can find out which accounts exist. With the e-mail code
login every address sees the same sentence: "If this address has access to
this page, we have sent you a code." Only the address with access really gets
a mail. If you often mistype
your own name, set it to "twice" or "once", or add your network under
**"Never block"**. Addresses in the VPN and in your own network
are never blocked anyway.
Expand Down
12 changes: 9 additions & 3 deletions docs/handbuch/07-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,12 +310,18 @@ Benutzername zählt dabei mehrfach – Einstellung **„Unbekannte Benutzernamen
zählen“**, voreingestellt **dreifach**. Mit 10 Fehlversuchen sperrt also schon
der 4. Versuch mit einem unbekannten Namen. Das gilt für den
GateControl-Login, den Login aus dem Portal, die Anmeldung in den Apps und
die Logins geschützter Routen. Ein falsches Passwort zu einem bestehenden
Konto und ein falscher zweiter Faktor zählen einfach.
die Logins geschützter Routen – dort auch, wenn jemand einen E-Mail-Code für
eine Adresse anfordert, die für die Route nicht freigeschaltet ist. Ein
falsches Passwort zu einem bestehenden Konto, ein falscher zweiter Faktor und
ein falscher oder abgelaufener E-Mail-Code zählen einfach. Im Protokoll stehen
solche Fehlversuche einer Route als `route_auth_failed` mit Route, gekürzter
Adresse (`s***@example.com`) und IP.

Wer sich anmeldet, merkt keinen Unterschied: Die Antwort ist für unbekannte
und bekannte Namen gleich, so lässt sich nicht herausfinden, welche Konten es
gibt. Vertippst du dich selbst öfter beim Namen, stell die Einstellung auf
gibt. Beim Login mit E-Mail-Code sieht jede Adresse denselben Satz: „Falls
diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code
geschickt.“ Nur die freigeschaltete Adresse bekommt wirklich eine Mail. Vertippst du dich selbst öfter beim Namen, stell die Einstellung auf
„doppelt“ oder „einfach“ oder trag dein Netz unter **„Nie sperren“** ein.
Adressen im VPN und im eigenen Netz werden ohnehin nie gesperrt.

Expand Down
6 changes: 5 additions & 1 deletion public/js/route-auth-login.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,12 @@ function initResendButton() {
if (!ok) {
const msg = (data && data.error) ? data.error : 'An error occurred. Please try again.';
showError(msg);
} else if (data && data.message) {
// E-mail code login: the same neutral text for every address
// ("Falls diese Adresse … freigeschaltet ist …").
const infoText = document.getElementById('code-info-text');
if (infoText) infoText.textContent = data.message;
}
// On success: code re-sent, no UI change needed
} catch (err) {
showError('An error occurred. Please try again.');
}
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,10 @@
"route_auth.invalid_code": "Ungültiger Code",
"route_auth.session_expired": "Sitzung abgelaufen",
"route_auth.code_sent": "Code gesendet",
"route_auth.code_sent_generic": "Falls diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code geschickt. Er ist 5 Minuten gültig.",
"route_auth.invalid_email_code": "Der Code stimmt nicht oder ist abgelaufen. Prüfen Sie ihn oder fordern Sie einen neuen Code an.",
"route_auth.email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"route_auth.code_unavailable": "Der Code kann gerade nicht verschickt werden. Bitte wenden Sie sich an die Person, die diese Seite betreibt.",
"route_auth.too_many_attempts": "Zu viele Versuche. Bitte später erneut versuchen.",
"route_auth.auth_type": "Authentifizierung",
"route_auth.auth_none": "Keine",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,10 @@
"route_auth.invalid_code": "Invalid code",
"route_auth.session_expired": "Session expired",
"route_auth.code_sent": "Code sent",
"route_auth.code_sent_generic": "If this address has access to this page, we have sent you a code. It is valid for 5 minutes.",
"route_auth.invalid_email_code": "The code is wrong or has expired. Check it or request a new code.",
"route_auth.email_invalid": "Please enter a valid e-mail address.",
"route_auth.code_unavailable": "The code cannot be sent right now. Please contact whoever runs this page.",
"route_auth.too_many_attempts": "Too many attempts. Try again later.",
"route_auth.auth_type": "Authentication",
"route_auth.auth_none": "None",
Expand Down
163 changes: 130 additions & 33 deletions src/routes/routeAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const {
completeTwoFactor,
deleteSession,
verifyPassword,
createAndSendOtp,
sendOtpInBackground,
verifyOtp,
verifyTotp,
maskEmail,
Expand Down Expand Up @@ -113,6 +113,61 @@ function clearSessionCookie(res) {
res.clearCookie(COOKIE_SID);
}

// ─── No enumeration of the route's address ─────────────────────────────
// A route with the e-mail code login has ONE address. Asking for a code
// answers the same for every address — same status, same body, at least
// SEND_CODE_MIN_MS — and the mail goes out in the background, so neither
// the text nor the time tells whether the address has access. An address
// without access counts for the IP block with the weight of "Unbekannte
// Benutzernamen" (services/ipBlock.js unknown_user_weight), like an unknown
// name at the GateControl login; the code step then fails like a wrong code.
const SEND_CODE_MIN_MS = 300;

/** Lower-case, trimmed e-mail address, or '' when it is no plausible address. */
function normalizeEmail(value) {
if (typeof value !== 'string') return '';
const v = value.trim().toLowerCase();
if (v.length < 3 || v.length > 254 || /[\s\x00-\x1f\x7f]/.test(v)) return '';
const at = v.lastIndexOf('@');
if (at < 1 || at === v.length - 1) return '';
return v;
}

function waitUntil(startedAt, ms) {
const left = ms - (Date.now() - startedAt);
return left > 0 ? new Promise((resolve) => setTimeout(resolve, left)) : Promise.resolve();
}

/** Pending 2FA session of THIS route (password step done), else null. */
function pendingSessionFor(req, authConfig) {
const sessionId = req.cookies && req.cookies[COOKIE_SID];
const s = sessionId ? getSession(sessionId) : null;
return s && s.two_factor_pending && s.route_id === authConfig.route_id ? s : null;
}

/**
* Activity entry of a failed route login (Logs & Monitoring): route, masked
* address, IP and the step. Only for the admin — the requester's answer is
* the same either way. Never contains a password or code.
*/
function logRouteAuthFailure(req, authConfig, domain, step, email, { unknownUser = false } = {}) {
try {
// Typed by the visitor: no control characters, bounded, then masked.
const masked = maskEmail(typeof email === 'string' ? email.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 254) : '');
const what = {
password: 'wrong e-mail or password',
code_request: 'code requested for an address without access',
code: 'wrong or expired code',
}[step] || step;
activity.log('route_auth_failed', `Failed route login for ${domain}: ${what} (${masked})`, {
details: { routeId: authConfig.route_id, domain, step, email: masked, ...(unknownUser ? { unknownUser: true } : {}) },
source: 'system',
ipAddress: req.ip,
severity: 'warning',
});
} catch { /* best-effort */ }
}

// GET /route-auth/verify — Caddy forward_auth endpoint
router.get('/verify', (req, res) => {
(async () => {
Expand Down Expand Up @@ -253,14 +308,10 @@ router.get('/login', (req, res) => {
}
}

// Check for pending 2FA session
let twoFactorPending = false;
if (sessionId) {
const pendingSession = getSession(sessionId);
if (pendingSession && pendingSession.two_factor_pending) {
twoFactorPending = true;
}
}
// Check for pending 2FA session (of this route: the password step was
// done here — a pending session of another route shows nothing)
const pendingSession = authConfig ? pendingSessionFor(req, authConfig) : null;
const twoFactorPending = !!pendingSession;

// Set CSRF double-submit cookie (bound to domain)
const csrfToken = generateSignedCsrf(domain);
Expand All @@ -277,7 +328,7 @@ router.get('/login', (req, res) => {
twoFactorEnabled: !!authConfig?.two_factor_enabled,
twoFactorMethod: authConfig?.two_factor_method || null,
is2faStep2: twoFactorPending,
maskedEmail: authConfig ? maskEmail(authConfig.email) : '',
maskedEmail: pendingSession ? maskEmail(pendingSession.email) : '',
routeCsrfToken: csrfToken,
branding: routeData ? {
title: routeData.branding_title,
Expand Down Expand Up @@ -333,7 +384,9 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => {
// matches no account and counts with the weight of "Unbekannte
// Benutzernamen" for the IP block (verifyPassword ran argon2 anyway;
// the answer below is the same).
lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip, { unknownUser: !authConfig.email || authConfig.email !== email });
const unknownUser = !authConfig.email || authConfig.email !== email;
lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip, { unknownUser });
logRouteAuthFailure(req, authConfig, domain, 'password', email, { unknownUser });
return res.status(401).json({ ok: false, error: req.t('route_auth.invalid_credentials') });
}

Expand All @@ -352,9 +405,10 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => {
);
setSessionCookie(res, sessionId, 5 * 60 * 1000);

// Send OTP if email_code 2FA
// Send OTP if email_code 2FA (in the background: the step answer does
// not wait for the mail server; a failed delivery is logged)
if (authConfig.two_factor_method === 'email_code') {
await createAndSendOtp(authConfig.route_id, email, domain, req.language);
sendOtpInBackground(authConfig.route_id, email, domain, req.language, { ip: req.ip });
}

return res.json({ ok: true, twoFactorRequired: true });
Expand All @@ -369,14 +423,20 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => {
})().catch((err) => res.status(500).json({ ok: false, error: err.message }));
});

// POST /route-auth/send-code — request a new OTP code
// POST /route-auth/send-code — request a (new) e-mail code
// • e-mail code login (auth type email_code): for the typed address. The
// answer is the same for every address (see SEND_CODE_MIN_MS above);
// only the route's own address gets a code.
// • second factor by e-mail ("Code erneut senden" after the password
// step): to the address of the pending session; the typed one is ignored.
router.post('/send-code', routeAuthCodeLimiter, (req, res) => {
(async () => {
const { email, domain: bodyDomain, _csrf } = req.body;
const domain = bodyDomain || req.headers['x-forwarded-host'] || req.headers.host;
const startedAt = Date.now();
const body = req.body || {};
const domain = body.domain || req.headers['x-forwarded-host'] || req.headers.host;

// CSRF check (bound to domain)
const csrfToken = _csrf || req.headers['x-csrf-token'];
const csrfToken = body._csrf || req.headers['x-csrf-token'];
if (!verifySignedCsrf(csrfToken, domain)) {
return res.status(403).json({ ok: false, error: 'CSRF validation failed' });
}
Expand All @@ -391,21 +451,48 @@ router.post('/send-code', routeAuthCodeLimiter, (req, res) => {
return res.status(404).json({ ok: false, error: req.t('route_auth.not_configured') });
}

// Verify pending 2FA session exists before allowing code resend
const sessionId = req.cookies && req.cookies[COOKIE_SID];
const pendingSession = sessionId ? getSession(sessionId) : null;
if (!pendingSession || !pendingSession.two_factor_pending) {
const pending = pendingSessionFor(req, authConfig);
if (pending) {
// Resend of the second factor: the password step proved the address.
if (!authConfig.two_factor_enabled || authConfig.two_factor_method !== 'email_code') {
return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') });
}
if (!isSmtpConfigured()) {
return res.status(503).json({ ok: false, error: req.t('route_auth.code_unavailable') });
}
sendOtpInBackground(authConfig.route_id, pending.email, domain, req.language, { ip: req.ip });
return res.json({ ok: true, message: req.t('route_auth.code_sent') });
}

if (authConfig.auth_type !== 'email_code') {
// Password route without a pending second factor (expired / never logged in).
return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') });
}

// Verify email matches config
if (authConfig.email !== email) {
return res.status(400).json({ ok: false, error: req.t('route_auth.email_mismatch') });
// Checks that do not depend on WHICH address was typed come first.
const typed = normalizeEmail(body.email);
if (!typed) {
return res.status(400).json({ ok: false, error: req.t('route_auth.email_invalid') });
}
if (!isSmtpConfigured()) {
return res.status(503).json({ ok: false, error: req.t('route_auth.code_unavailable') });
}

await createAndSendOtp(authConfig.route_id, email, domain, req.language);
res.json({ ok: true });
})().catch((err) => res.status(500).json({ ok: false, error: err.message }));
try {
if (authConfig.email && normalizeEmail(authConfig.email) === typed) {
// To the stored address (the typed one may differ in case).
sendOtpInBackground(authConfig.route_id, authConfig.email, domain, req.language, { ip: req.ip });
} else {
lockout.recordFailedAttempt(`${typed}:${authConfig.route_id}`, 'route_auth', req.ip, { unknownUser: true });
logRouteAuthFailure(req, authConfig, domain, 'code_request', typed, { unknownUser: true });
}
} catch (err) {
require('../utils/logger').warn({ err: err && err.message, routeId: authConfig.route_id }, 'route auth: code request failed');
}

await waitUntil(startedAt, SEND_CODE_MIN_MS);
return res.json({ ok: true, message: req.t('route_auth.code_sent_generic') });
})().catch(() => res.status(500).json({ ok: false, error: req.t('route_auth.code_unavailable') }));
});

// POST /route-auth/verify-code — verify OTP or TOTP code
Expand All @@ -431,12 +518,19 @@ router.post('/verify-code', routeAuthLoginLimiter, (req, res) => {
return res.status(404).json({ ok: false, error: req.t('route_auth.not_configured') });
}

// Get current session (pending or full)
// Second factor: the pending session of THIS route (password step done).
// Single factor (email_code / totp): always the route's own account — the
// address typed when asking for the code does not matter here, so a
// wrong code and "no code was ever sent to that address" fail alike
// (same answer, same lockout counter).
const sessionId = req.cookies && req.cookies[COOKIE_SID];
const existingSession = sessionId ? getSession(sessionId) : null;
const isTwoFactor = existingSession && existingSession.two_factor_pending;
const pending = pendingSessionFor(req, authConfig);
const isTwoFactor = !!pending;
if (isTwoFactor ? !authConfig.two_factor_enabled : !['email_code', 'totp'].includes(authConfig.auth_type)) {
return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') });
}

const email = (existingSession && existingSession.email) || req.body.email || authConfig.email || 'anonymous';
const email = (pending && pending.email) || authConfig.email || 'anonymous';

// Check account lockout for code verification
const lockoutId = `${email}:${authConfig.route_id}`;
Expand All @@ -459,12 +553,15 @@ router.post('/verify-code', routeAuthLoginLimiter, (req, res) => {
isValid = verifyTotp(authConfig.totp_secret_encrypted, code, authConfig.route_id);
} else {
// email OTP
isValid = verifyOtp(authConfig.route_id, email, code);
isValid = verifyOtp(authConfig.route_id, email, String(code == null ? '' : code));
}

if (!isValid) {
// Counts once for the IP block (the account exists); a wrong address
// already counted with the "unknown names" weight when it asked.
lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip);
return res.status(401).json({ ok: false, error: req.t('route_auth.invalid_code') });
logRouteAuthFailure(req, authConfig, domain, 'code', email === 'anonymous' ? '' : email);
return res.status(401).json({ ok: false, error: req.t(method === 'totp' ? 'route_auth.invalid_code' : 'route_auth.invalid_email_code') });
}

// Clear lockout on successful verification
Expand Down
Loading
Loading