|
6 | 6 | [com.brunobonacci.mulog :as mulog]
|
7 | 7 | [parts.auth :as auth]
|
8 | 8 | [reitit.ring.middleware.exception :as exception]
|
9 |
| - [ring.middleware.content-type :refer [wrap-content-type]] |
10 |
| - [ring.middleware.defaults :refer [site-defaults wrap-defaults]] |
| 9 | + [ring.middleware.anti-forgery :refer [wrap-anti-forgery]] |
| 10 | + [ring.middleware.defaults :refer [api-defaults wrap-defaults site-defaults]] |
11 | 11 | [ring.middleware.resource :refer [wrap-resource]]
|
12 |
| - [ring.middleware.session :refer [wrap-session]] |
13 |
| - [ring.middleware.session.cookie :refer [cookie-store]] |
14 | 12 | [ring.util.response :as response])
|
15 | 13 | (:import
|
16 | 14 | (org.sqlite SQLiteException)))
|
17 | 15 |
|
18 |
| -(defn exception-handler |
19 |
| - "Generic exceptions handler" |
| 16 | +(defn- exception-handler |
| 17 | + "Generic exceptions handler used by the `exception` middleware. |
| 18 | +
|
| 19 | + Sets the response status to the provided `status`, and sets the response |
| 20 | + message to the error message retrieved from the exception, or, failing that, |
| 21 | + to the `message` provided." |
20 | 22 | [message status]
|
21 | 23 | (fn [^Exception e _request]
|
22 | 24 | (let [error-message (.getMessage e)]
|
23 | 25 | {:status status
|
24 | 26 | :body {:error (or error-message message)}})))
|
25 | 27 |
|
26 | 28 | (def sqlite-errors
|
| 29 | + "A map containing substrings of a SQLiteException error message, and the |
| 30 | + corresponding user friendly error message." |
27 | 31 | {"UNIQUE constraint failed" "A resource with this unique identifier already exists"
|
28 | 32 | "CHECK constraint failed" "The provided data does not meet the required constraints"
|
29 | 33 | "NOT NULL constraint failed" "A required field was missing"
|
30 | 34 | "FOREIGN KEY constraint failed" "The referenced resource does not exist"})
|
31 | 35 |
|
32 | 36 | (defn sqlite-constraint-violation-handler
|
33 |
| - "Handler for SQLite-specific exceptions" |
| 37 | + "Handler for SQLite-specific exceptions. |
| 38 | +
|
| 39 | + If the error message includes a string that is a key in `sqlite-errors`, the |
| 40 | + error message will be the corresponding value; otherwise a generic message." |
34 | 41 | [^SQLiteException e _request]
|
35 | 42 | (let [error-message (.getMessage e)]
|
36 | 43 | (mulog/log ::sqlite-exception :error error-message)
|
|
41 | 48 | "A database constraint was violated")}}))
|
42 | 49 |
|
43 | 50 | (def exception
|
44 |
| - "Middleware handling exceptions" |
| 51 | + "Middleware handling exceptions. Combines the default exception handlers from |
| 52 | + Reitit with cutom handlers. New custom handlers should be added to this |
| 53 | + function." |
45 | 54 | (exception/create-exception-middleware
|
46 | 55 | (merge
|
47 | 56 | exception/default-handlers
|
|
50 | 59 |
|
51 | 60 | :not-found (exception-handler "Resource not found" 404)
|
52 | 61 |
|
53 |
| - ;; SQLite exceptions |
| 62 | + ;; SQLite exceptions |
54 | 63 | SQLiteException sqlite-constraint-violation-handler
|
55 | 64 |
|
56 |
| - ;; Default |
| 65 | + ;; Default |
57 | 66 | ::exception/default
|
58 | 67 | (fn [^Exception e _request]
|
59 | 68 | (mulog/log ::unhandled-exception :error (.getMessage e))
|
60 | 69 | {:status 500
|
61 | 70 | :body {:error "Internal server error"}})})))
|
62 | 71 |
|
63 | 72 | (defn logging
|
64 |
| - "Middleware logging each incoming request" |
| 73 | + "Middleware logging each incoming request with minimal information. |
| 74 | +
|
| 75 | + This middleware is called before Muuntaja converts the input into clojure |
| 76 | + objects, so to `:body` value is a `java.io.InputStream`, which is not easily |
| 77 | + inspectable. Later in the lifecycle, Muuntaja will insert a parsed body under |
| 78 | + the `:parsed-params` key." |
65 | 79 | [handler]
|
66 | 80 | (fn [request]
|
67 | 81 | (let [user-id (get-in request [:identity :sub])
|
| 82 | + request-info {:uri (:uri request) |
| 83 | + :request-method (:request-method request) |
| 84 | + :query-params (:query-params request) |
| 85 | + :remote-addr (:remote-addr request) |
| 86 | + :user-agent (get-in request [:headers "user-agent"])} |
68 | 87 | authenticated? (boolean user-id)]
|
69 |
| - (mulog/log ::request :request request, :authenticated? authenticated? :user-id user-id) |
| 88 | + (mulog/log ::request :info request-info :authenticated? authenticated? :user-id user-id) |
70 | 89 | (handler request))))
|
71 | 90 |
|
72 | 91 | (defn wrap-jwt-authentication
|
73 |
| - "Middleware adding JWT authentication to a route" |
| 92 | + "Middleware adding JWT authentication to a route. A route with this middleware |
| 93 | + applied will have an authentication status which can be validated against." |
74 | 94 | [handler]
|
75 | 95 | (-> handler
|
76 | 96 | (wrap-authentication auth/backend)
|
77 | 97 | (wrap-authorization auth/backend)))
|
78 | 98 |
|
79 | 99 | (defn jwt-auth
|
80 |
| - "Middleware ensuring a route is only accessible to authenticated users" |
| 100 | + "Middleware ensuring a route is only accessible to authenticated users. " |
81 | 101 | [handler]
|
82 | 102 | (fn [request]
|
83 | 103 | (if (authenticated? request)
|
84 | 104 | (handler request)
|
85 | 105 | (-> (response/response {:error "Unauthorized"})
|
86 | 106 | (response/status 401)))))
|
87 | 107 |
|
88 |
| -(defn wrap-default-middlewares |
89 |
| - "Wrap in the middlewares defined by ring-defaults" |
| 108 | +(defn wrap-html-defaults |
| 109 | + "Middleware that applies a customized set of Ring defaults for HTML routes. |
| 110 | +
|
| 111 | + This middleware configures a subset of Ring's `site-defaults` that's |
| 112 | + appropriate for server-rendered HTML pages. |
| 113 | +
|
| 114 | + Applied configurations: |
| 115 | + - Parameters: Parses standard, nested, and keyword parameters |
| 116 | + - Static resources: Enabled |
| 117 | + - Security headers: |
| 118 | + - X-Frame-Options |
| 119 | + - X-Content-Type-Options |
| 120 | + - X-XSS-Protection |
| 121 | + - X-Permitted-Cross-Domain-Policies |
| 122 | + - X-Download-Options |
| 123 | + - Cookie response attributes |
| 124 | +
|
| 125 | + Explicitly disabled: |
| 126 | + - Session handling: Disabled completely as we use stateless JWT auth |
| 127 | + - Global anti-forgery: Disabled here but applied selectively to form-handling |
| 128 | + routes, see `anti-forgery` |
| 129 | +
|
| 130 | + Usage: Apply this middleware to routes that serve HTML content, and separately |
| 131 | + apply `anti-forgery` middleware only to routes that process form submissions." |
| 132 | + [handler] |
| 133 | + (wrap-defaults |
| 134 | + handler |
| 135 | + (-> site-defaults |
| 136 | + (assoc :session false) |
| 137 | + (assoc :security |
| 138 | + (-> (:security site-defaults) |
| 139 | + (assoc :anti-forgery false)))))) |
| 140 | + |
| 141 | +;; TODO: Investigate whether Content Security Policy is needed: |
| 142 | +;; - https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP |
| 143 | +;; - https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html |
| 144 | +(defn wrap-api-defaults |
| 145 | + "Apply default middleware for API routes. |
| 146 | +
|
| 147 | + This uses standard `api-defaults` (not secure-api-defaults) as the base |
| 148 | + configuration, making it suitable for applications behind a reverse proxy that |
| 149 | + handles SSL termination. |
| 150 | +
|
| 151 | + Security enhancements: |
| 152 | + - Sets X-Frame-Options to SAMEORIGIN to prevent clickjacking attacks |
| 153 | + - Sets X-Content-Type-Options to nosniff to prevent MIME type sniffing |
| 154 | + - Enables X-XSS-Protection with mode=block to activate browser XSS filters |
| 155 | +
|
| 156 | + Notable omissions: |
| 157 | + - No HTTPS enforcement (handled by reverse proxy) |
| 158 | + - No Strict-Transport-Security header (better set at proxy level) |
| 159 | + - No Content-Security-Policy" |
| 160 | + [handler] |
| 161 | + (wrap-defaults |
| 162 | + handler |
| 163 | + (-> api-defaults |
| 164 | + (assoc-in [:security :frame-options] :sameorigin) |
| 165 | + (assoc-in [:security :content-type-options] :nosniff) |
| 166 | + (assoc-in [:security :xss-protection] {:mode :block})))) |
| 167 | + |
| 168 | +(defn wrap-core-middlewares |
| 169 | + "Apply essential Ring middleware for the entire application. Currently, this |
| 170 | + is only `wrap-resources`, which allows to download static files from |
| 171 | + resources/public." |
90 | 172 | [handler]
|
91 | 173 | (-> handler
|
92 |
| - (wrap-defaults (-> site-defaults |
93 |
| - (assoc-in [:session :store] (cookie-store)))) |
94 |
| - (wrap-resource "public") |
95 |
| - (wrap-session) |
96 |
| - (wrap-content-type))) |
97 |
| - |
98 |
| -;; FIXME: This feels like it shouldn't need a custom middleware, right? Is there |
99 |
| -;; a middleware supplied by either httpkit or hiccup2 already? |
| 174 | + (wrap-resource "public"))) |
| 175 | + |
100 | 176 | (defn wrap-html-response
|
101 |
| - "Set content type to text/html and convert response to string" |
| 177 | + "Middleware for properly formatting HTML responses. |
| 178 | +
|
| 179 | + This middleware performs two essential transformations for HTML routes: |
| 180 | +
|
| 181 | + 1. It ensures the response body is a string by calling `str` on it. This |
| 182 | + handles various body types like Hiccup structures or other Clojure data that |
| 183 | + should be rendered as HTML. |
| 184 | +
|
| 185 | + 2. It sets the Content-Type header to 'text/html; charset=utf-8' if no |
| 186 | + Content-Type is already specified, ensuring browsers properly interpret the |
| 187 | + response. |
| 188 | +
|
| 189 | + This middleware only processes responses that: |
| 190 | + - Are proper Ring response maps |
| 191 | + - Don't already have a Content-Type header set |
| 192 | +
|
| 193 | + Usage: Apply this middleware to routes that return HTML content. It's an |
| 194 | + alternative to using Muuntaja for HTML formatting, which is more appropriate |
| 195 | + for data formats rather than presentation formats." |
102 | 196 | [handler]
|
103 | 197 | (fn [request]
|
104 | 198 | (let [response (handler request)]
|
105 | 199 | (if (and (map? response)
|
106 | 200 | (not (get-in response [:headers "Content-Type"])))
|
107 | 201 | (-> response
|
108 | 202 | (update :body str)
|
109 |
| - (assoc-in [:headers "Content-Type"] "text/html")) |
| 203 | + (assoc-in [:headers "Content-Type"] "text/html; charset=utf-8")) |
110 | 204 | response))))
|
| 205 | + |
| 206 | +(def anti-forgery |
| 207 | + "Anti-forgery middleware for HTML forms, see docs on `wrap-anti-forgery`" |
| 208 | + wrap-anti-forgery) |
| 209 | + |
| 210 | +;; File upload handling is commented out for now |
| 211 | +;; To enable file uploads, uncomment the following: |
| 212 | +;; (defn wrap-multipart-params |
| 213 | +;; "Handle multipart form data including file uploads" |
| 214 | +;; [handler] |
| 215 | +;; (-> handler |
| 216 | +;; (multipart-params/wrap-multipart-params))) |
0 commit comments