Merge pull request #78 from mcataford/feat/jwt-cookie-auth

feat: jwt cookie auth
This commit is contained in:
Marc 2023-12-28 14:14:45 -05:00 committed by GitHub
commit 53d43ff070
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 90 additions and 27 deletions

View file

@ -20,6 +20,7 @@ dev = [
"pylint_django", "pylint_django",
"pytest-django", "pytest-django",
"pytest", "pytest",
"freezegun",
] ]
[tool.setuptools] [tool.setuptools]

View file

@ -33,6 +33,8 @@ djangorestframework==3.14.0
# via # via
# -c requirements.txt # -c requirements.txt
# rotini (pyproject.toml) # rotini (pyproject.toml)
freezegun==1.4.0
# via rotini (pyproject.toml)
h11==0.14.0 h11==0.14.0
# via # via
# -c requirements.txt # -c requirements.txt
@ -88,6 +90,8 @@ pytest==7.4.3
# rotini (pyproject.toml) # rotini (pyproject.toml)
pytest-django==4.7.0 pytest-django==4.7.0
# via rotini (pyproject.toml) # via rotini (pyproject.toml)
python-dateutil==2.8.2
# via freezegun
python-dotenv==1.0.0 python-dotenv==1.0.0
# via # via
# -c requirements.txt # -c requirements.txt
@ -104,6 +108,8 @@ pyyaml==6.0.1
# via # via
# -c requirements.txt # -c requirements.txt
# uvicorn # uvicorn
six==1.16.0
# via python-dateutil
sniffio==1.3.0 sniffio==1.3.0
# via # via
# -c requirements.txt # -c requirements.txt

View file

@ -9,11 +9,18 @@ import jwt
def generate_token_for_user(user_id: int) -> str: def generate_token_for_user(user_id: int) -> str:
""" """
Generates an identity token for a given user. Generates an identity token for a given user.
The token expires in JWT_EXPIRATION seconds (defined in base.settings) and
only contains the user's ID and a token ID that can be used to track the
token once emitted.
""" """
token_data = { token_data = {
"exp": (datetime.datetime.now() + datetime.timedelta(seconds=120)).timestamp(), "exp": (
datetime.datetime.now()
+ datetime.timedelta(seconds=django.conf.settings.JWT_EXPIRATION)
).timestamp(),
"user_id": user_id, "user_id": user_id,
"username": "yolo",
"token_id": str(uuid.uuid4()), "token_id": str(uuid.uuid4()),
} }

View file

@ -0,0 +1,29 @@
import pytest
import freezegun
import jwt
import auth.jwt
@freezegun.freeze_time("2012-01-01")
def test_generates_and_decodes_token_token():
MOCK_USER_ID = 1
token = auth.jwt.generate_token_for_user(MOCK_USER_ID)
assert token is not None
token_data = auth.jwt.decode_token(token)
assert token_data["user_id"] == MOCK_USER_ID
def test_token_decode_fails_if_expired():
MOCK_USER_ID = 1
with freezegun.freeze_time("2012-01-01"):
token = auth.jwt.generate_token_for_user(MOCK_USER_ID)
assert token is not None
with pytest.raises(jwt.ExpiredSignatureError):
auth.jwt.decode_token(token)

View file

@ -12,30 +12,36 @@ AuthUser = django.contrib.auth.get_user_model()
class JwtMiddleware: class JwtMiddleware:
""" """
Middleware that handles using credentials supplied via the authorization Middleware that handles using credentials supplied via request cookies
headers on requests to log users in seamlessly. carrying JWTs on requests to log users in seamlessly.
""" """
def __init__(self, get_response): def __init__(self, get_response):
self.get_response = get_response self.get_response = get_response
def __call__(self, request: django.http.HttpRequest) -> django.http.HttpResponse: def __call__(self, request: django.http.HttpRequest) -> django.http.HttpResponse:
authorization_header = request.META.get("HTTP_AUTHORIZATION") """
If a JWT cookie is attached to the request, its token value is
retrieved, tentatively decoded and authentication details are
added to the request data if available.
if authorization_header is not None: On failure, no details are added.
Views are expected to handle their own verification of
whether the user details are adequate.
"""
jwt_cookie = request.COOKIES.get("jwt")
if jwt_cookie is not None:
try: try:
_, token = authorization_header.split(" ") decoded_token = auth.jwt.decode_token(jwt_cookie)
decoded_token = auth.jwt.decode_token(token)
logger.info("Token: %s\nDecoded token: %s", token, decoded_token) logger.info("Token: %s\nDecoded token: %s", jwt_cookie, decoded_token)
user = AuthUser.objects.get(pk=decoded_token["user_id"]) user = AuthUser.objects.get(pk=decoded_token["user_id"])
request.user = user request.user = user
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # pylint: disable=broad-exception-caught
logger.exception( logger.exception(e, extra={"authorization_provided": jwt_cookie})
e, extra={"authorization_provided": authorization_header}
)
return django.http.HttpResponse(status=401)
return self.get_response(request) return self.get_response(request)

