Files
FHIR-Sandbox/app/middleware/session.go

163 lines
5.3 KiB
Go

// Package middleware provides HTTP middleware for the platform.
//
// The session middleware performs two functions:
// 1. RequireSession — a hard gate that returns 401/403 if no valid session
// is present. Use this on protected routes.
// 2. LoadSession — a soft loader that attaches session+user to the context
// if a valid cookie is present but does NOT block unauthenticated requests.
// Use this on public routes that want to show user-aware UI.
package middleware
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"time"
"github.com/AmanTahiliani/FHIR-Sandbox/app/db"
"github.com/AmanTahiliani/FHIR-Sandbox/app/models"
)
const sessionCookieName = "session_id"
// SessionMiddleware holds the dependencies needed by the session middleware.
type SessionMiddleware struct {
store *db.Store
}
// NewSessionMiddleware creates a new SessionMiddleware using the given store.
func NewSessionMiddleware(store *db.Store) *SessionMiddleware {
return &SessionMiddleware{store: store}
}
// LoadSession is a non-blocking middleware that attempts to resolve a session
// from the request cookie and, if valid, attaches both the Session and User
// to the request context.
//
// Requests without a valid session continue normally — this middleware does
// NOT reject unauthenticated requests. Use RequireSession for protected routes.
func (m *SessionMiddleware) LoadSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
// No cookie — proceed without a session context.
next.ServeHTTP(w, r)
return
}
sess, user, err := m.resolveSession(cookie.Value)
if err != nil {
// Invalid or expired session — clear the stale cookie and continue.
clearSessionCookie(w)
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), models.SessionContextKey{}, sess)
ctx = context.WithValue(ctx, models.UserContextKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireSession is a hard-gate middleware. It resolves the session cookie and,
// if valid, attaches session+user to the context. If the session is missing or
// invalid, it returns a 401 Unauthorized response (or redirects to "/" for
// browser clients).
func (m *SessionMiddleware) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
m.handleUnauthorized(w, r)
return
}
sess, user, err := m.resolveSession(cookie.Value)
if err != nil {
clearSessionCookie(w)
m.handleUnauthorized(w, r)
return
}
ctx := context.WithValue(r.Context(), models.SessionContextKey{}, sess)
ctx = context.WithValue(ctx, models.UserContextKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// resolveSession looks up the session by token, validates its expiry,
// and fetches the associated user record. Returns both or an error.
func (m *SessionMiddleware) resolveSession(token string) (*models.Session, *models.User, error) {
sess, err := m.store.GetSession(token)
if err == sql.ErrNoRows {
return nil, nil, err
}
if err != nil {
log.Printf("middleware: session lookup error: %v", err)
return nil, nil, err
}
// Ensure we compare in UTC and handle potential timezone interpretation issues
// from the database driver by forcing both to UTC.
now := time.Now().UTC()
expiresAt := sess.ExpiresAt.UTC()
if now.After(expiresAt) {
log.Printf("middleware: session %s expired (now=%v, expires=%v)", sess.ID, now, expiresAt)
// Session has expired — clean it up asynchronously.
go func() {
if delErr := m.store.DeleteSession(sess.ID); delErr != nil {
log.Printf("middleware: failed to delete expired session %s: %v", sess.ID, delErr)
}
}()
return nil, nil, fmt.Errorf("session expired")
}
user, err := m.store.GetUserByID(sess.UserID)
if err != nil {
log.Printf("middleware: user lookup for session %s failed: %v", sess.ID, err)
return nil, nil, err
}
return sess, user, nil
}
// handleUnauthorized returns a 401 for API/JSON requests and a redirect to
// the root for browser requests.
func (m *SessionMiddleware) handleUnauthorized(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "application/json" ||
r.Header.Get("Content-Type") == "application/json" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// clearSessionCookie sends a Set-Cookie header that immediately expires
// the session cookie in the browser.
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// SessionFromContext retrieves the Session from a request context.
// Returns nil if no session has been loaded.
func SessionFromContext(ctx context.Context) *models.Session {
sess, _ := ctx.Value(models.SessionContextKey{}).(*models.Session)
return sess
}
// UserFromContext retrieves the User from a request context.
// Returns nil if no user has been loaded.
func UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(models.UserContextKey{}).(*models.User)
return user
}