xtablo-source/go-backend/internal/web/handlers/auth.go
2026-05-08 12:08:53 +02:00

396 lines
10 KiB
Go

package handlers
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"xtablo-backend/internal/web/views"
)
const sessionCookieName = "xtablo_session"
var ErrUserNotFound = errors.New("user not found")
var ErrUserAlreadyExists = errors.New("user already exists")
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)
}
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 AuthHandler struct {
repo AuthRepository
sessions *sessionStore
}
type sessionStore struct {
mu sync.RWMutex
sessions map[string]uuid.UUID
}
type InMemoryAuthRepository struct {
mu sync.RWMutex
authUsers map[string]AuthUser
publicUsers map[uuid.UUID]PublicUser
}
func NewAuthHandler(repo AuthRepository) *AuthHandler {
return &AuthHandler{
repo: repo,
sessions: &sessionStore{sessions: map[string]uuid.UUID{}},
}
}
func NewInMemoryAuthRepository() *InMemoryAuthRepository {
repo := &InMemoryAuthRepository{
authUsers: map[string]AuthUser{},
publicUsers: map[uuid.UUID]PublicUser{},
}
demoHash, err := hashPassword("xtablo-demo")
if err != nil {
panic(err)
}
if _, err := repo.CreateAuthUser(context.Background(), CreateAuthUserInput{
Email: "demo@xtablo.com",
EncryptedPassword: demoHash,
DisplayName: "demo",
}); err != nil {
panic(err)
}
return repo
}
func (h *AuthHandler) GetHome() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := h.currentUserID(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := h.repo.GetPublicUserByID(r.Context(), userID)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.HomePage(user.DisplayName, user.Email).Render(r.Context(), w); err != nil {
http.Error(w, "failed to render home page", http.StatusInternalServerError)
}
}
}
func (h *AuthHandler) PostLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var email string
if userID, ok := h.currentUserID(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.sessions.delete(cookie.Value, email)
}
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); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.LoginScreen()).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); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := views.AuthPage(views.SignupScreen()).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(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
}
http.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
h.setSession(w, userID, email)
w.Header().Set("HX-Redirect", "/")
_ = views.AuthStatus("success", "Compte créé.").Render(r.Context(), w)
}
}
func (h *AuthHandler) currentUserID(r *http.Request) (uuid.UUID, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return uuid.Nil, false
}
return h.sessions.get(cookie.Value)
}
func (h *AuthHandler) setSession(w http.ResponseWriter, userID uuid.UUID, email string) {
sessionID := randomToken(32)
h.sessions.set(sessionID, userID, email)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *sessionStore) get(sessionID string) (uuid.UUID, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
userID, ok := s.sessions[sessionID]
return userID, ok
}
func (s *sessionStore) set(sessionID string, userID uuid.UUID, email string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionID] = userID
logStoreMutation("create_session", email, sessionID, 0, len(s.sessions))
}
func (s *sessionStore) delete(sessionID string, email string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
logStoreMutation("delete_session", email, sessionID, 0, len(s.sessions))
}
func (r *InMemoryAuthRepository) CreateAuthUser(_ context.Context, input CreateAuthUserInput) (uuid.UUID, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.authUsers[input.Email]; exists {
return uuid.Nil, ErrUserAlreadyExists
}
id := uuid.New()
now := time.Now().UTC()
authUser := AuthUser{
ID: id,
Email: input.Email,
EncryptedPassword: input.EncryptedPassword,
CreatedAt: now,
UpdatedAt: now,
}
publicUser := PublicUser{
ID: id,
Email: input.Email,
DisplayName: input.DisplayName,
CreatedAt: now,
UpdatedAt: now,
}
r.authUsers[input.Email] = authUser
r.publicUsers[id] = publicUser
logStoreMutation("create_user", input.Email, "", len(r.authUsers), 0)
return id, nil
}
func (r *InMemoryAuthRepository) GetAuthUserByEmail(_ context.Context, email string) (AuthUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.authUsers[email]
if !ok {
return AuthUser{}, ErrUserNotFound
}
return user, nil
}
func (r *InMemoryAuthRepository) GetPublicUserByID(_ context.Context, id uuid.UUID) (PublicUser, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.publicUsers[id]
if !ok {
return PublicUser{}, ErrUserNotFound
}
return user, nil
}
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")
}