feat: enforce TOTP enrollment via SQLite when email is saved

This commit is contained in:
Claus Lohmar 2026-07-15 14:11:39 +01:00
parent f4fe31c561
commit e1a4566cc3
2 changed files with 63 additions and 65 deletions

View file

@ -54,7 +54,7 @@ services:
command:
- sh
- -c
- "apk add --no-cache curl >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
- "apk add --no-cache curl sqlite3 >/dev/null 2>&1 && exec /opt/nextworkspace/nextworkspace"
environment:
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
- AUTHELIA_SECRET={AUTHELIA_SECRET}

126
main.go
View file

@ -864,37 +864,53 @@ func csvImportHandler(w http.ResponseWriter, r *http.Request) {
})
}
// --- MFA status check ---
// --- MFA enforcement ---
func checkMFAStatus(w http.ResponseWriter, r *http.Request) {
// Check if user has TOTP enrolled by querying Authelia's SQLite database directly.
func checkTOTPEnrolled(username string) bool {
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite"
out, err := exec.Command("sqlite3", dbPath,
"SELECT COUNT(*) FROM totp_configurations WHERE username='"+username+"'").Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "1"
}
// Set user's preferred 2FA method to totp, triggering enrollment prompt on next login.
func enforceTOTP(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User")
if user == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Attempt to call Authelia API to check TOTP status.
// This requires an active user session (firstfactor completed),
// so it may return 403 if called server-side without session.
token := os.Getenv("AUTHELIA_SECRET")
req, _ := http.NewRequest("GET", "http://authelia:9091/api/user/info", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
// Can't verify TOTP status server-side. Show prompt if email is set.
json.NewEncoder(w).Encode(map[string]bool{"mfa_enabled": false})
if checkTOTPEnrolled(user) {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "already_enrolled",
"totp_required": false,
})
return
}
defer resp.Body.Close()
var userInfo struct {
TOTP bool `json:"totp"`
// Update Authelia's user_preferences to require TOTP on next login
dbPath := "/opt/nextworkspace/data/authelia/db.sqlite"
cmd := exec.Command("sqlite3", dbPath,
"INSERT OR REPLACE INTO user_preferences (id, username, method) VALUES ((SELECT id FROM user_preferences WHERE username='"+user+"'), '"+user+"', 'totp')")
err := cmd.Run()
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "error",
"error": "Failed to update preferences",
"totp_required": true,
})
return
}
json.NewDecoder(resp.Body).Decode(&userInfo)
json.NewEncoder(w).Encode(map[string]bool{
"mfa_enabled": userInfo.TOTP,
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "enforced",
"totp_required": true,
})
}
@ -1168,26 +1184,7 @@ const settingsHTML = `<!DOCTYPE html>
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
</div>
</form>
<!-- MFA Enforcement Overlay -->
<div id="mfa-overlay" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:2000;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:2.5rem;width:480px;max-width:90%;text-align:center;">
<h3 style="font-size:1.3rem;margin-bottom:0.75rem;">🔐 Two-Factor Authentication Required</h3>
<p style="color:#4a5568;font-size:0.95rem;margin-bottom:1rem;">
You have added a work email. For security, you must enable two-factor authentication before continuing.
</p>
<div style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1.25rem;text-align:left;">
<p style="font-size:0.88rem;color:#4a5568;"><strong>1.</strong> Open the Authelia portal and log in.</p>
<p style="font-size:0.88rem;color:#4a5568;"><strong>2.</strong> Go to <strong>Security &rarr; Two-Factor</strong> to set up your authenticator app.</p>
<p style="font-size:0.88rem;color:#4a5568;"><strong>3.</strong> Scan the QR code with Google Authenticator, Authy, or similar.</p>
<p style="font-size:0.88rem;color:#4a5568;"><strong>4.</strong> Come back here and click <strong>Verify</strong>.</p>
</div>
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<a class="btn-secondary" href="https://auth.nextwks.eu" target="_blank" style="width:100%;text-align:center;">Open Authelia Portal </a>
<button onclick="verifyMFA()" style="padding:0.6rem 1rem;border:1px solid #3182ce;border-radius:6px;background:#ebf8ff;color:#3182ce;font-size:0.9rem;cursor:pointer;font-weight:500;"> I've Set Up Two-Factor Verify</button>
</div>
<p id="mfa-verify-msg" style="margin-top:0.75rem;font-size:0.85rem;color:#718096;display:none;">Checking... If verification keeps failing, make sure you completed the setup on the Authelia portal.</p>
</div>
</div>
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
{{if .IsAdmin}}
<div class="card">
@ -1198,39 +1195,40 @@ const settingsHTML = `<!DOCTYPE html>
{{end}}
</div>
<script>
async function checkMFA() {
const resp = await fetch('/api/user/mfa-status');
const data = await resp.json();
return data.mfa_enabled === true;
}
async function verifyMFA() {
const msg = document.getElementById('mfa-verify-msg');
msg.style.display = 'block';
msg.textContent = 'Checking...';
const enabled = await checkMFA();
if (enabled) {
document.getElementById('mfa-overlay').style.display = 'none';
} else {
msg.textContent = 'Two-factor not detected yet. Make sure you set it up on the Authelia portal, then click Verify again.';
}
}
async function saveSettings(e) {
e.preventDefault();
const form = document.getElementById('settings-form');
const data = new FormData(form);
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
const msg = document.getElementById('savemsg');
if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
const savemsg = document.getElementById('savemsg');
if (resp.ok) { savemsg.style.display = 'inline'; setTimeout(() => savemsg.style.display = 'none', 3000); }
// Check if email was saved and enforce MFA
// If user has an email, enforce TOTP enrollment
const emailField = document.querySelector('input[name="email"]');
if (emailField && emailField.value) {
setTimeout(async () => {
const enabled = await checkMFA();
if (!enabled) {
document.getElementById('mfa-overlay').style.display = 'flex';
const enforce = await fetch('/api/user/enforce-totp');
const result = await enforce.json();
const msgDiv = document.getElementById('mfa-msg');
if (result.totp_required && result.status === 'enforced') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#fff5f5';
msgDiv.style.border = '1px solid #fed7d7';
msgDiv.style.color = '#9b2c2c';
msgDiv.innerHTML = '<strong>🔐 Two-Factor Required:</strong> On your next login, you will be prompted to set up an authenticator app (Google Authenticator, Authy, etc.). This is required after adding a work email.';
} else if (result.totp_required && result.status === 'error') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#fff5f5';
msgDiv.style.border = '1px solid #fed7d7';
msgDiv.style.color = '#9b2c2c';
msgDiv.innerHTML = '<strong> Could not enforce two-factor:</strong> ' + (result.error || 'Unknown error');
} else if (!result.totp_required && result.status === 'already_enrolled') {
msgDiv.style.display = 'block';
msgDiv.style.background = '#f0fff4';
msgDiv.style.border = '1px solid #c6f6d5';
msgDiv.style.color = '#276749';
msgDiv.innerHTML = ' Two-factor authentication is already active. Your account is secure.';
setTimeout(() => { msgDiv.style.display = 'none'; }, 5000);
}
}, 1000);
}
@ -1973,7 +1971,7 @@ func main() {
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
mux.HandleFunc("/api/user/mfa-status", authMiddleware(checkMFAStatus))
mux.HandleFunc("/api/user/enforce-totp", authMiddleware(enforceTOTP))
// Protectected: launcher
if companyName != "" {