diff --git a/app/api/api/urls.py b/app/api/api/urls.py index 9830059ae..cd2ea28b5 100644 --- a/app/api/api/urls.py +++ b/app/api/api/urls.py @@ -98,9 +98,9 @@ name="scan-report-values", ), path(r"user/me/", views.UserDetailView.as_view(), name="currentuser"), - path(r"v2/users", views.UserViewSet.as_view(), name="users-list"), - path(r"v2/usersfilter", views.UserFilterViewSet.as_view(), name="usersfilter"), - path(r"v2/datapartners", views.DataPartnerViewSet.as_view(), name="datapartners"), + path(r"v2/users/", views.UserViewSet.as_view(), name="users-list"), + path(r"v2/usersfilter/", views.UserFilterViewSet.as_view(), name="usersfilter"), + path(r"v2/datapartners/", views.DataPartnerViewSet.as_view(), name="datapartners"), path( r"v2/omop/conceptsfilter", views.ConceptFilterViewSetV2.as_view(), diff --git a/app/api/authn/__init__.py b/app/api/authn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/api/authn/apps.py b/app/api/authn/apps.py new file mode 100644 index 000000000..a5d262325 --- /dev/null +++ b/app/api/authn/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthnConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "authn" diff --git a/app/api/authn/urls.py b/app/api/authn/urls.py new file mode 100644 index 000000000..65644cdf0 --- /dev/null +++ b/app/api/authn/urls.py @@ -0,0 +1,16 @@ +from dj_rest_auth.jwt_auth import get_refresh_view +from dj_rest_auth.registration.views import RegisterView +from dj_rest_auth.views import LoginView, LogoutView, UserDetailsView +from django.urls import path +from django.views.decorators.csrf import get_token +from rest_framework_simplejwt.views import TokenVerifyView + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="rest_register"), + path("login/", LoginView.as_view(), name="rest_login"), + path("logout/", LogoutView.as_view(), name="rest_logout"), + path("user/", UserDetailsView.as_view(), name="rest_user_details"), + path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path("token/refresh/", get_refresh_view().as_view(), name="token_refresh"), + path("csrf-token/", get_token, name="api-csrf-token"), +] diff --git a/app/api/config/settings.py b/app/api/config/settings.py index 03d09fa11..ec4957dac 100644 --- a/app/api/config/settings.py +++ b/app/api/config/settings.py @@ -11,6 +11,7 @@ """ import os +from datetime import timedelta from dotenv import load_dotenv @@ -50,6 +51,7 @@ # Application definition INSTALLED_APPS = [ + "django.contrib.sites", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -68,7 +70,12 @@ "rest_framework.authtoken", "corsheaders", "test", - "revproxy", + "authn.apps.AuthnConfig", + "rest_framework_simplejwt", + "allauth", + "allauth.account", + "dj_rest_auth", + "dj_rest_auth.registration", "shared", "shared.files", "shared.jobs", @@ -81,11 +88,15 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "allauth.account.middleware.AccountMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", ] +CSRF_TRUSTED_ORIGINS = [os.environ.get("NEXTJS_URL", "http://localhost:3000")] +SITE_ID = 1 + STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" ROOT_URLCONF = "config.urls" @@ -178,6 +189,7 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), } @@ -202,3 +214,24 @@ AZ_RULES_NAME = os.environ.get("AZ_RULES_NAME", "RulesOrchestrator") AZ_RULES_KEY = os.environ.get("AZ_RULES_KEY", "") AZ_RULES_EXPORT_QUEUE = os.environ.get("AZ_RULES_EXPORT_QUEUE", "rules-exports-local") + +# Auth + +ACCOUNT_EMAIL_REQUIRED = False +ACCOUNT_EMAIL_VERIFICATION = "none" + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": True, + "SIGNING_KEY": os.getenv("SIGNING_KEY"), + "ALGORITHM": "HS512", + "AUTH_HEADER_TYPES": ("JWT",), +} + +REST_AUTH = { + "USE_JWT": True, + "JWT_AUTH_HTTPONLY": False, +} diff --git a/app/api/config/urls.py b/app/api/config/urls.py index 2e3cbcfd2..b08b58e86 100644 --- a/app/api/config/urls.py +++ b/app/api/config/urls.py @@ -1,21 +1,8 @@ -import os - -from config import settings from django.contrib import admin -from django.urls import include, path, re_path -from revproxy.views import ProxyView # type: ignore +from django.urls import include, path urlpatterns = [ path("api/", include("api.urls")), - path("api_auth/", include("rest_framework.urls", namespace="rest_framework")), + path("api/auth/", include("authn.urls")), path("admin/", admin.site.urls), - path("accounts/", include("django.contrib.auth.urls")), - ( - re_path(r"(?P.*)", ProxyView.as_view(upstream=f"{settings.NEXTJS_URL}/")) - if os.environ.get("ENABLE_PROXY", "False").lower() == "true" - else None - ), - path("", include("shared.mapping.urls")), ] - -urlpatterns = [url for url in urlpatterns if url is not None] diff --git a/app/api/poetry.lock b/app/api/poetry.lock index 2bebc3ec2..70ac92f76 100644 --- a/app/api/poetry.lock +++ b/app/api/poetry.lock @@ -382,6 +382,17 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "deprecated" version = "1.2.14" @@ -399,6 +410,24 @@ wrapt = ">=1.10,<2" [package.extras] dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] +[[package]] +name = "dj-rest-auth" +version = "6.0.0" +description = "Authentication and Registration in Django Rest Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "dj-rest-auth-6.0.0.tar.gz", hash = "sha256:760b45f3a07cd6182e6a20fe07d0c55230c5f950167df724d7914d0dd8c50133"}, +] + +[package.dependencies] +Django = ">=3.2,<6.0" +django-allauth = {version = ">=0.56.0,<0.62.0", optional = true, markers = "extra == \"with_social\""} +djangorestframework = ">=3.13.0" + +[package.extras] +with-social = ["django-allauth (>=0.56.0,<0.62.0)"] + [[package]] name = "django" version = "4.2.15" @@ -419,6 +448,27 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-allauth" +version = "0.61.1" +description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." +optional = false +python-versions = ">=3.7" +files = [ + {file = "django-allauth-0.61.1.tar.gz", hash = "sha256:5b4ae515ea74f54f0041210692eee10c309ad15ddbbd03d3620693c75e3f7945"}, +] + +[package.dependencies] +Django = ">=3.2" +pyjwt = {version = ">=1.7", extras = ["crypto"]} +python3-openid = ">=3.0.8" +requests = ">=2.0.0" +requests-oauthlib = ">=0.3.0" + +[package.extras] +mfa = ["qrcode (>=7.0.0)"] +saml = ["python3-saml (>=1.15.0,<2.0.0)"] + [[package]] name = "django-appconf" version = "1.0.6" @@ -492,24 +542,6 @@ djangorestframework-stubs = ">=0.4.0" mypy = ">=0.750" typing-extensions = ">=3.7.4" -[[package]] -name = "django-revproxy" -version = "0.12.0" -description = "Yet another Django reverse proxy application" -optional = false -python-versions = ">=3.7" -files = [ - {file = "django_revproxy-0.12.0-py3-none-any.whl", hash = "sha256:bd8e735414783477e18a79b3bb1e2180e0c60d412cd2bfdcf34d8898406b26e4"}, -] - -[package.dependencies] -Django = ">=3.0" -urllib3 = ">=1.12" - -[package.extras] -diazo = ["diazo (>=1.0.5)", "lxml (>=3.4)"] -tests = ["coverage", "diazo", "flake8", "lxml (>=3.4)"] - [[package]] name = "django-select2" version = "8.1.2" @@ -582,6 +614,30 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-simplejwt" +version = "5.3.1" +description = "A minimal JSON Web Token authentication plugin for Django REST Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "djangorestframework_simplejwt-5.3.1-py3-none-any.whl", hash = "sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220"}, + {file = "djangorestframework_simplejwt-5.3.1.tar.gz", hash = "sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae"}, +] + +[package.dependencies] +django = ">=3.2" +djangorestframework = ">=3.12" +pyjwt = ">=1.7.1,<3" + +[package.extras] +crypto = ["cryptography (>=3.3.1)"] +dev = ["Sphinx (>=1.6.5,<2)", "cryptography", "flake8", "freezegun", "ipython", "isort", "pep8", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx_rtd_theme (>=0.1.9)"] +lint = ["flake8", "isort", "pep8"] +python-jose = ["python-jose (==3.3.0)"] +test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] + [[package]] name = "djangorestframework-stubs" version = "3.15.0" @@ -1291,6 +1347,26 @@ files = [ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +[[package]] +name = "pyjwt" +version = "2.9.0" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850"}, + {file = "pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pytest" version = "8.1.1" @@ -1343,6 +1419,24 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python3-openid" +version = "3.2.0" +description = "OpenID support for modern servers and consumers." +optional = false +python-versions = "*" +files = [ + {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, + {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, +] + +[package.dependencies] +defusedxml = "*" + +[package.extras] +mysql = ["mysql-connector-python"] +postgresql = ["psycopg2"] + [[package]] name = "requests" version = "2.31.0" @@ -1618,4 +1712,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d919c6553de3a3efb37c8b21678483278ddedefe3ae9a630f11bbf61c82f85b6" +content-hash = "cbd781c53ab372d3a08c2fda6296dec8fc32615f87659bb9147498f0ea6cc875" diff --git a/app/api/pyproject.toml b/app/api/pyproject.toml index f7e51f86f..0ed7a75b0 100644 --- a/app/api/pyproject.toml +++ b/app/api/pyproject.toml @@ -24,9 +24,11 @@ graphviz = "^0.20.3" drf-dynamic-fields = "^0.4.0" django-cors-headers = "^4.4.0" python-dotenv = "^1.0.1" -django-revproxy = "^0.12.0" shared = {path = "../shared", develop = true} azure-monitor-opentelemetry = "^1.6.0" +djangorestframework-simplejwt = "^5.3.1" +django-allauth = "0.61.1" +dj-rest-auth = {extras = ["with-social"], version = "^6.0.0"} [tool.poetry.group.test.dependencies] pytest-django = "^4.8.0" diff --git a/app/next-client-app/app/(protected)/layout.tsx b/app/next-client-app/app/(protected)/layout.tsx index af2422981..813750b58 100644 --- a/app/next-client-app/app/(protected)/layout.tsx +++ b/app/next-client-app/app/(protected)/layout.tsx @@ -1,6 +1,7 @@ import "react-tooltip/dist/react-tooltip.css"; import React from "react"; -import { getCurrentUser } from "@/api/users"; +import { getServerSession } from "next-auth"; +import { options } from "@/auth/options"; import { MenuBar } from "@/components/core/menubar"; export default async function ProtectedLayout({ @@ -8,7 +9,8 @@ export default async function ProtectedLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const user = await getCurrentUser(); + const session = await getServerSession(options); + const user = session?.token?.user; return ( <> diff --git a/app/next-client-app/app/(public)/accounts/login/page.tsx b/app/next-client-app/app/(public)/accounts/login/page.tsx new file mode 100644 index 000000000..af8c3598f --- /dev/null +++ b/app/next-client-app/app/(public)/accounts/login/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Alert } from "@/components/ui/alert"; + +export default function SignIn() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await signIn("credentials", { + redirect: false, + username, + password, + }); + if (result?.ok) { + router.push("/projects"); + } else { + setError("Login failed. Please check your credentials."); + } + }; + + return ( +
+
+

+ Sign In +

+ + {error && ( + + Login failed. Please check your credentials. + + )} + +
+
+ + setUsername(e.target.value)} + required + placeholder="Enter your username" + /> +
+
+ + setPassword(e.target.value)} + required + placeholder="Enter your password" + /> +
+ +
+
+
+ ); +} diff --git a/app/next-client-app/app/(public)/layout.tsx b/app/next-client-app/app/(public)/layout.tsx index 2e027a211..c901560b2 100644 --- a/app/next-client-app/app/(public)/layout.tsx +++ b/app/next-client-app/app/(public)/layout.tsx @@ -1,14 +1,16 @@ import React from "react"; import { MenuBar } from "@/components/core/menubar"; import Footer from "@/components/core/footer"; -import { getCurrentUser } from "@/api/users"; +import { getServerSession } from "next-auth"; +import { options } from "@/auth/options"; export default async function PublicLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const user = await getCurrentUser(); + const session = await getServerSession(options); + const user = session?.token?.user; return ( <> diff --git a/app/next-client-app/app/(public)/page.tsx b/app/next-client-app/app/(public)/page.tsx index de417ca91..1fd57af2e 100644 --- a/app/next-client-app/app/(public)/page.tsx +++ b/app/next-client-app/app/(public)/page.tsx @@ -17,7 +17,7 @@ export default function Default() { repeatDelay={1} className={cn( "[mask-image:radial-gradient(800px_circle_at_center,white,transparent)]", - "inset-x-0 inset-y-[-30%] h-full skew-y-12 mt-[200px] overflow-hidden" + "inset-x-0 inset-y-[-30%] h-full skew-y-12 mt-[200px] overflow-hidden", )} />{" "} {/* Content */} diff --git a/app/next-client-app/app/api/auth/[...nextauth]/route.ts b/app/next-client-app/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..ebe1a3425 --- /dev/null +++ b/app/next-client-app/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from "next-auth"; + +import { options } from "@/auth/options"; + +const handler = NextAuth(options); + +export { handler as GET, handler as POST }; diff --git a/app/next-client-app/auth/login.tsx b/app/next-client-app/auth/login.tsx new file mode 100644 index 000000000..b4f9a5e13 --- /dev/null +++ b/app/next-client-app/auth/login.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { signIn, signOut } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { LogIn } from "lucide-react"; + +export const LoginButton = () => { + return ( + + ); +}; + +export const LogoutButton = () => { + return ( + + ); +}; diff --git a/app/next-client-app/auth/options.ts b/app/next-client-app/auth/options.ts new file mode 100644 index 000000000..897d36af4 --- /dev/null +++ b/app/next-client-app/auth/options.ts @@ -0,0 +1,119 @@ +import { NextAuthOptions, Session } from "next-auth"; +import { JWT } from "next-auth/jwt"; +import CredentialsProvider from "next-auth/providers/credentials"; + +// These two values should be a bit less than actual token lifetimes +const BACKEND_ACCESS_TOKEN_LIFETIME = 45 * 60; // 45 minutes +const BACKEND_REFRESH_TOKEN_LIFETIME = 6 * 24 * 60 * 60; // 6 days + +const getCurrentEpochTime = () => { + return Math.floor(new Date().getTime() / 1000); +}; + +export const options: NextAuthOptions = { + secret: process.env.AUTH_SECRET, + session: { + strategy: "jwt", + maxAge: BACKEND_REFRESH_TOKEN_LIFETIME, + }, + pages: { + signIn: "/accounts/login", + }, + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + }, + // The data returned from this function is passed forward as the + // `user` variable to the signIn() and jwt() callback + async authorize(credentials, req) { + try { + const response = await fetch( + process.env.NEXTAUTH_BACKEND_URL + "auth/login/", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(credentials), + }, + ); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + return await response.json(); + } catch (error) { + console.error(error); + } + return null; + }, + }), + ], + callbacks: { + async jwt({ user, token, account }) { + // If `user` and `account` are set that means it is a login event + if (user && account) { + let backendResponse: any = + account.provider === "credentials" ? user : account.meta; + token.user = backendResponse.user; + token.access_token = backendResponse.access; + token.refresh_token = backendResponse.refresh; + token.expires_at = + getCurrentEpochTime() + BACKEND_ACCESS_TOKEN_LIFETIME; + return token; + } + // Refresh the backend token if necessary + // TODO: move this out to a function above. + if (getCurrentEpochTime() > token.expires_at) { + try { + const response = await fetch( + process.env.NEXTAUTH_BACKEND_URL + "auth/token/refresh/", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + refresh: token.refresh_token, + }), + }, + ); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const responseData = await response.json(); + token.access_token = responseData.access; + token.refresh_token = responseData.refresh; + token.expires_at = + getCurrentEpochTime() + BACKEND_ACCESS_TOKEN_LIFETIME; + } catch (error) { + console.error(error); + } + } + return token; + }, + // Since we're using Django as the backend we have to pass the JWT + // token to the client instead of the `session`. + async session({ + session, + token, + }: { + session: Session; + token: JWT; + }): Promise { + if (session) { + session = Object.assign({}, session, { + token: token, + access_token: token.access_token, + }); + } + return session; + }, + }, +}; diff --git a/app/next-client-app/components/core/UserMenu.tsx b/app/next-client-app/components/core/UserMenu.tsx new file mode 100644 index 000000000..6e26b4e69 --- /dev/null +++ b/app/next-client-app/components/core/UserMenu.tsx @@ -0,0 +1,57 @@ +import { LogOut, Settings } from "lucide-react"; + +import { LoginButton, LogoutButton } from "@/auth/login"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +export async function UserMenu({ username }: { username?: string }) { + if (!username) { + return ; + } + + const initials = + username + ?.split(" ") + .map((word) => word[0].toUpperCase()) + .join("") ?? ""; + + return ( + + + + + + My Account + + {/* TODO: Add change password here */} + {/* + + + Change Password + ⌘S + + + */} + + + + + + + + ); +} diff --git a/app/next-client-app/components/core/menuItems.tsx b/app/next-client-app/components/core/menuItems.tsx index 3c9048b68..c64cd8fe0 100644 --- a/app/next-client-app/components/core/menuItems.tsx +++ b/app/next-client-app/components/core/menuItems.tsx @@ -49,10 +49,5 @@ export const sidebarItems: SidebarItems = { href: "https://github.com/Health-Informatics-UoN/Carrot-Mapper", icon: Github, }, - { - label: "Login", - href: "/accounts/login/", - icon: LogIn, - }, ], }; diff --git a/app/next-client-app/components/core/menubar.tsx b/app/next-client-app/components/core/menubar.tsx index a834fde04..7796e2964 100644 --- a/app/next-client-app/components/core/menubar.tsx +++ b/app/next-client-app/components/core/menubar.tsx @@ -3,12 +3,9 @@ import { SidebarButton } from "./sidebar-button"; import { sidebarItems } from "./menuItems"; import { Sidebar } from "./sidebar"; import { ModeToggle } from "./mode-toggle"; -import { CircleUserRound, LogOut, Settings } from "lucide-react"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { Separator } from "../ui/separator"; -import { Button } from "../ui/button"; +import { UserMenu } from "@/components/core/UserMenu"; -export const MenuBar = ({ user }: { user: User | null }) => { +export const MenuBar = ({ user }: { user?: User | null }) => { return ( <> @@ -33,45 +30,9 @@ export const MenuBar = ({ user }: { user: User | null }) => { {link.label} - ) - )} - {user && ( -
- - -
- -
-
- -
-
- Hi, {user.username} -
- - - - - - - -
-
-
-
+ ), )} +
diff --git a/app/next-client-app/components/core/sidebar.tsx b/app/next-client-app/components/core/sidebar.tsx index be47e9ca0..1e33cb058 100644 --- a/app/next-client-app/components/core/sidebar.tsx +++ b/app/next-client-app/components/core/sidebar.tsx @@ -63,7 +63,7 @@ export function Sidebar({ userName }: { userName?: string }) { {link.label} - ) + ), )}
{userName && ( diff --git a/app/next-client-app/components/ui/avatar.tsx b/app/next-client-app/components/ui/avatar.tsx new file mode 100644 index 000000000..00c3ae83d --- /dev/null +++ b/app/next-client-app/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/app/next-client-app/lib/api/request.ts b/app/next-client-app/lib/api/request.ts index 96c13b680..60d507c20 100644 --- a/app/next-client-app/lib/api/request.ts +++ b/app/next-client-app/lib/api/request.ts @@ -1,6 +1,7 @@ import { apiUrl as apiUrl } from "@/constants"; import { ApiError } from "./error"; -import { cookies } from "next/headers"; +import { getServerSession } from "next-auth"; +import { options as authOptions } from "@/auth/options"; interface RequestOptions { method?: string; @@ -12,15 +13,12 @@ interface RequestOptions { } const request = async (url: string, options: RequestOptions = {}) => { - // Auth with Django session - const cookieStore = cookies(); - const session = cookieStore.get("sessionid")?.value; - const csrftoken = cookieStore.get("csrftoken")?.value; + // Auth with Django + const session = await getServerSession(authOptions); + const token = session?.access_token; const headers: HeadersInit = { - Cookie: `sessionid=${session}; csrftoken=${csrftoken}`, - "X-CSRFToken": csrftoken ?? "", - Referer: process.env.BACKEND_URL ?? "", + Authorization: `JWT ${token}`, ...(options.headers || {}), }; diff --git a/app/next-client-app/middleware.ts b/app/next-client-app/middleware.ts index cd042fa03..5ce04393f 100644 --- a/app/next-client-app/middleware.ts +++ b/app/next-client-app/middleware.ts @@ -1,16 +1,4 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export async function middleware(request: NextRequest) { - // Redirect logged out users. - let session = request.cookies.get("sessionid"); - let csrfToken = request.cookies.get("csrftoken"); - if (!session || !csrfToken) { - return NextResponse.redirect(new URL("/accounts/login/", request.url)); - } - - return NextResponse.next(); -} +export { default } from "next-auth/middleware"; export const config = { matcher: [ @@ -18,11 +6,12 @@ export const config = { * Match all paths except for: * 1. / index page * 2. /api routes - * 3. /_next (Next.js internals) - * 4. /_static (inside /public) - * 5. all root files inside /public (e.g. /favicon.ico) - * 6. folder containing logos inside "public" + * 3. /accounts/login + * 4. /_next (Next.js internals) + * 5. /_static (inside /public) + * 6. all root files inside /public (e.g. /favicon.ico) + * 7. folder containing logos inside "public" */ - "/((?!$|api/|_next/|_static/|logos|[\\w-]+\\.\\w+).*)", + "/((?!$|api/|accounts/login|_next/|_static/|logos|[\\w-]+\\.\\w+).*)", ], }; diff --git a/app/next-client-app/package-lock.json b/app/next-client-app/package-lock.json index 349202679..6cd178caa 100644 --- a/app/next-client-app/package-lock.json +++ b/app/next-client-app/package-lock.json @@ -8,6 +8,7 @@ "name": "next-client-app", "version": "0.1.0", "dependencies": { + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -31,6 +32,7 @@ "lucide": "^0.414.0", "lucide-react": "^0.367.0", "next": "14.1.4", + "next-auth": "^4.24.7", "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", @@ -1481,6 +1483,14 @@ "node": ">= 8" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1522,6 +1532,45 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-checkbox": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz", @@ -3708,6 +3757,14 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5883,6 +5940,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6306,6 +6371,33 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.7", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", + "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "next": "^12.2.5 || ^13 || ^14", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-themes": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", @@ -6424,6 +6516,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6558,6 +6655,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6581,6 +6686,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6972,6 +7115,26 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/preact": { + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz", + "integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6981,6 +7144,11 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8530,6 +8698,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vaul": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz", diff --git a/app/next-client-app/package.json b/app/next-client-app/package.json index ed90adbb0..e643cab5a 100644 --- a/app/next-client-app/package.json +++ b/app/next-client-app/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -32,6 +33,7 @@ "lucide": "^0.414.0", "lucide-react": "^0.367.0", "next": "14.1.4", + "next-auth": "^4.24.7", "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", diff --git a/app/next-client-app/types/next-auth.d.ts b/app/next-client-app/types/next-auth.d.ts new file mode 100644 index 000000000..ea31bae25 --- /dev/null +++ b/app/next-client-app/types/next-auth.d.ts @@ -0,0 +1,30 @@ +import NextAuth from "next-auth"; +import { Session } from "next-auth"; + +import { Permission } from "@/lib/auth"; + +declare module "next-auth" { + /** + * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context + */ + interface Session extends Session { + token: JWT; + access_token: string; + user: { + pk: number; + username: string; + email: string | null; + }; + } +} + +declare module "next-auth/jwt" { + /** Returned by the `jwt` callback and `getToken`, when using JWT sessions */ + interface JWT { + id_token: string; + access_token: string; + permissions: Permission[]; + expires_at: number; + refresh_token: string; + } +}