Add credentials property to Request objects (#2357)
This commit is contained in:
parent
4669036f45
commit
101151b419
|
@ -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
|
||||||
|
|
35
sanic/models/http_types.py
Normal file
35
sanic/models/http_types.py
Normal 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"
|
|
@ -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):
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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("/")
|
||||||
|
|
Loading…
Reference in New Issue
Block a user