tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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        ...