feat(story-1.4): add session-based authentication with login/logout

This commit is contained in:
Michael committed 2026-02-04 14:44:48 +01:00
1 parent e186a4dd87
commit d67e169a43
8 files changed
+446 -13

No files matched your search

+106 -1
View File
@@ -1,10 +1,10 @@
// Package handler contains HTTP handlers for the REST API.
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/michael/flowreader/internal/service"
)
@@ -45,6 +45,111 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusCreated, resp)
}
// Login handles POST /api/v1/auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req service.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Add request metadata
req.UserAgent = r.UserAgent()
req.IPAddress = getClientIP(r)
resp, err := h.authService.Login(req)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
respondError(w, http.StatusUnauthorized, "Invalid email or password")
} else {
respondError(w, http.StatusInternalServerError, "Login failed")
}
return
}
// Set session cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: resp.Token,
Path: "/",
Expires: resp.ExpiresAt,
HttpOnly: true,
Secure: r.TLS != nil, // Secure only if HTTPS
SameSite: http.SameSiteStrictMode,
})
respondJSON(w, http.StatusOK, resp)
}
// Logout handles POST /api/v1/auth/logout
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
respondJSON(w, http.StatusOK, map[string]string{"message": "Already logged out"})
return
}
_ = h.authService.Logout(cookie.Value)
// Clear the cookie
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: r.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
respondJSON(w, http.StatusOK, map[string]string{"message": "Logged out successfully"})
}
// Me handles GET /api/v1/users/me
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_id")
if err != nil {
respondError(w, http.StatusUnauthorized, "Not authenticated")
return
}
user, err := h.authService.GetUserByToken(cookie.Value)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get user")
return
}
if user == nil {
respondError(w, http.StatusUnauthorized, "Session expired")
return
}
respondJSON(w, http.StatusOK, service.UserInfo{
ID: user.ID,
Email: user.Email,
IsAdmin: user.IsAdmin,
})
}
// getClientIP extracts the client IP from the request.
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
// Take the first IP in the chain
parts := strings.Split(forwarded, ",")
return strings.TrimSpace(parts[0])
}
// Check X-Real-IP header
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return realIP
}
// Fall back to RemoteAddr
return r.RemoteAddr
}
// respondJSON writes a JSON response.
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")