|
|
|
|
@ -893,25 +893,84 @@ func enforceTOTP(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
// Try the new authelia-api policy endpoint first (if deployed)
|
|
|
|
|
token := os.Getenv("AUTHELIA_SECRET")
|
|
|
|
|
policyBody, _ := json.Marshal(map[string]interface{}{
|
|
|
|
|
"name": "TOTP enforcement for " + user,
|
|
|
|
|
"domain": []string{"*"},
|
|
|
|
|
"subjects": []string{"user:" + user},
|
|
|
|
|
"policy": "two_factor",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
apiReq, _ := http.NewRequest("POST", "http://authelia:8080/api/policies", bytes.NewReader(policyBody))
|
|
|
|
|
apiReq.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
apiReq.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
apiResp, apiErr := http.DefaultClient.Do(apiReq)
|
|
|
|
|
apiOk := apiErr == nil && apiResp != nil && apiResp.StatusCode == 201
|
|
|
|
|
|
|
|
|
|
if apiOk {
|
|
|
|
|
apiResp.Body.Close()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set user_preference regardless (triggers Authelia's enrollment prompt on next login)
|
|
|
|
|
exec.Command("sqlite3", "/opt/nextworkspace/data/authelia/db.sqlite",
|
|
|
|
|
"INSERT OR REPLACE INTO user_preferences (username, method) VALUES ('"+user+"', 'totp')").Run()
|
|
|
|
|
|
|
|
|
|
// Add user to tfa_required group (enforces two_factor via access_control)
|
|
|
|
|
token = os.Getenv("AUTHELIA_SECRET")
|
|
|
|
|
userReq, _ := http.NewRequest("GET", "http://authelia:8080/api/users/"+user, nil)
|
|
|
|
|
userReq.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
if userResp, err := http.DefaultClient.Do(userReq); err == nil && userResp.StatusCode == 200 {
|
|
|
|
|
var ud struct {
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
DisplayName string `json:"display_name"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Groups []string `json:"groups"`
|
|
|
|
|
}
|
|
|
|
|
json.NewDecoder(userResp.Body).Decode(&ud)
|
|
|
|
|
userResp.Body.Close()
|
|
|
|
|
|
|
|
|
|
hasTFA := false
|
|
|
|
|
for _, g := range ud.Groups {
|
|
|
|
|
if g == "tfa_required" {
|
|
|
|
|
hasTFA = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !hasTFA {
|
|
|
|
|
ud.Groups = append(ud.Groups, "tfa_required")
|
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{"users": []interface{}{ud}})
|
|
|
|
|
|
|
|
|
|
delR, _ := http.NewRequest("DELETE", "http://authelia:8080/api/users/"+user, nil)
|
|
|
|
|
delR.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
http.DefaultClient.Do(delR)
|
|
|
|
|
|
|
|
|
|
time.Sleep(1500 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
crR, _ := http.NewRequest("POST", "http://authelia:8080/api/users/bulk", bytes.NewReader(body))
|
|
|
|
|
crR.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
crR.Header.Set("Content-Type", "application/json")
|
|
|
|
|
http.DefaultClient.Do(crR)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if apiOk {
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
"status": "enforced",
|
|
|
|
|
"totp_required": true,
|
|
|
|
|
"policy_created": true,
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
"status": "enforced",
|
|
|
|
|
"totp_required": true,
|
|
|
|
|
"policy_created": false,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if apiResp != nil {
|
|
|
|
|
apiResp.Body.Close()
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Translation system ---
|
|
|
|
|
@ -1212,10 +1271,10 @@ const settingsHTML = `<!DOCTYPE html>
|
|
|
|
|
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.';
|
|
|
|
|
msgDiv.style.background = '#fffbeb';
|
|
|
|
|
msgDiv.style.border = '1px solid #fde68a';
|
|
|
|
|
msgDiv.style.color = '#92400e';
|
|
|
|
|
msgDiv.innerHTML = '<strong>🔐 Two-Factor Setup Required:</strong> Please visit <a href="https://auth.nextwks.eu" style="color:#3182ce;" target="_blank">the Authelia portal</a>, log in, and set up an authenticator app (Google Authenticator, Authy, etc.) under Security → Two-Factor. This is required after adding a work email.';
|
|
|
|
|
} else if (result.totp_required && result.status === 'error') {
|
|
|
|
|
msgDiv.style.display = 'block';
|
|
|
|
|
msgDiv.style.background = '#fff5f5';
|
|
|
|
|
@ -1693,21 +1752,28 @@ const adminHTML = `<!DOCTYPE html>
|
|
|
|
|
|
|
|
|
|
<!-- Import CSV Modal -->
|
|
|
|
|
<div id="import-csv-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;">
|
|
|
|
|
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;">
|
|
|
|
|
<h3 style="margin-bottom:1.5rem;">Import Users from CSV</h3>
|
|
|
|
|
<div style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
|
|
|
|
|
<div style="background:#fff;border-radius:12px;padding:2rem;width:550px;max-width:90%;margin:5vh auto;position:relative;">
|
|
|
|
|
<div id="import-loading" style="display:none;position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(255,255,255,0.85);border-radius:12px;z-index:10;align-items:center;justify-content:center;flex-direction:column;">
|
|
|
|
|
<div style="width:40px;height:40px;border:4px solid #e2e8f0;border-top-color:#1a1a2e;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>
|
|
|
|
|
<p style="font-weight:600;color:#1a1a2e;">{{t .Lang "importing"}}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
|
|
|
|
<h3 style="margin-bottom:1.5rem;">{{t .Lang "nav_access"}} — CSV Import</h3>
|
|
|
|
|
<div id="import-step1" style="background:#f7fafc;padding:1rem;border-radius:8px;margin-bottom:1rem;">
|
|
|
|
|
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>1.</strong> <a href="/api/templates/users.csv" download style="color:#3182ce;">Download CSV template</a></p>
|
|
|
|
|
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>2.</strong> Fill in user data (Excel, LibreOffice, or text editor)</p>
|
|
|
|
|
<p style="margin:0.25rem 0;font-size:0.9rem;"><strong>3.</strong> Upload the completed file</p>
|
|
|
|
|
</div>
|
|
|
|
|
<form id="csv-import-form" onsubmit="return importCSV(event)">
|
|
|
|
|
<div id="import-form-fields">
|
|
|
|
|
<div class="field"><label>CSV File</label><input type="file" name="csv_file" accept=".csv" required style="width:100%;"></div>
|
|
|
|
|
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
|
|
|
|
|
<button type="submit" class="btn btn-primary">Import</button>
|
|
|
|
|
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">Cancel</button>
|
|
|
|
|
<button type="submit" class="btn btn-primary" id="import-btn">{{t .Lang "import"}}</button>
|
|
|
|
|
<button type="button" class="btn btn-ghost" onclick="closeImportModal()">{{t .Lang "cancel"}}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
<div id="import-results" style="display:none;margin-top:1rem;"></div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@ -1832,6 +1898,13 @@ const adminHTML = `<!DOCTYPE html>
|
|
|
|
|
groups.push(cb.value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Save original user data before deleting (safety net)
|
|
|
|
|
let originalData = null;
|
|
|
|
|
try {
|
|
|
|
|
const origResp = await fetch('/api/users/' + username);
|
|
|
|
|
if (origResp.ok) originalData = await origResp.json();
|
|
|
|
|
} catch(e) {}
|
|
|
|
|
|
|
|
|
|
// 1. Delete user
|
|
|
|
|
const delResp = await fetch('/api/users/' + username, { method: 'DELETE' });
|
|
|
|
|
if (!delResp.ok) { alert('Failed to delete user for re-creation'); return; }
|
|
|
|
|
@ -1866,9 +1939,17 @@ const adminHTML = `<!DOCTYPE html>
|
|
|
|
|
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#f0fff4;border:1px solid #c6f6d5;border-radius:8px;color:#276749;font-size:0.88rem;">✅ User updated.<br>New password: <code style="background:#edf2f7;padding:0.15rem 0.4rem;border-radius:4px;font-size:0.82rem;">' + pwd + '</code><br>Share this with the user.</div>';
|
|
|
|
|
closeEditUserModal();
|
|
|
|
|
loadUsers();
|
|
|
|
|
} else {
|
|
|
|
|
// Restore original user if available
|
|
|
|
|
if (originalData && originalData.username) {
|
|
|
|
|
const restoreBody = JSON.stringify({users:[{username:originalData.username,display_name:originalData.display_name||originalData.username,email:originalData.email||'',groups:originalData.groups||['users']}]});
|
|
|
|
|
await fetch('/api/users/bulk', {method:'POST', headers:{'Content-Type':'application/json'}, body:restoreBody});
|
|
|
|
|
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ Update failed. The user has been restored to their original state. Error: ' + JSON.stringify(result) + '</div>';
|
|
|
|
|
loadUsers();
|
|
|
|
|
} else {
|
|
|
|
|
resultDiv.innerHTML = '<div style="padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fed7d7;border-radius:8px;color:#c53030;font-size:0.88rem;">❌ FAILED to recreate user. The user was deleted but could not be recreated. Please manually add the user again. Error: ' + JSON.stringify(result) + '</div>';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1886,31 +1967,44 @@ const adminHTML = `<!DOCTYPE html>
|
|
|
|
|
|
|
|
|
|
async function importCSV(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
// Show loading spinner
|
|
|
|
|
document.getElementById('import-form-fields').style.display = 'none';
|
|
|
|
|
document.getElementById('import-step1').style.display = 'none';
|
|
|
|
|
document.getElementById('import-loading').style.display = 'flex';
|
|
|
|
|
document.querySelector('#import-csv-modal h3').textContent = 'Importing...';
|
|
|
|
|
|
|
|
|
|
const form = document.getElementById('csv-import-form');
|
|
|
|
|
const formData = new FormData(form);
|
|
|
|
|
|
|
|
|
|
const resp = await fetch('/api/users/import', { method: 'POST', body: formData });
|
|
|
|
|
const result = await resp.json();
|
|
|
|
|
const resultsDiv = document.getElementById('import-results');
|
|
|
|
|
|
|
|
|
|
// Hide loading
|
|
|
|
|
document.getElementById('import-loading').style.display = 'none';
|
|
|
|
|
resultsDiv.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
if (result.api_result && result.api_result.success) {
|
|
|
|
|
const created = result.api_result.created || 0;
|
|
|
|
|
let html = '<div style="background:#f0fff4;color:#276749;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">✅ ' + created + ' users created successfully.</div>';
|
|
|
|
|
if (result.api_result.users) {
|
|
|
|
|
if (result.api_result.users && result.api_result.users.length > 0) {
|
|
|
|
|
html += '<table style="width:100%;border-collapse:collapse;"><tr style="background:#f7fafc;"><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">User</th><th style="padding:6px 12px;border:1px solid #e2e8f0;text-align:left;">Password</th></tr>';
|
|
|
|
|
result.api_result.users.forEach(u => {
|
|
|
|
|
html += '<tr><td style="padding:6px 12px;border:1px solid #e2e8f0;">' + u.username + '</td><td style="padding:6px 12px;border:1px solid #e2e8f0;"><code style="background:#edf2f7;padding:2px 6px;border-radius:4px;font-size:0.85rem;">' + (u.placeholder_password || '—') + '</code></td></tr>';
|
|
|
|
|
});
|
|
|
|
|
html += '</table>';
|
|
|
|
|
}
|
|
|
|
|
html += '<div style="margin-top:1rem;"><button class="btn btn-primary" onclick="closeImportModal(); loadUsers();">Done</button></div>';
|
|
|
|
|
resultsDiv.innerHTML = html;
|
|
|
|
|
closeImportModal();
|
|
|
|
|
loadUsers();
|
|
|
|
|
} else {
|
|
|
|
|
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">❌ Import failed: ' + (result.error || 'Unknown error') + '</div>';
|
|
|
|
|
let errorMsg = result.error || 'Unknown error';
|
|
|
|
|
if (result.api_result && result.api_result.error) errorMsg = result.api_result.error;
|
|
|
|
|
let html = '<div style="background:#fff5f5;color:#9b2c2c;padding:1rem;border-radius:8px;margin-bottom:0.5rem;">❌ Import failed: ' + errorMsg + '</div>';
|
|
|
|
|
if (result.parse_errors && result.parse_errors.length) {
|
|
|
|
|
html += '<ul style="color:#9b2c2c;font-size:0.88rem;">' + result.parse_errors.map(e => '<li>' + e + '</li>').join('') + '</ul>';
|
|
|
|
|
}
|
|
|
|
|
html += '<div style="margin-top:1rem;"><button class="btn btn-ghost" onclick="closeImportModal()">Close</button></div>';
|
|
|
|
|
resultsDiv.innerHTML = html;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
|