396 lines
10 KiB
Go
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")
|
|
}
|