Implement owner setup and browser authentication sessions
This commit is contained in:
parent
077cf7601a
commit
715423ab8e
21 changed files with 2185 additions and 14 deletions
|
|
@ -3,16 +3,28 @@ 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
|
||||
}
|
||||
|
|
@ -22,21 +34,155 @@ type SystemService interface {
|
|||
SetupStatus(context.Context) (application.SetupStatus, error)
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, logger *slog.Logger, system SystemService, frontend http.Handler) *Server {
|
||||
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: requestLogger(logger, mux),
|
||||
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 {
|
||||
|
|
@ -96,6 +242,68 @@ func problemResponse(w http.ResponseWriter, status int, code, detail string) {
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue