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:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -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:
|
environment:
|
||||||
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
|
- CONFIG_DIR=/opt/nextworkspace/config/nextworkspace
|
||||||
- AUTHELIA_SECRET={AUTHELIA_SECRET}
|
- 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")
|
user := r.Header.Get("Remote-User")
|
||||||
if user == "" {
|
if user == "" {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to call Authelia API to check TOTP status.
|
if checkTOTPEnrolled(user) {
|
||||||
// This requires an active user session (firstfactor completed),
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
// so it may return 403 if called server-side without session.
|
"status": "already_enrolled",
|
||||||
token := os.Getenv("AUTHELIA_SECRET")
|
"totp_required": false,
|
||||||
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})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var userInfo struct {
|
// Update Authelia's user_preferences to require TOTP on next login
|
||||||
TOTP bool `json:"totp"`
|
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{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"mfa_enabled": userInfo.TOTP,
|
"status": "enforced",
|
||||||
|
"totp_required": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1168,26 +1184,7 @@ const settingsHTML = `<!DOCTYPE html>
|
||||||
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
|
<span id="savemsg" class="saved-msg">{{t .Lang "saved"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<!-- MFA Enforcement Overlay -->
|
<div id="mfa-msg" style="display:none;margin-top:1rem;padding:1rem;border-radius:8px;"></div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -1198,39 +1195,40 @@ const settingsHTML = `<!DOCTYPE html>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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) {
|
async function saveSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = document.getElementById('settings-form');
|
const form = document.getElementById('settings-form');
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
|
const resp = await fetch('/settings/save', {method:'POST', body:new URLSearchParams(data)});
|
||||||
const msg = document.getElementById('savemsg');
|
const savemsg = document.getElementById('savemsg');
|
||||||
if (resp.ok) { msg.style.display = 'inline'; setTimeout(() => msg.style.display = 'none', 3000); }
|
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"]');
|
const emailField = document.querySelector('input[name="email"]');
|
||||||
if (emailField && emailField.value) {
|
if (emailField && emailField.value) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const enabled = await checkMFA();
|
const enforce = await fetch('/api/user/enforce-totp');
|
||||||
if (!enabled) {
|
const result = await enforce.json();
|
||||||
document.getElementById('mfa-overlay').style.display = 'flex';
|
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);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
@ -1973,7 +1971,7 @@ func main() {
|
||||||
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
|
mux.HandleFunc("/api/settings/public", publicSettingsHandler)
|
||||||
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
|
mux.HandleFunc("/api/templates/users.csv", csvTemplateHandler)
|
||||||
mux.Handle("/api/users/import", authMiddleware(csvImportHandler))
|
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
|
// Protectected: launcher
|
||||||
if companyName != "" {
|
if companyName != "" {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue