Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions backend/testjam/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from testjam.schemas.user import TokenResponse
from testjam.services.password_reset import (
PasswordResetMisconfigured,
PasswordResetOutcome,
confirm_password_reset,
request_password_reset,
)
Expand Down Expand Up @@ -106,9 +107,27 @@ def password_reset_request(
return Response(status_code=status.HTTP_204_NO_CONTENT)


RESET_ERROR_DETAIL = {
PasswordResetOutcome.UNKNOWN: "Reset token is not recognized",
PasswordResetOutcome.USED: "Reset token has already been used",
PasswordResetOutcome.EXPIRED: "Reset token has expired",
}
RESET_ERROR_CODE = {
PasswordResetOutcome.UNKNOWN: "reset_token_invalid",
PasswordResetOutcome.USED: "reset_token_used",
PasswordResetOutcome.EXPIRED: "reset_token_expired",
}


@router.post("/password-reset/confirm", status_code=status.HTTP_204_NO_CONTENT)
def password_reset_confirm(body: PasswordResetConfirm, db: Session = Depends(get_db)):
succeeded = confirm_password_reset(db, body.token, body.new_password)
if not succeeded:
raise HTTPException(status_code=400, detail="Invalid or expired token")
return Response(status_code=status.HTTP_204_NO_CONTENT)
outcome = confirm_password_reset(db, body.token, body.new_password)
if outcome is PasswordResetOutcome.OK:
return Response(status_code=status.HTTP_204_NO_CONTENT)
raise HTTPException(
status_code=400,
detail={
"code": RESET_ERROR_CODE[outcome],
"message": RESET_ERROR_DETAIL[outcome],
},
)
47 changes: 26 additions & 21 deletions backend/testjam/services/password_reset.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timezone
from enum import Enum

from fastapi import BackgroundTasks
from sqlalchemy.orm import Session
Expand All @@ -13,6 +14,13 @@
from testjam.services.settings import get_settings as get_app_settings


class PasswordResetOutcome(str, Enum):
OK = "ok"
UNKNOWN = "unknown"
USED = "used"
EXPIRED = "expired"


def request_password_reset(
db: Session,
email: str,
Expand All @@ -37,18 +45,28 @@ def issue_reset_email_for_user(
_dispatch_reset_email(db, user, raw_token, background)


def confirm_password_reset(db: Session, raw_token: str, new_password: str) -> bool:
token = _load_active_token(db, raw_token)
if token is None:
return False
user = db.get(User, token.user_id)
def confirm_password_reset(
db: Session, raw_token: str, new_password: str,
) -> PasswordResetOutcome:
record = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.token_hash == PasswordResetToken.hash(raw_token))
.first()
)
if record is None:
return PasswordResetOutcome.UNKNOWN
if record.used_at is not None:
return PasswordResetOutcome.USED
if _expires_at_utc(record) <= datetime.now(timezone.utc):
return PasswordResetOutcome.EXPIRED
user = db.get(User, record.user_id)
if user is None or not user.is_active or user.deleted_at is not None:
return False
return PasswordResetOutcome.UNKNOWN
user.hashed_password = hash_password(new_password)
token.used_at = datetime.now(timezone.utc)
record.used_at = datetime.now(timezone.utc)
db.commit()
clear_lockout(db, user)
return True
return PasswordResetOutcome.OK


def _create_reset_token(db: Session, user: User) -> tuple[str, str, datetime]:
Expand All @@ -59,19 +77,6 @@ def _create_reset_token(db: Session, user: User) -> tuple[str, str, datetime]:
return raw, token_hash, expires_at


def _load_active_token(db: Session, raw_token: str) -> PasswordResetToken | None:
record = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.token_hash == PasswordResetToken.hash(raw_token))
.first()
)
if record is None or record.used_at is not None:
return None
if _expires_at_utc(record) <= datetime.now(timezone.utc):
return None
return record


def _expires_at_utc(token: PasswordResetToken) -> datetime:
if token.expires_at.tzinfo is None:
return token.expires_at.replace(tzinfo=timezone.utc)
Expand Down
3 changes: 3 additions & 0 deletions backend/tests/test_password_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ def test_confirm_rejects_unknown_token(client):
response = _confirm_reset(client, "garbage-token-value")

assert response.status_code == 400
assert response.json()["detail"]["code"] == "reset_token_invalid"


def test_confirm_rejects_already_used_token(client, user_factory):
Expand All @@ -157,6 +158,7 @@ def test_confirm_rejects_already_used_token(client, user_factory):

assert first.status_code == 204
assert second.status_code == 400
assert second.json()["detail"]["code"] == "reset_token_used"


def test_confirm_rejects_expired_token(client, user_factory):
Expand All @@ -174,6 +176,7 @@ def test_confirm_rejects_expired_token(client, user_factory):
response = _confirm_reset(client, raw_token)