View file

@ -21,14 +21,17 @@ def fixture_jwt_middleware():
return auth.middleware.JwtMiddleware(_noop) return auth.middleware.JwtMiddleware(_noop)
def test_middleware_returns_401_on_invalid_authorization_header(jwt_middleware): def test_middleware_does_not_append_user_details_to_request_if_invalid_credentials(
"""If authorization headers are present but cannot be validated, 401.""" jwt_middleware,
mock_request = django.http.HttpRequest() ):
"""If authorization headers are present but cannot be validated, no user details."""
mock_request = HttpRequestWithUser()
mock_request.META["HTTP_AUTHORIZATION"] = "Bearer notatoken" mock_request.COOKIES["jwt"] = "notatoken"
response = jwt_middleware(mock_request)
assert response.status_code == 401 jwt_middleware(mock_request)
assert not hasattr(mock_request, "user")
def test_middleware_adds_user_to_request_in_if_valid_token( def test_middleware_adds_user_to_request_in_if_valid_token(
@ -38,9 +41,8 @@ def test_middleware_adds_user_to_request_in_if_valid_token(
mock_request = HttpRequestWithUser() mock_request = HttpRequestWithUser()
test_user = AuthUser.objects.get(username=test_user_credentials["username"]) test_user = AuthUser.objects.get(username=test_user_credentials["username"])
token = auth.jwt.generate_token_for_user(test_user.id) token = auth.jwt.generate_token_for_user(test_user.id)
mock_request.META["HTTP_AUTHORIZATION"] = f"Bearer {token}" mock_request.COOKIES["jwt"] = token
response = jwt_middleware(mock_request) jwt_middleware(mock_request)
assert response.status_code != 401
assert mock_request.user == test_user assert mock_request.user == test_user

View file

@ -65,11 +65,10 @@ def test_user_login_returns_valid_token_on_success(create_user_request, login_re
assert login_response.status_code == 201 assert login_response.status_code == 201
response_data = login_response.json()
create_user_data = creation_response.json() create_user_data = creation_response.json()
assert "token" in response_data assert "jwt" in login_response.cookies
decoded_token = auth.jwt.decode_token(response_data["token"]) decoded_token = auth.jwt.decode_token(login_response.cookies["jwt"].value)
assert decoded_token["user_id"] == create_user_data["id"] assert decoded_token["user_id"] == create_user_data["id"]

View file

@ -24,6 +24,8 @@ class SessionListView(rest_framework.views.APIView):
If valid credentials are provided, a token is included in the If valid credentials are provided, a token is included in the
response that can then be used to make authenticated requests. response that can then be used to make authenticated requests.
The token in included in the response cookies.
POST /auth/login/ POST /auth/login/
{ {
"username": "testuser", "username": "testuser",
@ -44,7 +46,13 @@ class SessionListView(rest_framework.views.APIView):
django.contrib.auth.login(request, user) django.contrib.auth.login(request, user)
token = auth.jwt.generate_token_for_user(user_id=user.id) token = auth.jwt.generate_token_for_user(user_id=user.id)
return django.http.JsonResponse({"token": token}, status=201) response = django.http.HttpResponse(status=201)
response.set_cookie(
"jwt", value=token, secure=False, domain="localhost", httponly=False
)
return response
return django.http.HttpResponse(status=401) return django.http.HttpResponse(status=401)

View file

@ -14,6 +14,8 @@ BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
JWT_SIGNING_SECRET = os.environ["JWT_SIGNING_SECRET"] JWT_SIGNING_SECRET = os.environ["JWT_SIGNING_SECRET"]
# JWT time-to-live, in seconds.
JWT_EXPIRATION = 600
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
@ -46,7 +48,7 @@ MIDDLEWARE = [
ROOT_URLCONF = "base.urls" ROOT_URLCONF = "base.urls"
CORS_ALLOWED_ORIGINS = ["http://localhost:1234"] CORS_ALLOWED_ORIGINS = ["http://localhost:1234"]
CSRF_TRUSTED_ORIGINS = ["http://localhost:1234"]
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",

View file

@ -2,6 +2,9 @@ import axios from "axios"
const axiosWithDefaults = axios.create({ const axiosWithDefaults = axios.create({
baseURL: "http://localhost:8000", baseURL: "http://localhost:8000",
withCredentials: true,
xsrfHeaderName: "X-CSRFTOKEN",
xsrfCookieName: "csrftoken",
}) })
export default axiosWithDefaults export default axiosWithDefaults