mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-06 22:08:52 +00:00
Add OAuth2 auth providers
This commit is contained in:
parent
c86779c8e1
commit
6c60ac0922
57 changed files with 2258 additions and 45 deletions
backend/requirements
changelog.mdenterprise
backend
src/baserow_enterprise
tests/baserow_enterprise_tests
web-frontend/modules/baserow_enterprise
assets
images/providers
scss/components
components
AuthProviderIcon.vue
admin
locales
pages
plugin.jsservices
store
web-frontend/modules/core
|
@ -34,4 +34,6 @@ psutil==5.9.0
|
|||
dj-database-url==0.5.0
|
||||
redis==4.1.4
|
||||
pysaml2==7.2.1
|
||||
validators==0.20.0
|
||||
validators==0.20.0
|
||||
requests-oauthlib==1.3.1
|
||||
|
||||
|
|
|
@ -175,6 +175,8 @@ ndg-httpsclient==0.5.1
|
|||
# via advocate
|
||||
netifaces==0.11.0
|
||||
# via advocate
|
||||
oauthlib==3.2.1
|
||||
# via requests-oauthlib
|
||||
packaging==21.3
|
||||
# via redis
|
||||
pillow==9.0.0
|
||||
|
@ -244,6 +246,9 @@ requests==2.26.0
|
|||
# -r base.in
|
||||
# advocate
|
||||
# pysaml2
|
||||
# requests-oauthlib
|
||||
requests-oauthlib==1.3.1
|
||||
# via -r base.in
|
||||
s3transfer==0.5.2
|
||||
# via boto3
|
||||
service-identity==21.1.0
|
||||
|
|
|
@ -10,8 +10,8 @@ For example:
|
|||
## Unreleased
|
||||
|
||||
### New Features
|
||||
* Background pending tasks like duplication and template_install are restored in a new frontend session if unfinished. [#885](https://gitlab.com/bramw/baserow/-/issues/885)
|
||||
|
||||
* Background pending tasks like duplication and template_install are restored in a new frontend session if unfinished. [#885](https://gitlab.com/bramw/baserow/-/issues/885)
|
||||
* Added Zapier integration code. [#816](https://gitlab.com/bramw/baserow/-/issues/816)
|
||||
* Made it possible to filter on the `created_on` and `updated_on` columns, even though
|
||||
they're not exposed via fields.
|
||||
|
@ -23,7 +23,7 @@ For example:
|
|||
* Upgraded docker containers base images from `debian:buster-slim` to the latest stable `debian:bullseye-slim`.
|
||||
* Upgraded python version from `python-3.7.16` to `python-3.9.2`.
|
||||
* Added SAML protocol implementation for Single Sign On as an enterprise feature. [#1227](https://gitlab.com/bramw/baserow/-/issues/1227)
|
||||
|
||||
* Added OAuth2 support for Single Sign On with Google, Facebook, GitHub, and GitLab as preconfigured providers. Added general support for OpenID Connect. [#1254](https://gitlab.com/bramw/baserow/-/issues/1254)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
|
|
@ -33,3 +33,7 @@ class UpdateAuthProviderSerializer(serializers.ModelSerializer):
|
|||
extra_kwargs = {
|
||||
"domain": {"required": False},
|
||||
}
|
||||
|
||||
|
||||
class NextAuthProviderIdSerializer(serializers.Serializer):
|
||||
next_provider_id = serializers.IntegerField(help_text="The next guessed id.")
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from .views import AdminAuthProvidersView, AdminAuthProviderView
|
||||
from .views import (
|
||||
AdminAuthProvidersView,
|
||||
AdminAuthProviderView,
|
||||
AdminNextAuthProvidersView,
|
||||
)
|
||||
|
||||
app_name = "baserow_enterprise.api.sso"
|
||||
|
||||
|
@ -9,4 +13,5 @@ urlpatterns = [
|
|||
re_path(
|
||||
r"(?P<auth_provider_id>[0-9]+)/$", AdminAuthProviderView.as_view(), name="item"
|
||||
),
|
||||
re_path(r"^next-id/$", AdminNextAuthProvidersView.as_view(), name="next_id"),
|
||||
]
|
||||
|
|
|
@ -22,7 +22,11 @@ from baserow_enterprise.auth_provider.handler import AuthProviderHandler
|
|||
from baserow_enterprise.sso.utils import check_sso_feature_is_active_or_raise
|
||||
|
||||
from .errors import ERROR_AUTH_PROVIDER_DOES_NOT_EXIST
|
||||
from .serializers import CreateAuthProviderSerializer, UpdateAuthProviderSerializer
|
||||
from .serializers import (
|
||||
CreateAuthProviderSerializer,
|
||||
NextAuthProviderIdSerializer,
|
||||
UpdateAuthProviderSerializer,
|
||||
)
|
||||
|
||||
|
||||
class AdminAuthProvidersView(APIView):
|
||||
|
@ -210,3 +214,25 @@ class AdminAuthProviderView(APIView):
|
|||
provider = handler.get_auth_provider(auth_provider_id)
|
||||
handler.delete_auth_provider(request.user, provider)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class AdminNextAuthProvidersView(APIView):
|
||||
permission_classes = (IsAdminUser,)
|
||||
|
||||
@extend_schema(
|
||||
exclude=True,
|
||||
tags=["Auth"],
|
||||
request=None,
|
||||
operation_id="get_next_auth_provider",
|
||||
description=("Returns the guessed next provider's id."),
|
||||
responses={200: NextAuthProviderIdSerializer},
|
||||
)
|
||||
@transaction.atomic
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Returns the guessed next provider's id."""
|
||||
|
||||
check_sso_feature_is_active_or_raise()
|
||||
|
||||
return Response(
|
||||
{"next_provider_id": AuthProviderHandler.get_next_provider_id()}
|
||||
)
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
from rest_framework.status import HTTP_404_NOT_FOUND
|
||||
|
||||
ERROR_AUTH_PROVIDER_DOES_NOT_EXIST = (
|
||||
"ERROR_AUTH_PROVIDER_DOES_NOT_EXIST",
|
||||
HTTP_404_NOT_FOUND,
|
||||
"The requested auth provider was not found.",
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
|
||||
ERROR_INVALID_PROVIDER_URL = (
|
||||
"ERROR_INVALID_PROVIDER_URL",
|
||||
HTTP_400_BAD_REQUEST,
|
||||
"The specified URL doesn't point to a valid provider of the provider type.",
|
||||
)
|
|
@ -0,0 +1,16 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from .views import OAuth2CallbackView, OAuth2LoginView
|
||||
|
||||
app_name = "baserow_enterprise.api.sso.oauth"
|
||||
|
||||
urlpatterns = [
|
||||
re_path(
|
||||
r"^login/(?P<provider_id>[0-9]+)/$", OAuth2LoginView.as_view(), name="login"
|
||||
),
|
||||
re_path(
|
||||
r"^callback/(?P<provider_id>[0-9]+)/$",
|
||||
OAuth2CallbackView.as_view(),
|
||||
name="callback",
|
||||
),
|
||||
]
|
|
@ -0,0 +1,127 @@
|
|||
from django.db import transaction
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from baserow.core.auth_provider.exceptions import AuthProviderModelNotFound
|
||||
from baserow.core.registries import auth_provider_type_registry
|
||||
from baserow.core.user.exceptions import UserAlreadyExist
|
||||
from baserow_enterprise.api.sso.utils import (
|
||||
SsoErrorCode,
|
||||
redirect_to_sign_in_error_page,
|
||||
redirect_user_on_success,
|
||||
)
|
||||
from baserow_enterprise.auth_provider.exceptions import DifferentAuthProvider
|
||||
from baserow_enterprise.auth_provider.handler import AuthProviderHandler
|
||||
from baserow_enterprise.sso.exceptions import AuthFlowError
|
||||
from baserow_enterprise.sso.utils import is_sso_feature_active
|
||||
|
||||
|
||||
class OAuth2LoginView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="provider_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The id of the provider for redirect.",
|
||||
),
|
||||
],
|
||||
tags=["Auth"],
|
||||
operation_id="oauth_provider_login_redirect",
|
||||
description=(
|
||||
"Redirects to the OAuth2 provider's authentication URL "
|
||||
"based on the provided auth provider's id."
|
||||
),
|
||||
responses={
|
||||
302: None,
|
||||
},
|
||||
auth=[],
|
||||
)
|
||||
@transaction.atomic
|
||||
def get(self, request, provider_id):
|
||||
"""
|
||||
Redirects users to the authorization URL of the chosen provider
|
||||
to start OAuth2 login flow.
|
||||
"""
|
||||
|
||||
if not is_sso_feature_active():
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.FEATURE_NOT_ACTIVE)
|
||||
|
||||
try:
|
||||
provider = AuthProviderHandler.get_auth_provider(provider_id)
|
||||
provider_type = auth_provider_type_registry.get_by_model(provider)
|
||||
except AuthProviderModelNotFound:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.PROVIDER_DOES_NOT_EXIST)
|
||||
|
||||
redirect_url = provider_type.get_authorization_url(
|
||||
provider.specific_class.objects.get(id=provider_id)
|
||||
)
|
||||
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
class OAuth2CallbackView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="provider_id",
|
||||
location=OpenApiParameter.PATH,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The id of the provider for which to process the callback.",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="code",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.INT,
|
||||
description="The id of the provider for which to process the callback.",
|
||||
),
|
||||
],
|
||||
tags=["Auth"],
|
||||
operation_id="oauth_provider_login_callback",
|
||||
description=(
|
||||
"Processes callback from OAuth2 provider and "
|
||||
"logs the user in if successful."
|
||||
),
|
||||
responses={
|
||||
302: None,
|
||||
},
|
||||
auth=[],
|
||||
)
|
||||
@transaction.atomic
|
||||
def get(self, request, provider_id):
|
||||
"""
|
||||
Processes callback from OAuth2 authentication provider by
|
||||
using the 'code' parameter to obtain tokens and query for user
|
||||
details. If successful, the user is given JWT token and is logged
|
||||
in.
|
||||
"""
|
||||
|
||||
if not is_sso_feature_active():
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.FEATURE_NOT_ACTIVE)
|
||||
|
||||
try:
|
||||
provider = AuthProviderHandler.get_auth_provider(provider_id)
|
||||
provider_type = auth_provider_type_registry.get_by_model(provider)
|
||||
code = request.query_params.get("code", None)
|
||||
userinfo = provider_type.get_user_info(provider, code)
|
||||
user = AuthProviderHandler.get_or_create_user_and_sign_in_via_auth_provider(
|
||||
userinfo, provider
|
||||
)
|
||||
except AuthProviderModelNotFound:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.PROVIDER_DOES_NOT_EXIST)
|
||||
except AuthFlowError:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.AUTH_FLOW_ERROR)
|
||||
except UserAlreadyExist:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.ERROR_USER_DEACTIVATED)
|
||||
except DifferentAuthProvider:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.DIFFERENT_PROVIDER)
|
||||
|
||||
return redirect_user_on_success(user)
|
|
@ -26,6 +26,7 @@ from baserow_enterprise.api.sso.utils import (
|
|||
redirect_to_sign_in_error_page,
|
||||
redirect_user_on_success,
|
||||
)
|
||||
from baserow_enterprise.auth_provider.exceptions import DifferentAuthProvider
|
||||
from baserow_enterprise.sso.saml.exceptions import (
|
||||
InvalidSamlConfiguration,
|
||||
InvalidSamlRequest,
|
||||
|
@ -61,6 +62,8 @@ class AssertionConsumerServiceView(View):
|
|||
return redirect_to_sign_in_error_page(SsoErrorCode.INVALID_SAML_RESPONSE)
|
||||
except UserAlreadyExist:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.ERROR_USER_DEACTIVATED)
|
||||
except DifferentAuthProvider:
|
||||
return redirect_to_sign_in_error_page(SsoErrorCode.DIFFERENT_PROVIDER)
|
||||
|
||||
requested_url = request.POST["RelayState"]
|
||||
return redirect_user_on_success(user, requested_url)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
from django.urls import include, path
|
||||
|
||||
from .oauth2 import urls as oauth2_urls
|
||||
from .saml import urls as saml_urls
|
||||
|
||||
app_name = "baserow_enterprise.api.sso"
|
||||
|
||||
urlpatterns = [
|
||||
path("saml/", include(saml_urls, namespace="saml")),
|
||||
path("oauth2/", include(oauth2_urls, namespace="oauth2")),
|
||||
]
|
||||
|
|
|
@ -17,6 +17,9 @@ class SsoErrorCode(Enum):
|
|||
INVALID_SAML_REQUEST = "errorInvalidSamlRequest"
|
||||
INVALID_SAML_RESPONSE = "errorInvalidSamlResponse"
|
||||
ERROR_USER_DEACTIVATED = "errorUserDeactivated"
|
||||
PROVIDER_DOES_NOT_EXIST = "errorProviderDoesNotExist"
|
||||
AUTH_FLOW_ERROR = "errorAuthFlowError"
|
||||
DIFFERENT_PROVIDER = "errorDifferentProvider"
|
||||
|
||||
|
||||
def redirect_to_sign_in_error_page(
|
||||
|
|
|
@ -91,9 +91,21 @@ class BaserowEnterpriseConfig(AppConfig):
|
|||
license_type_registry.register(EnterpriseLicenseType())
|
||||
|
||||
from baserow.core.registries import auth_provider_type_registry
|
||||
from baserow_enterprise.sso.oauth2.auth_provider_types import (
|
||||
FacebookAuthProviderType,
|
||||
GitHubAuthProviderType,
|
||||
GitLabAuthProviderType,
|
||||
GoogleAuthProviderType,
|
||||
OpenIdConnectAuthProviderType,
|
||||
)
|
||||
from baserow_enterprise.sso.saml.auth_provider_types import SamlAuthProviderType
|
||||
|
||||
auth_provider_type_registry.register(SamlAuthProviderType())
|
||||
auth_provider_type_registry.register(GoogleAuthProviderType())
|
||||
auth_provider_type_registry.register(FacebookAuthProviderType())
|
||||
auth_provider_type_registry.register(GitHubAuthProviderType())
|
||||
auth_provider_type_registry.register(GitLabAuthProviderType())
|
||||
auth_provider_type_registry.register(OpenIdConnectAuthProviderType())
|
||||
|
||||
# Create default roles
|
||||
post_migrate.connect(sync_default_roles_after_migrate, sender=self)
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class DifferentAuthProvider(Exception):
|
||||
"""
|
||||
Raised when logging in an existing user that should not
|
||||
be logged in using a different than the approved auth provider.
|
||||
"""
|
|
@ -2,7 +2,7 @@ from dataclasses import dataclass
|
|||
from typing import Any, Dict, Optional, Type
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import transaction
|
||||
from django.db import connection, transaction
|
||||
|
||||
from baserow.core.auth_provider.auth_provider_types import AuthProviderType
|
||||
from baserow.core.auth_provider.exceptions import AuthProviderModelNotFound
|
||||
|
@ -10,6 +10,7 @@ from baserow.core.auth_provider.models import AuthProviderModel
|
|||
from baserow.core.registries import auth_provider_type_registry
|
||||
from baserow.core.user.exceptions import UserNotFound
|
||||
from baserow.core.user.handler import UserHandler
|
||||
from baserow_enterprise.auth_provider.exceptions import DifferentAuthProvider
|
||||
|
||||
SpecificAuthProviderModel = Type[AuthProviderModel]
|
||||
|
||||
|
@ -111,7 +112,10 @@ class AuthProviderHandler:
|
|||
user_handler = UserHandler()
|
||||
try:
|
||||
user = user_handler.get_active_user(email=user_info.email)
|
||||
user_handler.user_signed_in_via_provider(user, auth_provider.specific)
|
||||
|
||||
is_original_provider = auth_provider.users.filter(id=user.id).exists()
|
||||
if not is_original_provider:
|
||||
raise DifferentAuthProvider()
|
||||
except UserNotFound:
|
||||
with transaction.atomic():
|
||||
user = user_handler.create_user(
|
||||
|
@ -123,3 +127,15 @@ class AuthProviderHandler:
|
|||
)
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_next_provider_id() -> int:
|
||||
"""
|
||||
Returns the next provider id so that the callback URL
|
||||
can be guessed before the provided is created.
|
||||
"""
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT last_value + 1 FROM core_authprovidermodel_id_seq;")
|
||||
row = cursor.fetchone()
|
||||
return int(row[0])
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
# Generated by Django 3.2.13 on 2022-10-28 07:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0035_add_auth_providers"),
|
||||
("baserow_enterprise", "0004_add_rbac_roles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FacebookAuthProviderModel",
|
||||
fields=[
|
||||
(
|
||||
"authprovidermodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="core.authprovidermodel",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
help_text="App ID, or consumer key", max_length=191
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
models.CharField(
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
max_length=191,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("core.authprovidermodel",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GitHubAuthProviderModel",
|
||||
fields=[
|
||||
(
|
||||
"authprovidermodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="core.authprovidermodel",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
help_text="App ID, or consumer key", max_length=191
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
models.CharField(
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
max_length=191,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("core.authprovidermodel",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GitLabAuthProviderModel",
|
||||
fields=[
|
||||
(
|
||||
"authprovidermodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="core.authprovidermodel",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"base_url",
|
||||
models.URLField(help_text="Base URL of the authorization server"),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
help_text="App ID, or consumer key", max_length=191
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
models.CharField(
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
max_length=191,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("core.authprovidermodel",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="GoogleAuthProviderModel",
|
||||
fields=[
|
||||
(
|
||||
"authprovidermodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="core.authprovidermodel",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
help_text="App ID, or consumer key", max_length=191
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
models.CharField(
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
max_length=191,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("core.authprovidermodel",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OpenIdConnectAuthProviderModel",
|
||||
fields=[
|
||||
(
|
||||
"authprovidermodel_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="core.authprovidermodel",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"base_url",
|
||||
models.URLField(help_text="Base URL of the authorization server"),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
help_text="App ID, or consumer key", max_length=191
|
||||
),
|
||||
),
|
||||
(
|
||||
"secret",
|
||||
models.CharField(
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
max_length=191,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_url",
|
||||
models.URLField(help_text="URL to initiate auth flow"),
|
||||
),
|
||||
(
|
||||
"access_token_url",
|
||||
models.URLField(help_text="URL to obtain access token"),
|
||||
),
|
||||
("user_info_url", models.URLField(help_text="URL to get user info")),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=("core.authprovidermodel",),
|
||||
),
|
||||
]
|
16
enterprise/backend/src/baserow_enterprise/sso/exceptions.py
Normal file
16
enterprise/backend/src/baserow_enterprise/sso/exceptions.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
class AuthProviderDoesNotExist(Exception):
|
||||
"""
|
||||
Raised when a requested auth provider doesn't exist.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidProviderUrl(Exception):
|
||||
"""
|
||||
Specified auth provider doesn't exist on this URL or doesn't work.
|
||||
"""
|
||||
|
||||
|
||||
class AuthFlowError(Exception):
|
||||
"""
|
||||
Error occured during the auth flow.
|
||||
"""
|
|
@ -0,0 +1,440 @@
|
|||
import logging
|
||||
import urllib
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
import requests
|
||||
from requests_oauthlib import OAuth2Session
|
||||
from requests_oauthlib.compliance_fixes import facebook_compliance_fix
|
||||
|
||||
from baserow.api.utils import ExceptionMappingType
|
||||
from baserow.core.auth_provider.auth_provider_types import AuthProviderType
|
||||
from baserow.core.auth_provider.models import AuthProviderModel
|
||||
from baserow_enterprise.api.sso.oauth2.errors import ERROR_INVALID_PROVIDER_URL
|
||||
from baserow_enterprise.auth_provider.handler import UserInfo
|
||||
from baserow_enterprise.sso.exceptions import AuthFlowError, InvalidProviderUrl
|
||||
from baserow_enterprise.sso.utils import is_sso_feature_active
|
||||
|
||||
from .models import (
|
||||
FacebookAuthProviderModel,
|
||||
GitHubAuthProviderModel,
|
||||
GitLabAuthProviderModel,
|
||||
GoogleAuthProviderModel,
|
||||
OpenIdConnectAuthProviderModel,
|
||||
)
|
||||
|
||||
OAUTH_BACKEND_URL = settings.PUBLIC_BACKEND_URL
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WellKnownUrls:
|
||||
authorization_url: str
|
||||
access_token_url: str
|
||||
user_info_url: str
|
||||
|
||||
|
||||
class OAuth2AuthProviderMixin:
|
||||
"""
|
||||
Mixin that can be used together with a subclass of AuthProviderType
|
||||
to reuse some common OAuth2 logic.
|
||||
|
||||
Expects the following to be set:
|
||||
- self.type
|
||||
- self.model_class
|
||||
- self.AUTHORIZATION_URL
|
||||
- self.SCOPE
|
||||
"""
|
||||
|
||||
def can_create_new_providers(self):
|
||||
return True
|
||||
|
||||
def get_login_options(self, **kwargs) -> Optional[Dict[str, Any]]:
|
||||
if not is_sso_feature_active():
|
||||
return {}
|
||||
|
||||
instances = self.model_class.objects.all()
|
||||
items = []
|
||||
for instance in instances:
|
||||
if instance.enabled:
|
||||
items.append(
|
||||
{
|
||||
"redirect_url": urllib.parse.urljoin(
|
||||
OAUTH_BACKEND_URL,
|
||||
reverse(
|
||||
"api:enterprise:sso:oauth2:login", args=(instance.id,)
|
||||
),
|
||||
),
|
||||
"name": instance.name,
|
||||
"type": self.type,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"type": self.type,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
def get_base_url(self, instance: AuthProviderModel) -> str:
|
||||
"""
|
||||
Returns base URL for the provider instance.
|
||||
|
||||
:param instance: A subclass of AuthProviderModel for which to
|
||||
return base URL.
|
||||
:return: Base URL.
|
||||
"""
|
||||
|
||||
return self.AUTHORIZATION_URL
|
||||
|
||||
def get_authorization_url(self, instance: AuthProviderModel) -> str:
|
||||
"""
|
||||
Generates authorization URL for the instance provider that will
|
||||
start OAuth2 login flow.
|
||||
|
||||
:param instance: A subclass of AuthProviderModel for which to
|
||||
generate the login URL.
|
||||
:return: URL that will redirect user to the provider's login
|
||||
page.
|
||||
"""
|
||||
|
||||
oauth = self.get_oauth_session(instance)
|
||||
authorization_url, state = oauth.authorization_url(self.get_base_url(instance))
|
||||
return authorization_url
|
||||
|
||||
def get_oauth_session(self, instance: AuthProviderModel) -> OAuth2Session:
|
||||
"""
|
||||
Creates OAuth2Session client to be used to interact with the provider
|
||||
during OAuth2 flow.
|
||||
|
||||
:param instance: A subclass of AuthProviderModel for which to
|
||||
create the session client.
|
||||
:return: HTTP client with the correct session.
|
||||
"""
|
||||
|
||||
redirect_uri = urllib.parse.urljoin(
|
||||
OAUTH_BACKEND_URL,
|
||||
reverse("api:enterprise:sso:oauth2:callback", args=(instance.id,)),
|
||||
)
|
||||
return OAuth2Session(
|
||||
instance.client_id, redirect_uri=redirect_uri, scope=self.SCOPE
|
||||
)
|
||||
|
||||
|
||||
class GoogleAuthProviderType(OAuth2AuthProviderMixin, AuthProviderType):
|
||||
"""
|
||||
The Google authentication provider type allows users to
|
||||
login using OAuth2 through Google.
|
||||
"""
|
||||
|
||||
type = "google"
|
||||
model_class = GoogleAuthProviderModel
|
||||
allowed_fields = ["id", "enabled", "name", "client_id", "secret"]
|
||||
serializer_field_names = ["enabled", "name", "client_id", "secret"]
|
||||
|
||||
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
SCOPE = [
|
||||
"openid",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
]
|
||||
|
||||
ACCESS_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token" # nosec B105
|
||||
USER_INFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" # nosec B105
|
||||
|
||||
def get_user_info(self, instance: GoogleAuthProviderModel, code: str) -> UserInfo:
|
||||
"""
|
||||
Queries the provider to obtain user info data (name and email).
|
||||
|
||||
:param instance: Provider model that will be used to retrieve the user
|
||||
info.
|
||||
:param code: The security code that was passed from the provider to
|
||||
the callback endpoint.
|
||||
:raises AuthFlowError if the provider is unavailable, misconfigured or
|
||||
the provided code is not valid.
|
||||
:return: User info with user's name and email.
|
||||
"""
|
||||
|
||||
try:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
oauth.fetch_token(
|
||||
self.ACCESS_TOKEN_URL,
|
||||
code=code,
|
||||
client_secret=instance.secret,
|
||||
)
|
||||
response = oauth.get(self.USER_INFO_URL)
|
||||
name = response.json().get("name", None)
|
||||
email = response.json().get("email", None)
|
||||
return UserInfo(name=name, email=email)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise AuthFlowError()
|
||||
|
||||
|
||||
class GitHubAuthProviderType(OAuth2AuthProviderMixin, AuthProviderType):
|
||||
"""
|
||||
The Github authentication provider type allows users to
|
||||
login using OAuth2 through Github.
|
||||
"""
|
||||
|
||||
type = "github"
|
||||
model_class = GitHubAuthProviderModel
|
||||
allowed_fields = ["id", "enabled", "name", "client_id", "secret"]
|
||||
serializer_field_names = ["enabled", "name", "client_id", "secret"]
|
||||
|
||||
AUTHORIZATION_URL = "https://github.com/login/oauth/authorize"
|
||||
SCOPE = "read:user,user:email"
|
||||
|
||||
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" # nosec B105
|
||||
USER_INFO_URL = "https://api.github.com/user" # nosec B105
|
||||
EMAILS_URL = "https://api.github.com/user/emails"
|
||||
|
||||
def get_user_info(self, instance: GitHubAuthProviderModel, code: str) -> UserInfo:
|
||||
"""
|
||||
Queries the provider to obtain user info data (name and email).
|
||||
|
||||
:param instance: Provider model that will be used to retrieve the user
|
||||
info.
|
||||
:param code: The security code that was passed from the provider to
|
||||
the callback endpoint.
|
||||
:raises AuthFlowError if the provider is unavailable, misconfigured or
|
||||
the provided code is not valid.
|
||||
:return: User info with user's name and email.
|
||||
"""
|
||||
|
||||
try:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
token = oauth.fetch_token(
|
||||
self.ACCESS_TOKEN_URL,
|
||||
code=code,
|
||||
client_secret=instance.secret,
|
||||
)
|
||||
response = oauth.get(self.USER_INFO_URL)
|
||||
name = response.json().get("name", None)
|
||||
email = self.get_email(
|
||||
{"Authorization": "token {}".format(token.get("access_token"))},
|
||||
)
|
||||
return UserInfo(name=name, email=email)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise AuthFlowError()
|
||||
|
||||
def get_email(self, headers) -> str:
|
||||
"""
|
||||
Helper method to obtain user's email from GitHub.
|
||||
|
||||
:param headers: Authorization headers that will authorize the request.
|
||||
:return: User's email.
|
||||
"""
|
||||
|
||||
email = None
|
||||
resp = requests.get(self.EMAILS_URL, headers=headers)
|
||||
resp.raise_for_status()
|
||||
emails = resp.json()
|
||||
if resp.status_code == 200 and emails:
|
||||
email = emails[0]
|
||||
primary_emails = [
|
||||
e for e in emails if not isinstance(e, dict) or e.get("primary")
|
||||
]
|
||||
if primary_emails:
|
||||
email = primary_emails[0]
|
||||
if isinstance(email, dict):
|
||||
email = email.get("email", "")
|
||||
return email
|
||||
|
||||
|
||||
class GitLabAuthProviderType(OAuth2AuthProviderMixin, AuthProviderType):
|
||||
"""
|
||||
The GitLab authentication provider type allows users to
|
||||
login using OAuth2 through GitLab.
|
||||
"""
|
||||
|
||||
type = "gitlab"
|
||||
model_class = GitLabAuthProviderModel
|
||||
allowed_fields = ["id", "enabled", "name", "base_url", "client_id", "secret"]
|
||||
serializer_field_names = ["enabled", "name", "base_url", "client_id", "secret"]
|
||||
|
||||
AUTHORIZATION_PATH = "/oauth/authorize"
|
||||
SCOPE = ["read_user"]
|
||||
|
||||
ACCESS_TOKEN_PATH = "/oauth/token" # nosec B105
|
||||
USER_INFO_PATH = "/api/v4/user" # nosec B105
|
||||
|
||||
def get_base_url(self, instance: AuthProviderModel) -> str:
|
||||
return f"{instance.base_url}{self.AUTHORIZATION_PATH}"
|
||||
|
||||
def get_user_info(self, instance: GitLabAuthProviderModel, code: str) -> UserInfo:
|
||||
"""
|
||||
Queries the provider to obtain user info data (name and email).
|
||||
|
||||
:param instance: Provider model that will be used to retrieve the user
|
||||
info.
|
||||
:param code: The security code that was passed from the provider to
|
||||
the callback endpoint.
|
||||
:raises AuthFlowError if the provider is unavailable, misconfigured or
|
||||
the provided code is not valid.
|
||||
:return: User info with user's name and email.
|
||||
"""
|
||||
|
||||
try:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
oauth.fetch_token(
|
||||
instance.base_url + self.ACCESS_TOKEN_PATH,
|
||||
code=code,
|
||||
client_secret=instance.secret,
|
||||
)
|
||||
response = oauth.get(instance.base_url + self.USER_INFO_PATH)
|
||||
name = response.json().get("name", None)
|
||||
email = response.json().get("email", None)
|
||||
return UserInfo(name=name, email=email)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise AuthFlowError()
|
||||
|
||||
|
||||
class FacebookAuthProviderType(OAuth2AuthProviderMixin, AuthProviderType):
|
||||
"""
|
||||
The Facebook authentication provider type allows users to
|
||||
login using OAuth2 through Facebook.
|
||||
"""
|
||||
|
||||
type = "facebook"
|
||||
model_class = FacebookAuthProviderModel
|
||||
allowed_fields = ["id", "enabled", "name", "client_id", "secret"]
|
||||
serializer_field_names = ["enabled", "name", "client_id", "secret"]
|
||||
|
||||
AUTHORIZATION_URL = "https://www.facebook.com/dialog/oauth"
|
||||
SCOPE = ["email"]
|
||||
|
||||
ACCESS_TOKEN_URL = "https://graph.facebook.com/oauth/access_token" # nosec B105
|
||||
USER_INFO_URL = "https://graph.facebook.com/me?fields=id,email,name" # nosec B105
|
||||
|
||||
def get_authorization_url(self, instance: FacebookAuthProviderModel) -> str:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
oauth = facebook_compliance_fix(oauth)
|
||||
authorization_url, state = oauth.authorization_url(self.AUTHORIZATION_URL)
|
||||
return authorization_url
|
||||
|
||||
def get_user_info(self, instance: FacebookAuthProviderModel, code: str) -> UserInfo:
|
||||
"""
|
||||
Queries the provider to obtain user info data (name and email).
|
||||
|
||||
:param instance: Provider model that will be used to retrieve the user
|
||||
info.
|
||||
:param code: The security code that was passed from the provider to
|
||||
the callback endpoint.
|
||||
:raises AuthFlowError if the provider is unavailable, misconfigured or
|
||||
the provided code is not valid.
|
||||
:return: User info with user's name and email.
|
||||
"""
|
||||
|
||||
try:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
oauth = facebook_compliance_fix(oauth)
|
||||
oauth.fetch_token(
|
||||
self.ACCESS_TOKEN_URL,
|
||||
code=code,
|
||||
client_secret=instance.secret,
|
||||
)
|
||||
response = oauth.get(self.USER_INFO_URL)
|
||||
name = response.json().get("name", None)
|
||||
email = response.json().get("email", None)
|
||||
return UserInfo(name=name, email=email)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise AuthFlowError()
|
||||
|
||||
|
||||
class OpenIdConnectAuthProviderType(OAuth2AuthProviderMixin, AuthProviderType):
|
||||
"""
|
||||
The OpenId authentication provider type allows users to
|
||||
login using OAuth2 through OpenId Connect compatible provider.
|
||||
"""
|
||||
|
||||
type = "openid_connect"
|
||||
model_class = OpenIdConnectAuthProviderModel
|
||||
allowed_fields = [
|
||||
"id",
|
||||
"enabled",
|
||||
"name",
|
||||
"base_url",
|
||||
"client_id",
|
||||
"secret",
|
||||
"authorization_url",
|
||||
"access_token_url",
|
||||
"user_info_url",
|
||||
]
|
||||
serializer_field_names = ["enabled", "name", "base_url", "client_id", "secret"]
|
||||
api_exceptions_map: ExceptionMappingType = {
|
||||
InvalidProviderUrl: ERROR_INVALID_PROVIDER_URL
|
||||
}
|
||||
|
||||
SCOPE = ["openid", "email", "profile"]
|
||||
|
||||
def create(self, **values):
|
||||
urls = self.get_wellknown_urls(values["base_url"])
|
||||
return super().create(**values, **asdict(urls))
|
||||
|
||||
def update(self, provider, **values):
|
||||
if values.get("base_url"):
|
||||
urls = self.get_wellknown_urls(values["base_url"])
|
||||
return super().update(provider, **values, **asdict(urls))
|
||||
return super().update(provider, **values)
|
||||
|
||||
def get_base_url(self, instance: AuthProviderModel) -> str:
|
||||
return instance.authorization_url
|
||||
|
||||
def get_user_info(
|
||||
self, instance: OpenIdConnectAuthProviderModel, code: str
|
||||
) -> UserInfo:
|
||||
"""
|
||||
Queries the provider to obtain user info data (name and email).
|
||||
|
||||
:param instance: Provider model that will be used to retrieve the user
|
||||
info.
|
||||
:param code: The security code that was passed from the provider to
|
||||
the callback endpoint.
|
||||
:raises AuthFlowError if the provider is unavailable, misconfigured or
|
||||
the provided code is not valid.
|
||||
:return: User info with user's name and email.
|
||||
"""
|
||||
|
||||
try:
|
||||
oauth = self.get_oauth_session(instance)
|
||||
oauth.fetch_token(
|
||||
instance.access_token_url,
|
||||
code=code,
|
||||
client_secret=instance.secret,
|
||||
)
|
||||
response = oauth.get(instance.user_info_url)
|
||||
name = response.json().get("name", None)
|
||||
email = response.json().get("email", None)
|
||||
return UserInfo(name=name, email=email)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise AuthFlowError()
|
||||
|
||||
def get_wellknown_urls(self, base_url: str) -> WellKnownUrls:
|
||||
"""
|
||||
Queries the provider "wellknown URL endpoint" to retrieve OpenId Connect
|
||||
wellknown URLS like authorization url, access token url or user info url.
|
||||
|
||||
:param base_url: The provider base URL (domain URL).
|
||||
:return: The collection of well-known OpenId Connect URL needed to
|
||||
work with the provider.
|
||||
"""
|
||||
|
||||
try:
|
||||
wellknown_url = f"{base_url}/.well-known/openid-configuration"
|
||||
response = requests.get(wellknown_url)
|
||||
return WellKnownUrls(
|
||||
authorization_url=response.json()["authorization_endpoint"],
|
||||
access_token_url=response.json()["token_endpoint"],
|
||||
user_info_url=response.json()["userinfo_endpoint"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
raise InvalidProviderUrl()
|
|
@ -0,0 +1,78 @@
|
|||
from django.db import models
|
||||
|
||||
from baserow.core.auth_provider.models import AuthProviderModel
|
||||
|
||||
|
||||
class GoogleAuthProviderModel(AuthProviderModel):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
client_id = models.CharField(
|
||||
max_length=191,
|
||||
help_text="App ID, or consumer key",
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=191,
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
)
|
||||
|
||||
|
||||
class FacebookAuthProviderModel(AuthProviderModel):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
client_id = models.CharField(
|
||||
max_length=191,
|
||||
help_text="App ID, or consumer key",
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=191,
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
)
|
||||
|
||||
|
||||
class GitHubAuthProviderModel(AuthProviderModel):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
client_id = models.CharField(
|
||||
max_length=191,
|
||||
help_text="App ID, or consumer key",
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=191,
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
)
|
||||
|
||||
|
||||
class GitLabAuthProviderModel(AuthProviderModel):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
base_url = models.URLField(help_text="Base URL of the authorization server")
|
||||
client_id = models.CharField(
|
||||
max_length=191,
|
||||
help_text="App ID, or consumer key",
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=191,
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
)
|
||||
|
||||
|
||||
class OpenIdConnectAuthProviderModel(AuthProviderModel):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
)
|
||||
base_url = models.URLField(help_text="Base URL of the authorization server")
|
||||
client_id = models.CharField(
|
||||
max_length=191,
|
||||
help_text="App ID, or consumer key",
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=191,
|
||||
help_text="API secret, client secret, or consumer secret",
|
||||
)
|
||||
authorization_url = models.URLField(help_text="URL to initiate auth flow")
|
||||
access_token_url = models.URLField(help_text="URL to obtain access token")
|
||||
user_info_url = models.URLField(help_text="URL to get user info")
|
|
@ -5,7 +5,6 @@ from baserow_enterprise.features import SSO
|
|||
|
||||
|
||||
def is_sso_feature_active():
|
||||
# return True
|
||||
return LicenseHandler.instance_has_feature(SSO)
|
||||
|
||||
|
||||
|
|
|
@ -533,3 +533,211 @@ def test_admin_can_get_saml_provider_with_an_enterprise_license(
|
|||
assert response_json["domain"] == saml_provider_1.domain
|
||||
assert response_json["metadata"] == saml_provider_1.metadata
|
||||
assert response_json["is_verified"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type,extra_params",
|
||||
[
|
||||
("google", {}),
|
||||
("facebook", {}),
|
||||
("github", {}),
|
||||
("gitlab", {"base_url": "https://gitlab.com"}),
|
||||
("openid_connect", {"base_url": "https://gitlab.com"}),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True)
|
||||
def test_create_and_get_oauth2_provider(
|
||||
api_client, data_fixture, enterprise_data_fixture, provider_type, extra_params
|
||||
):
|
||||
"""
|
||||
Tests that a provider can be successfully created, and that login options
|
||||
endpoint will output correct information for the created provider.
|
||||
"""
|
||||
|
||||
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
|
||||
|
||||
# create provider
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:enterprise:admin:auth_provider:list"),
|
||||
{
|
||||
"type": provider_type,
|
||||
"name": "Provider name",
|
||||
"client_id": "clientid",
|
||||
"secret": "secret",
|
||||
**extra_params,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] is not None
|
||||
assert response_json["type"] == provider_type
|
||||
assert response_json["name"] == "Provider name"
|
||||
assert response_json["client_id"] == "clientid"
|
||||
assert response_json["secret"] == "secret"
|
||||
assert response_json["enabled"] is True
|
||||
|
||||
# get created provider
|
||||
|
||||
response = api_client.get(
|
||||
reverse(
|
||||
"api:enterprise:admin:auth_provider:item",
|
||||
kwargs={"auth_provider_id": response_json["id"]},
|
||||
),
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["type"] == provider_type
|
||||
assert response_json["enabled"] is True
|
||||
assert response_json["name"] == "Provider name"
|
||||
assert response_json["client_id"] == "clientid"
|
||||
assert response_json["secret"] == "secret"
|
||||
for param in extra_params:
|
||||
assert response_json[param] == extra_params[param]
|
||||
|
||||
# ensure the login option is now listed in the login options
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:auth_provider:login_options"),
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
response_json = response.json()
|
||||
assert response_json[provider_type]["items"][0]["name"] == "Provider name"
|
||||
assert response_json[provider_type]["items"][0]["type"] == provider_type
|
||||
assert response_json[provider_type]["items"][0]["redirect_url"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type,required_params",
|
||||
[
|
||||
("google", ["name", "client_id", "secret"]),
|
||||
("facebook", ["name", "client_id", "secret"]),
|
||||
("github", ["name", "client_id", "secret"]),
|
||||
("gitlab", ["name", "client_id", "secret", "base_url"]),
|
||||
("openid_connect", ["name", "client_id", "secret", "base_url"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_create_oauth2_provider_required_fields(
|
||||
api_client, data_fixture, enterprise_data_fixture, provider_type, required_params
|
||||
):
|
||||
|
||||
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
|
||||
response = api_client.post(
|
||||
reverse("api:enterprise:admin:auth_provider:list"),
|
||||
{
|
||||
"type": provider_type,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
|
||||
for param in required_params:
|
||||
assert param in response_json["detail"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type,extra_params",
|
||||
[
|
||||
("google", {}),
|
||||
("facebook", {}),
|
||||
("github", {}),
|
||||
("gitlab", {"base_url": "https://gitlab.com"}),
|
||||
("openid_connect", {"base_url": "https://gitlab.com"}),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True)
|
||||
def test_update_oauth2_provider(
|
||||
api_client, data_fixture, enterprise_data_fixture, provider_type, extra_params
|
||||
):
|
||||
"""
|
||||
Tests that a provider can be updated after it is created.
|
||||
"""
|
||||
|
||||
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
|
||||
|
||||
provider = enterprise_data_fixture.create_oauth_provider(
|
||||
type=provider_type, **extra_params
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:enterprise:admin:auth_provider:item",
|
||||
kwargs={"auth_provider_id": provider.id},
|
||||
),
|
||||
{
|
||||
"name": "Provider name updated",
|
||||
"client_id": "clientid updated",
|
||||
"secret": "secret updated",
|
||||
"enabled": False,
|
||||
**extra_params,
|
||||
},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json["id"] is not None
|
||||
assert response_json["type"] == provider_type
|
||||
assert response_json["name"] == "Provider name updated"
|
||||
assert response_json["client_id"] == "clientid updated"
|
||||
assert response_json["secret"] == "secret updated"
|
||||
assert response_json["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_type",
|
||||
[
|
||||
"gitlab",
|
||||
"openid_connect",
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
@override_settings(DEBUG=True)
|
||||
def test_update_oauth_provider_invalid_url(
|
||||
api_client, data_fixture, enterprise_data_fixture, provider_type
|
||||
):
|
||||
"""
|
||||
Tests that OAuth provider cannot be updated with
|
||||
invalid URL.
|
||||
"""
|
||||
|
||||
admin, token = enterprise_data_fixture.create_enterprise_admin_user_and_token()
|
||||
|
||||
provider = enterprise_data_fixture.create_oauth_provider(
|
||||
type=provider_type, base_url="https://gitlab.com"
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
reverse(
|
||||
"api:enterprise:admin:auth_provider:item",
|
||||
kwargs={"auth_provider_id": provider.id},
|
||||
),
|
||||
{"base_url": "not_url"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
|
||||
assert json.dumps(response_json["detail"]) == json.dumps(
|
||||
{
|
||||
"base_url": [
|
||||
{
|
||||
"error": "Enter a valid URL.",
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
|
|
@ -12,9 +12,11 @@ VALID_ONE_SEAT_ENTERPRISE_LICENSE = (
|
|||
@pytest.fixture # noqa: F405
|
||||
def enterprise_data_fixture(data_fixture):
|
||||
from .enterprise_fixtures import EnterpriseFixtures
|
||||
from .fixtures.sso import SamlFixture
|
||||
from .fixtures.sso import OAuth2Fixture, SamlFixture
|
||||
|
||||
class EnterpriseFixtures(EnterpriseFixtures, SamlFixture, data_fixture.__class__):
|
||||
class EnterpriseFixtures(
|
||||
EnterpriseFixtures, SamlFixture, OAuth2Fixture, data_fixture.__class__
|
||||
):
|
||||
pass
|
||||
|
||||
return EnterpriseFixtures()
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import faker
|
||||
from baserow_premium.license.models import License
|
||||
|
||||
from baserow.core.models import Settings
|
||||
|
@ -10,6 +11,8 @@ VALID_ONE_SEAT_ENTERPRISE_LICENSE = (
|
|||
|
||||
|
||||
class EnterpriseFixtures:
|
||||
faker = faker.Faker()
|
||||
|
||||
def create_enterprise_admin_user_and_token(self, **kwargs):
|
||||
user, token = self.create_user_and_token(is_staff=True, **kwargs)
|
||||
self.enable_enterprise()
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
from .oauth2 import OAuth2Fixture # noqa: F401
|
||||
from .saml import SamlFixture # noqa: F401
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from .oauth2 import OAuth2Fixture # noqa: F401
|
|
@ -0,0 +1,35 @@
|
|||
from baserow_enterprise.sso.oauth2.models import (
|
||||
FacebookAuthProviderModel,
|
||||
GitHubAuthProviderModel,
|
||||
GitLabAuthProviderModel,
|
||||
GoogleAuthProviderModel,
|
||||
OpenIdConnectAuthProviderModel,
|
||||
)
|
||||
|
||||
|
||||
class OAuth2Fixture:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def create_oauth_provider(self, type, **kwargs):
|
||||
model_mapping = {
|
||||
"facebook": FacebookAuthProviderModel,
|
||||
"google": GoogleAuthProviderModel,
|
||||
"gitlab": GitLabAuthProviderModel,
|
||||
"github": GitHubAuthProviderModel,
|
||||
"openid_connect": OpenIdConnectAuthProviderModel,
|
||||
}
|
||||
|
||||
if "name" not in kwargs:
|
||||
kwargs["name"] = self.faker.name()
|
||||
|
||||
if "client_id" not in kwargs:
|
||||
kwargs["client_id"] = "clientid"
|
||||
|
||||
if "secret" not in kwargs:
|
||||
kwargs["secret"] = "secret"
|
||||
|
||||
if "enabled" not in kwargs:
|
||||
kwargs["enabled"] = True
|
||||
|
||||
return model_mapping[type].objects.create(**kwargs)
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_178_2796)">
|
||||
<path d="M16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 11.993 2.92547 15.3027 6.75 15.9028V10.3125H4.71875V8H6.75V6.2375C6.75 4.2325 7.94438 3.125 9.77172 3.125C10.6467 3.125 11.5625 3.28125 11.5625 3.28125V5.25H10.5538C9.56 5.25 9.25 5.86672 9.25 6.5V8H11.4688L11.1141 10.3125H9.25V15.9028C13.0745 15.3027 16 11.993 16 8Z" fill="#1877F2"/>
|
||||
<path d="M11.1141 10.3125L11.4688 8H9.25V6.5C9.25 5.86734 9.56 5.25 10.5538 5.25H11.5625V3.28125C11.5625 3.28125 10.647 3.125 9.77172 3.125C7.94438 3.125 6.75 4.2325 6.75 6.2375V8H4.71875V10.3125H6.75V15.9028C7.5783 16.0324 8.4217 16.0324 9.25 15.9028V10.3125H11.1141Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_178_2796">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After (image error) Size: 884 B |
|
@ -0,0 +1,10 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_248_42216)">
|
||||
<path d="M8 0C3.58267 0 0 3.582 0 8C0 11.5347 2.292 14.5333 5.47133 15.5913C5.87067 15.6653 6 15.4173 6 15.2067V13.7173C3.77467 14.2013 3.31133 12.7733 3.31133 12.7733C2.94733 11.8487 2.42267 11.6027 2.42267 11.6027C1.69667 11.106 2.478 11.1167 2.478 11.1167C3.28133 11.1727 3.704 11.9413 3.704 11.9413C4.41733 13.164 5.57533 12.8107 6.032 12.606C6.10333 12.0893 6.31067 11.736 6.54 11.5367C4.76333 11.3333 2.89533 10.6473 2.89533 7.58267C2.89533 6.70867 3.208 5.99533 3.71933 5.43533C3.63667 5.23333 3.36267 4.41933 3.79733 3.318C3.79733 3.318 4.46933 3.10333 5.998 4.138C6.636 3.96067 7.32 3.872 8 3.86867C8.68 3.872 9.36467 3.96067 10.004 4.138C11.5313 3.10333 12.202 3.318 12.202 3.318C12.6373 4.42 12.3633 5.234 12.2807 5.43533C12.794 5.99533 13.104 6.70933 13.104 7.58267C13.104 10.6553 11.2327 11.332 9.45133 11.53C9.738 11.778 10 12.2647 10 13.0113V15.2067C10 15.4193 10.128 15.6693 10.534 15.5907C13.7107 14.5313 16 11.5333 16 8C16 3.582 12.418 0 8 0Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_248_42216">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After (image error) Size: 1.2 KiB |
|
@ -0,0 +1,13 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_248_42218)">
|
||||
<path d="M15.7097 6.49951L15.6881 6.44208L13.5138 0.770283C13.4697 0.658915 13.3914 0.564442 13.2902 0.500538C13.2146 0.452166 13.1288 0.422169 13.0395 0.412951C12.9503 0.403734 12.8601 0.415552 12.7762 0.447459C12.6924 0.479366 12.6172 0.530474 12.5566 0.596687C12.4961 0.6629 12.4519 0.742376 12.4276 0.828744L10.9599 5.32208H5.01632L3.54863 0.828744C3.52415 0.742505 3.47985 0.663178 3.41928 0.597092C3.3587 0.531006 3.28352 0.479989 3.19974 0.448108C3.11595 0.416227 3.02587 0.404365 2.93669 0.413468C2.84751 0.422571 2.76168 0.452387 2.68607 0.500538C2.58479 0.564442 2.50649 0.658915 2.46247 0.770283L0.289141 6.4431L0.266577 6.49951C-0.0463191 7.31726 -0.0848668 8.21458 0.156745 9.05615C0.398357 9.89772 0.907031 10.6379 1.60607 11.1652L1.61427 11.1713L1.63273 11.1857L4.94042 13.6646L6.58145 14.9046L7.57837 15.6585C7.69534 15.7468 7.83794 15.7946 7.98453 15.7946C8.13112 15.7946 8.27371 15.7468 8.39068 15.6585L9.3876 14.9046L11.0286 13.6646L14.3599 11.1713L14.3691 11.1641C15.0681 10.6371 15.5769 9.89713 15.8187 9.05577C16.0605 8.2144 16.0222 7.31724 15.7097 6.49951V6.49951Z" fill="#E24329"/>
|
||||
<path d="M15.7097 6.49951L15.6882 6.44208C14.6288 6.65947 13.6306 7.10848 12.7651 7.75695L7.99072 11.3672L11.0307 13.6646L14.362 11.1713L14.3712 11.1641C15.0699 10.6368 15.5782 9.89674 15.8196 9.05539C16.061 8.21404 16.0225 7.31703 15.7097 6.49951V6.49951Z" fill="#FC6D26"/>
|
||||
<path d="M4.94043 13.6646L6.58146 14.9046L7.57838 15.6585C7.69535 15.7468 7.83794 15.7946 7.98453 15.7946C8.13112 15.7946 8.27372 15.7468 8.39069 15.6585L9.38761 14.9046L11.0286 13.6646L7.98863 11.3672L4.94043 13.6646Z" fill="#FCA326"/>
|
||||
<path d="M3.21119 7.7569C2.34602 7.10876 1.34815 6.66009 0.289141 6.44305L0.266577 6.49946C-0.0463191 7.31721 -0.0848668 8.21453 0.156745 9.0561C0.398357 9.89767 0.907031 10.6379 1.60607 11.1651L1.61427 11.1713L1.63273 11.1856L4.94042 13.6646L7.98248 11.3672L3.21119 7.7569Z" fill="#FC6D26"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_248_42218">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After (image error) Size: 2.1 KiB |
|
@ -0,0 +1,13 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_41_11355)">
|
||||
<path d="M15.8442 8.18429C15.8442 7.64047 15.8001 7.09371 15.706 6.55872H8.16016V9.63937H12.4813C12.302 10.6329 11.7258 11.5119 10.8822 12.0704V14.0693H13.4602C14.9741 12.6759 15.8442 10.6182 15.8442 8.18429Z" fill="#4285F4"/>
|
||||
<path d="M10.8717 12.3767L13.0707 14.0817C11.8169 15.1378 10.1413 15.7506 8.15974 15.7506C5.24903 15.7506 2.58565 14.1209 1.26074 11.5344V9.78354H3.49458C4.20777 11.6839 6.02306 13.0847 8.16268 13.0847C9.21039 13.0847 10.1317 12.8338 10.8717 12.3767Z" fill="#34A853" stroke="#828A95" stroke-width="0.5"/>
|
||||
<path d="M3.66852 9.53352C3.33341 8.53996 3.33341 7.46408 3.66852 6.47051V4.40988H1.01116C-0.123511 6.6704 -0.123511 9.33363 1.01116 11.5942L3.66852 9.53352Z" fill="#FBBC04"/>
|
||||
<path d="M8.15974 3.16644C9.30029 3.1488 10.4026 3.57798 11.2286 4.36578L13.5127 2.08174C12.0664 0.72367 10.1469 -0.0229773 8.15974 0.000539111C5.13494 0.000539111 2.36882 1.70548 1.01074 4.40987L3.6681 6.4705C4.3001 4.57449 6.07266 3.16644 8.15974 3.16644Z" fill="#EA4335"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_41_11355">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After (image error) Size: 1.2 KiB |
|
@ -0,0 +1,6 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 10C8.69036 10 9.25 9.44036 9.25 8.75C9.25 8.05964 8.69036 7.5 8 7.5C7.30964 7.5 6.75 8.05964 6.75 8.75C6.75 9.44036 7.30964 10 8 10Z" stroke="#828A95" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 10V11.5" stroke="#828A95" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 5.5H3C2.72386 5.5 2.5 5.72386 2.5 6V13C2.5 13.2761 2.72386 13.5 3 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V6C13.5 5.72386 13.2761 5.5 13 5.5Z" stroke="#828A95" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.75 5.5V3.25C5.75 2.65326 5.98705 2.08097 6.40901 1.65901C6.83097 1.23705 7.40326 1 8 1C8.59674 1 9.16903 1.23705 9.59099 1.65901C10.0129 2.08097 10.25 2.65326 10.25 3.25V5.5" stroke="#828A95" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After (image error) Size: 878 B |
|
@ -0,0 +1,11 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_248_42232)">
|
||||
<path d="M14.4229 6.33514C12.9032 5.3889 10.7814 4.80109 8.45878 4.80109C3.78495 4.80109 0 7.138 0 10.0197C0 12.6577 3.15412 14.8226 7.24014 15.1953V13.6756C4.48746 13.3316 2.42294 11.8262 2.42294 10.0197C2.42294 7.96954 5.11828 6.29213 8.45878 6.29213C10.1219 6.29213 11.6272 6.7079 12.7168 7.38173L11.1685 8.34231H16V5.36023L14.4229 6.33514Z" fill="#CCCCCC"/>
|
||||
<path d="M7.24072 2.22044V13.6756V15.1953L9.66366 13.6756V0.657715L7.24072 2.22044Z" fill="#FF6200"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_248_42232">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After (image error) Size: 713 B |
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.03168 14.5005C11.0694 14.5005 14.3718 9.49823 14.3718 5.16031C14.3718 5.01823 14.3718 4.87679 14.3622 4.73599C15.0047 4.27129 15.5593 3.69591 16 3.03679C15.4009 3.30239 14.7654 3.47649 14.1146 3.55327C14.7999 3.14306 15.3128 2.49779 15.5578 1.73759C14.9134 2.11999 14.2084 2.38947 13.4733 2.53439C12.9783 2.0081 12.3237 1.65961 11.6108 1.54284C10.8978 1.42607 10.1663 1.54753 9.52931 1.88842C8.89234 2.22931 8.38548 2.77064 8.08716 3.42862C7.78884 4.0866 7.71569 4.82456 7.87904 5.52831C6.57393 5.46284 5.29717 5.12366 4.13164 4.53279C2.9661 3.94192 1.93784 3.11256 1.1136 2.09855C0.693819 2.82121 0.565248 3.6767 0.754066 4.49083C0.942885 5.30496 1.43489 6.01652 2.12992 6.48063C1.60749 6.46532 1.09643 6.32438 0.64 6.06975V6.11135C0.640207 6.86925 0.902567 7.60374 1.38258 8.19026C1.86259 8.77677 2.53071 9.17919 3.2736 9.32927C2.79032 9.46109 2.28325 9.48036 1.79136 9.38559C2.00121 10.0378 2.40962 10.6081 2.95949 11.0169C3.50937 11.4256 4.17322 11.6523 4.85824 11.6653C4.17763 12.2002 3.39821 12.5958 2.56458 12.8293C1.73096 13.0627 0.859476 13.1296 0 13.0259C1.50122 13.9892 3.24795 14.5002 5.03168 14.4979" fill="#1DA1F2"/>
|
||||
</svg>
|
After (image error) Size: 1.2 KiB |
|
@ -1,3 +1,5 @@
|
|||
@import "license";
|
||||
@import "auth_provider_admin";
|
||||
@import "saml_settings_form";
|
||||
@import "auth_provider_buttons";
|
||||
@import "auth_provider_icon";
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
.auth-provider-buttons {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid $color-neutral-200;
|
||||
}
|
||||
|
||||
.auth-provider-buttons__button {
|
||||
color: $color-neutral-900;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
border: 1px solid $color-neutral-900;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.auth-provider-buttons__button:hover {
|
||||
text-decoration: none;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
.auth-provider-icon {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-right: 10px;
|
||||
}
|
|
@ -1,15 +1,27 @@
|
|||
import { AuthProviderType } from '@baserow/modules/core/authProviderTypes'
|
||||
|
||||
import SamlLoginAction from '@baserow_enterprise/components/admin/login/SamlLoginAction'
|
||||
import AuthProviderItem from '@baserow_enterprise/components/admin/AuthProviderItem'
|
||||
import SamlSettingsForm from '@baserow_enterprise/components/admin/forms/SamlSettingsForm'
|
||||
import OAuth2SettingsForm from '@baserow_enterprise/components/admin/forms/OAuth2SettingsForm.vue'
|
||||
import GitLabSettingsForm from '@baserow_enterprise/components/admin/forms/GitLabSettingsForm.vue'
|
||||
import OpenIdConnectSettingsForm from '@baserow_enterprise/components/admin/forms/OpenIdConnectSettingsForm.vue'
|
||||
import LoginButton from '@baserow_enterprise/components/admin/login/LoginButton.vue'
|
||||
|
||||
import SAMLIcon from '@baserow_enterprise/assets/images/providers/LockKey.svg'
|
||||
import GoogleIcon from '@baserow_enterprise/assets/images/providers/Google.svg'
|
||||
import FacebookIcon from '@baserow_enterprise/assets/images/providers/Facebook.svg'
|
||||
import GitHubIcon from '@baserow_enterprise/assets/images/providers/GitHub.svg'
|
||||
import GitLabIcon from '@baserow_enterprise/assets/images/providers/GitLab.svg'
|
||||
import OpenIdIcon from '@baserow_enterprise/assets/images/providers/OpenID.svg'
|
||||
|
||||
export class SamlAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'saml'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'fas fa-key'
|
||||
getIcon() {
|
||||
return SAMLIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
@ -54,3 +66,243 @@ export class SamlAuthProviderType extends AuthProviderType {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'google'
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return GoogleIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'Google'
|
||||
}
|
||||
|
||||
getProviderName(provider) {
|
||||
return provider.name ? provider.name : `Google`
|
||||
}
|
||||
|
||||
getLoginButtonComponent() {
|
||||
return LoginButton
|
||||
}
|
||||
|
||||
getAdminListComponent() {
|
||||
return AuthProviderItem
|
||||
}
|
||||
|
||||
getAdminSettingsFormComponent() {
|
||||
return OAuth2SettingsForm
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
|
||||
populateLoginOptions(authProviderOption) {
|
||||
const loginOptions = super.populateLoginOptions(authProviderOption)
|
||||
return {
|
||||
...loginOptions,
|
||||
}
|
||||
}
|
||||
|
||||
populate(authProviderType) {
|
||||
const populated = super.populate(authProviderType)
|
||||
return {
|
||||
...populated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class FacebookAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'facebook'
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return FacebookIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'Facebook'
|
||||
}
|
||||
|
||||
getProviderName(provider) {
|
||||
return provider.name ? provider.name : this.getName()
|
||||
}
|
||||
|
||||
getLoginButtonComponent() {
|
||||
return LoginButton
|
||||
}
|
||||
|
||||
getAdminListComponent() {
|
||||
return AuthProviderItem
|
||||
}
|
||||
|
||||
getAdminSettingsFormComponent() {
|
||||
return OAuth2SettingsForm
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
|
||||
populateLoginOptions(authProviderOption) {
|
||||
const loginOptions = super.populateLoginOptions(authProviderOption)
|
||||
return {
|
||||
...loginOptions,
|
||||
}
|
||||
}
|
||||
|
||||
populate(authProviderType) {
|
||||
const populated = super.populate(authProviderType)
|
||||
return {
|
||||
...populated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'github'
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return GitHubIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'GitHub'
|
||||
}
|
||||
|
||||
getProviderName(provider) {
|
||||
return provider.name ? provider.name : this.getName()
|
||||
}
|
||||
|
||||
getLoginButtonComponent() {
|
||||
return LoginButton
|
||||
}
|
||||
|
||||
getAdminListComponent() {
|
||||
return AuthProviderItem
|
||||
}
|
||||
|
||||
getAdminSettingsFormComponent() {
|
||||
return OAuth2SettingsForm
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
|
||||
populateLoginOptions(authProviderOption) {
|
||||
const loginOptions = super.populateLoginOptions(authProviderOption)
|
||||
return {
|
||||
...loginOptions,
|
||||
}
|
||||
}
|
||||
|
||||
populate(authProviderType) {
|
||||
const populated = super.populate(authProviderType)
|
||||
return {
|
||||
...populated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GitLabAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'gitlab'
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return GitLabIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'GitLab'
|
||||
}
|
||||
|
||||
getProviderName(provider) {
|
||||
return provider.name ? provider.name : this.getName()
|
||||
}
|
||||
|
||||
getLoginButtonComponent() {
|
||||
return LoginButton
|
||||
}
|
||||
|
||||
getAdminListComponent() {
|
||||
return AuthProviderItem
|
||||
}
|
||||
|
||||
getAdminSettingsFormComponent() {
|
||||
return GitLabSettingsForm
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
|
||||
populateLoginOptions(authProviderOption) {
|
||||
const loginOptions = super.populateLoginOptions(authProviderOption)
|
||||
return {
|
||||
...loginOptions,
|
||||
}
|
||||
}
|
||||
|
||||
populate(authProviderType) {
|
||||
const populated = super.populate(authProviderType)
|
||||
return {
|
||||
...populated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenIdConnectAuthProviderType extends AuthProviderType {
|
||||
getType() {
|
||||
return 'openid_connect'
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
return OpenIdIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'OpenID Connect'
|
||||
}
|
||||
|
||||
getProviderName(provider) {
|
||||
return provider.name ? provider.name : this.getName()
|
||||
}
|
||||
|
||||
getLoginButtonComponent() {
|
||||
return LoginButton
|
||||
}
|
||||
|
||||
getAdminListComponent() {
|
||||
return AuthProviderItem
|
||||
}
|
||||
|
||||
getAdminSettingsFormComponent() {
|
||||
return OpenIdConnectSettingsForm
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 50
|
||||
}
|
||||
|
||||
populateLoginOptions(authProviderOption) {
|
||||
const loginOptions = super.populateLoginOptions(authProviderOption)
|
||||
return {
|
||||
...loginOptions,
|
||||
}
|
||||
}
|
||||
|
||||
populate(authProviderType) {
|
||||
const populated = super.populate(authProviderType)
|
||||
return {
|
||||
...populated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<img v-if="icon" :src="icon" class="auth-provider-icon" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="auth-provider-admin__item">
|
||||
<AuthProviderIcon :icon="getIcon()" />
|
||||
<div class="auth-provider-admin__item-name">
|
||||
<i class="auth-provider-admin__item-icon" :class="getIconClass()" />
|
||||
{{ getName() }}
|
||||
</div>
|
||||
<div class="auth-provider-admin__item-menu">
|
||||
|
@ -41,6 +41,7 @@ import SwitchInput from '@baserow/modules/core/components/SwitchInput.vue'
|
|||
import EditAuthProviderMenuContext from '@baserow_enterprise/components/admin/contexts/EditAuthProviderMenuContext.vue'
|
||||
import UpdateSettingsAuthProviderModal from '@baserow_enterprise/components/admin/modals/UpdateSettingsAuthProviderModal.vue'
|
||||
import DeleteAuthProviderModal from '@baserow_enterprise/components/admin/modals/DeleteAuthProviderModal.vue'
|
||||
import AuthProviderIcon from '@baserow_enterprise/components/AuthProviderIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'AuthProviderItem',
|
||||
|
@ -49,6 +50,7 @@ export default {
|
|||
DeleteAuthProviderModal,
|
||||
EditAuthProviderMenuContext,
|
||||
UpdateSettingsAuthProviderModal,
|
||||
AuthProviderIcon,
|
||||
},
|
||||
props: {
|
||||
authProvider: {
|
||||
|
@ -57,10 +59,10 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
getIconClass() {
|
||||
getIcon() {
|
||||
return this.$registry
|
||||
.get('authProvider', this.authProvider.type)
|
||||
.getIconClass()
|
||||
.getIcon()
|
||||
},
|
||||
getName() {
|
||||
return this.$registry
|
||||
|
|
|
@ -6,10 +6,7 @@
|
|||
:key="authProviderType.type"
|
||||
>
|
||||
<a @click="$emit('create', authProviderType)">
|
||||
<i
|
||||
class="context__menu-icon fa-fw"
|
||||
:class="getIconClass(authProviderType)"
|
||||
></i>
|
||||
<AuthProviderIcon :icon="getIcon(authProviderType)" />
|
||||
{{ getName(authProviderType) }}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -19,10 +16,11 @@
|
|||
|
||||
<script>
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
import AuthProviderIcon from '@baserow_enterprise/components/AuthProviderIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'CreateAuthProviderContext',
|
||||
components: {},
|
||||
components: { AuthProviderIcon },
|
||||
mixins: [context],
|
||||
props: {
|
||||
authProviderTypes: {
|
||||
|
@ -31,10 +29,8 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
getIconClass(providerType) {
|
||||
return this.$registry
|
||||
.get('authProvider', providerType.type)
|
||||
.getIconClass()
|
||||
getIcon(providerType) {
|
||||
return this.$registry.get('authProvider', providerType.type).getIcon()
|
||||
},
|
||||
getName(providerType) {
|
||||
return this.$registry.get('authProvider', providerType.type).getName()
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
<template>
|
||||
<form class="context__form" @submit.prevent="submit">
|
||||
<FormElement :error="fieldHasErrors('name')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.providerName')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="name"
|
||||
v-model="values.name"
|
||||
:class="{ 'input--error': fieldHasErrors('name') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.providerNamePlaceholder')"
|
||||
@blur="$v.values.name.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.name.$dirty && !$v.values.name.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('base_url')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.baseUrl')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="base_url"
|
||||
v-model="values.base_url"
|
||||
:class="{ 'input--error': fieldHasErrors('base_url') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.baseUrlPlaceholder')"
|
||||
@blur="$v.values.base_url.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.base_url.$dirty && !$v.values.base_url.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="$v.values.base_url.$dirty && !$v.values.base_url.url"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('oauthSettingsForm.invalidBaseUrl') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('client_id')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.clientId')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="client_id"
|
||||
v-model="values.client_id"
|
||||
:class="{ 'input--error': fieldHasErrors('client_id') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.clientIdPlaceholder')"
|
||||
@blur="$v.values.client_id.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.client_id.$dirty && !$v.values.client_id.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('secret')" class="control">
|
||||
<label class="control__label">{{ $t('oauthSettingsForm.secret') }}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="secret"
|
||||
v-model="values.secret"
|
||||
:class="{ 'input--error': fieldHasErrors('secret') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.secretPlaceholder')"
|
||||
@blur="$v.values.secret.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.secret.$dirty && !$v.values.secret.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<div class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.callbackUrl')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<code>{{ callbackUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required, url } from 'vuelidate/lib/validators'
|
||||
import form from '@baserow/modules/core/mixins/form'
|
||||
|
||||
export default {
|
||||
name: 'GitLabSettingsForm',
|
||||
mixins: [form],
|
||||
props: {
|
||||
authProvider: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
allowedValues: ['name', 'base_url', 'client_id', 'secret'],
|
||||
values: {
|
||||
name: '',
|
||||
base_url: '',
|
||||
client_id: '',
|
||||
secret: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
providerName() {
|
||||
return this.$registry
|
||||
.get('authProvider', 'gitlab')
|
||||
.getProviderName(this.authProvider)
|
||||
},
|
||||
callbackUrl() {
|
||||
if (!this.authProvider.id) {
|
||||
const nextProviderId =
|
||||
this.$store.getters['authProviderAdmin/getNextProviderId']
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${nextProviderId}/`
|
||||
}
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${this.authProvider.id}/`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDefaultValues() {
|
||||
return {
|
||||
name: this.providerName,
|
||||
base_url: this.authProvider.base_url || 'https://gitlab.com',
|
||||
client_id: this.authProvider.client_id || '',
|
||||
secret: this.authProvider.secret || '',
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
this.$emit('submit', this.values)
|
||||
},
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
values: {
|
||||
name: { required },
|
||||
base_url: { url, required },
|
||||
client_id: { required },
|
||||
secret: { required },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<form class="context__form" @submit.prevent="submit">
|
||||
<FormElement :error="fieldHasErrors('name')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.providerName')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="name"
|
||||
v-model="values.name"
|
||||
:class="{ 'input--error': fieldHasErrors('name') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.providerNamePlaceholder')"
|
||||
@blur="$v.values.name.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.name.$dirty && !$v.values.name.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('client_id')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.clientId')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="client_id"
|
||||
v-model="values.client_id"
|
||||
:class="{ 'input--error': fieldHasErrors('client_id') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.clientIdPlaceholder')"
|
||||
@blur="$v.values.client_id.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.client_id.$dirty && !$v.values.client_id.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('secret')" class="control">
|
||||
<label class="control__label">{{ $t('oauthSettingsForm.secret') }}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="secret"
|
||||
v-model="values.secret"
|
||||
:class="{ 'input--error': fieldHasErrors('secret') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.secretPlaceholder')"
|
||||
@blur="$v.values.secret.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.secret.$dirty && !$v.values.secret.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<div class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.callbackUrl')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<code>{{ callbackUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required } from 'vuelidate/lib/validators'
|
||||
import form from '@baserow/modules/core/mixins/form'
|
||||
|
||||
export default {
|
||||
name: 'OAuth2SettingsForm',
|
||||
mixins: [form],
|
||||
props: {
|
||||
authProvider: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
authProviderType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
allowedValues: ['name', 'client_id', 'secret'],
|
||||
values: {
|
||||
name: '',
|
||||
client_id: '',
|
||||
secret: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
providerName() {
|
||||
const type = this.authProviderType
|
||||
? this.authProviderType
|
||||
: this.authProvider.type
|
||||
return this.$registry
|
||||
.get('authProvider', type)
|
||||
.getProviderName(this.authProvider)
|
||||
},
|
||||
callbackUrl() {
|
||||
if (!this.authProvider.id) {
|
||||
const nextProviderId =
|
||||
this.$store.getters['authProviderAdmin/getNextProviderId']
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${nextProviderId}/`
|
||||
}
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${this.authProvider.id}/`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDefaultValues() {
|
||||
return {
|
||||
name: this.providerName,
|
||||
client_id: this.authProvider.client_id || '',
|
||||
secret: this.authProvider.secret || '',
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
this.$emit('submit', this.values)
|
||||
},
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
values: {
|
||||
name: { required },
|
||||
client_id: { required },
|
||||
secret: { required },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,185 @@
|
|||
<template>
|
||||
<form class="context__form" @submit.prevent="submit">
|
||||
<FormElement :error="fieldHasErrors('name')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.providerName')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="name"
|
||||
v-model="values.name"
|
||||
:class="{ 'input--error': fieldHasErrors('name') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.providerNamePlaceholder')"
|
||||
@blur="$v.values.name.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.name.$dirty && !$v.values.name.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('base_url')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.baseUrl')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="base_url"
|
||||
v-model="values.base_url"
|
||||
:class="{ 'input--error': fieldHasErrors('base_url') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.baseUrlPlaceholder')"
|
||||
@blur="$v.values.base_url.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.base_url.$dirty && !$v.values.base_url.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
<div v-else-if="serverErrors.baseUrl" class="error">
|
||||
{{ $t('oauthSettingsForm.invalidBaseUrl') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('client_id')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.clientId')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="client_id"
|
||||
v-model="values.client_id"
|
||||
:class="{ 'input--error': fieldHasErrors('client_id') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.clientIdPlaceholder')"
|
||||
@blur="$v.values.client_id.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.client_id.$dirty && !$v.values.client_id.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<FormElement :error="fieldHasErrors('secret')" class="control">
|
||||
<label class="control__label">{{ $t('oauthSettingsForm.secret') }}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="secret"
|
||||
v-model="values.secret"
|
||||
:class="{ 'input--error': fieldHasErrors('secret') }"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="$t('oauthSettingsForm.secretPlaceholder')"
|
||||
@blur="$v.values.secret.$touch()"
|
||||
/>
|
||||
<div
|
||||
v-if="$v.values.secret.$dirty && !$v.values.secret.required"
|
||||
class="error"
|
||||
>
|
||||
{{ $t('error.requiredField') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<div class="control">
|
||||
<label class="control__label">{{
|
||||
$t('oauthSettingsForm.callbackUrl')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<code>{{ callbackUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required, url } from 'vuelidate/lib/validators'
|
||||
import form from '@baserow/modules/core/mixins/form'
|
||||
|
||||
export default {
|
||||
name: 'OpenIdConnectSettingsForm',
|
||||
mixins: [form],
|
||||
props: {
|
||||
authProvider: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
serverErrors: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
allowedValues: ['name', 'base_url', 'client_id', 'secret'],
|
||||
values: {
|
||||
name: '',
|
||||
base_url: '',
|
||||
client_id: '',
|
||||
secret: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
callbackUrl() {
|
||||
if (!this.authProvider.id) {
|
||||
const nextProviderId =
|
||||
this.$store.getters['authProviderAdmin/getNextProviderId']
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${nextProviderId}/`
|
||||
}
|
||||
return `${this.$env.PUBLIC_BACKEND_URL}/api/sso/oauth2/callback/${this.authProvider.id}/`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDefaultValues() {
|
||||
return {
|
||||
name: this.authProvider.name || '',
|
||||
base_url: this.authProvider.base_url || '',
|
||||
client_id: this.authProvider.client_id || '',
|
||||
secret: this.authProvider.secret || '',
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
this.$v.$touch()
|
||||
if (this.$v.$invalid) {
|
||||
return
|
||||
}
|
||||
this.$emit('submit', this.values)
|
||||
},
|
||||
handleServerError(error) {
|
||||
if (error.handler.code === 'ERROR_INVALID_PROVIDER_URL') {
|
||||
this.serverErrors.baseUrl = error.handler.detail
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.handler.code !== 'ERROR_REQUEST_BODY_VALIDATION') return false
|
||||
|
||||
for (const [key, value] of Object.entries(error.handler.detail || {})) {
|
||||
this.serverErrors[key] = value
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
values: {
|
||||
name: { required },
|
||||
base_url: { required, url },
|
||||
client_id: { required },
|
||||
secret: { required },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,35 @@
|
|||
<template>
|
||||
<div>
|
||||
<a
|
||||
class="auth-provider-buttons__button"
|
||||
:href="`${redirectUrl}`"
|
||||
target="_self"
|
||||
>
|
||||
<AuthProviderIcon :icon="icon" />{{ $t('loginButton.continueWith') }}
|
||||
{{ name }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuthProviderIcon from '@baserow_enterprise/components/AuthProviderIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'OAuth2LoginButton',
|
||||
components: { AuthProviderIcon },
|
||||
props: {
|
||||
redirectUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -11,6 +11,7 @@
|
|||
<component
|
||||
:is="getProviderAdminSettingsFormComponent()"
|
||||
ref="providerSettingsForm"
|
||||
:auth-provider-type="authProviderType"
|
||||
@submit="create($event)"
|
||||
>
|
||||
<div
|
||||
|
|
|
@ -51,7 +51,10 @@
|
|||
"errorSsoFeatureNotActive": "The SSO feature is not active. Please contact your administrator.",
|
||||
"errorInvalidSamlRequest": "The SAML request is invalid.",
|
||||
"errorInvalidSamlResponse": "The SAML response is invalid.",
|
||||
"errorUserDeactivated": "The user has been disabled."
|
||||
"errorUserDeactivated": "The user has been disabled.",
|
||||
"errorProviderDoesNotExist": "This authentication provider doesn't exist or is not available.",
|
||||
"errorAuthFlowError": "An error occured during the authentication flow with the selected provider.",
|
||||
"errorDifferentProvider": "Please use the provider that you originally signed up with."
|
||||
},
|
||||
"roles": {
|
||||
"admin": {
|
||||
|
@ -74,5 +77,20 @@
|
|||
"name": "Viewer",
|
||||
"description": "Can only read data."
|
||||
}
|
||||
},
|
||||
"oauthSettingsForm": {
|
||||
"providerName": "Name",
|
||||
"providerNamePlaceholder": "Custom provider name",
|
||||
"clientId": "Client ID",
|
||||
"clientIdPlaceholder": "Provider's client ID",
|
||||
"secret": "Secret",
|
||||
"secretPlaceholder": "Provider's secret",
|
||||
"baseUrl": "URL",
|
||||
"baseUrlPlaceholder": "Provider's base URL",
|
||||
"invalidBaseUrl": "The entered URL is not a valid provider URL",
|
||||
"callbackUrl": "Callback URL"
|
||||
},
|
||||
"loginButton": {
|
||||
"continueWith": "Continue with"
|
||||
}
|
||||
}
|
|
@ -46,6 +46,7 @@ export default {
|
|||
middleware: 'staff',
|
||||
asyncData: async ({ store }) => {
|
||||
await store.dispatch('authProviderAdmin/fetchAll')
|
||||
await store.dispatch('authProviderAdmin/fetchNextProviderId')
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -94,6 +94,8 @@ export default {
|
|||
})
|
||||
return redirect(data.redirect_url)
|
||||
}
|
||||
|
||||
return { redirectUrl: loginOptions.saml.redirect_url }
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
import { registerRealtimeEvents } from '@baserow_enterprise/realtime'
|
||||
import { RolePermissionManagerType } from '@baserow_enterprise/permissionManagerTypes'
|
||||
import { AuthProvidersType } from '@baserow_enterprise/adminTypes'
|
||||
import { SamlAuthProviderType } from '@baserow_enterprise/authProviderTypes'
|
||||
import authProviderAdminStore from '@baserow_enterprise/store/authProviderAdmin'
|
||||
import {
|
||||
SamlAuthProviderType,
|
||||
GitHubAuthProviderType,
|
||||
GoogleAuthProviderType,
|
||||
FacebookAuthProviderType,
|
||||
GitLabAuthProviderType,
|
||||
OpenIdConnectAuthProviderType,
|
||||
} from '@baserow_enterprise/authProviderTypes'
|
||||
|
||||
import { EnterpriseMembersPagePluginType } from '@baserow_enterprise/membersPagePluginTypes'
|
||||
import en from '@baserow_enterprise/locales/en.json'
|
||||
|
@ -36,6 +43,14 @@ export default (context) => {
|
|||
|
||||
app.$registry.register('admin', new AuthProvidersType(context))
|
||||
app.$registry.register('authProvider', new SamlAuthProviderType(context))
|
||||
app.$registry.register('authProvider', new GoogleAuthProviderType(context))
|
||||
app.$registry.register('authProvider', new FacebookAuthProviderType(context))
|
||||
app.$registry.register('authProvider', new GitHubAuthProviderType(context))
|
||||
app.$registry.register('authProvider', new GitLabAuthProviderType(context))
|
||||
app.$registry.register(
|
||||
'authProvider',
|
||||
new OpenIdConnectAuthProviderType(context)
|
||||
)
|
||||
|
||||
registerRealtimeEvents(app.$realtime)
|
||||
|
||||
|
|
|
@ -15,5 +15,8 @@ export default (client) => {
|
|||
delete(id) {
|
||||
return client.delete(`/admin/auth-provider/${id}/`)
|
||||
},
|
||||
fetchNextProviderId() {
|
||||
return client.get(`/admin/auth-provider/next-id/`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ function populateProviderType(authProviderType, registry) {
|
|||
|
||||
export const state = () => ({
|
||||
items: {},
|
||||
nextProviderId: null,
|
||||
})
|
||||
|
||||
export const mutations = {
|
||||
|
@ -37,6 +38,9 @@ export const mutations = {
|
|||
authProviders: authProviders.map((p) => (p.id === item.id ? item : p)),
|
||||
}
|
||||
},
|
||||
SET_NEXT_PROVIDER_ID(state, providerId) {
|
||||
state.nextProviderId = providerId
|
||||
},
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
@ -52,12 +56,13 @@ export const actions = {
|
|||
commit('SET_ITEMS', items)
|
||||
return items
|
||||
},
|
||||
async create({ commit }, { type, values }) {
|
||||
async create({ commit, dispatch }, { type, values }) {
|
||||
const { data: item } = await authProviderAdmin(this.$client).create({
|
||||
type,
|
||||
...values,
|
||||
})
|
||||
commit('ADD_ITEM', item)
|
||||
await dispatch('fetchNextProviderId')
|
||||
return item
|
||||
},
|
||||
async update({ commit }, { authProvider, values }) {
|
||||
|
@ -72,6 +77,12 @@ export const actions = {
|
|||
await authProviderAdmin(this.$client).delete(item.id)
|
||||
commit('DELETE_ITEM', item)
|
||||
},
|
||||
async fetchNextProviderId({ commit }) {
|
||||
const { data } = await authProviderAdmin(this.$client).fetchNextProviderId()
|
||||
const providerId = data.next_provider_id
|
||||
commit('SET_NEXT_PROVIDER_ID', providerId)
|
||||
return providerId
|
||||
},
|
||||
}
|
||||
|
||||
export const getters = {
|
||||
|
@ -99,6 +110,9 @@ export const getters = {
|
|||
}
|
||||
return items
|
||||
},
|
||||
getNextProviderId: (state) => {
|
||||
return state.nextProviderId
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.825 7.675C5.60826 7.14327 5.49784 6.57421 5.5 6C5.5 5.10999 5.76392 4.23996 6.25839 3.49994C6.75285 2.75991 7.45566 2.18314 8.27792 1.84254C9.10019 1.50195 10.005 1.41283 10.8779 1.58647C11.7508 1.7601 12.5526 2.18869 13.182 2.81802C13.8113 3.44736 14.2399 4.24918 14.4135 5.1221C14.5872 5.99501 14.4981 6.89981 14.1575 7.72208C13.8169 8.54434 13.2401 9.24715 12.5001 9.74162C11.76 10.2361 10.89 10.5 10 10.5C9.42579 10.5022 8.85673 10.3917 8.325 10.175V10.175L7.5 11H6V12.5H4.5V14H2V11.5L5.825 7.675Z" stroke="#828A95" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11.25 5.25C11.5261 5.25 11.75 5.02614 11.75 4.75C11.75 4.47386 11.5261 4.25 11.25 4.25C10.9739 4.25 10.75 4.47386 10.75 4.75C10.75 5.02614 10.9739 5.25 11.25 5.25Z" fill="#828A95"/>
|
||||
</svg>
|
After (image error) Size: 876 B |
|
@ -17,9 +17,8 @@
|
|||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
padding-bottom: 40px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 40px;
|
||||
border-bottom: 1px solid $color-neutral-200;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Registerable } from '@baserow/modules/core/registry'
|
||||
import PasswordAuthIcon from '@baserow/modules/core/assets/images/providers/Key.svg'
|
||||
|
||||
/**
|
||||
* The authorization provider type base class that can be extended when creating
|
||||
|
@ -6,12 +7,9 @@ import { Registerable } from '@baserow/modules/core/registry'
|
|||
*/
|
||||
export class AuthProviderType extends Registerable {
|
||||
/**
|
||||
* The font awesome 5 icon name that is used as convenience for the user to
|
||||
* recognize certain application types. If you for example want the database
|
||||
* icon, you must return 'database' here. This will result in the classname
|
||||
* 'fas fa-database'.
|
||||
* The icon for the provider
|
||||
*/
|
||||
getIconClass() {
|
||||
getIcon() {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -85,16 +83,16 @@ export class AuthProviderType extends Registerable {
|
|||
constructor(...args) {
|
||||
super(...args)
|
||||
this.type = this.getType()
|
||||
this.iconClass = this.getIconClass()
|
||||
this.icon = this.getIcon()
|
||||
|
||||
if (this.type === null) {
|
||||
throw new Error('The type name of an application type must be set.')
|
||||
throw new Error('The type name of a provider type must be set.')
|
||||
}
|
||||
if (this.iconClass === null) {
|
||||
throw new Error('The icon class of an application type must be set.')
|
||||
if (this.icon === null) {
|
||||
throw new Error('The icon of a provider type must be set.')
|
||||
}
|
||||
if (this.name === null) {
|
||||
throw new Error('The name of an application type must be set.')
|
||||
throw new Error('The name of a provider type must be set.')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,8 +118,8 @@ export class PasswordAuthProviderType extends AuthProviderType {
|
|||
return 'password'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'key'
|
||||
getIcon() {
|
||||
return PasswordAuthIcon
|
||||
}
|
||||
|
||||
getName() {
|
||||
|
|
|
@ -9,6 +9,17 @@
|
|||
<h1 class="box__head-title">{{ $t('login.title') }}</h1>
|
||||
<LangPicker />
|
||||
</div>
|
||||
<div v-if="loginButtons.length > 0" class="auth-provider-buttons">
|
||||
<div v-for="loginButton in loginButtons" :key="loginButton.redirect_url">
|
||||
<component
|
||||
:is="getLoginButtonComponent(loginButton)"
|
||||
:redirect-url="loginButton.redirect_url"
|
||||
:name="loginButton.name"
|
||||
:icon="getLoginButtonIcon(loginButton)"
|
||||
>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
<AuthLogin :invitation="invitation" @success="success"> </AuthLogin>
|
||||
<div>
|
||||
<ul class="login-action__links">
|
||||
|
@ -72,6 +83,7 @@ export default {
|
|||
...mapGetters({
|
||||
settings: 'settings/get',
|
||||
loginActions: 'authProvider/getAllLoginActions',
|
||||
loginButtons: 'authProvider/getAllLoginButtons',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
|
@ -80,6 +92,14 @@ export default {
|
|||
.get('authProvider', loginAction.type)
|
||||
.getLoginActionComponent()
|
||||
},
|
||||
getLoginButtonComponent(loginButton) {
|
||||
return this.$registry
|
||||
.get('authProvider', loginButton.type)
|
||||
.getLoginButtonComponent()
|
||||
},
|
||||
getLoginButtonIcon(loginButton) {
|
||||
return this.$registry.get('authProvider', loginButton.type).getIcon()
|
||||
},
|
||||
success() {
|
||||
const { original } = this.$route.query
|
||||
if (original && isRelativeUrl(original)) {
|
||||
|
|
|
@ -38,13 +38,17 @@ export const getters = {
|
|||
return state.loginOptionsLoaded
|
||||
},
|
||||
getAllLoginButtons: (state) => {
|
||||
const loginActions = []
|
||||
let optionsWithButton = []
|
||||
for (const loginOption of Object.values(state.loginOptions)) {
|
||||
if (loginOption.hasLoginButton) {
|
||||
loginActions.push(loginOption)
|
||||
if (
|
||||
loginOption.hasLoginButton &&
|
||||
loginOption.items &&
|
||||
loginOption.items.length > 0
|
||||
) {
|
||||
optionsWithButton = optionsWithButton.concat(loginOption.items)
|
||||
}
|
||||
}
|
||||
return loginActions
|
||||
return optionsWithButton
|
||||
},
|
||||
getAllLoginActions: (state) => {
|
||||
const loginActions = []
|
||||
|
|
Loading…
Add table
Reference in a new issue