242 lines
7.6 KiB
Go
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
|
|
}
|