Merge pull request #78 from mcataford/feat/jwt-cookie-auth
feat: jwt cookie auth
This commit is contained in:
commit
53d43ff070
10 changed files with 90 additions and 27 deletions
|
@ -20,6 +20,7 @@ dev = [
|
||||||
"pylint_django",
|
"pylint_django",
|
||||||
"pytest-django",
|
"pytest-django",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
"freezegun",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
29
backend/rotini/auth/jwt_test.py
Normal file
29
backend/rotini/auth/jwt_test.py
Normal 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)
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in a new issue