package handlers import ( "context" "html/template" "log/slog" "net/http" "github.com/leolionad58/ticketbot/internal/db" panelauth "github.com/leolionad58/ticketbot/internal/panel/auth" ) // AuthHandler handles TOTP setup, credential verification, and logout. type AuthHandler struct { Admins *db.PanelAdminRepo Auth *panelauth.Service AuditLog *db.AuditLogRepo Issuer string Renderer *Renderer } // HandlePasswordSetupGET serves GET /auth/password-setup (first login). func (h *AuthHandler) HandlePasswordSetupGET(w http.ResponseWriter, r *http.Request) { if panelauth.PendingAuthDiscordID(r) == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } h.Renderer.Auth(w, "password_setup", struct{ Error string }{}) } // HandlePasswordSetupPOST serves POST /auth/password-setup. func (h *AuthHandler) HandlePasswordSetupPOST(w http.ResponseWriter, r *http.Request) { discordID := panelauth.PendingAuthDiscordID(r) if discordID == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } renderErr := func(msg string) { h.Renderer.Auth(w, "password_setup", struct{ Error string }{Error: msg}) } password := r.FormValue("password") confirm := r.FormValue("confirm") if len(password) < 12 { renderErr("Le mot de passe doit faire au moins 12 caractères.") return } if password != confirm { renderErr("Les mots de passe ne correspondent pas.") return } ctx := r.Context() admin, err := h.Admins.GetByDiscordID(ctx, discordID) if err != nil || admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } hash, err := panelauth.HashPassword(password) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if err := h.Admins.UpdatePassword(ctx, admin.ID, hash); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if !admin.TOTPEnabled { http.Redirect(w, r, "/auth/totp-setup", http.StatusSeeOther) return } http.Redirect(w, r, "/auth/verify", http.StatusSeeOther) } // HandleTOTPSetupGET serves GET /auth/totp-setup. func (h *AuthHandler) HandleTOTPSetupGET(w http.ResponseWriter, r *http.Request) { discordID := panelauth.PendingAuthDiscordID(r) if discordID == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } ctx := r.Context() admin, err := h.Admins.GetByDiscordID(ctx, discordID) if err != nil || admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } qr, secret, err := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername) if err != nil { slog.Error("totp setup: generate", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } type totpSetupData struct { QRCodeBase64 template.URL Secret string Error string } h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: secret}) } // HandleTOTPSetupPOST serves POST /auth/totp-setup. func (h *AuthHandler) HandleTOTPSetupPOST(w http.ResponseWriter, r *http.Request) { discordID := panelauth.PendingAuthDiscordID(r) if discordID == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } secret := r.FormValue("secret") code := r.FormValue("code") ctx := r.Context() admin, err := h.Admins.GetByDiscordID(ctx, discordID) if err != nil || admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } type totpSetupData struct { QRCodeBase64 template.URL Secret string Error string } if err := h.Auth.ConfirmTOTP(ctx, admin.ID, secret, code); err != nil { qr, sec, _ := panelauth.GenerateTOTP(h.Issuer, admin.DiscordUsername) h.Renderer.Auth(w, "totp_setup", totpSetupData{QRCodeBase64: template.URL(qr), Secret: sec, Error: "Code invalide. Réessaie."}) return } slog.Info("totp setup completed", "discord_id", discordID) http.Redirect(w, r, "/auth/verify", http.StatusSeeOther) } // HandleVerifyGET serves GET /auth/verify. func (h *AuthHandler) HandleVerifyGET(w http.ResponseWriter, r *http.Request) { discordID := panelauth.PendingAuthDiscordID(r) if discordID == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } ctx := r.Context() admin, err := h.Admins.GetByDiscordID(ctx, discordID) if err != nil || admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } h.Renderer.Auth(w, "totp_verify", struct { Admin *db.PanelAdmin Error string }{Admin: admin}) } // HandleVerifyPOST serves POST /auth/verify. func (h *AuthHandler) HandleVerifyPOST(w http.ResponseWriter, r *http.Request) { discordID := panelauth.PendingAuthDiscordID(r) if discordID == "" { http.Redirect(w, r, "/login", http.StatusSeeOther) return } ctx := r.Context() admin, err := h.Admins.GetByDiscordID(ctx, discordID) if err != nil || admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } renderErr := func(msg string) { h.Renderer.Auth(w, "totp_verify", struct { Admin *db.PanelAdmin Error string }{Admin: admin, Error: msg}) } ip := clientIP(r) // Rate limit check if !h.Auth.Allow(discordID) { h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked) renderErr("Trop de tentatives. Réessaie dans 15 minutes.") return } password := r.FormValue("password") totpCode := r.FormValue("totp") if !h.Auth.VerifyCredentials(admin, password, totpCode) { locked := h.Auth.RecordFailure(discordID) h.auditLoginFail(ctx, admin, ip, db.AuditAdminLoginFail) if locked { h.auditLoginFail(ctx, admin, ip, db.AuditAdminLocked) slog.Warn("admin account locked", "discord_id", discordID, "ip", ip) } renderErr("Identifiants invalides.") return } // Success — clear rate limit and pending cookie, create session h.Auth.ResetLimit(discordID) panelauth.ClearPendingAuthCookie(w, r) sess, err := h.Auth.CreateSession(ctx, admin.ID, ip, r.UserAgent()) if err != nil { slog.Error("verify: create session", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } panelauth.SetSessionCookie(w, r, sess.Token) h.auditLogin(ctx, admin, ip) slog.Info("admin logged in", "discord_id", discordID, "ip", clientIP(r)) http.Redirect(w, r, "/", http.StatusSeeOther) } // HandleLogout serves POST /auth/logout. func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("panel_session"); err == nil { _ = h.Auth.InvalidateSession(r.Context(), c.Value) } http.SetCookie(w, &http.Cookie{Name: "panel_session", MaxAge: -1, Path: "/"}) http.Redirect(w, r, "/login", http.StatusSeeOther) } func (h *AuthHandler) auditLogin(ctx context.Context, admin *db.PanelAdmin, ip string) { if h.AuditLog == nil { return } _ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{ AdminID: admin.ID, Action: db.AuditAdminLogin, EntityType: "admin", IPAddress: ip, }) } func (h *AuthHandler) auditLoginFail(ctx context.Context, admin *db.PanelAdmin, ip, action string) { if h.AuditLog == nil { return } _ = h.AuditLog.Insert(ctx, &db.AuditLogEntry{ AdminID: admin.ID, Action: action, EntityType: "admin", IPAddress: ip, }) }