assert response.status_code == 400
assert response.json()["detail"]["code"] == "reset_token_expired"


def test_confirm_rejects_weak_password(client, user_factory):
Expand Down
1 change: 1 addition & 0 deletions frontend/src/__tests__/AppLayoutMobile.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock("../hooks/useAuth", () => ({
}),
useLogout: () => () => {},
useUpdateMe: () => ({ mutate: () => {}, isPending: false }),
useStorageAuthSync: () => {},
}))

vi.mock("../hooks/useProjects", () => ({
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/__tests__/ResetPasswordPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,21 @@ describe("ResetPasswordPage", () => {

expect(await screen.findByRole("alert")).toHaveTextContent(/invalid or expired token/i)
})

it.each([
["reset_token_invalid", /not recognized/i],
["reset_token_used", /already used/i],
["reset_token_expired", /expired/i],
])("maps backend error code %s to its localized message", async (code, pattern) => {
authApi.confirmPasswordReset.mockRejectedValue({
response: { data: { detail: { code, message: "fallback" } } },
})
renderPage("/reset-password?token=bad")

fireEvent.change(screen.getByLabelText(/new password/i), { target: { value: "fresh-secret-12" } })
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: "fresh-secret-12" } })
fireEvent.click(screen.getByRole("button", { name: /update password/i }))

expect(await screen.findByRole("alert")).toHaveTextContent(pattern)
})
})
69 changes: 69 additions & 0 deletions frontend/src/__tests__/useStorageAuthSync.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"

import { useStorageAuthSync } from "../hooks/useAuth"


function setup() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const clearSpy = vi.spyOn(queryClient, "clear")
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
renderHook(() => useStorageAuthSync(), { wrapper })
return { clearSpy }
}


function fireStorageEvent({ key, newValue }) {
const event = new StorageEvent("storage", {
key,
newValue,
oldValue: "previous",
storageArea: window.localStorage,
})
window.dispatchEvent(event)
}


describe("useStorageAuthSync", () => {
const originalHref = window.location.href

beforeEach(() => {
delete window.location
window.location = { href: originalHref }
})

afterEach(() => {
delete window.location
window.location = { href: originalHref }
})

it("clears the query cache and redirects to /login when the token is removed in another tab", () => {
const { clearSpy } = setup()

fireStorageEvent({ key: "token", newValue: null })

expect(clearSpy).toHaveBeenCalled()
expect(window.location.href).toBe("/login")
})

it("ignores storage events for unrelated keys", () => {
const { clearSpy } = setup()

fireStorageEvent({ key: "theme", newValue: null })

expect(clearSpy).not.toHaveBeenCalled()
expect(window.location.href).toBe(originalHref)
})

it("ignores storage events that set a new token value (login in another tab)", () => {
const { clearSpy } = setup()

fireStorageEvent({ key: "token", newValue: "fresh-token" })

expect(clearSpy).not.toHaveBeenCalled()
expect(window.location.href).toBe(originalHref)
})
})
3 changes: 2 additions & 1 deletion frontend/src/components/layout/AppLayout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { Menu } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Sidebar } from './Sidebar'
import { CommandPalette } from '../ui/command-palette'
import { useMe, useUpdateMe } from '../../hooks/useAuth'
import { useMe, useUpdateMe, useStorageAuthSync } from '../../hooks/useAuth'
import { useProjectDeletionWatcher } from '../../hooks/useProjectDeletionWatcher'
import { browserTimezone } from '../../lib/format'
import i18n, { setLocale, SUPPORTED_LOCALES } from '../../i18n'

