fix: convert auth flows from SSE to standard HTTP to fix session cookies
Some checks failed
CI / Deploy / test (pull_request) Successful in 33s
CI / Deploy / lint (pull_request) Failing after 38s
CI / Deploy / deploy (pull_request) Has been skipped

Datastar's NewSSE() flushes HTTP headers before SCS's session middleware
can attach the Set-Cookie header, so the session cookie never reaches the
browser after login/register/logout.

Convert login, register, and logout to standard HTML forms with HTTP
redirects, which lets SCS write cookies normally. Also fix return_url
capture on the login page (was never being stored in the session).

Add handler tests covering login, register, and logout flows.
This commit is contained in:
Ryan Hamamura
2026-03-11 10:10:28 -10:00
parent 8573e87bf6
commit 72d31fd143
7 changed files with 424 additions and 102 deletions

View File

@@ -3,10 +3,10 @@ package auth
import (
"database/sql"
"net/http"
"net/url"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
"github.com/starfederation/datastar-go/datastar"
"github.com/ryanhamamura/games/auth"
"github.com/ryanhamamura/games/db/repository"
@@ -14,20 +14,15 @@ import (
appsessions "github.com/ryanhamamura/games/sessions"
)
type LoginSignals struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
}
type RegisterSignals struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // form input, not stored
Confirm string `json:"confirm"`
}
func HandleLoginPage() http.HandlerFunc {
func HandleLoginPage(sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := pages.LoginPage().Render(r.Context(), w); err != nil {
// Capture return_url so we can redirect back after login
if returnURL := r.URL.Query().Get("return_url"); returnURL != "" {
sessions.Put(r.Context(), "return_url", returnURL)
}
errorMsg := r.URL.Query().Get("error")
if err := pages.LoginPage(errorMsg).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -35,7 +30,8 @@ func HandleLoginPage() http.HandlerFunc {
func HandleRegisterPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := pages.RegisterPage().Render(r.Context(), w); err != nil {
errorMsg := r.URL.Query().Get("error")
if err := pages.RegisterPage(errorMsg).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
}
@@ -43,25 +39,20 @@ func HandleRegisterPage() http.HandlerFunc {
func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var signals LoginSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
sse := datastar.NewSSE(w, r)
user, err := queries.GetUserByUsername(r.Context(), signals.Username)
user, err := queries.GetUserByUsername(r.Context(), username)
if err == sql.ErrNoRows {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
return
}
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck
http.Redirect(w, r, "/login?error="+url.QueryEscape("An error occurred"), http.StatusSeeOther)
return
}
if !auth.CheckPassword(signals.Password, user.PasswordHash) {
sse.MarshalAndPatchSignals(map[string]any{"error": "Invalid username or password"}) //nolint:errcheck
if !auth.CheckPassword(password, user.PasswordHash) {
http.Redirect(w, r, "/login?error="+url.QueryEscape("Invalid username or password"), http.StatusSeeOther)
return
}
@@ -76,46 +67,42 @@ func HandleLogin(queries *repository.Queries, sessions *scs.SessionManager) http
redirectURL = returnURL
}
sse.Redirect(redirectURL) //nolint:errcheck
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
}
func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var signals RegisterSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
username := r.FormValue("username")
password := r.FormValue("password")
confirm := r.FormValue("confirm")
if err := auth.ValidateUsername(username); err != nil {
http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
if err := auth.ValidatePassword(password); err != nil {
http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
if password != confirm {
http.Redirect(w, r, "/register?error="+url.QueryEscape("Passwords do not match"), http.StatusSeeOther)
return
}
sse := datastar.NewSSE(w, r)
if err := auth.ValidateUsername(signals.Username); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if err := auth.ValidatePassword(signals.Password); err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": err.Error()}) //nolint:errcheck
return
}
if signals.Password != signals.Confirm {
sse.MarshalAndPatchSignals(map[string]any{"error": "Passwords do not match"}) //nolint:errcheck
return
}
hash, err := auth.HashPassword(signals.Password)
hash, err := auth.HashPassword(password)
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "An error occurred"}) //nolint:errcheck
http.Redirect(w, r, "/register?error="+url.QueryEscape("An error occurred"), http.StatusSeeOther)
return
}
user, err := queries.CreateUser(r.Context(), repository.CreateUserParams{
ID: uuid.New().String(),
Username: signals.Username,
Username: username,
PasswordHash: hash,
})
if err != nil {
sse.MarshalAndPatchSignals(map[string]any{"error": "Username already taken"}) //nolint:errcheck
http.Redirect(w, r, "/register?error="+url.QueryEscape("Username already taken"), http.StatusSeeOther)
return
}
@@ -130,6 +117,6 @@ func HandleRegister(queries *repository.Queries, sessions *scs.SessionManager) h
redirectURL = returnURL
}
sse.Redirect(redirectURL) //nolint:errcheck
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
}