diff --git a/docs/api/swagger.json b/docs/api/swagger.json index f1bf1c1..61f5381 100644 --- a/docs/api/swagger.json +++ b/docs/api/swagger.json @@ -70,6 +70,86 @@ } } }, + "/api/v1/sso": { + "get": { + "tags": [ + "Login" + ], + "summary": "Get Sso", + "description": "SSO configuration.", + "operationId": "get_sso_api_v1_sso_get", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SSO" + } + } + } + } + } + } + }, + "/api/v1/websso": { + "post": { + "tags": [ + "Login" + ], + "summary": "Websso", + "description": "Websso", + "operationId": "websso_api_v1_websso_post", + "parameters": [ + { + "required": false, + "schema": { + "title": "X-Openstack-Request-Id", + "pattern": "^req-\\w{8}(-\\w{4}){3}-\\w{12}", + "type": "string", + "default": "" + }, + "name": "X-Openstack-Request-Id", + "in": "header" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_websso_api_v1_websso_post" + } + } + }, + "required": true + }, + "responses": { + "302": { + "description": "Redirect" + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedMessage" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/profile": { "get": { "tags": [ @@ -1903,6 +1983,19 @@ } } }, + "Body_websso_api_v1_websso_post": { + "title": "Body_websso_api_v1_websso_post", + "required": [ + "token" + ], + "type": "object", + "properties": { + "token": { + "title": "Token", + "type": "string" + } + } + }, "ComputeServicesResponse": { "title": "ComputeServicesResponse", "required": [ @@ -2871,6 +2964,45 @@ } } }, + "SSO": { + "title": "SSO", + "required": [ + "enable_sso", + "protocols" + ], + "type": "object", + "properties": { + "enable_sso": { + "title": "Enable Sso", + "type": "boolean" + }, + "protocols": { + "title": "Protocols", + "type": "array", + "items": { + "$ref": "#/components/schemas/SSOInfo" + } + } + } + }, + "SSOInfo": { + "title": "SSOInfo", + "required": [ + "protocol", + "url" + ], + "type": "object", + "properties": { + "protocol": { + "title": "Protocol", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + } + } + }, "ServerSortKey": { "title": "ServerSortKey", "enum": [ diff --git a/etc/skyline.yaml.sample b/etc/skyline.yaml.sample index 5a7b85e..adf7d85 100644 --- a/etc/skyline.yaml.sample +++ b/etc/skyline.yaml.sample @@ -11,6 +11,7 @@ default: prometheus_endpoint: http://localhost:9091 secret_key: aCtmgbcUqYUy_HNVg5BDXCaeJgJQzHJXwqbXr0Nmb2o session_name: session + ssl_enabled: true openstack: base_domains: - heat_user_domain @@ -40,6 +41,10 @@ openstack: placement: placement sharev2: manilav2 volumev3: cinder + sso_enabled: false + sso_protocols: + - openid + sso_region: RegionOne system_admin_roles: - admin - system_admin diff --git a/releasenotes/notes/add-sso-login-via-openid.yaml b/releasenotes/notes/add-sso-login-via-openid.yaml new file mode 100644 index 0000000..8c69e57 --- /dev/null +++ b/releasenotes/notes/add-sso-login-via-openid.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add single sign-on (SSO) support. Skyline login with SSO configured with OpenID Connect. diff --git a/skyline_apiserver/api/v1/login.py b/skyline_apiserver/api/v1/login.py index 2952733..834987e 100644 --- a/skyline_apiserver/api/v1/login.py +++ b/skyline_apiserver/api/v1/login.py @@ -14,8 +14,11 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status -from keystoneauth1.identity.v3 import Password +from pathlib import PurePath + +from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from keystoneauth1.identity.v3 import Password, Token from keystoneauth1.session import Session from keystoneclient.client import Client as KeystoneClient @@ -126,6 +129,118 @@ async def login( return profile +@router.get( + "/sso", + description="SSO configuration.", + responses={ + 200: {"model": schemas.SSO}, + }, + response_model=schemas.SSO, + status_code=status.HTTP_200_OK, + response_description="OK", +) +async def get_sso( + request: Request, +) -> schemas.SSO: + sso = { + "enable_sso": False, + "protocols": [], + } + if CONF.openstack.sso_enabled: + protocols = [] + + ks_url = CONF.openstack.keystone_url.rstrip("/") + url_scheme = "https" if CONF.default.ssl_enabled else "http" + base_url = f"{url_scheme}://{request.url.hostname}:{request.url.port}" + base_path = str(PurePath("/").joinpath(CONF.openstack.nginx_prefix, "skyline")) + + for protocol in CONF.openstack.sso_protocols: + + url = ( + f"{ks_url}/auth/OS-FEDERATION/websso/{protocol}" + f"?origin={base_url}{base_path}{constants.API_PREFIX}/websso" + ) + + protocols.append( + { + "protocol": protocol, + "url": url, + } + ) + + sso = { + "enable_sso": CONF.openstack.sso_enabled, + "protocols": protocols, + } + + return schemas.SSO(**sso) + + +@router.post( + "/websso", + description="Websso", + responses={ + 302: {"class": RedirectResponse}, + 401: {"model": schemas.common.UnauthorizedMessage}, + }, + response_class=RedirectResponse, + status_code=status.HTTP_302_FOUND, + response_description="Redirect", +) +async def websso( + token: str = Form(...), + x_openstack_request_id: str = Header( + "", + alias=constants.INBOUND_HEADER, + regex=constants.INBOUND_HEADER_REGEX, + ), +) -> RedirectResponse: + try: + auth_url = await utils.get_endpoint( + region=CONF.openstack.sso_region, + service="keystone", + session=get_system_session(), + ) + unscope_auth = Token( + auth_url=auth_url, + token=token, + reauthenticate=False, + ) + session = Session(auth=unscope_auth, verify=False, timeout=constants.DEFAULT_TIMEOUT) + unscope_client = KeystoneClient( + session=session, + endpoint=auth_url, + interface=CONF.openstack.interface_type, + ) + project_scope = unscope_client.auth.projects() + # we must get the project_scope with enabled project + project_scope = [scope for scope in project_scope if scope.enabled] + if not project_scope: + raise Exception("You are not authorized for any projects or domains.") + + project_scope_token = await get_project_scope_token( + keystone_token=token, + region=CONF.openstack.sso_region, + project_id=project_scope[0].id, + ) + + profile = await generate_profile( + keystone_token=project_scope_token, + region=CONF.openstack.sso_region, + ) + + profile = await _patch_profile(profile, x_openstack_request_id) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + ) + else: + response = RedirectResponse(url="/base/overview", status_code=status.HTTP_302_FOUND) + response.set_cookie(CONF.default.session_name, profile.toJWTPayload()) + return response + + @router.get( "/profile", description="Get user profile.", diff --git a/skyline_apiserver/config/default.py b/skyline_apiserver/config/default.py index 32f69d6..82437e9 100644 --- a/skyline_apiserver/config/default.py +++ b/skyline_apiserver/config/default.py @@ -104,6 +104,13 @@ prometheus_basic_auth_password = Opt( default="", ) +ssl_enabled = Opt( + name="ssl_enabled", + description="enable ssl", + schema=StrictBool, + default=True, +) + GROUP_NAME = __name__.split(".")[-1] ALL_OPTS = ( debug, @@ -113,6 +120,7 @@ ALL_OPTS = ( access_token_renew, cors_allow_origins, session_name, + ssl_enabled, database_url, prometheus_endpoint, prometheus_enable_basic_auth, diff --git a/skyline_apiserver/config/openstack.py b/skyline_apiserver/config/openstack.py index 3d100ca..0439b50 100644 --- a/skyline_apiserver/config/openstack.py +++ b/skyline_apiserver/config/openstack.py @@ -16,7 +16,7 @@ from __future__ import annotations from typing import Dict, List -from pydantic import HttpUrl, StrictInt, StrictStr +from pydantic import HttpUrl, StrictBool, StrictInt, StrictStr from skyline_apiserver.config.base import Opt from skyline_apiserver.types import InterfaceType @@ -152,9 +152,34 @@ reclaim_instance_interval = Opt( default=60 * 60 * 24 * 7, ) +sso_enabled = Opt( + name="sso_enabled", + description="enable sso", + schema=StrictBool, + default=False, +) + +sso_protocols = Opt( + name="sso_protocols", + description="SSO protocol list", + schema=List[StrictStr], + default=[ + "openid", + ], +) + +sso_region = Opt( + name="sso_region", + description="SSO region", + schema=StrictStr, + default="RegionOne", +) GROUP_NAME = __name__.split(".")[-1] ALL_OPTS = ( + sso_enabled, + sso_protocols, + sso_region, keystone_url, system_project_domain, system_project, diff --git a/skyline_apiserver/main.py b/skyline_apiserver/main.py index 00422fa..9d03d48 100644 --- a/skyline_apiserver/main.py +++ b/skyline_apiserver/main.py @@ -24,9 +24,9 @@ from skyline_apiserver.config import CONF, configure from skyline_apiserver.db import setup as db_setup from skyline_apiserver.log import LOG, setup as log_setup from skyline_apiserver.policy import setup as policies_setup +from skyline_apiserver.types import constants PROJECT_NAME = "Skyline API" -API_PREFIX = "/api/v1" async def on_startup() -> None: @@ -56,9 +56,9 @@ async def on_shutdown() -> None: app = FastAPI( title=PROJECT_NAME, - openapi_url=f"{API_PREFIX}/openapi.json", + openapi_url=f"{constants.API_PREFIX}/openapi.json", on_startup=[on_startup], on_shutdown=[on_shutdown], ) -app.include_router(api_router, prefix=API_PREFIX) +app.include_router(api_router, prefix=constants.API_PREFIX) diff --git a/skyline_apiserver/schemas/__init__.py b/skyline_apiserver/schemas/__init__.py index ae0bde5..f590bcf 100644 --- a/skyline_apiserver/schemas/__init__.py +++ b/skyline_apiserver/schemas/__init__.py @@ -42,7 +42,7 @@ from .extension import ( VolumesResponse, VolumeStatus, ) -from .login import Credential, Payload, Profile +from .login import SSO, Credential, Payload, Profile from .policy import Policies, PoliciesRules from .policy_manager import Operation, OperationsSchema, ScopeTypesSchema from .prometheus import ( diff --git a/skyline_apiserver/schemas/login.py b/skyline_apiserver/schemas/login.py index beffc5d..88177ae 100644 --- a/skyline_apiserver/schemas/login.py +++ b/skyline_apiserver/schemas/login.py @@ -106,3 +106,13 @@ class Profile(PayloadBase): def toJWTPayload(self) -> str: return self.toPayLoad().toJWTPayload() + + +class SSOInfo(BaseModel): + protocol: str + url: str + + +class SSO(BaseModel): + enable_sso: bool + protocols: List[SSOInfo] diff --git a/skyline_apiserver/types/constants.py b/skyline_apiserver/types/constants.py index 55a1585..2b64925 100644 --- a/skyline_apiserver/types/constants.py +++ b/skyline_apiserver/types/constants.py @@ -45,6 +45,8 @@ DEFAULT_TIMEOUT = 30 POLICY_NS = "oslo.policy.policies" +API_PREFIX = "/api/v1" + SUPPORTED_SERVICE_EPS = { # openstack_service: [, ,] "barbican": ["barbican"],