diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index d2c2fcbfce..acd761be1e 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -17,11 +17,16 @@ Depends, WebSocket, APIRouter, + Header, ) from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from .models import User +from typing import Dict, Any +from fastapi_sso.sso.generic import create_provider +from fastapi_sso.sso.base import OpenID + +from .models import User, UserRole # ============================================================================ @@ -35,6 +40,29 @@ PWD_CONTEXT = CryptContext(schemes=["bcrypt"], deprecated="auto") +PASSWORD_DISABLED = bool(int(os.environ.get("PASSWORD_DISABLED", 0))) +INVITES_ENABLED = not bool(int(os.environ.get("INVITES_DISABLED", 0))) # Negation as Workaround for inability of setting a value to 0 in values.yml: https://github.com/helm/helm/issues/3164 +SSO_SUPERUSER_GROUPS = os.environ.get("SSO_SUPERUSER_GROUPS", "browsertrix-admins").split(";") + +SSO_HEADER_ENABLED = bool(int(os.environ.get("SSO_HEADER_ENABLED", 0))) +SSO_HEADER_GROUPS_SEPARATOR = os.environ.get("SSO_HEADER_GROUPS_SEPARATOR", ";") +SSO_HEADER_EMAIL_FIELD = os.environ.get("SSO_HEADER_EMAIL_FIELD", "x-remote-email") +SSO_HEADER_USERNAME_FIELD = os.environ.get("SSO_HEADER_USERNAME_FIELD", "x-remote-user") +SSO_HEADER_GROUPS_FIELD = os.environ.get("SSO_HEADER_GROUPS_FIELD", "x-remote-groups") + + +SSO_OIDC_ENABLED = bool(int(os.environ.get("SSO_OIDC_ENABLED", 0))) +SSO_OIDC_AUTH_ENDPOINT = os.environ.get("SSO_OIDC_AUTH_ENDPOINT", "") +SSO_OIDC_TOKEN_ENDPOINT = os.environ.get("SSO_OIDC_TOKEN_ENDPOINT", "") +SSO_OIDC_USERINFO_ENDPOINT = os.environ.get("SSO_OIDC_USERINFO_ENDPOINT", "") +SSO_OIDC_CLIENT_ID = os.environ.get("SSO_OIDC_CLIENT_ID", "") +SSO_OIDC_CLIENT_SECRET = os.environ.get("SSO_OIDC_CLIENT_SECRET", "") +SSO_OIDC_REDIRECT_URL = os.environ.get("SSO_OIDC_REDIRECT_URL", "") +SSO_OIDC_ALLOW_HTTP_INSECURE = bool(int(os.environ.get("SSO_OIDC_ALLOW_HTTP_INSECURE", 0))) +SSO_OIDC_USERINFO_EMAIL_FIELD = os.environ.get("SSO_OIDC_USERINFO_EMAIL_FIELD", "email") +SSO_OIDC_USERINFO_USERNAME_FIELD = os.environ.get("SSO_OIDC_USERINFO_USERNAME_FIELD", "preferred_username") +SSO_OIDC_USERINFO_GROUPS_FIELD = os.environ.get("SSO_OIDC_USERINFO_GROUPS_FIELD", "isMemberOf") + # Audiences AUTH_AUD = "btrix:auth" RESET_AUD = "btrix:reset" @@ -55,6 +83,12 @@ class BearerResponse(BaseModel): access_token: str token_type: str +class LoginMethodsInquiryResponse(BaseModel): + login_methods: dict + invites_enabled: bool + +class OIDCRedirectResponse(BaseModel): + redirect_url: str # ============================================================================ # pylint: disable=too-few-public-methods @@ -137,6 +171,97 @@ def generate_password() -> str: return pwd.genword() +# ============================================================================ +def openid_convertor(response: Dict[str, Any], session = None) -> OpenID: + + email = response.get(SSO_OIDC_USERINFO_EMAIL_FIELD, None) + username = response.get(SSO_OIDC_USERINFO_USERNAME_FIELD, None) + groups = response.get(SSO_OIDC_USERINFO_GROUPS_FIELD, None) + + if email is None or username is None or groups is None or not isinstance(groups, list): + raise HTTPException( + status_code=500, + detail="error_processing_sso_response", + ) + + return OpenID( + email=email, + display_name=username, # Abusing variable names to match what we need + id=";".join(groups) # Abusing variable names to match what we need + ) + +if SSO_OIDC_ENABLED: + discovery = { + "authorization_endpoint": SSO_OIDC_AUTH_ENDPOINT, + "token_endpoint": SSO_OIDC_TOKEN_ENDPOINT, + "userinfo_endpoint": SSO_OIDC_USERINFO_ENDPOINT, + } + + SSOProvider = create_provider(name="oidc", discovery_document=discovery, response_convertor=openid_convertor) + sso = SSOProvider( + client_id=SSO_OIDC_CLIENT_ID, + client_secret=SSO_OIDC_CLIENT_SECRET, + redirect_uri=SSO_OIDC_REDIRECT_URL, + allow_insecure_http=SSO_OIDC_ALLOW_HTTP_INSECURE + ) + +# ============================================================================ +async def update_user_orgs(groups: [str], user, ops): + if user.is_superuser: + return + orgs = await ops.get_org_slugs_by_ids() + user_orgs, _ = await ops.get_orgs_for_user(user) + for org_id, slug in orgs.items(): + if slug.lower() in groups: + already_in_org = False + for user_org in user_orgs: + if user_org.slug == slug: + # User is already in org, no need to add + already_in_org = True + if not already_in_org: + org = await ops.get_org_by_id(org_id) + await ops.add_user_to_org(org, user.id, UserRole.CRAWLER) + + for org in user_orgs: + if org.slug.lower() not in groups: + del org.users[str(user.id)] + await ops.update_users(org) + +async def update_user_role(groups: [str], user, user_manager): + """Update if user should be superuser""" + is_superuser = False + for group in groups: + if group in SSO_SUPERUSER_GROUPS: + is_superuser = True + if user.is_superuser != is_superuser: + query: dict[str, str] = {} + query["is_superuser"] = is_superuser + await user_manager.users.find_one_and_update({"id": user.id}, {"$set": query}) + +async def process_sso_user_login(user_manager, login_email, login_name, groups) -> User: + user = await user_manager.get_by_email(login_email) + ops = user_manager.org_ops + + if user: + await update_user_role(groups, user, user_manager) + await update_user_orgs(groups, user, ops) + # User exist, and correct orgs have been set, proceed to login + return user + else: + # Create verified user + await user_manager.create_non_super_user(login_email, None, login_name, is_sso=True) + user = await user_manager.get_by_email(login_email) + if user: + await update_user_role(groups, user, user_manager) + await update_user_orgs(groups, user, ops) + # User has been created and correct orgs have been set, proceed to login + return user + else: + raise HTTPException( + status_code=500, + detail="user_creation_failed", + ) + # ============================================================================ # pylint: disable=raise-missing-from def init_jwt_auth(user_manager): @@ -176,6 +301,12 @@ async def login( lock the user account and send an email to reset their password. On successful login when user is not already locked, reset count to 0. """ + if PASSWORD_DISABLED: + raise HTTPException( + status_code=405, + detail="password_based_login_disabled", + ) + login_email = credentials.username failed_count = await user_manager.get_failed_logins_count(login_email) @@ -227,6 +358,85 @@ async def send_reset_if_needed(): # successfully logged in, reset failed logins, return user await user_manager.reset_failed_logins(login_email) return get_bearer_response(user) + + @auth_jwt_router.get("/login/header", response_model=BearerResponse) + async def login_header( + request: Request + ) -> BearerResponse: + + x_remote_email = request.headers.get(SSO_HEADER_EMAIL_FIELD, None) + x_remote_user = request.headers.get(SSO_HEADER_USERNAME_FIELD, None) + x_remote_groups = request.headers.get(SSO_HEADER_GROUPS_FIELD, None) + + if not SSO_HEADER_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + + if not (x_remote_user is not None and x_remote_email is not None and x_remote_groups is not None): + raise HTTPException( + status_code=500, + detail="invalid_parameters_for_login", + ) + + login_email = x_remote_email + login_name = x_remote_user + groups = [group.lower() for group in x_remote_groups.split(SSO_HEADER_GROUPS_SEPARATOR)] + + user = await process_sso_user_login(user_manager, login_email, login_name, groups) + return get_bearer_response(user) + + @auth_jwt_router.get("/login/oidc", response_model=OIDCRedirectResponse) + async def login_oidc() -> OIDCRedirectResponse: + if not SSO_OIDC_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + + """Redirect the user to the OIDC login page.""" + with sso: + redirect_url = await sso.get_login_url() + return OIDCRedirectResponse(redirect_url=redirect_url) + + @auth_jwt_router.get("/login/oidc/callback", response_model=BearerResponse) + async def login_oidc_callback(request: Request) -> BearerResponse: + if not SSO_OIDC_ENABLED: + raise HTTPException( + status_code=405, + detail="sso_is_disabled", + ) + + with sso: + openid = await sso.verify_and_process(request) + if not openid: + raise HTTPException(status_code=401, detail="Authentication failed") + login_email = openid.email + login_name = openid.display_name # Abusing variable names, see openid convertor above + groups = [group.lower() for group in openid.id.split(";")] # Abusing variable names, see openid convertor above + + user = await process_sso_user_login(user_manager, login_email, login_name, groups) + return get_bearer_response(user) + + @auth_jwt_router.get("/login/methods", response_model=LoginMethodsInquiryResponse) + async def login_methods() -> LoginMethodsInquiryResponse: + enabled_login_methods = { + 'password': True, + 'sso_header': False, + 'sso_oidc': False + } + + if PASSWORD_DISABLED: + enabled_login_methods['password'] = False + + if SSO_HEADER_ENABLED: + enabled_login_methods['sso_header'] = True + + if SSO_OIDC_ENABLED: + enabled_login_methods['sso_oidc'] = True + + return LoginMethodsInquiryResponse(login_methods=enabled_login_methods, invites_enabled=INVITES_ENABLED) @auth_jwt_router.post("/refresh", response_model=BearerResponse) async def refresh_jwt(user=Depends(current_active_user)): diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index 3b204f9768..7680ab3aaa 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -125,6 +125,12 @@ async def invite_user( :returns: is_new_user (bool), invite token (str) """ + if bool(int(os.environ.get("INVITES_DISABLED", 0))): + raise HTTPException( + status_code=405, + detail="invites_are_disabled", + ) + invite_code = uuid4().hex if org: diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index 6f1f6ee5f6..87127f0a01 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -94,6 +94,8 @@ class User(BaseModel): email: EmailStr is_superuser: bool = False is_verified: bool = False + + is_sso: bool = False invites: Dict[str, InvitePending] = {} hashed_password: str @@ -144,6 +146,7 @@ class UserOut(BaseModel): email: EmailStr is_superuser: bool = False is_verified: bool = False + is_sso: bool = False orgs: List[UserOrgInfoOut] @@ -1151,6 +1154,7 @@ class UserCreate(UserCreateIn): is_superuser: Optional[bool] = False is_verified: Optional[bool] = False + is_sso: Optional[bool] = False # ============================================================================ diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index f542a1729e..2ab283fe9b 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -162,6 +162,7 @@ async def get_user_info_with_orgs(self, user: User) -> UserOut: orgs=orgs, is_superuser=user.is_superuser, is_verified=user.is_verified, + is_sso=user.is_sso, ) async def validate_password(self, password: str) -> None: @@ -274,6 +275,7 @@ async def create_non_super_user( email: str, password: str, name: str = "New user", + is_sso: bool = False, ) -> None: """create a regular user with given credentials""" if not email: @@ -291,6 +293,7 @@ async def create_non_super_user( is_superuser=False, newOrg=False, is_verified=True, + is_sso=is_sso, ) await self._create(user_create) @@ -342,6 +345,7 @@ async def _create( if isinstance(create, UserCreate): is_superuser = create.is_superuser is_verified = create.is_verified + is_sso = create.is_sso else: is_superuser = False is_verified = create.inviteToken is not None @@ -355,6 +359,7 @@ async def _create( hashed_password=hashed_password, is_superuser=is_superuser, is_verified=is_verified, + is_sso=is_sso, ) try: @@ -503,12 +508,22 @@ async def reset_password(self, token: str, password: str) -> None: user = await self.get_by_id(user_uuid) if user: + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) await self._update_password(user, password) async def change_password( self, user_update: UserUpdatePassword, user: User ) -> None: """Change password after checking existing password""" + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) if not await self.check_password(user, user_update.password): raise HTTPException(status_code=400, detail="invalid_current_password") @@ -518,6 +533,11 @@ async def change_email_name( self, user_update: UserUpdateEmailName, user: User ) -> None: """Change email and/or name, if specified, throw if neither is specified""" + if user.is_sso: + raise HTTPException( + status_code=400, + detail="external_user", + ) if not user_update.email and not user_update.name: raise HTTPException(status_code=400, detail="no_updates_specified") @@ -663,7 +683,7 @@ async def forgot_password( email: EmailStr = Body(..., embed=True), ): user = await user_manager.get_by_email(email) - if not user: + if not user or user.is_sso: return None await user_manager.forgot_password(user, request) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0877636c95..66799bf946 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,3 +28,4 @@ types_aiobotocore_s3 types-redis types-python-slugify types-pyYAML +fastapi-sso diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 9eb8d9e422..df94d76c3d 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -50,6 +50,45 @@ data: CRAWLER_CHANNELS_JSON: "/ops-configs/crawler_channels.json" + PASSWORD_DISABLED: "{{ .Values.password_disabled | default 0 }}" + + INVITES_DISABLED: "{{ .Values.invites_disabled | default 0 }}" + + SSO_SUPERUSER_GROUPS: {{ .Values.sso_superuser_groups | default "browsertrix-admins" }} + + SSO_HEADER_ENABLED: "{{ .Values.sso_header_enabled | default 0 }}" + + SSO_HEADER_EMAIL_FIELD: {{ .Values.sso_header_email_field | default "x-remote-email" }} + + SSO_HEADER_USERNAME_FIELD: {{ .Values.sso_header_username_field | default "x-remote-user" }} + + SSO_HEADER_GROUPS_FIELD: {{ .Values.sso_header_groups_field | default "x-remote-groups" }} + + SSO_HEADER_GROUPS_SEPARATOR: {{ .Values.sso_header_groups_separator | default ";" }} + + SSO_OIDC_ENABLED: "{{ .Values.sso_oidc_enabled | default 0 }}" + + SSO_OIDC_AUTH_ENDPOINT: {{ .Values.sso_oidc_auth_endpoint | default "-" }} + + SSO_OIDC_TOKEN_ENDPOINT: {{ .Values.sso_oidc_token_endpoint | default "-" }} + + SSO_OIDC_USERINFO_ENDPOINT: {{ .Values.sso_oidc_userinfo_endpoint | default "-" }} + + SSO_OIDC_CLIENT_ID: {{ .Values.sso_oidc_client_id | default "-" }} + + SSO_OIDC_CLIENT_SECRET: {{ .Values.sso_oidc_client_secret | default "-" }} + + SSO_OIDC_REDIRECT_URL: {{ .Values.sso_oidc_redirect_url | default "-" }} + + SSO_OIDC_ALLOW_HTTP_INSECURE: "{{ .Values.sso_oidc_allow_http_insecure | default 0 }}" + + SSO_OIDC_USERINFO_EMAIL_FIELD: {{ .Values.sso_oidc_userinfo_email_field | default "email" }} + + SSO_OIDC_USERINFO_USERNAME_FIELD: {{ .Values.sso_oidc_userinfo_username_field | default "preferred_username" }} + + SSO_OIDC_USERINFO_GROUPS_FIELD: {{ .Values.sso_oidc_userinfo_groups_field | default "isMemberOf" }} + + --- apiVersion: v1 kind: ConfigMap diff --git a/chart/values.yaml b/chart/values.yaml index 63685bea5a..271820c7a2 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -83,6 +83,36 @@ superuser: # Set name for default organization created with superuser default_org: "My Organization" +# SSO Configuration +# ========================================= +# Header SSO + +# Enabled: 1, Disabled 0 (Default) +# sso_header_enabled: 0 +# sso_header_email_field: x-remote-email +# sso_header_username_field: x-remote-user +# sso_header_groups_field: x-remote-groups +# sso_header_groups_separator: ';' + +# Open ID Connect SSO + +# sso_oidc_enabled: 0 +# sso_oidc_auth_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/auth +# sso_oidc_token_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/token +# sso_oidc_userinfo_endpoint: https://localhost/auth/realms/example/protocol/openid-connect/userinfo +# sso_oidc_client_id: yourclientid +# sso_oidc_client_secret: yourclientsecret +# sso_oidc_redirect_url: http://localhost:30870/log-in/oidc +# sso_oidc_allow_http_insecure: 0 +# sso_oidc_userinfo_email_field: email +# sso_oidc_userinfo_username_field: preferred_username +# sso_oidc_userinfo_groups_field: isMemberOf + +# Additional SSO Options +# sso_superuser_groups: browsertrix-admins # Semicolon separated list of groups whose users should be promoted to superadmins +# Optionally completely disable password based login +# password_disabled: 0 +# invites_disabled: 0 # API Image # ========================================= diff --git a/docs/deploy/sso.md b/docs/deploy/sso.md new file mode 100644 index 0000000000..0250178b50 --- /dev/null +++ b/docs/deploy/sso.md @@ -0,0 +1,185 @@ + + +# Deploying Single Sign-On +Browsertrix supports Single Sign-On (SSO) using either the OIDC protocol or based on header values. + +Although it is technically possible to enable both OIDC and Header based SSO at the same time it is not suggested, to keep the user experience seamless. + +Single Logout is not supported. + +## General Configuration +When using SSO, user organization membership within Browsertrix is assigned and removed dynamically based on a user's group membership status provided by the IDP/Proxy. + +This can result in two conflicts with the invite functionality: +- Users org membership will be reset as soon as an user logs in with SSO, therefore any manual assignement will be lost. +- Users that are supposed to login with SSO are able to create accounts with password login if invited manually. + +If this is a problem, it is possible to disable either function by setting the following variables in the local values.yml file: + +```yaml +password_disabled: 1 +invites_disabled: 1 +``` + +If disabling passwords altogether, it is recommended to assign some users superuser privileges by adding them to the `browsertrix-admins` group on the authentication backend. + +The superuser group can be changed by setting the `sso_superuser_groups` variable in values.yml + +eg. +```yaml +sso_superuser_groups: browsertrix-admins;Domain Admins # Semicolon separated list of groups whose users should be promoted to superadmins +``` + +## Deploying SSO with OIDC +### Requirements +- IDP supporting OIDC. This guide uses [Keycloak](https://www.keycloak.org/) as an example. + +### Configuration of IDP +1. Create a new client scope + 1. Set a pertinent name, eg. `browsertrix-authorization` + 2. Type: None + 3. Protocol OpenID Connect + 4. In the Mappers + 1. Create new mapper, by configuration + 2. Type: Group Membership + 3. Name: isMemberOf + 4. Token Claim Name: isMemberOf + 1. This should be the value set in values.yml (default if not set: isMemberOf) + 5. FullGroupPath: Off + 6. Add to ID token: On + 7. Add to access token: Off + 8. Add to userinfo: On +2. Create a new client in Keycloak +3. In client settings: + 1. Choose a client ID + 2. Set Root URL, Home URL, Admin URL to your main Browsertrix URL (eg. https://archive.example.com) + 3. Set Valid redirect URIs to https://archive.example.com/* + 4. Set Web origins to "+" + 5. Ensure Client Authentication and Standard Flow are enabled. +4. In client credentials + 1. Ensure authenticator is set to client id and secret + 2. Copy client secred to reuse in values.yml +5. In client scopes + 1. Add client scope, select previously created scope + 2. Set assigned type to Default + +When Browsertrix processes the OIDC login, it is expected that the userinfo token has the following fields set, if your IDP uses different names ensure that it is reflected in the values.yml config. +- preferred_username + - string + - will be used as the user display name +- email + - string + - will be used as email and for matching user +- isMemberOf + - list of groups + - each group should be a single string + - will be used to dynamically add/remove organization membership for the user on login + +This can be verified with the Evaluate tool in Keycloak client scopes. +Evaluate with a test user and verify that in user info the following is correct: + 1. preferred_username is present and set to correct value + 2. email is present and set to correct value + 3. isMemberOf is present and set to a LIST of groups the user belongs to. + +### Configuration of Browsertrix +When configuring SSO with the OIDC protocol, the following variables must be set and match the previously configured settings in the IDP client. + +sso_oidc_auth_endpoint, sso_oidc_token_endpoint, sso_oidc_userinfo_endpoint can be found in the .well-known configuration for OIDC (eg. https://idp.example.com/auth/realms/example/.well-known/openid-configuration) + +```yaml +# Open ID Connect SSO + +sso_oidc_enabled: 1 +sso_oidc_auth_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/auth +sso_oidc_token_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/token +sso_oidc_userinfo_endpoint: https://idp.example.com/auth/realms/example/protocol/openid-connect/userinfo +sso_oidc_client_id: yourclientid +sso_oidc_client_secret: yourclientsecret +sso_oidc_redirect_url: https://browsertrix.example.com/log-in/oidc +# sso_oidc_allow_http_insecure: 0 (optional and not suggested, only for testing purposes) +# Optional, defaults to the below values +# sso_oidc_userinfo_email_field: email +# sso_oidc_userinfo_username_field: preferred_username +# sso_oidc_userinfo_groups_field: isMemberOf +``` + +## Deploying SSO with Headers +### Requirements +- Authenticating proxy. This guide uses Apache2 as an example configured with Shibboleth. + +!!! danger + + Direct access to the ingress endpoint in the Kubernetes cluster must only be limited to the proxy. If not restricted, any user with direct access to the ingress would be able to manually set the required headers. + + +### Configuration of Proxy +1. Configure proxy to authenticate users with your preferred Identity Provider. Ensure that username, email and group membership are provided to the proxy. Configuration of this step is outside of this guide scope. +2. Create virtual host for Browsertrix +3. Protect the following paths behind authentication + - /log-in/header + - /api/auth/jwt/login/header +4. Transform and send the following user attributes as headers: + - email -> x-remote-email + - username -> x-remote-user + - group memberships (as a single, semicolon separated string) -> x-remote-groups + - If they are sent with different header names or you use a different separator for the group string ensure you edit values.yml accordingly. +```apache + + + ServerAdmin webmaster@example.com + ServerAlias archive.example.com + + + AuthType shibboleth + ShibRequestSetting requireSession true + Require valid-user + RequestHeader set X-Remote-User %{uid}e + RequestHeader set X-Remote-Email %{principalName}e + RequestHeader set X-Remote-Groups %{isMemberOf}e + + + + AuthType shibboleth + ShibRequestSetting requireSession true + Require valid-user + RequestHeader set X-Remote-User %{uid}e + RequestHeader set X-Remote-Email %{principalName}e + RequestHeader set X-Remote-Groups %{isMemberOf}e + + + SSLProxyEngine on + + ProxyPreserveHost On + + ProxyPass / https://k8s-ingress.example.com:443/ + ProxyPassReverse / https://k8s-ingress.example.com:443/ + + Protocols h2 http/1.1 + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + SSLEngine on + + SSLCertificateFile /etc/letsencrypt/live/archive.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/archive.example.com/privkey.pem + + + +``` + + +### Configuration of Browsertrix +When configuring SSO with Header Auth, the following variables must be set and match the previously configured settings in the IDP client. + +```yaml +# Header SSO + +# Enabled: 1, Disabled 0 (Default) +sso_header_enabled: 1 +# Optional, defaults to below values +# sso_header_email_field: x-remote-email +# sso_header_username_field: x-remote-user +# sso_header_groups_field: x-remote-groups +# sso_header_groups_separator: ';' +``` \ No newline at end of file diff --git a/frontend/src/features/accounts/account-settings.ts b/frontend/src/features/accounts/account-settings.ts index 071196b9be..8e8a7efcd4 100644 --- a/frontend/src/features/accounts/account-settings.ts +++ b/frontend/src/features/accounts/account-settings.ts @@ -137,6 +137,12 @@ export class AccountSettings extends LiteElement {

