Merge pull request #104 from mcataford/feat/users-can-log-out
feat: users can log out
This commit is contained in:
commit
eec8da0c53
11 changed files with 279 additions and 13 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
31
backend/rotini/identity/token_management.py
Normal file
31
backend/rotini/identity/token_management.py
Normal 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()
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
|
|
21
frontend/src/components/LogoutView/index.tsx
Normal file
21
frontend/src/components/LogoutView/index.tsx
Normal 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
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
60
frontend/src/queries/auth.test.tsx
Normal file
60
frontend/src/queries/auth.test.tsx
Normal 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"))
|
||||
})
|
||||
})
|
43
frontend/src/queries/auth.ts
Normal file
43
frontend/src/queries/auth.ts
Normal 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,
|
||||
}
|
Reference in a new issue