diff --git a/pyproject.toml b/pyproject.toml index 003b426b0e..33b8ac0cf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,12 @@ dependencies = [ [project.optional-dependencies] ldap = ["python-ldap==3.4.5"] # optional for LDAP authentication, requires libldap (OpenLDAP) to build +allauth-mfa = [ + "django-allauth[mfa]", +] +allauth-social = [ + "django-allauth[socialaccount]", +] docs = ["sphinx_rtd_theme>=2.0.0"] [dependency-groups] diff --git a/python/nav/django/defaults.py b/python/nav/django/defaults.py new file mode 100644 index 0000000000..f028d7520c --- /dev/null +++ b/python/nav/django/defaults.py @@ -0,0 +1,14 @@ +PUBLIC_URLS = [ + '/api/', # No auth/different auth system + '/doc/', # No auth/different auth system + '/about/', + '/index/login/', + '/index/audit-logging-modal/', + '/refresh_session', + '/accounts/', + '/accounts/2fa/authenticate/', +] +NAV_LOGIN_URL = '/index/login/' +ALLAUTH_LOGIN_URL = '/accounts/login/' + +LOGIN_URL = ALLAUTH_LOGIN_URL diff --git a/python/nav/django/settings.py b/python/nav/django/settings.py index 4fea859d62..bb5972f684 100644 --- a/python/nav/django/settings.py +++ b/python/nav/django/settings.py @@ -137,6 +137,7 @@ 'nav.django.legacy.LegacyCleanupMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django_htmx.middleware.HtmxMiddleware', + 'allauth.account.middleware.AccountMiddleware', ) SESSION_SERIALIZER = 'nav.web.session_serializer.PickleSerializer' @@ -236,12 +237,21 @@ 'nav.portadmin.napalm', 'nav.web.portadmin', 'django.contrib.postgres', + 'allauth', + 'allauth.account', + 'allauth.mfa', + 'allauth.socialaccount', + # noqa: Needs to be a setting + 'allauth.socialaccount.providers.dataporten', ) DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' AUTH_USER_MODEL = 'nav_models.Account' -AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend'] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + "allauth.account.auth_backends.AuthenticationBackend", +] LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/index/login/' @@ -318,3 +328,17 @@ 'JWT_ISSUERS': _issuers_setting, 'JWT_AUTH_HEADER_PREFIX': 'Bearer', } + +# Allauth settings + +ACCOUNT_ADAPTER = "nav.web.auth.allauth.adapter.NAVAccountAdapter" +ACCOUNT_USER_MODEL_USERNAME_FIELD = 'login' +ACCOUNT_ALLOW_SIGNUPS = False +ACCOUNT_MAX_EMAIL_ADDRESSES = 1 +LOGIN_URL = '/accounts/login/' +MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True # allow localhost +MFA_TOTP_ISSUER = 'NAV' +MFA_TOTP_TOLERANCE = 1 +MFA_SUPPORTED_TYPES = ['totp', 'recovery_codes'] +SOCIALACCOUNT_AUTO_SIGNUP = True +SOCIALACCOUNT_ADAPTER = 'nav.web.auth.allauth.adapter.NAVSocialAccountAdapter' diff --git a/python/nav/django/urls.py b/python/nav/django/urls.py index 062899ffe2..e9ddca12b4 100644 --- a/python/nav/django/urls.py +++ b/python/nav/django/urls.py @@ -66,6 +66,7 @@ path('refresh_session/', refresh_session, name='refresh-session'), path('auditlog/', include('nav.auditlog.urls')), path('interfaces/', include('nav.web.interface_browser.urls')), + path('accounts/', include('allauth.urls')), path('500/', force_500), ] diff --git a/python/nav/models/sql/changes/sc.05.15.0503.sql b/python/nav/models/sql/changes/sc.05.15.0503.sql new file mode 100644 index 0000000000..3233008208 --- /dev/null +++ b/python/nav/models/sql/changes/sc.05.15.0503.sql @@ -0,0 +1,247 @@ +-- Tables for django-allauth[mfa,socialaccount] + +-- account_emailaddress + +CREATE TABLE profiles.account_emailaddress ( + id integer NOT NULL, + email character varying(254) NOT NULL, + verified boolean NOT NULL, + "primary" boolean NOT NULL, + user_id integer NOT NULL +); + + +ALTER TABLE profiles.account_emailaddress OWNER TO nav; + + +ALTER TABLE profiles.account_emailaddress ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.account_emailaddress_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.account_emailaddress + ADD CONSTRAINT account_emailaddress_pkey PRIMARY KEY (id); + + +ALTER TABLE ONLY profiles.account_emailaddress + ADD CONSTRAINT account_emailaddress_user_id_email_987c8728_uniq UNIQUE (user_id, email); + + +CREATE INDEX account_emailaddress_email_03be32b2 ON profiles.account_emailaddress USING btree (email); + + +CREATE INDEX account_emailaddress_email_03be32b2_like ON profiles.account_emailaddress USING btree (email varchar_pattern_ops); + + +CREATE INDEX account_emailaddress_user_id_2c513194 ON profiles.account_emailaddress USING btree (user_id); + + +CREATE UNIQUE INDEX unique_primary_email ON profiles.account_emailaddress USING btree (user_id, "primary") WHERE "primary"; + + +CREATE UNIQUE INDEX unique_verified_email ON profiles.account_emailaddress USING btree (email) WHERE verified; + + +ALTER TABLE ONLY profiles.account_emailaddress + ADD CONSTRAINT account_emailaddress_user_id_2c513194_fk_account_id FOREIGN KEY (user_id) REFERENCES profiles.account(id) DEFERRABLE INITIALLY DEFERRED; + +-- account_emailconfirmation + +CREATE TABLE profiles.account_emailconfirmation ( + id integer NOT NULL, + created timestamp with time zone NOT NULL, + sent timestamp with time zone, + key character varying(64) NOT NULL, + email_address_id integer NOT NULL +); + + +ALTER TABLE profiles.account_emailconfirmation OWNER TO nav; + + +ALTER TABLE profiles.account_emailconfirmation ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.account_emailconfirmation_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.account_emailconfirmation + ADD CONSTRAINT account_emailconfirmation_key_key UNIQUE (key); + + +ALTER TABLE ONLY profiles.account_emailconfirmation + ADD CONSTRAINT account_emailconfirmation_pkey PRIMARY KEY (id); + + +CREATE INDEX account_emailconfirmation_email_address_id_5b7f8c58 ON profiles.account_emailconfirmation USING btree (email_address_id); + + +CREATE INDEX account_emailconfirmation_key_f43612bd_like ON profiles.account_emailconfirmation USING btree (key varchar_pattern_ops); + + +ALTER TABLE ONLY profiles.account_emailconfirmation + ADD CONSTRAINT account_emailconfirm_email_address_id_5b7f8c58_fk_account_e FOREIGN KEY (email_address_id) REFERENCES profiles.account_emailaddress(id) DEFERRABLE INITIALLY DEFERRED; + +-- mfa_authenticator + +CREATE TABLE profiles.mfa_authenticator ( + id bigint NOT NULL, + type character varying(20) NOT NULL, + data jsonb NOT NULL, + user_id integer NOT NULL, + created_at timestamp with time zone NOT NULL, + last_used_at timestamp with time zone +); + + +ALTER TABLE profiles.mfa_authenticator OWNER TO nav; + + +ALTER TABLE profiles.mfa_authenticator ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.mfa_authenticator_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.mfa_authenticator + ADD CONSTRAINT mfa_authenticator_pkey PRIMARY KEY (id); + + +CREATE INDEX mfa_authenticator_user_id_0c3a50c0 ON profiles.mfa_authenticator USING btree (user_id); + + +CREATE UNIQUE INDEX unique_authenticator_type ON profiles.mfa_authenticator USING btree (user_id, type) WHERE ((type)::text = ANY ((ARRAY['totp'::character varying, 'recovery_codes'::character varying])::text[])); + + +ALTER TABLE ONLY profiles.mfa_authenticator + ADD CONSTRAINT mfa_authenticator_user_id_0c3a50c0_fk_account_id FOREIGN KEY (user_id) REFERENCES profiles.account(id) DEFERRABLE INITIALLY DEFERRED; + +-- socialaccount_socialaccount + +CREATE TABLE profiles.socialaccount_socialaccount ( + id integer NOT NULL, + provider character varying(200) NOT NULL, + uid character varying(191) NOT NULL, + last_login timestamp with time zone NOT NULL, + date_joined timestamp with time zone NOT NULL, + extra_data jsonb NOT NULL, + user_id integer NOT NULL +); + + +ALTER TABLE profiles.socialaccount_socialaccount OWNER TO nav; + + +ALTER TABLE profiles.socialaccount_socialaccount ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.socialaccount_socialaccount_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.socialaccount_socialaccount + ADD CONSTRAINT socialaccount_socialaccount_pkey PRIMARY KEY (id); + + +ALTER TABLE ONLY profiles.socialaccount_socialaccount + ADD CONSTRAINT socialaccount_socialaccount_provider_uid_fc810c6e_uniq UNIQUE (provider, uid); + + +CREATE INDEX socialaccount_socialaccount_user_id_8146e70c ON profiles.socialaccount_socialaccount USING btree (user_id); + + +ALTER TABLE ONLY profiles.socialaccount_socialaccount + ADD CONSTRAINT socialaccount_social_user_id_8146e70c_fk_account FOREIGN KEY (user_id) REFERENCES profiles.account(id) DEFERRABLE INITIALLY DEFERRED; + +-- socialaccount_socialapp + +CREATE TABLE profiles.socialaccount_socialapp ( + id integer NOT NULL, + provider character varying(30) NOT NULL, + name character varying(40) NOT NULL, + client_id character varying(191) NOT NULL, + secret character varying(191) NOT NULL, + key character varying(191) NOT NULL, + provider_id character varying(200) NOT NULL, + settings jsonb NOT NULL +); + + +ALTER TABLE profiles.socialaccount_socialapp OWNER TO nav; + + +ALTER TABLE profiles.socialaccount_socialapp ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.socialaccount_socialapp_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.socialaccount_socialapp + ADD CONSTRAINT socialaccount_socialapp_pkey PRIMARY KEY (id); + +-- socialaccount_socialapp + + +CREATE TABLE profiles.socialaccount_socialtoken ( + id integer NOT NULL, + token text NOT NULL, + token_secret text NOT NULL, + expires_at timestamp with time zone, + account_id integer NOT NULL, + app_id integer +); + + +ALTER TABLE profiles.socialaccount_socialtoken OWNER TO nav; + + +ALTER TABLE profiles.socialaccount_socialtoken ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME profiles.socialaccount_socialtoken_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +ALTER TABLE ONLY profiles.socialaccount_socialtoken + ADD CONSTRAINT socialaccount_socialtoken_app_id_account_id_fca4e0ac_uniq UNIQUE (app_id, account_id); + + +ALTER TABLE ONLY profiles.socialaccount_socialtoken + ADD CONSTRAINT socialaccount_socialtoken_pkey PRIMARY KEY (id); + + +CREATE INDEX socialaccount_socialtoken_account_id_951f210e ON profiles.socialaccount_socialtoken USING btree (account_id); + + +CREATE INDEX socialaccount_socialtoken_app_id_636a42d7 ON profiles.socialaccount_socialtoken USING btree (app_id); + + +ALTER TABLE ONLY profiles.socialaccount_socialtoken + ADD CONSTRAINT socialaccount_social_account_id_951f210e_fk_socialacc FOREIGN KEY (account_id) REFERENCES profiles.socialaccount_socialaccount(id) DEFERRABLE INITIALLY DEFERRED; + + +ALTER TABLE ONLY profiles.socialaccount_socialtoken + ADD CONSTRAINT socialaccount_social_app_id_636a42d7_fk_socialacc FOREIGN KEY (app_id) REFERENCES profiles.socialaccount_socialapp(id) DEFERRABLE INITIALLY DEFERRED; diff --git a/python/nav/web/auth/__init__.py b/python/nav/web/auth/__init__.py index 8c4cb14ca9..768aa58a41 100644 --- a/python/nav/web/auth/__init__.py +++ b/python/nav/web/auth/__init__.py @@ -26,6 +26,7 @@ from django.http import HttpRequest from django.urls import reverse +from nav.django.defaults import LOGIN_URL from nav.auditlog.models import LogEntry from nav.models.profiles import Account, AccountGroup from nav.web.auth import ldap, remote_user @@ -42,7 +43,7 @@ # This may seem like redundant information, but it seems django's reverse # will hang under some usages of these middleware classes - so until we figure # out what's going on, we'll hardcode this here. -LOGIN_URL = '/index/login/' +# LOGIN_URL = '/accounts/login/' # The local logout url, redirects to '/' after logout # If the entire site is protected via remote_user, this link must be outside # that protection! diff --git a/python/nav/web/auth/allauth/__init__.py b/python/nav/web/auth/allauth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/nav/web/auth/allauth/adapter.py b/python/nav/web/auth/allauth/adapter.py new file mode 100644 index 0000000000..cbbc754dc1 --- /dev/null +++ b/python/nav/web/auth/allauth/adapter.py @@ -0,0 +1,26 @@ +from django.conf import settings + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount import app_settings + + +class NAVAccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + """ + Whether to allow sign ups with username/password + """ + allow_signups = super().is_open_for_signup(request) + # Override with setting, otherwise default to super. + return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups) + + +class NAVSocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request, sociallogin): + """ + Whether to allow sign ups via social account + """ + if app_settings.AUTO_SIGNUP: + return True + return get_account_adapter(request).is_open_for_signup(request) diff --git a/python/nav/web/auth/utils.py b/python/nav/web/auth/utils.py index 7b526cc28c..ae6504c3c6 100644 --- a/python/nav/web/auth/utils.py +++ b/python/nav/web/auth/utils.py @@ -24,6 +24,7 @@ from django.contrib.auth import get_user as django_get_user from django.core.cache import cache +from nav.django.defaults import PUBLIC_URLS from nav.models.profiles import Account @@ -103,15 +104,7 @@ def authorization_not_required(fullpath): Should the user be able to decide this? Currently not. """ - auth_not_required = [ - '/api/', - '/doc/', # No auth/different auth system - '/about/', - '/index/login/', - '/index/audit-logging-modal/', - '/refresh_session', - ] - for url in auth_not_required: + for url in PUBLIC_URLS: if fullpath.startswith(url): _logger.debug('authorization_not_required: %s', url) return True diff --git a/python/nav/web/templates/allauth/layouts/base.html b/python/nav/web/templates/allauth/layouts/base.html new file mode 100644 index 0000000000..54f25717c3 --- /dev/null +++ b/python/nav/web/templates/allauth/layouts/base.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% load socialaccount %} +{% load i18n %} +{% block base_content %} +{% if request.user.is_authenticated %} + +{% endif %} +
+
+ {% block content %} + {% endblock content %} +
+
+{% endblock base_content %} diff --git a/requirements/base.txt b/requirements/base.txt index a82ca170a7..2454f86c1f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -47,3 +47,5 @@ service-identity==21.1.0 requests pyjwt>=2.6.0 + +django-allauth[mfa,socialaccount] diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 0c1a232496..1f1966d462 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -1,6 +1,8 @@ import os import subprocess +from nav.django.default import NAV_LOGIN_URL as LOGIN_URL + import pytest from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -45,7 +47,7 @@ def selenium(selenium, base_url, admin_username, admin_password): wait = WebDriverWait(selenium, 10) # visit the login page and submit the login form - selenium.get(f"{base_url}/index/login") + selenium.get(f"{base_url}/{LOGIN_URL}") wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, "label"), "Username")) username = selenium.find_element(By.ID, "id_username") diff --git a/tests/integration/web/crawler_test.py b/tests/integration/web/crawler_test.py index 0b45837beb..d58de0aa9d 100644 --- a/tests/integration/web/crawler_test.py +++ b/tests/integration/web/crawler_test.py @@ -35,6 +35,8 @@ urlunparse, ) +from nav.django.settings import LOGIN_URL + TIMEOUT = 90 # seconds? @@ -165,7 +167,7 @@ def _queue_links_from(self, content, base_url): self.queue.append('%s://%s%s' % (url.scheme, url.netloc, url.path)) def login(self): - login_url = urljoin(self.base_url, '/index/login/') + login_url = urljoin(self.base_url, LOGIN_URL) opener = build_opener(HTTPCookieProcessor()) data = urlencode({'username': self.username, 'password': self.password}) opener.open(login_url, data.encode('utf-8'), TIMEOUT)