add password/mfa setup to profile

This commit is contained in:
2025-12-06 09:38:23 +00:00
parent fa204326e7
commit 7c0e28882e
5 changed files with 272 additions and 22 deletions

View File

@@ -1,26 +1,162 @@
package main
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
)
const sessionName = "session"
func profileHandler(w http.ResponseWriter, r *http.Request) {
configMu.RLock()
mfaEnabled := config.MFASecret != ""
configMu.RUnlock()
data := struct {
MFAEnabled bool
}{
session, _ := store.Get(r, sessionName)
data := TemplateData{
MFAEnabled: mfaEnabled,
}
// Check if MFA setup is in progress
if setupSecret, ok := session.Values["mfa_setup_secret"].(string); ok && setupSecret != "" {
if setupURL, ok := session.Values["mfa_setup_url"].(string); ok {
data.MFASetupInProgress = true
data.MFASecret = setupSecret
data.MFAURL = setupURL
}
}
if r.Method == http.MethodPost {
action := r.FormValue("action")
switch action {
case "change_password":
current := r.FormValue("current_password")
newpw := r.FormValue("new_password")
confirmpw := r.FormValue("confirm_new_password")
mfaCode := r.FormValue("mfa_code")
// Validate current password
if bcrypt.CompareHashAndPassword([]byte(config.HashedPass), []byte(current)) != nil {
data.Notification = "Current password is incorrect."
data.NotificationType = "error"
renderTemplate(w, r, "profile.html", data)
return
}
// Check if new passwords match
if newpw != confirmpw {
data.Notification = "New passwords do not match."
data.NotificationType = "error"
renderTemplate(w, r, "profile.html", data)
return
}
// If MFA enabled, validate code
if config.MFASecret != "" {
if !validateTOTP(config.MFASecret, mfaCode) {
data.Notification = "Invalid MFA code."
data.NotificationType = "error"
renderTemplate(w, r, "profile.html", data)
return
}
}
// Validate new password
if len(newpw) < 8 {
data.Notification = "Password must be at least 8 characters."
data.NotificationType = "error"
renderTemplate(w, r, "profile.html", data)
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(newpw), bcrypt.DefaultCost)
config.HashedPass = string(hash)
saveConfig()
// Invalidate session
session.Options.MaxAge = -1
session.Save(r, w)
data.Notification = "Password changed successfully. Please log in again."
data.NotificationType = "success"
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
case "toggle_mfa":
mfaAction := r.FormValue("mfa")
if mfaAction == "on" && config.MFASecret == "" {
// Generate new MFA secret and store in session
secret, qrURL := generateMFASecret(config.Username, config.AppName)
session.Values["mfa_setup_secret"] = secret
session.Values["mfa_setup_url"] = qrURL
session.Save(r, w)
data.MFASetupInProgress = true
data.MFASecret = secret
data.MFAURL = qrURL
data.Notification = "MFA setup initiated. Please configure your authenticator app."
data.NotificationType = "info"
} else if mfaAction == "off" && config.MFASecret != "" {
config.MFASecret = ""
saveConfig()
data.Notification = "MFA disabled."
data.NotificationType = "success"
data.MFAEnabled = false
}
case "confirm_mfa":
mfaCode := r.FormValue("mfa_code")
if setupSecret, ok := session.Values["mfa_setup_secret"].(string); ok && setupSecret != "" {
if validateTOTP(setupSecret, mfaCode) {
config.MFASecret = setupSecret
saveConfig()
// Clear session
delete(session.Values, "mfa_setup_secret")
delete(session.Values, "mfa_setup_url")
session.Save(r, w)
data.Notification = "MFA enabled successfully."
data.NotificationType = "success"
data.MFAEnabled = true
data.MFASetupInProgress = false
data.MFASecret = ""
data.MFAURL = ""
} else {
data.Notification = "Invalid MFA code. Please try again."
data.NotificationType = "error"
}
}
case "cancel_mfa":
// Clear session
delete(session.Values, "mfa_setup_secret")
delete(session.Values, "mfa_setup_url")
session.Save(r, w)
data.MFASetupInProgress = false
data.MFASecret = ""
data.MFAURL = ""
data.Notification = "MFA setup cancelled."
data.NotificationType = "info"
}
}
renderTemplate(w, r, "profile.html", data)
}
func mfaSetupHandler(w http.ResponseWriter, r *http.Request) {
if config.MFASecret != "" {
http.Error(w, "MFA already enabled", 400)
return
}
secret, qrURL := generateMFASecret(config.Username, config.AppName)
// Store in session
session, _ := store.Get(r, sessionName)
session.Values["mfa_setup_secret"] = secret
session.Values["mfa_setup_url"] = qrURL
session.Save(r, w)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"secret": secret, "url": qrURL})
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, r, "dashboard.html", nil)
}

