oups
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user