feat: enforce TOTP enrollment via SQLite when email is saved
This commit is contained in:
parent
f4fe31c561
commit
e1a4566cc3
2 changed files with 63 additions and 65 deletions
|
|
@ -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
126
main.go
|
|
@ -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 → 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 != "" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue