authentication.rst (11758B)
1 Authentication 2 ============== 3 4 The WebSocket protocol was designed for creating web applications that need 5 bidirectional communication between clients running in browsers and servers. 6 7 In most practical use cases, WebSocket servers need to authenticate clients in 8 order to route communications appropriately and securely. 9 10 :rfc:`6455` stays elusive when it comes to authentication: 11 12 This protocol doesn't prescribe any particular way that servers can 13 authenticate clients during the WebSocket handshake. The WebSocket 14 server can use any client authentication mechanism available to a 15 generic HTTP server, such as cookies, HTTP authentication, or TLS 16 authentication. 17 18 None of these three mechanisms works well in practice. Using cookies is 19 cumbersome, HTTP authentication isn't supported by all mainstream browsers, 20 and TLS authentication in a browser is an esoteric user experience. 21 22 Fortunately, there are better alternatives! Let's discuss them. 23 24 System design 25 ------------- 26 27 Consider a setup where the WebSocket server is separate from the HTTP server. 28 29 Most servers built with websockets to complement a web application adopt this 30 design because websockets doesn't aim at supporting HTTP. 31 32 The following diagram illustrates the authentication flow. 33 34 .. image:: authentication.svg 35 36 Assuming the current user is authenticated with the HTTP server (1), the 37 application needs to obtain credentials from the HTTP server (2) in order to 38 send them to the WebSocket server (3), who can check them against the database 39 of user accounts (4). 40 41 Usernames and passwords aren't a good choice of credentials here, if only 42 because passwords aren't available in clear text in the database. 43 44 Tokens linked to user accounts are a better choice. These tokens must be 45 impossible to forge by an attacker. For additional security, they can be 46 short-lived or even single-use. 47 48 Sending credentials 49 ------------------- 50 51 Assume the web application obtained authentication credentials, likely a 52 token, from the HTTP server. There's four options for passing them to the 53 WebSocket server. 54 55 1. **Sending credentials as the first message in the WebSocket connection.** 56 57 This is fully reliable and the most secure mechanism in this discussion. It 58 has two minor downsides: 59 60 * Authentication is performed at the application layer. Ideally, it would 61 be managed at the protocol layer. 62 63 * Authentication is performed after the WebSocket handshake, making it 64 impossible to monitor authentication failures with HTTP response codes. 65 66 2. **Adding credentials to the WebSocket URI in a query parameter.** 67 68 This is also fully reliable but less secure. Indeed, it has a major 69 downside: 70 71 * URIs end up in logs, which leaks credentials. Even if that risk could be 72 lowered with single-use tokens, it is usually considered unacceptable. 73 74 Authentication is still performed at the application layer but it can 75 happen before the WebSocket handshake, which improves separation of 76 concerns and enables responding to authentication failures with HTTP 401. 77 78 3. **Setting a cookie on the domain of the WebSocket URI.** 79 80 Cookies are undoubtedly the most common and hardened mechanism for sending 81 credentials from a web application to a server. In an HTTP application, 82 credentials would be a session identifier or a serialized, signed session. 83 84 Unfortunately, when the WebSocket server runs on a different domain from 85 the web application, this idea bumps into the `Same-Origin Policy`_. For 86 security reasons, setting a cookie on a different origin is impossible. 87 88 The proper workaround consists in: 89 90 * creating a hidden iframe_ served from the domain of the WebSocket server 91 * sending the token to the iframe with postMessage_ 92 * setting the cookie in the iframe 93 94 before opening the WebSocket connection. 95 96 Sharing a parent domain (e.g. example.com) between the HTTP server (e.g. 97 www.example.com) and the WebSocket server (e.g. ws.example.com) and setting 98 the cookie on that parent domain would work too. 99 100 However, the cookie would be shared with all subdomains of the parent 101 domain. For a cookie containing credentials, this is unacceptable. 102 103 .. _Same-Origin Policy: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy 104 .. _iframe: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe 105 .. _postMessage: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage 106 107 4. **Adding credentials to the WebSocket URI in user information.** 108 109 Letting the browser perform HTTP Basic Auth is a nice idea in theory. 110 111 In practice it doesn't work due to poor support in browsers. 112 113 As of May 2021: 114 115 * Chrome 90 behaves as expected. 116 117 * Firefox 88 caches credentials too aggressively. 118 119 When connecting again to the same server with new credentials, it reuses 120 the old credentials, which may be expired, resulting in an HTTP 401. Then 121 the next connection succeeds. Perhaps errors clear the cache. 122 123 When tokens are short-lived or single-use, this bug produces an 124 interesting effect: every other WebSocket connection fails. 125 126 * Safari 14 ignores credentials entirely. 127 128 Two other options are off the table: 129 130 1. **Setting a custom HTTP header** 131 132 This would be the most elegant mechanism, solving all issues with the options 133 discussed above. 134 135 Unfortunately, it doesn't work because the `WebSocket API`_ doesn't support 136 `setting custom headers`_. 137 138 .. _WebSocket API: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API 139 .. _setting custom headers: https://github.com/whatwg/html/issues/3062 140 141 2. **Authenticating with a TLS certificate** 142 143 While this is suggested by the RFC, installing a TLS certificate is too far 144 from the mainstream experience of browser users. This could make sense in 145 high security contexts. I hope developers working on such projects don't 146 take security advice from the documentation of random open source projects. 147 148 Let's experiment! 149 ----------------- 150 151 The `experiments/authentication`_ directory demonstrates these techniques. 152 153 Run the experiment in an environment where websockets is installed: 154 155 .. _experiments/authentication: https://github.com/python-websockets/websockets/tree/main/experiments/authentication 156 157 .. code-block:: console 158 159 $ python experiments/authentication/app.py 160 Running on http://localhost:8000/ 161 162 When you browse to the HTTP server at http://localhost:8000/ and you submit a 163 username, the server creates a token and returns a testing web page. 164 165 This page opens WebSocket connections to four WebSocket servers running on 166 four different origins. It attempts to authenticate with the token in four 167 different ways. 168 169 First message 170 ............. 171 172 As soon as the connection is open, the client sends a message containing the 173 token: 174 175 .. code-block:: javascript 176 177 const websocket = new WebSocket("ws://.../"); 178 websocket.onopen = () => websocket.send(token); 179 180 // ... 181 182 At the beginning of the connection handler, the server receives this message 183 and authenticates the user. If authentication fails, the server closes the 184 connection: 185 186 .. code-block:: python 187 188 async def first_message_handler(websocket): 189 token = await websocket.recv() 190 user = get_user(token) 191 if user is None: 192 await websocket.close(CloseCode.INTERNAL_ERROR, "authentication failed") 193 return 194 195 ... 196 197 Query parameter 198 ............... 199 200 The client adds the token to the WebSocket URI in a query parameter before 201 opening the connection: 202 203 .. code-block:: javascript 204 205 const uri = `ws://.../?token=${token}`; 206 const websocket = new WebSocket(uri); 207 208 // ... 209 210 The server intercepts the HTTP request, extracts the token and authenticates 211 the user. If authentication fails, it returns an HTTP 401: 212 213 .. code-block:: python 214 215 class QueryParamProtocol(websockets.WebSocketServerProtocol): 216 async def process_request(self, path, headers): 217 token = get_query_parameter(path, "token") 218 if token is None: 219 return http.HTTPStatus.UNAUTHORIZED, [], b"Missing token\n" 220 221 user = get_user(token) 222 if user is None: 223 return http.HTTPStatus.UNAUTHORIZED, [], b"Invalid token\n" 224 225 self.user = user 226 227 async def query_param_handler(websocket): 228 user = websocket.user 229 230 ... 231 232 Cookie 233 ...... 234 235 The client sets a cookie containing the token before opening the connection. 236 237 The cookie must be set by an iframe loaded from the same origin as the 238 WebSocket server. This requires passing the token to this iframe. 239 240 .. code-block:: javascript 241 242 // in main window 243 iframe.contentWindow.postMessage(token, "http://..."); 244 245 // in iframe 246 document.cookie = `token=${data}; SameSite=Strict`; 247 248 // in main window 249 const websocket = new WebSocket("ws://.../"); 250 251 // ... 252 253 This sequence must be synchronized between the main window and the iframe. 254 This involves several events. Look at the full implementation for details. 255 256 The server intercepts the HTTP request, extracts the token and authenticates 257 the user. If authentication fails, it returns an HTTP 401: 258 259 .. code-block:: python 260 261 class CookieProtocol(websockets.WebSocketServerProtocol): 262 async def process_request(self, path, headers): 263 # Serve iframe on non-WebSocket requests 264 ... 265 266 token = get_cookie(headers.get("Cookie", ""), "token") 267 if token is None: 268 return http.HTTPStatus.UNAUTHORIZED, [], b"Missing token\n" 269 270 user = get_user(token) 271 if user is None: 272 return http.HTTPStatus.UNAUTHORIZED, [], b"Invalid token\n" 273 274 self.user = user 275 276 async def cookie_handler(websocket): 277 user = websocket.user 278 279 ... 280 281 User information 282 ................ 283 284 The client adds the token to the WebSocket URI in user information before 285 opening the connection: 286 287 .. code-block:: javascript 288 289 const uri = `ws://token:${token}@.../`; 290 const websocket = new WebSocket(uri); 291 292 // ... 293 294 Since HTTP Basic Auth is designed to accept a username and a password rather 295 than a token, we send ``token`` as username and the token as password. 296 297 The server intercepts the HTTP request, extracts the token and authenticates 298 the user. If authentication fails, it returns an HTTP 401: 299 300 .. code-block:: python 301 302 class UserInfoProtocol(websockets.BasicAuthWebSocketServerProtocol): 303 async def check_credentials(self, username, password): 304 if username != "token": 305 return False 306 307 user = get_user(password) 308 if user is None: 309 return False 310 311 self.user = user 312 return True 313 314 async def user_info_handler(websocket): 315 user = websocket.user 316 317 ... 318 319 Machine-to-machine authentication 320 --------------------------------- 321 322 When the WebSocket client is a standalone program rather than a script running 323 in a browser, there are far fewer constraints. HTTP Authentication is the best 324 solution in this scenario. 325 326 To authenticate a websockets client with HTTP Basic Authentication 327 (:rfc:`7617`), include the credentials in the URI: 328 329 .. code-block:: python 330 331 async with websockets.connect( 332 f"wss://{username}:{password}@example.com", 333 ) as websocket: 334 ... 335 336 (You must :func:`~urllib.parse.quote` ``username`` and ``password`` if they 337 contain unsafe characters.) 338 339 To authenticate a websockets client with HTTP Bearer Authentication 340 (:rfc:`6750`), add a suitable ``Authorization`` header: 341 342 .. code-block:: python 343 344 async with websockets.connect( 345 "wss://example.com", 346 extra_headers={"Authorization": f"Bearer {token}"} 347 ) as websocket: 348 ...