View File

@@ -64,6 +64,7 @@ func main() {
// Protected routes
r.HandleFunc("/profile", authMiddleware(profileHandler)).Methods("GET", "POST")
r.HandleFunc("/api/mfa-setup", authMiddleware(mfaSetupHandler)).Methods("POST")
r.HandleFunc("/", authMiddleware(dashboardHandler))
r.HandleFunc("/urllists", authMiddleware(urlListsHandler)).Methods("GET", "POST")
r.HandleFunc("/domains", authMiddleware(domainsHandler)).Methods("GET", "POST")

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,122 @@
{{ define "content" }}
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Profile</h1>
<div class="bg-gray-800 p-6 rounded">
<div class="bg-gray-800 p-6 rounded mb-6">
<p class="mb-4">
<strong class="text-white">Username:</strong>
<span class="text-gray-300">admin</span>
</p>
<p class="mb-6">
<strong class="text-white">MFA Status:</strong>
{{ if .PageData.MFAEnabled }}
{{ if .MFAEnabled }}
<span class="text-green-400 font-bold">Enabled</span>
{{ else }}
<span class="text-yellow-400 font-bold">Disabled</span>
{{ end }}
</p>
<div class="bg-gray-700 p-4 rounded mt-4">
<p class="text-sm text-gray-300">
<strong>To manage MFA or change password:</strong>
</p>
<p class="text-sm text-gray-400 mt-2">Use the command-line interface with the executable flags:</p>
<ul class="text-sm text-gray-400 mt-2 ml-4 space-y-1">
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -pw "NewPassword"</code> - Change password</li>
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -mfa on</code> - Enable MFA</li>
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -mfa off</code> - Disable MFA</li>
</ul>
</div>
<!-- Password Change Form -->
<div class="bg-gray-800 p-6 rounded mb-6">
<h2 class="text-xl font-bold mb-4 text-white">Change Password</h2>
<form method="POST" action="/profile">
<input type="hidden" name="action" value="change_password">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-4">
<label class="block text-gray-300 mb-1">Current Password</label>
<input type="password" name="current_password" required class="w-full p-2 rounded bg-gray-700 text-white">
</div>
<div class="mb-4">
<label class="block text-gray-300 mb-1">New Password</label>
<input type="password" name="new_password" required class="w-full p-2 rounded bg-gray-700 text-white">
</div>
<div class="mb-4">
<label class="block text-gray-300 mb-1">Confirm New Password</label>
<input type="password" name="confirm_new_password" required class="w-full p-2 rounded bg-gray-700 text-white">
</div>
{{ if .MFAEnabled }}
<div class="mb-4">
<label class="block text-gray-300 mb-1">MFA Code</label>
<input type="text" name="mfa_code" required class="w-full p-2 rounded bg-gray-700 text-white">
</div>
{{ end }}
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Change Password</button>
</form>
</div>
<!-- MFA Toggle Form -->
<div class="bg-gray-800 p-6 rounded mb-6" id="mfa-toggle">
<h2 class="text-xl font-bold mb-4 text-white">MFA Management</h2>
<form method="POST" action="/profile">
<input type="hidden" name="action" value="toggle_mfa">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ if .MFAEnabled }}
<button type="submit" name="mfa" value="off" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded">Disable MFA</button>
{{ else if not .MFASetupInProgress }}
<button type="button" onclick="enableMFA()" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded">Enable MFA</button>
{{ end }}
</form>
</div>
<!-- MFA Setup Section -->
<div class="bg-gray-800 p-6 rounded mb-6" id="mfa-setup" style="display: {{ if .MFASetupInProgress }}block{{ else }}none{{ end }};">
<h2 class="text-xl font-bold mb-4 text-white">Setup MFA</h2>
<p class="text-gray-300 mb-4">Scan the QR code with your authenticator app or enter the secret manually.</p>
<div class="mb-4">
<strong class="text-white">Secret:</strong> <code class="bg-gray-900 px-2 py-1 rounded" id="mfa-secret">{{ .MFASecret }}</code>
</div>
<div class="mb-4">
<strong class="text-white">OTP URL:</strong> <code class="bg-gray-900 px-2 py-1 rounded" id="mfa-url">{{ .MFAURL }}</code>
</div>
<div class="mb-4">
<img id="mfa-qr" src="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data={{ .MFAURL }}" alt="QR Code" class="border border-gray-600" style="display: {{ if .MFASetupInProgress }}block{{ else }}none{{ end }};">
</div>
<form method="POST" action="/profile">
<input type="hidden" name="action" value="confirm_mfa">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-4">
<label class="block text-gray-300 mb-1">Enter 6-digit code from your app</label>
<input type="text" name="mfa_code" required class="w-full p-2 rounded bg-gray-700 text-white">
</div>
<button type="submit" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded mr-2">Confirm MFA</button>
<button type="submit" name="action" value="cancel_mfa" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded">Cancel</button>
</form>
</div>
<!-- Terminal Commands -->
<div class="bg-gray-800 p-6 rounded">
<h2 class="text-xl font-bold mb-4 text-white">Terminal Commands</h2>
<p class="text-sm text-gray-300 mb-2">
<strong>Alternative CLI management:</strong>
</p>
<p class="text-sm text-gray-400 mt-2">Use the command-line interface with the executable flags:</p>
<ul class="text-sm text-gray-400 mt-2 ml-4 space-y-1">
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -pw "NewPassword"</code> - Change password</li>
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -mfa on</code> - Enable MFA</li>
<li><code class="bg-gray-900 px-2 py-1 rounded">./unifi-blocklist-app -mfa off</code> - Disable MFA</li>
</ul>
</div>
</div>
<script>
function enableMFA() {
fetch('/api/mfa-setup', {
method: 'POST',
headers: {
'X-CSRF-Token': '{{ .CSRFToken }}'
}
})
.then(response => response.json())
.then(data => {
document.getElementById('mfa-secret').textContent = data.secret;
document.getElementById('mfa-url').textContent = data.url;
document.getElementById('mfa-qr').src = 'https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=' + encodeURIComponent(data.url);
document.getElementById('mfa-qr').style.display = 'block';
document.getElementById('mfa-setup').style.display = 'block';
document.getElementById('mfa-toggle').style.display = 'none';
})
.catch(error => {
alert('Error enabling MFA: ' + error);
});
}
</script>
{{ end }}

