Merge pull request #104 from mcataford/feat/users-can-log-out

feat: users can log out
This commit is contained in:
Marc 2024-01-03 13:49:28 -05:00 committed by GitHub
commit eec8da0c53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 279 additions and 13 deletions

View file

@ -44,6 +44,8 @@ class JwtAuthentication(authentication.BaseAuthentication):
if auth_token.revoked:
raise RevokedTokenException("Revoked tokens cannot be used")
request.session["token_id"] = decoded_token["token_id"]
return user, None
except Exception as e: # pylint: disable=broad-exception-caught

View file

@ -21,6 +21,8 @@ def test_jwt_authentication_accepts_valid_unrevoked_tokens(test_user):
)
request = HttpRequest()
# Middleware would set up the session dict in a normal context.
request.session = {}
request.COOKIES["jwt"] = auth_token
user, _ = JwtAuthentication().authenticate(request)

View file

@ -0,0 +1,31 @@
from identity.models import AuthenticationToken
class UnregisteredTokenException(Exception):
pass
class TokenAlreadyRevokedException(Exception):
pass
def revoke_token_by_id(token_id: str):
"""
Revokes a token given its identifier.
If the token does not exist (i.e. no record for the id), an UnregisteredTokenException
is raised.
If the token is already revoked, an exception is also raised (double-revocation should not happen).
"""
try:
token_record = AuthenticationToken.objects.get(id=token_id)
except AuthenticationToken.DoesNotExist as e:
raise UnregisteredTokenException("Token {token_id} not registered.") from e
if token_record.revoked:
raise TokenAlreadyRevokedException(f"Token {token_id} already revoked.")
token_record.revoked = True
token_record.save()

View file

