1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-06 05:55:28 +00:00

Allow password authentication to be disabled

This commit is contained in:
Petr Stribny 2022-12-12 09:09:32 +00:00
parent d6ca1f6ed5
commit fbe9a8a27b
50 changed files with 817 additions and 122 deletions

View file

@ -50,3 +50,9 @@ ERROR_DEACTIVATED_USER = (
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
"User account has been disabled.", "User account has been disabled.",
) )
ERROR_AUTH_PROVIDER_DISABLED = (
"ERROR_AUTH_PROVIDER_DISABLED",
HTTP_401_UNAUTHORIZED,
"Authentication provider is disabled.",
)

View file

@ -18,6 +18,8 @@ from baserow.api.groups.invitations.serializers import UserGroupInvitationSerial
from baserow.api.user.jwt import get_user_from_token from baserow.api.user.jwt import get_user_from_token
from baserow.api.user.registries import user_data_registry from baserow.api.user.registries import user_data_registry
from baserow.api.user.validators import language_validation, password_validation from baserow.api.user.validators import language_validation, password_validation
from baserow.core.auth_provider.exceptions import AuthProviderDisabled
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.models import Template from baserow.core.models import Template
from baserow.core.user.exceptions import DeactivatedUserException from baserow.core.user.exceptions import DeactivatedUserException
from baserow.core.user.handler import UserHandler from baserow.core.user.handler import UserHandler
@ -201,13 +203,16 @@ class TokenObtainPairWithUserSerializer(TokenObtainPairSerializer):
super().validate(attrs) super().validate(attrs)
user = self.user user = self.user
password_provider = PasswordProviderHandler.get()
if not password_provider.enabled and user.is_staff is False:
raise AuthProviderDisabled()
if not user.is_active: if not user.is_active:
raise DeactivatedUserException() raise DeactivatedUserException()
data = generate_session_tokens_for_user(user, include_refresh_token=True) data = generate_session_tokens_for_user(user, include_refresh_token=True)
data.update(**get_all_user_data_serialized(user, self.context["request"])) data.update(**get_all_user_data_serialized(user, self.context["request"]))
UserHandler().user_signed_in_via_default_provider(user) UserHandler().user_signed_in_via_provider(user, password_provider)
return data return data

View file

@ -36,6 +36,8 @@ from baserow.api.sessions import get_untrusted_client_session_id
from baserow.api.user.registries import user_data_registry from baserow.api.user.registries import user_data_registry
from baserow.core.action.handler import ActionHandler from baserow.core.action.handler import ActionHandler
from baserow.core.action.registries import ActionScopeStr from baserow.core.action.registries import ActionScopeStr
from baserow.core.auth_provider.exceptions import AuthProviderDisabled
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.exceptions import ( from baserow.core.exceptions import (
BaseURLHostnameNotAllowed, BaseURLHostnameNotAllowed,
GroupInvitationDoesNotExist, GroupInvitationDoesNotExist,
@ -57,6 +59,7 @@ from baserow.core.user.utils import generate_session_tokens_for_user
from .errors import ( from .errors import (
ERROR_ALREADY_EXISTS, ERROR_ALREADY_EXISTS,
ERROR_AUTH_PROVIDER_DISABLED,
ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET, ERROR_CLIENT_SESSION_ID_HEADER_NOT_SET,
ERROR_DEACTIVATED_USER, ERROR_DEACTIVATED_USER,
ERROR_DISABLED_RESET_PASSWORD, ERROR_DISABLED_RESET_PASSWORD,
@ -119,6 +122,7 @@ class ObtainJSONWebToken(TokenObtainPairView):
{ {
AuthenticationFailed: ERROR_INVALID_CREDENTIALS, AuthenticationFailed: ERROR_INVALID_CREDENTIALS,
DeactivatedUserException: ERROR_DEACTIVATED_USER, DeactivatedUserException: ERROR_DEACTIVATED_USER,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
} }
) )
def post(self, *args, **kwargs): def post(self, *args, **kwargs):
@ -202,12 +206,16 @@ class UserView(APIView):
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST, GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH, GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
DisabledSignupError: ERROR_DISABLED_SIGNUP, DisabledSignupError: ERROR_DISABLED_SIGNUP,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
} }
) )
@validate_body(RegisterSerializer) @validate_body(RegisterSerializer)
def post(self, request, data): def post(self, request, data):
"""Registers a new user.""" """Registers a new user."""
if not PasswordProviderHandler.get().enabled:
raise AuthProviderDisabled()
template = ( template = (
Template.objects.get(pk=data["template_id"]) Template.objects.get(pk=data["template_id"])
if data["template_id"] if data["template_id"]
@ -267,6 +275,7 @@ class SendResetPasswordEmailView(APIView):
{ {
BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED, BaseURLHostnameNotAllowed: ERROR_HOSTNAME_IS_NOT_ALLOWED,
ResetPasswordDisabledError: ERROR_DISABLED_RESET_PASSWORD, ResetPasswordDisabledError: ERROR_DISABLED_RESET_PASSWORD,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
} }
) )
def post(self, request, data): def post(self, request, data):
@ -279,6 +288,8 @@ class SendResetPasswordEmailView(APIView):
try: try:
user = handler.get_active_user(email=data["email"]) user = handler.get_active_user(email=data["email"])
if not PasswordProviderHandler.get().enabled and user.is_staff is False:
raise AuthProviderDisabled()
handler.send_reset_password_email(user, data["base_url"]) handler.send_reset_password_email(user, data["base_url"])
except UserNotFound: except UserNotFound:
pass pass
@ -357,12 +368,15 @@ class ChangePasswordView(APIView):
@map_exceptions( @map_exceptions(
{ {
InvalidPassword: ERROR_INVALID_OLD_PASSWORD, InvalidPassword: ERROR_INVALID_OLD_PASSWORD,
AuthProviderDisabled: ERROR_AUTH_PROVIDER_DISABLED,
} }
) )
@validate_body(ChangePasswordBodyValidationSerializer) @validate_body(ChangePasswordBodyValidationSerializer)
def post(self, request, data): def post(self, request, data):
"""Changes the authenticated user's password if the old password is correct.""" """Changes the authenticated user's password if the old password is correct."""
if not PasswordProviderHandler.get().enabled and request.user.is_staff is False:
raise AuthProviderDisabled()
handler = UserHandler() handler = UserHandler()
handler.change_password( handler.change_password(
request.user, data["old_password"], data["new_password"] request.user, data["old_password"], data["new_password"]

View file

@ -180,7 +180,7 @@ class CoreConfig(AppConfig):
) )
from baserow.core.registries import auth_provider_type_registry from baserow.core.registries import auth_provider_type_registry
auth_provider_type_registry.register_default(PasswordAuthProviderType()) auth_provider_type_registry.register(PasswordAuthProviderType())
# Clear the key after migration so we will trigger a new template sync. # Clear the key after migration so we will trigger a new template sync.
post_migrate.connect(start_sync_templates_task_after_migrate, sender=self) post_migrate.connect(start_sync_templates_task_after_migrate, sender=self)

View file

