257 lines
7.0 KiB
Go
257 lines
7.0 KiB
Go
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,
|
|
})
|
|
}
|