This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+256
View File
@@ -0,0 +1,256 @@
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,
})
}