diff --git a/authentication/services.py b/authentication/services.py index 18231367b2..d1533f2d21 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -1,8 +1,12 @@ +import uuid + from django.conf import settings from django.contrib.auth.tokens import default_token_generator from django.core.signing import TimestampSigner from django.utils.crypto import get_random_string from rest_framework.exceptions import ValidationError +from rest_framework_simplejwt.exceptions import AuthenticationFailed +from rest_framework_simplejwt.tokens import RefreshToken from users.models import User from utils.email import send_email_with_template @@ -123,3 +127,17 @@ def send_email(self, invited_by: User, email: str): }, from_email=settings.EMAIL_HOST_USER, ) + + +def get_tokens_for_user(user): + if not user.is_active: + raise AuthenticationFailed("User is not active") + + refresh = RefreshToken.for_user(user) + # Add a session identification to isolate multiple sessions of the same user + refresh["session_id"] = str(uuid.uuid4()) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } diff --git a/authentication/urls.py b/authentication/urls.py index 6c0b1aca99..d92d16b411 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -1,10 +1,14 @@ from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView from .views import common, social urlpatterns = [ path("auth/login/token/", common.login_api_view), + path("auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("auth/verify_token/", common.verify_token_api_view), + # DEPRECATED: Legacy token migration endpoint (remove after 30 days) + path("auth/exchange-legacy-token/", common.exchange_legacy_token_api_view), path("auth/signup/", common.signup_api_view, name="auth-signup"), path( "auth/signup/simplified/", diff --git a/authentication/views/common.py b/authentication/views/common.py index d480d456ce..8c9ea6a3fd 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -25,6 +25,7 @@ send_password_reset_email, check_password_reset, SignupInviteService, + get_tokens_for_user, ) from projects.models import ProjectUserPermission from projects.permissions import ObjectPermission @@ -62,9 +63,9 @@ def login_api_view(request): if not user: raise ValidationError({"password": ["incorrect login / password"]}) - token, _ = ApiKey.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) @api_view(["POST"]) @@ -120,13 +121,12 @@ def signup_api_view(request): ) is_active = user.is_active - token = None + tokens = None if is_active: # We need to treat this as login action, so we should call `authenticate` service as well user = authenticate(login=email, password=password) - token_obj, _ = ApiKey.objects.get_or_create(user=user) - token = token_obj.key + tokens = get_tokens_for_user(user) if not is_active: send_activation_email(user, redirect_url) @@ -137,8 +137,8 @@ def signup_api_view(request): return Response( { "is_active": is_active, - "token": token, "user": UserPrivateSerializer(user).data, + "tokens": tokens, }, status=status.HTTP_201_CREATED, ) @@ -169,14 +169,13 @@ def signup_simplified_api_view(request): last_login=timezone.now(), ) - token_obj, _ = ApiKey.objects.get_or_create(user=user) - token = token_obj.key + tokens = get_tokens_for_user(user) return Response( { "is_active": user.is_active, - "token": token, "user": UserPrivateSerializer(user).data, + "tokens": tokens, }, status=status.HTTP_201_CREATED, ) @@ -210,9 +209,9 @@ def signup_activate_api_view(request): token = serializer.validated_data["token"] user = check_and_activate_user(user_id, token) - token, _ = ApiKey.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) @api_view(["GET"]) @@ -253,9 +252,9 @@ def password_reset_confirm_api_view(request): user.set_password(password) user.save() - token, _ = ApiKey.objects.get_or_create(user=user) + tokens = get_tokens_for_user(user) - return Response({"token": token.key, "user": UserPrivateSerializer(user).data}) + return Response({"tokens": tokens, "user": UserPrivateSerializer(user).data}) return Response(status=status.HTTP_204_NO_CONTENT) @@ -290,3 +289,28 @@ def api_key_rotate_api_view(request): api_key = ApiKey.objects.create(user=request.user) return Response({"key": api_key.key}, status=status.HTTP_201_CREATED) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def exchange_legacy_token_api_view(request): + """ + Exchange a legacy DRF auth token for new JWT tokens. + + DEPRECATED: This endpoint exists only for backward compatibility during + the migration period. It should be removed after the grace period (30 days). + """ + token = serializers.CharField().run_validation(request.data.get("token")) + + try: + token_obj = ApiKey.objects.get(key=token) + except ApiKey.DoesNotExist: + raise ValidationError({"token": ["Invalid token"]}) + + user = token_obj.user + if not user.is_active: + raise ValidationError({"token": ["User account is inactive"]}) + + tokens = get_tokens_for_user(user) + + return Response({"tokens": tokens}) diff --git a/authentication/views/social.py b/authentication/views/social.py index 8d435972c5..431beb8984 100644 --- a/authentication/views/social.py +++ b/authentication/views/social.py @@ -4,11 +4,12 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response +from rest_framework_simplejwt.authentication import JWTAuthentication from rest_social_auth.views import SocialTokenOnlyAuthView from social_core.backends.oauth import BaseOAuth2 -from authentication.auth import FallbackTokenAuthentication -from authentication.models import ApiKey +from authentication.services import get_tokens_for_user +from users.models import User @api_view(["GET"]) @@ -45,14 +46,13 @@ def social_providers_api_view(request): class SocialCodeAuth(SocialTokenOnlyAuthView): class TokenSerializer(serializers.Serializer): - token = serializers.SerializerMethodField() + tokens = serializers.SerializerMethodField() - def get_token(self, obj): - token, created = ApiKey.objects.get_or_create(user=obj) - return token.key + def get_tokens(self, obj: User): + return get_tokens_for_user(obj) serializer_class = TokenSerializer - authentication_classes = (FallbackTokenAuthentication,) + authentication_classes = (JWTAuthentication,) def respond_error(self, error): response = super().respond_error(error) diff --git a/front_end/src/app/(api)/api-proxy/[...path]/route.ts b/front_end/src/app/(api)/api-proxy/[...path]/route.ts index bdba81340f..37cdca5376 100644 --- a/front_end/src/app/(api)/api-proxy/[...path]/route.ts +++ b/front_end/src/app/(api)/api-proxy/[...path]/route.ts @@ -1,7 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getLocale } from "next-intl/server"; -import { getServerSession, getAlphaTokenSession } from "@/services/session"; +import { refreshWithSingleFlight } from "@/services/auth_refresh"; +import { + AuthCookieManager, + AuthTokens, + getAuthCookieManager, +} from "@/services/auth_tokens"; +import { getAlphaTokenSession } from "@/services/session"; import { getPublicSettings } from "@/utils/public_settings.server"; export async function GET(request: NextRequest) { @@ -34,7 +40,7 @@ async function handleProxyRequest(request: NextRequest, method: string) { const includeLocale = request.headers.get("x-include-locale") !== "false"; const shouldPassAuth = passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED; - const authToken = shouldPassAuth ? await getServerSession() : null; + const authManager = await getAuthCookieManager(); const alphaToken = await getAlphaTokenSession(); const locale = includeLocale ? await getLocale() : "en"; @@ -58,33 +64,56 @@ async function handleProxyRequest(request: NextRequest, method: string) { "x-include-locale", ]; - const requestHeaders: HeadersInit = new Headers({ - ...Object.fromEntries( - Array.from(request.headers.entries()).filter( - ([key]) => !blocklistHeaders.includes(key.toLowerCase()) - ) - ), - }); - - requestHeaders.set("Accept", "application/json"); - requestHeaders.set("Accept-Language", locale); - - if (emptyContentType && requestHeaders.has("Content-Type")) { - requestHeaders.delete("Content-Type"); + const buildHeaders = (accessToken?: string): Headers => { + const headers = new Headers({ + ...Object.fromEntries( + Array.from(request.headers.entries()).filter( + ([key]) => !blocklistHeaders.includes(key.toLowerCase()) + ) + ), + }); + + headers.set("Accept", "application/json"); + headers.set("Accept-Language", locale); + if (emptyContentType) headers.delete("Content-Type"); + + const token = + accessToken ?? (shouldPassAuth ? authManager.getAccessToken() : null); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (alphaToken) headers.set("x-alpha-auth-token", alphaToken); + + return headers; + }; + + let refreshedTokens: AuthTokens | null = null; + + // Proactive refresh: check expiration before making request + if (shouldPassAuth && authManager.isAccessTokenExpired()) { + const refreshToken = authManager.getRefreshToken(); + if (refreshToken) { + const newTokens = await refreshWithSingleFlight(refreshToken); + if (newTokens) { + refreshedTokens = newTokens; + } + } } - if (authToken) { - requestHeaders.set("Authorization", `Token ${authToken}`); - } - if (alphaToken) { - requestHeaders.set("x-alpha-auth-token", alphaToken); + let headers = buildHeaders(refreshedTokens?.access); + let response = await fetch(targetUrl, { method, headers }); + + // Fallback: retry on 401 (in case proactive check missed edge cases) + if (response.status === 401 && shouldPassAuth) { + const refreshToken = authManager.getRefreshToken(); + if (refreshToken) { + const newTokens = await refreshWithSingleFlight(refreshToken); + if (newTokens) { + refreshedTokens = newTokens; + headers = buildHeaders(newTokens.access); + response = await fetch(targetUrl, { method, headers }); + } + } } - const response = await fetch(targetUrl, { - method, - headers: requestHeaders, - }); - const responseData = await response.blob(); const responseHeaders: HeadersInit = {}; response.headers.forEach((value, key) => { @@ -97,11 +126,17 @@ async function handleProxyRequest(request: NextRequest, method: string) { } }); - return new NextResponse(responseData, { + const nextResponse = new NextResponse(responseData, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); + + if (refreshedTokens) { + new AuthCookieManager(nextResponse.cookies).setAuthTokens(refreshedTokens); + } + + return nextResponse; } // Normalize the Content-Disposition header to ensure the filename is quoted correctly diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index bcac46ee92..53e2b55d51 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -1,7 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { getLocale } from "next-intl/server"; import { z } from "zod"; @@ -9,12 +9,8 @@ import { z } from "zod"; import { signInSchema, SignUpSchema } from "@/app/(main)/accounts/schemas"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { LanguageService } from "@/services/language_service"; -import { - deleteImpersonatorSession, - deleteServerSession, - setServerSession, -} from "@/services/session"; import { AuthResponse, SignUpResponse } from "@/types/auth"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; @@ -71,7 +67,8 @@ export default async function loginAction( }; } - await setServerSession(response.token); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); // Set user's language preference as the active locale if (response.user.language) { @@ -135,8 +132,9 @@ export async function signUpAction( const signUpActionState: SignUpActionState = { ...response }; - if (response.is_active && response.token) { - await setServerSession(response.token); + if (response.is_active && response.tokens) { + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); // Set user's language preference as the active locale if (response.user?.language) { @@ -167,8 +165,11 @@ export async function signUpAction( } export async function LogOut() { - await deleteServerSession(); - await deleteImpersonatorSession(); + const authManager = await getAuthCookieManager(); + authManager.clearAuthTokens(); + authManager.clearImpersonatorRefreshToken(); + // DEPRECATED: Remove after 30-day migration period + (await cookies()).delete("auth_token"); return redirect("/"); } @@ -216,8 +217,9 @@ export async function simplifiedSignUpAction( try { const response = await ServerAuthApi.simplifiedSignUp(username, authToken); - if (response?.token) { - await setServerSession(response.token); + if (response && response.tokens) { + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } return response; } catch (err: unknown) { diff --git a/front_end/src/app/(main)/accounts/activate/route.ts b/front_end/src/app/(main)/accounts/activate/route.ts index b81de0d732..b46c765300 100644 --- a/front_end/src/app/(main)/accounts/activate/route.ts +++ b/front_end/src/app/(main)/accounts/activate/route.ts @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import invariant from "ts-invariant"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { logError } from "@/utils/core/errors"; export async function GET(request: Request) { @@ -15,7 +15,8 @@ export async function GET(request: Request) { try { const response = await ServerAuthApi.activateAccount(userId, token); - await setServerSession(response.token); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } catch (err) { logError(err); } diff --git a/front_end/src/app/(main)/accounts/profile/actions.tsx b/front_end/src/app/(main)/accounts/profile/actions.tsx index e1cc807a74..d97a9bcb7c 100644 --- a/front_end/src/app/(main)/accounts/profile/actions.tsx +++ b/front_end/src/app/(main)/accounts/profile/actions.tsx @@ -7,8 +7,8 @@ import { updateProfileSchema, } from "@/app/(main)/accounts/schemas"; import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { LanguageService } from "@/services/language_service"; -import { getServerSession } from "@/services/session"; import type { ErrorResponse } from "@/types/fetch"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; @@ -129,9 +129,9 @@ export async function updateLanguagePreference( language: string, revalidate = true ) { - const serverSession = await getServerSession(); + const authManager = await getAuthCookieManager(); - if (serverSession) { + if (authManager.hasAuthSession()) { // Update the user's language preference in the database await ServerProfileApi.updateProfile({ language: language, diff --git a/front_end/src/app/(main)/accounts/reset/actions.ts b/front_end/src/app/(main)/accounts/reset/actions.ts index 41c275ea00..1da1d18868 100644 --- a/front_end/src/app/(main)/accounts/reset/actions.ts +++ b/front_end/src/app/(main)/accounts/reset/actions.ts @@ -5,7 +5,7 @@ import { passwordResetRequestSchema, } from "@/app/(main)/accounts/schemas"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { AuthResponse } from "@/types/auth"; import { ApiError } from "@/utils/core/errors"; @@ -66,7 +66,8 @@ export async function passwordResetConfirmAction( validatedFields.data.password ); - await setServerSession(response.token); + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); return { data: response, diff --git a/front_end/src/app/(main)/accounts/reset/page.tsx b/front_end/src/app/(main)/accounts/reset/page.tsx index acebe612a8..f7dd5fa1bf 100644 --- a/front_end/src/app/(main)/accounts/reset/page.tsx +++ b/front_end/src/app/(main)/accounts/reset/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import PasswordReset from "@/app/(main)/accounts/reset/components/password_reset"; import { GlobalErrorContainer } from "@/components/global_error_boundary"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { ApiError, logError } from "@/utils/core/errors"; export default async function ResetPassword(props: { @@ -13,8 +13,8 @@ export default async function ResetPassword(props: { const { user_id, token } = searchParams; - const serverSession = await getServerSession(); - if (serverSession) { + const authManager = await getAuthCookieManager(); + if (authManager.hasAuthSession()) { return redirect("/"); } diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index 71c909ce37..892c7dae96 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -5,13 +5,7 @@ import { redirect } from "next/navigation"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { - deleteImpersonatorSession, - getImpersonatorSession, - getServerSession, - setImpersonatorSession, - setServerSession, -} from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { ApiError } from "@/utils/core/errors"; export async function changePassword(password: string, new_password: string) { @@ -154,11 +148,15 @@ export async function rotateApiKeyAction() { } export async function stopImpersonatingAction() { - const impersonatorToken = await getImpersonatorSession(); + const authManager = await getAuthCookieManager(); + const impersonatorRefreshToken = authManager.getImpersonatorRefreshToken(); - if (impersonatorToken) { - await setServerSession(impersonatorToken); - await deleteImpersonatorSession(); + if (impersonatorRefreshToken) { + const tokens = await ServerAuthApi.refreshTokens(impersonatorRefreshToken); + if (tokens) { + authManager.setAuthTokens(tokens); + } + authManager.clearImpersonatorRefreshToken(); } redirect("/accounts/settings/bots/"); @@ -166,14 +164,15 @@ export async function stopImpersonatingAction() { export async function impersonateBotAction(botId: number) { try { - const userToken = await getServerSession(); - const { token: botToken } = await ServerProfileApi.getBotToken(botId); + const authManager = await getAuthCookieManager(); + const userRefreshToken = authManager.getRefreshToken(); + const botTokens = await ServerProfileApi.getBotJwt(botId); - if (userToken) { - await setImpersonatorSession(userToken); + if (userRefreshToken) { + authManager.setImpersonatorRefreshToken(userRefreshToken); } - await setServerSession(botToken); + authManager.setAuthTokens(botTokens); redirect("/"); } catch (err) { diff --git a/front_end/src/app/(main)/accounts/settings/bots/page.tsx b/front_end/src/app/(main)/accounts/settings/bots/page.tsx index 056a05c031..b3bc24197e 100644 --- a/front_end/src/app/(main)/accounts/settings/bots/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/bots/page.tsx @@ -6,7 +6,7 @@ import BotsDisclaimer from "@/app/(main)/accounts/settings/bots/components/bots_ import BotCreateButton from "@/app/(main)/accounts/settings/bots/components/create_button"; import EmptyPlaceholder from "@/app/(main)/accounts/settings/bots/components/empty_placeholder"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import PreferencesSection from "../components/preferences_section"; @@ -15,8 +15,8 @@ export const metadata = { }; export default async function Bots() { - const token = await getServerSession(); - invariant(token); + const authManager = await getAuthCookieManager(); + invariant(authManager.hasAuthSession()); const t = await getTranslations(); const bots = await ServerProfileApi.getMyBots(); diff --git a/front_end/src/app/(main)/accounts/settings/layout.tsx b/front_end/src/app/(main)/accounts/settings/layout.tsx index 4c985696e8..b7d015e03f 100644 --- a/front_end/src/app/(main)/accounts/settings/layout.tsx +++ b/front_end/src/app/(main)/accounts/settings/layout.tsx @@ -3,7 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import { redirect } from "next/navigation"; import ServerProfileApi from "@/services/api/profile/profile.server"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import SettingsHeader from "./components/settings_header"; @@ -12,10 +12,10 @@ export default async function Layout({ }: Readonly<{ children: React.ReactNode; }>) { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); const currentUser = await ServerProfileApi.getMyProfile(); - if (!token || !currentUser) return redirect("/"); + if (!authManager.hasAuthSession() || !currentUser) return redirect("/"); return (
diff --git a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts index e945c7e30c..d9ec06de2c 100644 --- a/front_end/src/app/(main)/accounts/social/[provider]/actions.ts +++ b/front_end/src/app/(main)/accounts/social/[provider]/actions.ts @@ -1,7 +1,7 @@ "use server"; import ServerAuthApi from "@/services/api/auth/auth.server"; -import { setServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { SocialProviderType } from "@/types/auth"; import { getPublicSettings } from "@/utils/public_settings.server"; @@ -16,7 +16,8 @@ export async function exchangeSocialOauthCode( `${PUBLIC_APP_URL}/accounts/social/${provider}` ); - if (response?.token) { - await setServerSession(response.token); + if (response?.tokens) { + const authManager = await getAuthCookieManager(); + authManager.setAuthTokens(response.tokens); } } diff --git a/front_end/src/app/(main)/aib/2024/q3/page.tsx b/front_end/src/app/(main)/aib/2024/q3/page.tsx index 694528a99c..d4b2ae0ad5 100644 --- a/front_end/src/app/(main)/aib/2024/q3/page.tsx +++ b/front_end/src/app/(main)/aib/2024/q3/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2024-q3"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2024/q4/page.tsx b/front_end/src/app/(main)/aib/2024/q4/page.tsx index bcaf96fef2..fba3c6643a 100644 --- a/front_end/src/app/(main)/aib/2024/q4/page.tsx +++ b/front_end/src/app/(main)/aib/2024/q4/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2024-q4"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/fall/page.tsx b/front_end/src/app/(main)/aib/2025/fall/page.tsx index bcfa28da37..a7bfbe890a 100644 --- a/front_end/src/app/(main)/aib/2025/fall/page.tsx +++ b/front_end/src/app/(main)/aib/2025/fall/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-fall"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Settings() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/q1/page.tsx b/front_end/src/app/(main)/aib/2025/q1/page.tsx index 3e21d736da..226ccc3ff7 100644 --- a/front_end/src/app/(main)/aib/2025/q1/page.tsx +++ b/front_end/src/app/(main)/aib/2025/q1/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-q1"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Q1Page() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/aib/2025/q2/page.tsx b/front_end/src/app/(main)/aib/2025/q2/page.tsx index cb6c9193a9..257dd6131e 100644 --- a/front_end/src/app/(main)/aib/2025/q2/page.tsx +++ b/front_end/src/app/(main)/aib/2025/q2/page.tsx @@ -1,4 +1,4 @@ -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import AiBenchmarkingTournamentPage from "../../components/page-view-2025-q2"; @@ -9,7 +9,8 @@ export const metadata = { }; export default async function Q2Page() { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); + const token = authManager.getAccessToken(); return ; } diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index 014035924c..0f4db9b72c 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -3,7 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; import { defaultDescription } from "@/constants/metadata"; -import { getImpersonatorSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(home)/components/feedback_float"; @@ -28,13 +28,14 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const impersonatorToken = await getImpersonatorSession(); + const authManager = await getAuthCookieManager(); + const isImpersonating = authManager.isImpersonating(); return (
- {impersonatorToken && } + {isImpersonating && }
{children}
diff --git a/front_end/src/middleware.ts b/front_end/src/middleware.ts index aee470bec9..67bf7c6bcb 100644 --- a/front_end/src/middleware.ts +++ b/front_end/src/middleware.ts @@ -1,59 +1,93 @@ import { NextRequest, NextResponse } from "next/server"; import ServerAuthApi from "@/services/api/auth/auth.server"; +import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; +// DEPRECATED: Remove after 30-day migration period +import { handleLegacyTokenMigration } from "@/services/auth_tokens_migration"; import { LanguageService, LOCALE_COOKIE_NAME, } from "@/services/language_service"; -import { - COOKIE_NAME_TOKEN, - getAlphaTokenSession, - getServerSession, -} from "@/services/session"; +import { getAlphaTokenSession } from "@/services/session"; import { getAlphaAccessToken } from "@/utils/alpha_access"; -import { ApiError } from "@/utils/core/errors"; import { getPublicSettings } from "@/utils/public_settings.server"; +async function verifyToken(responseAuth: AuthCookieManager): Promise { + try { + await ServerAuthApi.verifyToken(); + } catch { + // Token is invalid (user banned, token revoked, etc.) - clear all auth cookies + console.error("Token verification failed, clearing auth cookies"); + responseAuth.clearAuthTokens(); + } +} + +/** + * Refresh tokens and apply new cookies to response. + * Returns true if tokens were refreshed. + */ +async function refreshTokensIfNeeded( + requestAuth: AuthCookieReader, + responseAuth: AuthCookieManager +): Promise { + const refreshToken = requestAuth.getRefreshToken(); + + // No refresh token = can't refresh + if (!refreshToken) return false; + + // Access token still valid = no refresh needed + if (!requestAuth.isAccessTokenExpired()) return false; + + let tokens; + try { + tokens = await ServerAuthApi.refreshTokens(refreshToken); + } catch (error) { + console.error("Middleware token refresh failed:", error); + return false; + } + + responseAuth.setAuthTokens(tokens); + return true; +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; + const requestAuth = new AuthCookieReader(request.cookies); + let hasSession = requestAuth.hasAuthSession(); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-url", request.url); - const serverSession = await getServerSession(); + const response = NextResponse.next({ request: { headers: requestHeaders } }); + const responseAuth = new AuthCookieManager(response.cookies); + + // DEPRECATED: Legacy token migration - remove after 30-day grace period + // Must run before auth checks so users with legacy tokens aren't rejected + const wasMigrated = await handleLegacyTokenMigration( + request, + response, + requestAuth, + responseAuth + ); + if (wasMigrated) { + hasSession = true; + } const { PUBLIC_AUTHENTICATION_REQUIRED } = getPublicSettings(); - // if authentication is required, check for token + + // If authentication is required, redirect unauthenticated users if (PUBLIC_AUTHENTICATION_REQUIRED) { if ( - !request.nextUrl.pathname.startsWith("/not-found/") && - !request.nextUrl.pathname.startsWith("/accounts/") && - !serverSession + !pathname.startsWith("/not-found/") && + !pathname.startsWith("/accounts/") && + !hasSession ) { - // return a not found page return NextResponse.rewrite(new URL("/not-found/", request.url)); } } - let deleteCookieToken = false; - - if (serverSession) { - // Verify auth token - try { - await ServerAuthApi.verifyToken(); - } catch (error) { - const errorResponse = error as ApiError; - - if (errorResponse?.response?.status === 403) { - request.cookies.delete(COOKIE_NAME_TOKEN); - // A small workaround of deleting cookies. - // We need to delete cookies from request before generating response - // to let other services know we've eliminated the auth token. - // But Nextjs does not apply such cookies deletion to the response - // automatically, so we have to do it for both req and res - // https://github.com/vercel/next.js/issues/40146 - deleteCookieToken = true; - } - } - - // Check restricted access token + // Check restricted alpha access + if (hasSession) { const alphaAccessToken = await getAlphaAccessToken(); const alphaAuthUrl = "/alpha-auth"; @@ -69,27 +103,25 @@ export async function middleware(request: NextRequest) { } } - const requestHeaders = new Headers(request.headers); - requestHeaders.set("x-url", request.url); + // Proactive token refresh (MUST happen in middleware to persist cookies) + if (hasSession) { + const tokensRefreshed = await refreshTokensIfNeeded( + requestAuth, + responseAuth + ); + // Skip verification if tokens were just refreshed (they're valid by definition) + // Only verify existing tokens to catch banned users or revoked tokens + if (!tokensRefreshed) { + await verifyToken(responseAuth); + } + } const locale_in_url = request.nextUrl.searchParams.get("locale"); const locale_in_cookie = request.cookies.get(LOCALE_COOKIE_NAME)?.value; - - const response = NextResponse.next({ - request: { - headers: requestHeaders, - }, - }); - - // Handle explicit locale parameter in URL if (locale_in_url && locale_in_url !== locale_in_cookie) { LanguageService.setLocaleCookieInResponse(response, locale_in_url); } - if (deleteCookieToken) { - response.cookies.delete(COOKIE_NAME_TOKEN); - } - return response; } @@ -100,7 +132,7 @@ export const config = { // Ignores prefetch requests, all media files // And embedded urls source: - "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|.*\\..*).*)", + "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|app-version|.*\\..*).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, diff --git a/front_end/src/services/api/api_service.ts b/front_end/src/services/api/api_service.ts index 4f93ff348c..b840d9b8bd 100644 --- a/front_end/src/services/api/api_service.ts +++ b/front_end/src/services/api/api_service.ts @@ -14,9 +14,10 @@ export abstract class ApiService { protected post>( url: string, body: B, - options?: FetchOptions + options?: FetchOptions, + config?: FetchConfig ): Promise { - return this.fetcher.post(url, body, options); + return this.fetcher.post(url, body, options, config); } protected put( diff --git a/front_end/src/services/api/auth/auth.server.ts b/front_end/src/services/api/auth/auth.server.ts index 9c8b053642..fd34118564 100644 --- a/front_end/src/services/api/auth/auth.server.ts +++ b/front_end/src/services/api/auth/auth.server.ts @@ -1,5 +1,7 @@ import "server-only"; + import { ApiService } from "@/services/api/api_service"; +import { AuthTokens } from "@/types/auth"; import { AuthResponse, SignUpResponse, @@ -24,6 +26,15 @@ export type SignUpProps = { }; class ServerAuthApiClass extends ApiService { + async refreshTokens(refreshToken: string) { + return this.post( + "/auth/refresh/", + { refresh: refreshToken }, + {}, + { passAuthHeader: false } + ); + } + async getSocialProviders(redirect_uri: string): Promise { try { return await this.get( @@ -52,10 +63,12 @@ class ServerAuthApiClass extends ApiService { return this.post< SocialAuthResponse, { code: string; redirect_uri: string } - >(`/auth/social/${provider}/`, { - code, - redirect_uri, - }); + >( + `/auth/social/${provider}/`, + { code, redirect_uri }, + {}, + { passAuthHeader: false } + ); } async resendActivationEmail(login: string, redirect_url: string) { @@ -71,27 +84,37 @@ class ServerAuthApiClass extends ApiService { async signIn(login: string, password: string) { return this.post( "/auth/login/token/", - { login, password } + { login, password }, + {}, + { passAuthHeader: false } ); } async signUp(props: SignUpProps, turnstileHeaders: HeadersInit) { - return this.post("/auth/signup/", props, { - headers: turnstileHeaders, - }); + return this.post( + "/auth/signup/", + props, + { headers: turnstileHeaders }, + { passAuthHeader: false } + ); } async activateAccount(userId: string, token: string) { return this.post( "/auth/signup/activate/", - { user_id: userId, token } + { user_id: userId, token }, + {}, + { passAuthHeader: false } ); } async passwordResetRequest(login: string) { - return this.post("/auth/password-reset/", { - login, - }); + return this.post( + "/auth/password-reset/", + { login }, + {}, + { passAuthHeader: false } + ); } async passwordResetVerifyToken(user_id: number, token: string) { @@ -107,9 +130,9 @@ class ServerAuthApiClass extends ApiService { ): Promise { return this.post( `/auth/password-reset/change/?user_id=${user_id}&token=${token}`, - { - password, - } + { password }, + {}, + { passAuthHeader: false } ); } @@ -125,7 +148,9 @@ class ServerAuthApiClass extends ApiService { ): Promise { return this.post( "/auth/signup/simplified/", - { username, auth_token } + { username, auth_token }, + {}, + { passAuthHeader: false } ); } diff --git a/front_end/src/services/api/profile/profile.server.ts b/front_end/src/services/api/profile/profile.server.ts index aae0b08cf8..d2efa519ae 100644 --- a/front_end/src/services/api/profile/profile.server.ts +++ b/front_end/src/services/api/profile/profile.server.ts @@ -1,5 +1,6 @@ import "server-only"; -import { getServerSession } from "@/services/session"; +import { getAuthCookieManager } from "@/services/auth_tokens"; +import { AuthTokens } from "@/types/auth"; import { SubscriptionEmailType } from "@/types/notifications"; import { ProfilePreferencesType } from "@/types/preferences"; import { CurrentBot, CurrentUser } from "@/types/users"; @@ -35,9 +36,9 @@ class ServerProfileApiClass extends ProfileApi { // We make getMyProfile server-only, as it depends on the session // On client side, we can access user profile using `useAuth` hook async getMyProfile(): Promise { - const token = await getServerSession(); + const authManager = await getAuthCookieManager(); - if (!token) { + if (!authManager.hasAuthSession()) { return null; } @@ -114,6 +115,10 @@ class ServerProfileApiClass extends ProfileApi { async getBotToken(botId: number) { return await this.get<{ token: string }>(`/users/me/bots/${botId}/token/`); } + + async getBotJwt(botId: number) { + return await this.post(`/users/me/bots/${botId}/jwt/`, {}); + } } const ServerProfileApi = new ServerProfileApiClass(serverFetcher); diff --git a/front_end/src/services/auth_refresh.ts b/front_end/src/services/auth_refresh.ts new file mode 100644 index 0000000000..2435821212 --- /dev/null +++ b/front_end/src/services/auth_refresh.ts @@ -0,0 +1,45 @@ +import "server-only"; + +import ServerAuthApi from "@/services/api/auth/auth.server"; +import { type AuthTokens } from "@/services/auth_tokens"; + +/** + * Perform the actual token refresh API call. + */ +async function doRefresh(refreshToken: string): Promise { + try { + return await ServerAuthApi.refreshTokens(refreshToken); + } catch (error) { + console.error("Token refresh failed:", error); + return null; + } +} + +/** + * Single-flight refresh for Route Handlers (api-proxy). + * Uses Map-based deduplication for concurrent requests. + * + * Note: SSR refresh is handled by middleware, not here. + */ +const inFlightRefreshes = new Map>(); + +export function refreshWithSingleFlight( + refreshToken: string +): Promise { + const existing = inFlightRefreshes.get(refreshToken); + if (existing) return existing; + + const promise = doRefresh(refreshToken); + + inFlightRefreshes.set(refreshToken, promise); + + promise.finally(() => { + setTimeout(() => { + if (inFlightRefreshes.get(refreshToken) === promise) { + inFlightRefreshes.delete(refreshToken); + } + }, 10_000); + }); + + return promise; +} diff --git a/front_end/src/services/auth_tokens.ts b/front_end/src/services/auth_tokens.ts new file mode 100644 index 0000000000..4bc29f9188 --- /dev/null +++ b/front_end/src/services/auth_tokens.ts @@ -0,0 +1,165 @@ +import "server-only"; + +import { cookies } from "next/headers"; + +import { AuthTokens } from "@/types/auth"; + +// Re-export for convenience +export type { AuthTokens } from "@/types/auth"; + +// Constants +export const COOKIE_NAME_ACCESS_TOKEN = "metaculus_access_token"; +export const COOKIE_NAME_REFRESH_TOKEN = "metaculus_refresh_token"; +export const COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN = + "impersonator_refresh_token"; + +// Token expiration times (should match backend) +export const ACCESS_TOKEN_EXPIRY_SECONDS = 15 * 60; // 15 minutes +export const REFRESH_TOKEN_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days +export const REFRESH_BUFFER_SECONDS = 30; // Refresh if expiring within 30s + +const AUTH_COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: "lax" as const, + path: "/", +}; + +/** + * Decode JWT payload without verification (we just need expiration time) + */ +export function decodeJWTPayload(token: string): { exp?: number } | null { + try { + const [, payload] = token.split("."); + if (!payload) return null; + const decoded = Buffer.from(payload, "base64url").toString("utf-8"); + return JSON.parse(decoded); + } catch { + return null; + } +} + +/** + * Check if a token is expired or about to expire + */ +export function isTokenExpired( + token: string | undefined, + bufferSeconds: number = REFRESH_BUFFER_SECONDS +): boolean { + if (!token) return true; + + const payload = decodeJWTPayload(token); + if (!payload?.exp) return false; // Can't determine, assume not expired + + const expiresAt = payload.exp * 1000; + const now = Date.now(); + const bufferMs = bufferSeconds * 1000; + + return now >= expiresAt - bufferMs; +} + +type CookieOptions = { + httpOnly?: boolean; + secure?: boolean; + sameSite?: "lax" | "strict" | "none"; + path?: string; + maxAge?: number; +}; + +export interface ReadonlyCookieStorage { + get(name: string): { value: string } | undefined; +} + +export interface CookieStorage extends ReadonlyCookieStorage { + set(name: string, value: string, options?: CookieOptions): void; + delete(name: string): void; +} + +/** + * Read-only manager for auth token cookies. + * Works with request.cookies (ReadonlyCookieStorage). + */ +export class AuthCookieReader { + constructor(private cookieStorage: ReadonlyCookieStorage) {} + + getAccessToken(): string | null { + return this.cookieStorage.get(COOKIE_NAME_ACCESS_TOKEN)?.value || null; + } + + getRefreshToken(): string | null { + return this.cookieStorage.get(COOKIE_NAME_REFRESH_TOKEN)?.value || null; + } + + getImpersonatorRefreshToken(): string | null { + return ( + this.cookieStorage.get(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN)?.value || + null + ); + } + + hasAuthSession(): boolean { + return !!(this.getAccessToken() || this.getRefreshToken()); + } + + isImpersonating(): boolean { + return !!this.getImpersonatorRefreshToken(); + } + + isAccessTokenExpired( + bufferSeconds: number = REFRESH_BUFFER_SECONDS + ): boolean { + const token = this.getAccessToken(); + return isTokenExpired(token ?? undefined, bufferSeconds); + } +} + +/** + * Full manager for auth token cookies with read/write access. + * Works with next/headers cookies() and response.cookies (CookieStorage). + */ +export class AuthCookieManager extends AuthCookieReader { + constructor(private writableCookieStorage: CookieStorage) { + super(writableCookieStorage); + } + + setAuthTokens(tokens: AuthTokens): void { + this.writableCookieStorage.set(COOKIE_NAME_ACCESS_TOKEN, tokens.access, { + ...AUTH_COOKIE_OPTIONS, + maxAge: ACCESS_TOKEN_EXPIRY_SECONDS, + }); + + this.writableCookieStorage.set(COOKIE_NAME_REFRESH_TOKEN, tokens.refresh, { + ...AUTH_COOKIE_OPTIONS, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + }); + } + + clearAuthTokens(): void { + this.writableCookieStorage.delete(COOKIE_NAME_ACCESS_TOKEN); + this.writableCookieStorage.delete(COOKIE_NAME_REFRESH_TOKEN); + } + + setImpersonatorRefreshToken(refreshToken: string): void { + this.writableCookieStorage.set( + COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN, + refreshToken, + { + ...AUTH_COOKIE_OPTIONS, + maxAge: REFRESH_TOKEN_EXPIRY_SECONDS, + } + ); + } + + clearImpersonatorRefreshToken(): void { + this.writableCookieStorage.delete(COOKIE_NAME_IMPERSONATOR_REFRESH_TOKEN); + } +} + +/** + * Factory function to create an AuthCookieManager from next/headers cookies(). + * Use this in server components and server actions. + */ +export async function getAuthCookieManager(): Promise { + const cookieStorage = await cookies(); + return new AuthCookieManager(cookieStorage); +} diff --git a/front_end/src/services/auth_tokens_migration.ts b/front_end/src/services/auth_tokens_migration.ts new file mode 100644 index 0000000000..de0993c36a --- /dev/null +++ b/front_end/src/services/auth_tokens_migration.ts @@ -0,0 +1,57 @@ +/** + * Legacy Auth Token Migration + * + * DEPRECATED: Remove this file after 30 days from release. + */ +import "server-only"; + +import { NextRequest, NextResponse } from "next/server"; + +import { AuthCookieManager, AuthCookieReader } from "@/services/auth_tokens"; +import { getPublicSettings } from "@/utils/public_settings.server"; + +const LEGACY_COOKIE_NAME = "auth_token"; + +/** + * Migrate legacy auth_token cookie to new JWT tokens. + * Call this in middleware before normal auth handling. + * Deletes invalid legacy tokens on 400 response. + */ +export async function handleLegacyTokenMigration( + request: NextRequest, + response: NextResponse, + requestAuth: AuthCookieReader, + responseAuth: AuthCookieManager +): Promise { + if (requestAuth.hasAuthSession()) return false; + + const legacyToken = request.cookies.get(LEGACY_COOKIE_NAME)?.value; + if (!legacyToken) return false; + + const { PUBLIC_API_BASE_URL } = getPublicSettings(); + + try { + const apiResponse = await fetch( + `${PUBLIC_API_BASE_URL}/api/auth/exchange-legacy-token/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: legacyToken }), + } + ); + + if (apiResponse.status === 400) { + // Invalid token - clean it up + response.cookies.delete(LEGACY_COOKIE_NAME); + return false; + } + + if (!apiResponse.ok) return false; + + const data = await apiResponse.json(); + responseAuth.setAuthTokens(data.tokens); + return true; + } catch { + return false; + } +} diff --git a/front_end/src/services/session.ts b/front_end/src/services/session.ts index 047cfdf8e2..0bc98f0912 100644 --- a/front_end/src/services/session.ts +++ b/front_end/src/services/session.ts @@ -1,11 +1,9 @@ import "server-only"; import { cookies } from "next/headers"; -export const COOKIE_NAME_TOKEN = "auth_token"; export const COOKIE_NAME_DEV_TOKEN = "alpha_token"; -export const COOKIE_NAME_IMPERSONATOR_TOKEN = "impersonator_token"; -export async function setServerCookie(name: string, value: string) { +async function setServerCookie(name: string, value: string) { const cookieStorage = await cookies(); cookieStorage.set(name, value, { httpOnly: true, @@ -15,42 +13,9 @@ export async function setServerCookie(name: string, value: string) { }); } -export async function setServerSession(auth_token: string) { - return setServerCookie(COOKIE_NAME_TOKEN, auth_token); -} - -export async function getServerSession() { - const cookieStorage = await cookies(); - const cookie = cookieStorage.get(COOKIE_NAME_TOKEN); - - return cookie?.value || null; -} - -export async function getImpersonatorSession() { - const cookieStorage = await cookies(); - const cookie = cookieStorage.get(COOKIE_NAME_IMPERSONATOR_TOKEN); - - return cookie?.value || null; -} - -export async function setImpersonatorSession(token: string) { - return setServerCookie(COOKIE_NAME_IMPERSONATOR_TOKEN, token); -} - -export async function deleteImpersonatorSession() { - const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_IMPERSONATOR_TOKEN); -} - -export async function deleteServerSession() { - const cookieStorage = await cookies(); - cookieStorage.delete(COOKIE_NAME_TOKEN); -} - export async function getAlphaTokenSession() { const cookieStorage = await cookies(); const cookie = cookieStorage.get(COOKIE_NAME_DEV_TOKEN); - return cookie?.value || null; } diff --git a/front_end/src/types/auth.ts b/front_end/src/types/auth.ts index e2cdc9b201..03a74f5870 100644 --- a/front_end/src/types/auth.ts +++ b/front_end/src/types/auth.ts @@ -5,8 +5,13 @@ export type AuthContextType = { setUser: (user: CurrentUser | null) => void; }; +export type AuthTokens = { + access: string; + refresh: string; +}; + export type SocialAuthResponse = { - token: string; + tokens: AuthTokens; }; export type SocialProviderType = "facebook" | "google-oauth2"; @@ -17,11 +22,12 @@ export type SocialProvider = { }; export type AuthResponse = { - token: string; + tokens: AuthTokens; user: CurrentUser; }; -export type SignUpResponse = AuthResponse & { +export type SignUpResponse = { + tokens: AuthTokens | null; + user: CurrentUser; is_active: boolean; - token: string | null; }; diff --git a/front_end/src/types/fetch.ts b/front_end/src/types/fetch.ts index cb08533225..83c1280b52 100644 --- a/front_end/src/types/fetch.ts +++ b/front_end/src/types/fetch.ts @@ -10,7 +10,8 @@ export type Fetcher = { post>( url: string, body: B, - options?: FetchOptions + options?: FetchOptions, + config?: FetchConfig ): Promise; put(url: string, body: B, options?: FetchOptions): Promise; patch(url: string, body: B, options?: FetchOptions): Promise; diff --git a/front_end/src/utils/core/fetch/fetch.server.ts b/front_end/src/utils/core/fetch/fetch.server.ts index dda55d9043..8cd9b154a8 100644 --- a/front_end/src/utils/core/fetch/fetch.server.ts +++ b/front_end/src/utils/core/fetch/fetch.server.ts @@ -2,12 +2,52 @@ import "server-only"; import { getLocale } from "next-intl/server"; -import { getAlphaTokenSession, getServerSession } from "@/services/session"; -import { FetchOptions, FetchConfig } from "@/types/fetch"; +import { getAuthCookieManager } from "@/services/auth_tokens"; +import { getAlphaTokenSession } from "@/services/session"; +import { FetchConfig, FetchOptions } from "@/types/fetch"; import { createFetcher, defaultOptions, handleResponse } from "./fetch.shared"; import { getPublicSettings } from "../../public_settings.server"; +/** + * Server-side fetch for API calls. + * + * Token refresh is handled by middleware BEFORE this runs. + * If we get a 401 here, it means the token is invalid (not just expired) + * and we should not attempt refresh during SSR (would invalidate tokens + * without being able to persist new ones). + */ +async function serverFetch( + url: string, + options: FetchOptions, + config: { withNextJsNotFoundRedirect: boolean; passAuthHeader: boolean } +): Promise { + const { PUBLIC_API_BASE_URL, PUBLIC_AUTHENTICATION_REQUIRED } = + getPublicSettings(); + const shouldPassAuth = + config.passAuthHeader ?? PUBLIC_AUTHENTICATION_REQUIRED; + + const authManager = await getAuthCookieManager(); + const token = shouldPassAuth ? authManager.getAccessToken() : null; + const alphaToken = await getAlphaTokenSession(); + + const requestOptions: FetchOptions = { + ...options, + headers: { + ...options.headers, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), + }, + }; + + const finalUrl = `${PUBLIC_API_BASE_URL}/api${url}`; + const response = await fetch(finalUrl, requestOptions); + + return handleResponse(response, { + withNextJsNotFoundRedirect: config.withNextJsNotFoundRedirect, + }); +} + const serverAppFetch = async ( url: string, options: FetchOptions = {}, @@ -25,29 +65,14 @@ const serverAppFetch = async ( passAuthHeader = false; } - const { PUBLIC_API_BASE_URL, PUBLIC_AUTHENTICATION_REQUIRED } = - getPublicSettings(); - - const authToken = - passAuthHeader || PUBLIC_AUTHENTICATION_REQUIRED - ? await getServerSession() - : null; - const alphaToken = await getAlphaTokenSession(); - const locale = forceLocale - ? forceLocale - : includeLocale - ? await getLocale() - : "en"; + const locale = forceLocale ?? (includeLocale ? await getLocale() : "en"); - const finalUrl = `${PUBLIC_API_BASE_URL}/api${url}`; const finalOptions: FetchOptions = { ...defaultOptions, ...options, headers: { ...defaultOptions.headers, ...options.headers, - ...(authToken ? { Authorization: `Token ${authToken}` } : {}), - ...(alphaToken ? { "x-alpha-auth-token": alphaToken } : {}), "Accept-Language": locale, }, }; @@ -60,9 +85,9 @@ const serverAppFetch = async ( delete finalOptions.headers["Content-Type"]; } - const response = await fetch(finalUrl, finalOptions); - return await handleResponse(response, { + return serverFetch(url, finalOptions, { withNextJsNotFoundRedirect: true, + passAuthHeader, }); }; diff --git a/front_end/src/utils/core/fetch/fetch.shared.ts b/front_end/src/utils/core/fetch/fetch.shared.ts index fc3d041262..83a17bfccd 100644 --- a/front_end/src/utils/core/fetch/fetch.shared.ts +++ b/front_end/src/utils/core/fetch/fetch.shared.ts @@ -87,7 +87,8 @@ export function createFetcher(fetchInitializer: FetchInitializer): Fetcher { const post = >( url: string, body: B, - options: FetchOptions = {} + options: FetchOptions = {}, + config?: FetchConfig ): Promise => { const isFormData = body instanceof FormData; @@ -98,7 +99,7 @@ export function createFetcher(fetchInitializer: FetchInitializer): Fetcher { method: "POST", body: isFormData ? body : JSON.stringify(body), }, - { emptyContentType: isFormData } + { emptyContentType: isFormData, ...config } ); }; diff --git a/metaculus_web/settings.py b/metaculus_web/settings.py index 5e7f0d3766..8b95e24639 100644 --- a/metaculus_web/settings.py +++ b/metaculus_web/settings.py @@ -13,11 +13,14 @@ import os import re import sys +from datetime import timedelta from pathlib import Path import dj_database_url import django.conf.locale import sentry_sdk +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import load_pem_private_key from django.core.exceptions import DisallowedHost from dramatiq.errors import RateLimitExceeded from sentry_sdk.integrations.django import DjangoIntegration @@ -146,6 +149,9 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", + # Primary auth mechanism for web users + "rest_framework_simplejwt.authentication.JWTAuthentication", + # Auth Token: should be used for bots only! "authentication.auth.FallbackTokenAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ @@ -157,6 +163,43 @@ "MAX_LIMIT": 100, } + +# Simple JWT +# https://django-rest-framework-simplejwt.readthedocs.io/ +# Generate key with: openssl genrsa -out jwt_private.pem 2048 +# Falls back to HS256 with SECRET_KEY if JWT_PRIVATE_KEY is not set +def get_jwt_encryption_config(): + private_key_pem = os.environ.get("JWT_PRIVATE_KEY", "").replace("\\n", "\n") + + if not private_key_pem: + # Fallback to HS256 with SECRET_KEY + return {"ALGORITHM": "HS256", "SIGNING_KEY": SECRET_KEY, "VERIFYING_KEY": None} + + private_key = load_pem_private_key(private_key_pem.encode(), password=None) + public_key_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return { + "ALGORITHM": "RS256", + "SIGNING_KEY": private_key_pem, + "VERIFYING_KEY": public_key_pem, + } + + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "ROTATE_REFRESH_TOKENS": True, + "CHECK_REVOKE_TOKEN": True, + "REVOKE_TOKEN_CLAIM": "hash", + **get_jwt_encryption_config(), +} + # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators @@ -307,7 +350,6 @@ ], } - # Setting StubBroker broker for unit tests environment # Integration tests should run as the real env if IS_TEST_ENV: diff --git a/poetry.lock b/poetry.lock index e64212799b..7de198ab36 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -790,68 +790,87 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "cryptography" -version = "44.0.2" +version = "46.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" +python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main"] files = [ - {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, - {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, - {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, - {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, - {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, - {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, - {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, ] [package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -1198,6 +1217,31 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469"}, + {file = "djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f"}, +] + +[package.dependencies] +django = ">=4.2" +djangorestframework = ">=3.14" +pyjwt = ">=1.7.1" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"] +doc = ["Sphinx", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "docstring-parser" version = "0.16" @@ -4001,7 +4045,7 @@ files = [ {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "tzlocal" @@ -4166,4 +4210,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.1" python-versions = "3.12.3" -content-hash = "b1c67f05c92a4fbaae07accd07dc8ce12e8c8de5451b62ef9a1a255520623ab9" +content-hash = "f9d99cb7d959e43b2d4cfbb8322037b81c7fd0c06bb6d0415272356b34f1aff9" diff --git a/projects/admin.py b/projects/admin.py index d33e00d562..5dd5c4f072 100644 --- a/projects/admin.py +++ b/projects/admin.py @@ -6,11 +6,9 @@ from django.db.models import QuerySet, Q from django.http import HttpResponse from django.shortcuts import render, redirect, get_object_or_404 -from django.urls import path -from django.urls import reverse +from django.urls import path, reverse from django.utils.html import format_html, format_html_join from django_select2.forms import ModelSelect2MultipleWidget -from authentication.models import ApiKey from posts.models import Post from projects.models import ( @@ -561,7 +559,6 @@ def generate_50_users_password_simple(self, request, queryset: QuerySet[Project] ) user.set_password(username) user.save() - ApiKey.objects.create(user=user) data += f"{user.username}\n" for project in queryset: ProjectUserPermission.objects.create( @@ -619,7 +616,6 @@ def generate_50_users_password_complex(self, request, queryset: QuerySet[Project ) user.set_password(password) user.save() - ApiKey.objects.create(user=user) data += f"{user.username},{password}\n" for project in queryset: ProjectUserPermission.objects.create( diff --git a/pyproject.toml b/pyproject.toml index bc02260938..f65a958d48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,8 @@ instructor = "^1.7.7" pytz = "^2025.2" psycopg2 = "^2.9.10" scikit-learn = "^1.7.2" +djangorestframework-simplejwt = "^5.5.1" +cryptography = "^46.0.3" [tool.poetry.group.dev.dependencies] pytest = "^8.2.1" diff --git a/tests/unit/test_auth/test_views.py b/tests/unit/test_auth/test_views.py index 14ca8c6e3d..6d35c3885f 100644 --- a/tests/unit/test_auth/test_views.py +++ b/tests/unit/test_auth/test_views.py @@ -28,7 +28,7 @@ def test_signup__verify_email(self, anon_client, mocker): assert response.status_code == 201 assert response.data["is_active"] == user.is_active == False - assert not response.data["token"] + assert not response.data["tokens"] assert not user.last_login mock_send_activation_email.assert_called_once() @@ -53,7 +53,7 @@ def test_signup__do_not_verify_email(self, anon_client, mocker): assert response.status_code == 201 assert response.data["is_active"] == user.is_active == True assert user.last_login - assert response.data["token"] + assert response.data["tokens"] mock_send_activation_email.assert_not_called() @override_settings(PUBLIC_ALLOW_SIGNUP=False) @@ -100,7 +100,7 @@ def test_signup_invitation(self, anon_client): ) assert response.status_code == 201 assert response.data["is_active"] == True - assert response.data["token"] + assert response.data["tokens"] @pytest.mark.parametrize( "params,expected_langauge", diff --git a/users/urls.py b/users/urls.py index eec4d0dadd..c592fd0713 100644 --- a/users/urls.py +++ b/users/urls.py @@ -59,4 +59,9 @@ views.bot_token_api_view, name="reveal-bot-token", ), + path( + "users/me/bots//jwt/", + views.bot_jwt_api_view, + name="reveal-bot-jwt", + ), ] diff --git a/users/views.py b/users/views.py index e5b9f200f3..e411ae3d20 100644 --- a/users/views.py +++ b/users/views.py @@ -12,6 +12,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from authentication.services import get_tokens_for_user from users.models import User, UserSpamActivity from users.serializers import ( UserPrivateSerializer, @@ -287,3 +288,16 @@ def bot_token_api_view(request: Request, pk: int): token, _ = ApiKey.objects.get_or_create(user=bot) return Response({"token": token.key}) + + +@api_view(["POST"]) +def bot_jwt_api_view(request: Request, pk: int): + """ + Get JWT tokens to impersonate a bot account. + Returns access and refresh tokens for the bot. + """ + + bot = get_object_or_404(get_user_bots(request.user), pk=pk) + tokens = get_tokens_for_user(bot) + + return Response(tokens)