Files
skyline-apiserver/skyline_apiserver/main.py
Wu Wenxiang dde20cbb20 feat: Add configurable user default domain for login
Add 'user_default_domain' config option to replace the hardcoded
"Default" domain when users login without specifying a domain.

Also add '/config' API endpoint to expose public configurations
before authentication. Currently returns user_default_domain and
can be extended for other public configs.

Changes:
- Add user_default_domain in openstack config section
- Add GET /config endpoint (no auth required)
- Use generic Config schema for extensibility
- Update docs and sample files

This improves flexibility for multi-domain deployments.

Change-Id: I6fc40337c9835cf8172bcfa86cac621b7effeb0d
Signed-off-by: Reet Srivastava <reet.srivastava@rackspace.com>
Signed-off-by: Wu Wenxiang <wu.wenxiang@algoblu.com>
2025-10-01 07:28:20 +08:00

163 lines
5.7 KiB
Python

# Copyright 2021 99cloud
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
import jose
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from starlette.middleware.cors import CORSMiddleware
from skyline_apiserver.api.v1 import api_router
from skyline_apiserver.config import CONF, configure
from skyline_apiserver.context import RequestContext
from skyline_apiserver.core.security import generate_profile_by_token, parse_access_token
from skyline_apiserver.db import api as db_api, 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"
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
configure("skyline")
log_setup(
Path(CONF.default.log_dir).joinpath(CONF.default.log_file),
debug=CONF.default.debug,
)
policies_setup()
db_setup()
# Set all CORS enabled origins
if CONF.default.cors_allow_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in CONF.default.cors_allow_origins],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
LOG.debug("Skyline API server start")
yield
LOG.debug("Skyline API server stop")
app = FastAPI(
title=PROJECT_NAME,
openapi_url=f"{constants.API_PREFIX}/openapi.json",
lifespan=lifespan,
)
@app.middleware("http")
async def validate_token(request: Request, call_next):
url_path = request.url.path
LOG.debug(f"Request path: {url_path}")
# Skip authentication for login and static endpoints
ignore_urls = [
f"{constants.API_PREFIX}/login",
f"{constants.API_PREFIX}/websso",
"/static",
"/docs",
f"{constants.API_PREFIX}/openapi.json",
"/favicon.ico",
f"{constants.API_PREFIX}/sso",
f"{constants.API_PREFIX}/config",
f"{constants.API_PREFIX}/contrib/keystone_endpoints",
# f"{constants.API_PREFIX}/contrib/domains",
f"{constants.API_PREFIX}/contrib/regions",
]
for ignore_url in ignore_urls:
if url_path.startswith(ignore_url):
return await call_next(request)
if url_path.startswith(constants.API_PREFIX):
# Get token from cookie
token = request.cookies.get(CONF.default.session_name)
if not token:
return JSONResponse(
content={"message": "Unauthorized: Token not found"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
try:
# Purge revoked tokens
db_api.purge_revoked_token()
# Parse and validate token
parsed_token = parse_access_token(token)
is_revoked = db_api.check_token(parsed_token.uuid)
if is_revoked:
return JSONResponse(
content={"message": "Unauthorized: Token revoked"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Generate profile from token
profile = generate_profile_by_token(parsed_token)
# Create RequestContext from profile
request.state.context = RequestContext(
user_id=profile.user.id,
project_id=profile.project.id,
project_name=profile.project.name,
user_domain_id=profile.user.domain.id,
project_domain_id=profile.project.domain.id,
roles=[role.name for role in profile.roles],
auth_token=profile.keystone_token,
)
# Store profile in request state for backward compatibility
request.state.profile = profile
# Check if token needs renewal
if 0 < profile.exp - time.time() < CONF.default.access_token_renew:
profile.exp = int(time.time()) + CONF.default.access_token_expire
# Note: We can't set cookies in middleware, so we'll handle this in the response
request.state.token_needs_renewal = True
request.state.new_token = profile.toJWTPayload()
request.state.new_exp = str(profile.exp)
except jose.exceptions.ExpiredSignatureError as e:
return JSONResponse(
content={"message": f"Unauthorized: Token expired - {str(e)}"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
except Exception as e:
return JSONResponse(
content={"message": f"Unauthorized: {str(e)}"},
status_code=status.HTTP_401_UNAUTHORIZED,
)
response = await call_next(request)
# Handle token renewal in response
if hasattr(request.state, "token_needs_renewal") and request.state.token_needs_renewal:
response.set_cookie(CONF.default.session_name, request.state.new_token)
response.set_cookie(constants.TIME_EXPIRED_KEY, request.state.new_exp)
return response
app.include_router(api_router, prefix=constants.API_PREFIX)