From ad1e1343edf46d3dbf111af1aafe79b9f57bbb8c Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 29 Oct 2024 18:41:34 -0700 Subject: [PATCH 01/28] Update `client_manager` to use `mq_connector` for authentication via `neon-users-service` Update tokens to include more data, maintaining backwards-compat and adding `TokenConfig` compat. Update tokens for Klat token compat Update permissions handling to respect user configuration values Update auth request to include token_name for User database integration Add UserProfile.from_user_config for database compat. Update MQ connector to integrate with users service --- neon_hana/app/dependencies.py | 2 +- neon_hana/auth/client_manager.py | 82 ++++++++++++++++++++++--------- neon_hana/mq_service_api.py | 63 +++++++++++++++++++++++- neon_hana/mq_websocket_api.py | 2 +- neon_hana/schema/auth_requests.py | 5 +- neon_hana/schema/user_profile.py | 62 +++++++++++++++++++++++ requirements/requirements.txt | 4 +- tests/test_schema.py | 18 +++++++ 8 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 tests/test_schema.py diff --git a/neon_hana/app/dependencies.py b/neon_hana/app/dependencies.py index 0c9dcf5..e6e4726 100644 --- a/neon_hana/app/dependencies.py +++ b/neon_hana/app/dependencies.py @@ -31,5 +31,5 @@ config = Configuration().get("hana") or dict() mq_connector = MQServiceManager(config) -client_manager = ClientManager(config) +client_manager = ClientManager(config, mq_connector) jwt_bearer = UserTokenAuth(client_manager) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 7ad2e26..de42372 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -37,10 +37,12 @@ from token_throttler.storage import RuntimeStorage from neon_hana.auth.permissions import ClientPermissions +from neon_hana.mq_service_api import MQServiceManager +from neon_users_service.models import User, AccessRoles, TokenConfig class ClientManager: - def __init__(self, config: dict): + def __init__(self, config: dict, mq_connector: MQServiceManager): self.rate_limiter = TokenThrottler(cost=1, storage=RuntimeStorage()) self.authorized_clients: Dict[str, dict] = dict() @@ -58,8 +60,9 @@ def __init__(self, config: dict): self._jwt_algo = "HS256" self._connected_streams = 0 self._stream_check_lock = Lock() + self._mq_connector = mq_connector - def _create_tokens(self, encode_data: dict) -> dict: + def _create_tokens(self, encode_data: dict) -> TokenConfig: # Permissions were not included in old tokens, allow refreshing with # default permissions encode_data.setdefault("permissions", ClientPermissions().as_dict()) @@ -69,13 +72,14 @@ def _create_tokens(self, encode_data: dict) -> dict: encode_data['expire'] = time() + self._refresh_token_lifetime encode_data['access_token'] = token refresh = jwt.encode(encode_data, self._refresh_secret, self._jwt_algo) - # TODO: Store refresh token on server to allow invalidating clients - return {"username": encode_data['username'], - "client_id": encode_data['client_id'], - "permissions": encode_data['permissions'], - "access_token": token, - "refresh_token": refresh, - "expiration": token_expiration} + return TokenConfig(**{"username": encode_data['username'], + "client_id": encode_data['client_id'], + "permissions": encode_data['permissions'], + "access_token": token, + "refresh_token": refresh, + "expiration": token_expiration, + "token_name": encode_data['name'], + "refresh_expiration": encode_data['expire']}) def get_permissions(self, client_id: str) -> ClientPermissions: """ @@ -114,6 +118,7 @@ def disconnect_stream(self): def check_auth_request(self, client_id: str, username: str, password: Optional[str] = None, + token_name: Optional[str] = None, origin_ip: str = "127.0.0.1") -> dict: """ Authenticate and Authorize a new client connection with the specified @@ -121,6 +126,7 @@ def check_auth_request(self, client_id: str, username: str, @param client_id: Client ID of the connection to auth @param username: Supplied username to authenticate @param password: Supplied password to authenticate + @param token_name: Token name to add to user database @param origin_ip: Origin IP address of request @return: response tokens, permissions, and other metadata """ @@ -142,23 +148,40 @@ def check_auth_request(self, client_id: str, username: str, detail=f"Too many auth requests from: " f"{origin_ip}. Wait {wait_time}s.") - node_access = False - if username != "guest": - # TODO: Validate password here - pass - if all((self._node_username, username == self._node_username, - password == self._node_password)): - node_access = True - permissions = ClientPermissions(node=node_access) - expiration = time() + self._access_token_lifetime + # TODO: disable "guest" access? + if username == "guest": + user = User(username=username, password=password) + elif all((self._node_username, username == self._node_username, + password == self._node_password)): + user = User(username=username, password=password) + user.permissions.node = AccessRoles.USER + else: + user = self._mq_connector.get_user_profile(username, password) + username = user.username + password = user.password_hash + + # Boolean permissions allow access for any role, including `NODE`. + # Specific endpoints may enforce more granular controls/limits based on + # specific user.permissions values. + permissions = ClientPermissions( + node=user.permissions.node != AccessRoles.NONE, + assist=user.permissions.core != AccessRoles.NONE, + backend=user.permissions.diana != AccessRoles.NONE) + create_time = time() + expiration = create_time + self._access_token_lifetime encode_data = {"client_id": client_id, + "sub": username, # Added for Klat token compat. + "name": token_name, "username": username, "password": password, "permissions": permissions.as_dict(), - "expire": expiration} + "create": create_time, + "expire": expiration, + "last_refresh_timestamp": create_time} auth = self._create_tokens(encode_data) - self.authorized_clients[client_id] = auth - return auth + self._add_token_to_userdb(user, auth) + self.authorized_clients[client_id] = auth.model_dump() + return auth.model_dump() def check_refresh_request(self, access_token: str, refresh_token: str, client_id: str): @@ -185,9 +208,22 @@ def check_refresh_request(self, access_token: str, refresh_token: str, detail="Access token does not match client_id") encode_data = {k: token_data[k] for k in ("client_id", "username", "password")} - encode_data["expire"] = time() + self._access_token_lifetime + refresh_time = time() + encode_data['last_refresh_timestamp'] = refresh_time + encode_data["expire"] = refresh_time + self._access_token_lifetime new_auth = self._create_tokens(encode_data) - return new_auth + user = self._mq_connector.get_user_profile(username=token_data['username'], + password=token_data['password']) + self._add_token_to_userdb(user, new_auth) + return new_auth.model_dump() + + def _add_token_to_userdb(self, user: User, token_data: TokenConfig): + # Enforce unique `creation_timestamp` values to avoid duplicate entries + for idx, token in enumerate(user.tokens): + if token.creation_timestamp == token_data.creation_timestamp: + user.tokens.remove(token) + user.tokens.append(token_data) + self._mq_connector.update_user(user) def get_client_id(self, token: str) -> str: """ diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index a36e5e3..2f05419 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -27,13 +27,14 @@ import json from time import time -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Union from uuid import uuid4 from fastapi import HTTPException from neon_hana.schema.node_model import NodeData from neon_hana.schema.user_profile import UserProfile from neon_mq_connector.utils.client_utils import send_mq_request +from neon_users_service.models import User class APIError(HTTPException): @@ -77,6 +78,29 @@ def _validate_api_proxy_response(response: dict, query_params: dict): code = response['status_code'] if response['status_code'] > 200 else 500 raise APIError(status_code=code, detail=response['content']) + @staticmethod + def _query_users_api(operation: str, username: Optional[str] = None, + password: Optional[str] = None, + user: Optional[User] = None) -> (bool, Union[User, int, str]): + """ + Query the users API and return a status code and either a valid User or + a string error message + @param operation: Operation to perform (create, read, update, delete) + @param username: Optional username to include + @param password: Optional password to include + @param user: Optional user object to include + @return: success bool, User object or string error message + """ + response = send_mq_request("/neon_users", + {"operation": operation, + "username": username, + "password": password, + "user": user}, + "neon_users_input") + if response.get("success"): + return True, 200, response.get("user") + return False, response.get("code", 500), response.get("error", "") + def get_session(self, node_data: NodeData) -> dict: """ Get a serialized Session object for the specified Node. @@ -89,6 +113,43 @@ def get_session(self, node_data: NodeData) -> dict: "site_id": node_data.location.site_id}) return self.sessions_by_id[session_id] + def get_user_profile(self, username: str, password: str) -> User: + """ + Get a User object for a user. This requires that a valid password be + provided to prevent arbitrary users from reading private profile info. + @param username: Valid username to get a User object for + @param password: Valid password for the input username + @returns: User object from the Users service. + """ + stat, code, err_or_user = self._query_users_api("read", + username=username, + password=password) + if not stat: + raise HTTPException(status_code=code, detail=err_or_user) + return err_or_user + + def create_user(self, user: User) -> User: + """ + Create a new user. + @param user: User object to add to the users service database + @returns: User object added to the database + """ + stat, code, err_or_user = self._query_users_api("create", user=user) + if not stat: + raise HTTPException(status_code=code, detail=err_or_user) + return err_or_user + + def update_user(self, user: User) -> User: + """ + Update an existing user in the database. + @param user: Updated user object to write + @returns: User as read from the database + """ + stat, code, err_or_user = self._query_users_api("update", user=user) + if not stat: + raise HTTPException(status_code=code, detail=err_or_user) + return err_or_user + def query_api_proxy(self, service_name: str, query_params: dict, timeout: int = 10): query_params['service'] = service_name diff --git a/neon_hana/mq_websocket_api.py b/neon_hana/mq_websocket_api.py index 6b9b96e..d9d0bae 100644 --- a/neon_hana/mq_websocket_api.py +++ b/neon_hana/mq_websocket_api.py @@ -33,7 +33,7 @@ from neon_iris.client import NeonAIClient from ovos_bus_client.message import Message from threading import RLock -from ovos_utils import LOG +from ovos_utils.log import LOG class ClientNotKnown(RuntimeError): diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index d02724d..c889639 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -24,6 +24,7 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from datetime import datetime from typing import Optional from uuid import uuid4 @@ -33,13 +34,15 @@ class AuthenticationRequest(BaseModel): username: str = "guest" password: Optional[str] = None + token_name: str = Field(default_factory=lambda: datetime.utcnow().isoformat()) client_id: str = Field(default_factory=lambda: str(uuid4())) model_config = { "json_schema_extra": { "examples": [{ "username": "guest", - "password": "password" + "password": "password", + "token_name": "My Client" }]}} diff --git a/neon_hana/schema/user_profile.py b/neon_hana/schema/user_profile.py index 91a9a05..2c8bdfb 100644 --- a/neon_hana/schema/user_profile.py +++ b/neon_hana/schema/user_profile.py @@ -24,9 +24,14 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import pytz +import datetime + from typing import Optional, List from pydantic import BaseModel +from neon_users_service.models import User + class ProfileUser(BaseModel): first_name: str = "" @@ -102,3 +107,60 @@ class UserProfile(BaseModel): location: ProfileLocation = ProfileLocation() response_mode: ProfileResponseMode = ProfileResponseMode() privacy: ProfilePrivacy = ProfilePrivacy() + + @classmethod + def from_user_config(cls, user: User): + user_config = user.neon + today = datetime.date.today() + if user_config.user.dob: + dob = user_config.user.dob + age = today.year - dob.year - ( + (today.month, today.day) < (dob.month, dob.day)) + dob = dob.strftime("%Y/%m/%d") + else: + age = "" + dob = "YYYY/MM/DD" + full_name = " ".join((n for n in (user_config.user.first_name, + user_config.user.middle_name, + user_config.user.last_name) if n)) + user = ProfileUser(about=user_config.user.about, + age=age, dob=dob, + email=user_config.user.email, + email_verified=user_config.user.email_verified, + first_name=user_config.user.first_name, + full_name=full_name, + last_name=user_config.user.last_name, + middle_name=user_config.user.middle_name, + password=user.password_hash or "", + phone=user_config.user.phone, + phone_verified=user_config.user.phone_verified, + picture=user_config.user.avatar_url, + preferred_name=user_config.user.preferred_name, + username=user.username + ) + alt_stt = [lang.split('-')[0] for lang in + user_config.language.input_languages[1:]] + secondary_tts_lang = user_config.language.output_languages[1] if ( + len(user_config.language.output_languages) > 1) else None + speech = ProfileSpeech( + alt_langs=alt_stt, + secondary_tts_gender=user_config.response_mode.tts_gender, + secondary_tts_language=secondary_tts_lang, + speed_multiplier=user_config.response_mode.tts_speed_multiplier, + stt_language=user_config.language.input_languages[0].split('-')[0], + tts_gender=user_config.response_mode.tts_gender, + tts_language=user_config.language.output_languages[0]) + units = ProfileUnits(**user_config.units.model_dump()) + utc_hours = (pytz.timezone(user_config.location.timezone or "UTC") + .utcoffset(datetime.datetime.now()).total_seconds() / 3600) + # TODO: Get city, state, country from lat/lon + location = ProfileLocation(lat=user_config.location.latitude, + lng=user_config.location.longitude, + tz=user_config.location.timezone, + utc=utc_hours) + response_mode = ProfileResponseMode(**user_config.response_mode.model_dump()) + privacy = ProfilePrivacy(**user_config.privacy.model_dump()) + + return UserProfile(location=location, privacy=privacy, + response_mode=response_mode, speech=speech, + units=units, user=user) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 0e5f1c8..b8e34bd 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -5,4 +5,6 @@ pydantic~=2.5 pyjwt~=2.8 token-throttler~=1.4 neon-mq-connector~=0.7 -ovos-config~=0.0.12 \ No newline at end of file +ovos-config~=0.0,>=0.0.12 +ovos-utils~=0.0,>=0.0.38 +neon-users-service@git+https://github.com/neongeckocom/neon-users-service@FEAT_InitialImplementation \ No newline at end of file diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..bc29085 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,18 @@ +from unittest import TestCase + +from neon_hana.schema.user_profile import UserProfile + +from neon_users_service.models import User + + +class TestUserProfile(TestCase): + def test_user_profile(self): + # Test default + profile = UserProfile() + self.assertIsInstance(profile, UserProfile) + + # Test from User + default_user = User(username="test_user") + profile = UserProfile.from_user_config(default_user) + self.assertIsInstance(profile, UserProfile) + self.assertEqual(default_user.username, profile.user.username) From 6ec7e5c1e4b34b9c8ef46666b045ea9380ec9f33 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 30 Oct 2024 13:15:29 -0700 Subject: [PATCH 02/28] Add `register` endpoint with default `USER` permissions Update TokenConfig for compat. Remove `password` from tokens and allow users service auth via token Add RegistrationRequest model with example Update MQ API calls to pass `username` and `password` to resolve validation errors --- neon_hana/app/routers/auth.py | 6 +++++ neon_hana/auth/client_manager.py | 40 ++++++++++++++++++++++++------- neon_hana/mq_service_api.py | 40 +++++++++++++++++++++---------- neon_hana/schema/auth_requests.py | 18 ++++++++++++++ 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/neon_hana/app/routers/auth.py b/neon_hana/app/routers/auth.py index 14a9359..d309a56 100644 --- a/neon_hana/app/routers/auth.py +++ b/neon_hana/app/routers/auth.py @@ -28,6 +28,7 @@ from neon_hana.app.dependencies import client_manager from neon_hana.schema.auth_requests import * +from neon_users_service.models import User auth_route = APIRouter(prefix="/auth", tags=["authentication"]) @@ -43,3 +44,8 @@ async def check_login(auth_request: AuthenticationRequest, @auth_route.post("/refresh") async def check_refresh(request: RefreshRequest) -> AuthenticationResponse: return client_manager.check_refresh_request(**dict(request)) + + +@auth_route.post("/register") +async def register_user(request: RegistrationRequest) -> User: + return client_manager.check_registration_request(**dict(request)) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index de42372..2d0b3f3 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -38,7 +38,14 @@ from neon_hana.auth.permissions import ClientPermissions from neon_hana.mq_service_api import MQServiceManager -from neon_users_service.models import User, AccessRoles, TokenConfig +from neon_users_service.models import User, AccessRoles, TokenConfig, NeonUserConfig, PermissionsConfig + +_DEFAULT_USER_PERMISSIONS = PermissionsConfig(klat=AccessRoles.USER, + core=AccessRoles.USER, + diana=AccessRoles.USER, + node=AccessRoles.USER, + hub=AccessRoles.USER, + llm=AccessRoles.USER) class ClientManager: @@ -69,7 +76,7 @@ def _create_tokens(self, encode_data: dict) -> TokenConfig: token_expiration = encode_data['expire'] token = jwt.encode(encode_data, self._access_secret, self._jwt_algo) - encode_data['expire'] = time() + self._refresh_token_lifetime + encode_data['expire'] = round(time()) + self._refresh_token_lifetime encode_data['access_token'] = token refresh = jwt.encode(encode_data, self._refresh_secret, self._jwt_algo) return TokenConfig(**{"username": encode_data['username'], @@ -78,8 +85,11 @@ def _create_tokens(self, encode_data: dict) -> TokenConfig: "access_token": token, "refresh_token": refresh, "expiration": token_expiration, + "refresh_expiration": encode_data['expire'], "token_name": encode_data['name'], - "refresh_expiration": encode_data['expire']}) + "creation_timestamp": encode_data['create'], + "last_refresh_timestamp": encode_data['last_refresh_timestamp'] + }) def get_permissions(self, client_id: str) -> ClientPermissions: """ @@ -116,6 +126,15 @@ def disconnect_stream(self): with self._stream_check_lock: self._connected_streams -= 1 + def check_registration_request(self, username: str, password: str, + user_config: NeonUserConfig) -> User: + """ + Handle a request to register a new user. + """ + new_user = User(username=username, password_hash=password, + neon=user_config, permissions=_DEFAULT_USER_PERMISSIONS) + return self._mq_connector.create_user(new_user) + def check_auth_request(self, client_id: str, username: str, password: Optional[str] = None, token_name: Optional[str] = None, @@ -158,7 +177,6 @@ def check_auth_request(self, client_id: str, username: str, else: user = self._mq_connector.get_user_profile(username, password) username = user.username - password = user.password_hash # Boolean permissions allow access for any role, including `NODE`. # Specific endpoints may enforce more granular controls/limits based on @@ -167,13 +185,12 @@ def check_auth_request(self, client_id: str, username: str, node=user.permissions.node != AccessRoles.NONE, assist=user.permissions.core != AccessRoles.NONE, backend=user.permissions.diana != AccessRoles.NONE) - create_time = time() + create_time = round(time()) expiration = create_time + self._access_token_lifetime encode_data = {"client_id": client_id, "sub": username, # Added for Klat token compat. "name": token_name, "username": username, - "password": password, "permissions": permissions.as_dict(), "create": create_time, "expire": expiration, @@ -208,12 +225,17 @@ def check_refresh_request(self, access_token: str, refresh_token: str, detail="Access token does not match client_id") encode_data = {k: token_data[k] for k in ("client_id", "username", "password")} - refresh_time = time() + + user = self._mq_connector.get_user_profile(username=token_data['username'], + access_token=refresh_token) + if not user.password_hash: + # This should not be possible, but don't let an error in the + # users service allow for injecting a new valid token to the db + raise HTTPException(status_code=500, detail="Error Fetching User") + refresh_time = round(time()) encode_data['last_refresh_timestamp'] = refresh_time encode_data["expire"] = refresh_time + self._access_token_lifetime new_auth = self._create_tokens(encode_data) - user = self._mq_connector.get_user_profile(username=token_data['username'], - password=token_data['password']) self._add_token_to_userdb(user, new_auth) return new_auth.model_dump() diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index 2f05419..4595caa 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -79,26 +79,30 @@ def _validate_api_proxy_response(response: dict, query_params: dict): raise APIError(status_code=code, detail=response['content']) @staticmethod - def _query_users_api(operation: str, username: Optional[str] = None, + def _query_users_api(operation: str, username: str, password: Optional[str] = None, - user: Optional[User] = None) -> (bool, Union[User, int, str]): + access_token: Optional[str] = None, + user: Optional[User] = None) -> (bool, int, Union[User, str]): """ Query the users API and return a status code and either a valid User or - a string error message + a string error message. Authentication may use EITHER a password or + a token. @param operation: Operation to perform (create, read, update, delete) @param username: Optional username to include @param password: Optional password to include + @param access_token: Optional auth token to include @param user: Optional user object to include - @return: success bool, User object or string error message + @return: success bool, HTTP status code User object or string error message """ response = send_mq_request("/neon_users", {"operation": operation, "username": username, "password": password, - "user": user}, + "access_token": access_token, + "user": user.model_dump() if user else None}, "neon_users_input") if response.get("success"): - return True, 200, response.get("user") + return True, 200, User(**response.get("user")) return False, response.get("code", 500), response.get("error", "") def get_session(self, node_data: NodeData) -> dict: @@ -113,17 +117,21 @@ def get_session(self, node_data: NodeData) -> dict: "site_id": node_data.location.site_id}) return self.sessions_by_id[session_id] - def get_user_profile(self, username: str, password: str) -> User: + def get_user_profile(self, username: str, password: Optional[str] = None, + access_token: Optional[str] = None) -> User: """ - Get a User object for a user. This requires that a valid password be - provided to prevent arbitrary users from reading private profile info. + Get a User object for a user. This requires that a valid password OR + access token be provided to prevent arbitrary users from reading + private profile info. @param username: Valid username to get a User object for - @param password: Valid password for the input username + @param password: Valid password to use for authentication + @param access_token: Valid access token to use for authentication @returns: User object from the Users service. """ stat, code, err_or_user = self._query_users_api("read", username=username, - password=password) + password=password, + access_token=access_token) if not stat: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user @@ -134,7 +142,10 @@ def create_user(self, user: User) -> User: @param user: User object to add to the users service database @returns: User object added to the database """ - stat, code, err_or_user = self._query_users_api("create", user=user) + stat, code, err_or_user = self._query_users_api("create", + username=user.username, + password=user.password_hash, + user=user) if not stat: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user @@ -145,7 +156,10 @@ def update_user(self, user: User) -> User: @param user: Updated user object to write @returns: User as read from the database """ - stat, code, err_or_user = self._query_users_api("update", user=user) + stat, code, err_or_user = self._query_users_api("update", + username=user.username, + password=user.password_hash, + user=user) if not stat: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index c889639..46463ee 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -30,6 +30,8 @@ from pydantic import BaseModel, Field +from neon_users_service.models import NeonUserConfig + class AuthenticationRequest(BaseModel): username: str = "guest" @@ -68,3 +70,19 @@ class RefreshRequest(BaseModel): access_token: str refresh_token: str client_id: str + + +class RegistrationRequest(BaseModel): + username: str + password: str + user_config: NeonUserConfig = NeonUserConfig() + + model_config = { + "json_schema_extra": { + "examples": [{ + "username": "guest", + "password": "password", + "user_config": NeonUserConfig().model_dump() + }, {"username": "guest", + "password": "password"} + ]}} From 13b8a22152e160aa12c23f79ce3c8cfed707ebdf Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 30 Oct 2024 14:05:08 -0700 Subject: [PATCH 03/28] Add `user` route with authenticated `get` and `update` endpoints. Add helper method for `update` requests to allow for changing the current auth method (password or token) --- neon_hana/app/__init__.py | 2 ++ neon_hana/app/routers/user.py | 45 ++++++++++++++++++++++++++++ neon_hana/mq_service_api.py | 15 ++++++++++ neon_hana/schema/user_requests.py | 49 +++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 neon_hana/app/routers/user.py create mode 100644 neon_hana/schema/user_requests.py diff --git a/neon_hana/app/__init__.py b/neon_hana/app/__init__.py index 9fd7f1d..299f963 100644 --- a/neon_hana/app/__init__.py +++ b/neon_hana/app/__init__.py @@ -32,6 +32,7 @@ from neon_hana.app.routers.llm import llm_route from neon_hana.app.routers.mq_backend import mq_route from neon_hana.app.routers.auth import auth_route +from neon_hana.app.routers.user import user_route from neon_hana.app.routers.util import util_route from neon_hana.app.routers.node_server import node_route from neon_hana.version import __version__ @@ -49,5 +50,6 @@ def create_app(config: dict): app.include_router(llm_route) app.include_router(util_route) app.include_router(node_route) + app.include_router(user_route) return app diff --git a/neon_hana/app/routers/user.py b/neon_hana/app/routers/user.py new file mode 100644 index 0000000..9d6b067 --- /dev/null +++ b/neon_hana/app/routers/user.py @@ -0,0 +1,45 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2021 Neongecko.com Inc. +# BSD-3 +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from fastapi import APIRouter, Depends +from neon_hana.app.dependencies import jwt_bearer, mq_connector +from neon_hana.schema.user_requests import GetUserRequest, UpdateUserRequest +from neon_users_service.models import User + +user_route = APIRouter(tags=["user"], dependencies=[Depends(jwt_bearer)]) + + +@user_route.post("/get") +async def get_user(request: GetUserRequest, + token: str = Depends(jwt_bearer)) -> User: + return mq_connector.get_user_profile(access_token=token, **dict(request)) + + +@user_route.post("/update") +async def update_user(request: UpdateUserRequest, + token: str = Depends(jwt_bearer)) -> User: + return mq_connector.handle_update_user_request(access_token=token, + **dict(request)) diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index 4595caa..af08ec3 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -164,6 +164,21 @@ def update_user(self, user: User) -> User: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user + def handle_update_user_request(self, user: User, access_token: str): + """ + Handle a request to update a user. This accepts an `auth_token` to + account for requests to change the password or registered tokens. + @param user: Updated User object to write to the database + @param access_token: JWT auth token submitted with the request + """ + stat, code, err_or_user = self._query_users_api("update", + username=user.username, + access_token=access_token, + user=user) + if not stat: + raise HTTPException(status_code=code, detail=err_or_user) + return err_or_user + def query_api_proxy(self, service_name: str, query_params: dict, timeout: int = 10): query_params['service'] = service_name diff --git a/neon_hana/schema/user_requests.py b/neon_hana/schema/user_requests.py new file mode 100644 index 0000000..ffb3993 --- /dev/null +++ b/neon_hana/schema/user_requests.py @@ -0,0 +1,49 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2021 Neongecko.com Inc. +# BSD-3 +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from pydantic import BaseModel + +from neon_users_service.models import User + + +class GetUserRequest(BaseModel): + username: str = "guest" + + model_config = { + "json_schema_extra": { + "examples": [{ + "username": "guest" + }]}} + + +class UpdateUserRequest(BaseModel): + user: User + + model_config = { + "json_schema_extra": { + "examples": [{ + "user": User(username="guest").model_dump() + }]}} From 61c9fea913563d286fdfb22aea13c3adfeffe38c Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 11:39:41 -0800 Subject: [PATCH 04/28] Update data model imports --- neon_hana/schema/node_model.py | 32 +------ neon_hana/schema/node_v1.py | 131 +-------------------------- neon_hana/schema/user_profile.py | 141 +----------------------------- neon_hana/schema/user_requests.py | 4 +- requirements/requirements.txt | 2 +- tests/test_schema.py | 4 +- 6 files changed, 9 insertions(+), 305 deletions(-) diff --git a/neon_hana/schema/node_model.py b/neon_hana/schema/node_model.py index 3fbdc50..c7baf73 100644 --- a/neon_hana/schema/node_model.py +++ b/neon_hana/schema/node_model.py @@ -23,35 +23,5 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from uuid import uuid4 -from pydantic import BaseModel, Field -from typing import Optional, Dict - - -class NodeSoftware(BaseModel): - operating_system: str = "" - os_version: str = "" - neon_packages: Optional[Dict[str, str]] = None - - -class NodeNetworking(BaseModel): - local_ip: str = "127.0.0.1" - public_ip: str = "" - mac_address: str = "" - - -class NodeLocation(BaseModel): - lat: Optional[float] = None - lon: Optional[float] = None - site_id: Optional[str] = None - - -class NodeData(BaseModel): - device_id: str = Field(default_factory=lambda: str(uuid4())) - device_name: str = "" - device_description: str = "" - platform: str = "" - networking: NodeNetworking = NodeNetworking() - software: NodeSoftware = NodeSoftware() - location: NodeLocation = NodeLocation() +from neon_data_models.models.client.node import * diff --git a/neon_hana/schema/node_v1.py b/neon_hana/schema/node_v1.py index 913c6b0..6067be0 100644 --- a/neon_hana/schema/node_v1.py +++ b/neon_hana/schema/node_v1.py @@ -1,6 +1,6 @@ # NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System # All trademark and other rights reserved by their respective owners -# Copyright 2008-2021 Neongecko.com Inc. +# Copyright 2008-2024 Neongecko.com Inc. # BSD-3 # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -24,131 +24,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from pydantic import BaseModel, Field -from typing import Optional, Dict, List, Literal -from neon_hana.schema.node_model import NodeData - - -class NodeInputContext(BaseModel): - node_data: Optional[NodeData] = Field(description="Node Data") - - -class AudioInputData(BaseModel): - audio_data: str = Field(description="base64-encoded audio") - lang: str = Field(description="BCP-47 language code") - - -class TextInputData(BaseModel): - text: str = Field(description="String text input") - lang: str = Field(description="BCP-47 language code") - - -class UtteranceInputData(BaseModel): - utterances: List[str] = Field(description="List of input utterance(s)") - lang: str = Field(description="BCP-47 language") - - -class KlatResponse(BaseModel): - sentence: str = Field(description="Text response") - audio: dict = {Field(description="Audio Gender", - type=Literal["male", "female"]): - Field(description="b64-encoded audio", type=str)} - - -class TtsResponse(KlatResponse): - translated: bool = Field(description="True if sentence was translated") - phonemes: List[str] = Field(description="List of phonemes") - genders: List[str] = Field(description="List of audio genders") - - -class KlatResponseData(BaseModel): - responses: dict = {Field(type=str, - description="BCP-47 language"): KlatResponse} - - -class NodeAudioInput(BaseModel): - msg_type: str = "neon.audio_input" - data: AudioInputData - context: NodeInputContext - - -class NodeTextInput(BaseModel): - msg_type: str = "recognizer_loop:utterance" - data: UtteranceInputData - context: NodeInputContext - - -class NodeGetStt(BaseModel): - msg_type: str = "neon.get_stt" - data: AudioInputData - context: NodeInputContext - - -class NodeGetTts(BaseModel): - msg_type: str = "neon.get_tts" - data: TextInputData - context: NodeInputContext - - -class NodeKlatResponse(BaseModel): - msg_type: str = "klat.response" - data: dict = {Field(type=str, description="BCP-47 language"): KlatResponse} - context: dict - - -class NodeAudioInputResponse(BaseModel): - msg_type: str = "neon.audio_input.response" - data: dict = {"parser_data": Field(description="Dict audio parser data", - type=dict), - "transcripts": Field(description="Transcribed text", - type=List[str]), - "skills_recv": Field(description="Skills service acknowledge", - type=bool)} - context: dict - - -class NodeGetSttResponse(BaseModel): - msg_type: str = "neon.get_stt.response" - data: dict = {"parser_data": Field(description="Dict audio parser data", - type=dict), - "transcripts": Field(description="Transcribed text", - type=List[str]), - "skills_recv": Field(description="Skills service acknowledge", - type=bool)} - context: dict - - -class NodeGetTtsResponse(BaseModel): - msg_type: str = "neon.get_tts.response" - data: KlatResponseData - context: dict - - -class CoreWWDetected(BaseModel): - msg_type: str = "neon.ww_detected" - data: dict - context: dict - - -class CoreIntentFailure(BaseModel): - msg_type: str = "complete.intent.failure" - data: dict - context: dict - - -class CoreErrorResponse(BaseModel): - msg_type: str = "klat.error" - data: dict - context: dict - - -class CoreClearData(BaseModel): - msg_type: str = "neon.clear_data" - data: dict - context: dict - - -class CoreAlertExpired(BaseModel): - msg_type: str = "neon.alert_expired" - data: dict - context: dict +from neon_data_models.models.api.node_v1 import * diff --git a/neon_hana/schema/user_profile.py b/neon_hana/schema/user_profile.py index 2c8bdfb..47d4e14 100644 --- a/neon_hana/schema/user_profile.py +++ b/neon_hana/schema/user_profile.py @@ -24,143 +24,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import pytz -import datetime - -from typing import Optional, List -from pydantic import BaseModel - -from neon_users_service.models import User - - -class ProfileUser(BaseModel): - first_name: str = "" - middle_name: str = "" - last_name: str = "" - preferred_name: str = "" - full_name: str = "" - dob: str = "YYYY/MM/DD" - age: str = "" - email: str = "" - username: str = "" - password: str = "" - picture: str = "" - about: str = "" - phone: str = "" - phone_verified: bool = False - email_verified: bool = False - - -class ProfileBrands(BaseModel): - ignored_brands: dict = {} - favorite_brands: dict = {} - specially_requested: dict = {} - - -class ProfileSpeech(BaseModel): - stt_language: str = "en-us" - alt_languages: List[str] = ['en'] - tts_language: str = "en-us" - tts_gender: str = "female" - neon_voice: Optional[str] = '' - secondary_tts_language: Optional[str] = '' - secondary_tts_gender: str = "male" - secondary_neon_voice: str = '' - speed_multiplier: float = 1.0 - - -class ProfileUnits(BaseModel): - time: int = 12 - # 12, 24 - date: str = "MDY" - # MDY, YMD, YDM - measure: str = "imperial" - # imperial, metric - - -class ProfileLocation(BaseModel): - lat: Optional[float] = None - lng: Optional[float] = None - city: Optional[str] = None - state: Optional[str] = None - country: Optional[str] = None - tz: Optional[str] = None - utc: Optional[float] = None - - -class ProfileResponseMode(BaseModel): - speed_mode: str = "quick" - hesitation: bool = False - limit_dialog: bool = False - - -class ProfilePrivacy(BaseModel): - save_audio: bool = False - save_text: bool = False - - -class UserProfile(BaseModel): - user: ProfileUser = ProfileUser() - # brands: ProfileBrands - speech: ProfileSpeech = ProfileSpeech() - units: ProfileUnits = ProfileUnits() - location: ProfileLocation = ProfileLocation() - response_mode: ProfileResponseMode = ProfileResponseMode() - privacy: ProfilePrivacy = ProfilePrivacy() - - @classmethod - def from_user_config(cls, user: User): - user_config = user.neon - today = datetime.date.today() - if user_config.user.dob: - dob = user_config.user.dob - age = today.year - dob.year - ( - (today.month, today.day) < (dob.month, dob.day)) - dob = dob.strftime("%Y/%m/%d") - else: - age = "" - dob = "YYYY/MM/DD" - full_name = " ".join((n for n in (user_config.user.first_name, - user_config.user.middle_name, - user_config.user.last_name) if n)) - user = ProfileUser(about=user_config.user.about, - age=age, dob=dob, - email=user_config.user.email, - email_verified=user_config.user.email_verified, - first_name=user_config.user.first_name, - full_name=full_name, - last_name=user_config.user.last_name, - middle_name=user_config.user.middle_name, - password=user.password_hash or "", - phone=user_config.user.phone, - phone_verified=user_config.user.phone_verified, - picture=user_config.user.avatar_url, - preferred_name=user_config.user.preferred_name, - username=user.username - ) - alt_stt = [lang.split('-')[0] for lang in - user_config.language.input_languages[1:]] - secondary_tts_lang = user_config.language.output_languages[1] if ( - len(user_config.language.output_languages) > 1) else None - speech = ProfileSpeech( - alt_langs=alt_stt, - secondary_tts_gender=user_config.response_mode.tts_gender, - secondary_tts_language=secondary_tts_lang, - speed_multiplier=user_config.response_mode.tts_speed_multiplier, - stt_language=user_config.language.input_languages[0].split('-')[0], - tts_gender=user_config.response_mode.tts_gender, - tts_language=user_config.language.output_languages[0]) - units = ProfileUnits(**user_config.units.model_dump()) - utc_hours = (pytz.timezone(user_config.location.timezone or "UTC") - .utcoffset(datetime.datetime.now()).total_seconds() / 3600) - # TODO: Get city, state, country from lat/lon - location = ProfileLocation(lat=user_config.location.latitude, - lng=user_config.location.longitude, - tz=user_config.location.timezone, - utc=utc_hours) - response_mode = ProfileResponseMode(**user_config.response_mode.model_dump()) - privacy = ProfilePrivacy(**user_config.privacy.model_dump()) - - return UserProfile(location=location, privacy=privacy, - response_mode=response_mode, speech=speech, - units=units, user=user) +from neon_data_models.models.user.neon_profile import * diff --git a/neon_hana/schema/user_requests.py b/neon_hana/schema/user_requests.py index ffb3993..3311608 100644 --- a/neon_hana/schema/user_requests.py +++ b/neon_hana/schema/user_requests.py @@ -1,6 +1,6 @@ # NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System # All trademark and other rights reserved by their respective owners -# Copyright 2008-2021 Neongecko.com Inc. +# Copyright 2008-2024 Neongecko.com Inc. # BSD-3 # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -26,7 +26,7 @@ from pydantic import BaseModel -from neon_users_service.models import User +from neon_data_models.models.user.database import User class GetUserRequest(BaseModel): diff --git a/requirements/requirements.txt b/requirements/requirements.txt index b8e34bd..298dcb5 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ token-throttler~=1.4 neon-mq-connector~=0.7 ovos-config~=0.0,>=0.0.12 ovos-utils~=0.0,>=0.0.38 -neon-users-service@git+https://github.com/neongeckocom/neon-users-service@FEAT_InitialImplementation \ No newline at end of file +neon-data-models \ No newline at end of file diff --git a/tests/test_schema.py b/tests/test_schema.py index bc29085..53e6dc0 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -2,7 +2,7 @@ from neon_hana.schema.user_profile import UserProfile -from neon_users_service.models import User +from neon_data_models.models.user import User class TestUserProfile(TestCase): @@ -13,6 +13,6 @@ def test_user_profile(self): # Test from User default_user = User(username="test_user") - profile = UserProfile.from_user_config(default_user) + profile = UserProfile.from_user_object(default_user) self.assertIsInstance(profile, UserProfile) self.assertEqual(default_user.username, profile.user.username) From 7f0fdf1a38565bc2835a15f70f3472fa7c73d780 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 11:42:55 -0800 Subject: [PATCH 05/28] Update missed import refactor --- neon_hana/app/routers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_hana/app/routers/user.py b/neon_hana/app/routers/user.py index 9d6b067..07c638d 100644 --- a/neon_hana/app/routers/user.py +++ b/neon_hana/app/routers/user.py @@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends from neon_hana.app.dependencies import jwt_bearer, mq_connector from neon_hana.schema.user_requests import GetUserRequest, UpdateUserRequest -from neon_users_service.models import User +from neon_data_models.models.user import User user_route = APIRouter(tags=["user"], dependencies=[Depends(jwt_bearer)]) From 4cf6f2a63857743dd40a28569d084a4a59b71867 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 11:45:33 -0800 Subject: [PATCH 06/28] Update missed import refactor --- neon_hana/mq_service_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index af08ec3..5f96529 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -34,7 +34,7 @@ from neon_hana.schema.node_model import NodeData from neon_hana.schema.user_profile import UserProfile from neon_mq_connector.utils.client_utils import send_mq_request -from neon_users_service.models import User +from neon_data_models.models.user import User class APIError(HTTPException): From 9f7c1d143e751cba3a5e324abda0e1e6a8849464 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 11:50:07 -0800 Subject: [PATCH 07/28] Update missed import refactor --- neon_hana/app/routers/auth.py | 2 +- neon_hana/auth/client_manager.py | 4 +++- neon_hana/schema/auth_requests.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/neon_hana/app/routers/auth.py b/neon_hana/app/routers/auth.py index d309a56..46b1005 100644 --- a/neon_hana/app/routers/auth.py +++ b/neon_hana/app/routers/auth.py @@ -28,7 +28,7 @@ from neon_hana.app.dependencies import client_manager from neon_hana.schema.auth_requests import * -from neon_users_service.models import User +from neon_data_models.models.user import User auth_route = APIRouter(prefix="/auth", tags=["authentication"]) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 2d0b3f3..7cb4146 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -38,7 +38,9 @@ from neon_hana.auth.permissions import ClientPermissions from neon_hana.mq_service_api import MQServiceManager -from neon_users_service.models import User, AccessRoles, TokenConfig, NeonUserConfig, PermissionsConfig +from neon_data_models.models.user import (User, TokenConfig, NeonUserConfig, + PermissionsConfig) +from neon_data_models.enum import AccessRoles _DEFAULT_USER_PERMISSIONS = PermissionsConfig(klat=AccessRoles.USER, core=AccessRoles.USER, diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index 46463ee..e2e1619 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -30,7 +30,7 @@ from pydantic import BaseModel, Field -from neon_users_service.models import NeonUserConfig +from neon_data_models.models.user import NeonUserConfig class AuthenticationRequest(BaseModel): From 2c4c269f654bdbd1c98cdc8367ba7d90d860925e Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 11:59:59 -0800 Subject: [PATCH 08/28] Update Client Manager refactor to enable backwards-compat. without a users service --- neon_hana/auth/client_manager.py | 44 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 7cb4146..290fef6 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -51,7 +51,8 @@ class ClientManager: - def __init__(self, config: dict, mq_connector: MQServiceManager): + def __init__(self, config: dict, + mq_connector: Optional[MQServiceManager] = None): self.rate_limiter = TokenThrottler(cost=1, storage=RuntimeStorage()) self.authorized_clients: Dict[str, dict] = dict() @@ -135,7 +136,11 @@ def check_registration_request(self, username: str, password: str, """ new_user = User(username=username, password_hash=password, neon=user_config, permissions=_DEFAULT_USER_PERMISSIONS) - return self._mq_connector.create_user(new_user) + if self._mq_connector: + return self._mq_connector.create_user(new_user) + else: + print("No User Database connected. Return valid registration.") + return new_user def check_auth_request(self, client_id: str, username: str, password: Optional[str] = None, @@ -169,12 +174,11 @@ def check_auth_request(self, client_id: str, username: str, detail=f"Too many auth requests from: " f"{origin_ip}. Wait {wait_time}s.") - # TODO: disable "guest" access? - if username == "guest": - user = User(username=username, password=password) + if self._mq_connector is None: + user = User(username=username, password_hash=password) elif all((self._node_username, username == self._node_username, password == self._node_password)): - user = User(username=username, password=password) + user = User(username=username, password_hash=password) user.permissions.node = AccessRoles.USER else: user = self._mq_connector.get_user_profile(username, password) @@ -228,20 +232,26 @@ def check_refresh_request(self, access_token: str, refresh_token: str, encode_data = {k: token_data[k] for k in ("client_id", "username", "password")} - user = self._mq_connector.get_user_profile(username=token_data['username'], - access_token=refresh_token) - if not user.password_hash: - # This should not be possible, but don't let an error in the - # users service allow for injecting a new valid token to the db - raise HTTPException(status_code=500, detail="Error Fetching User") - refresh_time = round(time()) - encode_data['last_refresh_timestamp'] = refresh_time - encode_data["expire"] = refresh_time + self._access_token_lifetime - new_auth = self._create_tokens(encode_data) - self._add_token_to_userdb(user, new_auth) + if self._mq_connector: + user = self._mq_connector.get_user_profile(username=token_data['username'], + access_token=refresh_token) + if not user.password_hash: + # This should not be possible, but don't let an error in the + # users service allow for injecting a new valid token to the db + raise HTTPException(status_code=500, detail="Error Fetching User") + refresh_time = round(time()) + encode_data['last_refresh_timestamp'] = refresh_time + encode_data["expire"] = refresh_time + self._access_token_lifetime + new_auth = self._create_tokens(encode_data) + self._add_token_to_userdb(user, new_auth) + else: + new_auth = self._create_tokens(encode_data) return new_auth.model_dump() def _add_token_to_userdb(self, user: User, token_data: TokenConfig): + if self._mq_connector is None: + print("No MQ Connection to a user database") + return # Enforce unique `creation_timestamp` values to avoid duplicate entries for idx, token in enumerate(user.tokens): if token.creation_timestamp == token_data.creation_timestamp: From 01ce5d9f260550237c5eac74a9c1431864a1d91f Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 18:09:04 -0800 Subject: [PATCH 09/28] Add `jwt_issuer` config Deprecate `ClientPermissions` which duplicates role-based permissions spec in neon-data-models Refactor token handling to use JWT model and updated configuration spec --- README.md | 1 + neon_hana/auth/client_manager.py | 204 +++++++++++++++++-------------- neon_hana/auth/permissions.py | 43 ------- tests/test_auth.py | 4 +- 4 files changed, 114 insertions(+), 138 deletions(-) delete mode 100644 neon_hana/auth/permissions.py diff --git a/README.md b/README.md index d741025..c378839 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ hana: auth_requests_per_minute: 6 # This counts valid and invalid requests from an IP address access_token_secret: a800445648142061fc238d1f84e96200da87f4f9fa7835cac90db8b4391b117b refresh_token_secret: 833d369ac73d883123743a44b4a7fe21203cffc956f4c8fec712e71aafa8e1aa + jwt_issuer: neon.ai # Used in the `iss` field of generated JWT tokens. fastapi_title: "My HANA API Host" fastapi_summary: "Personal HTTP API to access my DIANA backend." disable_auth: True diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 290fef6..c28e6c9 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -23,10 +23,12 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from threading import Lock +from uuid import uuid4 import jwt +from datetime import datetime +from threading import Lock from time import time from typing import Dict, Optional from fastapi import Request, HTTPException @@ -36,11 +38,12 @@ from token_throttler import TokenThrottler, TokenBucket from token_throttler.storage import RuntimeStorage -from neon_hana.auth.permissions import ClientPermissions +from neon_data_models.models.api.jwt import HanaToken from neon_hana.mq_service_api import MQServiceManager from neon_data_models.models.user import (User, TokenConfig, NeonUserConfig, PermissionsConfig) from neon_data_models.enum import AccessRoles +from neon_hana.schema.auth_requests import AuthenticationResponse _DEFAULT_USER_PERMISSIONS = PermissionsConfig(klat=AccessRoles.USER, core=AccessRoles.USER, @@ -55,10 +58,14 @@ def __init__(self, config: dict, mq_connector: Optional[MQServiceManager] = None): self.rate_limiter = TokenThrottler(cost=1, storage=RuntimeStorage()) - self.authorized_clients: Dict[str, dict] = dict() + # TODO: Is `authorized_clients` useful to track? + # Keep a dict of `client_id` to auth tokens that have authenticated to + # this instance + self.authorized_clients: Dict[str, HanaToken] = dict() self._access_token_lifetime = config.get("access_token_ttl", 3600 * 24) self._refresh_token_lifetime = config.get("refresh_token_ttl", - 3600 * 24 * 7) + 3600 * 24 * 90) + self._jwt_issuer = config.get("jwt_issuer", "neon.ai") self._access_secret = config.get("access_token_secret") self._refresh_secret = config.get("refresh_token_secret") self._rpm = config.get("requests_per_minute", 60) @@ -72,42 +79,48 @@ def __init__(self, config: dict, self._stream_check_lock = Lock() self._mq_connector = mq_connector - def _create_tokens(self, encode_data: dict) -> TokenConfig: - # Permissions were not included in old tokens, allow refreshing with - # default permissions - encode_data.setdefault("permissions", ClientPermissions().as_dict()) + def _create_tokens(self, + user_id: str, + client_id: str, + token_name: Optional[str] = None, + permissions: Optional[PermissionsConfig] = None, + **kwargs) -> (HanaToken, HanaToken, TokenConfig): + token_id = str(uuid4()) + creation_timestamp = round(time()) + expiration_timestamp = creation_timestamp + self._access_token_lifetime + refresh_expiration_timestamp = creation_timestamp + self._refresh_token_lifetime + permissions = permissions or PermissionsConfig(core=AccessRoles.GUEST, + diana=AccessRoles.GUEST, + node=AccessRoles.GUEST, + llm=AccessRoles.GUEST) + token_name = token_name or kwargs.get("name") or \ + datetime.fromtimestamp(creation_timestamp).isoformat() + access_token_data = HanaToken(iss=self._jwt_issuer, + sub=user_id, + exp=expiration_timestamp, + iat=creation_timestamp, + jti=token_id, + client_id=client_id, + roles=permissions.to_roles(), + purpose="access") + refresh_token_data = HanaToken(iss=self._jwt_issuer, + sub=user_id, + exp=refresh_expiration_timestamp, + iat=creation_timestamp, + jti=f"{token_id}.refresh", + client_id=client_id, + roles=permissions.to_roles(), + purpose="refresh") - token_expiration = encode_data['expire'] - token = jwt.encode(encode_data, self._access_secret, self._jwt_algo) - encode_data['expire'] = round(time()) + self._refresh_token_lifetime - encode_data['access_token'] = token - refresh = jwt.encode(encode_data, self._refresh_secret, self._jwt_algo) - return TokenConfig(**{"username": encode_data['username'], - "client_id": encode_data['client_id'], - "permissions": encode_data['permissions'], - "access_token": token, - "refresh_token": refresh, - "expiration": token_expiration, - "refresh_expiration": encode_data['expire'], - "token_name": encode_data['name'], - "creation_timestamp": encode_data['create'], - "last_refresh_timestamp": encode_data['last_refresh_timestamp'] - }) - - def get_permissions(self, client_id: str) -> ClientPermissions: - """ - Get ClientPermissions model for the given client_id - @param client_id: Client ID to get permissions for - @return: ClientPermissions object for the specified client - """ - if self._disable_auth: - LOG.debug("Auth disabled, allow full client permissions") - return ClientPermissions(assist=True, backend=True, node=True) - if client_id not in self.authorized_clients: - LOG.warning(f"{client_id} not known to this server") - return ClientPermissions(assist=False, backend=False, node=False) - client = self.authorized_clients[client_id] - return ClientPermissions(**client.get('permissions', dict())) + token_config = TokenConfig(token_name=token_name, + token_id=token_id, + user_id=user_id, + client_id=client_id, + permissions=permissions, + refresh_expiration_timestamp=refresh_expiration_timestamp, + creation_timestamp=creation_timestamp, + last_refresh_timestamp=creation_timestamp) + return access_token_data, refresh_token_data, token_config def check_connect_stream(self) -> bool: """ @@ -145,7 +158,7 @@ def check_registration_request(self, username: str, password: str, def check_auth_request(self, client_id: str, username: str, password: Optional[str] = None, token_name: Optional[str] = None, - origin_ip: str = "127.0.0.1") -> dict: + origin_ip: str = "127.0.0.1") -> AuthenticationResponse: """ Authenticate and Authorize a new client connection with the specified username, password, and origin IP address. @@ -156,9 +169,9 @@ def check_auth_request(self, client_id: str, username: str, @param origin_ip: Origin IP address of request @return: response tokens, permissions, and other metadata """ - if client_id in self.authorized_clients: - print(f"Using cached client: {self.authorized_clients[client_id]}") - return self.authorized_clients[client_id] + # if client_id in self.authorized_clients: + # print(f"Using cached client: {self.authorized_clients[client_id]}") + # return self.authorized_clients[client_id] ratelimit_id = f"auth{origin_ip}" if not self.rate_limiter.get_all_buckets(ratelimit_id): @@ -182,91 +195,93 @@ def check_auth_request(self, client_id: str, username: str, user.permissions.node = AccessRoles.USER else: user = self._mq_connector.get_user_profile(username, password) - username = user.username - # Boolean permissions allow access for any role, including `NODE`. - # Specific endpoints may enforce more granular controls/limits based on - # specific user.permissions values. - permissions = ClientPermissions( - node=user.permissions.node != AccessRoles.NONE, - assist=user.permissions.core != AccessRoles.NONE, - backend=user.permissions.diana != AccessRoles.NONE) create_time = round(time()) - expiration = create_time + self._access_token_lifetime encode_data = {"client_id": client_id, - "sub": username, # Added for Klat token compat. - "name": token_name, - "username": username, - "permissions": permissions.as_dict(), - "create": create_time, - "expire": expiration, + "user_id": user.user_id, + "permissions": user.permissions, + "token_name": token_name, "last_refresh_timestamp": create_time} - auth = self._create_tokens(encode_data) - self._add_token_to_userdb(user, auth) - self.authorized_clients[client_id] = auth.model_dump() - return auth.model_dump() + access, refresh, config = self._create_tokens(**encode_data) + self.authorized_clients[client_id] = config + self._add_token_to_userdb(user, config) + return AuthenticationResponse(username=user.username, + client_id=client_id, + access_token=access, + refresh_token=refresh, + expiration=config.refresh_expiration_timestamp) def check_refresh_request(self, access_token: str, refresh_token: str, - client_id: str): + client_id: str) -> AuthenticationResponse: # Read and validate refresh token try: - refresh_data = jwt.decode(refresh_token, self._refresh_secret, - self._jwt_algo) + refresh_data = HanaToken(**jwt.decode(refresh_token, + self._refresh_secret, + self._jwt_algo)) + token_data = HanaToken(**jwt.decode(access_token, + self._access_secret, + self._jwt_algo)) except DecodeError: raise HTTPException(status_code=400, detail="Invalid refresh token supplied") - if refresh_data['access_token'] != access_token: + if refresh_data.jti != token_data.jti + ".refresh": raise HTTPException(status_code=403, detail="Refresh and access token mismatch") - if time() > refresh_data['expire']: + if time() > refresh_data.exp: raise HTTPException(status_code=401, detail="Refresh token is expired") - # Read access token and re-generate a new pair of tokens - # This is already known to be a valid token based on the refresh token - token_data = jwt.decode(access_token, self._access_secret, - self._jwt_algo) - if token_data['client_id'] != client_id: + if token_data.client_id != client_id: raise HTTPException(status_code=403, detail="Access token does not match client_id") - encode_data = {k: token_data[k] for k in - ("client_id", "username", "password")} + # `token_name` is not known here, but it will be read from the database + # when the new token replaces the old one + encode_data = {"user_id": token_data.sub, + "client_id": client_id, + "permissions": PermissionsConfig.from_roles(token_data.roles) + } if self._mq_connector: - user = self._mq_connector.get_user_profile(username=token_data['username'], + user = self._mq_connector.get_user_profile(username=token_data.sub, access_token=refresh_token) if not user.password_hash: # This should not be possible, but don't let an error in the # users service allow for injecting a new valid token to the db raise HTTPException(status_code=500, detail="Error Fetching User") - refresh_time = round(time()) - encode_data['last_refresh_timestamp'] = refresh_time - encode_data["expire"] = refresh_time + self._access_token_lifetime - new_auth = self._create_tokens(encode_data) - self._add_token_to_userdb(user, new_auth) + access, refresh, config = self._create_tokens(**encode_data) + username = user.username + self._add_token_to_userdb(user, config) else: - new_auth = self._create_tokens(encode_data) - return new_auth.model_dump() + username = token_data.sub + access, refresh, config = self._create_tokens(**encode_data) + return AuthenticationResponse(username=username, + client_id=client_id, + access_token=access, + refresh_token=refresh, + expiration=config.refresh_expiration_timestamp) - def _add_token_to_userdb(self, user: User, token_data: TokenConfig): + def _add_token_to_userdb(self, user: User, new_token: TokenConfig): if self._mq_connector is None: print("No MQ Connection to a user database") return - # Enforce unique `creation_timestamp` values to avoid duplicate entries for idx, token in enumerate(user.tokens): - if token.creation_timestamp == token_data.creation_timestamp: + if token.token_id == new_token.token_id: + # Tokens don't contain `token_name`, so use the same one as is + # being replaced + new_token.token_name = token.token_name user.tokens.remove(token) - user.tokens.append(token_data) + user.tokens.append(new_token) self._mq_connector.update_user(user) def get_client_id(self, token: str) -> str: """ - Extract the client_id from a JWT token - @param token: JWT token to parse + Extract the client_id from a JWT string + @param token: JWT to parse @return: client_id associated with token """ - auth = jwt.decode(token, self._access_secret, self._jwt_algo) - return auth['client_id'] + auth = HanaToken(**jwt.decode(token, self._access_secret, + self._jwt_algo)) + return auth.client_id def validate_auth(self, token: str, origin_ip: str) -> bool: if not self.rate_limiter.get_all_buckets(origin_ip): @@ -281,11 +296,12 @@ def validate_auth(self, token: str, origin_ip: str) -> bool: if self._disable_auth: return True try: - auth = jwt.decode(token, self._access_secret, self._jwt_algo) - if auth['expire'] < time(): - self.authorized_clients.pop(auth['client_id'], None) + auth = HanaToken(**jwt.decode(token, self._access_secret, + self._jwt_algo)) + if auth.exp < time(): + self.authorized_clients.pop(auth.client_id, None) return False - self.authorized_clients[auth['client_id']] = auth + self.authorized_clients[auth.client_id] = auth return True except DecodeError: # Invalid token supplied diff --git a/neon_hana/auth/permissions.py b/neon_hana/auth/permissions.py deleted file mode 100644 index 287cc38..0000000 --- a/neon_hana/auth/permissions.py +++ /dev/null @@ -1,43 +0,0 @@ -# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System -# All trademark and other rights reserved by their respective owners -# Copyright 2008-2021 Neongecko.com Inc. -# BSD-3 -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from this -# software without specific prior written permission. -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, -# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from dataclasses import dataclass, asdict - - -@dataclass -class ClientPermissions: - """ - Data class representing permissions of a particular client connection. - """ - assist: bool = True - backend: bool = True - node: bool = False - - def as_dict(self) -> dict: - """ - Get a dict representation of this instance. - """ - return asdict(self) diff --git a/tests/test_auth.py b/tests/test_auth.py index 88422fb..73b0ae8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -76,7 +76,7 @@ def test_validate_auth(self): "127.0.0.1")) self.assertFalse(self.client_manager.validate_auth(invalid_client, "127.0.0.1")) - + # TODO: Update token data expired_token = self.client_manager._create_tokens( {"client_id": invalid_client, "username": "test", "password": "test", "expire": time(), @@ -93,6 +93,7 @@ def test_validate_auth(self): def test_check_refresh_request(self): valid_client = str(uuid4()) + # TODO: Update token data tokens = self.client_manager._create_tokens({"client_id": valid_client, "username": "test", "password": "test", @@ -134,6 +135,7 @@ def test_check_refresh_request(self): # Test expired refresh token real_refresh = self.client_manager._refresh_token_lifetime self.client_manager._refresh_token_lifetime = 0 + # TODO: Update token data tokens = self.client_manager._create_tokens({"client_id": valid_client, "username": "test", "password": "test", From eb4e8c7df653fe2b4a580eb1822d6878b44c2da4 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 18:58:34 -0800 Subject: [PATCH 10/28] Update auth tests to address code changes Fix error in token generation logic Update exception handling for proper JWTs --- neon_hana/auth/client_manager.py | 26 +++++-- tests/test_auth.py | 129 ++++++++----------------------- 2 files changed, 52 insertions(+), 103 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index c28e6c9..206d9fa 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -33,7 +33,7 @@ from typing import Dict, Optional from fastapi import Request, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from jwt import DecodeError +from jwt import DecodeError, ExpiredSignatureError from ovos_utils import LOG from token_throttler import TokenThrottler, TokenBucket from token_throttler.storage import RuntimeStorage @@ -84,9 +84,11 @@ def _create_tokens(self, client_id: str, token_name: Optional[str] = None, permissions: Optional[PermissionsConfig] = None, - **kwargs) -> (HanaToken, HanaToken, TokenConfig): + **kwargs) -> (str, str, TokenConfig): token_id = str(uuid4()) - creation_timestamp = round(time()) + # Subtract a second from creation so the token may be used immediately + # upon return + creation_timestamp = round(time()) - 1 expiration_timestamp = creation_timestamp + self._access_token_lifetime refresh_expiration_timestamp = creation_timestamp + self._refresh_token_lifetime permissions = permissions or PermissionsConfig(core=AccessRoles.GUEST, @@ -111,7 +113,10 @@ def _create_tokens(self, client_id=client_id, roles=permissions.to_roles(), purpose="refresh") - + access_token = jwt.encode(access_token_data.model_dump(), + self._access_secret, self._jwt_algo) + refresh_token = jwt.encode(refresh_token_data.model_dump(), + self._refresh_secret, self._jwt_algo) token_config = TokenConfig(token_name=token_name, token_id=token_id, user_id=user_id, @@ -120,7 +125,7 @@ def _create_tokens(self, refresh_expiration_timestamp=refresh_expiration_timestamp, creation_timestamp=creation_timestamp, last_refresh_timestamp=creation_timestamp) - return access_token_data, refresh_token_data, token_config + return access_token, refresh_token, token_config def check_connect_stream(self) -> bool: """ @@ -220,10 +225,14 @@ def check_refresh_request(self, access_token: str, refresh_token: str, self._jwt_algo)) token_data = HanaToken(**jwt.decode(access_token, self._access_secret, - self._jwt_algo)) + self._jwt_algo, + leeway=self._refresh_token_lifetime)) except DecodeError: raise HTTPException(status_code=400, - detail="Invalid refresh token supplied") + detail="Invalid token supplied") + except ExpiredSignatureError: + raise HTTPException(status_code=401, + detail="Refresh token is expired") if refresh_data.jti != token_data.jti + ".refresh": raise HTTPException(status_code=403, detail="Refresh and access token mismatch") @@ -306,6 +315,9 @@ def validate_auth(self, token: str, origin_ip: str) -> bool: except DecodeError: # Invalid token supplied pass + except ExpiredSignatureError: + # Expired token + pass return False diff --git a/tests/test_auth.py b/tests/test_auth.py index 73b0ae8..655c73d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -25,7 +25,7 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import unittest -from time import time +from time import time, sleep from uuid import uuid4 from fastapi import HTTPException @@ -34,7 +34,7 @@ class TestClientManager(unittest.TestCase): from neon_hana.auth.client_manager import ClientManager client_manager = ClientManager({"access_token_secret": "a800445648142061fc238d1f84e96200da87f4f9f784108ac90db8b4391b117b", - "refresh_token_secret": "a800445648142061fc238d1f84e96200da87f4f9f784108ac90db8b4391b117b", + "refresh_token_secret": "a800445648142061fc238d1f84e96200da87f4f9f784108ac90db8b4391ba800", "disable_auth": False}) def test_check_auth_request(self): @@ -67,20 +67,24 @@ def test_check_auth_request(self): self.client_manager.check_auth_request(**request_2)) def test_validate_auth(self): + # Test valid client valid_client = str(uuid4()) - invalid_client = str(uuid4()) auth_response = self.client_manager.check_auth_request( - username="valid", client_id=valid_client)['access_token'] - + username="valid", client_id=valid_client).access_token self.assertTrue(self.client_manager.validate_auth(auth_response, "127.0.0.1")) + + # Unauthenticated client fails + invalid_client = str(uuid4()) self.assertFalse(self.client_manager.validate_auth(invalid_client, "127.0.0.1")) - # TODO: Update token data - expired_token = self.client_manager._create_tokens( - {"client_id": invalid_client, "username": "test", - "password": "test", "expire": time(), - "permissions": {}})['access_token'] + # Test expired token fails auth + self.client_manager._access_token_lifetime = 1 + self.client_manager._refresh_token_lifetime = 1 + expired_token, _, _ = self.client_manager._create_tokens( + user_id=str(uuid4()), + client_id=str(uuid4())) + sleep(1) self.assertFalse(self.client_manager.validate_auth(expired_token, "127.0.0.1")) @@ -93,118 +97,51 @@ def test_validate_auth(self): def test_check_refresh_request(self): valid_client = str(uuid4()) - # TODO: Update token data - tokens = self.client_manager._create_tokens({"client_id": valid_client, - "username": "test", - "password": "test", - "expire": time(), - "permissions": {}}) - self.assertEqual(tokens['client_id'], valid_client) + self.client_manager._access_token_lifetime = 60 + self.client_manager._refresh_token_lifetime = 3600 + access, refresh, config = self.client_manager._create_tokens( + user_id=str(uuid4()), client_id=valid_client) + access2, refresh2, config2 = self.client_manager._create_tokens( + user_id=str(uuid4()), client_id=str(uuid4())) + self.assertEqual(config.client_id, valid_client) # Test invalid refresh token with self.assertRaises(HTTPException) as e: - self.client_manager.check_refresh_request(tokens['access_token'], - valid_client, + self.client_manager.check_refresh_request(access, access, valid_client) self.assertEqual(e.exception.status_code, 400) # Test incorrect access token with self.assertRaises(HTTPException) as e: - self.client_manager.check_refresh_request(tokens['refresh_token'], - tokens['refresh_token'], + self.client_manager.check_refresh_request(access2, refresh, valid_client) self.assertEqual(e.exception.status_code, 403) # Test invalid client_id with self.assertRaises(HTTPException) as e: - self.client_manager.check_refresh_request(tokens['access_token'], - tokens['refresh_token'], + self.client_manager.check_refresh_request(access, refresh, str(uuid4())) self.assertEqual(e.exception.status_code, 403) # Test valid refresh valid_refresh = self.client_manager.check_refresh_request( - tokens['access_token'], tokens['refresh_token'], - tokens['client_id']) - self.assertEqual(valid_refresh['client_id'], tokens['client_id']) - self.assertNotEqual(valid_refresh['access_token'], - tokens['access_token']) - self.assertNotEqual(valid_refresh['refresh_token'], - tokens['refresh_token']) + access, refresh, config.client_id) + self.assertEqual(valid_refresh.client_id, config.client_id) + self.assertNotEqual(valid_refresh.access_token, access) + self.assertNotEqual(valid_refresh.refresh_token, refresh) # Test expired refresh token real_refresh = self.client_manager._refresh_token_lifetime self.client_manager._refresh_token_lifetime = 0 - # TODO: Update token data - tokens = self.client_manager._create_tokens({"client_id": valid_client, - "username": "test", - "password": "test", - "expire": time(), - "permissions": {}}) + + access, refresh, config = self.client_manager._create_tokens( + user_id=str(uuid4()), client_id=valid_client) with self.assertRaises(HTTPException) as e: - self.client_manager.check_refresh_request(tokens['access_token'], - tokens['refresh_token'], - tokens['client_id']) + self.client_manager.check_refresh_request(access, refresh, + config.client_id) self.assertEqual(e.exception.status_code, 401) self.client_manager._refresh_token_lifetime = real_refresh - def test_get_permissions(self): - from neon_hana.auth.permissions import ClientPermissions - - node_user = "node_test" - rest_user = "rest_user" - self.client_manager._node_username = node_user - self.client_manager._node_password = node_user - - rest_resp = self.client_manager.check_auth_request(rest_user, rest_user) - node_resp = self.client_manager.check_auth_request(node_user, node_user, - node_user) - node_fail = self.client_manager.check_auth_request("node_fail", - node_user, rest_user) - - rest_cid = rest_resp['client_id'] - node_cid = node_resp['client_id'] - fail_cid = node_fail['client_id'] - - permissive = ClientPermissions(True, True, True) - no_node = ClientPermissions(True, True, False) - no_perms = ClientPermissions(False, False, False) - - # Auth disabled, returns all True - self.client_manager._disable_auth = True - self.assertEqual(self.client_manager.get_permissions(rest_cid), - permissive) - self.assertEqual(self.client_manager.get_permissions(node_cid), - permissive) - self.assertEqual(self.client_manager.get_permissions(rest_cid), - permissive) - self.assertEqual(self.client_manager.get_permissions(fail_cid), - permissive) - self.assertEqual(self.client_manager.get_permissions("fake_user"), - permissive) - - # Auth enabled - self.client_manager._disable_auth = False - self.assertEqual(self.client_manager.get_permissions(rest_cid), no_node) - self.assertEqual(self.client_manager.get_permissions(node_cid), - permissive) - self.assertEqual(self.client_manager.get_permissions(fail_cid), no_node) - self.assertEqual(self.client_manager.get_permissions("fake_user"), - no_perms) - - def test_client_permissions(self): - from neon_hana.auth.permissions import ClientPermissions - default_perms = ClientPermissions() - restricted_perms = ClientPermissions(False, False, False) - permissive_perms = ClientPermissions(True, True, True) - self.assertIsInstance(default_perms.as_dict(), dict) - for v in default_perms.as_dict().values(): - self.assertIsInstance(v, bool) - self.assertIsInstance(restricted_perms.as_dict(), dict) - self.assertFalse(any([v for v in restricted_perms.as_dict().values()])) - self.assertIsInstance(permissive_perms.as_dict(), dict) - self.assertTrue(all([v for v in permissive_perms.as_dict().values()])) - def test_stream_connections(self): # Test configured maximum self.client_manager._max_streaming_clients = 1 From 28fc8db977243e05f62834c03271f9b05a7fbcdd Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 19:03:49 -0800 Subject: [PATCH 11/28] Update `neon-data-models` dependency to validate changes --- requirements/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 298dcb5..b40b58d 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ token-throttler~=1.4 neon-mq-connector~=0.7 ovos-config~=0.0,>=0.0.12 ovos-utils~=0.0,>=0.0.38 -neon-data-models \ No newline at end of file +neon-data-models @ git+https://github.com/neongeckocom/neon-data-models@FEAT_JWTModelAndTokenConfigUpdates \ No newline at end of file From 701b26477ffcbd26ea42a673a3e21466d2396ff7 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 4 Nov 2024 19:14:21 -0800 Subject: [PATCH 12/28] Resolve test failures --- tests/test_auth.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 655c73d..f1b5ed3 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -47,24 +47,25 @@ def test_check_auth_request(self): # Check simple auth auth_resp_1 = self.client_manager.check_auth_request(**request_1) - self.assertEqual(self.client_manager.authorized_clients[client_1], - auth_resp_1) - self.assertEqual(auth_resp_1['username'], 'guest') - self.assertEqual(auth_resp_1['client_id'], client_1) + # self.assertEqual(self.client_manager.authorized_clients[client_1], + # auth_resp_1.access_token) + self.assertEqual(auth_resp_1.username, 'guest') + self.assertEqual(auth_resp_1.client_id, client_1) # Check auth from different client auth_resp_2 = self.client_manager.check_auth_request(**request_2) self.assertNotEquals(auth_resp_1, auth_resp_2) - self.assertEqual(self.client_manager.authorized_clients[client_2], - auth_resp_2) - self.assertEqual(auth_resp_2['username'], 'guest') - self.assertEqual(auth_resp_2['client_id'], client_2) + # self.assertEqual(self.client_manager.authorized_clients[client_2], + # auth_resp_2.access_token) + self.assertEqual(auth_resp_2.username, 'guest') + self.assertEqual(auth_resp_2.client_id, client_2) # TODO: Test permissions - # Check auth already authorized - self.assertEqual(auth_resp_2, - self.client_manager.check_auth_request(**request_2)) + # Check auth already authorized. New tokens are generated with new + # expirations + self.assertNotEqual(auth_resp_2, + self.client_manager.check_auth_request(**request_2)) def test_validate_auth(self): # Test valid client From fd172a5f7cdbef177a69ac12c1fb1df56f82ea3c Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 5 Nov 2024 09:06:34 -0800 Subject: [PATCH 13/28] Update `authorized_clients` handling to partially restore backwards-compat. --- neon_hana/app/routers/util.py | 2 ++ neon_hana/auth/client_manager.py | 48 +++++++++++++++++++++----------- tests/test_auth.py | 8 +++--- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/neon_hana/app/routers/util.py b/neon_hana/app/routers/util.py index 2d62d94..ed3a9b9 100644 --- a/neon_hana/app/routers/util.py +++ b/neon_hana/app/routers/util.py @@ -50,6 +50,7 @@ async def api_client_ip(request: Request) -> str: # Reported host is a hostname, not an IP address. Return a generic # loopback value ip_addr = "127.0.0.1" + # Validation will fail, but this increments the rate-limiting client_manager.validate_auth("", ip_addr) return ip_addr @@ -57,5 +58,6 @@ async def api_client_ip(request: Request) -> str: @util_route.get("/headers") async def api_headers(request: Request): ip_addr = request.client.host if request.client else "127.0.0.1" + # Validation will fail, but this increments the rate-limiting client_manager.validate_auth("", ip_addr) return request.headers diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 206d9fa..66339ed 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -58,10 +58,9 @@ def __init__(self, config: dict, mq_connector: Optional[MQServiceManager] = None): self.rate_limiter = TokenThrottler(cost=1, storage=RuntimeStorage()) - # TODO: Is `authorized_clients` useful to track? # Keep a dict of `client_id` to auth tokens that have authenticated to # this instance - self.authorized_clients: Dict[str, HanaToken] = dict() + self._authorized_clients: Dict[str, AuthenticationResponse] = dict() self._access_token_lifetime = config.get("access_token_ttl", 3600 * 24) self._refresh_token_lifetime = config.get("refresh_token_ttl", 3600 * 24 * 90) @@ -79,6 +78,16 @@ def __init__(self, config: dict, self._stream_check_lock = Lock() self._mq_connector = mq_connector + @property + def authorized_clients(self) -> Dict[str, AuthenticationResponse]: + """ + Dict of `client_id` to `AuthenticationResponse` objects for clients + known by this instance. NOTE: Refresh tokens are not reliably stored + here and should never be retrievable after generation for security. + """ + # TODO: Is `authorized_clients` useful to track? + return self._authorized_clients + def _create_tokens(self, user_id: str, client_id: str, @@ -92,9 +101,9 @@ def _create_tokens(self, expiration_timestamp = creation_timestamp + self._access_token_lifetime refresh_expiration_timestamp = creation_timestamp + self._refresh_token_lifetime permissions = permissions or PermissionsConfig(core=AccessRoles.GUEST, - diana=AccessRoles.GUEST, - node=AccessRoles.GUEST, - llm=AccessRoles.GUEST) + diana=AccessRoles.GUEST, + node=AccessRoles.GUEST, + llm=AccessRoles.GUEST) token_name = token_name or kwargs.get("name") or \ datetime.fromtimestamp(creation_timestamp).isoformat() access_token_data = HanaToken(iss=self._jwt_issuer, @@ -174,9 +183,9 @@ def check_auth_request(self, client_id: str, username: str, @param origin_ip: Origin IP address of request @return: response tokens, permissions, and other metadata """ - # if client_id in self.authorized_clients: - # print(f"Using cached client: {self.authorized_clients[client_id]}") - # return self.authorized_clients[client_id] + if client_id in self.authorized_clients: + print(f"Using cached client: {self.authorized_clients[client_id]}") + return self.authorized_clients[client_id] ratelimit_id = f"auth{origin_ip}" if not self.rate_limiter.get_all_buckets(ratelimit_id): @@ -208,13 +217,15 @@ def check_auth_request(self, client_id: str, username: str, "token_name": token_name, "last_refresh_timestamp": create_time} access, refresh, config = self._create_tokens(**encode_data) - self.authorized_clients[client_id] = config + + auth_response = AuthenticationResponse(username=user.username, + client_id=client_id, + access_token=access, + refresh_token=refresh, + expiration=config.refresh_expiration_timestamp) + self.authorized_clients[client_id] = auth_response self._add_token_to_userdb(user, config) - return AuthenticationResponse(username=user.username, - client_id=client_id, - access_token=access, - refresh_token=refresh, - expiration=config.refresh_expiration_timestamp) + return auth_response def check_refresh_request(self, access_token: str, refresh_token: str, client_id: str) -> AuthenticationResponse: @@ -263,11 +274,14 @@ def check_refresh_request(self, access_token: str, refresh_token: str, else: username = token_data.sub access, refresh, config = self._create_tokens(**encode_data) - return AuthenticationResponse(username=username, + + auth_response = AuthenticationResponse(username=username, client_id=client_id, access_token=access, refresh_token=refresh, expiration=config.refresh_expiration_timestamp) + self._authorized_clients[client_id] = auth_response + return auth_response def _add_token_to_userdb(self, user: User, new_token: TokenConfig): if self._mq_connector is None: @@ -310,7 +324,9 @@ def validate_auth(self, token: str, origin_ip: str) -> bool: if auth.exp < time(): self.authorized_clients.pop(auth.client_id, None) return False - self.authorized_clients[auth.client_id] = auth + self.authorized_clients[auth.client_id] = AuthenticationResponse( + username=auth.sub, client_id=auth.client_id, access_token=token, + refresh_token="", expiration=auth.exp) return True except DecodeError: # Invalid token supplied diff --git a/tests/test_auth.py b/tests/test_auth.py index f1b5ed3..0d46b3c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -47,16 +47,16 @@ def test_check_auth_request(self): # Check simple auth auth_resp_1 = self.client_manager.check_auth_request(**request_1) - # self.assertEqual(self.client_manager.authorized_clients[client_1], - # auth_resp_1.access_token) + self.assertEqual(self.client_manager.authorized_clients[client_1], + auth_resp_1) self.assertEqual(auth_resp_1.username, 'guest') self.assertEqual(auth_resp_1.client_id, client_1) # Check auth from different client auth_resp_2 = self.client_manager.check_auth_request(**request_2) self.assertNotEquals(auth_resp_1, auth_resp_2) - # self.assertEqual(self.client_manager.authorized_clients[client_2], - # auth_resp_2.access_token) + self.assertEqual(self.client_manager.authorized_clients[client_2], + auth_resp_2) self.assertEqual(auth_resp_2.username, 'guest') self.assertEqual(auth_resp_2.client_id, client_2) From b98e2f2355da18dd7876d0ae04a7eebbd0239ba6 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 5 Nov 2024 09:16:34 -0800 Subject: [PATCH 14/28] Restore original test cases Add `__getitem__` to `AuthenticationResponse` for backwards-compat. --- neon_hana/auth/client_manager.py | 9 ++++++--- neon_hana/schema/auth_requests.py | 4 ++++ tests/test_auth.py | 8 ++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 66339ed..29fb8ce 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -183,9 +183,12 @@ def check_auth_request(self, client_id: str, username: str, @param origin_ip: Origin IP address of request @return: response tokens, permissions, and other metadata """ - if client_id in self.authorized_clients: - print(f"Using cached client: {self.authorized_clients[client_id]}") - return self.authorized_clients[client_id] + # Caching does not work here because there is no guarantee that this + # instance knows the client's refresh token. One client may also want + # to generate multiple tokens. + # if client_id in self.authorized_clients: + # print(f"Using cached client: {self.authorized_clients[client_id]}") + # return self.authorized_clients[client_id] ratelimit_id = f"auth{origin_ip}" if not self.rate_limiter.get_all_buckets(ratelimit_id): diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index e2e1619..5032b51 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -65,6 +65,10 @@ class AuthenticationResponse(BaseModel): "expiration": 1706045776.4168212 }]}} + def __getitem__(self, item): + if hasattr(self, item): + return getattr(self, item) + raise KeyError(item) class RefreshRequest(BaseModel): access_token: str diff --git a/tests/test_auth.py b/tests/test_auth.py index 0d46b3c..7e2c862 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -49,16 +49,16 @@ def test_check_auth_request(self): auth_resp_1 = self.client_manager.check_auth_request(**request_1) self.assertEqual(self.client_manager.authorized_clients[client_1], auth_resp_1) - self.assertEqual(auth_resp_1.username, 'guest') - self.assertEqual(auth_resp_1.client_id, client_1) + self.assertEqual(auth_resp_1['username'], 'guest') + self.assertEqual(auth_resp_1['client_id'], client_1) # Check auth from different client auth_resp_2 = self.client_manager.check_auth_request(**request_2) self.assertNotEquals(auth_resp_1, auth_resp_2) self.assertEqual(self.client_manager.authorized_clients[client_2], auth_resp_2) - self.assertEqual(auth_resp_2.username, 'guest') - self.assertEqual(auth_resp_2.client_id, client_2) + self.assertEqual(auth_resp_2['username'], 'guest') + self.assertEqual(auth_resp_2['client_id'], client_2) # TODO: Test permissions From b72a4b221381bd105e8941516c7d1494abb3bc32 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Tue, 5 Nov 2024 09:37:24 -0800 Subject: [PATCH 15/28] Deprecate configured `node_username`/`node_password` and annotate `disable_auth` config Use `disable_auth` config to skip MQ Users service connection --- README.md | 4 +--- neon_hana/auth/client_manager.py | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c378839..074b470 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,10 @@ hana: jwt_issuer: neon.ai # Used in the `iss` field of generated JWT tokens. fastapi_title: "My HANA API Host" fastapi_summary: "Personal HTTP API to access my DIANA backend." - disable_auth: True + disable_auth: True # If true, no authentication will be attempted; all connections will be allowed stt_max_length_encoded: 500000 # Arbitrary limit that is larger than any expected voice command tts_max_words: 128 # Arbitrary limit that is longer than any default LLM token limit enable_email: True # Disabled by default; anyone with access to the API will be able to send emails from the configured address - node_username: node_user # Username to authenticate Node API access; leave empty to disable Node API access - node_password: node_password # Password associated with node_username max_streaming_clients: -1 # Maximum audio streaming clients allowed (including 0). Default unset value allows infinite clients ``` It is recommended to generate unique values for configured tokens, these are 32 diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 29fb8ce..c6e8a23 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -70,13 +70,13 @@ def __init__(self, config: dict, self._rpm = config.get("requests_per_minute", 60) self._auth_rpm = config.get("auth_requests_per_minute", 6) self._disable_auth = config.get("disable_auth") - self._node_username = config.get("node_username") - self._node_password = config.get("node_password") self._max_streaming_clients = config.get("max_streaming_clients") self._jwt_algo = "HS256" self._connected_streams = 0 self._stream_check_lock = Lock() - self._mq_connector = mq_connector + # If authentication is explicitly disabled, don't try to query the + # users service + self._mq_connector = None if self._disable_auth else mq_connector @property def authorized_clients(self) -> Dict[str, AuthenticationResponse]: @@ -205,11 +205,15 @@ def check_auth_request(self, client_id: str, username: str, f"{origin_ip}. Wait {wait_time}s.") if self._mq_connector is None: - user = User(username=username, password_hash=password) - elif all((self._node_username, username == self._node_username, - password == self._node_password)): - user = User(username=username, password_hash=password) - user.permissions.node = AccessRoles.USER + # Auth is disabled; every auth request gets a successful response + user = User(username=username, password_hash=password, + permissions=_DEFAULT_USER_PERMISSIONS) + # elif all((self._node_username, username == self._node_username, + # password == self._node_password)): + # # User matches configured node username/password + # user = User(username=username, password_hash=password, + # permissions=_DEFAULT_USER_PERMISSIONS) + # user.permissions.node = AccessRoles.USER else: user = self._mq_connector.get_user_profile(username, password) From 993ad09191eaa0f3be405334d8dca9500776d835 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 14:17:40 -0800 Subject: [PATCH 16/28] Mark refactored import paths for deprecation Deprecate `node_v1` schema that is moved to `neon-data-models` --- neon_hana/app/routers/node_server.py | 19 +++++++++++-------- neon_hana/schema/auth_requests.py | 1 + neon_hana/schema/node_model.py | 3 ++- neon_hana/schema/node_v1.py | 27 --------------------------- neon_hana/schema/user_profile.py | 1 + 5 files changed, 15 insertions(+), 36 deletions(-) delete mode 100644 neon_hana/schema/node_v1.py diff --git a/neon_hana/app/routers/node_server.py b/neon_hana/app/routers/node_server.py index c7acd3f..3a2782a 100644 --- a/neon_hana/app/routers/node_server.py +++ b/neon_hana/app/routers/node_server.py @@ -26,7 +26,6 @@ from asyncio import Event from signal import signal, SIGINT -from time import sleep from typing import Optional, Union from fastapi import APIRouter, WebSocket, HTTPException @@ -36,13 +35,17 @@ from neon_hana.app.dependencies import config, client_manager from neon_hana.mq_websocket_api import MQWebsocketAPI, ClientNotKnown -from neon_hana.schema.node_v1 import (NodeAudioInput, NodeGetStt, - NodeGetTts, NodeKlatResponse, - NodeAudioInputResponse, - NodeGetSttResponse, - NodeGetTtsResponse, CoreWWDetected, - CoreIntentFailure, CoreErrorResponse, - CoreClearData, CoreAlertExpired) +from neon_data_models.models.api.node_v1 import (NodeAudioInput, NodeGetStt, + NodeGetTts, NodeKlatResponse, + NodeAudioInputResponse, + NodeGetSttResponse, + NodeGetTtsResponse, + CoreWWDetected, + CoreIntentFailure, + CoreErrorResponse, + CoreClearData, + CoreAlertExpired) + node_route = APIRouter(prefix="/node", tags=["node"]) socket_api = MQWebsocketAPI(config) diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index 5032b51..a0acda9 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -70,6 +70,7 @@ def __getitem__(self, item): return getattr(self, item) raise KeyError(item) + class RefreshRequest(BaseModel): access_token: str refresh_token: str diff --git a/neon_hana/schema/node_model.py b/neon_hana/schema/node_model.py index c7baf73..7345497 100644 --- a/neon_hana/schema/node_model.py +++ b/neon_hana/schema/node_model.py @@ -24,4 +24,5 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from neon_data_models.models.client.node import * +from neon_data_models.models.client.node import NodeSoftware, NodeNetworking, NodeLocation, NodeData +# TODO: Mark for deprecation diff --git a/neon_hana/schema/node_v1.py b/neon_hana/schema/node_v1.py deleted file mode 100644 index 6067be0..0000000 --- a/neon_hana/schema/node_v1.py +++ /dev/null @@ -1,27 +0,0 @@ -# NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System -# All trademark and other rights reserved by their respective owners -# Copyright 2008-2024 Neongecko.com Inc. -# BSD-3 -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# 1. Redistributions of source code must retain the above copyright notice, -# this list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# 3. Neither the name of the copyright holder nor the names of its -# contributors may be used to endorse or promote products derived from this -# software without specific prior written permission. -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, -# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from neon_data_models.models.api.node_v1 import * diff --git a/neon_hana/schema/user_profile.py b/neon_hana/schema/user_profile.py index 47d4e14..85f5f61 100644 --- a/neon_hana/schema/user_profile.py +++ b/neon_hana/schema/user_profile.py @@ -25,3 +25,4 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from neon_data_models.models.user.neon_profile import * +# TODO: Mark for deprecation From 23cef465cc0fdd0b19db5e52faf301bc60dd2d87 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 15:30:21 -0800 Subject: [PATCH 17/28] Fix bug that reset tokens' `creation_timestamp` upon refresh Update imports to use `neon_data_models` Mark old imports in `schema` as deprecated Better document usage, including token management --- README.md | 28 ++++++++++++++++++++++++---- neon_hana/auth/client_manager.py | 5 +++-- neon_hana/mq_service_api.py | 4 ++-- neon_hana/schema/assist_requests.py | 4 ++-- neon_hana/schema/node_model.py | 5 ++++- neon_hana/schema/user_profile.py | 5 ++++- 6 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 074b470..5e3e5ba 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,27 @@ docker run -p 8080:8080 -v ~/.config/neon:/config/neon ghcr.io/neongeckocom/neon are using the default port 8080 ## Usage -Full API documentation is available at `/docs`. The `/auth/login` endpoint should -be used to generate a `client_id`, `access_token`, and `refresh_token`. The -`access_token` should be included in every request and upon expiration of the -`access_token`, a new token can be obtained from the `auth/refresh` endpoint. +Full API documentation is available at `/docs`. + +### Registration +The `/auth/register` endpoint may be used to create a new user if auth is enabled. +If auth is disabled, any login requests will return a successful response. + +### Token Generation +The `/auth/login` endpoint should be used to generate a `client_id`, +`access_token`, and `refresh_token`. The `access_token` should be included in +every request and upon expiration of the `access_token`, a new token can be +obtained from the `auth/refresh` endpoint. Tokens are client-specific and clients +are expected to include the same `client_id` and valid tokens for that client +with every request. + +### Token Management +`access_token`s should not be saved to persistent storage; they are only valid +for a short period of time and a new `access_token` should be generated for +every new session. + +`refresh_token`s should be saved to persistent storage and used to generate a new +`access_token` and `refresh_token` at the beginning of a session, or when the +current `access_token` expires. A `refresh_token` may only be used once; a new +`refresh_token` returned from the `/auth/refresh` endpoint will replace the one +included in the request. diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index c6e8a23..ab8045a 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -295,10 +295,11 @@ def _add_token_to_userdb(self, user: User, new_token: TokenConfig): print("No MQ Connection to a user database") return for idx, token in enumerate(user.tokens): + # If the token is already defined, maintain the original + # token_id and creation timestamp if token.token_id == new_token.token_id: - # Tokens don't contain `token_name`, so use the same one as is - # being replaced new_token.token_name = token.token_name + new_token.creation_timestamp = token.creation_timestamp user.tokens.remove(token) user.tokens.append(new_token) self._mq_connector.update_user(user) diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index 5f96529..49bcd88 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -31,9 +31,9 @@ from uuid import uuid4 from fastapi import HTTPException -from neon_hana.schema.node_model import NodeData -from neon_hana.schema.user_profile import UserProfile from neon_mq_connector.utils.client_utils import send_mq_request +from neon_data_models.models.client.node import NodeData +from neon_data_models.models.user.neon_profile import UserProfile from neon_data_models.models.user import User diff --git a/neon_hana/schema/assist_requests.py b/neon_hana/schema/assist_requests.py index 7af09b7..472b708 100644 --- a/neon_hana/schema/assist_requests.py +++ b/neon_hana/schema/assist_requests.py @@ -27,8 +27,8 @@ from typing import List, Optional from pydantic import BaseModel -from neon_hana.schema.node_model import NodeData -from neon_hana.schema.user_profile import UserProfile +from neon_data_models.models.client.node import NodeData +from neon_data_models.models.user.neon_profile import UserProfile class STTRequest(BaseModel): diff --git a/neon_hana/schema/node_model.py b/neon_hana/schema/node_model.py index 7345497..b9f0080 100644 --- a/neon_hana/schema/node_model.py +++ b/neon_hana/schema/node_model.py @@ -25,4 +25,7 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from neon_data_models.models.client.node import NodeSoftware, NodeNetworking, NodeLocation, NodeData -# TODO: Mark for deprecation +from ovos_utils.log import log_deprecation + +log_deprecation('Imports moved to `neon_data_models.models.client.node`', + '1.0.0') diff --git a/neon_hana/schema/user_profile.py b/neon_hana/schema/user_profile.py index 85f5f61..231c39b 100644 --- a/neon_hana/schema/user_profile.py +++ b/neon_hana/schema/user_profile.py @@ -25,4 +25,7 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from neon_data_models.models.user.neon_profile import * -# TODO: Mark for deprecation +from ovos_utils.log import log_deprecation + +log_deprecation('Imports moved to `neon_data_models.models.user.neon_profile`', + '1.0.0') From 27e1caaf4a0d2e8cb9bdf605851a777cc1ea46f2 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 6 Nov 2024 15:45:21 -0800 Subject: [PATCH 18/28] Refactor refresh endpoint handler to use only refresh token instead of also requiring the (potentially expired) auth token --- neon_hana/auth/client_manager.py | 40 ++++++++++++++++++------------- neon_hana/schema/auth_requests.py | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index ab8045a..9d132e6 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -234,42 +234,48 @@ def check_auth_request(self, client_id: str, username: str, self._add_token_to_userdb(user, config) return auth_response - def check_refresh_request(self, access_token: str, refresh_token: str, + def check_refresh_request(self, access_token: Optional[str], + refresh_token: str, client_id: str) -> AuthenticationResponse: # Read and validate refresh token try: refresh_data = HanaToken(**jwt.decode(refresh_token, self._refresh_secret, self._jwt_algo)) - token_data = HanaToken(**jwt.decode(access_token, - self._access_secret, - self._jwt_algo, - leeway=self._refresh_token_lifetime)) + # token_data = HanaToken(**jwt.decode(access_token, + # self._access_secret, + # self._jwt_algo)) + if refresh_data.purpose != "refresh": + raise HTTPException(status_code=400, + detail="Supplied refresh token not valid") + # if token_data.purpose != "access": + # raise HTTPException(status_code=400, + # detail="Supplied refresh token not valid") except DecodeError: raise HTTPException(status_code=400, detail="Invalid token supplied") except ExpiredSignatureError: raise HTTPException(status_code=401, detail="Refresh token is expired") - if refresh_data.jti != token_data.jti + ".refresh": - raise HTTPException(status_code=403, - detail="Refresh and access token mismatch") + # if refresh_data.jti != token_data.jti + ".refresh": + # raise HTTPException(status_code=403, + # detail="Refresh and access token mismatch") if time() > refresh_data.exp: raise HTTPException(status_code=401, detail="Refresh token is expired") - if token_data.client_id != client_id: + if refresh_data.client_id != client_id: raise HTTPException(status_code=403, detail="Access token does not match client_id") # `token_name` is not known here, but it will be read from the database # when the new token replaces the old one - encode_data = {"user_id": token_data.sub, + encode_data = {"user_id": refresh_data.sub, "client_id": client_id, - "permissions": PermissionsConfig.from_roles(token_data.roles) + "permissions": PermissionsConfig.from_roles(refresh_data.roles) } if self._mq_connector: - user = self._mq_connector.get_user_profile(username=token_data.sub, + user = self._mq_connector.get_user_profile(username=refresh_data.sub, access_token=refresh_token) if not user.password_hash: # This should not be possible, but don't let an error in the @@ -279,14 +285,14 @@ def check_refresh_request(self, access_token: str, refresh_token: str, username = user.username self._add_token_to_userdb(user, config) else: - username = token_data.sub + username = refresh_data.sub access, refresh, config = self._create_tokens(**encode_data) auth_response = AuthenticationResponse(username=username, - client_id=client_id, - access_token=access, - refresh_token=refresh, - expiration=config.refresh_expiration_timestamp) + client_id=client_id, + access_token=access, + refresh_token=refresh, + expiration=config.refresh_expiration_timestamp) self._authorized_clients[client_id] = auth_response return auth_response diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index a0acda9..4aa6acc 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -72,7 +72,7 @@ def __getitem__(self, item): class RefreshRequest(BaseModel): - access_token: str + access_token: Optional[str] = None refresh_token: str client_id: str From 299ff22a0b5b8d8f61b6a28f0239dff05ef6caae Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 8 Nov 2024 11:46:46 -0800 Subject: [PATCH 19/28] Add rate limiting to `/register` endpoint and update documented example config Refactor rate limiting to consolidate code Refactor rate limit buckets to be semantically consistent --- README.md | 3 ++- neon_hana/app/routers/auth.py | 6 +++-- neon_hana/auth/client_manager.py | 45 ++++++++++++++++++++------------ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5e3e5ba..41d25a1 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ hana: mq_default_timeout: 10 access_token_ttl: 86400 # 1 day refresh_token_ttl: 604800 # 1 week - requests_per_minute: 60 + requests_per_minute: 60 # All other requests (auth, registration, etc) also count towards this limit auth_requests_per_minute: 6 # This counts valid and invalid requests from an IP address + registration_requests_per_hour: 4 # This is low to prevent malicious user creation that will pollute the database access_token_secret: a800445648142061fc238d1f84e96200da87f4f9fa7835cac90db8b4391b117b refresh_token_secret: 833d369ac73d883123743a44b4a7fe21203cffc956f4c8fec712e71aafa8e1aa jwt_issuer: neon.ai # Used in the `iss` field of generated JWT tokens. diff --git a/neon_hana/app/routers/auth.py b/neon_hana/app/routers/auth.py index 46b1005..605db9b 100644 --- a/neon_hana/app/routers/auth.py +++ b/neon_hana/app/routers/auth.py @@ -47,5 +47,7 @@ async def check_refresh(request: RefreshRequest) -> AuthenticationResponse: @auth_route.post("/register") -async def register_user(request: RegistrationRequest) -> User: - return client_manager.check_registration_request(**dict(request)) +async def register_user(register_request: RegistrationRequest, + request: Request) -> User: + return client_manager.check_registration_request(**dict(register_request), + origin_ip=request.client.host) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 9d132e6..b8694d0 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -69,6 +69,7 @@ def __init__(self, config: dict, self._refresh_secret = config.get("refresh_token_secret") self._rpm = config.get("requests_per_minute", 60) self._auth_rpm = config.get("auth_requests_per_minute", 6) + self._register_rph = config.get("registration_requests_per_hour", 4) self._disable_auth = config.get("disable_auth") self._max_streaming_clients = config.get("max_streaming_clients") self._jwt_algo = "HS256" @@ -156,11 +157,31 @@ def disconnect_stream(self): with self._stream_check_lock: self._connected_streams -= 1 + def _consume_rate_limit_token(self, ratelimit_id: str): + if not self.rate_limiter.consume(ratelimit_id): + bucket = list(self.rate_limiter.get_all_buckets(ratelimit_id). + values())[0] + replenish_time = bucket.last_replenished + bucket.replenish_time + wait_time = round(replenish_time - time()) + ip_addr, request_cls = ratelimit_id.split('-', 1) + raise HTTPException(status_code=429, + detail=f"Too many {request_cls} requests from: " + f"{ip_addr}. Wait {wait_time}s.") + def check_registration_request(self, username: str, password: str, - user_config: NeonUserConfig) -> User: + user_config: NeonUserConfig, + origin_ip: str = "127.0.0.1") -> User: """ Handle a request to register a new user. """ + + ratelimit_id = f"{origin_ip}-register" + if not self.rate_limiter.get_all_buckets(ratelimit_id): + self.rate_limiter.add_bucket(ratelimit_id, + TokenBucket(replenish_time=3600, + max_tokens=self._register_rph)) + self._consume_rate_limit_token(ratelimit_id) + new_user = User(username=username, password_hash=password, neon=user_config, permissions=_DEFAULT_USER_PERMISSIONS) if self._mq_connector: @@ -190,19 +211,12 @@ def check_auth_request(self, client_id: str, username: str, # print(f"Using cached client: {self.authorized_clients[client_id]}") # return self.authorized_clients[client_id] - ratelimit_id = f"auth{origin_ip}" + ratelimit_id = f"{origin_ip}-auth" if not self.rate_limiter.get_all_buckets(ratelimit_id): self.rate_limiter.add_bucket(ratelimit_id, TokenBucket(replenish_time=60, max_tokens=self._auth_rpm)) - if not self.rate_limiter.consume(ratelimit_id): - bucket = list(self.rate_limiter.get_all_buckets(ratelimit_id). - values())[0] - replenish_time = bucket.last_replenished + bucket.replenish_time - wait_time = round(replenish_time - time()) - raise HTTPException(status_code=429, - detail=f"Too many auth requests from: " - f"{origin_ip}. Wait {wait_time}s.") + self._consume_rate_limit_token(ratelimit_id) if self._mq_connector is None: # Auth is disabled; every auth request gets a successful response @@ -321,14 +335,13 @@ def get_client_id(self, token: str) -> str: return auth.client_id def validate_auth(self, token: str, origin_ip: str) -> bool: - if not self.rate_limiter.get_all_buckets(origin_ip): - self.rate_limiter.add_bucket(origin_ip, + ratelimit_id = f"{origin_ip}-total" + if not self.rate_limiter.get_all_buckets(ratelimit_id): + self.rate_limiter.add_bucket(ratelimit_id, TokenBucket(replenish_time=60, max_tokens=self._rpm)) - if not self.rate_limiter.consume(origin_ip) and self._rpm > 0: - raise HTTPException(status_code=429, - detail=f"Requests limited to {self._rpm}/min " - f"per client connection") + if self._rpm > 0: + self._consume_rate_limit_token(ratelimit_id) if self._disable_auth: return True From 5c2c6acfe999b021527f80a2088dd889c906efcf Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 8 Nov 2024 16:28:03 -0800 Subject: [PATCH 20/28] Refactor `get_user_profile` to `read_user` for consistency in method names Remove `handle_update_user_request` and use `update_user` directly to consolidate logic Add method to read `user_id` from a token for user update endpoint support Add support for admin authentication to `update_user` endpoint Refactor internal `_query_users_api` method to accept CRUD request objects --- neon_hana/app/routers/user.py | 8 ++- neon_hana/auth/client_manager.py | 17 ++++- neon_hana/mq_service_api.py | 108 +++++++++++++----------------- neon_hana/schema/user_requests.py | 9 ++- 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/neon_hana/app/routers/user.py b/neon_hana/app/routers/user.py index 07c638d..5810c8a 100644 --- a/neon_hana/app/routers/user.py +++ b/neon_hana/app/routers/user.py @@ -35,11 +35,13 @@ @user_route.post("/get") async def get_user(request: GetUserRequest, token: str = Depends(jwt_bearer)) -> User: - return mq_connector.get_user_profile(access_token=token, **dict(request)) + user_id = jwt_bearer.client_manager.get_token_user_id(token) + return mq_connector.read_user(access_token=token, auth_user=user_id, + **dict(request)) @user_route.post("/update") async def update_user(request: UpdateUserRequest, token: str = Depends(jwt_bearer)) -> User: - return mq_connector.handle_update_user_request(access_token=token, - **dict(request)) + return mq_connector.update_user(access_token=token, + **dict(request)) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index b8694d0..4d60e03 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -229,7 +229,7 @@ def check_auth_request(self, client_id: str, username: str, # permissions=_DEFAULT_USER_PERMISSIONS) # user.permissions.node = AccessRoles.USER else: - user = self._mq_connector.get_user_profile(username, password) + user = self._mq_connector.read_user(username, password) create_time = round(time()) encode_data = {"client_id": client_id, @@ -289,8 +289,8 @@ def check_refresh_request(self, access_token: Optional[str], "permissions": PermissionsConfig.from_roles(refresh_data.roles) } if self._mq_connector: - user = self._mq_connector.get_user_profile(username=refresh_data.sub, - access_token=refresh_token) + user = self._mq_connector.read_user(username=refresh_data.sub, + access_token=refresh_token) if not user.password_hash: # This should not be possible, but don't let an error in the # users service allow for injecting a new valid token to the db @@ -334,6 +334,17 @@ def get_client_id(self, token: str) -> str: self._jwt_algo)) return auth.client_id + def get_token_user_id(self, token: str) -> str: + """ + Extract the user_id from a JWT string + @param token: JWT to parse + @retrun: user_id associated with token + """ + auth = HanaToken(**jwt.decode(token, self._access_secret, + self._jwt_algo)) + return auth.user_id + + def validate_auth(self, token: str, origin_ip: str) -> bool: ratelimit_id = f"{origin_ip}-total" if not self.rate_limiter.get_all_buckets(ratelimit_id): diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index 49bcd88..4908a67 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -31,6 +31,8 @@ from uuid import uuid4 from fastapi import HTTPException +from neon_data_models.models.api import CreateUserRequest, ReadUserRequest, \ + UpdateUserRequest, DeleteUserRequest from neon_mq_connector.utils.client_utils import send_mq_request from neon_data_models.models.client.node import NodeData from neon_data_models.models.user.neon_profile import UserProfile @@ -79,31 +81,26 @@ def _validate_api_proxy_response(response: dict, query_params: dict): raise APIError(status_code=code, detail=response['content']) @staticmethod - def _query_users_api(operation: str, username: str, - password: Optional[str] = None, - access_token: Optional[str] = None, - user: Optional[User] = None) -> (bool, int, Union[User, str]): + def _query_users_api(user_db_request: Union[CreateUserRequest, + ReadUserRequest, + UpdateUserRequest, + DeleteUserRequest]) -> \ + (int, Union[User, str]): """ Query the users API and return a status code and either a valid User or a string error message. Authentication may use EITHER a password or a token. - @param operation: Operation to perform (create, read, update, delete) - @param username: Optional username to include - @param password: Optional password to include - @param access_token: Optional auth token to include - @param user: Optional user object to include - @return: success bool, HTTP status code User object or string error message + @param user_db_request: UserDbRequest object describing CRUD operation + to return + @return: success bool, HTTP status code, User object or string error """ response = send_mq_request("/neon_users", - {"operation": operation, - "username": username, - "password": password, - "access_token": access_token, - "user": user.model_dump() if user else None}, - "neon_users_input") + user_db_request.model_dump(exclude={ + "message_id"}), + target_queue="neon_users_input") if response.get("success"): - return True, 200, User(**response.get("user")) - return False, response.get("code", 500), response.get("error", "") + return 200, User(**response.get("user")) + return response.get("code", 500), response.get("error", "") def get_session(self, node_data: NodeData) -> dict: """ @@ -117,8 +114,21 @@ def get_session(self, node_data: NodeData) -> dict: "site_id": node_data.location.site_id}) return self.sessions_by_id[session_id] - def get_user_profile(self, username: str, password: Optional[str] = None, - access_token: Optional[str] = None) -> User: + def create_user(self, user: User) -> User: + """ + Create a new user. + @param user: User object to add to the users service database + @returns: User object added to the database + """ + create_user_request = CreateUserRequest(user=user, message_id="") + code, err_or_user = self._query_users_api(create_user_request) + if code != 200: + raise HTTPException(status_code=code, detail=err_or_user) + return err_or_user + + def read_user(self, username: str, password: Optional[str] = None, + access_token: Optional[str] = None, + auth_user: Optional[str] = None) -> User: """ Get a User object for a user. This requires that a valid password OR access token be provided to prevent arbitrary users from reading @@ -126,56 +136,34 @@ def get_user_profile(self, username: str, password: Optional[str] = None, @param username: Valid username to get a User object for @param password: Valid password to use for authentication @param access_token: Valid access token to use for authentication + @param auth_user: Optional username to use for authentication @returns: User object from the Users service. """ - stat, code, err_or_user = self._query_users_api("read", - username=username, - password=password, - access_token=access_token) - if not stat: + read_user_request = ReadUserRequest(user_spec=username, + auth_user_spec=auth_user, + access_token=access_token, + password=password, message_id="") + code, err_or_user = self._query_users_api(read_user_request) + if code != 200: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user - def create_user(self, user: User) -> User: - """ - Create a new user. - @param user: User object to add to the users service database - @returns: User object added to the database - """ - stat, code, err_or_user = self._query_users_api("create", - username=user.username, - password=user.password_hash, - user=user) - if not stat: - raise HTTPException(status_code=code, detail=err_or_user) - return err_or_user - - def update_user(self, user: User) -> User: + def update_user(self, user: User, + auth_user: Optional[str] = None, + auth_password: Optional[str] = None) -> User: """ Update an existing user in the database. @param user: Updated user object to write + @param auth_user: Username to use for authentication + @param auth_password: Password associated with `auth_user` @returns: User as read from the database """ - stat, code, err_or_user = self._query_users_api("update", - username=user.username, - password=user.password_hash, - user=user) - if not stat: - raise HTTPException(status_code=code, detail=err_or_user) - return err_or_user - - def handle_update_user_request(self, user: User, access_token: str): - """ - Handle a request to update a user. This accepts an `auth_token` to - account for requests to change the password or registered tokens. - @param user: Updated User object to write to the database - @param access_token: JWT auth token submitted with the request - """ - stat, code, err_or_user = self._query_users_api("update", - username=user.username, - access_token=access_token, - user=user) - if not stat: + update_user_request = UpdateUserRequest(user=user, + auth_username=auth_user, + auth_password=auth_password, + message_id="") + code, err_or_user = self._query_users_api(update_user_request) + if code != 200: raise HTTPException(status_code=code, detail=err_or_user) return err_or_user diff --git a/neon_hana/schema/user_requests.py b/neon_hana/schema/user_requests.py index 3311608..3e1618b 100644 --- a/neon_hana/schema/user_requests.py +++ b/neon_hana/schema/user_requests.py @@ -25,6 +25,7 @@ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from pydantic import BaseModel +from typing import Optional from neon_data_models.models.user.database import User @@ -41,9 +42,15 @@ class GetUserRequest(BaseModel): class UpdateUserRequest(BaseModel): user: User + auth_username: Optional[str] = None + auth_password: Optional[str] = None model_config = { "json_schema_extra": { "examples": [{ "user": User(username="guest").model_dump() - }]}} + }, + {"user": User(username="some_user").model_dump(), + "auth_username": "admin_user", + "auth_password": "admin_password"} + ]}} From e1694ba926e850354f80df98c2fd75bdb82f97d7 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 11 Nov 2024 19:08:35 -0800 Subject: [PATCH 21/28] Refactor token handling to use same HanaToken model that is JWT-encoded --- neon_hana/app/routers/user.py | 5 +-- neon_hana/auth/client_manager.py | 52 +++++++++++++++++--------------- neon_hana/mq_service_api.py | 8 +++-- tests/test_auth.py | 5 +-- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/neon_hana/app/routers/user.py b/neon_hana/app/routers/user.py index 5810c8a..059320b 100644 --- a/neon_hana/app/routers/user.py +++ b/neon_hana/app/routers/user.py @@ -35,8 +35,9 @@ @user_route.post("/get") async def get_user(request: GetUserRequest, token: str = Depends(jwt_bearer)) -> User: - user_id = jwt_bearer.client_manager.get_token_user_id(token) - return mq_connector.read_user(access_token=token, auth_user=user_id, + hana_token = jwt_bearer.client_manager.get_token_data(token) + return mq_connector.read_user(access_token=hana_token, + auth_user=hana_token.sub, **dict(request)) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 4d60e03..af25ea1 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -40,7 +40,7 @@ from neon_data_models.models.api.jwt import HanaToken from neon_hana.mq_service_api import MQServiceManager -from neon_data_models.models.user import (User, TokenConfig, NeonUserConfig, +from neon_data_models.models.user import (User, NeonUserConfig, PermissionsConfig) from neon_data_models.enum import AccessRoles from neon_hana.schema.auth_requests import AuthenticationResponse @@ -94,7 +94,7 @@ def _create_tokens(self, client_id: str, token_name: Optional[str] = None, permissions: Optional[PermissionsConfig] = None, - **kwargs) -> (str, str, TokenConfig): + **kwargs) -> (str, str, Dict[str, HanaToken]): token_id = str(uuid4()) # Subtract a second from creation so the token may be used immediately # upon return @@ -106,7 +106,7 @@ def _create_tokens(self, node=AccessRoles.GUEST, llm=AccessRoles.GUEST) token_name = token_name or kwargs.get("name") or \ - datetime.fromtimestamp(creation_timestamp).isoformat() + datetime.fromtimestamp(creation_timestamp).isoformat() access_token_data = HanaToken(iss=self._jwt_issuer, sub=user_id, exp=expiration_timestamp, @@ -114,6 +114,9 @@ def _create_tokens(self, jti=token_id, client_id=client_id, roles=permissions.to_roles(), + token_name=token_name, + creation_timestamp=creation_timestamp, + last_refresh_timestamp=creation_timestamp, purpose="access") refresh_token_data = HanaToken(iss=self._jwt_issuer, sub=user_id, @@ -122,20 +125,17 @@ def _create_tokens(self, jti=f"{token_id}.refresh", client_id=client_id, roles=permissions.to_roles(), + token_name=token_name, + creation_timestamp=creation_timestamp, + last_refresh_timestamp=creation_timestamp, purpose="refresh") access_token = jwt.encode(access_token_data.model_dump(), self._access_secret, self._jwt_algo) refresh_token = jwt.encode(refresh_token_data.model_dump(), self._refresh_secret, self._jwt_algo) - token_config = TokenConfig(token_name=token_name, - token_id=token_id, - user_id=user_id, - client_id=client_id, - permissions=permissions, - refresh_expiration_timestamp=refresh_expiration_timestamp, - creation_timestamp=creation_timestamp, - last_refresh_timestamp=creation_timestamp) - return access_token, refresh_token, token_config + + return access_token, refresh_token, {"access": access_token_data, + "refresh": refresh_token_data} def check_connect_stream(self) -> bool: """ @@ -243,9 +243,9 @@ def check_auth_request(self, client_id: str, username: str, client_id=client_id, access_token=access, refresh_token=refresh, - expiration=config.refresh_expiration_timestamp) + expiration=config['access'].exp) self.authorized_clients[client_id] = auth_response - self._add_token_to_userdb(user, config) + self._add_token_to_userdb(user, config['refresh']) return auth_response def check_refresh_request(self, access_token: Optional[str], @@ -256,9 +256,10 @@ def check_refresh_request(self, access_token: Optional[str], refresh_data = HanaToken(**jwt.decode(refresh_token, self._refresh_secret, self._jwt_algo)) - # token_data = HanaToken(**jwt.decode(access_token, - # self._access_secret, - # self._jwt_algo)) + token_data = HanaToken(**jwt.decode(access_token, + self._access_secret, + self._jwt_algo, + options={"verify_signature": False})) if refresh_data.purpose != "refresh": raise HTTPException(status_code=400, detail="Supplied refresh token not valid") @@ -290,7 +291,7 @@ def check_refresh_request(self, access_token: Optional[str], } if self._mq_connector: user = self._mq_connector.read_user(username=refresh_data.sub, - access_token=refresh_token) + access_token=token_data) if not user.password_hash: # This should not be possible, but don't let an error in the # users service allow for injecting a new valid token to the db @@ -306,18 +307,21 @@ def check_refresh_request(self, access_token: Optional[str], client_id=client_id, access_token=access, refresh_token=refresh, - expiration=config.refresh_expiration_timestamp) + expiration=config['access'].refresh_expiration_timestamp) self._authorized_clients[client_id] = auth_response return auth_response - def _add_token_to_userdb(self, user: User, new_token: TokenConfig): + def _add_token_to_userdb(self, user: User, new_token: HanaToken): + if new_token.purpose != "refresh": + raise ValueError(f"Expected a refresh token, got: " + f"{new_token.purpose}") if self._mq_connector is None: print("No MQ Connection to a user database") return for idx, token in enumerate(user.tokens): # If the token is already defined, maintain the original # token_id and creation timestamp - if token.token_id == new_token.token_id: + if token.jti == new_token.jti: new_token.token_name = token.token_name new_token.creation_timestamp = token.creation_timestamp user.tokens.remove(token) @@ -334,16 +338,14 @@ def get_client_id(self, token: str) -> str: self._jwt_algo)) return auth.client_id - def get_token_user_id(self, token: str) -> str: + def get_token_data(self, token: str) -> HanaToken: """ Extract the user_id from a JWT string @param token: JWT to parse @retrun: user_id associated with token """ - auth = HanaToken(**jwt.decode(token, self._access_secret, + return HanaToken(**jwt.decode(token, self._access_secret, self._jwt_algo)) - return auth.user_id - def validate_auth(self, token: str, origin_ip: str) -> bool: ratelimit_id = f"{origin_ip}-total" diff --git a/neon_hana/mq_service_api.py b/neon_hana/mq_service_api.py index 4908a67..e1fd292 100644 --- a/neon_hana/mq_service_api.py +++ b/neon_hana/mq_service_api.py @@ -33,6 +33,7 @@ from neon_data_models.models.api import CreateUserRequest, ReadUserRequest, \ UpdateUserRequest, DeleteUserRequest +from neon_data_models.models.api.jwt import HanaToken from neon_mq_connector.utils.client_utils import send_mq_request from neon_data_models.models.client.node import NodeData from neon_data_models.models.user.neon_profile import UserProfile @@ -127,7 +128,7 @@ def create_user(self, user: User) -> User: return err_or_user def read_user(self, username: str, password: Optional[str] = None, - access_token: Optional[str] = None, + access_token: Optional[HanaToken] = None, auth_user: Optional[str] = None) -> User: """ Get a User object for a user. This requires that a valid password OR @@ -136,9 +137,10 @@ def read_user(self, username: str, password: Optional[str] = None, @param username: Valid username to get a User object for @param password: Valid password to use for authentication @param access_token: Valid access token to use for authentication - @param auth_user: Optional username to use for authentication + @param auth_user: Optional username or user ID to use for authentication @returns: User object from the Users service. """ + auth_user = auth_user or username read_user_request = ReadUserRequest(user_spec=username, auth_user_spec=auth_user, access_token=access_token, @@ -158,6 +160,8 @@ def update_user(self, user: User, @param auth_password: Password associated with `auth_user` @returns: User as read from the database """ + auth_user = auth_user or user.username + auth_password = auth_password or user.password_hash update_user_request = UpdateUserRequest(user=user, auth_username=auth_user, auth_password=auth_password, diff --git a/tests/test_auth.py b/tests/test_auth.py index 7e2c862..c6e5139 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -104,7 +104,8 @@ def test_check_refresh_request(self): user_id=str(uuid4()), client_id=valid_client) access2, refresh2, config2 = self.client_manager._create_tokens( user_id=str(uuid4()), client_id=str(uuid4())) - self.assertEqual(config.client_id, valid_client) + self.assertEqual(config['access'].client_id, valid_client) + self.assertEqual(config['refresh'].client_id, valid_client) # Test invalid refresh token with self.assertRaises(HTTPException) as e: @@ -139,7 +140,7 @@ def test_check_refresh_request(self): user_id=str(uuid4()), client_id=valid_client) with self.assertRaises(HTTPException) as e: self.client_manager.check_refresh_request(access, refresh, - config.client_id) + config['access'].client_id) self.assertEqual(e.exception.status_code, 401) self.client_manager._refresh_token_lifetime = real_refresh From 757ff159cfdf8cd68793a18fb67c1bc67d3573e2 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Wed, 13 Nov 2024 10:55:56 -0800 Subject: [PATCH 22/28] Refactor token refresh handling to account for data model changes --- neon_hana/auth/client_manager.py | 17 ++++++----------- neon_hana/schema/auth_requests.py | 3 ++- requirements/requirements.txt | 2 +- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index af25ea1..77c84f3 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -283,12 +283,13 @@ def check_refresh_request(self, access_token: Optional[str], raise HTTPException(status_code=403, detail="Access token does not match client_id") - # `token_name` is not known here, but it will be read from the database - # when the new token replaces the old one encode_data = {"user_id": refresh_data.sub, "client_id": client_id, + "token_name": refresh_data.token_name, "permissions": PermissionsConfig.from_roles(refresh_data.roles) } + access, refresh, tokens = self._create_tokens(**encode_data) + username = refresh_data.sub if self._mq_connector: user = self._mq_connector.read_user(username=refresh_data.sub, access_token=token_data) @@ -296,18 +297,13 @@ def check_refresh_request(self, access_token: Optional[str], # This should not be possible, but don't let an error in the # users service allow for injecting a new valid token to the db raise HTTPException(status_code=500, detail="Error Fetching User") - access, refresh, config = self._create_tokens(**encode_data) - username = user.username - self._add_token_to_userdb(user, config) - else: - username = refresh_data.sub - access, refresh, config = self._create_tokens(**encode_data) + self._add_token_to_userdb(user, tokens['refresh']) auth_response = AuthenticationResponse(username=username, client_id=client_id, access_token=access, refresh_token=refresh, - expiration=config['access'].refresh_expiration_timestamp) + expiration=tokens['refresh'].exp) self._authorized_clients[client_id] = auth_response return auth_response @@ -320,9 +316,8 @@ def _add_token_to_userdb(self, user: User, new_token: HanaToken): return for idx, token in enumerate(user.tokens): # If the token is already defined, maintain the original - # token_id and creation timestamp + # creation timestamp if token.jti == new_token.jti: - new_token.token_name = token.token_name new_token.creation_timestamp = token.creation_timestamp user.tokens.remove(token) user.tokens.append(new_token) diff --git a/neon_hana/schema/auth_requests.py b/neon_hana/schema/auth_requests.py index 4aa6acc..cd215e2 100644 --- a/neon_hana/schema/auth_requests.py +++ b/neon_hana/schema/auth_requests.py @@ -53,7 +53,8 @@ class AuthenticationResponse(BaseModel): client_id: str access_token: str refresh_token: str - expiration: float + expiration: float = Field( + description="Expiration timestamp of the refresh token") model_config = { "json_schema_extra": { diff --git a/requirements/requirements.txt b/requirements/requirements.txt index b40b58d..a237fae 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ token-throttler~=1.4 neon-mq-connector~=0.7 ovos-config~=0.0,>=0.0.12 ovos-utils~=0.0,>=0.0.38 -neon-data-models @ git+https://github.com/neongeckocom/neon-data-models@FEAT_JWTModelAndTokenConfigUpdates \ No newline at end of file +neon-data-models @ git+https://github.com/neongeckocom/neon-data-models@FEAT_UpdateUserDbCRUDOperations From eb76f474efa9b9ebdd4e85911fd77d5713e928a6 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 21 Nov 2024 17:36:14 -0800 Subject: [PATCH 23/28] Implement unit tests for auth endpoints Update dependencies to stable spec Update dockerfile to resolve warnings --- Dockerfile | 6 +++--- requirements/requirements.txt | 2 +- tests/test_app.py | 36 ++++++++++++++++++++++------------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index d8394da..6879709 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,9 @@ FROM python:3.9-slim LABEL vendor=neon.ai \ ai.neon.name="neon-hana" -ENV OVOS_CONFIG_BASE_FOLDER neon -ENV OVOS_CONFIG_FILENAME diana.yaml -ENV XDG_CONFIG_HOME /config +ENV OVOS_CONFIG_BASE_FOLDER=neon +ENV OVOS_CONFIG_FILENAME=diana.yaml +ENV XDG_CONFIG_HOME=/config RUN apt update && apt install -y swig gcc libpulse-dev portaudio19-dev diff --git a/requirements/requirements.txt b/requirements/requirements.txt index a237fae..2116970 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -7,4 +7,4 @@ token-throttler~=1.4 neon-mq-connector~=0.7 ovos-config~=0.0,>=0.0.12 ovos-utils~=0.0,>=0.0.38 -neon-data-models @ git+https://github.com/neongeckocom/neon-data-models@FEAT_UpdateUserDbCRUDOperations +neon-data-models diff --git a/tests/test_app.py b/tests/test_app.py index ef546b1..0349a72 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,6 +5,8 @@ from fastapi.testclient import TestClient +from neon_data_models.models.user import User + _TEST_CONFIG = { "mq_default_timeout": 10, "access_token_ttl": 86400, # 1 day @@ -52,12 +54,14 @@ def test_app_init(self): @patch("neon_hana.mq_service_api.send_mq_request") def test_auth_login(self, send_request): - send_request.return_value = {} # TODO: Define valid login + valid_user = User(username="guest", password_hash="password") + send_request.return_value = {"user": valid_user.model_dump(), + "success": True} # Valid Login response = self.test_app.post("/auth/login", - json={"username": "guest", - "password": "password"}) + json={"username": valid_user.username, + "password": valid_user.password_hash}) response_data = response.json() self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response_data['username'], "guest") @@ -66,7 +70,13 @@ def test_auth_login(self, send_request): self.assertGreater(response_data['expiration'], time()) # Invalid Login - # TODO: Define invalid login request + send_request.return_value = {"code": 404, "error": "User not found"} + response = self.test_app.post("/auth/login", + json={"username": valid_user.username, + "password": valid_user.password_hash}) + self.assertEqual(response.status_code, 404, response.status_code) + self.assertEqual(response.json()['detail'], + "User not found", response.text) # Invalid Request self.assertEqual(self.test_app.post("/auth/login").status_code, 422) @@ -76,7 +86,9 @@ def test_auth_login(self, send_request): @patch("neon_hana.mq_service_api.send_mq_request") def test_auth_refresh(self, send_request): - send_request.return_value = {} # TODO: Define valid refresh + valid_user = User(username="guest", password_hash="password") + send_request.return_value = {"user": valid_user.model_dump(), + "success": True} valid_tokens = self._get_tokens() @@ -86,14 +98,12 @@ def test_auth_refresh(self, send_request): response_data = response.json() self.assertNotEqual(response_data, valid_tokens) - # # TODO - # # Refresh with old tokens fails - # response = self.test_app.post("/auth/refresh", json=valid_tokens) - # self.assertEqual(response.status_code, 422, response.text) - - # Valid request with new tokens - response = self.test_app.post("/auth/refresh", json=response_data) - self.assertEqual(response.status_code, 200, response.text) + # Refresh with old tokens fails (mocked return from users service) + send_request.return_value = {"code": 422, + "detail": "Invalid token", + "success": False} + response = self.test_app.post("/auth/refresh", json=valid_tokens) + self.assertEqual(response.status_code, 422, response.text) # TODO: Test with expired token From b1d3b03218551c114da762c7c3237e149e6e4418 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 21 Nov 2024 17:47:32 -0800 Subject: [PATCH 24/28] Mock token return for unit tests --- tests/test_app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_app.py b/tests/test_app.py index 0349a72..11c69b6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -38,7 +38,11 @@ def setUpClass(cls, ws_api, config): app = create_app(_TEST_CONFIG) cls.test_app = TestClient(app) - def _get_tokens(self): + @patch("neon_hana.mq_service_api.send_mq_request") + def _get_tokens(self, send_request): + valid_user = User(username="guest", password_hash="password") + send_request.return_value = {"user": valid_user.model_dump(), + "success": True} if not self.tokens: response = self.test_app.post("/auth/login", json={"username": "guest", From e843bbf35603ee83563eabfbf4b8d7263b7724bf Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Thu, 21 Nov 2024 17:54:00 -0800 Subject: [PATCH 25/28] Fix invalid references in unit tests Replace validation that auth and refresh tokens match --- neon_hana/auth/client_manager.py | 6 +++--- tests/test_auth.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 77c84f3..7a5f7b1 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -272,9 +272,9 @@ def check_refresh_request(self, access_token: Optional[str], except ExpiredSignatureError: raise HTTPException(status_code=401, detail="Refresh token is expired") - # if refresh_data.jti != token_data.jti + ".refresh": - # raise HTTPException(status_code=403, - # detail="Refresh and access token mismatch") + if refresh_data.jti != token_data.jti + ".refresh": + raise HTTPException(status_code=403, + detail="Refresh and access token mismatch") if time() > refresh_data.exp: raise HTTPException(status_code=401, detail="Refresh token is expired") diff --git a/tests/test_auth.py b/tests/test_auth.py index c6e5139..b5270d1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -127,8 +127,8 @@ def test_check_refresh_request(self): # Test valid refresh valid_refresh = self.client_manager.check_refresh_request( - access, refresh, config.client_id) - self.assertEqual(valid_refresh.client_id, config.client_id) + access, refresh, config['access'].client_id) + self.assertEqual(valid_refresh.client_id, config['access'].client_id) self.assertNotEqual(valid_refresh.access_token, access) self.assertNotEqual(valid_refresh.refresh_token, refresh) From 6f862c83636a426cfe08b86fb94631dcfd7c5430 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Fri, 20 Dec 2024 17:52:59 -0800 Subject: [PATCH 26/28] Catch and log errors when an old/invalid JWT token is parsed --- neon_hana/auth/client_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index 7a5f7b1..cc7b83c 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -35,6 +35,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jwt import DecodeError, ExpiredSignatureError from ovos_utils import LOG +from pydantic import ValidationError from token_throttler import TokenThrottler, TokenBucket from token_throttler.storage import RuntimeStorage @@ -363,6 +364,8 @@ def validate_auth(self, token: str, origin_ip: str) -> bool: username=auth.sub, client_id=auth.client_id, access_token=token, refresh_token="", expiration=auth.exp) return True + except ValidationError: + LOG.error(f"Invalid token data received from {origin_ip}.") except DecodeError: # Invalid token supplied pass From a31ad5fce16075e50dc5794f1d1c301dc759e32a Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 23 Dec 2024 09:32:41 -0800 Subject: [PATCH 27/28] Update neon_hana/app/routers/user.py Co-authored-by: Mike --- neon_hana/app/routers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neon_hana/app/routers/user.py b/neon_hana/app/routers/user.py index 059320b..729760b 100644 --- a/neon_hana/app/routers/user.py +++ b/neon_hana/app/routers/user.py @@ -1,6 +1,6 @@ # NEON AI (TM) SOFTWARE, Software Development Kit & Application Development System # All trademark and other rights reserved by their respective owners -# Copyright 2008-2021 Neongecko.com Inc. +# Copyright 2008-2024 Neongecko.com Inc. # BSD-3 # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: From bb3e2fbc956244649722510a05e19542d655f603 Mon Sep 17 00:00:00 2001 From: Daniel McKnight Date: Mon, 23 Dec 2024 09:43:34 -0800 Subject: [PATCH 28/28] Update logging and mark `authorized_clients` as deprecated to address review comments --- neon_hana/auth/client_manager.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/neon_hana/auth/client_manager.py b/neon_hana/auth/client_manager.py index cc7b83c..daa89d5 100644 --- a/neon_hana/auth/client_manager.py +++ b/neon_hana/auth/client_manager.py @@ -23,10 +23,10 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from uuid import uuid4 import jwt +from uuid import uuid4 from datetime import datetime from threading import Lock from time import time @@ -35,6 +35,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from jwt import DecodeError, ExpiredSignatureError from ovos_utils import LOG +from ovos_utils.log import log_deprecation from pydantic import ValidationError from token_throttler import TokenThrottler, TokenBucket from token_throttler.storage import RuntimeStorage @@ -87,7 +88,7 @@ def authorized_clients(self) -> Dict[str, AuthenticationResponse]: known by this instance. NOTE: Refresh tokens are not reliably stored here and should never be retrievable after generation for security. """ - # TODO: Is `authorized_clients` useful to track? + log_deprecation("This property is deprecated with no replacement", "1.0.0") return self._authorized_clients def _create_tokens(self, @@ -188,7 +189,7 @@ def check_registration_request(self, username: str, password: str, if self._mq_connector: return self._mq_connector.create_user(new_user) else: - print("No User Database connected. Return valid registration.") + LOG.debug("No User Database connected. Return valid registration.") return new_user def check_auth_request(self, client_id: str, username: str, @@ -313,7 +314,7 @@ def _add_token_to_userdb(self, user: User, new_token: HanaToken): raise ValueError(f"Expected a refresh token, got: " f"{new_token.purpose}") if self._mq_connector is None: - print("No MQ Connection to a user database") + LOG.debug("No MQ Connection to a user database") return for idx, token in enumerate(user.tokens): # If the token is already defined, maintain the original