export function AppLayout() {
const { t } = useTranslation(['nav', 'common'])
useStorageAuthSync()
const { data: user, isLoading, isError } = useMe()
const updateMe = useUpdateMe()
const detectedTimezoneBootstrapped = useRef(false)
Expand Down
29 changes: 27 additions & 2 deletions frontend/src/hooks/useAuth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import { useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { authApi } from '../api/auth'


const TOKEN_KEY = 'token'


// Bind to the browser storage event so a logout in tab A drops the
// cache + redirects tab B immediately, instead of waiting for tab B's
// next request to come back 401. Mounted once by AppLayout so the
// listener exists for the lifetime of an authenticated session.
export function useStorageAuthSync() {
const queryClient = useQueryClient()
useEffect(() => {
const handler = (event) => {
if (event.key !== TOKEN_KEY) return
if (event.newValue) return
if (event.storageArea !== window.localStorage) return
queryClient.clear()
window.location.href = '/login'
}
window.addEventListener('storage', handler)
return () => window.removeEventListener('storage', handler)
}, [queryClient])
}


export function useMe() {
return useQuery({
queryKey: ['me'],
Expand All @@ -14,7 +39,7 @@ export function useLogin() {
return useMutation({
mutationFn: ({ username, password }) => authApi.login(username, password),
onSuccess: (data) => {
localStorage.setItem('token', data.access_token)
localStorage.setItem(TOKEN_KEY, data.access_token)
queryClient.invalidateQueries({ queryKey: ['me'] })
},
})
Expand All @@ -23,7 +48,7 @@ export function useLogin() {
export function useLogout() {
const queryClient = useQueryClient()
return () => {
localStorage.removeItem('token')
localStorage.removeItem(TOKEN_KEY)
queryClient.clear()
window.location.href = '/login'
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/ca/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"success": "Contrasenya actualitzada. Redirigint a l'inici de sessió…",
"update": "Actualitza la contrasenya",
"updating": "Actualitzant…",
"failure": "No s'ha pogut restablir. Demana un enllaç nou."
"failure": "No s'ha pogut restablir. Demana un enllaç nou.",
"errors": {
"reset_token_invalid": "Aquest enllaç de recuperació no és vàlid. Demana'n un de nou.",
"reset_token_used": "Aquest enllaç ja s'ha utilitzat. Demana'n un de nou per triar una altra contrasenya.",
"reset_token_expired": "Aquest enllaç ha caducat. Demana'n un de nou."
}
}
}
6 changes: 6 additions & 0 deletions frontend/src/i18n/locales/ca/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
"fieldSteps": "Passos"
},
"discussion": {
"empty": "Encara no hi ha comentaris",
"add": "Afegeix comentari",
"edit": "Edita",
"delete": "Elimina",
"save": "Desa",
"cancel": "Cancel·la",
"deleteTitle": "Eliminar aquest comentari?",
"deleteDescription": "El comentari s'eliminarà de manera permanent."
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/en/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"success": "Password updated. Redirecting to sign in…",
"update": "Update password",
"updating": "Updating…",
"failure": "Reset failed. Please request a new link."
"failure": "Reset failed. Please request a new link.",
"errors": {
"reset_token_invalid": "This reset link is not recognized. Please request a new one.",
"reset_token_used": "This reset link was already used. Request a new one to choose another password.",
"reset_token_expired": "This reset link has expired. Please request a new one."
}
}
}
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/es/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"success": "Contraseña actualizada. Redirigiendo al inicio de sesión…",
"update": "Actualizar contraseña",
"updating": "Actualizando…",
"failure": "No se pudo restablecer. Solicita un nuevo enlace."
"failure": "No se pudo restablecer. Solicita un nuevo enlace.",
"errors": {
"reset_token_invalid": "Este enlace de recuperación no es válido. Solicita uno nuevo.",
"reset_token_used": "Este enlace ya fue usado. Solicita uno nuevo para elegir otra contraseña.",
"reset_token_expired": "Este enlace ha caducado. Solicita uno nuevo."
}
}
}
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/eu/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"success": "Pasahitza eguneratuta. Saio-hasierara birbideratzen…",
"update": "Eguneratu pasahitza",
"updating": "Eguneratzen…",
"failure": "Ezin izan da berrezarri. Eskatu esteka berria."
"failure": "Ezin izan da berrezarri. Eskatu esteka berria.",
"errors": {
"reset_token_invalid": "Berrezartzeko esteka hau ez da baliozkoa. Eskatu berri bat.",
"reset_token_used": "Esteka hau dagoeneko erabili da. Eskatu berri bat beste pasahitz bat aukeratzeko.",
"reset_token_expired": "Esteka hau iraungi da. Eskatu berri bat."
}
}
}
6 changes: 6 additions & 0 deletions frontend/src/i18n/locales/eu/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
"fieldSteps": "Urratsak"
},
"discussion": {
"empty": "Oraindik ez dago iruzkinik",
"add": "Gehitu iruzkina",
"edit": "Editatu",
"delete": "Ezabatu",
"save": "Gorde",
"cancel": "Utzi",
"deleteTitle": "Iruzkin hau ezabatu?",
"deleteDescription": "Iruzkina behin betiko ezabatuko da."
}
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/gl/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"success": "Contrasinal actualizado. Redirixindo ao inicio de sesión…",
"update": "Actualizar contrasinal",
"updating": "Actualizando…",
"failure": "Non se puido restablecer. Solicita unha nova ligazón."
"failure": "Non se puido restablecer. Solicita unha nova ligazón.",
"errors": {
"reset_token_invalid": "Esta ligazón de recuperación non é válida. Solicita unha nova.",
"reset_token_used": "Esta ligazón xa foi utilizada. Solicita unha nova para escoller outro contrasinal.",
"reset_token_expired": "Esta ligazón caducou. Solicita unha nova."
}
}
}
Loading
Loading