Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSO Support #1492

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2415946
Initial skeleton of SSO with Headers
fservida Jan 26, 2024
aa4f202
Update User Org Membership on creation and login from Header variable
fservida Jan 27, 2024
495dfe7
Initial Config Setting, allow Admin to enable/disable SSO
fservida Jan 27, 2024
ff71cb8
Refactored Endpoints to better match structure
fservida Jan 27, 2024
1b5d58a
Partial OIDC support
fservida Jan 27, 2024
9b2f7d2
Fix Template for Configmap
fservida Jan 27, 2024
4d367d5
Dynamic OIDC fields
fservida Jan 27, 2024
16fee64
Dynamic Header Names
fservida Jan 27, 2024
544579f
Updated values.yml with default values
fservida Jan 27, 2024
006d080
Fix to openid_convertor dynamic parameters
fservida Jan 31, 2024
4965a9e
Implemented login with OIDC and Headers
fservida Jan 31, 2024
edf6c40
Remove redundant import in index.ts
fservida Jan 31, 2024
0d0e4b3
Implemented Dynamic Buttons for Login Form
fservida Feb 2, 2024
c97af6e
Backend Support for SSO User flag field
fservida Feb 2, 2024
dba01db
Disable user edits if SSO on frontend
fservida Feb 2, 2024
6e5e0d0
Disable user edits in backend if user is SSO
fservida Feb 2, 2024
8d989c3
Allow fully disabling password login
fservida Feb 2, 2024
fa25e8f
Fix configmap value for PASSWORD_DISABLED:
fservida Feb 2, 2024
82d9507
Promote/Demote superusers based on group
fservida Feb 2, 2024
975f0b8
Implemented ability to disable invites
fservida Feb 2, 2024
888d7f8
Merged from upstream
fservida Feb 2, 2024
560066c
Dynamically disable last elements of interface for invites and invite…
fservida Feb 2, 2024
cea4cfd
Updated values.yml with new options
fservida Feb 2, 2024
521fafa
Created Documentation and fixed callback url in values.yml
fservida Feb 3, 2024
4307fc2
Doc fix
fservida Feb 3, 2024
af4e461
Apply suggestions from code review
fservida Feb 27, 2024
fad02f2
Apply suggestions from code review
fservida Feb 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 211 additions & 1 deletion backend/btrixcloud/auth.py
Original file line number Diff line number Diff line change
@@ -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)):
6 changes: 6 additions & 0 deletions backend/btrixcloud/invites.py
Original file line number Diff line number Diff line change
@@ -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:
4 changes: 4 additions & 0 deletions backend/btrixcloud/models.py
Original file line number Diff line number Diff line change
@@ -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


# ============================================================================
22 changes: 21 additions & 1 deletion backend/btrixcloud/users.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -28,3 +28,4 @@ types_aiobotocore_s3
types-redis
types-python-slugify
types-pyYAML
fastapi-sso
Loading