zmp-authentication-provider 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zmp_authentication_provider-0.1.0/LICENSE +21 -0
- zmp_authentication_provider-0.1.0/PKG-INFO +26 -0
- zmp_authentication_provider-0.1.0/README.md +0 -0
- zmp_authentication_provider-0.1.0/pyproject.toml +80 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/__init__.py +0 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/auth/__init__.py +6 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/auth/basic_auth.py +47 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/auth/oauth2_keycloak.py +109 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/db/basic_auth_user_repository.py +180 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/exceptions.py +120 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/routes/auth.py +286 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/scheme/auth_model.py +68 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/service/auth_service.py +91 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/setting.py +52 -0
- zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/utils/encryption_utils.py +43 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Cloud Z MP
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: zmp-authentication-provider
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: This is a library project for the authentication using the basic auth and oidc
|
|
5
|
+
Author: Kilsoo Kang
|
|
6
|
+
Author-email: kilsoo75@gmail.com
|
|
7
|
+
Requires-Python: >=3.12,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Dist: cryptography (>=45.0.3,<46.0.0)
|
|
12
|
+
Requires-Dist: fastapi (>=0.115.11,<0.116.0)
|
|
13
|
+
Requires-Dist: motor (>=3.7.0,<4.0.0)
|
|
14
|
+
Requires-Dist: pydantic (>=2.10.6)
|
|
15
|
+
Requires-Dist: pydantic-settings (>=2.9.1,<3.0.0)
|
|
16
|
+
Requires-Dist: pyjwt (>=2.10.1,<3.0.0)
|
|
17
|
+
Requires-Dist: pymongo (>=4.12.0,<5.0.0)
|
|
18
|
+
Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
|
|
19
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
20
|
+
Project-URL: Bug Tracker, https://github.com/cloudz-mp/zmp-authentication-provider/issues
|
|
21
|
+
Project-URL: Documentation, https://github.com/cloudz-mp/zmp-authentication-provider
|
|
22
|
+
Project-URL: Homepage, https://github.com/cloudz-mp
|
|
23
|
+
Project-URL: Repository, https://github.com/cloudz-mp/zmp-authentication-provider
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
|
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zmp-authentication-provider"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "This is a library project for the authentication using the basic auth and oidc"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Kilsoo Kang",email = "kilsoo75@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12,<4.0"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic (>=2.10.6)",
|
|
12
|
+
"pydantic-settings (>=2.9.1,<3.0.0)",
|
|
13
|
+
"fastapi (>=0.115.11,<0.116.0)",
|
|
14
|
+
"python-dotenv (>=1.0.1,<2.0.0)",
|
|
15
|
+
"pyjwt (>=2.10.1,<3.0.0)",
|
|
16
|
+
"requests (>=2.32.3,<3.0.0)",
|
|
17
|
+
"cryptography (>=45.0.3,<46.0.0)",
|
|
18
|
+
"pymongo (>=4.12.0,<5.0.0)",
|
|
19
|
+
"motor (>=3.7.0,<4.0.0)",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
homepage = "https://github.com/cloudz-mp"
|
|
24
|
+
repository = "https://github.com/cloudz-mp/zmp-authentication-provider"
|
|
25
|
+
documentation = "https://github.com/cloudz-mp/zmp-authentication-provider"
|
|
26
|
+
"Bug Tracker" = "https://github.com/cloudz-mp/zmp-authentication-provider/issues"
|
|
27
|
+
|
|
28
|
+
[tool.poetry]
|
|
29
|
+
packages = [{include = "zmp_authentication_provider", from = "src"}]
|
|
30
|
+
|
|
31
|
+
[tool.poetry.group.dev.dependencies]
|
|
32
|
+
pytest = "^8.3.5"
|
|
33
|
+
pytest-cov = "^6.0.0"
|
|
34
|
+
pytest-watcher = "^0.4.3"
|
|
35
|
+
pytest-asyncio = "^0.25.3"
|
|
36
|
+
certifi = "^2025.1.31"
|
|
37
|
+
ruff = "^0.11.0"
|
|
38
|
+
|
|
39
|
+
[tool.poetry.group.quality.dependencies]
|
|
40
|
+
pre-commit = "^4.1.0"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
44
|
+
build-backend = "poetry.core.masonry.api"
|
|
45
|
+
|
|
46
|
+
[tool.pytest-watcher]
|
|
47
|
+
now = true
|
|
48
|
+
delay = 0.1
|
|
49
|
+
runner_args = ["--ff", "-x", "-v", "--tb", "short"]
|
|
50
|
+
patterns = ["*.py"]
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
lint.select = [
|
|
54
|
+
"E", # pycodestyle
|
|
55
|
+
"F", # pyflakes
|
|
56
|
+
"I", # isort
|
|
57
|
+
"D", # pydocstyle
|
|
58
|
+
"D401", # First line should be in imperative mood
|
|
59
|
+
"T201",
|
|
60
|
+
"UP",
|
|
61
|
+
]
|
|
62
|
+
lint.ignore = [
|
|
63
|
+
"UP006",
|
|
64
|
+
"UP007",
|
|
65
|
+
# We actually do want to import from typing_extensions
|
|
66
|
+
"UP035",
|
|
67
|
+
# Relax the convention by _not_ requiring documentation for every function parameter.
|
|
68
|
+
"D417",
|
|
69
|
+
"E501",
|
|
70
|
+
"T201",
|
|
71
|
+
]
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests/*" = ["D", "UP"]
|
|
74
|
+
[tool.ruff.lint.pydocstyle]
|
|
75
|
+
convention = "google"
|
|
76
|
+
|
|
77
|
+
[dependency-groups]
|
|
78
|
+
dev = [
|
|
79
|
+
"langgraph-cli[inmem]>=0.1.71",
|
|
80
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Basic auth module for the AIops Pilot."""
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
4
|
+
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
5
|
+
|
|
6
|
+
from zmp_authentication_provider.scheme.auth_model import BasicAuthUser
|
|
7
|
+
from zmp_authentication_provider.service.auth_service import AuthService
|
|
8
|
+
from zmp_authentication_provider.setting import auth_default_settings
|
|
9
|
+
|
|
10
|
+
basic_security = HTTPBasic()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_basic_auth_user(service: AuthService, credentials: HTTPBasicCredentials):
|
|
14
|
+
"""Verify the basic auth user."""
|
|
15
|
+
user = service.get_basic_auth_user_by_username(credentials.username)
|
|
16
|
+
|
|
17
|
+
if not user:
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
20
|
+
detail=f"{credentials.username} not found",
|
|
21
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if user.password != credentials.password:
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
27
|
+
detail="Password is incorrect",
|
|
28
|
+
headers={"WWW-Authenticate": "Basic"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return BasicAuthUser(username=credentials.username, password=credentials.password)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_current_user_for_basicauth(
|
|
35
|
+
request: Request,
|
|
36
|
+
credentials: HTTPBasicCredentials = Depends(basic_security),
|
|
37
|
+
) -> BasicAuthUser:
|
|
38
|
+
"""Get the current user for basic auth."""
|
|
39
|
+
service = getattr(request.state, auth_default_settings.service_name, None)
|
|
40
|
+
if not service:
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=500,
|
|
43
|
+
detail=f"Service '{auth_default_settings.service_name}' not available in the request state. "
|
|
44
|
+
"You should set the service in the request state.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return verify_basic_auth_user(service=service, credentials=credentials)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""OAuth2 Keycloak module for the AIops Pilot."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
import requests
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from fastapi.security import OAuth2AuthorizationCodeBearer
|
|
9
|
+
from jwt import ExpiredSignatureError, InvalidIssuedAtError, InvalidKeyError, PyJWTError
|
|
10
|
+
from jwt.algorithms import RSAAlgorithm
|
|
11
|
+
|
|
12
|
+
from zmp_authentication_provider.exceptions import (
|
|
13
|
+
AuthError,
|
|
14
|
+
OauthTokenValidationException,
|
|
15
|
+
)
|
|
16
|
+
from zmp_authentication_provider.scheme.auth_model import TokenData
|
|
17
|
+
from zmp_authentication_provider.setting import keycloak_settings
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# KeyCloak Configuration using the settings
|
|
22
|
+
KEYCLOAK_SERVER_URL = keycloak_settings.server_url
|
|
23
|
+
KEYCLOAK_REALM = keycloak_settings.realm
|
|
24
|
+
KEYCLOAK_CLIENT_ID = keycloak_settings.client_id
|
|
25
|
+
KEYCLOAK_CLIENT_SECRET = keycloak_settings.client_secret
|
|
26
|
+
ALGORITHM = keycloak_settings.algorithm
|
|
27
|
+
KEYCLOAK_REDIRECT_URI = keycloak_settings.redirect_uri
|
|
28
|
+
|
|
29
|
+
HTTP_CLIENT_SSL_VERIFY = keycloak_settings.http_client_ssl_verify
|
|
30
|
+
|
|
31
|
+
# KeyCloak Endpoints
|
|
32
|
+
KEYCLOAK_REALM_ROOT_URL = (
|
|
33
|
+
f"{KEYCLOAK_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect"
|
|
34
|
+
)
|
|
35
|
+
KEYCLOAK_JWKS_ENDPOINT = f"{KEYCLOAK_REALM_ROOT_URL}/certs"
|
|
36
|
+
KEYCLOAK_AUTH_ENDPOINT = f"{KEYCLOAK_REALM_ROOT_URL}/auth"
|
|
37
|
+
KEYCLOAK_TOKEN_ENDPOINT = KEYCLOAK_REFRESH_ENDPOINT = f"{KEYCLOAK_REALM_ROOT_URL}/token"
|
|
38
|
+
KEYCLOAK_USER_ENDPOINT = f"{KEYCLOAK_REALM_ROOT_URL}/userinfo"
|
|
39
|
+
KEYCLOAK_END_SESSION_ENDPOINT = f"{KEYCLOAK_REALM_ROOT_URL}/logout"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_public_key():
|
|
43
|
+
"""Get the public key."""
|
|
44
|
+
response = requests.get(
|
|
45
|
+
KEYCLOAK_JWKS_ENDPOINT, verify=HTTP_CLIENT_SSL_VERIFY
|
|
46
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
47
|
+
jwks = response.json()
|
|
48
|
+
|
|
49
|
+
public_key = None
|
|
50
|
+
try:
|
|
51
|
+
public_key = RSAAlgorithm.from_jwk(jwks["keys"][0])
|
|
52
|
+
except InvalidKeyError as ike:
|
|
53
|
+
log.error(f"InvalidKeyError: {ike}")
|
|
54
|
+
|
|
55
|
+
return public_key
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
PUBLIC_KEY = get_public_key()
|
|
59
|
+
# oauth2_token_scheme = OAuth2PasswordBearer(tokenUrl=KEYCLOAK_TOKEN_ENDPOINT)
|
|
60
|
+
|
|
61
|
+
oauth2_auth_scheme = OAuth2AuthorizationCodeBearer(
|
|
62
|
+
authorizationUrl=KEYCLOAK_AUTH_ENDPOINT,
|
|
63
|
+
tokenUrl=KEYCLOAK_TOKEN_ENDPOINT,
|
|
64
|
+
refreshUrl=KEYCLOAK_USER_ENDPOINT,
|
|
65
|
+
# scopes={"openid": "openid", "profile": "profile", "email": "email"}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_token(token: str) -> TokenData:
|
|
70
|
+
"""Verify the token."""
|
|
71
|
+
try:
|
|
72
|
+
payload = jwt.decode(
|
|
73
|
+
jwt=token,
|
|
74
|
+
key=PUBLIC_KEY,
|
|
75
|
+
algorithms=[ALGORITHM],
|
|
76
|
+
audience=KEYCLOAK_CLIENT_ID,
|
|
77
|
+
options={"verify_aud": False, "verify_iat": False},
|
|
78
|
+
leeway=60,
|
|
79
|
+
)
|
|
80
|
+
"""
|
|
81
|
+
# if not options["verify_signature"]:
|
|
82
|
+
# options.setdefault("verify_exp", False)
|
|
83
|
+
# options.setdefault("verify_nbf", False)
|
|
84
|
+
# options.setdefault("verify_iat", False)
|
|
85
|
+
# options.setdefault("verify_aud", False)
|
|
86
|
+
# options.setdefault("verify_iss", False)
|
|
87
|
+
"""
|
|
88
|
+
if payload is None:
|
|
89
|
+
raise OauthTokenValidationException(
|
|
90
|
+
AuthError.INVALID_TOKEN, details="jwt docode failed"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
token_data = TokenData(username=payload.get("preferred_username"), **payload)
|
|
94
|
+
except ExpiredSignatureError as ese:
|
|
95
|
+
log.error(f"ExpiredSignatureError: {ese}")
|
|
96
|
+
raise OauthTokenValidationException(AuthError.INVALID_TOKEN, details=str(ese))
|
|
97
|
+
except InvalidIssuedAtError as iiae:
|
|
98
|
+
log.error(f"InvalidIssuedAtError: {iiae}")
|
|
99
|
+
raise OauthTokenValidationException(AuthError.INVALID_TOKEN, details=str(iiae))
|
|
100
|
+
except PyJWTError as jwte:
|
|
101
|
+
log.error(f"JWTError: {jwte}")
|
|
102
|
+
raise OauthTokenValidationException(AuthError.INVALID_TOKEN, details=str(jwte))
|
|
103
|
+
|
|
104
|
+
return token_data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def get_current_user(token: str = Depends(oauth2_auth_scheme)) -> TokenData:
|
|
108
|
+
"""Get the current user from the token."""
|
|
109
|
+
return verify_token(token)
|
zmp_authentication_provider-0.1.0/src/zmp_authentication_provider/db/basic_auth_user_repository.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""BasicAuthUserRepository class for the basic auth user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import pymongo
|
|
9
|
+
from bson import ObjectId
|
|
10
|
+
from bson.errors import InvalidId
|
|
11
|
+
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
12
|
+
from pymongo.errors import DuplicateKeyError
|
|
13
|
+
from pymongo.results import InsertOneResult
|
|
14
|
+
|
|
15
|
+
from zmp_authentication_provider.exceptions import (
|
|
16
|
+
InvalidObjectIDException,
|
|
17
|
+
ObjectNotFoundException,
|
|
18
|
+
)
|
|
19
|
+
from zmp_authentication_provider.scheme.auth_model import BasicAuthUser
|
|
20
|
+
from zmp_authentication_provider.utils.encryption_utils import decrypt, encrypt
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
25
|
+
"""Default time format and time zone %Y-%m-%dT%H:%M:%S%z"""
|
|
26
|
+
DEFAULT_TIME_ZONE = datetime.UTC
|
|
27
|
+
"""Default time zone is UTC(+00:00)"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BasicAuthUserRepository:
|
|
31
|
+
"""BasicAuthUserRepository class."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, collection: AsyncIOMotorCollection):
|
|
34
|
+
"""Initialize the repository with MongoDB database."""
|
|
35
|
+
self._collection = collection
|
|
36
|
+
|
|
37
|
+
def __call__(self) -> BasicAuthUserRepository:
|
|
38
|
+
"""Create indexes for the collection."""
|
|
39
|
+
indexes = self._collection.list_indexes()
|
|
40
|
+
|
|
41
|
+
unique_index_name = "unique_key_username"
|
|
42
|
+
|
|
43
|
+
unique_index_exists = False
|
|
44
|
+
for index in indexes:
|
|
45
|
+
if index["name"] == unique_index_name and index.get("unique", True):
|
|
46
|
+
unique_index_exists = True
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
if not unique_index_exists:
|
|
50
|
+
self._collection.create_index(
|
|
51
|
+
"username", name=unique_index_name, unique=True
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def insert(self, basic_auth_user: BasicAuthUser) -> str:
|
|
57
|
+
"""Insert a basic auth user."""
|
|
58
|
+
if basic_auth_user.created_at is None:
|
|
59
|
+
basic_auth_user.created_at = datetime.now(DEFAULT_TIME_ZONE)
|
|
60
|
+
|
|
61
|
+
# encrypt the token data
|
|
62
|
+
basic_auth_user.password = encrypt(basic_auth_user.password).hex()
|
|
63
|
+
|
|
64
|
+
basic_auth_user_dict = basic_auth_user.model_dump(by_alias=True, exclude=["id"])
|
|
65
|
+
basic_auth_user_dict.update({"created_at": basic_auth_user.created_at})
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
result: InsertOneResult = self._collection.insert_one(basic_auth_user_dict)
|
|
69
|
+
except DuplicateKeyError as e:
|
|
70
|
+
raise ValueError(f"BasicAuthUser already exists: {e}")
|
|
71
|
+
|
|
72
|
+
return str(result.inserted_id)
|
|
73
|
+
|
|
74
|
+
def find(self) -> list[BasicAuthUser]:
|
|
75
|
+
"""Find all basic auth users."""
|
|
76
|
+
cursor = self._collection.find().sort("created_at", pymongo.DESCENDING)
|
|
77
|
+
basic_auth_users = []
|
|
78
|
+
for document in cursor:
|
|
79
|
+
if document is not None:
|
|
80
|
+
basic_auth_user = BasicAuthUser(**document)
|
|
81
|
+
# decrypt the token data
|
|
82
|
+
basic_auth_user.password = decrypt(
|
|
83
|
+
bytes.fromhex(basic_auth_user.password)
|
|
84
|
+
)
|
|
85
|
+
basic_auth_users.append(basic_auth_user)
|
|
86
|
+
|
|
87
|
+
return basic_auth_users
|
|
88
|
+
# return [BasicAuthUser(**document) for document in cursor if document is not None]
|
|
89
|
+
|
|
90
|
+
def update(self, basic_auth_user: BasicAuthUser) -> BasicAuthUser:
|
|
91
|
+
"""Update a basic auth user."""
|
|
92
|
+
try:
|
|
93
|
+
query = {"_id": ObjectId(basic_auth_user.id)}
|
|
94
|
+
except InvalidId as e:
|
|
95
|
+
raise InvalidObjectIDException(e)
|
|
96
|
+
|
|
97
|
+
if basic_auth_user.updated_at is None:
|
|
98
|
+
basic_auth_user.updated_at = datetime.now(DEFAULT_TIME_ZONE)
|
|
99
|
+
|
|
100
|
+
# encrypt the token data
|
|
101
|
+
basic_auth_user.password = encrypt(basic_auth_user.password).hex()
|
|
102
|
+
|
|
103
|
+
basic_auth_user_dict = basic_auth_user.model_dump(
|
|
104
|
+
by_alias=True, exclude=["id", "created_at"]
|
|
105
|
+
)
|
|
106
|
+
basic_auth_user_dict.update({"updated_at": basic_auth_user.updated_at})
|
|
107
|
+
update = {"$set": basic_auth_user_dict}
|
|
108
|
+
# update = {"$set": basic_auth_user.model_dump(by_alias=True, exclude=['id', 'created_at'])}
|
|
109
|
+
|
|
110
|
+
log.debug(f"Update basic_auth_user: {basic_auth_user.id}")
|
|
111
|
+
log.debug(f"Update data: {update}")
|
|
112
|
+
|
|
113
|
+
document = self._collection.find_one_and_update(
|
|
114
|
+
query, update=update, return_document=pymongo.ReturnDocument.AFTER
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if document is None:
|
|
118
|
+
raise ObjectNotFoundException(object_id=basic_auth_user.id)
|
|
119
|
+
|
|
120
|
+
updated = BasicAuthUser(**document)
|
|
121
|
+
|
|
122
|
+
# decrypt the token data
|
|
123
|
+
updated.password = decrypt(bytes.fromhex(updated.password))
|
|
124
|
+
return updated
|
|
125
|
+
|
|
126
|
+
def find_by_id(self, basic_auth_user_id: str) -> BasicAuthUser:
|
|
127
|
+
"""Find a basic auth user by id."""
|
|
128
|
+
if not basic_auth_user_id:
|
|
129
|
+
raise InvalidObjectIDException("BasicAuthUser id is required")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
query = {"_id": ObjectId(basic_auth_user_id)}
|
|
133
|
+
except InvalidId as e:
|
|
134
|
+
raise InvalidObjectIDException(e)
|
|
135
|
+
|
|
136
|
+
document = self._collection.find_one(query)
|
|
137
|
+
|
|
138
|
+
if document is None:
|
|
139
|
+
raise ObjectNotFoundException(object_id=basic_auth_user_id)
|
|
140
|
+
|
|
141
|
+
basic_auth_user = BasicAuthUser(**document)
|
|
142
|
+
|
|
143
|
+
# decrypt the token data
|
|
144
|
+
basic_auth_user.password = decrypt(bytes.fromhex(basic_auth_user.password))
|
|
145
|
+
return basic_auth_user
|
|
146
|
+
|
|
147
|
+
def find_by_username(self, basic_auth_username: str) -> BasicAuthUser:
|
|
148
|
+
"""Find a basic auth user by username."""
|
|
149
|
+
if not basic_auth_username:
|
|
150
|
+
raise InvalidObjectIDException("BasicAuthUser username is required")
|
|
151
|
+
|
|
152
|
+
document = self._collection.find_one({"username": basic_auth_username})
|
|
153
|
+
|
|
154
|
+
if document is None:
|
|
155
|
+
raise ObjectNotFoundException(object_id=basic_auth_username)
|
|
156
|
+
|
|
157
|
+
basic_auth_user = BasicAuthUser(**document)
|
|
158
|
+
|
|
159
|
+
# decrypt the token data
|
|
160
|
+
basic_auth_user.password = decrypt(bytes.fromhex(basic_auth_user.password))
|
|
161
|
+
return basic_auth_user
|
|
162
|
+
|
|
163
|
+
def delete_by_id(self, basic_auth_user_id: str) -> bool:
|
|
164
|
+
"""Delete a basic auth user by id."""
|
|
165
|
+
if not basic_auth_user_id:
|
|
166
|
+
raise InvalidObjectIDException("BasicAuthUser id is required")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
query = {"_id": ObjectId(basic_auth_user_id)}
|
|
170
|
+
except InvalidId as e:
|
|
171
|
+
raise InvalidObjectIDException(e)
|
|
172
|
+
|
|
173
|
+
result = self._collection.find_one_and_delete(query)
|
|
174
|
+
|
|
175
|
+
if result is None:
|
|
176
|
+
raise ObjectNotFoundException(
|
|
177
|
+
f"BasicAuthUser not found: {basic_auth_user_id}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return True
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Exceptions for AIOps Pilot."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from http import HTTPStatus
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from bson.errors import InvalidId
|
|
8
|
+
from fastapi import HTTPException
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Error(BaseModel):
|
|
13
|
+
"""Error model."""
|
|
14
|
+
|
|
15
|
+
http_status: Optional[int]
|
|
16
|
+
code: Optional[str]
|
|
17
|
+
message: Optional[str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(Enum):
|
|
21
|
+
"""Auth error model."""
|
|
22
|
+
|
|
23
|
+
ID_NOT_FOUND = Error(
|
|
24
|
+
code="E001",
|
|
25
|
+
http_status=HTTPStatus.NOT_FOUND,
|
|
26
|
+
message="The item {document}:'{object_id}' was not found",
|
|
27
|
+
)
|
|
28
|
+
"""The keyword arguments '{document}' and '{object_id}' should be present in the message string"""
|
|
29
|
+
|
|
30
|
+
INVALID_OBJECTID = Error(
|
|
31
|
+
code="E002",
|
|
32
|
+
http_status=HTTPStatus.BAD_REQUEST,
|
|
33
|
+
message="The input value was invalid. Details: {details}",
|
|
34
|
+
)
|
|
35
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
36
|
+
|
|
37
|
+
BAD_REQUEST = Error(
|
|
38
|
+
code="E003",
|
|
39
|
+
http_status=HTTPStatus.BAD_REQUEST,
|
|
40
|
+
message="Bad request. Details: {details}",
|
|
41
|
+
)
|
|
42
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
43
|
+
|
|
44
|
+
INVALID_TOKEN = Error(
|
|
45
|
+
code="E004",
|
|
46
|
+
http_status=HTTPStatus.UNAUTHORIZED,
|
|
47
|
+
message="Invalid token. Details: {details}",
|
|
48
|
+
)
|
|
49
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
50
|
+
|
|
51
|
+
PERMISSION_DENIED = Error(
|
|
52
|
+
code="E005",
|
|
53
|
+
http_status=HTTPStatus.FORBIDDEN,
|
|
54
|
+
message="Permission denied. Details: {details}",
|
|
55
|
+
)
|
|
56
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
57
|
+
|
|
58
|
+
TOKEN_DATA_TOO_LARGE = Error(
|
|
59
|
+
code="E006",
|
|
60
|
+
http_status=HTTPStatus.BAD_REQUEST,
|
|
61
|
+
message="The token data for the client cookie is too large. Details: {details}",
|
|
62
|
+
)
|
|
63
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
64
|
+
|
|
65
|
+
SESSION_EXPIRED = Error(
|
|
66
|
+
code="E007",
|
|
67
|
+
http_status=HTTPStatus.UNAUTHORIZED,
|
|
68
|
+
message="The session is expired. Details: {details}",
|
|
69
|
+
)
|
|
70
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
71
|
+
|
|
72
|
+
OAUTH_IDP_ERROR = Error(
|
|
73
|
+
code="E008",
|
|
74
|
+
http_status=HTTPStatus.UNAUTHORIZED,
|
|
75
|
+
message="OAuth IDP error. Details: {details}",
|
|
76
|
+
)
|
|
77
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
78
|
+
|
|
79
|
+
INTERNAL_SERVER_ERROR = Error(
|
|
80
|
+
code="E500",
|
|
81
|
+
http_status=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
82
|
+
message="Internal server error. Details: {details}",
|
|
83
|
+
)
|
|
84
|
+
"""The keyword argument '{details}' should be present in the message string"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AuthBackendException(HTTPException):
|
|
88
|
+
"""Auth backend exception."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, error: AuthError, **kwargs):
|
|
91
|
+
"""Initialize the AIOps backend exception."""
|
|
92
|
+
self.status_code = error.value.http_status
|
|
93
|
+
self.code = error.value.code
|
|
94
|
+
self.detail = error.value.message.format(**kwargs)
|
|
95
|
+
super().__init__(status_code=self.status_code, detail=self.detail)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OauthTokenValidationException(HTTPException):
|
|
99
|
+
"""Oauth token validation exception."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, error: AuthError, **kwargs):
|
|
102
|
+
"""Initialize the Oauth token validation exception."""
|
|
103
|
+
self.status_code = error.value.http_status
|
|
104
|
+
self.code = error.value.code
|
|
105
|
+
self.detail = error.value.message.format(**kwargs)
|
|
106
|
+
super().__init__(status_code=self.status_code, detail=self.detail)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ObjectNotFoundException(Exception):
|
|
110
|
+
"""Object not found exception."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, object_id: str):
|
|
113
|
+
"""Initialize the object not found exception."""
|
|
114
|
+
self.object_id = object_id
|
|
115
|
+
self.message = f"The ID '{object_id}' was not found"
|
|
116
|
+
super().__init__(self.message)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class InvalidObjectIDException(InvalidId):
|
|
120
|
+
"""Invalid ObjectId exception."""
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Auth routes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
8
|
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
9
|
+
|
|
10
|
+
from zmp_authentication_provider.auth.basic_auth import get_current_user_for_basicauth
|
|
11
|
+
from zmp_authentication_provider.auth.oauth2_keycloak import (
|
|
12
|
+
KEYCLOAK_AUTH_ENDPOINT,
|
|
13
|
+
KEYCLOAK_CLIENT_ID,
|
|
14
|
+
KEYCLOAK_CLIENT_SECRET,
|
|
15
|
+
KEYCLOAK_END_SESSION_ENDPOINT,
|
|
16
|
+
KEYCLOAK_REDIRECT_URI,
|
|
17
|
+
KEYCLOAK_TOKEN_ENDPOINT,
|
|
18
|
+
KEYCLOAK_USER_ENDPOINT,
|
|
19
|
+
TokenData,
|
|
20
|
+
get_current_user,
|
|
21
|
+
oauth2_auth_scheme,
|
|
22
|
+
)
|
|
23
|
+
from zmp_authentication_provider.exceptions import (
|
|
24
|
+
AuthBackendException,
|
|
25
|
+
AuthError,
|
|
26
|
+
)
|
|
27
|
+
from zmp_authentication_provider.scheme.auth_model import BasicAuthUser
|
|
28
|
+
from zmp_authentication_provider.setting import auth_default_settings
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
router = APIRouter(
|
|
33
|
+
prefix="/auth",
|
|
34
|
+
tags=["auth"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get(
|
|
39
|
+
"/home",
|
|
40
|
+
summary="Home page",
|
|
41
|
+
response_class=HTMLResponse
|
|
42
|
+
)
|
|
43
|
+
def home(request: Request): # , csrf_token: str = Depends(csrf_scheme)):
|
|
44
|
+
"""Get home page."""
|
|
45
|
+
# check the session_id in the cookie
|
|
46
|
+
csrf_token = request.cookies.get("csrftoken")
|
|
47
|
+
session_id = request.cookies.get("session_id")
|
|
48
|
+
|
|
49
|
+
if csrf_token and session_id:
|
|
50
|
+
user_info = request.session.get("user_info")
|
|
51
|
+
if not user_info:
|
|
52
|
+
request.session.clear()
|
|
53
|
+
raise AuthBackendException(
|
|
54
|
+
AuthError.SESSION_EXPIRED,
|
|
55
|
+
code=401,
|
|
56
|
+
details="Session data has been lost "
|
|
57
|
+
"because the server has been restared."
|
|
58
|
+
"Please login again",
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
username = user_info.get("preferred_username") if user_info else "Ooops!!"
|
|
62
|
+
log.debug(f"session_data: {username}")
|
|
63
|
+
return HTMLResponse(content=f"<p>Hello, {username}!!</p>")
|
|
64
|
+
else:
|
|
65
|
+
return RedirectResponse(url=f"{auth_default_settings.application_endpoint}/login")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get(
|
|
69
|
+
"/login",
|
|
70
|
+
summary="API to login into the keyclaok using the browser",
|
|
71
|
+
response_class=RedirectResponse
|
|
72
|
+
)
|
|
73
|
+
def login():
|
|
74
|
+
"""Login."""
|
|
75
|
+
return RedirectResponse(
|
|
76
|
+
url=f"{KEYCLOAK_AUTH_ENDPOINT}?response_type=code"
|
|
77
|
+
f"&client_id={KEYCLOAK_CLIENT_ID}"
|
|
78
|
+
f"&redirect_uri={KEYCLOAK_REDIRECT_URI}"
|
|
79
|
+
f"&scope=openid profile email"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get(
|
|
84
|
+
"/logout",
|
|
85
|
+
summary="API to logout from the keyclaok",
|
|
86
|
+
response_class=RedirectResponse
|
|
87
|
+
)
|
|
88
|
+
def logout(
|
|
89
|
+
request: Request,
|
|
90
|
+
# csrf_token: str = Depends(csrf_scheme),
|
|
91
|
+
# session_id: str = Depends(session_id_scheme)
|
|
92
|
+
):
|
|
93
|
+
"""Logout."""
|
|
94
|
+
csrf_token = request.cookies.get("csrftoken")
|
|
95
|
+
session_id = request.cookies.get("session_id")
|
|
96
|
+
if not csrf_token or not session_id:
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
99
|
+
detail="No csrf token in cookie and session id in cookie",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
else:
|
|
103
|
+
refresh_token = request.session.get("refresh_token")
|
|
104
|
+
|
|
105
|
+
# if not session_id and not refresh_token:
|
|
106
|
+
if refresh_token:
|
|
107
|
+
data = {
|
|
108
|
+
"client_id": KEYCLOAK_CLIENT_ID,
|
|
109
|
+
"client_secret": KEYCLOAK_CLIENT_SECRET,
|
|
110
|
+
"refresh_token": refresh_token,
|
|
111
|
+
}
|
|
112
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
113
|
+
idp_response = requests.post(
|
|
114
|
+
KEYCLOAK_END_SESSION_ENDPOINT,
|
|
115
|
+
data=data,
|
|
116
|
+
headers=headers,
|
|
117
|
+
verify=auth_default_settings.http_client_ssl_verify,
|
|
118
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
119
|
+
|
|
120
|
+
if idp_response.status_code != 204:
|
|
121
|
+
raise AuthBackendException(
|
|
122
|
+
AuthError.OAUTH_IDP_ERROR,
|
|
123
|
+
details=f"Failed to logout.({idp_response.reason})",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
request.session.clear()
|
|
127
|
+
|
|
128
|
+
redirect_response = RedirectResponse(url=f"{auth_default_settings.application_endpoint}/")
|
|
129
|
+
redirect_response.delete_cookie(key="csrftoken")
|
|
130
|
+
|
|
131
|
+
return redirect_response
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@router.post(
|
|
135
|
+
"/oauth2/logout",
|
|
136
|
+
summary="API to logout from the keyclaok only OAuth2"
|
|
137
|
+
)
|
|
138
|
+
def logout_only_oauth(
|
|
139
|
+
refresh_token: str | None, access_token: str = Depends(oauth2_auth_scheme)
|
|
140
|
+
):
|
|
141
|
+
"""Logout only OAuth2."""
|
|
142
|
+
data = {
|
|
143
|
+
"client_id": KEYCLOAK_CLIENT_ID,
|
|
144
|
+
"client_secret": KEYCLOAK_CLIENT_SECRET,
|
|
145
|
+
"refresh_token": refresh_token,
|
|
146
|
+
}
|
|
147
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "*/*"}
|
|
148
|
+
idp_response = requests.post(
|
|
149
|
+
KEYCLOAK_END_SESSION_ENDPOINT,
|
|
150
|
+
data=data,
|
|
151
|
+
headers=headers,
|
|
152
|
+
verify=auth_default_settings.http_client_ssl_verify,
|
|
153
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
154
|
+
|
|
155
|
+
if idp_response.status_code != 204:
|
|
156
|
+
raise AuthBackendException(
|
|
157
|
+
AuthError.OAUTH_IDP_ERROR,
|
|
158
|
+
details=f"Failed to logout.({idp_response.reason})",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return {"result": "success"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@router.get(
|
|
165
|
+
"/oauth2/callback",
|
|
166
|
+
summary="Keycloak OAuth2 callback for the redirect URI"
|
|
167
|
+
)
|
|
168
|
+
def callback(request: Request, code: str):
|
|
169
|
+
"""Keycloak OAuth2 callback for the redirect URI."""
|
|
170
|
+
data = {
|
|
171
|
+
"grant_type": "authorization_code",
|
|
172
|
+
"code": code,
|
|
173
|
+
"redirect_uri": KEYCLOAK_REDIRECT_URI,
|
|
174
|
+
"client_id": KEYCLOAK_CLIENT_ID,
|
|
175
|
+
"client_secret": KEYCLOAK_CLIENT_SECRET,
|
|
176
|
+
}
|
|
177
|
+
headers = {
|
|
178
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
179
|
+
"Accept": "application/json",
|
|
180
|
+
}
|
|
181
|
+
idp_response = requests.post(
|
|
182
|
+
KEYCLOAK_TOKEN_ENDPOINT,
|
|
183
|
+
data=data,
|
|
184
|
+
headers=headers,
|
|
185
|
+
verify=auth_default_settings.http_client_ssl_verify,
|
|
186
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
187
|
+
|
|
188
|
+
if idp_response.status_code != 200:
|
|
189
|
+
raise AuthBackendException(
|
|
190
|
+
AuthError.OAUTH_IDP_ERROR,
|
|
191
|
+
details=f"Failed to obtain token.({idp_response.reason})",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
tokens = idp_response.json()
|
|
195
|
+
|
|
196
|
+
access_token = tokens.get("access_token")
|
|
197
|
+
refresh_token = tokens.get("refresh_token")
|
|
198
|
+
# id_token = tokens.get("id_token")
|
|
199
|
+
|
|
200
|
+
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
|
|
201
|
+
idp_response = requests.get(
|
|
202
|
+
KEYCLOAK_USER_ENDPOINT,
|
|
203
|
+
headers=headers,
|
|
204
|
+
verify=auth_default_settings.http_client_ssl_verify,
|
|
205
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
206
|
+
if idp_response.status_code != 200:
|
|
207
|
+
raise AuthBackendException(
|
|
208
|
+
AuthError.OAUTH_IDP_ERROR,
|
|
209
|
+
details=f"Failed to fetch user info.({idp_response.reason})",
|
|
210
|
+
)
|
|
211
|
+
user_info = idp_response.json()
|
|
212
|
+
|
|
213
|
+
log.debug(f"user_info: {user_info}")
|
|
214
|
+
|
|
215
|
+
# because the max size of the cookie is 4kb, the session middleware saves the session data in the client side cookie in default
|
|
216
|
+
# so, if the session data size is over than 4kb, the session data will be lost or occur the error in client side
|
|
217
|
+
# request.session['access_token'] = access_token
|
|
218
|
+
# request.session['id_token'] = id_token
|
|
219
|
+
request.session["refresh_token"] = refresh_token
|
|
220
|
+
request.session["user_info"] = user_info
|
|
221
|
+
|
|
222
|
+
total_bytes = _get_size(request.session)
|
|
223
|
+
|
|
224
|
+
if total_bytes > 4096:
|
|
225
|
+
log.debug(f"Total bytes: {total_bytes}")
|
|
226
|
+
log.warning(f"The session data size({total_bytes}) is over than 4kb.")
|
|
227
|
+
raise AuthBackendException(
|
|
228
|
+
AuthError.TOKEN_DATA_TOO_LARGE,
|
|
229
|
+
details=f"The session data size is {total_bytes} bytes. It is over than 4kb.",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# If the same-site of cookie is 'lax', the cookie will be sent only if the request is same-site request
|
|
233
|
+
# If the same-site of cookie is 'strict', the cookie will not be sent
|
|
234
|
+
# return RedirectResponse(url=f"{ALERT_SERVICE_ENDPOINT}/home")
|
|
235
|
+
|
|
236
|
+
return tokens
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_size(obj, seen=None):
|
|
240
|
+
"""Recursively find the size of objects including nested objects."""
|
|
241
|
+
size = sys.getsizeof(obj)
|
|
242
|
+
if seen is None:
|
|
243
|
+
seen = set()
|
|
244
|
+
obj_id = id(obj)
|
|
245
|
+
if obj_id in seen:
|
|
246
|
+
return 0
|
|
247
|
+
# Mark as seen
|
|
248
|
+
seen.add(obj_id)
|
|
249
|
+
# Recursively add sizes of referred objects
|
|
250
|
+
if isinstance(obj, dict):
|
|
251
|
+
size += sum([_get_size(v, seen) for v in obj.values()])
|
|
252
|
+
size += sum([_get_size(k, seen) for k in obj.keys()])
|
|
253
|
+
elif hasattr(obj, "__dict__"):
|
|
254
|
+
size += _get_size(obj.__dict__, seen)
|
|
255
|
+
elif isinstance(obj, list | tuple | set):
|
|
256
|
+
size += sum([_get_size(i, seen) for i in obj])
|
|
257
|
+
return size
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.get(
|
|
261
|
+
"/users/me",
|
|
262
|
+
summary="Get the current user info from IDP(Keycloak)"
|
|
263
|
+
)
|
|
264
|
+
def read_users_me(token: str = Depends(oauth2_auth_scheme)):
|
|
265
|
+
"""Get the current user info from IDP(Keycloak)."""
|
|
266
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
267
|
+
idp_response = requests.get(
|
|
268
|
+
KEYCLOAK_USER_ENDPOINT,
|
|
269
|
+
headers=headers,
|
|
270
|
+
verify=auth_default_settings.http_client_ssl_verify,
|
|
271
|
+
) # verify=False: because of the SKCC self-signed certificate
|
|
272
|
+
if idp_response.status_code != 200:
|
|
273
|
+
raise AuthBackendException(
|
|
274
|
+
AuthError.OAUTH_IDP_ERROR,
|
|
275
|
+
details=f"Failed to fetch user info: {idp_response.reason}",
|
|
276
|
+
)
|
|
277
|
+
return idp_response.json()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@router.get(
|
|
281
|
+
"/users/oauth_user",
|
|
282
|
+
summary="Get the current user info from Token"
|
|
283
|
+
)
|
|
284
|
+
def read_oauth_user(oauth_user: TokenData = Depends(get_current_user)):
|
|
285
|
+
"""Get the current user info from Token."""
|
|
286
|
+
return oauth_user
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Auth model module for the AIops Pilot."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, List, Optional
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_serializer
|
|
7
|
+
|
|
8
|
+
PyObjectId = Annotated[str, BeforeValidator(str)]
|
|
9
|
+
|
|
10
|
+
class AuthBaseModel(BaseModel):
|
|
11
|
+
"""Auth Base Model.
|
|
12
|
+
|
|
13
|
+
mongodb objectId _id issues
|
|
14
|
+
|
|
15
|
+
refence:
|
|
16
|
+
https://github.com/tiangolo/fastapi/issues/1515
|
|
17
|
+
https://github.com/mongodb-developer/mongodb-with-fastapi
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
id: PyObjectId | None = Field(default=None, alias="_id")
|
|
21
|
+
|
|
22
|
+
created_at: datetime | None = None
|
|
23
|
+
updated_at: datetime | None = None
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(
|
|
26
|
+
populate_by_name=True,
|
|
27
|
+
arbitrary_types_allowed=True,
|
|
28
|
+
extra="forbid",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@field_serializer("id")
|
|
32
|
+
def _serialize_id(self, id: PyObjectId | None) -> str | None:
|
|
33
|
+
if id is None:
|
|
34
|
+
return None
|
|
35
|
+
else:
|
|
36
|
+
return str(id)
|
|
37
|
+
|
|
38
|
+
@field_serializer("created_at", "updated_at")
|
|
39
|
+
def _serialize_created_updated_at(self, dt: datetime | None) -> str | None:
|
|
40
|
+
return dt.isoformat(timespec="milliseconds") if dt else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BasicAuthUser(AuthBaseModel):
|
|
44
|
+
"""Basic auth user model."""
|
|
45
|
+
|
|
46
|
+
username: str
|
|
47
|
+
password: str
|
|
48
|
+
modifier: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TokenData(BaseModel):
|
|
52
|
+
"""Token data model."""
|
|
53
|
+
|
|
54
|
+
sub: Optional[str] = None
|
|
55
|
+
username: Optional[str] = None
|
|
56
|
+
email: Optional[str] = None
|
|
57
|
+
given_name: Optional[str] = None
|
|
58
|
+
family_name: Optional[str] = None
|
|
59
|
+
# for backward compatibility of keycloak
|
|
60
|
+
realm_roles: Optional[List[str]] = None
|
|
61
|
+
"""
|
|
62
|
+
{realm_access: [role1, role2, ...]}
|
|
63
|
+
"""
|
|
64
|
+
realm_access: Optional[dict] = None
|
|
65
|
+
"""
|
|
66
|
+
{realm_access: {roles: [role1, role2, ...]}}
|
|
67
|
+
"""
|
|
68
|
+
resource_access: Optional[dict] = None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""AuthService class to handle auth service."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
7
|
+
|
|
8
|
+
from zmp_authentication_provider.db.basic_auth_user_repository import (
|
|
9
|
+
BasicAuthUserRepository,
|
|
10
|
+
)
|
|
11
|
+
from zmp_authentication_provider.exceptions import (
|
|
12
|
+
AuthBackendException,
|
|
13
|
+
AuthError,
|
|
14
|
+
InvalidObjectIDException,
|
|
15
|
+
ObjectNotFoundException,
|
|
16
|
+
)
|
|
17
|
+
from zmp_authentication_provider.scheme.auth_model import BasicAuthUser
|
|
18
|
+
from zmp_authentication_provider.setting import auth_default_settings
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthService:
|
|
24
|
+
"""AuthService class to handle auth service."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, database: AsyncIOMotorDatabase):
|
|
27
|
+
"""Initialize the repository with MongoDB database."""
|
|
28
|
+
self._database = database
|
|
29
|
+
self._basic_auth_user_repository = BasicAuthUserRepository(
|
|
30
|
+
collection=self._database[auth_default_settings.basic_auth_user_collection]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
log.info(f"{__name__} AuthService Initialized")
|
|
34
|
+
|
|
35
|
+
def create_basic_auth_user(self, user: BasicAuthUser) -> str:
|
|
36
|
+
"""Create a basic auth user."""
|
|
37
|
+
try:
|
|
38
|
+
return self._basic_auth_user_repository.insert(user)
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
raise AuthBackendException(AuthError.BAD_REQUEST, details=str(e))
|
|
41
|
+
|
|
42
|
+
def modify_basic_auth_user(self, user: BasicAuthUser) -> BasicAuthUser:
|
|
43
|
+
"""Update a basic auth user."""
|
|
44
|
+
try:
|
|
45
|
+
return self._basic_auth_user_repository.update(user)
|
|
46
|
+
except ObjectNotFoundException:
|
|
47
|
+
raise AuthBackendException(
|
|
48
|
+
AuthError.ID_NOT_FOUND,
|
|
49
|
+
document=auth_default_settings.basic_auth_user_collection,
|
|
50
|
+
object_id=user.id,
|
|
51
|
+
)
|
|
52
|
+
except InvalidObjectIDException as e:
|
|
53
|
+
raise AuthBackendException(AuthError.INVALID_OBJECTID, details=str(e))
|
|
54
|
+
|
|
55
|
+
def remove_basic_auth_user(self, id: str) -> bool:
|
|
56
|
+
"""Delete a basic auth user by id."""
|
|
57
|
+
if not id:
|
|
58
|
+
raise AuthBackendException(AuthError.BAD_REQUEST, details="ID is required")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
return self._basic_auth_user_repository.delete_by_id(id)
|
|
62
|
+
except ObjectNotFoundException:
|
|
63
|
+
raise AuthBackendException(
|
|
64
|
+
AuthError.ID_NOT_FOUND,
|
|
65
|
+
document=auth_default_settings.basic_auth_user_collection,
|
|
66
|
+
object_id=id,
|
|
67
|
+
)
|
|
68
|
+
except InvalidObjectIDException as e:
|
|
69
|
+
raise AuthBackendException(AuthError.INVALID_OBJECTID, details=str(e))
|
|
70
|
+
|
|
71
|
+
def get_basic_auth_user_by_username(self, username: str) -> BasicAuthUser:
|
|
72
|
+
"""Get a basic auth user by username."""
|
|
73
|
+
if not username:
|
|
74
|
+
raise AuthBackendException(
|
|
75
|
+
AuthError.BAD_REQUEST, details="Username is required"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return self._basic_auth_user_repository.find_by_username(username)
|
|
80
|
+
except ObjectNotFoundException:
|
|
81
|
+
raise AuthBackendException(
|
|
82
|
+
AuthError.ID_NOT_FOUND,
|
|
83
|
+
document=auth_default_settings.basic_auth_user_collection,
|
|
84
|
+
object_id=username,
|
|
85
|
+
)
|
|
86
|
+
except InvalidObjectIDException as e:
|
|
87
|
+
raise AuthBackendException(AuthError.INVALID_OBJECTID, details=str(e))
|
|
88
|
+
|
|
89
|
+
def get_basic_auth_users(self) -> List[BasicAuthUser]:
|
|
90
|
+
"""Get basic auth users."""
|
|
91
|
+
return self._basic_auth_user_repository.find()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Base settings for MCP and Gateway."""
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KeycloakSettings(BaseSettings):
|
|
7
|
+
"""Settings for Keycloak."""
|
|
8
|
+
|
|
9
|
+
model_config: SettingsConfigDict = SettingsConfigDict(
|
|
10
|
+
env_prefix="KEYCLOAK_", env_file=".env", extra="allow"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
server_url: str
|
|
14
|
+
realm: str
|
|
15
|
+
client_id: str
|
|
16
|
+
client_secret: str
|
|
17
|
+
redirect_uri: str
|
|
18
|
+
algorithm: str = "RS256"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
keycloak_settings = KeycloakSettings()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthDefaultSettings(BaseSettings):
|
|
25
|
+
"""Settings for Basic Auth."""
|
|
26
|
+
|
|
27
|
+
model_config: SettingsConfigDict = SettingsConfigDict(
|
|
28
|
+
env_prefix="AUTH_", env_file=".env", extra="allow"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# The name of the auth service
|
|
32
|
+
service_name: str = "auth_service"
|
|
33
|
+
|
|
34
|
+
# The api endpoint of the application which is used to redirect to the login page
|
|
35
|
+
application_endpoint: str
|
|
36
|
+
|
|
37
|
+
# The secret key for the encryption
|
|
38
|
+
basic_auth_encryption_key: str = (
|
|
39
|
+
"425df05bf30c8434e8a619563b602a7aa0421011f71727289eee66d310897118"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# The collection name for the basic auth user
|
|
43
|
+
basic_auth_user_collection: str = "basic_auth_user"
|
|
44
|
+
|
|
45
|
+
# The http client ssl verify
|
|
46
|
+
http_client_ssl_verify: bool = True
|
|
47
|
+
|
|
48
|
+
# The http client timeout
|
|
49
|
+
http_client_timeout: int = 30
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
auth_default_settings = AuthDefaultSettings()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""This module contains helper functions for encrypting and decrypting data."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from binascii import unhexlify
|
|
5
|
+
|
|
6
|
+
from cryptography.hazmat.backends import default_backend
|
|
7
|
+
from cryptography.hazmat.primitives import padding
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
9
|
+
|
|
10
|
+
from zmp_authentication_provider.setting import auth_default_settings
|
|
11
|
+
|
|
12
|
+
str_key = auth_default_settings.aes_secret_key
|
|
13
|
+
key = unhexlify(str_key) # 256-bit key
|
|
14
|
+
iv = os.urandom(16) # 128-bit IV
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def encrypt(data: str) -> bytes:
|
|
18
|
+
"""Encrypt the data."""
|
|
19
|
+
data_bytes = data.encode("utf-8")
|
|
20
|
+
|
|
21
|
+
padder = padding.PKCS7(128).padder()
|
|
22
|
+
padded_data = padder.update(data_bytes) + padder.finalize()
|
|
23
|
+
|
|
24
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
25
|
+
encryptor = cipher.encryptor()
|
|
26
|
+
encrypted_data = encryptor.update(padded_data) + encryptor.finalize()
|
|
27
|
+
|
|
28
|
+
return iv + encrypted_data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decrypt(encrypted_data: bytes) -> str:
|
|
32
|
+
"""Decrypt the data."""
|
|
33
|
+
iv = encrypted_data[:16]
|
|
34
|
+
encrypted_data = encrypted_data[16:]
|
|
35
|
+
|
|
36
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
|
|
37
|
+
decryptor = cipher.decryptor()
|
|
38
|
+
padded_data = decryptor.update(encrypted_data) + decryptor.finalize()
|
|
39
|
+
|
|
40
|
+
unpadder = padding.PKCS7(128).unpadder()
|
|
41
|
+
data_bytes = unpadder.update(padded_data) + unpadder.finalize()
|
|
42
|
+
|
|
43
|
+
return data_bytes.decode("utf-8")
|