@ -8,6 +8,7 @@ import rest_framework.status
import identity.jwt
from identity.models import AuthenticationToken
from identity.token_management import revoke_token_by_id
AuthUser = django.contrib.auth.get_user_model()
@ -67,6 +68,24 @@ class SessionListView(rest_framework.views.APIView):
return django.http.HttpResponse(status=401)
def delete(self, request: django.http.HttpRequest) -> django.http.HttpResponse:
"""
Logs out the requesting user.
The token associated with the user's session is revoked
and cannot be reused after this is called.
"""
current_token_id = request.session.get("token_id", None)
if current_token_id is None:
return django.http.HttpResponse(status=400)
revoke_token_by_id(current_token_id)
django.contrib.auth.logout(request)
return django.http.HttpResponse(status=204)
class UserListView(rest_framework.views.APIView):
"""

View file

@ -1,10 +1,11 @@
import identity.jwt
import pytest
import django.urls
import django.contrib.auth
import identity.jwt
from identity.models import AuthenticationToken
AuthUser = django.contrib.auth.get_user_model()
@ -30,6 +31,16 @@ def fixture_login_request(auth_client):
return _login_request
@pytest.fixture(name="logout_request")
def fixture_logout_request(auth_client):
def _logout_request():
return auth_client.delete(
django.urls.reverse("auth-session-list"),
)
return _logout_request
def test_create_new_user_returns_created_resource_on_success(create_user_request):
mock_uname = "user"
mock_pwd = "password"
@ -70,5 +81,21 @@ def test_user_login_returns_valid_token_on_success(create_user_request, login_re
assert "jwt" in login_response.cookies
decoded_token = identity.jwt.decode_token(login_response.cookies["jwt"].value)
assert decoded_token["user_id"] == create_user_data["id"]
def test_user_logout_ends_session(login_request, logout_request, test_user_credentials):
login_response = login_request(
test_user_credentials["username"], test_user_credentials["password"]
)
token = login_response.cookies["jwt"].value
token_id = identity.jwt.decode_token(token)["token_id"]
token_record = AuthenticationToken.objects.get(id=token_id)
assert not token_record.revoked
logout_response = logout_request()
token_record.refresh_from_db()
assert logout_response.status_code == 204
assert token_record.revoked

View file

@ -14,6 +14,7 @@ import { Router, Route } from "./router"
import FileListView from "./components/FileListView"
import RegisterView from "./components/RegisterView"
import LoginView from "./components/LoginView"
import LogoutView from "./components/LogoutView"
const routeLabels = {
ITEM_DETAILS: "item-details",
@ -41,6 +42,9 @@ const App = () => {
<Route path="/login">
<LoginView />
</Route>
<Route path="/logout">
<LogoutView />
</Route>
</Router>
</Box>
</Box>

View file

@ -0,0 +1,21 @@
import React from "react"
import Box from "@mui/material/Box"
import Typography from "@mui/material/Typography"
import { useLogout } from "../../queries/auth"
function LogoutView() {
const { logout } = useLogout()
React.useEffect(() => {
logout()
}, [logout])
return (
<Box sx={{ display: "flex", justifyContent: "center", width: "100%" }}>
<Typography>{"You are now logged out!"}</Typography>
</Box>
)
}
export default LogoutView

View file

@ -1,20 +1,32 @@
import { vi, it, describe, expect } from "vitest"
import { within } from "@testing-library/dom"
import userEvent from "@testing-library/user-event"
import { screen, render, waitFor } from "@testing-library/react"
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
import {
renderWithContexts as render,
getAxiosMockAdapter,
} from "../../tests/helpers"
import * as locationHook from "../../contexts/LocationContext"
import { getAxiosMockAdapter } from "../../tests/helpers"
import NavigationBar from "."
import { type FileData } from "../../types/files"
function renderComponent() {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
)
return {
...render(<NavigationBar />, { wrapper }),
user: userEvent.setup(),
}
}
describe("NavigationBar", () => {
describe("Upload functionality", () => {
it("Renders the upload button", () => {
const { getByText } = render(<NavigationBar />)
getByText("Upload file")
renderComponent()
expect(screen.queryByText("Upload file")).toBeInTheDocument()
})
it("Clicking the upload button and selecting a file POSTs the file", async () => {
@ -27,10 +39,8 @@ describe("NavigationBar", () => {
size: 1,
})
const user = userEvent.setup()
const { getByText, container } = render(<NavigationBar />)
const uploadButton = getByText("Upload file")
const { user, container } = renderComponent()
const uploadButton = screen.getByText("Upload file")
const mockFile = new File(["test"], "test.txt", { type: "text/plain" })
const fileInput = container.querySelector('input[type="file"]')
@ -48,4 +58,40 @@ describe("NavigationBar", () => {
expect(postRequest.url).toMatch(expectedUrlPattern)
})
})
describe("Log out", () => {
it("renders a logout button", () => {
renderComponent()
expect(screen.queryByText("Log out")).toBeInTheDocument()
const button = screen.getByText("Log out")
})
it("sends a logout request and redirects to the login page when logging out", async () => {
const axiosMock = getAxiosMockAdapter()
axiosMock.onDelete("/auth/session/").reply(204)
const mockNavigate = vi.fn()
const mockLocationHook = vi
.spyOn(locationHook, "useLocationContext")
.mockImplementation(() => ({
location: {
path: "",
label: "",
params: {},
pattern: "",
},
navigate: mockNavigate,
}))
const { user } = renderComponent()
const logoutButton = screen.getByText("Log out")
await user.click(logoutButton)
await waitFor(() => expect(axiosMock.history.delete.length).toEqual(1))
expect(mockNavigate).toHaveBeenCalledWith("/login")
})
})
})

View file

@ -8,6 +8,7 @@ import Typography from "@mui/material/Typography"
import UploadIcon from "@mui/icons-material/Upload"
import { useFileMutations } from "../../hooks/files"
import { useLogout } from "../../queries/auth"
function UploadFileButton() {
const fileRef = useRef(null)
@ -45,6 +46,8 @@ function UploadFileButton() {
}
function NavigationBar() {
const { logout } = useLogout()
return (
<AppBar position="sticky" sx={{ display: "flex" }}>
<Toolbar>
@ -52,6 +55,14 @@ function NavigationBar() {
Rotini
</Typography>
<UploadFileButton />
<Button
color="inherit"
onClick={() => {
logout()
}}
>
Log out
</Button>
</Toolbar>
</AppBar>
)

View file

@ -0,0 +1,60 @@
import React from "react"
import { describe, it, expect, vi } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"
import AxiosMockAdapter from "axios-mock-adapter"
import * as locationHook from "../contexts/LocationContext"
import axiosWithDefaults from "../axios"
import { useLogout } from "./auth"
function WithProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
)
}
describe("useLogout", () => {
it("sends a request to the logout api", async () => {
const axios = new AxiosMockAdapter(axiosWithDefaults)
axios.onDelete("/auth/session/").reply(204)
const { result } = renderHook(() => useLogout(), { wrapper: WithProviders })
result.current.logout()
await waitFor(() => expect(axios.history.delete.length).toEqual(1))
const deleteRequest = axios.history.delete[0]
expect(deleteRequest.url).toEqual("/auth/session/")
})
it("navigates to the login page on success", async () => {
const mockNavigate = vi.fn()
const mockLocationHook = vi
.spyOn(locationHook, "useLocationContext")
.mockImplementation(() => ({
location: {
path: "",
label: "",
params: {},
pattern: "",
},
navigate: mockNavigate,
}))
const axios = new AxiosMockAdapter(axiosWithDefaults)
axios.onDelete("/auth/session/").reply(204)
const { result } = renderHook(() => useLogout(), { wrapper: WithProviders })
result.current.logout()
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/login"))
})
})

View file

@ -0,0 +1,43 @@
/*
* Authentication queries
*
* This module contains the set of queries (both fetches and mutations) that
* affect the user's session.
*
*/
import { useMutation } from "@tanstack/react-query"
import { useLocationContext } from "../contexts/LocationContext"
import axiosWithDefaults from "../axios"
/*
* Handles the log-out interaction.
*
* Using `logout` will instruct the application to invalidate
* the current authentication token and will redirect the user
* to the login page.
*/
function useLogout() {
const { navigate } = useLocationContext()
const logoutMutation = useMutation({
mutationFn: async () => {
return axiosWithDefaults.delete("/auth/session/")
},
onSuccess: () => {
navigate("/login")
},
})
const { mutate, isError, isPending, ...rest } = logoutMutation
return {
logout: mutate,
isError,
isPending,
}
}
export { useLogout }
export default {
useLogout,
}