@ -2,6 +2,7 @@ from typing import Any, Dict, Optional
from rest_framework import serializers from rest_framework import serializers
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.auth_provider.validators import validate_domain from baserow.core.auth_provider.validators import validate_domain
from baserow.core.registry import ( from baserow.core.registry import (
APIUrlsInstanceMixin, APIUrlsInstanceMixin,
@ -32,12 +33,19 @@ class AuthProviderType(
raise NotImplementedError() raise NotImplementedError()
def can_create_new_providers(self): def can_create_new_providers(self) -> bool:
""" """
Returns True if it's possible to create an authentication provider of this type. Returns True if it's possible to create an authentication provider of this type.
""" """
raise NotImplementedError() return True
def can_delete_existing_providers(self) -> bool:
"""
Returns True if it's possible to delete an authentication provider of this type.
"""
return True
def before_create(self, user, **values): def before_create(self, user, **values):
""" """
@ -126,6 +134,7 @@ class AuthProviderType(
return { return {
"type": self.type, "type": self.type,
"can_create_new": self.can_create_new_providers(), "can_create_new": self.can_create_new_providers(),
"can_delete_existing": self.can_delete_existing_providers(),
"auth_providers": [ "auth_providers": [
self.get_serializer(provider, AuthProviderSerializer).data self.get_serializer(provider, AuthProviderSerializer).data
for provider in self.list_providers() for provider in self.list_providers()
@ -142,6 +151,8 @@ class PasswordAuthProviderType(AuthProviderType):
type = "password" type = "password"
model_class = PasswordAuthProviderModel model_class = PasswordAuthProviderModel
allowed_fields = ["id", "enabled"]
serializer_field_names = ["enabled"]
serializer_field_overrides = { serializer_field_overrides = {
"domain": serializers.CharField( "domain": serializers.CharField(
validators=[validate_domain], validators=[validate_domain],
@ -155,7 +166,12 @@ class PasswordAuthProviderType(AuthProviderType):
} }
def get_login_options(self, **kwargs) -> Optional[Dict[str, Any]]: def get_login_options(self, **kwargs) -> Optional[Dict[str, Any]]:
return {} if not PasswordProviderHandler.get().enabled:
return None
return {"type": self.type}
def can_create_new_providers(self): def can_create_new_providers(self):
return False return False
def can_delete_existing_providers(self):
return False

View file

@ -1,2 +1,6 @@
class AuthProviderModelNotFound(Exception): class AuthProviderModelNotFound(Exception):
"""Raised if the requested authentication provider does not exist.""" """Raised if the requested authentication provider does not exist."""
class AuthProviderDisabled(Exception):
"""Raised when it is not possible to use a particular auth provider."""

View file

@ -0,0 +1,14 @@
from baserow.core.auth_provider.models import PasswordAuthProviderModel
class PasswordProviderHandler:
@classmethod
def get(cls) -> PasswordAuthProviderModel:
"""
Returns the password provider
:return: The one and only password provider.
"""
obj, created = PasswordAuthProviderModel.objects.get_or_create()
return obj

View file

@ -39,9 +39,4 @@ class AuthProviderModel(
class PasswordAuthProviderModel(AuthProviderModel): class PasswordAuthProviderModel(AuthProviderModel):
def save(self, *args, **kwargs): ...
if not self.enabled:
raise ValueError(
"The password authentication provider cannot be disabled. "
)
super().save(*args, **kwargs)

View file

@ -356,17 +356,6 @@ class AuthenticationProviderTypeRegistry(
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._default = None self._default = None
def register_default(self, instance):
super().register(instance)
self._default = instance
def get_default_provider(self):
provider, _ = self._default.model_class.objects.get_or_create()
return provider
def get_default(self):
return self._default
def get_all_available_login_options(self): def get_all_available_login_options(self):
login_options = {} login_options = {}
for provider in self.get_all(): for provider in self.get_all():

View file

@ -14,6 +14,7 @@ from django.utils.translation import gettext as _
from itsdangerous import URLSafeTimedSerializer from itsdangerous import URLSafeTimedSerializer
from baserow.core.auth_provider.handler import PasswordProviderHandler
from baserow.core.auth_provider.models import AuthProviderModel from baserow.core.auth_provider.models import AuthProviderModel
from baserow.core.exceptions import ( from baserow.core.exceptions import (
BaseURLHostnameNotAllowed, BaseURLHostnameNotAllowed,
@ -21,7 +22,7 @@ from baserow.core.exceptions import (
) )
from baserow.core.handler import CoreHandler from baserow.core.handler import CoreHandler
from baserow.core.models import Group, GroupUser, Template, UserLogEntry, UserProfile from baserow.core.models import Group, GroupUser, Template, UserLogEntry, UserProfile
from baserow.core.registries import auth_provider_type_registry, plugin_registry from baserow.core.registries import plugin_registry
from baserow.core.signals import ( from baserow.core.signals import (
before_user_deleted, before_user_deleted,
user_deleted, user_deleted,
@ -209,7 +210,7 @@ class UserHandler:
# register the authentication provider used to create the user # register the authentication provider used to create the user
if auth_provider is None: if auth_provider is None:
auth_provider = auth_provider_type_registry.get_default_provider() auth_provider = PasswordProviderHandler.get()
auth_provider.user_signed_in(user) auth_provider.user_signed_in(user)
return user return user
@ -347,16 +348,6 @@ class UserHandler:
return user return user
def user_signed_in_via_default_provider(self, user: AbstractUser):
"""
Registers that a user has signed in via the default authentication provider.
:param user: The user that has signed in.
"""
default_provider = auth_provider_type_registry.get_default_provider()
self.user_signed_in_via_provider(user, default_provider)
def user_signed_in_via_provider( def user_signed_in_via_provider(
self, user: AbstractUser, authentication_provider: AuthProviderModel self, user: AbstractUser, authentication_provider: AuthProviderModel
): ):

View file

@ -2,6 +2,7 @@ from faker import Faker
from .airtable import AirtableFixtures from .airtable import AirtableFixtures
from .application import ApplicationFixtures from .application import ApplicationFixtures
from .auth_provider import AuthProviderFixtures
from .field import FieldFixtures from .field import FieldFixtures
from .file_import import FileImportFixtures from .file_import import FileImportFixtures
from .group import GroupFixtures from .group import GroupFixtures
@ -35,5 +36,6 @@ class Fixtures(
JobFixtures, JobFixtures,
FileImportFixtures, FileImportFixtures,
SnapshotFixtures, SnapshotFixtures,
AuthProviderFixtures,
): ):
fake = Faker() fake = Faker()

View file

@ -0,0 +1,9 @@
from baserow.core.auth_provider.models import PasswordAuthProviderModel
class AuthProviderFixtures:
def create_password_provider(self, **kwargs):
if "enabled" not in kwargs:
kwargs["enabled"] = True
return PasswordAuthProviderModel.objects.create(**kwargs)

View file

@ -32,7 +32,10 @@ def test_get_settings(api_client):
@pytest.mark.django_db @pytest.mark.django_db
def test_require_first_admin_user_is_false_after_admin_creation(api_client): def test_require_first_admin_user_is_false_after_admin_creation(
api_client, data_fixture
):
data_fixture.create_password_provider()
response = api_client.get(reverse("api:settings:get")) response = api_client.get(reverse("api:settings:get"))
assert response.status_code == HTTP_200_OK assert response.status_code == HTTP_200_OK
response_json = response.json() response_json = response.json()

View file

@ -22,6 +22,8 @@ User = get_user_model()
@pytest.mark.django_db @pytest.mark.django_db
def test_token_auth(api_client, data_fixture): def test_token_auth(api_client, data_fixture):
data_fixture.create_password_provider()
class TmpPlugin(Plugin): class TmpPlugin(Plugin):
type = "tmp_plugin" type = "tmp_plugin"
called = False called = False
@ -193,6 +195,48 @@ def test_token_auth(api_client, data_fixture):
assert "user" in response_json assert "user" in response_json
@pytest.mark.django_db
def test_token_password_auth_disabled(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
user, token = data_fixture.create_user_and_token(
email="test@localhost", password="test"
)
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@localhost", "password": "test"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"error": "ERROR_AUTH_PROVIDER_DISABLED",
"detail": "Authentication provider is disabled.",
}
@pytest.mark.django_db
def test_token_password_auth_disabled_superadmin(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
user, token = data_fixture.create_user_and_token(
email="test@localhost", password="test", is_staff=True
)
response = api_client.post(
reverse("api:user:token_auth"),
{"email": "test@localhost", "password": "test"},
format="json",
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert "access_token" in response_json
assert "refresh_token" in response_json
assert "user" in response_json
assert response_json["user"]["id"] == user.id
assert response_json["user"]["is_staff"] is True
@pytest.mark.django_db @pytest.mark.django_db
def test_token_refresh(api_client, data_fixture): def test_token_refresh(api_client, data_fixture):
class TmpPlugin(Plugin): class TmpPlugin(Plugin):

View file

@ -28,6 +28,7 @@ User = get_user_model()
@pytest.mark.django_db @pytest.mark.django_db
def test_create_user(client, data_fixture): def test_create_user(client, data_fixture):
data_fixture.create_password_provider()
valid_password = "thisIsAValidPassword" valid_password = "thisIsAValidPassword"
short_password = "short" short_password = "short"
response = client.post( response = client.post(
@ -255,6 +256,7 @@ def test_user_account(data_fixture, api_client):
@pytest.mark.django_db @pytest.mark.django_db
def test_create_user_with_invitation(data_fixture, client): def test_create_user_with_invitation(data_fixture, client):
data_fixture.create_password_provider()
core_handler = CoreHandler() core_handler = CoreHandler()
valid_password = "thisIsAValidPassword" valid_password = "thisIsAValidPassword"
invitation = data_fixture.create_group_invitation(email="test0@test.nl") invitation = data_fixture.create_group_invitation(email="test0@test.nl")
@ -335,6 +337,7 @@ def test_create_user_with_invitation(data_fixture, client):
@pytest.mark.django_db @pytest.mark.django_db
def test_create_user_with_template(data_fixture, client): def test_create_user_with_template(data_fixture, client):
data_fixture.create_password_provider()
old_templates = settings.APPLICATION_TEMPLATES_DIR old_templates = settings.APPLICATION_TEMPLATES_DIR
valid_password = "thisIsAValidPassword" valid_password = "thisIsAValidPassword"
settings.APPLICATION_TEMPLATES_DIR = os.path.join( settings.APPLICATION_TEMPLATES_DIR = os.path.join(
@ -395,6 +398,7 @@ def test_create_user_with_template(data_fixture, client):
@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True)
def test_send_reset_password_email(data_fixture, client, mailoutbox): def test_send_reset_password_email(data_fixture, client, mailoutbox):
data_fixture.create_password_provider()
data_fixture.create_user(email="test@localhost.nl") data_fixture.create_user(email="test@localhost.nl")
response = client.post( response = client.post(
@ -454,6 +458,44 @@ def test_send_reset_password_email(data_fixture, client, mailoutbox):
assert response.status_code == HTTP_400_BAD_REQUEST assert response.status_code == HTTP_400_BAD_REQUEST
@pytest.mark.django_db
def test_send_reset_password_email_password_auth_disabled(
api_client, data_fixture, mailoutbox
):
data_fixture.create_password_provider(enabled=False)
data_fixture.create_user(email="test@localhost.nl")
response = api_client.post(
reverse("api:user:send_reset_password_email"),
{"email": "test@localhost.nl", "base_url": "http://localhost:3000"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"error": "ERROR_AUTH_PROVIDER_DISABLED",
"detail": "Authentication provider is disabled.",
}
assert len(mailoutbox) == 0
@pytest.mark.django_db(transaction=True)
def test_send_reset_password_email_password_auth_disabled_staff(
api_client, data_fixture, mailoutbox
):
data_fixture.create_password_provider(enabled=False)
data_fixture.create_user(email="test@localhost.nl", is_staff=True)
response = api_client.post(
reverse("api:user:send_reset_password_email"),
{"email": "test@localhost.nl", "base_url": "http://localhost:3000"},
format="json",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert len(mailoutbox) == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_password_reset(data_fixture, client): def test_password_reset(data_fixture, client):
user = data_fixture.create_user(email="test@localhost") user = data_fixture.create_user(email="test@localhost")
@ -581,6 +623,7 @@ def test_password_reset(data_fixture, client):
@pytest.mark.django_db @pytest.mark.django_db
def test_change_password(data_fixture, client): def test_change_password(data_fixture, client):
data_fixture.create_password_provider()
valid_old_password = "thisIsAValidPassword" valid_old_password = "thisIsAValidPassword"
valid_new_password = "thisIsAValidNewPassword" valid_new_password = "thisIsAValidNewPassword"
short_password = "short" short_password = "short"
@ -671,6 +714,50 @@ def test_change_password(data_fixture, client):
assert user.check_password(valid_new_password) assert user.check_password(valid_new_password)
@pytest.mark.django_db
def test_change_password_auth_disabled(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
valid_old_password = "thisIsAValidPassword"
valid_new_password = "thisIsAValidNewPassword"
user, token = data_fixture.create_user_and_token(
email="test@localhost", password=valid_old_password
)
response = api_client.post(
reverse("api:user:change_password"),
{"old_password": valid_old_password, "new_password": valid_new_password},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"error": "ERROR_AUTH_PROVIDER_DISABLED",
"detail": "Authentication provider is disabled.",
}
@pytest.mark.django_db
def test_change_password_auth_disabled_staff(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
valid_old_password = "thisIsAValidPassword"
valid_new_password = "thisIsAValidNewPassword"
user, token = data_fixture.create_user_and_token(
email="test@localhost", password=valid_old_password, is_staff=True
)
response = api_client.post(
reverse("api:user:change_password"),
{"old_password": valid_old_password, "new_password": valid_new_password},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
user.refresh_from_db()
assert user.check_password(valid_new_password)
@pytest.mark.django_db @pytest.mark.django_db
def test_dashboard(data_fixture, client): def test_dashboard(data_fixture, client):
user, token = data_fixture.create_user_and_token(email="test@localhost") user, token = data_fixture.create_user_and_token(email="test@localhost")
@ -699,6 +786,8 @@ def test_dashboard(data_fixture, client):
@pytest.mark.django_db @pytest.mark.django_db
def test_additional_user_data(api_client, data_fixture): def test_additional_user_data(api_client, data_fixture):
data_fixture.create_password_provider()
class TmpUserDataType(UserDataType): class TmpUserDataType(UserDataType):
type = "type" type = "type"
@ -831,3 +920,23 @@ def test_token_error_if_user_deleted_or_disabled(api_client, data_fixture):
"error": "ERROR_INVALID_REFRESH_TOKEN", "error": "ERROR_INVALID_REFRESH_TOKEN",
"detail": "Refresh token is expired or invalid.", "detail": "Refresh token is expired or invalid.",
} }
@pytest.mark.django_db
def test_create_user_password_auth_disabled(api_client, data_fixture):
data_fixture.create_password_provider(enabled=False)
user, token = data_fixture.create_user_and_token(
email="test@localhost", password="test"
)
response = api_client.post(
reverse("api:user:index"),
{"name": "Test1", "email": "test@test.nl", "password": "thisIsAValidPassword"},
format="json",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json() == {
"error": "ERROR_AUTH_PROVIDER_DISABLED",
"detail": "Authentication provider is disabled.",
}

View file

@ -11,6 +11,8 @@ For example:
### New Features ### New Features
* Possibility to disable password authentication if another authentication provider is enabled. [#1317](https://gitlab.com/bramw/baserow/-/issues/1317)
### Bug Fixes ### Bug Fixes
### Refactors ### Refactors
@ -18,6 +20,7 @@ For example:
## Released (2022-12-8 1.13.2) ## Released (2022-12-8 1.13.2)
### New Features ### New Features
* Add drag and drop zone for files to the row edit modal [#1161](https://gitlab.com/bramw/baserow/-/issues/1161) * Add drag and drop zone for files to the row edit modal [#1161](https://gitlab.com/bramw/baserow/-/issues/1161)
* Automatically enable/disable enterprise features upon activation/deactivation without needing a page refresh first. [#1306](https://gitlab.com/bramw/baserow/-/issues/1306) * Automatically enable/disable enterprise features upon activation/deactivation without needing a page refresh first. [#1306](https://gitlab.com/bramw/baserow/-/issues/1306)

View file

@ -1,7 +1,25 @@
from rest_framework.status import HTTP_404_NOT_FOUND from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND
ERROR_AUTH_PROVIDER_DOES_NOT_EXIST = ( ERROR_AUTH_PROVIDER_DOES_NOT_EXIST = (
"ERROR_AUTH_PROVIDER_DOES_NOT_EXIST", "ERROR_AUTH_PROVIDER_DOES_NOT_EXIST",
HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND,
"The requested auth provider does not exist.", "The requested auth provider does not exist.",
) )
ERROR_AUTH_PROVIDER_CANNOT_BE_CREATED = (
"ERROR_AUTH_PROVIDER_CANNOT_BE_CREATED",
HTTP_400_BAD_REQUEST,
"The provider type cannot be created.",
)
ERROR_AUTH_PROVIDER_CANNOT_BE_DELETED = (
"ERROR_AUTH_PROVIDER_CANNOT_BE_DELETED",
HTTP_400_BAD_REQUEST,
"The provider type cannot be deleted.",
)
ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS = (
"ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS",
HTTP_400_BAD_REQUEST,
"The last enabled provider cannot be disabled.",
)

View file

@ -18,10 +18,20 @@ from baserow.api.utils import (
) )
from baserow.core.auth_provider.exceptions import AuthProviderModelNotFound from baserow.core.auth_provider.exceptions import AuthProviderModelNotFound
from baserow.core.registries import auth_provider_type_registry from baserow.core.registries import auth_provider_type_registry
from baserow_enterprise.auth_provider.exceptions import (
CannotCreateAuthProvider,
CannotDeleteAuthProvider,
CannotDisableLastAuthProvider,
)
from baserow_enterprise.auth_provider.handler import AuthProviderHandler from baserow_enterprise.auth_provider.handler import AuthProviderHandler
from baserow_enterprise.sso.utils import check_sso_feature_is_active_or_raise from baserow_enterprise.sso.utils import check_sso_feature_is_active_or_raise
from .errors import ERROR_AUTH_PROVIDER_DOES_NOT_EXIST from .errors import (
ERROR_AUTH_PROVIDER_CANNOT_BE_CREATED,
ERROR_AUTH_PROVIDER_CANNOT_BE_DELETED,
ERROR_AUTH_PROVIDER_DOES_NOT_EXIST,
ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS,
)
from .serializers import ( from .serializers import (
CreateAuthProviderSerializer, CreateAuthProviderSerializer,
NextAuthProviderIdSerializer, NextAuthProviderIdSerializer,
@ -52,6 +62,11 @@ class AdminAuthProvidersView(APIView):
auth_provider_type_registry, auth_provider_type_registry,
base_serializer_class=CreateAuthProviderSerializer, base_serializer_class=CreateAuthProviderSerializer,
) )
@map_exceptions(
{
CannotCreateAuthProvider: ERROR_AUTH_PROVIDER_CANNOT_BE_CREATED,
}
)
def post(self, request: Request, data: Dict[str, Any]) -> Response: def post(self, request: Request, data: Dict[str, Any]) -> Response:
"""Create a new authentication provider.""" """Create a new authentication provider."""
@ -121,6 +136,7 @@ class AdminAuthProviderView(APIView):
@map_exceptions( @map_exceptions(
{ {
AuthProviderModelNotFound: ERROR_AUTH_PROVIDER_DOES_NOT_EXIST, AuthProviderModelNotFound: ERROR_AUTH_PROVIDER_DOES_NOT_EXIST,
CannotDisableLastAuthProvider: ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS,
} }
) )
def patch(self, request, auth_provider_id: int): def patch(self, request, auth_provider_id: int):
@ -203,6 +219,8 @@ class AdminAuthProviderView(APIView):
@map_exceptions( @map_exceptions(
{ {
AuthProviderModelNotFound: ERROR_AUTH_PROVIDER_DOES_NOT_EXIST, AuthProviderModelNotFound: ERROR_AUTH_PROVIDER_DOES_NOT_EXIST,
CannotDeleteAuthProvider: ERROR_AUTH_PROVIDER_CANNOT_BE_DELETED,
CannotDisableLastAuthProvider: ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS,
} }
) )
def delete(self, request: Request, auth_provider_id: int) -> Response: def delete(self, request: Request, auth_provider_id: int) -> Response:

View file

@ -182,3 +182,13 @@ def get_frontend_login_error_url() -> str:
""" """
return urljoin(settings.PUBLIC_WEB_FRONTEND_URL, "/login/error") return urljoin(settings.PUBLIC_WEB_FRONTEND_URL, "/login/error")
def get_frontend_login_saml_url() -> str:
"""
Returns the url to the frontend SAML login page.
:return: The absolute url to the Baserow SAML login page.
"""
return urljoin(settings.PUBLIC_WEB_FRONTEND_URL, "/login/saml")

View file

@ -3,3 +3,21 @@ class DifferentAuthProvider(Exception):
Raised when logging in an existing user that should not Raised when logging in an existing user that should not
be logged in using a different than the approved auth provider. be logged in using a different than the approved auth provider.
""" """
class CannotCreateAuthProvider(Exception):
"""
Raised when a provider type cannot be created.
"""
class CannotDeleteAuthProvider(Exception):
"""
Raised when a provider type cannot be deleted.
"""
class CannotDisableLastAuthProvider(Exception):
"""
Raised during an attempt to disable last enabled auth provider.
"""

View file

@ -13,7 +13,12 @@ from baserow.core.handler import CoreHandler
from baserow.core.registries import auth_provider_type_registry from baserow.core.registries import auth_provider_type_registry
from baserow.core.user.exceptions import UserNotFound from baserow.core.user.exceptions import UserNotFound
from baserow.core.user.handler import UserHandler from baserow.core.user.handler import UserHandler
from baserow_enterprise.auth_provider.exceptions import DifferentAuthProvider from baserow_enterprise.auth_provider.exceptions import (
CannotCreateAuthProvider,
CannotDeleteAuthProvider,
CannotDisableLastAuthProvider,
DifferentAuthProvider,
)
SpecificAuthProviderModel = Type[AuthProviderModel] SpecificAuthProviderModel = Type[AuthProviderModel]
@ -59,6 +64,8 @@ class AuthProviderHandler:
:return: The created authentication provider. :return: The created authentication provider.
""" """
if not auth_provider_type.can_create_new_providers():
raise CannotCreateAuthProvider()
auth_provider_type.before_create(user, **values) auth_provider_type.before_create(user, **values)
return auth_provider_type.create(**values) return auth_provider_type.create(**values)
@ -79,6 +86,17 @@ class AuthProviderHandler:
""" """
auth_provider_type = auth_provider_type_registry.get_by_model(auth_provider) auth_provider_type = auth_provider_type_registry.get_by_model(auth_provider)
enabled_next = values.get("enabled", None)
if enabled_next is False:
another_enabled = (
AuthProviderModel.objects.filter(enabled=True)
.exclude(id=auth_provider.id)
.exists()
)
if not another_enabled:
raise CannotDisableLastAuthProvider()
auth_provider_type.before_update(user, auth_provider, **values) auth_provider_type.before_update(user, auth_provider, **values)
return auth_provider_type.update(auth_provider, **values) return auth_provider_type.update(auth_provider, **values)
@ -93,6 +111,15 @@ class AuthProviderHandler:
""" """
auth_provider_type = auth_provider_type_registry.get_by_model(auth_provider) auth_provider_type = auth_provider_type_registry.get_by_model(auth_provider)
if not auth_provider_type.can_delete_existing_providers():
raise CannotDeleteAuthProvider()
another_enabled = (
AuthProviderModel.objects.filter(enabled=True)
.exclude(id=auth_provider.id)
.exists()
)
if not another_enabled:
raise CannotDisableLastAuthProvider()
auth_provider_type.before_delete(user, auth_provider) auth_provider_type.before_delete(user, auth_provider)
auth_provider_type.delete(auth_provider) auth_provider_type.delete(auth_provider)

View file

@ -53,32 +53,35 @@ class OAuth2AuthProviderMixin:
- self.SCOPE - self.SCOPE
""" """
def can_create_new_providers(self):
return True
def get_login_options(self, **kwargs) -> Optional[Dict[str, Any]]: def get_login_options(self, **kwargs) -> Optional[Dict[str, Any]]:
if not is_sso_feature_active(): if not is_sso_feature_active():
return {} return None
instances = self.model_class.objects.filter(enabled=True)
if not instances:
return None
instances = self.model_class.objects.all()
items = [] items = []
for instance in instances: for instance in instances:
if instance.enabled: items.append(
items.append( {
{ "redirect_url": urllib.parse.urljoin(
"redirect_url": urllib.parse.urljoin( OAUTH_BACKEND_URL,
OAUTH_BACKEND_URL, reverse("api:enterprise:sso:oauth2:login", args=(instance.id,)),
reverse( ),
"api:enterprise:sso:oauth2:login", args=(instance.id,) "name": instance.name,
), "type": self.type,
), }
"name": instance.name, )
"type": self.type,
} default_redirect_url = None
) if len(items) == 1:
default_redirect_url = items[0]["redirect_url"]
return { return {
"type": self.type, "type": self.type,
"items": items, "items": items,
"default_redirect_url": default_redirect_url,
} }
def get_base_url(self, instance: AuthProviderModel) -> str: def get_base_url(self, instance: AuthProviderModel) -> str:

View file

@ -16,7 +16,10 @@ from baserow_enterprise.api.sso.saml.validators import (
validate_saml_metadata, validate_saml_metadata,
validate_unique_saml_domain, validate_unique_saml_domain,
) )
from baserow_enterprise.api.sso.utils import get_frontend_default_redirect_url from baserow_enterprise.api.sso.utils import (
get_frontend_default_redirect_url,
get_frontend_login_saml_url,
)
from baserow_enterprise.sso.saml.exceptions import SamlProviderForDomainAlreadyExists from baserow_enterprise.sso.saml.exceptions import SamlProviderForDomainAlreadyExists
from baserow_enterprise.sso.utils import is_sso_feature_active from baserow_enterprise.sso.utils import is_sso_feature_active
@ -83,22 +86,32 @@ class SamlAuthProviderType(AuthProviderType):
if not configured_domains: if not configured_domains:
return None return None
default_redirect_url = None
if configured_domains == 1:
default_redirect_url = self.get_login_absolute_url()
if configured_domains > 1:
default_redirect_url = get_frontend_login_saml_url()
return { return {
"type": self.type, "type": self.type,
# if configure_domains = 1, we can redirect directly the user to the # if configure_domains = 1, we can redirect directly the user to the
# IdP login page without asking for the email # IdP login page without asking for the email
"domain_required": configured_domains > 1, "domain_required": configured_domains > 1,
"default_redirect_url": default_redirect_url,
} }
def can_create_new_providers(self):
return True
@classmethod @classmethod
def get_acs_absolute_url(cls): def get_acs_absolute_url(cls):
return urljoin( return urljoin(
settings.PUBLIC_BACKEND_URL, reverse("api:enterprise:sso:saml:acs") settings.PUBLIC_BACKEND_URL, reverse("api:enterprise:sso:saml:acs")
) )
@classmethod
def get_login_absolute_url(cls):
return urljoin(
settings.PUBLIC_BACKEND_URL, reverse("api:enterprise:sso:saml:login")
)
def export_serialized(self) -> Dict[str, Any]: def export_serialized(self) -> Dict[str, Any]:
serialized_data = super().export_serialized() serialized_data = super().export_serialized()
serialized_data["relay_state_url"] = get_frontend_default_redirect_url() serialized_data["relay_state_url"] = get_frontend_default_redirect_url()

View file

@ -41,6 +41,7 @@ def test_admin_cannot_list_saml_provider_without_an_enterprise_license(
def test_admin_can_list_saml_provider_with_an_enterprise_license( def test_admin_can_list_saml_provider_with_an_enterprise_license(
api_client, data_fixture, enterprise_data_fixture api_client, data_fixture, enterprise_data_fixture
): ):
data_fixture.create_password_provider()
auth_prov_1 = enterprise_data_fixture.create_saml_auth_provider(domain="test.com") auth_prov_1 = enterprise_data_fixture.create_saml_auth_provider(domain="test.com")
auth_prov_2 = enterprise_data_fixture.create_saml_auth_provider(domain="acme.com") auth_prov_2 = enterprise_data_fixture.create_saml_auth_provider(domain="acme.com")
@ -115,6 +116,7 @@ def test_admin_cannot_create_saml_provider_without_an_enterprise_license(
def test_admin_can_create_saml_provider_with_an_enterprise_license( def test_admin_can_create_saml_provider_with_an_enterprise_license(
api_client, data_fixture, enterprise_data_fixture api_client, data_fixture, enterprise_data_fixture
): ):
data_fixture.create_password_provider()
# create a valid SAML provider # create a valid SAML provider
domain = "test.it" domain = "test.it"
@ -429,6 +431,7 @@ def test_admin_cannot_delete_saml_provider_without_an_enterprise_license(
def test_admin_can_delete_saml_provider_with_an_enterprise_license( def test_admin_can_delete_saml_provider_with_an_enterprise_license(
api_client, data_fixture, enterprise_data_fixture api_client, data_fixture, enterprise_data_fixture
): ):
unrelated_provider = enterprise_data_fixture.create_saml_auth_provider()
saml_provider_1 = enterprise_data_fixture.create_saml_auth_provider() saml_provider_1 = enterprise_data_fixture.create_saml_auth_provider()
_, token = enterprise_data_fixture.create_enterprise_admin_user_and_token() _, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
@ -463,7 +466,7 @@ def test_admin_can_delete_saml_provider_with_an_enterprise_license(
HTTP_AUTHORIZATION=f"JWT {token}", HTTP_AUTHORIZATION=f"JWT {token}",
) )
assert response.status_code == HTTP_204_NO_CONTENT assert response.status_code == HTTP_204_NO_CONTENT
assert SamlAuthProviderModel.objects.count() == 0 assert SamlAuthProviderModel.objects.count() == 1
@pytest.mark.django_db @pytest.mark.django_db
@ -555,6 +558,7 @@ def test_create_and_get_oauth2_provider(
endpoint will output correct information for the created provider. endpoint will output correct information for the created provider.
""" """
data_fixture.create_password_provider()
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token() admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
# create provider # create provider
@ -667,6 +671,7 @@ def test_update_oauth2_provider(
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token() admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
unrelated_provider = enterprise_data_fixture.create_saml_auth_provider()
provider = enterprise_data_fixture.create_oauth_provider( provider = enterprise_data_fixture.create_oauth_provider(
type=provider_type, **extra_params type=provider_type, **extra_params
) )
@ -741,3 +746,120 @@ def test_update_oauth_provider_invalid_url(
] ]
} }
) )
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_create_password_provider(
api_client, data_fixture, enterprise_data_fixture
):
"""
Password provider cannot be created as to keep only one instance.
"""
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
auth_provider_1_url = reverse("api:enterprise:admin:auth_provider:list")
response = api_client.post(
auth_provider_1_url,
{"type": "password", "enabled": True},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_AUTH_PROVIDER_CANNOT_BE_CREATED"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_delete_password_provider(
api_client, data_fixture, enterprise_data_fixture
):
"""
Password provider cannot be deleted, only enabled and disabled.
"""
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
password_provider = data_fixture.create_password_provider()
auth_provider_1_url = reverse(
"api:enterprise:admin:auth_provider:item",
kwargs={"auth_provider_id": password_provider.id},
)
response = api_client.delete(
auth_provider_1_url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_AUTH_PROVIDER_CANNOT_BE_DELETED"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_delete_last_provider(
api_client, data_fixture, enterprise_data_fixture
):
"""
At least one auth provider needs to be always enabled.
"""
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
last_provider = enterprise_data_fixture.create_oauth_provider(type="github")
# but not he last one
last_provider_url = reverse(
"api:enterprise:admin:auth_provider:item",
kwargs={"auth_provider_id": last_provider.id},
)
response = api_client.delete(
last_provider_url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS"
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admin_cannot_disable_last_provider(
api_client, data_fixture, enterprise_data_fixture
):
"""
At least one auth provider needs to be always enabled.
"""
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
password_provider = data_fixture.create_password_provider()
github_provider = enterprise_data_fixture.create_oauth_provider(type="github")
# it is possible to disable second provider
github_provider_url = reverse(
"api:enterprise:admin:auth_provider:item",
kwargs={"auth_provider_id": github_provider.id},
)
response = api_client.patch(
github_provider_url,
{"enabled": False},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
# but not he last one
password_provider_url = reverse(
"api:enterprise:admin:auth_provider:item",
kwargs={"auth_provider_id": password_provider.id},
)
response = api_client.patch(
password_provider_url,
{"enabled": False},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS"

View file

@ -10,6 +10,8 @@ from rest_framework.status import HTTP_200_OK
def test_saml_not_available_without_an_enterprise_license( def test_saml_not_available_without_an_enterprise_license(
api_client, data_fixture, enterprise_data_fixture api_client, data_fixture, enterprise_data_fixture
): ):
data_fixture.create_password_provider()
# create a valid SAML provider # create a valid SAML provider
enterprise_data_fixture.create_saml_auth_provider(domain="test1.com") enterprise_data_fixture.create_saml_auth_provider(domain="test1.com")
@ -24,6 +26,8 @@ def test_saml_not_available_without_an_enterprise_license(
def test_saml_available_with_an_enterprise_license( def test_saml_available_with_an_enterprise_license(
api_client, data_fixture, enterprise_data_fixture api_client, data_fixture, enterprise_data_fixture
): ):
data_fixture.create_password_provider()
# create a valid SAML provider # create a valid SAML provider
enterprise_data_fixture.create_saml_auth_provider(domain="test1.com") enterprise_data_fixture.create_saml_auth_provider(domain="test1.com")
enterprise_data_fixture.create_enterprise_admin_user_and_token() enterprise_data_fixture.create_enterprise_admin_user_and_token()

View file

@ -28,7 +28,10 @@ from baserow_enterprise.sso.oauth2.auth_provider_types import (
) )
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(DEBUG=True) @override_settings(DEBUG=True)
def test_get_login_options(provider_type, extra_params, enterprise_data_fixture): def test_get_login_options(
provider_type, extra_params, data_fixture, enterprise_data_fixture
):
data_fixture.create_password_provider()
provider = enterprise_data_fixture.create_oauth_provider( provider = enterprise_data_fixture.create_oauth_provider(
type=provider_type, type=provider_type,
client_id="test_client_id", client_id="test_client_id",
@ -59,6 +62,9 @@ def test_get_login_options(provider_type, extra_params, enterprise_data_fixture)
"type": provider_type, "type": provider_type,
} }
], ],
"default_redirect_url": (
f"{settings.PUBLIC_BACKEND_URL}" f"/api/sso/oauth2/login/{provider.id}/"
),
} }

View file

@ -9,7 +9,8 @@ from baserow_enterprise.sso.saml.exceptions import SamlProviderForDomainAlreadyE
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(DEBUG=True) @override_settings(DEBUG=True)
def test_get_login_options(enterprise_data_fixture): def test_get_login_options(data_fixture, enterprise_data_fixture):
data_fixture.create_password_provider()
enterprise_data_fixture.create_saml_auth_provider(domain="test.com") enterprise_data_fixture.create_saml_auth_provider(domain="test.com")
login_options = auth_provider_type_registry.get_all_available_login_options() login_options = auth_provider_type_registry.get_all_available_login_options()
assert "saml" not in login_options assert "saml" not in login_options
@ -19,6 +20,7 @@ def test_get_login_options(enterprise_data_fixture):
assert login_options["saml"] == { assert login_options["saml"] == {
"type": "saml", "type": "saml",
"domain_required": False, "domain_required": False,
"default_redirect_url": "http://localhost:8000/api/sso/saml/login/",
} }
enterprise_data_fixture.create_saml_auth_provider(domain="acme.com") enterprise_data_fixture.create_saml_auth_provider(domain="acme.com")
@ -26,6 +28,7 @@ def test_get_login_options(enterprise_data_fixture):
assert login_options["saml"] == { assert login_options["saml"] == {
"type": "saml", "type": "saml",
"domain_required": True, "domain_required": True,
"default_redirect_url": "http://localhost:3000/login/saml",
} }

View file

@ -8,6 +8,7 @@ import GitLabSettingsForm from '@baserow_enterprise/components/admin/forms/GitLa
import OpenIdConnectSettingsForm from '@baserow_enterprise/components/admin/forms/OpenIdConnectSettingsForm.vue' import OpenIdConnectSettingsForm from '@baserow_enterprise/components/admin/forms/OpenIdConnectSettingsForm.vue'
import LoginButton from '@baserow_enterprise/components/admin/login/LoginButton.vue' import LoginButton from '@baserow_enterprise/components/admin/login/LoginButton.vue'
import PasswordAuthIcon from '@baserow/modules/core/assets/images/providers/Key.svg'
import SAMLIcon from '@baserow_enterprise/assets/images/providers/LockKey.svg' import SAMLIcon from '@baserow_enterprise/assets/images/providers/LockKey.svg'
import GoogleIcon from '@baserow_enterprise/assets/images/providers/Google.svg' import GoogleIcon from '@baserow_enterprise/assets/images/providers/Google.svg'
import FacebookIcon from '@baserow_enterprise/assets/images/providers/Facebook.svg' import FacebookIcon from '@baserow_enterprise/assets/images/providers/Facebook.svg'
@ -16,6 +17,36 @@ import GitLabIcon from '@baserow_enterprise/assets/images/providers/GitLab.svg'
import OpenIdIcon from '@baserow_enterprise/assets/images/providers/OpenID.svg' import OpenIdIcon from '@baserow_enterprise/assets/images/providers/OpenID.svg'
import VerifiedProviderIcon from '@baserow_enterprise/assets/images/providers/VerifiedProviderIcon.svg' import VerifiedProviderIcon from '@baserow_enterprise/assets/images/providers/VerifiedProviderIcon.svg'
export class PasswordAuthProviderType extends AuthProviderType {
getType() {
return 'password'
}
getIcon() {
return PasswordAuthIcon
}
getName() {
return this.app.i18n.t('authProviderTypes.password')
}
getProviderName(provider) {
return this.getName()
}
getAdminListComponent() {
return AuthProviderItem
}
getAdminSettingsFormComponent() {
return null
}
getOrder() {
return 1
}
}
export class SamlAuthProviderType extends AuthProviderType { export class SamlAuthProviderType extends AuthProviderType {
getType() { getType() {
return 'saml' return 'saml'

View file

@ -5,22 +5,29 @@
{{ getName() }} {{ getName() }}
</div> </div>
<div class="auth-provider-admin__item-menu"> <div class="auth-provider-admin__item-menu">
<a ref="editMenuContextLink" @click="openContext()"> <a
v-if="hasContextMenu(authProvider.type)"
ref="editMenuContextLink"
@click="openContext()"
>
<i class="fas fa-ellipsis-v"></i> <i class="fas fa-ellipsis-v"></i>
</a> </a>
<EditAuthProviderMenuContext <EditAuthProviderMenuContext
v-if="hasContextMenu(authProvider.type)"
ref="editMenuContext" ref="editMenuContext"
:auth-provider="authProvider" :auth-provider="authProvider"
@edit="showUpdateSettingsModal" @edit="showUpdateSettingsModal"
@delete="showDeleteModal" @delete="showDeleteModal"
/> />
<UpdateSettingsAuthProviderModal <UpdateSettingsAuthProviderModal
v-if="canBeEdited(authProvider.type)"
ref="updateSettingsModal" ref="updateSettingsModal"
:auth-provider="authProvider" :auth-provider="authProvider"
@settings-updated="onSettingsUpdated" @settings-updated="onSettingsUpdated"
@cancel="$refs.updateSettingsModal.hide()" @cancel="$refs.updateSettingsModal.hide()"
/> />
<DeleteAuthProviderModal <DeleteAuthProviderModal
v-if="canBeDeleted(authProvider.type)"
ref="deleteModal" ref="deleteModal"
:auth-provider="authProvider" :auth-provider="authProvider"
@deleteConfirmed="onDeleteConfirmed" @deleteConfirmed="onDeleteConfirmed"
@ -31,6 +38,7 @@
class="auth-provider-admin__item-toggle" class="auth-provider-admin__item-toggle"
:value="authProvider.enabled" :value="authProvider.enabled"
:large="true" :large="true"
:disabled="isOneProviderEnabled && authProvider.enabled"
@input="setEnabled($event)" @input="setEnabled($event)"
></SwitchInput> ></SwitchInput>
</div> </div>
@ -42,6 +50,7 @@ import EditAuthProviderMenuContext from '@baserow_enterprise/components/admin/co
import UpdateSettingsAuthProviderModal from '@baserow_enterprise/components/admin/modals/UpdateSettingsAuthProviderModal.vue' import UpdateSettingsAuthProviderModal from '@baserow_enterprise/components/admin/modals/UpdateSettingsAuthProviderModal.vue'
import DeleteAuthProviderModal from '@baserow_enterprise/components/admin/modals/DeleteAuthProviderModal.vue' import DeleteAuthProviderModal from '@baserow_enterprise/components/admin/modals/DeleteAuthProviderModal.vue'
import AuthProviderIcon from '@baserow_enterprise/components/AuthProviderIcon.vue' import AuthProviderIcon from '@baserow_enterprise/components/AuthProviderIcon.vue'
import authProviderItemMixin from '@baserow_enterprise/mixins/authProviderItemMixin'
export default { export default {
name: 'AuthProviderItem', name: 'AuthProviderItem',
@ -52,12 +61,18 @@ export default {
UpdateSettingsAuthProviderModal, UpdateSettingsAuthProviderModal,
AuthProviderIcon, AuthProviderIcon,
}, },
mixins: [authProviderItemMixin],
props: { props: {
authProvider: { authProvider: {
type: Object, type: Object,
required: true, required: true,
}, },
}, },
computed: {
isOneProviderEnabled() {
return this.$store.getters['authProviderAdmin/isOneProviderEnabled']
},
},
methods: { methods: {
getIcon() { getIcon() {
return this.$registry return this.$registry

View file

@ -1,13 +1,13 @@
<template> <template>
<Context ref="context"> <Context ref="context">
<ul class="context__menu"> <ul class="context__menu">
<li> <li v-if="canBeEdited(authProvider.type)">
<a @click="$emit('edit', authProvider.id)"> <a @click="$emit('edit', authProvider.id)">
<i class="context__menu-icon fas fa-fw fa-pen"></i> <i class="context__menu-icon fas fa-fw fa-pen"></i>
{{ $t('editAuthProviderMenuContext.edit') }} {{ $t('editAuthProviderMenuContext.edit') }}
</a> </a>
</li> </li>
<li> <li v-if="canBeDeleted(authProvider.type)">
<a @click="$emit('delete', authProvider.id)"> <a @click="$emit('delete', authProvider.id)">
<i class="context__menu-icon fas fa-fw fa-trash-alt"></i> <i class="context__menu-icon fas fa-fw fa-trash-alt"></i>
{{ $t('editAuthProviderMenuContext.delete') }} {{ $t('editAuthProviderMenuContext.delete') }}
@ -20,11 +20,12 @@
<script> <script>
import context from '@baserow/modules/core/mixins/context' import context from '@baserow/modules/core/mixins/context'
import authProviderItemMixin from '@baserow_enterprise/mixins/authProviderItemMixin'
export default { export default {
name: 'EditAuthProviderMenuContext', name: 'EditAuthProviderMenuContext',
components: {}, components: {},
mixins: [context], mixins: [context, authProviderItemMixin],
props: { props: {
authProvider: { authProvider: {
type: Object, type: Object,

View file

@ -1,4 +1,8 @@
{ {
"clientHandler": {
"cannotDisableAllAuthProvidersTitle": "Last enabled provider",
"cannotDisableAllAuthProvidersDescription": "It is not possible to disable or delete last enabled provider."
},
"enterprise": { "enterprise": {
"license": "Enterprise", "license": "Enterprise",
"sidebarTooltip": "Your account has access to the enterprise features globally", "sidebarTooltip": "Your account has access to the enterprise features globally",
@ -58,6 +62,9 @@
"noProviders": "No authentication providers configured yet.", "noProviders": "No authentication providers configured yet.",
"addProvider": "Add provider" "addProvider": "Add provider"
}, },
"authProviderTypes": {
"password": "Email and password authentication"
},
"editAuthProviderMenuContext": { "editAuthProviderMenuContext": {
"edit": "Edit", "edit": "Edit",
"delete": "Delete" "delete": "Delete"
@ -211,4 +218,4 @@
"chatwootSuportSidebarGroup": { "chatwootSuportSidebarGroup": {
"directSupport": "Direct support" "directSupport": "Direct support"
} }
} }

View file

@ -0,0 +1,22 @@
export default {
methods: {
canBeEdited(authProviderType) {
return (
this.$registry
.get('authProvider', authProviderType)
.getAdminSettingsFormComponent() != null
)
},
canBeDeleted(authProviderType) {
const getType = this.$store.getters['authProviderAdmin/getType']
const providerTypeData = getType(authProviderType)
return providerTypeData.canDeleteExistingProviders
},
hasContextMenu(authProviderType) {
return (
this.canBeEdited(authProviderType) ||
this.canBeDeleted(authProviderType)
)
},
},
}

View file

@ -17,7 +17,7 @@
<ul class="auth__action-links"> <ul class="auth__action-links">
<li> <li>
{{ $t('loginError.loginText') }} {{ $t('loginError.loginText') }}
<nuxt-link :to="{ name: 'login' }"> <nuxt-link :to="{ name: 'login', query: { noredirect: null } }">
{{ $t('action.login') }} {{ $t('action.login') }}
</nuxt-link> </nuxt-link>
</li> </li>

View file

@ -54,7 +54,7 @@
</div> </div>
<div> <div>
<ul class="auth__action-links"> <ul class="auth__action-links">
<li> <li v-if="passwordLoginEnabled">
{{ $t('loginWithSaml.loginText') }} {{ $t('loginWithSaml.loginText') }}
<nuxt-link :to="{ name: 'login' }"> <nuxt-link :to="{ name: 'login' }">
{{ $t('action.login') }} {{ $t('action.login') }}
@ -72,6 +72,7 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
import decamelize from 'decamelize' import decamelize from 'decamelize'
import { required, email } from 'vuelidate/lib/validators' import { required, email } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form' import form from '@baserow/modules/core/mixins/form'
@ -131,6 +132,11 @@ export default {
}, },
} }
}, },
computed: {
...mapGetters({
passwordLoginEnabled: 'authProvider/getPasswordLoginEnabled',
}),
},
mounted() { mounted() {
if (this.redirectImmediately) { if (this.redirectImmediately) {
window.location.href = this.getRedirectUrlWithValidQueryParams( window.location.href = this.getRedirectUrlWithValidQueryParams(

View file

@ -2,7 +2,9 @@ import { registerRealtimeEvents } from '@baserow_enterprise/realtime'
import { RolePermissionManagerType } from '@baserow_enterprise/permissionManagerTypes' import { RolePermissionManagerType } from '@baserow_enterprise/permissionManagerTypes'
import { AuthProvidersType } from '@baserow_enterprise/adminTypes' import { AuthProvidersType } from '@baserow_enterprise/adminTypes'
import authProviderAdminStore from '@baserow_enterprise/store/authProviderAdmin' import authProviderAdminStore from '@baserow_enterprise/store/authProviderAdmin'
import { PasswordAuthProviderType as CorePasswordAuthProviderType } from '@baserow/modules/core/authProviderTypes'
import { import {
PasswordAuthProviderType,
SamlAuthProviderType, SamlAuthProviderType,
GitHubAuthProviderType, GitHubAuthProviderType,
GoogleAuthProviderType, GoogleAuthProviderType,
@ -50,6 +52,11 @@ export default (context) => {
store.registerModule('authProviderAdmin', authProviderAdminStore) store.registerModule('authProviderAdmin', authProviderAdminStore)
app.$registry.register('admin', new AuthProvidersType(context)) app.$registry.register('admin', new AuthProvidersType(context))
app.$registry.unregister(
'authProvider',
new CorePasswordAuthProviderType(context)
)
app.$registry.register('authProvider', new PasswordAuthProviderType(context))
app.$registry.register('authProvider', new SamlAuthProviderType(context)) app.$registry.register('authProvider', new SamlAuthProviderType(context))
app.$registry.register('authProvider', new GoogleAuthProviderType(context)) app.$registry.register('authProvider', new GoogleAuthProviderType(context))
app.$registry.register('authProvider', new FacebookAuthProviderType(context)) app.$registry.register('authProvider', new FacebookAuthProviderType(context))

View file

@ -1,4 +1,5 @@
import authProviderAdmin from '@baserow_enterprise/services/authProviderAdmin' import authProviderAdmin from '@baserow_enterprise/services/authProviderAdmin'
import { notifyIf } from '@baserow/modules/core/utils/error'
function populateProviderType(authProviderType, registry) { function populateProviderType(authProviderType, registry) {
const type = registry.get('authProvider', authProviderType.type) const type = registry.get('authProvider', authProviderType.type)
@ -75,8 +76,12 @@ export const actions = {
return item return item
}, },
async delete({ commit }, item) { async delete({ commit }, item) {
await authProviderAdmin(this.$client).delete(item.id) try {
commit('DELETE_ITEM', item) await authProviderAdmin(this.$client).delete(item.id)
commit('DELETE_ITEM', item)
} catch (error) {
notifyIf(error, 'authProvider')
}
}, },
async fetchNextProviderId({ commit }) { async fetchNextProviderId({ commit }) {
const { data } = await authProviderAdmin(this.$client).fetchNextProviderId() const { data } = await authProviderAdmin(this.$client).fetchNextProviderId()
@ -84,7 +89,7 @@ export const actions = {
commit('SET_NEXT_PROVIDER_ID', providerId) commit('SET_NEXT_PROVIDER_ID', providerId)
return providerId return providerId
}, },
async setEnabled({ commit }, { authProvider, enabled }) { async setEnabled({ commit, dispatch }, { authProvider, enabled }) {
// use optimistic update to enable/disable the auth provider // use optimistic update to enable/disable the auth provider
const wasEnabled = authProvider.enabled const wasEnabled = authProvider.enabled
commit('UPDATE_ITEM', { ...authProvider, enabled }) commit('UPDATE_ITEM', { ...authProvider, enabled })
@ -92,6 +97,7 @@ export const actions = {
await authProviderAdmin(this.$client).update(authProvider.id, { enabled }) await authProviderAdmin(this.$client).update(authProvider.id, { enabled })
} catch (error) { } catch (error) {
commit('UPDATE_ITEM', { ...authProvider, enabled: wasEnabled }) commit('UPDATE_ITEM', { ...authProvider, enabled: wasEnabled })
notifyIf(error, 'authProvider')
} }
}, },
} }
@ -127,6 +133,17 @@ export const getters = {
getType: (state) => (type) => { getType: (state) => (type) => {
return state.items[type] return state.items[type]
}, },
isOneProviderEnabled: (state) => {
let nEnabled = 0
for (const authProviderType of Object.values(state.items)) {
for (const authProvider of authProviderType.authProviders) {
if (authProvider.enabled) {
nEnabled += 1
}
}
}
return nEnabled === 1
},
} }
export default { export default {

View file

@ -248,7 +248,9 @@
"snapshotNameNotUniqueTitle": "The snapshot name has to be unique.", "snapshotNameNotUniqueTitle": "The snapshot name has to be unique.",
"snapshotNameNotUniqueDescription": "All snapshot names have to be unique per application.", "snapshotNameNotUniqueDescription": "All snapshot names have to be unique per application.",
"snapshotOperationLimitExceededTitle": "Limit reached", "snapshotOperationLimitExceededTitle": "Limit reached",
"snapshotOperationLimitExceededDescription": "You have reached a limit on the number of running snapshot operations. Please wait until previous operation finishes." "snapshotOperationLimitExceededDescription": "You have reached a limit on the number of running snapshot operations. Please wait until previous operation finishes.",
"disabledPasswordProviderTitle": "Password authentication is disabled.",
"disabledPasswordProviderMessage": "Please use another authentication provider."
}, },
"importerType": { "importerType": {
"csv": "Import a CSV file", "csv": "Import a CSV file",

View file

@ -76,6 +76,7 @@ export class AuthProviderType extends Registerable {
order: this.getOrder(), order: this.getOrder(),
hasAdminSettings: this.getAdminListComponent() !== null, hasAdminSettings: this.getAdminListComponent() !== null,
canCreateNewProviders: authProviderType.can_create_new, canCreateNewProviders: authProviderType.can_create_new,
canDeleteExistingProviders: authProviderType.can_delete_existing,
authProviders: authProviderType.auth_providers, authProviders: authProviderType.auth_providers,
} }
} }
@ -123,14 +124,22 @@ export class PasswordAuthProviderType extends AuthProviderType {
} }
getName() { getName() {
return 'Password' return this.app.i18n.t('authProviderTypes.password')
} }
getProviderName(provider) { getProviderName(provider) {
return this.getName()
}
getAdminListComponent() {
return null
}
getAdminSettingsFormComponent() {
return null return null
} }
getOrder() { getOrder() {
return 100 return 1
} }
} }

View file

@ -13,28 +13,43 @@
<LangPicker /> <LangPicker />
</div> </div>
</div> </div>
<LoginButtons <div v-if="redirectByDefault && defaultRedirectUrl">
show-border="bottom" {{ $t('login.redirecting') }}
:hide-if-no-buttons="loginButtonsCompact" </div>
:invitation="invitation" <div v-else>
:original="original" <LoginButtons
/> show-border="bottom"
<PasswordLogin :invitation="invitation" @success="success"> </PasswordLogin> :hide-if-no-buttons="loginButtonsCompact"
<LoginActions :invitation="invitation" :original="original"> :invitation="invitation"
<li v-if="settings.allow_reset_password"> :original="original"
<nuxt-link :to="{ name: 'forgot-password' }"> />
{{ $t('login.forgotPassword') }} <PasswordLogin
</nuxt-link> v-if="!passwordLoginHidden"
</li> :invitation="invitation"
<li v-if="settings.allow_new_signups"> @success="success"
<slot name="signup"> >
{{ $t('login.signUpText') }} </PasswordLogin>
<nuxt-link :to="{ name: 'signup' }"> <LoginActions :invitation="invitation" :original="original">
{{ $t('login.signUp') }} <li v-if="passwordLoginHidden">
<a @click="passwordLoginHiddenIfDisabled = false">
{{ $t('login.displayPasswordLogin') }}
</a>
</li>
<li v-if="settings.allow_reset_password && !passwordLoginHidden">
<nuxt-link :to="{ name: 'forgot-password' }">
{{ $t('login.forgotPassword') }}
</nuxt-link> </nuxt-link>
</slot> </li>
</li> <li v-if="settings.allow_new_signups">
</LoginActions> <slot name="signup">
{{ $t('login.signUpText') }}
<nuxt-link :to="{ name: 'signup' }">
{{ $t('login.signUp') }}
</nuxt-link>
</slot>
</li>
</LoginActions>
</div>
</div> </div>
</template> </template>
@ -45,7 +60,10 @@ import LoginButtons from '@baserow/modules/core/components/auth/LoginButtons'
import LoginActions from '@baserow/modules/core/components/auth/LoginActions' import LoginActions from '@baserow/modules/core/components/auth/LoginActions'
import PasswordLogin from '@baserow/modules/core/components/auth/PasswordLogin' import PasswordLogin from '@baserow/modules/core/components/auth/PasswordLogin'
import LangPicker from '@baserow/modules/core/components/LangPicker' import LangPicker from '@baserow/modules/core/components/LangPicker'
import { isRelativeUrl } from '@baserow/modules/core/utils/url' import {
isRelativeUrl,
addQueryParamsToRedirectUrl,
} from '@baserow/modules/core/utils/url'
export default { export default {
components: { PasswordLogin, LoginButtons, LangPicker, LoginActions }, components: { PasswordLogin, LoginButtons, LangPicker, LoginActions },
@ -75,12 +93,23 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
redirectByDefault: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
passwordLoginHiddenIfDisabled: true,
}
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
settings: 'settings/get', settings: 'settings/get',
loginActions: 'authProvider/getAllLoginActions', loginActions: 'authProvider/getAllLoginActions',
loginButtons: 'authProvider/getAllLoginButtons', loginButtons: 'authProvider/getAllLoginButtons',
passwordLoginEnabled: 'authProvider/getPasswordLoginEnabled',
}), }),
computedOriginal() { computedOriginal() {
let original = this.original let original = this.original
@ -89,6 +118,24 @@ export default {
} }
return original return original
}, },
passwordLoginHidden() {
return this.passwordLoginHiddenIfDisabled && !this.passwordLoginEnabled
},
defaultRedirectUrl() {
return this.$store.getters['authProvider/getDefaultRedirectUrl']
},
},
mounted() {
if (this.redirectByDefault) {
if (this.defaultRedirectUrl !== null) {
const { groupInvitationToken } = this.$route.query
const url = addQueryParamsToRedirectUrl(this.defaultRedirectUrl, {
original: this.computedOriginal,
groupInvitationToken,
})
window.location = url
}
}
}, },
methods: { methods: {
success() { success() {

View file

@ -178,6 +178,13 @@ export default {
this.$t('error.disabledAccountTitle'), this.$t('error.disabledAccountTitle'),
this.$t('error.disabledAccountMessage') this.$t('error.disabledAccountMessage')
) )
} else if (
response.data?.error === 'ERROR_AUTH_PROVIDER_DISABLED'
) {
this.showError(
this.$t('clientHandler.disabledPasswordProviderTitle'),
this.$t('clientHandler.disabledPasswordProviderMessage')
)
} else { } else {
this.showError( this.showError(
this.$t('error.incorrectCredentialTitle'), this.$t('error.incorrectCredentialTitle'),

View file

@ -49,7 +49,9 @@ export default {
}, },
computed: { computed: {
registeredSettings() { registeredSettings() {
return this.$registry.getOrderedList('settings') return this.$registry
.getOrderedList('settings')
.filter((settings) => settings.isEnabled() === true)
}, },
settingPageComponent() { settingPageComponent() {
const active = Object.values(this.$registry.getAll('settings')).find( const active = Object.values(this.$registry.getAll('settings')).find(
@ -61,6 +63,9 @@ export default {
name: 'auth/getName', name: 'auth/getName',
}), }),
}, },
async mounted() {
await this.$store.dispatch('authProvider/fetchLoginOptions')
},
methods: { methods: {
setPage(page) { setPage(page) {
this.page = page this.page = page

View file

@ -87,7 +87,9 @@
"disabledAccountMessage": "This user account has been disabled.", "disabledAccountMessage": "This user account has been disabled.",
"incorrectCredentialTitle": "Incorrect credentials", "incorrectCredentialTitle": "Incorrect credentials",
"incorrectCredentialMessage": "The provided e-mail address or password is incorrect.", "incorrectCredentialMessage": "The provided e-mail address or password is incorrect.",
"inputRequired": "Input is required." "inputRequired": "Input is required.",
"disabledPasswordProviderTitle": "Password authentication is disabled.",
"disabledPasswordProviderMessage": "Please use another authentication provider."
}, },
"field": { "field": {
"language": "Language", "language": "Language",
@ -291,7 +293,9 @@
"passwordPlaceholder": "Enter your password..", "passwordPlaceholder": "Enter your password..",
"forgotPassword": "Forgot password?", "forgotPassword": "Forgot password?",
"signUpText": "Don't have an account?", "signUpText": "Don't have an account?",
"signUp": "Sign Up" "signUp": "Sign Up",
"displayPasswordLogin": "Log in using email and password",
"redirecting": "Redirecting to authentication provider..."
}, },
"resetPassword": { "resetPassword": {
"title": "Reset password", "title": "Reset password",

View file

@ -4,6 +4,7 @@
:display-header="true" :display-header="true"
:redirect-on-success="true" :redirect-on-success="true"
:invitation="invitation" :invitation="invitation"
:redirect-by-default="redirectByDefault"
></Login> ></Login>
</div> </div>
</template> </template>
@ -35,5 +36,13 @@ export default {
], ],
} }
}, },
computed: {
redirectByDefault() {
if (this.$route.query.noredirect === null) {
return false
}
return true
},
},
} }
</script> </script>

View file

@ -30,27 +30,24 @@
</template> </template>
<template v-else> <template v-else>
<PasswordRegister <PasswordRegister
v-if="afterSignupStep < 0" v-if="afterSignupStep < 0 && passwordLoginEnabled"
:invitation="invitation" :invitation="invitation"
@success="next" @success="next"
> >
<LoginButtons
show-border="top"
:hide-if-no-buttons="true"
:invitation="invitation"
/>
<LoginActions
v-if="!shouldShowAdminSignupPage"
:invitation="invitation"
>
<li>
{{ $t('signup.loginText') }}
<nuxt-link :to="{ name: 'login' }">
{{ $t('action.login') }}
</nuxt-link>
</li>
</LoginActions>
</PasswordRegister> </PasswordRegister>
<LoginButtons
show-border="top"
:hide-if-no-buttons="true"
:invitation="invitation"
/>
<LoginActions v-if="!shouldShowAdminSignupPage" :invitation="invitation">
<li>
{{ $t('signup.loginText') }}
<nuxt-link :to="{ name: 'login' }">
{{ $t('action.login') }}
</nuxt-link>
</li>
</LoginActions>
<component <component
:is="afterSignupStepComponents[afterSignupStep]" :is="afterSignupStepComponents[afterSignupStep]"
v-else v-else
@ -110,6 +107,7 @@ export default {
...mapGetters({ ...mapGetters({
settings: 'settings/get', settings: 'settings/get',
loginActions: 'authProvider/getAllLoginActions', loginActions: 'authProvider/getAllLoginActions',
passwordLoginEnabled: 'authProvider/getPasswordLoginEnabled',
}), }),
}, },
methods: { methods: {

View file

@ -118,6 +118,15 @@ export class ClientErrorMap {
app.i18n.t('clientHandler.snapshotOperationLimitExceededTitle'), app.i18n.t('clientHandler.snapshotOperationLimitExceededTitle'),
app.i18n.t('clientHandler.snapshotOperationLimitExceededDescription') app.i18n.t('clientHandler.snapshotOperationLimitExceededDescription')
), ),
ERROR_AUTH_PROVIDER_DISABLED: new ResponseErrorMessage(
app.i18n.t('clientHandler.disabledPasswordProviderTitle'),
app.i18n.t('clientHandler.disabledPasswordProviderMessage')
),
// TODO: Move to enterprise module if possible
ERROR_CANNOT_DISABLE_ALL_AUTH_PROVIDERS: new ResponseErrorMessage(
app.i18n.t('clientHandler.cannotDisableAllAuthProvidersTitle'),
app.i18n.t('clientHandler.cannotDisableAllAuthProvidersDescription')
),
} }
} }

View file

@ -33,6 +33,10 @@ export class SettingsType extends Registerable {
throw new Error('The component of a settings type must be set.') throw new Error('The component of a settings type must be set.')
} }
isEnabled() {
return true
}
constructor(...args) { constructor(...args) {
super(...args) super(...args)
this.type = this.getType() this.type = this.getType()
@ -98,6 +102,13 @@ export class PasswordSettingsType extends SettingsType {
return i18n.t('settingType.password') return i18n.t('settingType.password')
} }
isEnabled() {
return (
this.app.store.getters['authProvider/getPasswordLoginEnabled'] ||
this.app.store.getters['auth/isStaff']
)
}
getComponent() { getComponent() {
return PasswordSettings return PasswordSettings
} }

View file

@ -62,6 +62,22 @@ export const getters = {
} }
return loginActions return loginActions
}, },
getPasswordLoginEnabled: (state) => {
return state.loginOptions.password
},
getDefaultRedirectUrl: (state) => {
const loginOptionsArr = Object.values(state.loginOptions)
const possibleRedirectLoginOptions = loginOptionsArr.filter(
(loginOption) => loginOption.default_redirect_url
)
if (
loginOptionsArr.length === 1 &&
possibleRedirectLoginOptions.length === 1
) {
return possibleRedirectLoginOptions[0].default_redirect_url
}
return null
},
} }
export default { export default {

View file

@ -46,7 +46,7 @@ export const logoutAndRedirectToLogin = (
showSessionExpiredNotification = false showSessionExpiredNotification = false
) => { ) => {
store.dispatch('auth/logoff') store.dispatch('auth/logoff')
router.push({ name: 'login' }, () => { router.push({ name: 'login', query: { noredirect: null } }, () => {
if (showSessionExpiredNotification) { if (showSessionExpiredNotification) {
store.dispatch('notification/setUserSessionExpired', true) store.dispatch('notification/setUserSessionExpired', true)
} }

View file

@ -2,3 +2,26 @@ export function isRelativeUrl(url) {
const absoluteUrlRegExp = /^(?:[a-z+]+:)?\/\//i const absoluteUrlRegExp = /^(?:[a-z+]+:)?\/\//i
return !absoluteUrlRegExp.test(url) return !absoluteUrlRegExp.test(url)
} }
export function addQueryParamsToRedirectUrl(url, params) {
const parsedUrl = new URL(url)
for (const [key, value] of Object.entries(params)) {
if (['language'].includes(key)) {
parsedUrl.searchParams.append(key, value)
}
}
if (params.original && isRelativeUrl(params.original)) {
parsedUrl.searchParams.append('original', params.original)
}
if (params.groupInvitationToken) {
parsedUrl.searchParams.append(
'group_invitation_token',
params.groupInvitationToken
)
}
return parsedUrl.toString()
}

View file

@ -28,9 +28,12 @@ describe('index redirect', () => {
}, },
}) })
mock mock.onGet('http://localhost/api/auth-provider/login-options/').reply(200, {
.onGet('http://localhost/api/auth-provider/login-options/') password: {
.reply(200, {}) type: 'password',
enabled: true,
},
})
nuxt = await createNuxt(true) nuxt = await createNuxt(true)
done() done()