${msg("Account Settings")}

+ ${when(this.userInfo.isSSO, () => + html` + ${msg("Some settings are managed by your organization and cannot be changed.")} +
+ ` + )}

@@ -153,6 +159,7 @@ export class AccountSettings extends LiteElement { maxlength="40" minlength="2" required + ?disabled=${this.userInfo.isSSO} aria-label=${msg("Display name")} >

@@ -162,6 +169,7 @@ export class AccountSettings extends LiteElement { size="small" variant="primary" ?loading=${this.sectionSubmitting === "name"} + ?disabled=${this.userInfo.isSSO} >${msg("Save")} @@ -176,6 +184,7 @@ export class AccountSettings extends LiteElement { name="email" value=${this.userInfo.email} type="email" + ?disabled=${this.userInfo.isSSO} aria-label=${msg("Email")} >
@@ -212,77 +221,91 @@ export class AccountSettings extends LiteElement { size="small" variant="primary" ?loading=${this.sectionSubmitting === "email"} + ?disabled=${this.userInfo.isSSO} >${msg("Save")} -
- ${when( - this.isChangingPassword, - () => html` -
-
-

+ ${when(!this.userInfo.isSSO, () => html` +
+ ${when( + this.isChangingPassword, + () => html` + +
+

+ ${msg("Password")} +

+ + } + > + + ${when(this.pwStrengthResults, this.renderPasswordStrength)} +
+
+

+ ${msg( + str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`, + )} +

+ ${msg("Save")} +
+ + `, + () => html` +
+

${msg("Password")}

- - } - > - - ${when(this.pwStrengthResults, this.renderPasswordStrength)} -
-
-

- ${msg( - str`Choose a strong password between ${PASSWORD_MINLENGTH}-${PASSWORD_MAXLENGTH} characters.`, - )} -

${msg("Save")} (this.isChangingPassword = true)} + >${msg("Change Password")} -
- - `, - () => html` -
-

- ${msg("Password")} -

- (this.isChangingPassword = true)} - >${msg("Change Password")} -
- `, - )} -
+

+ `, + )} +
+ `, + () => html` +
+
+

+ ${msg("Password")} +

+

${msg("Password changes are disabled for external accounts. Please change your password in your organization's portal.")}

+
+
+ ` + )}
`; } diff --git a/frontend/src/index.ts b/frontend/src/index.ts index d2ce2061c9..a6f7ed1b0d 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -50,6 +50,7 @@ export type APIUser = { is_verified: boolean; is_superuser: boolean; orgs: UserOrg[]; + is_sso: boolean; }; @localized() @@ -58,6 +59,9 @@ export class App extends LiteElement { @property({ type: String }) version?: string; + @property({ type: Boolean }) + InvitesEnabled: boolean = true; + private readonly router = new APIRouter(ROUTES); authService = new AuthService(); @@ -151,6 +155,7 @@ export class App extends LiteElement { isVerified: userInfo.is_verified, isAdmin: userInfo.is_superuser, orgs: userInfo.orgs, + isSSO: userInfo.is_sso, }); const orgs = userInfo.orgs; if ( @@ -219,6 +224,10 @@ export class App extends LiteElement { ); } + firstUpdated() { + this.checkEnabledInvites(); + } + render() { return html`
@@ -309,7 +318,7 @@ export class App extends LiteElement { ${msg("Account Settings")} - ${this.appState.userInfo?.isAdmin + ${this.appState.userInfo?.isAdmin && this.InvitesEnabled ? html` this.navigate(ROUTES.usersInvite)} > @@ -554,6 +563,20 @@ export class App extends LiteElement { this.viewState.data?.redirectUrl} >`; + case "loginSsoHeader": + return html``; + + case "loginSsoOidc": + return html``; + case "resetPassword": return html`) { if (changedProperties.has("slug") && this.slug) { this.navTo(`/orgs/${this.slug}`); @@ -164,14 +172,19 @@ export class Home extends LiteElement { >
-
-
-

- ${msg("Invite User to Org")} -

- ${this.renderInvite()} -
-
+ ${when(this.InvitesEnabled, () => + html` +
+
+

+ ${msg("Invite User to Org")} +

+ ${this.renderInvite()} +
+
+ ` + )} + +
+
+ + ${this.serverError} + +
+
${this.renderBackButton()}
+
+ `; + } + return html`
`; + } + + private renderBackButton() { + + return html` +
+ ${msg("Back To Log In")} +
+ `; + } + + private async login(): Promise { + try { + const data = await AuthService.login_header({}); + + this.dispatchEvent( + AuthService.createLoggedInEvent({ + ...data, + redirectUrl: this.redirectUrl, + }) + ); + + // no state update here, since "btrix-logged-in" event + // will result in a route change + } catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting single sign-on"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + async onSubmitBack(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in"; + } +} diff --git a/frontend/src/pages/log-in-oidc.ts b/frontend/src/pages/log-in-oidc.ts new file mode 100644 index 0000000000..fbbc741e4f --- /dev/null +++ b/frontend/src/pages/log-in-oidc.ts @@ -0,0 +1,119 @@ +import { state, property, customElement } from "lit/decorators.js"; +import { msg, localized } from "@lit/localize"; + +import type { AuthState } from "@/utils/AuthService"; +import LiteElement, { html } from "@/utils/LiteElement"; +import AuthService from "@/utils/AuthService"; +import { ROUTES } from "@/routes"; + +@localized() +@customElement("btrix-log-in-oidc") +export class LoginSsoOidc extends LiteElement { + @property({ type: Object }) + authState?: AuthState; + + @property({ type: String }) + token?: string; + + @property({ type: String }) + redirectUrl: string = ROUTES.home; + + @property({ type: String }) + session_state: string = ''; + + @property({ type: String }) + code: string = ''; + + @state() + private serverError?: string; + + firstUpdated() { + let params = new URLSearchParams(window.location.search); + this.session_state = params.get('session_state') || '' as string; + this.code = params.get('code') || '' as string; + + if (this.code !== '' && this.session_state !== '') { + this.login_callback(); + } + else { + this.login_init(); + } + } + + render() { + if (this.serverError) { + return html` +
+
+
+ + ${this.serverError} + +
+
${this.renderBackButton()}
+
+
`; + } + return html`
`; + } + + private renderBackButton() { + + return html` +
+ ${msg("Back To Log In")} +
+ `; + } + + + private async login_init(): Promise { + try { + const redirect_url = await AuthService.login_oidc({}); + window.location.href = redirect_url; + } + catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting single sign-on"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + private async login_callback(): Promise { + try { + const data = await AuthService.login_oidc_callback({session_state: this.session_state, code: this.code}); + + this.dispatchEvent( + AuthService.createLoggedInEvent({ + ...data, + redirectUrl: this.redirectUrl, + }) + ); + + // no state update here, since "btrix-logged-in" event + // will result in a route change + } catch (e: any) { + if (e.isApiError) { + let message = msg("Sorry, an error occurred while attempting single sign-on"); + this.serverError = message; + } else { + let message = msg("Something went wrong, couldn't sign you in"); + this.serverError = message; + } + } + } + + async onSubmitBack(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in"; + } +} diff --git a/frontend/src/pages/log-in.ts b/frontend/src/pages/log-in.ts index 52b82ab8ec..922cae104e 100644 --- a/frontend/src/pages/log-in.ts +++ b/frontend/src/pages/log-in.ts @@ -2,6 +2,7 @@ import { state, property, customElement } from "lit/decorators.js"; import { msg, localized } from "@lit/localize"; import { createMachine, interpret, assign } from "@xstate/fsm"; +import { when } from "lit/directives/when.js"; import type { ViewState } from "@/utils/APIRouter"; import LiteElement, { html } from "@/utils/LiteElement"; @@ -147,6 +148,15 @@ export class LogInPage extends LiteElement { @property({ type: String }) redirectUrl: string = ROUTES.home; + @property({ type: Boolean }) + PasswordLoginEnabled: boolean = true; + + @property({ type: Boolean }) + HeaderSSOEnabled: boolean = false; + + @property({ type: Boolean }) + OIDCSSOEnabled: boolean = false; + private readonly formStateService = interpret(machine); @state() @@ -214,12 +224,20 @@ export class LogInPage extends LiteElement { `; } + if (!this.PasswordLoginEnabled){ + form = ''; + link = ''; + } + return html`
${successMessage}
+
${this.renderFormError()}
${form}
+ ${when(this.HeaderSSOEnabled, () => html`
${this.renderLoginHeaderButton()}
`)} + ${when(this.OIDCSSOEnabled, () => html`
${this.renderLoginOIDCButton()}
`)}
${link}
@@ -236,7 +254,7 @@ export class LogInPage extends LiteElement { } } - private renderLoginForm() { + private renderFormError() { let formError; if (this.formState.context.serverError) { @@ -249,6 +267,16 @@ export class LogInPage extends LiteElement { `; } + return formError + } + + private renderLoginForm() { + + var button_type = "primary" + if (this.HeaderSSOEnabled || this.OIDCSSOEnabled) { + button_type = "default" + } + return html`
@@ -275,11 +303,9 @@ export class LogInPage extends LiteElement {
- ${formError} - + ${msg("Log In with Single Sign-On")} + + `; + } + + private renderLoginOIDCButton() { + + return html` +
+ ${msg("Log In with Single Sign-On")} +
+ `; + } + private renderForgotPasswordForm() { let formError; @@ -342,6 +400,7 @@ export class LogInPage extends LiteElement { const resp = await fetch("/api/settings"); if (resp.status === 200) { this.formStateService.send("BACKEND_INITIALIZED"); + this.checkEnabledSSOMethods(); } else { this.formStateService.send("BACKEND_NOT_INITIALIZED"); this.timerId = window.setTimeout( @@ -351,6 +410,16 @@ export class LogInPage extends LiteElement { } } + async checkEnabledSSOMethods() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + this.PasswordLoginEnabled = data.login_methods.password; + this.HeaderSSOEnabled = data.login_methods.sso_header; + this.OIDCSSOEnabled = data.login_methods.sso_oidc; + } + } + async onSubmitLogIn(event: SubmitEvent) { event.preventDefault(); this.formStateService.send("SUBMIT"); @@ -396,6 +465,16 @@ export class LogInPage extends LiteElement { } } + async onSubmitLogInHeader(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in/header"; + } + + async onSubmitLogInOIDC(event: SubmitEvent) { + event.preventDefault(); + window.location.href = "/log-in/oidc"; + } + async onSubmitResetPassword(event: SubmitEvent) { event.preventDefault(); this.formStateService.send("SUBMIT"); diff --git a/frontend/src/pages/org/settings.ts b/frontend/src/pages/org/settings.ts index f51132ef16..501cdffb5f 100644 --- a/frontend/src/pages/org/settings.ts +++ b/frontend/src/pages/org/settings.ts @@ -82,6 +82,9 @@ export class OrgSettings extends LiteElement { @property({ type: Boolean }) isSavingOrgName = false; + @property({ type: Boolean }) + InvitesEnabled: boolean = true; + @state() pendingInvites: Invite[] = []; @@ -103,6 +106,10 @@ export class OrgSettings extends LiteElement { private readonly validateOrgNameMax = maxLengthValidator(40); + firstUpdated() { + this.checkEnabledInvites(); + } + async willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("isAddingMember") && this.isAddingMember) { this.isAddMemberFormVisible = true; @@ -126,20 +133,24 @@ export class OrgSettings extends LiteElement { this.activePanel === "members", () => html`

${msg("Active Members")}

- - - ${msg("Invite New Member")} + ${when(this.InvitesEnabled, () => + html` + + + ${msg("Invite New Member")} + ` + )} `, () => html`

${this.tabLabels[this.activePanel]}

`, )} @@ -318,15 +329,19 @@ export class OrgSettings extends LiteElement { `, )} - (this.isAddMemberFormVisible = true)} - @sl-after-hide=${() => (this.isAddMemberFormVisible = false)} - > - ${this.isAddMemberFormVisible ? this.renderInviteForm() : ""} - + ${when(this.InvitesEnabled, () => + html` + (this.isAddMemberFormVisible = true)} + @sl-after-hide=${() => (this.isAddMemberFormVisible = false)} + > + ${this.isAddMemberFormVisible ? this.renderInviteForm() : ""} + + ` + )} `; } @@ -595,4 +610,12 @@ export class OrgSettings extends LiteElement { }); } } + + async checkEnabledInvites() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + this.InvitesEnabled = data.invites_enabled; + } + } } diff --git a/frontend/src/pages/users-invite.ts b/frontend/src/pages/users-invite.ts index a89296b2a2..2fc2bd7cee 100644 --- a/frontend/src/pages/users-invite.ts +++ b/frontend/src/pages/users-invite.ts @@ -19,6 +19,10 @@ export class UsersInvite extends LiteElement { @state() private invitedEmail?: string; + firstUpdated() { + this.checkEnabledInvites(); + } + render() { let successMessage; @@ -58,4 +62,14 @@ export class UsersInvite extends LiteElement { private onSuccess(event: CustomEvent<{ inviteEmail: string }>) { this.invitedEmail = event.detail.inviteEmail; } + + async checkEnabledInvites() { + const resp = await fetch("/api/auth/jwt/login/methods"); + if (resp.status == 200) { + const data = await resp.json(); + if (!data.invites_enabled){ + this.navTo(`/`); + } + } + } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9d297cb0a0..e607e82533 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -5,6 +5,8 @@ export const ROUTES = { acceptInvite: "/invite/accept/:token", verify: "/verify", login: "/log-in", + loginSsoHeader: "/log-in/header", + loginSsoOidc: "/log-in/oidc", loginWithRedirect: "/log-in?redirectUrl", forgotPassword: "/log-in/forgot-password", resetPassword: "/reset-password", diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index bd708e7265..435a74f16c 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -12,4 +12,5 @@ export type CurrentUser = { isVerified: boolean; isAdmin: boolean; orgs: UserOrg[]; + isSSO: boolean; }; diff --git a/frontend/src/utils/AuthService.ts b/frontend/src/utils/AuthService.ts index 2feade6af4..a90ef2c1aa 100644 --- a/frontend/src/utils/AuthService.ts +++ b/frontend/src/utils/AuthService.ts @@ -161,6 +161,73 @@ export default class AuthService { }; } + static async login_header({}: { + }): Promise { + const resp = await fetch("/api/auth/jwt/login/header"); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + const token = AuthService.decodeToken(data.access_token); + const authHeaders = AuthService.parseAuthHeaders(data); + + return { + username: "placeholder", + headers: authHeaders, + tokenExpiresAt: token.exp * 1000, + }; + } + + static async login_oidc({}: { + }): Promise { + const resp = await fetch("/api/auth/jwt/login/oidc"); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + + return data.redirect_url + } + + static async login_oidc_callback({ + session_state, + code, + }: { + session_state: string, + code: string + }): Promise { + const params = "?session_state=" + session_state + "&code=" + code + const resp = await fetch("/api/auth/jwt/login/oidc/callback" + params); + + if (resp.status !== 200) { + throw new APIError({ + message: resp.statusText, + status: resp.status, + }); + } + + const data = await resp.json(); + const token = AuthService.decodeToken(data.access_token); + const authHeaders = AuthService.parseAuthHeaders(data); + + return { + username: "placeholder", + headers: authHeaders, + tokenExpiresAt: token.exp * 1000, + }; + } + + /** * Decode JSON web token returned as access token */ diff --git a/mkdocs.yml b/mkdocs.yml index 1d3ab9a032..fbc43a594f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - deploy/index.md - deploy/local.md - deploy/remote.md + - deploy/sso.md - Ansible: - deploy/ansible/digitalocean.md - deploy/ansible/microk8s.md