diff --git a/frontend/public/config.json b/frontend/public/config.json index ce94d8db3..29d53a3df 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -1,4 +1,7 @@ { - "API_BASE_URL": "/api/v2/", - "API_DOC_URL": "/api/schema-v2/swagger/" + "API_BASE_URL": "/api/v2/", + "API_DOC_URL": "/api/schema-v2/swagger/", + "OAUTH_AUTHORITY_URL": "http://localhost:8000/api/o", + "OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD", + "OAUTH_REDIRECT_URI": "http://localhost:3000/login/logged" } diff --git a/frontend/src/layouts/dashboard/NavBar.vue b/frontend/src/layouts/dashboard/NavBar.vue index 0b4f7b5c8..f9bebedf6 100644 --- a/frontend/src/layouts/dashboard/NavBar.vue +++ b/frontend/src/layouts/dashboard/NavBar.vue @@ -272,7 +272,7 @@ const userMenuItems = [ }, ] -if (authUser.value.role !== 'SimpleUsers') { +if (isAuthenticated && authUser.value.role !== 'SimpleUsers') { userMenuItems.unshift({ text: $gettext('Admin'), to: { name: 'Dashboard' }, diff --git a/frontend/src/main.js b/frontend/src/main.js index ca186bb61..0c6cb6f23 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -16,11 +16,14 @@ import { createApp } from 'vue' //api import repository from '@/api/repository.js' +let globalConfig + fetch(import.meta.env.BASE_URL + 'config.json').then((resp) => { resp.json().then((config) => { if (!config.API_BASE_URL) { throw Error('API_BASE_URL is not defined in config.json') } + globalConfig = config repository.defaults.baseURL = config.API_BASE_URL const app = createApp(App) @@ -32,3 +35,5 @@ fetch(import.meta.env.BASE_URL + 'config.json').then((resp) => { app.provide('$config', config) }) }) + +export const useGlobalConfig = () => globalConfig diff --git a/frontend/src/stores/auth.store.js b/frontend/src/stores/auth.store.js index 0919efa1b..4ea1dd199 100644 --- a/frontend/src/stores/auth.store.js +++ b/frontend/src/stores/auth.store.js @@ -10,13 +10,16 @@ import repository from '@/api/repository' import accountApi from '@/api/account' import accountsApi from '@/api/accounts' +import { useGlobalConfig } from '@/main' + export const useAuthStore = defineStore('auth', () => { + const config = useGlobalConfig() const authUser = ref(null) const isAuthenticated = ref(false) const manager = new UserManager({ - authority: 'http://localhost:8000/api/o', - client_id: 'LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD', - redirect_uri: 'http://localhost:3000/login/logged', + authority: config.OAUTH_AUTHORITY_URL, + client_id: config.OAUTH_CLIENT_ID, + redirect_uri: config.OAUTH_REDIRECT_URI, response_type: 'code', scope: 'openid read write', automaticSilentRenew: true, diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py index 9fc5ef6b6..48993e231 100644 --- a/modoboa/core/api/v2/urls.py +++ b/modoboa/core/api/v2/urls.py @@ -3,9 +3,6 @@ from django.urls import path from rest_framework import routers -from rest_framework_simplejwt.views import ( - TokenRefreshView, -) from . import views from . import viewsets @@ -18,8 +15,6 @@ router.register(r"logs/audit-trail", viewsets.LogViewSet) urlpatterns = router.urls urlpatterns += [ - path("token/", views.TokenObtainPairView.as_view(), name="token_obtain_pair"), - path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path( "password_reset/", views.DefaultPasswordResetView.as_view(), diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 3e3f32973..7f3b5ac92 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -1,33 +1,22 @@ """Core API v2 views.""" import logging +from smtplib import SMTPException -from django.utils.html import escape -from django.utils.translation import gettext as _ from django.utils.datastructures import MultiValueDictKeyError -from django.contrib.auth import login - from drf_spectacular.utils import extend_schema -from rest_framework import permissions, response, status -from rest_framework.exceptions import AuthenticationFailed -from rest_framework_simplejwt import views as jwt_views -from rest_framework_simplejwt.exceptions import InvalidToken +from rest_framework import permissions, response from rest_framework.views import APIView -from modoboa.core.password_hashers import get_password_hasher from modoboa.core.utils import check_for_updates from modoboa.lib.permissions import IsSuperUser from modoboa.lib.throttle import ( UserLesserDdosUser, - LoginThrottle, PasswordResetApplyThrottle, PasswordResetRequestThrottle, PasswordResetTotpThrottle, ) -from modoboa.parameters import tools as param_tools - -from smtplib import SMTPException from . import serializers @@ -43,62 +32,6 @@ def delete_cache_key(class_target, throttles, request): return -class TokenObtainPairView(jwt_views.TokenObtainPairView): - """We overwrite this view to deal with password scheme update.""" - - throttle_classes = [LoginThrottle] - - def post(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - try: - serializer.is_valid(raise_exception=True) - except AuthenticationFailed as e: - logger.warning( - _("Failed connection attempt from '%s' as user '%s'"), - request.META["REMOTE_ADDR"], - escape(serializer.initial_data["username"]), - ) - raise InvalidToken(e.args[0]) - - user = serializer.user - login(request, user) - - # Reset login throttle - delete_cache_key(LoginThrottle, self.get_throttles(), request) - - logger.info(_("User '%s' successfully logged in"), user.username) - if user and user.is_active: - condition = user.is_local and param_tools.get_global_parameter( - "update_scheme", raise_exception=False - ) - if condition: - # check if password scheme is correct - scheme = param_tools.get_global_parameter( - "password_scheme", raise_exception=False - ) - # use SHA512CRYPT as default fallback - if scheme is None: - pwhash = get_password_hasher("sha512crypt")() - else: - pwhash = get_password_hasher(scheme)() - if not user.password.startswith(pwhash.scheme): - logger.info( - _("Password scheme mismatch. Updating %s password"), - user.username, - ) - user.set_password(request.data["password"]) - user.save() - if pwhash.needs_rehash(user.password): - logger.info( - _("Password hash parameter missmatch. " "Updating %s password"), - user.username, - ) - user.set_password(serializer.data["password"]) - user.save() - - return response.Response(serializer.validated_data, status=status.HTTP_200_OK) - - class EmailPasswordResetView(APIView): """ An Api View which provides a method to request a password reset token based on an e-mail address. diff --git a/modoboa/core/commands/deploy.py b/modoboa/core/commands/deploy.py index 014fac790..594fa2e7f 100644 --- a/modoboa/core/commands/deploy.py +++ b/modoboa/core/commands/deploy.py @@ -9,13 +9,18 @@ import string import subprocess import sys +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa import dj_database_url import django +from django.conf import settings from django.core import management from django.template import Context, Template from django.utils.encoding import smart_str +from oauth2_provider.models import Application + from modoboa.core.commands import Command from modoboa.lib.api_client import ModoAPIClient from modoboa.lib.sysutils import exec_cmd @@ -197,6 +202,30 @@ class DeployCommand(Command): extra_settings.append(extension[1]) return extra_settings + def create_frontend_oauth_app(self) -> str: + app = Application.objects.create( + name="Modoboa frontend", + client_type="public", + algorithm="RS256", + authorization_grant_type="authorization-code", + skip_authorization=True, + ) + return app.client_id + + def generate_rsa_private_key(self, storage_path: str): + """Generate RSA private key for OIDC support.""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + with open(f"{storage_path}/.env", "wb") as fp: + fp.write(b'OIDC_RSA_PRIVATE_KEY="' + bytes(pem) + b'"\n') + def handle(self, parsed_args): django.setup() management.call_command("startproject", parsed_args.name, verbosity=False) @@ -267,6 +296,8 @@ class DeployCommand(Command): ) with open("%s/settings.py" % path, "w") as fp: fp.write(tpl) + self.generate_rsa_private_key(path) + shutil.copyfile("%s/urls.py.tpl" % self._templates_dir, "%s/urls.py" % path) os.mkdir("%s/media" % parsed_args.name) @@ -291,11 +322,15 @@ class DeployCommand(Command): frontend_target_dir = "{}/frontend".format(parsed_args.name) shutil.copytree(base_frontend_dir, frontend_target_dir) + client_id = self.create_frontend_oauth_app() + frontend_path = getattr(settings, "NEW_ADMIN_URL", "new-admin") with open("{}/config.json".format(frontend_target_dir), "w") as fp: fp.write( - """{ - "API_BASE_URL": "https://%s/api/v2" + f"""{ + "API_BASE_URL": "https://{allowed_host}/api/v2", + "OAUTH_AUTHORITY_URL": "https://{allowed_host}/api/o", + "OAUTH_CLIENT_ID": "{client_id}", + "OAUTH_REDIRECT_URI": "https://{allowed_host}/{frontend_path}/login/logged" } """ - % allowed_host ) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 96a8b03a4..af2072778 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -10,15 +10,16 @@ https://docs.djangoproject.com/en/2.2/ref/settings/ from logging.handlers import SysLogHandler import os +import environ {% if devmode %} from modoboa.core.dev_settings import * # noqa {% endif %} - +env = environ.Env() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) - +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ @@ -62,6 +63,7 @@ INSTALLED_APPS = ( 'reversion', 'ckeditor', 'ckeditor_uploader', + 'oauth2_provider', 'rest_framework', 'rest_framework.authtoken', 'drf_spectacular', @@ -198,6 +200,22 @@ STATICFILES_DIRS = ( MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +# oAuth2 settings + +OAUTH2_PROVIDER = { + 'OIDC_ENABLED': True, + 'OIDC_RP_INITIATED_LOGOUT_ENABLED': True, + 'OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT': True, + 'OIDC_RSA_PRIVATE_KEY': env('OIDC_RSA_PRIVATE_KEY'), + 'SCOPES': { + 'openid': 'OpenID Connect scope', + 'read': 'Read scope', + 'write': 'Write scope', + 'introspection': 'Introspect token scope', + }, + 'DEFAULT_SCOPES': ['openid', 'read', 'write'], +} + # Rest framework settings REST_FRAMEWORK = { @@ -211,7 +229,7 @@ REST_FRAMEWORK = { 'password_recovery_apply': '25/hour' }, 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'modoboa.core.drf_authentication.JWTAuthenticationWith2FA', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', ), diff --git a/requirements.txt b/requirements.txt index 6b11a48aa..9b42637be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,13 @@ django-xforwardedfor-middleware==2.0 django-otp==1.4.1 django-filter django-rename-app==0.1.7 +django-environ dj-database-url djangorestframework>=3.14,<3.16 drf-spectacular uritemplate -djangorestframework-simplejwt==5.3.1 django-oauth-toolkit django-cors-headers