View File

@@ -12,18 +12,23 @@ import (
"strings"
"github.com/justinas/nosurf"
"github.com/pquerna/otp/totp"
)
//go:embed templates/*.html static/*
var content embed.FS
type TemplateData struct {
CSRFToken string
PageData interface{}
Authenticated bool
AppName string
Notification string
NotificationType string
CSRFToken string
PageData interface{}
Authenticated bool
AppName string
Notification string
NotificationType string
MFAEnabled bool
MFASetupInProgress bool
MFASecret string
MFAURL string
}
func renderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, pageData interface{}) {
@@ -36,6 +41,7 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, pageDat
configMu.RLock()
appName := config.AppName
mfaEnabled := config.MFASecret != ""
configMu.RUnlock()
td := TemplateData{
@@ -43,6 +49,7 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, pageDat
PageData: pageData,
Authenticated: isAuth,
AppName: appName,
MFAEnabled: mfaEnabled,
}
// Extract notification and type from pageData if it has those fields
@@ -249,3 +256,17 @@ func generateSecret() string {
// since we're using pquerna/otp for TOTP generation
return ""
}
// validateTOTP validates a TOTP code against a secret
func validateTOTP(secret, code string) bool {
return totp.Validate(code, secret)
}
// generateMFASecret generates a new MFA secret and QR code URL
func generateMFASecret(username, appname string) (string, string) {
key, _ := totp.Generate(totp.GenerateOpts{
Issuer: appname,
AccountName: username,
})
return key.Secret(), key.URL()
}