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.
@@ -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
+ ]
@@ -0,0 +1,6 @@
1
+ """Auth module for the AIops Pilot."""
2
+
3
+ from .basic_auth import get_current_user_for_basicauth
4
+ from .oauth2_keycloak import get_current_user
5
+
6
+ __all__ = ["get_current_user", "get_current_user_for_basicauth"]
@@ -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)
@@ -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")