Files
mailgoserver/internal/webui/login.go
T
2026-08-13 10:40:27 +01:00

242 lines
7.6 KiB
Go

package webui
import (
"net/http"
"strconv"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// pendingMFACookie holds the user id awaiting a second factor, set right after a
// successful password check and cleared once MFA passes (or the user logs out).
// Kept separate from the real session cookie so an unfinished login never grants
// access to anything.
const pendingMFACookieName = "mailgoserver_pending_mfa"
func setPendingMFACookie(w http.ResponseWriter, userID string) {
http.SetCookie(w, &http.Cookie{
Name: pendingMFACookieName, Value: userID, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: 10 * 60,
})
}
func clearPendingMFACookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: pendingMFACookieName, Value: "", Path: "/", MaxAge: -1})
}
func pendingMFAUserID(r *http.Request) int64 {
c, err := r.Cookie(pendingMFACookieName)
if err != nil {
return 0
}
return int64(atoi(c.Value))
}
func (a *App) loginForm(w http.ResponseWriter, r *http.Request) {
if sess, user, _ := a.currentSession(r); sess != nil && user != nil {
http.Redirect(w, r, Prefix+"/", http.StatusFound)
return
}
a.render(w, r, "login.html", M{"next": r.URL.Query().Get("next")})
}
// loginSubmit checks username+password, then either starts a fully-verified session
// (no second factor enabled) or a pending-MFA state that requires /login/mfa next.
func (a *App) loginSubmit(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
next := r.FormValue("next")
fail := func(msg string) {
a.render(w, r, "login.html", M{"error": msg, "username": username, "next": next})
}
user, err := a.DB.GetAdminUserByUsername(username)
if err != nil {
a.Logger.Error("login lookup: %v", err)
fail("Something went wrong. Try again.")
return
}
if user == nil || !db.CheckPassword(password, user.PasswordHash) {
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), false, "Incorrect username or password")
fail("Incorrect username or password.")
return
}
needsMFA := user.TOTPEnabled
if !needsMFA {
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
needsMFA = true
}
}
if !needsMFA {
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
if err != nil {
fail("Something went wrong. Try again.")
return
}
_ = a.DB.LogAuthAttempt("admin_login", username, requestIP(r), true, "Login successful")
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
return
}
setPendingMFACookie(w, strconv.FormatInt(user.ID, 10))
http.Redirect(w, r, Prefix+"/login/mfa?next="+next, http.StatusFound)
}
func redirectTarget(next string) string {
if next == "" || !strings.HasPrefix(next, Prefix) {
return Prefix + "/"
}
return next
}
func (a *App) mfaForm(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
if userID == 0 {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
user, _ := a.DB.GetAdminUserByID(userID)
if user == nil {
clearPendingMFACookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": r.URL.Query().Get("next"), "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0,
})
}
// mfaSubmit verifies the TOTP code for the pending login and, on success, promotes
// the pending state into a real, fully-verified session.
func (a *App) mfaSubmit(w http.ResponseWriter, r *http.Request) {
userID := pendingMFAUserID(r)
next := r.FormValue("next")
if userID == 0 {
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
user, err := a.DB.GetAdminUserByID(userID)
if err != nil || user == nil {
clearPendingMFACookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
code := strings.TrimSpace(r.FormValue("code"))
if !user.TOTPEnabled || !totp.Validate(code, user.TOTPSecret) {
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), false, "Invalid MFA code")
hasPasskeys, _ := a.DB.CountWebAuthnCredentials(userID)
a.render(w, r, "login_mfa.html", M{
"next": next, "totp_enabled": user.TOTPEnabled, "has_passkeys": hasPasskeys > 0, "error": "Invalid code.",
})
return
}
token, err := a.DB.CreateSession(user.ID, true, sessionTTL)
if err != nil {
a.Logger.Error("create session: %v", err)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("admin_login", user.Username, requestIP(r), true, "Login successful (authenticator app)")
clearPendingMFACookie(w)
setSessionCookie(w, token, r.TLS != nil)
http.Redirect(w, r, redirectTarget(next), http.StatusFound)
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
_ = a.DB.DeleteSession(c.Value)
}
clearSessionCookie(w)
http.Redirect(w, r, Prefix+"/login", http.StatusFound)
}
func (a *App) firstLoginForm(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
a.render(w, r, "first_login.html", M{"username": user.Username, "must_change_username": user.MustChangeUsername})
}
// firstLoginSubmit mirrors the forced credential-change flow: always require a new
// password before must_change_password clears; only the seeded default admin
// (MustChangeUsername) is additionally required to pick a new username — a delegated
// admin already chose their own username when the account was created, so re-asking
// for one here would just be busywork with no security purpose.
func (a *App) firstLoginSubmit(w http.ResponseWriter, r *http.Request) {
user := userFromContext(r)
newPassword := r.FormValue("password")
confirm := r.FormValue("password_confirm")
fail := func(msg string) {
a.render(w, r, "first_login.html", M{
"username": r.FormValue("username"), "must_change_username": user.MustChangeUsername, "error": msg,
})
}
if !isStrongPassword(newPassword) {
fail("Password must be at least 10 characters and include a letter, a number, and a symbol.")
return
}
if newPassword != confirm {
fail("Passwords don't match.")
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
fail("Something went wrong. Try again.")
return
}
if !user.MustChangeUsername {
if err := a.DB.UpdateAdminPasswordClearMustChange(user.ID, hash); err != nil {
fail("Something went wrong. Try again.")
return
}
setFlash(w, "success", "Password updated. Welcome to your dashboard.")
http.Redirect(w, r, Prefix+"/", http.StatusFound)
return
}
newUsername := strings.TrimSpace(r.FormValue("username"))
if newUsername == "" {
fail("Choose a username.")
return
}
if existing, _ := a.DB.GetAdminUserByUsername(newUsername); existing != nil && existing.ID != user.ID {
fail("That username is already taken.")
return
}
if err := a.DB.UpdateAdminCredentials(user.ID, newUsername, hash); err != nil {
fail("Something went wrong. Try again.")
return
}
setFlash(w, "success", "Credentials updated. Welcome to your dashboard.")
http.Redirect(w, r, Prefix+"/", http.StatusFound)
}
// isStrongPassword requires the same practical minimum most providers enforce: not
// the literal default, long enough, and not just letters.
func isStrongPassword(pw string) bool {
if len(pw) < 10 {
return false
}
var hasLetter, hasDigit, hasSymbol bool
for _, c := range pw {
switch {
case c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z':
hasLetter = true
case c >= '0' && c <= '9':
hasDigit = true
default:
hasSymbol = true
}
}
return hasLetter && hasDigit && hasSymbol
}