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",
|
||||
"pytest-django",
|
||||
"pytest",
|
||||
"freezegun",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
|
||||
|
|
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:
|
||||
"""
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
Reference in a new issue