package webui import ( "bytes" "encoding/base64" "html/template" "image/png" "net/http" "strings" "github.com/pquerna/otp/totp" "mailgoserver/internal/db" ) // accountPage shows the admin their own profile: password change, TOTP MFA // enable/disable, and registered passkeys (passkey registration itself is wired up // in webauthn.go). Only ever reached with MFA already satisfying enforce_admin_mfa // (or enforcement off) — requireAuth redirects everywhere else, including here, to // the isolated /mfa-setup page otherwise (see mfaSetupRequiredPage). func (a *App) accountPage(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) creds, _ := a.DB.ListWebAuthnCredentials(user.ID) a.render(w, r, "account.html", M{"active": "account", "user": user, "passkeys": creds}) } // changePassword mirrors a normal (not forced) password change from account settings. func (a *App) changePassword(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) current := r.FormValue("current_password") newPassword := r.FormValue("new_password") confirm := r.FormValue("new_password_confirm") if !db.CheckPassword(current, user.PasswordHash) { setFlash(w, "error", "Current password is incorrect") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } if !isStrongPassword(newPassword) { setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } if newPassword != confirm { setFlash(w, "error", "New passwords don't match") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } hash, err := db.HashPassword(newPassword) if err != nil { setFlash(w, "error", "Something went wrong") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } if err := a.DB.UpdateAdminPassword(user.ID, hash); err != nil { setFlash(w, "error", "Something went wrong") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } setFlash(w, "success", "Password updated") http.Redirect(w, r, Prefix+"/account", http.StatusFound) } // mfaSetupRequiredPage is the isolated, sidebar-free landing page requireAuth sends // an admin to when enforce_admin_mfa applies and they have no second factor yet — the // only page (besides the totp/passkey setup actions themselves) reachable until they // set one up, so there's no visible navigation to anything else in the browser. func (a *App) mfaSetupRequiredPage(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) a.render(w, r, "mfa_setup_required.html", M{"username": user.Username, "flashes": popFlashes(w, r)}) } // totpSetupBegin generates a fresh (not-yet-enabled) TOTP secret and shows it as a // scannable QR code (rendered inline as a data: URI — simplest way to hand the // browser an image without a second round-trip route) plus the manual entry key. func (a *App) totpSetupBegin(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) key, err := totp.Generate(totp.GenerateOpts{ Issuer: "mailgoserver", AccountName: user.Username, }) if err != nil { setFlash(w, "error", "Could not generate a TOTP secret") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } if err := a.DB.SetAdminTOTPSecret(user.ID, key.Secret(), false); err != nil { setFlash(w, "error", "Could not save the TOTP secret") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } img, err := key.Image(256, 256) qrDataURI := "" if err == nil { var buf bytes.Buffer if png.Encode(&buf, img) == nil { qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) } } // html/template's URL-context escaper only allows http/https/mailto schemes for a // plain string in a src="..." attribute — anything else, including data: URIs, // gets silently replaced with "#ZgotmplZ" (confirmed live). template.URL marks // this value as pre-approved so the actual QR image renders instead of nothing. a.render(w, r, "totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)}) } // totpSetupConfirm verifies a code against the pending secret and, if correct, flips // TOTP on for the account. func (a *App) totpSetupConfirm(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) code := strings.TrimSpace(r.FormValue("code")) if user.TOTPSecret == "" || !totp.Validate(code, user.TOTPSecret) { setFlash(w, "error", "That code didn't match — try scanning the QR code again") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } if err := a.DB.SetAdminTOTPSecret(user.ID, user.TOTPSecret, true); err != nil { setFlash(w, "error", "Something went wrong enabling MFA") http.Redirect(w, r, Prefix+"/account", http.StatusFound) return } _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator enabled") setFlash(w, "success", "Authenticator app MFA enabled") http.Redirect(w, r, Prefix+"/account", http.StatusFound) } func (a *App) totpDisable(w http.ResponseWriter, r *http.Request) { user := userFromContext(r) if err := a.DB.DisableAdminTOTP(user.ID); err != nil { setFlash(w, "error", "Something went wrong") } else { _ = a.DB.LogAuthAttempt("admin_mfa", user.Username, a.requestIP(r), true, "TOTP authenticator disabled") setFlash(w, "success", "Authenticator app MFA disabled") } http.Redirect(w, r, Prefix+"/account", http.StatusFound) }