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",
"pytest-django",
"pytest",
"freezegun",
]
[tool.setuptools]

View file

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

View file

@ -9,11 +9,18 @@ import jwt
def generate_token_for_user(user_id: int) -> str:
"""
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 = {
"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,
"username": "yolo",
"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:
"""
Middleware that handles using credentials supplied via the authorization
headers on requests to log users in seamlessly.
Middleware that handles using credentials supplied via request cookies
carrying JWTs on requests to log users in seamlessly.
"""
def __init__(self, get_response):
self.get_response = get_response
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:
_, token = authorization_header.split(" ")
decoded_token = auth.jwt.decode_token(token)
decoded_token = auth.jwt.decode_token(jwt_cookie)
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"])
request.user = user
except Exception as e: # pylint: disable=broad-exception-caught
logger.exception(
e, extra={"authorization_provided": authorization_header}
)
return django.http.HttpResponse(status=401)
logger.exception(e, extra={"authorization_provided": jwt_cookie})
return self.get_response(request)

View file

@ -21,14 +21,17 @@ def fixture_jwt_middleware():
return auth.middleware.JwtMiddleware(_noop)
def test_middleware_returns_401_on_invalid_authorization_header(jwt_middleware):
"""If authorization headers are present but cannot be validated, 401."""
mock_request = django.http.HttpRequest()
def test_middleware_does_not_append_user_details_to_request_if_invalid_credentials(
jwt_middleware,
):
"""If authorization headers are present but cannot be validated, no user details."""
mock_request = HttpRequestWithUser()
mock_request.META["HTTP_AUTHORIZATION"] = "Bearer notatoken"
response = jwt_middleware(mock_request)
mock_request.COOKIES["jwt"] = "notatoken"
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(
@ -38,9 +41,8 @@ def test_middleware_adds_user_to_request_in_if_valid_token(
mock_request = HttpRequestWithUser()
test_user = AuthUser.objects.get(username=test_user_credentials["username"])
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

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
response_data = login_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"]

View file

@ -24,6 +24,8 @@ class SessionListView(rest_framework.views.APIView):
If valid credentials are provided, a token is included in the
response that can then be used to make authenticated requests.
The token in included in the response cookies.
POST /auth/login/
{
"username": "testuser",
@ -44,7 +46,13 @@ class SessionListView(rest_framework.views.APIView):
django.contrib.auth.login(request, user)
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)

View file

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

View file

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