Add credentials property to Request objects (#2357)

This commit is contained in:
Sergey Rybakov 2022-01-06 20:14:52 +03:00 committed by GitHub
parent 4669036f45
commit 101151b419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 170 additions and 86 deletions

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
from urllib.parse import unquote from urllib.parse import unquote
from sanic.exceptions import InvalidHeader from sanic.exceptions import InvalidHeader
@ -394,3 +394,17 @@ def parse_accept(accept: str) -> AcceptContainer:
return AcceptContainer( return AcceptContainer(
sorted(accept_list, key=_sort_accept_value, reverse=True) sorted(accept_list, key=_sort_accept_value, reverse=True)
) )
def parse_credentials(
header: Optional[str],
prefixes: Union[List, Tuple, Set] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Parses any header with the aim to retrieve any credentials from it."""
if not prefixes or not isinstance(prefixes, (list, tuple, set)):
prefixes = ("Basic", "Bearer", "Token")
if header is not None:
for prefix in prefixes:
if prefix in header:
return prefix, header.partition(prefix)[-1].strip()
return None, header

View File

@ -0,0 +1,35 @@
from __future__ import annotations
from base64 import b64decode
from dataclasses import dataclass, field
from typing import Optional
@dataclass()
class Credentials:
auth_type: Optional[str]
token: Optional[str]
_username: Optional[str] = field(default=None)
_password: Optional[str] = field(default=None)
def __post_init__(self):
if self._auth_is_basic:
self._username, self._password = (
b64decode(self.token.encode("utf-8")).decode().split(":")
)
@property
def username(self):
if not self._auth_is_basic:
raise AttributeError("Username is available for Basic Auth only")
return self._username
@property
def password(self):
if not self._auth_is_basic:
raise AttributeError("Password is available for Basic Auth only")
return self._password
@property
def _auth_is_basic(self) -> bool:
return self.auth_type == "Basic"

View File

@ -14,6 +14,8 @@ from typing import (
from sanic_routing.route import Route # type: ignore from sanic_routing.route import Route # type: ignore
from sanic.models.http_types import Credentials
if TYPE_CHECKING: # no cov if TYPE_CHECKING: # no cov
from sanic.server import ConnInfo from sanic.server import ConnInfo
@ -37,6 +39,7 @@ from sanic.headers import (
Options, Options,
parse_accept, parse_accept,
parse_content_header, parse_content_header,
parse_credentials,
parse_forwarded, parse_forwarded,
parse_host, parse_host,
parse_xforwarded, parse_xforwarded,
@ -98,11 +101,13 @@ class Request:
"method", "method",
"parsed_accept", "parsed_accept",
"parsed_args", "parsed_args",
"parsed_not_grouped_args", "parsed_credentials",
"parsed_files", "parsed_files",
"parsed_form", "parsed_form",
"parsed_json",
"parsed_forwarded", "parsed_forwarded",
"parsed_json",
"parsed_not_grouped_args",
"parsed_token",
"raw_url", "raw_url",
"responded", "responded",
"request_middleware_started", "request_middleware_started",
@ -122,6 +127,7 @@ class Request:
app: Sanic, app: Sanic,
head: bytes = b"", head: bytes = b"",
): ):
self.raw_url = url_bytes self.raw_url = url_bytes
# TODO: Content-Encoding detection # TODO: Content-Encoding detection
self._parsed_url = parse_url(url_bytes) self._parsed_url = parse_url(url_bytes)
@ -141,9 +147,11 @@ class Request:
self.ctx = SimpleNamespace() self.ctx = SimpleNamespace()
self.parsed_forwarded: Optional[Options] = None self.parsed_forwarded: Optional[Options] = None
self.parsed_accept: Optional[AcceptContainer] = None self.parsed_accept: Optional[AcceptContainer] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_json = None self.parsed_json = None
self.parsed_form = None self.parsed_form = None
self.parsed_files = None self.parsed_files = None
self.parsed_token: Optional[str] = None
self.parsed_args: DefaultDict[ self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters Tuple[bool, bool, str, str], RequestParameters
] = defaultdict(RequestParameters) ] = defaultdict(RequestParameters)
@ -332,20 +340,41 @@ class Request:
return self.parsed_accept return self.parsed_accept
@property @property
def token(self): def token(self) -> Optional[str]:
"""Attempt to return the auth header token. """Attempt to return the auth header token.
:return: token related to request :return: token related to request
""" """
prefixes = ("Bearer", "Token") if self.parsed_token is None:
auth_header = self.headers.getone("authorization", None) prefixes = ("Bearer", "Token")
_, token = parse_credentials(
self.headers.getone("authorization", None), prefixes
)
self.parsed_token = token
return self.parsed_token
if auth_header is not None: @property
for prefix in prefixes: def credentials(self) -> Optional[Credentials]:
if prefix in auth_header: """Attempt to return the auth header value.
return auth_header.partition(prefix)[-1].strip()
return auth_header Covers NoAuth, Basic Auth, Bearer Token, Api Token authentication
schemas.
:return: A named tuple with token or username and password related
to request
"""
if self.parsed_credentials is None:
try:
prefix, credentials = parse_credentials(
self.headers.getone("authorization", None)
)
if credentials:
self.parsed_credentials = Credentials(
auth_type=prefix, token=credentials
)
except ValueError:
pass
return self.parsed_credentials
@property @property
def form(self): def form(self):

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import base64
import logging import logging
import random import random
import re import re
@ -204,3 +205,7 @@ def sanic_ext(ext_instance): # noqa
yield sanic_ext yield sanic_ext
with suppress(KeyError): with suppress(KeyError):
del sys.modules["sanic_ext"] del sys.modules["sanic_ext"]
def encode_basic_auth_credentials(username, password):
return base64.b64encode(f"{username}:{password}".encode()).decode("ascii")

View File

@ -15,9 +15,10 @@ from sanic_testing.testing import (
) )
from sanic import Blueprint, Sanic from sanic import Blueprint, Sanic
from sanic.exceptions import SanicException, ServerError from sanic.exceptions import ServerError
from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, RequestParameters
from sanic.response import html, json, text from sanic.response import html, json, text
from tests.conftest import encode_basic_auth_credentials
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
@ -362,93 +363,95 @@ async def test_uri_template_asgi(app):
assert request.uri_template == "/foo/<id:int>/bar/<name:[A-z]+>" assert request.uri_template == "/foo/<id:int>/bar/<name:[A-z]+>"
def test_token(app): @pytest.mark.parametrize(
("auth_type", "token"),
[
# uuid4 generated token set in "Authorization" header
(None, "a1d895e0-553a-421a-8e22-5ff8ecb48cbf"),
# uuid4 generated token with API Token authorization
("Token", "a1d895e0-553a-421a-8e22-5ff8ecb48cbf"),
# uuid4 generated token with Bearer Token authorization
("Bearer", "a1d895e0-553a-421a-8e22-5ff8ecb48cbf"),
# no Authorization header
(None, None),
],
)
def test_token(app, auth_type, token):
@app.route("/") @app.route("/")
async def handler(request): async def handler(request):
return text("OK") return text("OK")
# uuid4 generated token. if token:
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf" headers = {
headers = { "content-type": "application/json",
"content-type": "application/json", "Authorization": f"{auth_type} {token}"
"Authorization": f"{token}", if auth_type
} else f"{token}",
}
else:
headers = {"content-type": "application/json"}
request, response = app.test_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert request.token == token assert request.token == token
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf"
headers = {
"content-type": "application/json",
"Authorization": f"Token {token}",
}
request, response = app.test_client.get("/", headers=headers) @pytest.mark.parametrize(
("auth_type", "token", "username", "password"),
assert request.token == token [
# uuid4 generated token set in "Authorization" header
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf" (None, "a1d895e0-553a-421a-8e22-5ff8ecb48cbf", None, None),
headers = { # uuid4 generated token with API Token authorization
"content-type": "application/json", ("Token", "a1d895e0-553a-421a-8e22-5ff8ecb48cbf", None, None),
"Authorization": f"Bearer {token}", # uuid4 generated token with Bearer Token authorization
} ("Bearer", "a1d895e0-553a-421a-8e22-5ff8ecb48cbf", None, None),
# username and password with Basic Auth authorization
request, response = app.test_client.get("/", headers=headers) (
"Basic",
assert request.token == token encode_basic_auth_credentials("some_username", "some_pass"),
"some_username",
# no Authorization headers "some_pass",
headers = {"content-type": "application/json"} ),
# no Authorization header
request, response = app.test_client.get("/", headers=headers) (None, None, None, None),
],
assert request.token is None )
def test_credentials(app, capfd, auth_type, token, username, password):
@pytest.mark.asyncio
async def test_token_asgi(app):
@app.route("/") @app.route("/")
async def handler(request): async def handler(request):
return text("OK") return text("OK")
# uuid4 generated token. if token:
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf" headers = {
headers = { "content-type": "application/json",
"content-type": "application/json", "Authorization": f"{auth_type} {token}"
"Authorization": f"{token}", if auth_type
} else f"{token}",
}
else:
headers = {"content-type": "application/json"}
request, response = await app.asgi_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert request.token == token if auth_type == "Basic":
assert request.credentials.username == username
assert request.credentials.password == password
else:
_, err = capfd.readouterr()
with pytest.raises(AttributeError):
request.credentials.password
assert "Password is available for Basic Auth only" in err
request.credentials.username
assert "Username is available for Basic Auth only" in err
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf" if token:
headers = { assert request.credentials.token == token
"content-type": "application/json", assert request.credentials.auth_type == auth_type
"Authorization": f"Token {token}", else:
} assert request.credentials is None
assert not hasattr(request.credentials, "token")
request, response = await app.asgi_client.get("/", headers=headers) assert not hasattr(request.credentials, "auth_type")
assert not hasattr(request.credentials, "_username")
assert request.token == token assert not hasattr(request.credentials, "_password")
token = "a1d895e0-553a-421a-8e22-5ff8ecb48cbf"
headers = {
"content-type": "application/json",
"Authorization": f"Bearer {token}",
}
request, response = await app.asgi_client.get("/", headers=headers)
assert request.token == token
# no Authorization headers
headers = {"content-type": "application/json"}
request, response = await app.asgi_client.get("/", headers=headers)
assert request.token is None
def test_content_type(app): def test_content_type(app):
@ -1714,7 +1717,6 @@ async def test_request_query_args_custom_parsing_asgi(app):
def test_request_cookies(app): def test_request_cookies(app):
cookies = {"test": "OK"} cookies = {"test": "OK"}
@app.get("/") @app.get("/")
@ -1729,7 +1731,6 @@ def test_request_cookies(app):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_request_cookies_asgi(app): async def test_request_cookies_asgi(app):
cookies = {"test": "OK"} cookies = {"test": "OK"}
@app.get("/") @app.get("/")