322 lines
8.7 KiB
Go
322 lines
8.7 KiB
Go
package webadmin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
|
|
"ghb.freebede.com/nahakubuilder/mailgosend/internal/totp"
|
|
)
|
|
|
|
const adminPreAuthCookieName = "mailgo_admin_preauth"
|
|
const adminPreAuthMaxAge = 300 // 5 minutes
|
|
|
|
// loginGet renders the admin login form.
|
|
// Redirects to /admin/setup if no admin user has been created yet.
|
|
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
|
|
// Already logged in → dashboard.
|
|
if s.currentAdmin(r) != nil {
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
// First-run: no admin user exists yet.
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
if has, err := s.deps.DB.HasAdminUser(ctx); err == nil && !has {
|
|
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "login", struct {
|
|
basePage
|
|
}{newBaseNoUser(flash, errMsg)})
|
|
}
|
|
|
|
// loginPost handles credential submission (step 1 of 2 if MFA enabled).
|
|
func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
password := r.FormValue("password")
|
|
|
|
clientIP := realIP(r)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
// Brute-force check.
|
|
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
|
banned, err := s.deps.Brute.IsBanned(ctx, clientIP)
|
|
if err != nil {
|
|
log.Printf("[admin] brute check: %v", err)
|
|
}
|
|
if banned {
|
|
redirect(w, r, "/admin/login", "", "Too many failed attempts. Try again later.")
|
|
return
|
|
}
|
|
}
|
|
|
|
if email == "" || password == "" || len(email) > 254 || len(password) > 1024 {
|
|
s.recordAttempt(ctx, clientIP, email, false)
|
|
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
|
return
|
|
}
|
|
|
|
user, err := s.deps.DB.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
log.Printf("[admin] login db: %v", err)
|
|
redirect(w, r, "/admin/login", "", "Internal error.")
|
|
return
|
|
}
|
|
|
|
if user == nil || !user.Enabled || !user.Admin {
|
|
s.recordAttempt(ctx, clientIP, email, false)
|
|
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
|
return
|
|
}
|
|
|
|
if err := appCrypto.CheckPassword(user.PasswordHash, password); err != nil {
|
|
s.recordAttempt(ctx, clientIP, email, false)
|
|
redirect(w, r, "/admin/login", "", "Invalid credentials.")
|
|
return
|
|
}
|
|
|
|
// Password OK. Check MFA.
|
|
if user.MFAEnabled && len(user.MFASecretEnc) > 0 {
|
|
s.setAdminPreAuthCookie(w, user.ID)
|
|
http.Redirect(w, r, "/admin/login/mfa", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
// No MFA — create session.
|
|
s.recordAttempt(ctx, clientIP, email, true)
|
|
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
|
log.Printf("[admin] session create: %v", err)
|
|
redirect(w, r, "/admin/login", "", "Session error.")
|
|
return
|
|
}
|
|
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
|
}
|
|
|
|
// mfaGet renders the TOTP challenge page for admin login.
|
|
func (s *Server) mfaGet(w http.ResponseWriter, r *http.Request) {
|
|
if s.currentAdmin(r) != nil {
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if _, ok := s.adminPreAuthUserID(r); !ok {
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
flash, errMsg := flashFrom(r)
|
|
s.render(w, "mfa", struct{ basePage }{newBaseNoUser(flash, errMsg)})
|
|
}
|
|
|
|
// mfaPost verifies TOTP and completes admin login.
|
|
func (s *Server) mfaPost(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID, ok := s.adminPreAuthUserID(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
clientIP := realIP(r)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if s.deps.Brute != nil && s.deps.Cfg.BruteMaxTries > 0 {
|
|
banned, _ := s.deps.Brute.IsBanned(ctx, clientIP)
|
|
if banned {
|
|
clearAdminPreAuth(w)
|
|
redirect(w, r, "/admin/login", "", "Too many failed attempts. Try again later.")
|
|
return
|
|
}
|
|
}
|
|
|
|
code := strings.TrimSpace(r.FormValue("code"))
|
|
if len(code) == 0 || len(code) > 64 {
|
|
s.recordAttempt(ctx, clientIP, "", false)
|
|
redirect(w, r, "/admin/login/mfa", "", "Invalid code.")
|
|
return
|
|
}
|
|
|
|
user, err := s.deps.DB.GetUserByID(ctx, userID)
|
|
if err != nil || user == nil || !user.Enabled || !user.Admin || !user.MFAEnabled {
|
|
clearAdminPreAuth(w)
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
secretRaw, err := s.deps.Crypt.DecryptForUser(user.ID, "totp", user.MFASecretEnc)
|
|
if err != nil {
|
|
log.Printf("[admin] mfa decrypt: %v", err)
|
|
clearAdminPreAuth(w)
|
|
redirect(w, r, "/admin/login", "", "MFA error. Please try again.")
|
|
return
|
|
}
|
|
|
|
var authenticated bool
|
|
if len(code) == totp.Digits {
|
|
authenticated = totp.Verify(string(secretRaw), code)
|
|
} else if len(user.RecoveryCodesEnc) > 0 {
|
|
codesRaw, cerr := s.deps.Crypt.DecryptForUser(user.ID, "recovery", user.RecoveryCodesEnc)
|
|
if cerr == nil {
|
|
codes, cerr := totp.DecodeRecoveryCodes(codesRaw)
|
|
if cerr == nil {
|
|
updated, consumed := totp.ConsumeRecoveryCode(codes, code)
|
|
if consumed {
|
|
authenticated = true
|
|
newEnc, encErr := s.deps.Crypt.EncryptForUser(user.ID, "recovery", mustMarshalAdminCodes(updated))
|
|
if encErr == nil {
|
|
_ = s.deps.DB.SetRecoveryCodes(ctx, user.ID, newEnc)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !authenticated {
|
|
s.recordAttempt(ctx, clientIP, user.Email, false)
|
|
redirect(w, r, "/admin/login/mfa", "", "Invalid code.")
|
|
return
|
|
}
|
|
|
|
clearAdminPreAuth(w)
|
|
s.recordAttempt(ctx, clientIP, user.Email, true)
|
|
if _, err := s.deps.Sessions.Create(w, r, user.ID); err != nil {
|
|
log.Printf("[admin] session create: %v", err)
|
|
redirect(w, r, "/admin/login", "", "Session error.")
|
|
return
|
|
}
|
|
s.deps.DB.UpdateLastLogin(ctx, user.ID)
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
|
}
|
|
|
|
// logout destroys the session and redirects to login.
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.deps.Sessions.Destroy(w, r); err != nil {
|
|
log.Printf("[admin] session destroy: %v", err)
|
|
}
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
}
|
|
|
|
// ---- Pre-auth cookie helpers ----
|
|
|
|
func (s *Server) setAdminPreAuthCookie(w http.ResponseWriter, userID int64) {
|
|
ts := fmt.Sprintf("%d", time.Now().Unix())
|
|
uid := fmt.Sprintf("%d", userID)
|
|
payload := uid + "|" + ts
|
|
mac := adminPreAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
|
value := payload + "|" + mac
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: adminPreAuthCookieName,
|
|
Value: value,
|
|
Path: "/admin/login",
|
|
MaxAge: adminPreAuthMaxAge,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminPreAuthUserID(r *http.Request) (int64, bool) {
|
|
c, err := r.Cookie(adminPreAuthCookieName)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
parts := strings.SplitN(c.Value, "|", 3)
|
|
if len(parts) != 3 {
|
|
return 0, false
|
|
}
|
|
uid, ts, gotMAC := parts[0], parts[1], parts[2]
|
|
payload := uid + "|" + ts
|
|
|
|
wantMAC := adminPreAuthMAC(s.deps.Cfg.SessionSecret, payload)
|
|
if !hmac.Equal([]byte(gotMAC), []byte(wantMAC)) {
|
|
return 0, false
|
|
}
|
|
|
|
tsi, err := strconv.ParseInt(ts, 10, 64)
|
|
if err != nil || time.Now().Unix()-tsi > adminPreAuthMaxAge {
|
|
return 0, false
|
|
}
|
|
|
|
id, err := strconv.ParseInt(uid, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func clearAdminPreAuth(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: adminPreAuthCookieName,
|
|
Value: "",
|
|
Path: "/admin/login",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
func adminPreAuthMAC(secret []byte, payload string) string {
|
|
mac := hmac.New(sha256.New, secret)
|
|
mac.Write([]byte(payload))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func mustMarshalAdminCodes(codes []string) []byte {
|
|
data, err := totp.EncodeRecoveryCodes(codes)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("totp marshal: %v", err))
|
|
}
|
|
return data
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func (s *Server) recordAttempt(ctx context.Context, ip, email string, success bool) {
|
|
if s.deps.Brute != nil {
|
|
s.deps.Brute.RecordAttempt(ctx, ip, email, success)
|
|
}
|
|
}
|
|
|
|
// newBaseNoUser builds a basePage without requiring a session (used on login page).
|
|
func newBaseNoUser(flash, errMsg string) basePage {
|
|
return basePage{Flash: flash, Error: errMsg}
|
|
}
|
|
|
|
// realIP extracts the client IP, honouring X-Forwarded-For when behind a proxy.
|
|
func realIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
ip := strings.TrimSpace(parts[0])
|
|
if net.ParseIP(ip) != nil {
|
|
return ip
|
|
}
|
|
}
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
return host
|
|
}
|