444 lines
13 KiB
Go
444 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"xtablo-backend/internal/web/views"
|
|
|
|
"github.com/a-h/templ"
|
|
)
|
|
|
|
const sessionCookieName = "xtablo_session"
|
|
const sessionLifetime = 30 * 24 * time.Hour
|
|
|
|
var ErrUserNotFound = errors.New("user not found")
|
|
var ErrUserAlreadyExists = errors.New("user already exists")
|
|
var ErrSessionNotFound = errors.New("session not found")
|
|
|
|
type AuthRepository interface {
|
|
CreateAuthUser(ctx context.Context, input CreateAuthUserInput) (uuid.UUID, error)
|
|
GetAuthUserByEmail(ctx context.Context, email string) (AuthUser, error)
|
|
GetPublicUserByID(ctx context.Context, id uuid.UUID) (PublicUser, error)
|
|
CreateSession(ctx context.Context, token string, userID uuid.UUID, expiresAt time.Time) error
|
|
GetSessionByToken(ctx context.Context, token string) (Session, error)
|
|
DeleteSessionByToken(ctx context.Context, token string) error
|
|
CreateTablo(ctx context.Context, input CreateTabloInput) (TabloRecord, error)
|
|
ListTablos(ctx context.Context, input ListTablosInput) ([]TabloRecord, error)
|
|
SoftDeleteTablo(ctx context.Context, tabloID uuid.UUID, ownerID uuid.UUID) error
|
|
}
|
|
|
|
type CreateAuthUserInput struct {
|
|
Email string
|
|
EncryptedPassword string
|
|
DisplayName string
|
|
}
|
|
|
|
type AuthUser struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
EncryptedPassword string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type PublicUser struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
DisplayName string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type Session struct {
|
|
Token string
|
|
UserID uuid.UUID
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type AuthHandler struct {
|
|
repo AuthRepository
|
|
}
|
|
|
|
func NewAuthHandler(repo AuthRepository) *AuthHandler {
|
|
return &AuthHandler{repo: repo}
|
|
}
|
|
|
|
func (h *AuthHandler) GetHome() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := h.authenticatedUser(r.Context(), r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
tablos, err := h.repo.ListTablos(context.Background(), ListTablosInput{
|
|
OwnerID: user.ID,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "failed to load projects", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
showAllProjects := r.URL.Query().Get("show_projects") == "all"
|
|
projects := views.OverviewProjectsFromTablos(tablos)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if isHXRequest(r) && targetsOverviewProjectsSection(r) {
|
|
if err := views.OverviewProjectsSection(projects, showAllProjects).Render(r.Context(), w); err != nil {
|
|
http.Error(w, "failed to render overview projects", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
content := views.OverviewMainContent(user.DisplayName, user.Email, projects, showAllProjects)
|
|
var renderErr error
|
|
if isHXRequest(r) {
|
|
renderErr = views.DashboardContentSwap("/", content).Render(r.Context(), w)
|
|
} else {
|
|
renderErr = views.DashboardPage("/", content).Render(r.Context(), w)
|
|
}
|
|
if renderErr != nil {
|
|
http.Error(w, "failed to render app page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) GetTasksPage() http.HandlerFunc {
|
|
return h.renderAppPage("/tasks", func(user PublicUser) templ.Component {
|
|
return views.TasksMainContent()
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetTablosPage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
h.renderTablosPage(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) GetPlanningPage() http.HandlerFunc {
|
|
return h.renderAppPage("/planning", func(user PublicUser) templ.Component {
|
|
return views.PlanningMainContent()
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetChatPage() http.HandlerFunc {
|
|
return h.renderAppPage("/chat", func(user PublicUser) templ.Component {
|
|
return views.ChatMainContent()
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetFilesPage() http.HandlerFunc {
|
|
return h.renderAppPage("/files", func(user PublicUser) templ.Component {
|
|
return views.FilesMainContent()
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetFeedbackPage() http.HandlerFunc {
|
|
return h.renderAppPage("/feedback", func(user PublicUser) templ.Component {
|
|
return views.FeedbackMainContent()
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) GetNotFound() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := h.authenticatedUser(r.Context(), r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
content := views.NotFoundContent(user.DisplayName)
|
|
var err error
|
|
if isHXRequest(r) {
|
|
err = views.DashboardContentSwap("", content).Render(r.Context(), w)
|
|
} else {
|
|
err = views.DashboardPage("", content).Render(r.Context(), w)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "failed to render not found page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) renderAppPage(activePath string, content func(user PublicUser) templ.Component) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := h.authenticatedUser(r.Context(), r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
pageContent := content(user)
|
|
var err error
|
|
if isHXRequest(r) {
|
|
err = views.DashboardContentSwap(activePath, pageContent).Render(r.Context(), w)
|
|
} else {
|
|
err = views.DashboardPage(activePath, pageContent).Render(r.Context(), w)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "failed to render app page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) authenticatedUser(ctx context.Context, r *http.Request) (PublicUser, bool) {
|
|
userID, ok := h.currentUserID(ctx, r)
|
|
if !ok {
|
|
return PublicUser{}, false
|
|
}
|
|
|
|
user, err := h.repo.GetPublicUserByID(ctx, userID)
|
|
if err != nil {
|
|
return PublicUser{}, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
func (h *AuthHandler) PostLogout() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var email string
|
|
if userID, ok := h.currentUserID(r.Context(), r); ok {
|
|
if user, err := h.repo.GetPublicUserByID(r.Context(), userID); err == nil {
|
|
email = user.Email
|
|
}
|
|
}
|
|
|
|
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
|
|
_ = h.repo.DeleteSessionByToken(r.Context(), cookie.Value)
|
|
logStoreMutation("delete_session", email, cookie.Value, 0, 0)
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) GetLoginPage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.currentUserID(r.Context(), r); ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := views.LoginPage().Render(r.Context(), w); err != nil {
|
|
http.Error(w, "failed to render login page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) GetSignupPage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.currentUserID(r.Context(), r); ok {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := views.SignupPage().Render(r.Context(), w); err != nil {
|
|
http.Error(w, "failed to render signup page", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) PostLogin() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
email := normalizeEmail(r.FormValue("email"))
|
|
password := strings.TrimSpace(r.FormValue("password"))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
switch {
|
|
case email == "" || password == "":
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = views.AuthStatus("error", "Veuillez renseigner votre email et votre mot de passe.").Render(r.Context(), w)
|
|
return
|
|
}
|
|
|
|
authUser, err := h.repo.GetAuthUserByEmail(r.Context(), email)
|
|
if err != nil || bcrypt.CompareHashAndPassword([]byte(authUser.EncryptedPassword), []byte(password)) != nil {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = views.AuthStatus("error", "Identifiants invalides. Essayez demo@xtablo.com / xtablo-demo.").Render(r.Context(), w)
|
|
return
|
|
}
|
|
|
|
h.setSession(r.Context(), w, authUser.ID, authUser.Email)
|
|
w.Header().Set("HX-Redirect", "/")
|
|
_ = views.AuthStatus("success", "Connexion réussie.").Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) PostSignup() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form payload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
email := normalizeEmail(r.FormValue("email"))
|
|
password := strings.TrimSpace(r.FormValue("password"))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
switch {
|
|
case email == "" || password == "":
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_ = views.AuthStatus("error", "Veuillez renseigner votre email et choisir un mot de passe.").Render(r.Context(), w)
|
|
return
|
|
}
|
|
|
|
if _, err := h.repo.GetAuthUserByEmail(r.Context(), email); err == nil {
|
|
w.WriteHeader(http.StatusConflict)
|
|
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
|
|
return
|
|
} else if !errors.Is(err, ErrUserNotFound) {
|
|
http.Error(w, "failed to check existing user", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
passwordHash, err := hashPassword(password)
|
|
if err != nil {
|
|
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
displayName := displayNameFromEmail(email)
|
|
userID, err := h.repo.CreateAuthUser(r.Context(), CreateAuthUserInput{
|
|
Email: email,
|
|
EncryptedPassword: passwordHash,
|
|
DisplayName: displayName,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, ErrUserAlreadyExists) {
|
|
w.WriteHeader(http.StatusConflict)
|
|
_ = views.AuthStatus("error", "Un compte existe déjà avec cet email.").Render(r.Context(), w)
|
|
return
|
|
}
|
|
log.Err(err).Msg("failed to create user")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = views.AuthStatus("error", "Un problème est survenu.").Render(r.Context(), w)
|
|
return
|
|
}
|
|
|
|
h.setSession(r.Context(), w, userID, email)
|
|
w.Header().Set("HX-Redirect", "/")
|
|
_ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w)
|
|
}
|
|
}
|
|
|
|
func (h *AuthHandler) currentUserID(ctx context.Context, r *http.Request) (uuid.UUID, bool) {
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if err != nil || cookie.Value == "" {
|
|
return uuid.Nil, false
|
|
}
|
|
session, err := h.repo.GetSessionByToken(ctx, cookie.Value)
|
|
if err != nil {
|
|
return uuid.Nil, false
|
|
}
|
|
if session.ExpiresAt.Before(time.Now().UTC()) {
|
|
_ = h.repo.DeleteSessionByToken(ctx, cookie.Value)
|
|
return uuid.Nil, false
|
|
}
|
|
return session.UserID, true
|
|
}
|
|
|
|
func (h *AuthHandler) setSession(ctx context.Context, w http.ResponseWriter, userID uuid.UUID, email string) {
|
|
sessionID := randomToken(32)
|
|
expiresAt := time.Now().UTC().Add(sessionLifetime)
|
|
if err := h.repo.CreateSession(ctx, sessionID, userID, expiresAt); err != nil {
|
|
panic(err)
|
|
}
|
|
logStoreMutation("create_session", email, sessionID, 0, 0)
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: sessionID,
|
|
Path: "/",
|
|
Expires: expiresAt,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func normalizeEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
func displayNameFromEmail(email string) string {
|
|
email = normalizeEmail(email)
|
|
if email == "" {
|
|
return ""
|
|
}
|
|
return strings.Split(email, "@")[0]
|
|
}
|
|
|
|
func hashPassword(password string) (string, error) {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(passwordHash), nil
|
|
}
|
|
|
|
func randomToken(size int) string {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
func logStoreMutation(action string, email string, sessionID string, usersCount int, sessionsCount int) {
|
|
event := log.Info().
|
|
Str("component", "auth_store").
|
|
Str("action", action).
|
|
Int("users_count", usersCount).
|
|
Int("sessions_count", sessionsCount)
|
|
|
|
if email != "" {
|
|
event = event.Str("email", email)
|
|
}
|
|
if sessionID != "" {
|
|
event = event.Str("session_id", sessionID)
|
|
}
|
|
|
|
event.Msg("auth store mutated")
|
|
}
|
|
|
|
func isHXRequest(r *http.Request) bool {
|
|
return r.Header.Get("HX-Request") == "true"
|
|
}
|
|
|
|
func targetsOverviewProjectsSection(r *http.Request) bool {
|
|
target := strings.TrimSpace(r.Header.Get("HX-Target"))
|
|
if target == "" {
|
|
return false
|
|
}
|
|
return target == "overview-projects-section" || strings.Contains(target, "#overview-projects-section")
|
|
}
|