175 lines
6.9 KiB
Go
175 lines
6.9 KiB
Go
package webui
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"image/png"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/pquerna/otp/totp"
|
|
"mailgoserver/internal/db"
|
|
)
|
|
|
|
// webmailDashboard is the mailbox owner's single self-service page: their own quota
|
|
// usage, password change, TOTP MFA enable/disable, registered passkeys, and app
|
|
// passwords for IMAP/SMTP clients — everything scoped to reusing the app-password
|
|
// CRUD already built for the admin-managed mailbox pages (db.ListAppPasswordsForMailbox
|
|
// etc.), just presented for self-service instead of admin management. Only ever
|
|
// reached with MFA already satisfying enforce_mailbox_mfa (or enforcement off) —
|
|
// requireMailboxAuth redirects everywhere else, including here, to the isolated
|
|
// /mfa-setup page otherwise (see webmailMFASetupRequiredPage).
|
|
func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
|
|
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
|
|
pctFull := 0.0
|
|
if mbox.QuotaBytes > 0 {
|
|
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
|
|
}
|
|
// webmail_account.html is a standalone page (own <head>, no admin base.html/sidebar)
|
|
// so render() doesn't auto-populate flashes for it the way admin pages get — pop
|
|
// them explicitly here instead.
|
|
a.render(w, r, "webmail_account.html", M{
|
|
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
|
|
"flashes": popFlashes(w, r),
|
|
})
|
|
}
|
|
|
|
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page
|
|
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_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. Existing app passwords keep
|
|
// authenticating IMAP/SMTP clients throughout — that's a separate, non-interactive
|
|
// protocol path this gate has no bearing on.
|
|
func (a *App) webmailMFASetupRequiredPage(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
a.render(w, r, "webmail_mfa_setup_required.html", M{"email": mbox.Email, "flashes": popFlashes(w, r)})
|
|
}
|
|
|
|
func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
current := r.FormValue("current_password")
|
|
newPassword := r.FormValue("new_password")
|
|
confirm := r.FormValue("new_password_confirm")
|
|
|
|
if !db.CheckPassword(current, mbox.PasswordHash) {
|
|
setFlash(w, "error", "Current password is incorrect")
|
|
http.Redirect(w, r, MailboxPrefix+"/", 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, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
if newPassword != confirm {
|
|
setFlash(w, "error", "New passwords don't match")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
hash, err := db.HashPassword(newPassword)
|
|
if err != nil {
|
|
setFlash(w, "error", "Something went wrong")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
if err := a.DB.SetMailboxPasswordHash(mbox.ID, hash); err != nil {
|
|
setFlash(w, "error", "Something went wrong")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
setFlash(w, "success", "Password updated")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
key, err := totp.Generate(totp.GenerateOpts{Issuer: "mailgoserver", AccountName: mbox.Email})
|
|
if err != nil {
|
|
setFlash(w, "error", "Could not generate a TOTP secret")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, key.Secret(), false); err != nil {
|
|
setFlash(w, "error", "Could not save the TOTP secret")
|
|
http.Redirect(w, r, MailboxPrefix+"/", 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())
|
|
}
|
|
}
|
|
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": qrDataURI})
|
|
}
|
|
|
|
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
code := strings.TrimSpace(r.FormValue("code"))
|
|
if mbox.TOTPSecret == "" || !totp.Validate(code, mbox.TOTPSecret) {
|
|
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, mbox.TOTPSecret, true); err != nil {
|
|
setFlash(w, "error", "Something went wrong enabling MFA")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator enabled")
|
|
setFlash(w, "success", "Authenticator app MFA enabled")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
|
|
setFlash(w, "error", "Something went wrong")
|
|
} else {
|
|
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, requestIP(r), true, "TOTP authenticator disabled")
|
|
setFlash(w, "success", "Authenticator app MFA disabled")
|
|
}
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
}
|
|
|
|
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
|
|
// self-service — same generation/storage, just reached from the mailbox's own portal
|
|
// instead of an admin managing it on their behalf.
|
|
func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
label := strings.TrimSpace(r.FormValue("label"))
|
|
if label == "" {
|
|
label = "App password"
|
|
}
|
|
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
|
|
secret := db.GenerateAppPassword(minLen)
|
|
hash, err := db.HashPassword(secret)
|
|
if err != nil {
|
|
setFlash(w, "error", "Error creating app password")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash, nil); err != nil {
|
|
setFlash(w, "error", "Error creating app password")
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
return
|
|
}
|
|
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
}
|
|
|
|
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
|
|
mbox := mailboxFromContext(r)
|
|
pwID := int64(atoi(r.PathValue("pw_id")))
|
|
if err := a.DB.RemoveAppPassword(pwID, mbox.ID); err != nil {
|
|
setFlash(w, "error", "Error revoking app password")
|
|
} else {
|
|
setFlash(w, "success", "App password revoked")
|
|
}
|
|
http.Redirect(w, r, MailboxPrefix+"/", http.StatusFound)
|
|
}
|