newdrive/internal/transport/httpapi/server.go
Elijah 715423ab8e
Some checks failed
CI / Backend (push) Failing after 1s
CI / Frontend (push) Successful in 13s
CI / Contracts and repository policy (push) Failing after 3s
CI / Container (push) Has been skipped
Implement owner setup and browser authentication sessions
2026-07-16 19:45:40 -07:00

313 lines
11 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"strings"
"time"
"drive.local/drivev2/internal/application"
"drive.local/drivev2/internal/config"
"drive.local/drivev2/internal/domain"
)
var ErrServerClosed = http.ErrServerClosed
const (
sessionCookieName = "__Host-drive_session"
csrfCookieName = "__Host-drive_csrf"
csrfHeaderName = "X-CSRF-Token"
maximumJSONBody = 1 << 20
)
type Server struct {
httpServer *http.Server
}
type SystemService interface {
Readiness(context.Context) error
SetupStatus(context.Context) (application.SetupStatus, error)
}
type AuthenticationService interface {
SetupOwner(context.Context, application.SetupOwnerInput) (application.SetupOwnerResult, error)
Login(context.Context, application.LoginInput) (application.AuthenticatedSession, error)
CurrentSession(context.Context, string) (application.BrowserSession, error)
Logout(context.Context, string, string, string) error
}
func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, authentication AuthenticationService, frontend http.Handler) *Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", jsonResponse(http.StatusOK, map[string]string{"status": "live"}))
mux.HandleFunc("GET /health/ready", readinessHandler(logger, system))
mux.HandleFunc("GET /api/v1/setup/status", setupStatusHandler(logger, system))
mux.HandleFunc("POST /api/v1/setup", ownerSetupHandler(logger, authentication))
mux.HandleFunc("POST /api/v1/sessions", loginHandler(logger, authentication))
mux.HandleFunc("GET /api/v1/sessions/current", currentSessionHandler(logger, authentication))
mux.HandleFunc("DELETE /api/v1/sessions/current", logoutHandler(logger, authentication))
mux.Handle("/", frontend)
return &Server{httpServer: &http.Server{
Addr: cfg.HTTPAddress,
Handler: securityHeaders(requestLogger(logger, mux)),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,
}}
}
type ownerSetupRequest struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type ownerResponse struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
RootNodeID string `json:"rootNodeId"`
}
type setupResponse struct {
Owner ownerResponse `json:"owner"`
RecoveryCodes []string `json:"recoveryCodes"`
Session application.BrowserSession `json:"session"`
}
func ownerSetupHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var request ownerSetupRequest
if err := decodeJSON(w, r, &request); err != nil {
problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON")
return
}
result, err := authentication.SetupOwner(r.Context(), application.SetupOwnerInput{
Username: request.Username, DisplayName: request.DisplayName, Password: request.Password,
RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(),
})
if err != nil {
switch {
case errors.Is(err, application.ErrSetupAlreadyComplete):
problemResponse(w, http.StatusConflict, "setup_already_complete", "Owner setup has already been completed")
case errors.Is(err, domain.ErrInvalidUsername), errors.Is(err, domain.ErrInvalidDisplayName), errors.Is(err, domain.ErrInvalidPassword):
problemResponse(w, http.StatusUnprocessableEntity, "validation_failed", "Username, display name, or password is invalid")
default:
logger.Error("owner setup failed", "error", err)
problemResponse(w, http.StatusInternalServerError, "internal_error", "Owner setup could not be completed")
}
return
}
setAuthenticationCookies(w, result.Authentication)
writeJSON(w, http.StatusCreated, setupResponse{
Owner: ownerResponse{ID: result.OwnerID, Username: result.Username, DisplayName: result.DisplayName, RootNodeID: result.RootNodeID},
RecoveryCodes: result.RecoveryCodes,
Session: result.Authentication.Session,
})
}
}
func loginHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var request loginRequest
if err := decodeJSON(w, r, &request); err != nil {
problemResponse(w, http.StatusBadRequest, "invalid_request", "The request body is not valid JSON")
return
}
authenticated, err := authentication.Login(r.Context(), application.LoginInput{
Username: request.Username, Password: request.Password, RemoteAddress: remoteAddress(r), UserAgent: r.UserAgent(),
})
if err != nil {
if errors.Is(err, application.ErrInvalidCredentials) {
problemResponse(w, http.StatusUnauthorized, "invalid_credentials", "The username or password is invalid")
return
}
logger.Error("login failed", "error", err)
problemResponse(w, http.StatusInternalServerError, "internal_error", "Login could not be completed")
return
}
setAuthenticationCookies(w, authenticated)
writeJSON(w, http.StatusCreated, authenticated.Session)
}
}
func currentSessionHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := authentication.CurrentSession(r.Context(), cookieValue(r, sessionCookieName))
if err != nil {
if errors.Is(err, application.ErrSessionNotFound) {
clearAuthenticationCookies(w)
problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required")
return
}
logger.Error("current session failed", "error", err)
problemResponse(w, http.StatusInternalServerError, "internal_error", "The current session could not be read")
return
}
writeJSON(w, http.StatusOK, session)
}
}
func logoutHandler(logger *slog.Logger, authentication AuthenticationService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := authentication.Logout(
r.Context(),
cookieValue(r, sessionCookieName),
cookieValue(r, csrfCookieName),
r.Header.Get(csrfHeaderName),
)
if err != nil {
switch {
case errors.Is(err, application.ErrSessionNotFound):
clearAuthenticationCookies(w)
problemResponse(w, http.StatusUnauthorized, "authentication_required", "A valid browser session is required")
case errors.Is(err, application.ErrCSRFValidation):
problemResponse(w, http.StatusForbidden, "csrf_validation_failed", "The CSRF token is missing or invalid")
default:
logger.Error("logout failed", "error", err)
problemResponse(w, http.StatusInternalServerError, "internal_error", "The session could not be revoked")
}
return
}
clearAuthenticationCookies(w)
w.WriteHeader(http.StatusNoContent)
}
}
func readinessHandler(logger *slog.Logger, system SystemService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := system.Readiness(r.Context()); err != nil {
logger.Error("readiness check failed", "error", err)
problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
}
func setupStatusHandler(logger *slog.Logger, system SystemService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
status, err := system.SetupStatus(r.Context())
if err != nil {
logger.Error("setup status failed", "error", err)
problemResponse(w, http.StatusServiceUnavailable, "dependency_unavailable", "A required dependency is unavailable")
return
}
writeJSON(w, http.StatusOK, status)
}
}
func (s *Server) ListenAndServe() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
func jsonResponse(status int, payload any) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, status, payload) }
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
slog.Error("encode response", "error", err)
}
}
func problemResponse(w http.ResponseWriter, status int, code, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{
"type": "about:blank",
"title": http.StatusText(status),
"status": status,
"detail": detail,
"code": code,
}); err != nil {
slog.Error("encode problem response", "error", err)
}
}
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
r.Body = http.MaxBytesReader(w, r.Body, maximumJSONBody)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one JSON value")
}
return nil
}
func setAuthenticationCookies(w http.ResponseWriter, authenticated application.AuthenticatedSession) {
maxAge := int(time.Until(authenticated.Session.ExpiresAt).Seconds())
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: authenticated.SessionToken, Path: "/", MaxAge: maxAge,
Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode,
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName, Value: authenticated.CSRFToken, Path: "/", MaxAge: maxAge,
Expires: authenticated.Session.ExpiresAt, Secure: true, HttpOnly: false, SameSite: http.SameSiteStrictMode,
})
}
func clearAuthenticationCookies(w http.ResponseWriter) {
expires := time.Unix(1, 0).UTC()
for _, name := range []string{sessionCookieName, csrfCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: "/", MaxAge: -1, Expires: expires,
Secure: true, HttpOnly: name == sessionCookieName, SameSite: http.SameSiteStrictMode,
})
}
}
func cookieValue(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err != nil {
return ""
}
return cookie.Value
}
func remoteAddress(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds())
})
}