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
12 changes: 9 additions & 3 deletions app/account_step_up_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@
current_authentication_session,
)
from .ldap_service import LDAPDirectory, LDAPLookupRejected, LDAPUnavailable
from .models import LDAPIdentity, OIDCIdentity, User, WebAuthnCredential, db
from .models import (
LDAPIdentity,
OIDCIdentity,
User,
WebAuthnCredential,
db,
)
from .oidc_service import OIDCStateError
from .security_features import feature_is_active
from .step_up import (
Expand All @@ -45,7 +51,7 @@
consume_account_step_up_grant,
recent_strong_assurance,
)
from .totp_service import verify_totp
from .totp_service import disable_totp_mfa, verify_totp
from .webauthn_service import ChallengeError, consume_challenge, create_challenge


Expand Down Expand Up @@ -549,7 +555,7 @@ def disable_account_mfa():
)
except StepUpError:
return _error("step_up_required", 403)
user.mfa_enabled = False
disable_totp_mfa(user)
db.session.commit()
log_security_event(
"ACCOUNT_STEP_UP_CONSUMED",
Expand Down
3 changes: 2 additions & 1 deletion app/auth_assurance.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
db,
)
from .session_epoch import current_epoch
from .totp_service import disable_totp_mfa


_PENDING_TTL = timedelta(minutes=5)
Expand Down Expand Up @@ -497,7 +498,7 @@ def clear_recovery_restriction(*, replacement_factor=None, disable_mfa=False):
if user is None:
raise AuthenticationFinalizationError("account is no longer eligible")
if disable_mfa:
user.mfa_enabled = False
disable_totp_mfa(user)
row.assurance = AssuranceLevel.BASIC.value
db.session.commit()
log_security_event(
Expand Down
3 changes: 2 additions & 1 deletion app/totp_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TOTPEnrollmentError,
activate_totp_enrollment,
begin_totp_enrollment,
disable_totp_mfa,
verify_totp,
)
from .step_up import (
Expand Down Expand Up @@ -228,7 +229,7 @@ def disable_mfa():
if grant_error is not None:
return grant_error
user = db.session.get(User, current_user.id)
user.mfa_enabled = False
disable_totp_mfa(user)
db.session.commit()
log_security_event("MFA_DISABLED", user=user.username)
return jsonify({"ok": True})
8 changes: 8 additions & 0 deletions app/totp_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ class TOTPEnrollmentError(RuntimeError):
"""The enrollment token, binding, or first code was invalid."""


def disable_totp_mfa(user):
"""Remove every TOTP factor and disable MFA without committing."""
TOTPAuthenticator.query.filter_by(
user_id=user.id,
).delete(synchronize_session=False)
user.mfa_enabled = False


@dataclass(frozen=True)
class EnrollmentView:
token: str
Expand Down
12 changes: 12 additions & 0 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,8 @@ const translations = {
'security.methodTotp': 'Authenticator app',
'security.confirmSecurityAction': 'Confirm security action',
'security.confirmDisableMfa': 'Disable every MFA factor?',
'security.confirmDisableMfaAndRemoveTotp': 'Disable MFA and remove all authenticator apps? This cannot be undone.',
'security.disableMfaAndRemoveTotp': 'Disable MFA and remove authenticators',
'security.invalidTotpCode': 'Enter a valid six-digit authenticator code.',
'security.mfaDisabled': 'MFA disabled',
'security.mfaEnabled': 'MFA enabled',
Expand Down Expand Up @@ -1457,6 +1459,8 @@ const translations = {
'security.methodTotp': 'Ứng dụng xác thực',
'security.confirmSecurityAction': 'Xác nhận thao tác bảo mật',
'security.confirmDisableMfa': 'Tắt tất cả các yếu tố MFA?',
'security.confirmDisableMfaAndRemoveTotp': 'T\u1eaft MFA v\u00e0 x\u00f3a t\u1ea5t c\u1ea3 \u1ee9ng d\u1ee5ng x\u00e1c th\u1ef1c? Kh\u00f4ng th\u1ec3 ho\u00e0n t\u00e1c thao t\u00e1c n\u00e0y.',
'security.disableMfaAndRemoveTotp': 'T\u1eaft MFA v\u00e0 x\u00f3a tr\u00ecnh x\u00e1c th\u1ef1c',
'security.invalidTotpCode': 'Nhập mã xác thực gồm sáu chữ số hợp lệ.',
'security.mfaDisabled': 'Đã tắt MFA',
'security.mfaEnabled': 'Đã bật MFA',
Expand Down Expand Up @@ -2520,6 +2524,8 @@ const translations = {
'security.methodTotp': 'Authenticator-App',
'security.confirmSecurityAction': 'Sicherheitsaktion bestätigen',
'security.confirmDisableMfa': 'Alle MFA-Faktoren deaktivieren?',
'security.confirmDisableMfaAndRemoveTotp': 'MFA deaktivieren und alle Authenticator-Apps entfernen? Dies kann nicht r\u00fcckg\u00e4ngig gemacht werden.',
'security.disableMfaAndRemoveTotp': 'MFA deaktivieren und Authenticator-Apps entfernen',
'security.invalidTotpCode': 'Gib einen gültigen sechsstelligen Authenticator-Code ein.',
'security.mfaDisabled': 'MFA deaktiviert',
'security.mfaEnabled': 'MFA aktiviert',
Expand Down Expand Up @@ -3508,6 +3514,8 @@ const translations = {
'security.methodTotp': "Application d’authentification",
'security.confirmSecurityAction': 'Confirmer l’action de sécurité',
'security.confirmDisableMfa': 'Désactiver tous les facteurs MFA ?',
'security.confirmDisableMfaAndRemoveTotp': 'D\u00e9sactiver l\u2019AMF et supprimer toutes les applications d\u2019authentification\u00a0? Cette action est irr\u00e9versible.',
'security.disableMfaAndRemoveTotp': 'D\u00e9sactiver l\u2019AMF et supprimer les authentificateurs',
'security.invalidTotpCode': 'Saisissez un code d’authentification valide à six chiffres.',
'security.mfaDisabled': 'MFA désactivée',
'security.mfaEnabled': 'MFA activée',
Expand Down Expand Up @@ -4533,6 +4541,8 @@ const translations = {
'security.methodTotp': 'Aplicación de autenticación',
'security.confirmSecurityAction': 'Confirmar acción de seguridad',
'security.confirmDisableMfa': '¿Desactivar todos los factores MFA?',
'security.confirmDisableMfaAndRemoveTotp': '\u00bfDesactivar MFA y eliminar todas las aplicaciones de autenticaci\u00f3n? Esta acci\u00f3n no se puede deshacer.',
'security.disableMfaAndRemoveTotp': 'Desactivar MFA y eliminar autenticadores',
'security.invalidTotpCode': 'Introduce un código de autenticación válido de seis dígitos.',
'security.mfaDisabled': 'MFA desactivado',
'security.mfaEnabled': 'MFA activado',
Expand Down Expand Up @@ -5558,6 +5568,8 @@ const translations = {
'security.methodTotp': '身份验证器应用',
'security.confirmSecurityAction': '确认安全操作',
'security.confirmDisableMfa': '停用所有 MFA 因素?',
'security.confirmDisableMfaAndRemoveTotp': '\u7981\u7528 MFA \u5e76\u79fb\u9664\u6240\u6709\u8eab\u4efd\u9a8c\u8bc1\u5668\u5e94\u7528\uff1f\u6b64\u64cd\u4f5c\u65e0\u6cd5\u64a4\u9500\u3002',
'security.disableMfaAndRemoveTotp': '\u7981\u7528 MFA \u5e76\u79fb\u9664\u8eab\u4efd\u9a8c\u8bc1\u5668',
'security.invalidTotpCode': '请输入有效的六位身份验证器代码。',
'security.mfaDisabled': 'MFA 已停用',
'security.mfaEnabled': 'MFA 已启用',
Expand Down
2 changes: 1 addition & 1 deletion static/js/sftp-file-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class SFTPFileManager {
left: this.createEmptyPaneState(),
right: this.createEmptyPaneState(),
};
this.panes = this.workspaceEmptyPanes;
this.panes = { ...this.workspaceEmptyPanes };
this.standalonePanes = this.panes;
this.embeddedPanes = null;
this.activePane = 'left';
Expand Down
4 changes: 2 additions & 2 deletions static/js/webauthn.js
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,8 @@

async function disableTotpMfa() {
if (!window.confirm(t(
'security.confirmDisableMfa',
'Disable the MFA requirement for future sign-ins? Enrolled factors remain stored.'
'security.confirmDisableMfaAndRemoveTotp',
'Disable MFA and remove all authenticator apps? This cannot be undone.'
))) { return; }
const headers = await stepUpHeaders('mfa.disable');
if (headers === null) { return; }
Expand Down
2 changes: 1 addition & 1 deletion templates/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ <h2 id="restoreSecondConfirmTitle" data-i18n="backup.secondConfirm">Final destru

<div id="notificationContainer" class="notification-container"></div>

<script src="{{ url_for('static', filename='js/i18n.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='js/i18n.js') }}?v=25"></script>
<script src="{{ url_for('static', filename='js/security-ui.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='js/restore-status-flow.js') }}"></script>
<script src="{{ url_for('static', filename='js/admin.js') }}?v=2"></script>
Expand Down
2 changes: 1 addition & 1 deletion templates/change_password.html
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ <h1 data-i18n="auth.changePassword">Change Password</h1>
</main>
</div>

<script src="{{ url_for('static', filename='js/i18n.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='js/i18n.js') }}?v=25"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}?v=4"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1391,7 +1391,7 @@ <h2 id="filePreviewTitle" data-i18n="preview.title">File Preview</h2>
<script src="{{ url_for('static', filename='vendor/highlight/highlight.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/socketio/socket.io.min.js') }}"></script>

<script src="{{ url_for('static', filename='js/i18n.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='js/i18n.js') }}?v=25"></script>
<script src="{{ url_for('static', filename='js/ssh-error-ui.js') }}?v=1"></script>

<script src="{{ url_for('static', filename='js/binary-transfer-client.js') }}?v=2"></script>
Expand Down
84 changes: 57 additions & 27 deletions templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,15 @@ <h1 data-i18n="auth.serverControlCenter">The control center for your servers.</h
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
{% if mfa_required %}
<span class="auth-form-eyebrow" data-i18n="auth.verificationOverviewEyebrow">Confirm sign-in</span>
<h1 data-i18n="auth.twoFactorAuthentication">Two-factor authentication</h1>
<p data-i18n="auth.verificationOverviewHint">Use one of your available factors for this second step. A recovery code opens a restricted session where you renew your account protection.</p>
{% else %}
<span class="auth-form-eyebrow" data-i18n="auth.login">Sign in</span>
<h1 data-i18n="auth.signInPrompt">Access your SSH workspace</h1>
<p data-i18n="auth.signInSourceHint">Use the identity source configured for this WebSSH instance.</p>
{% endif %}
</div>

{% with messages = get_flashed_messages(with_categories=true) %}
Expand All @@ -113,31 +119,31 @@ <h1 data-i18n="auth.signInPrompt">Access your SSH workspace</h1>
<div id="authNotificationContainer" class="auth-notification-container" aria-live="polite" aria-atomic="true"></div>

{% set selected_auth_source = auth_source if auth_source in ('local', 'ldap') else ('ldap' if ldap_enabled else 'local') %}
{% if mfa_required and 'totp' in mfa_methods %}
<section id="totpMfaPanel" class="login-mode">
<h2 data-i18n="auth.twoFactorAuthentication">Two-factor authentication</h2>
<p data-i18n="auth.enterAuthenticatorCode">Enter the six-digit code from your authenticator app.</p>
<div class="form-group">
<label for="totpMfaCode" data-i18n="security.authenticatorCode">Authenticator code</label>
<input type="text" id="totpMfaCode" class="form-control" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" autofocus>
</div>
<div class="alert alert-error hidden" id="totpMfaError" role="alert"></div>
<button type="button" class="btn-primary" id="submitTotpMfa" data-i18n="auth.verifyCode">Verify code</button>
</section>
{% endif %}
{% if mfa_required and 'recovery' in mfa_methods %}
<section id="recoveryMfaPanel" class="login-mode">
<h2 data-i18n="auth.useRecoveryCode">Use a recovery code</h2>
<p data-i18n="auth.recoverySessionHint">This creates a restricted session where you must add a replacement factor or explicitly disable MFA.</p>
<div class="form-group">
<label for="recoveryMfaCode" data-i18n="auth.recoveryCode">Recovery code</label>
<input type="text" id="recoveryMfaCode" class="form-control" autocomplete="one-time-code">
</div>
<div class="alert alert-error hidden" id="recoveryMfaError" role="alert"></div>
<button type="button" class="btn-primary" id="submitRecoveryMfa" data-i18n="auth.continueWithRecoveryCode">Continue with recovery code</button>
</section>
{% if mfa_required %}
{% set default_mfa_method = 'totp' if 'totp' in mfa_methods else ('passkey' if 'passkey' in mfa_methods else 'recovery') %}
{% if mfa_methods|length > 1 %}
<div id="authMfaMethodSwitcher" class="auth-method-switcher" role="tablist" aria-label="Choose a verification method" data-i18n-aria-label="auth.availableVerificationMethods">
{% if 'totp' in mfa_methods %}
<button class="auth-method-option" type="button" role="tab" data-auth-mode="totp" aria-selected="{{ 'true' if default_mfa_method == 'totp' else 'false' }}" aria-controls="totpMfaPanel">
<span class="material-icons" aria-hidden="true">phonelink_lock</span>
<span><strong data-i18n="security.methodTotp">Authenticator app</strong><small data-i18n="security.authenticatorCode">Authenticator code</small></span>
</button>
{% endif %}
{% if 'passkey' in mfa_methods %}
<button class="auth-method-option" type="button" role="tab" data-auth-mode="passkey" aria-selected="{{ 'true' if default_mfa_method == 'passkey' else 'false' }}" aria-controls="passkeyLoginMode">
<span class="material-icons" aria-hidden="true">key</span>
<span><strong data-i18n="security.methodPasskey">Passkey</strong><small data-i18n="auth.deviceAuthentication">Device authentication</small></span>
</button>
{% endif %}
{% if 'recovery' in mfa_methods %}
<button class="auth-method-option" type="button" role="tab" data-auth-mode="recovery" aria-selected="{{ 'true' if default_mfa_method == 'recovery' else 'false' }}" aria-controls="recoveryMfaPanel">
<span class="material-icons" aria-hidden="true">lock_reset</span>
<span><strong data-i18n="security.methodRecoveryCode">Recovery code</strong><small data-i18n="auth.recoveryAccessHint">One-time account recovery</small></span>
</button>
{% endif %}
</div>
{% endif %}
{% if not mfa_required %}
{% else %}
<div class="auth-method-switcher" role="tablist" aria-label="Choose how to sign in" data-i18n-aria-label="auth.chooseSignInMethod">
<button class="auth-method-option" type="button" role="tab" data-auth-mode="password" aria-selected="true" aria-controls="passwordLoginMode">
<span class="material-icons" aria-hidden="true">person</span>
Expand All @@ -159,6 +165,30 @@ <h2 data-i18n="auth.useRecoveryCode">Use a recovery code</h2>
{% endif %}

<div class="auth-credentials-zone">
{% if mfa_required and 'totp' in mfa_methods %}
<section id="totpMfaPanel" class="login-mode{% if default_mfa_method != 'totp' %} hidden{% endif %}" data-auth-mode-panel="totp" aria-hidden="{{ 'false' if default_mfa_method == 'totp' else 'true' }}">
<h2 data-i18n="security.methodTotp">Authenticator app</h2>
<p data-i18n="auth.enterAuthenticatorCode">Enter the six-digit code from your authenticator app.</p>
<div class="form-group">
<label for="totpMfaCode" data-i18n="security.authenticatorCode">Authenticator code</label>
<input type="text" id="totpMfaCode" class="form-control" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" autofocus>
</div>
<div class="alert alert-error hidden" id="totpMfaError" role="alert"></div>
<button type="button" class="btn-primary" id="submitTotpMfa" data-i18n="auth.verifyCode">Verify code</button>
</section>
{% endif %}
{% if mfa_required and 'recovery' in mfa_methods %}
<section id="recoveryMfaPanel" class="login-mode{% if default_mfa_method != 'recovery' %} hidden{% endif %}" data-auth-mode-panel="recovery" aria-hidden="{{ 'false' if default_mfa_method == 'recovery' else 'true' }}">
<h2 data-i18n="auth.useRecoveryCode">Use a recovery code</h2>
<p data-i18n="auth.recoverySessionHint">This creates a restricted session where you must add a replacement factor or explicitly disable MFA.</p>
<div class="form-group">
<label for="recoveryMfaCode" data-i18n="auth.recoveryCode">Recovery code</label>
<input type="text" id="recoveryMfaCode" class="form-control" autocomplete="one-time-code">
</div>
<div class="alert alert-error hidden" id="recoveryMfaError" role="alert"></div>
<button type="button" class="btn-primary" id="submitRecoveryMfa" data-i18n="auth.continueWithRecoveryCode">Continue with recovery code</button>
</section>
{% endif %}
<section id="passwordLoginMode" class="login-mode{% if mfa_required %} hidden{% endif %}" data-auth-mode-panel="password" aria-hidden="{{ 'true' if mfa_required else 'false' }}">
<div id="passwordAuthenticationForms"{% if mfa_required %} class="hidden"{% endif %}>
{% if ldap_enabled %}
Expand Down Expand Up @@ -229,7 +259,7 @@ <h2 data-i18n="auth.useRecoveryCode">Use a recovery code</h2>
</div>
</section>
{% if webauthn_enabled and (not mfa_required or 'passkey' in mfa_methods) %}
<section id="passkeyLoginMode" class="login-mode auth-provider-mode{% if not mfa_required %} hidden{% endif %}" data-auth-mode-panel="passkey" aria-hidden="{{ 'false' if mfa_required else 'true' }}">
<section id="passkeyLoginMode" class="login-mode auth-provider-mode{% if not mfa_required or default_mfa_method != 'passkey' %} hidden{% endif %}" data-auth-mode-panel="passkey" aria-hidden="{{ 'false' if mfa_required and default_mfa_method == 'passkey' else 'true' }}">
<span class="auth-provider-mode-icon material-icons" aria-hidden="true">key</span>
<h2 data-i18n="auth.passkeyPrompt">Use a Passkey</h2>
<p data-i18n="auth.passkeyPromptHint">Authenticate with a Passkey saved on this device or another nearby device.</p>
Expand Down Expand Up @@ -258,9 +288,9 @@ <h2 data-i18n="auth.identityProviderPrompt">Continue with your identity provider
</main>
</div>

<script src="{{ url_for('static', filename='js/i18n.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='js/i18n.js') }}?v=25"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}?v=4"></script>
<script src="{{ url_for('static', filename='js/security-ui.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='js/webauthn.js') }}?v=2"></script>
<script src="{{ url_for('static', filename='js/webauthn.js') }}?v=3"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion templates/register.html
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ <h1 data-i18n="auth.registerPrompt">Create a local WebSSH account</h1>
</main>
</div>

<script src="{{ url_for('static', filename='js/i18n.js') }}?v=24"></script>
<script src="{{ url_for('static', filename='js/i18n.js') }}?v=25"></script>
<script src="{{ url_for('static', filename='js/auth.js') }}?v=4"></script>
</body>
</html>
Loading
Loading