diff --git a/moonraker/common.py b/moonraker/common.py index d6bda98..4c0ae62 100644 --- a/moonraker/common.py +++ b/moonraker/common.py @@ -10,8 +10,9 @@ import logging import copy import re import inspect +import dataclasses +import time from enum import Enum, Flag, auto -from dataclasses import dataclass from abc import ABCMeta, abstractmethod from .utils import ServerError, Sentinel from .utils import json_wrapper as jsonw @@ -177,7 +178,24 @@ class RenderableTemplate(metaclass=ABCMeta): async def render_async(self, context: Dict[str, Any] = {}) -> str: ... -@dataclass(frozen=True) +@dataclasses.dataclass +class UserInfo: + username: str + password: str + created_on: float = dataclasses.field(default_factory=time.time) + salt: str = "" + source: str = "moonraker" + jwt_secret: Optional[str] = None + jwk_id: Optional[str] = None + groups: List[str] = dataclasses.field(default_factory=lambda: ["admin"]) + + def as_tuple(self) -> Tuple[Any, ...]: + return dataclasses.astuple(self) + + def as_dict(self) -> Dict[str, Any]: + return dataclasses.asdict(self) + +@dataclasses.dataclass(frozen=True) class APIDefinition: endpoint: str http_path: str diff --git a/moonraker/components/authorization.py b/moonraker/components/authorization.py index 204c675..269f24c 100644 --- a/moonraker/components/authorization.py +++ b/moonraker/components/authorization.py @@ -20,7 +20,7 @@ import logging from tornado.web import HTTPError from libnacl.sign import Signer, Verifier from ..utils import json_wrapper as jsonw -from ..common import RequestType, TransportType +from ..common import RequestType, TransportType, SqlTableDefinition, UserInfo # Annotation imports from typing import ( @@ -39,6 +39,7 @@ if TYPE_CHECKING: from .websockets import WebsocketManager from tornado.httputil import HTTPServerRequest from .database import MoonrakerDatabase as DBComp + from .database import DBProviderWrapper from .ldap import MoonrakerLDAP IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address] IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network] @@ -60,6 +61,7 @@ TRUSTED_CONNECTION_TIMEOUT = 3600 FQDN_CACHE_TIMEOUT = 84000 PRUNE_CHECK_TIME = 300. +USER_TABLE = "authorized_users" AUTH_SOURCES = ["moonraker", "ldap"] HASH_ITER = 100000 API_USER = "_API_KEY_USER_" @@ -71,6 +73,47 @@ JWT_HEADER = { 'typ': "JWT" } +class UserSqlDefinition(SqlTableDefinition): + name = USER_TABLE + prototype = ( + f""" + {USER_TABLE} ( + username TEXT PRIMARY KEY NOT NULL, + password TEXT NOT NULL, + created_on REAL NOT NULL, + salt TEXT NOT NULL, + source TEXT NOT NULL, + jwt_secret TEXT, + jwk_id TEXT, + groups pyjson + ) + """ + ) + version = 1 + + def migrate(self, last_version: int, db_provider: DBProviderWrapper) -> None: + if last_version == 0: + users: Dict[str, Dict[str, Any]] + users = db_provider.get_namespace("authorized_users") + api_user = users.pop(API_USER, {}) + user_vals: List[Tuple[Any, ...]] = [ + UserInfo( + username=API_USER, + password=api_user.get("api_key", uuid.uuid4().hex), + created_on=api_user.get("created_on", time.time()) + ).as_tuple() + ] + for user in users.values(): + user_vals.append(UserInfo(**user).as_tuple()) + placeholders = ",".join("?" * len(user_vals[0])) + conn = db_provider.connection + with conn: + conn.executemany( + f"INSERT OR IGNORE INTO {USER_TABLE} VALUES({placeholders})", + user_vals + ) + db_provider.wipe_local_namespace("authorized_users") + class Authorization: def __init__(self, config: ConfigHelper) -> None: self.server = config.get_server() @@ -97,62 +140,13 @@ class Authorization: " however [ldap] section failed to load or not configured" ) database: DBComp = self.server.lookup_component('database') - database.register_local_namespace('authorized_users', forbidden=True) - self.user_db = database.wrap_namespace('authorized_users') - self.users: Dict[str, Dict[str, Any]] = self.user_db.as_dict() - api_user: Optional[Dict[str, Any]] = self.users.get(API_USER, None) - if api_user is None: - self.api_key = uuid.uuid4().hex - self.users[API_USER] = { - 'username': API_USER, - 'api_key': self.api_key, - 'created_on': time.time() - } - else: - self.api_key = api_user['api_key'] + self.user_table = database.register_table(UserSqlDefinition()) + self.users: Dict[str, UserInfo] = {} + self.api_key = uuid.uuid4().hex hi = self.server.get_host_info() self.issuer = f"http://{hi['hostname']}:{hi['port']}" self.public_jwks: Dict[str, Dict[str, Any]] = {} - for username, user_info in list(self.users.items()): - if username == API_USER: - # Validate the API User - for item in ["username", "api_key", "created_on"]: - if item not in user_info: - self.users[API_USER] = { - 'username': API_USER, - 'api_key': self.api_key, - 'created_on': time.time() - } - break - continue - else: - # validate created users - valid = True - for item in ["username", "password", "salt", "created_on"]: - if item not in user_info: - logging.info( - f"Authorization: User {username} does not " - f"contain field {item}, removing") - del self.users[username] - valid = False - break - if not valid: - continue - # generate jwks for valid users - if 'jwt_secret' in user_info: - try: - priv_key = self._load_private_key(user_info['jwt_secret']) - jwk_id = user_info['jwk_id'] - except (self.server.error, KeyError): - logging.info("Invalid key found for user, removing") - user_info.pop('jwt_secret', None) - user_info.pop('jwk_id', None) - self.users[username] = user_info - continue - self.public_jwks[jwk_id] = self._generate_public_jwk(priv_key) - # sync user changes to the database - self.user_db.sync(self.users) - self.trusted_users: Dict[IPAddr, Any] = {} + self.trusted_users: Dict[IPAddr, Dict[str, Any]] = {} self.oneshot_tokens: Dict[str, OneshotToken] = {} # Get allowed cors domains @@ -276,17 +270,68 @@ class Authorization: "authorization:user_logged_out", event_type="logout" ) - def _sync_user(self, username: str) -> None: - self.user_db[username] = self.users[username] - async def component_init(self) -> None: + # Populate users from database + cursor = await self.user_table.execute(f"SELECT * FROM {USER_TABLE}") + self.users = {row[0]: UserInfo(**dict(row)) for row in await cursor.fetchall()} + need_sync = self._initialize_users() + if need_sync: + await self._sync_user_table() self.prune_timer.start(delay=PRUNE_CHECK_TIME) + async def _sync_user(self, username: str) -> None: + user = self.users[username] + vals = user.as_tuple() + placeholders = ",".join("?" * len(vals)) + async with self.user_table as tx: + await tx.execute( + f"REPLACE INTO {USER_TABLE} VALUES({placeholders})", vals + ) + + async def _sync_user_table(self) -> None: + async with self.user_table as tx: + await tx.execute(f"DELETE FROM {USER_TABLE}") + user_vals: List[Tuple[Any, ...]] + user_vals = [user.as_tuple() for user in self.users.values()] + if not user_vals: + return + placeholders = ",".join("?" * len(user_vals[0])) + await tx.executemany( + f"INSERT INTO {USER_TABLE} VALUES({placeholders})", user_vals + ) + + def _initialize_users(self) -> bool: + need_sync = False + api_user: Optional[UserInfo] = self.users.get(API_USER, None) + if api_user is None: + need_sync = True + self.users[API_USER] = UserInfo(username=API_USER, password=self.api_key) + else: + self.api_key = api_user.password + for username, user_info in list(self.users.items()): + if username == API_USER: + continue + # generate jwks for valid users + if user_info.jwt_secret is not None: + try: + priv_key = self._load_private_key(user_info.jwt_secret) + jwk_id = user_info.jwk_id + assert jwk_id is not None + except (self.server.error, KeyError, AssertionError): + logging.info("Invalid jwk found for user, removing") + user_info.jwt_secret = None + user_info.jwk_id = None + self.users[username] = user_info + need_sync = True + continue + self.public_jwks[jwk_id] = self._generate_public_jwk(priv_key) + return need_sync + async def _handle_apikey_request(self, web_request: WebRequest) -> str: if web_request.get_request_type() == RequestType.POST: self.api_key = uuid.uuid4().hex - self.users[API_USER]['api_key'] = self.api_key - self._sync_user(API_USER) + self.users[API_USER].password = self.api_key + await self._sync_user(API_USER) return self.api_key async def _handle_oneshot_request(self, web_request: WebRequest) -> str: @@ -322,10 +367,12 @@ class Authorization: if username in RESERVED_USERS: raise self.server.error( f"Invalid log out request for user {username}") - self.users[username].pop("jwt_secret", None) - jwk_id: str = self.users[username].pop("jwk_id", None) - self._sync_user(username) - self.public_jwks.pop(jwk_id, None) + jwk_id: Optional[str] = self.users[username].jwk_id + self.users[username].jwt_secret = None + self.users[username].jwk_id = None + if jwk_id is not None: + self.public_jwks.pop(jwk_id, None) + await self._sync_user(username) eventloop = self.server.get_event_loop() eventloop.delay_callback( .005, self.server.send_event, "authorization:user_logged_out", @@ -363,16 +410,16 @@ class Authorization: user_info = self.decode_jwt(refresh_token, token_type="refresh") except Exception: raise self.server.error("Invalid Refresh Token", 401) - username: str = user_info['username'] - if 'jwt_secret' not in user_info or "jwk_id" not in user_info: + username: str = user_info.username + if user_info.jwt_secret is None or user_info.jwk_id is None: raise self.server.error("User not logged in", 401) - private_key = self._load_private_key(user_info['jwt_secret']) - jwk_id: str = user_info['jwk_id'] + private_key = self._load_private_key(user_info.jwt_secret) + jwk_id: str = user_info.jwk_id token = self._generate_jwt(username, jwk_id, private_key) return { 'username': username, 'token': token, - 'source': user_info.get("source", "moonraker"), + 'source': user_info.source, 'action': 'user_jwt_refresh' } @@ -399,7 +446,7 @@ class Authorization: return await self._login_jwt_user(web_request, create=True) elif req_type == RequestType.DELETE: # Delete User - return self._delete_jwt_user(web_request) + return await self._delete_jwt_user(web_request) raise self.server.error("Invalid Request Method") async def _handle_list_request(self, @@ -407,12 +454,12 @@ class Authorization: ) -> Dict[str, List[Dict[str, Any]]]: user_list = [] for user in self.users.values(): - if user['username'] == API_USER: + if user.username == API_USER: continue user_list.append({ - 'username': user['username'], - 'source': user.get("source", "moonraker"), - 'created_on': user['created_on'] + 'username': user.username, + 'source': user.source, + 'created_on': user.created_on }) return { 'users': user_list @@ -440,8 +487,8 @@ class Authorization: raise self.server.error("Invalid Password") new_hashed_pass = hashlib.pbkdf2_hmac( 'sha256', new_pass.encode(), salt, HASH_ITER).hex() - self.users[username]['password'] = new_hashed_pass - self._sync_user(username) + self.users[username].password = new_hashed_pass + await self._sync_user(username) return { 'username': username, 'action': "user_password_reset" @@ -457,7 +504,7 @@ class Authorization: ).lower() if source not in AUTH_SOURCES: raise self.server.error(f"Invalid 'source': {source}") - user_info: Dict[str, Any] + user_info: UserInfo if username in RESERVED_USERS: raise self.server.error( f"Invalid Request for user {username}") @@ -477,15 +524,14 @@ class Authorization: salt = secrets.token_bytes(32) hashed_pass = hashlib.pbkdf2_hmac( 'sha256', password.encode(), salt, HASH_ITER).hex() - user_info = { - 'username': username, - 'password': hashed_pass, - 'salt': salt.hex(), - 'source': source, - 'created_on': time.time() - } + user_info = UserInfo( + username=username, + password=hashed_pass, + salt=salt.hex(), + source=source, + ) self.users[username] = user_info - self._sync_user(username) + await self._sync_user(username) action = "user_created" if source == "ldap": # Dont notify user created @@ -495,30 +541,32 @@ class Authorization: if username not in self.users: raise self.server.error(f"Unregistered User: {username}") user_info = self.users[username] - auth_src = user_info.get("source", "moonraker") + auth_src = user_info.source if auth_src != source: raise self.server.error( f"Moonraker cannot authenticate user '{username}', must " f"specify source '{auth_src}'", 401 ) - salt = bytes.fromhex(user_info['salt']) + salt = bytes.fromhex(user_info.salt) hashed_pass = hashlib.pbkdf2_hmac( 'sha256', password.encode(), salt, HASH_ITER).hex() action = "user_logged_in" - if hashed_pass != user_info['password']: + if hashed_pass != user_info.password: raise self.server.error("Invalid Password") - jwt_secret_hex: Optional[str] = user_info.get('jwt_secret', None) + jwt_secret_hex: Optional[str] = user_info.jwt_secret if jwt_secret_hex is None: private_key = Signer() jwk_id = base64url_encode(secrets.token_bytes()).decode() - user_info['jwt_secret'] = private_key.hex_seed().decode() - user_info['jwk_id'] = jwk_id + user_info.jwt_secret = private_key.hex_seed().decode() + user_info.jwk_id = jwk_id self.users[username] = user_info - self._sync_user(username) + await self._sync_user(username) self.public_jwks[jwk_id] = self._generate_public_jwk(private_key) else: private_key = self._load_private_key(jwt_secret_hex) - jwk_id = user_info['jwk_id'] + if user_info.jwk_id is None: + user_info.jwk_id = base64url_encode(secrets.token_bytes()).decode() + jwk_id = user_info.jwk_id token = self._generate_jwt(username, jwk_id, private_key) refresh_token = self._generate_jwt( username, jwk_id, private_key, token_type="refresh", @@ -531,16 +579,16 @@ class Authorization: "authorization:user_created", {'username': username}) elif conn is not None: - conn.user_info = user_info + conn.user_info = user_info.as_dict() return { 'username': username, 'token': token, - 'source': user_info.get("source", "moonraker"), + 'source': user_info.source, 'refresh_token': refresh_token, 'action': action } - def _delete_jwt_user(self, web_request: WebRequest) -> Dict[str, str]: + async def _delete_jwt_user(self, web_request: WebRequest) -> Dict[str, str]: username: str = web_request.get_str('username') current_user = web_request.get_current_user() if current_user is not None: @@ -551,13 +599,16 @@ class Authorization: if username in RESERVED_USERS: raise self.server.error( f"Invalid Request for reserved user {username}") - user_info: Optional[Dict[str, Any]] = self.users.get(username) + user_info: Optional[UserInfo] = self.users.get(username) if user_info is None: raise self.server.error(f"No registered user: {username}") - if 'jwk_id' in user_info: - self.public_jwks.pop(user_info['jwk_id'], None) + if user_info.jwk_id is not None: + self.public_jwks.pop(user_info.jwk_id, None) del self.users[username] - del self.user_db[username] + async with self.user_table as tx: + await tx.execute( + f"DELETE FROM {USER_TABLE} WHERE username = ?", (username,) + ) event_loop = self.server.get_event_loop() event_loop.delay_callback( .005, self.server.send_event, @@ -595,7 +646,7 @@ class Authorization: def decode_jwt( self, token: str, token_type: str = "access", check_exp: bool = True - ) -> Dict[str, Any]: + ) -> UserInfo: message, sig = token.rsplit('.', maxsplit=1) enc_header, enc_payload = message.split('.') header: Dict[str, Any] = jsonw.loads(base64url_decode(enc_header)) @@ -626,7 +677,7 @@ class Authorization: raise self.server.error("JWT Expired", 401) # get user - user_info: Optional[Dict[str, Any]] = self.users.get( + user_info: Optional[UserInfo] = self.users.get( payload.get('username', ""), None) if user_info is None: raise self.server.error("Unknown user", 401) @@ -641,13 +692,13 @@ class Authorization: raise self.server.error( f"Failed to decode JWT: {e}", 401 ) from e - return user_info + return user_info.as_dict() def validate_api_key(self, api_key: str) -> Dict[str, Any]: if not self.enable_api_key: raise self.server.error("API Key authentication is disabled", 401) if api_key and api_key == self.api_key: - return self.users[API_USER] + return self.users[API_USER].as_dict() raise self.server.error("Invalid API Key", 401) def _load_private_key(self, secret: str) -> Signer: @@ -709,7 +760,7 @@ class Authorization: def _check_json_web_token( self, request: HTTPServerRequest, required: bool = True - ) -> Optional[Dict[str, Any]]: + ) -> Optional[UserInfo]: auth_token: Optional[str] = request.headers.get("Authorization") if auth_token is None: auth_token = request.headers.get("X-Access-Token") @@ -757,23 +808,21 @@ class Authorization: async def _check_trusted_connection( self, ip: Optional[IPAddr] - ) -> Optional[Dict[str, Any]]: + ) -> Optional[UserInfo]: if ip is not None: curtime = time.time() exp_time = curtime + TRUSTED_CONNECTION_TIMEOUT if ip in self.trusted_users: - self.trusted_users[ip]['expires_at'] = exp_time - return self.trusted_users[ip] + self.trusted_users[ip]["expires_at"] = exp_time + return self.trusted_users[ip]["user"] elif await self._check_authorized_ip(ip): logging.info( f"Trusted Connection Detected, IP: {ip}") self.trusted_users[ip] = { - 'username': TRUSTED_USER, - 'password': None, - 'created_on': curtime, - 'expires_at': exp_time + "user": UserInfo(TRUSTED_USER, "", curtime), + "expires_at": exp_time } - return self.trusted_users[ip] + return self.trusted_users[ip]["user"] return None def _check_oneshot_token(self, @@ -805,7 +854,7 @@ class Authorization: # Check JSON Web Token jwt_user = self._check_json_web_token(request, auth_required) if jwt_user is not None: - return jwt_user + return jwt_user.as_dict() try: ip = ipaddress.ip_address(request.remote_ip) # type: ignore @@ -825,7 +874,7 @@ class Authorization: if self.enable_api_key: key: Optional[str] = request.headers.get("X-Api-Key") if key and key == self.api_key: - return self.users[API_USER] + return self.users[API_USER].as_dict() # If the force_logins option is enabled and at least one user is created # then trusted user authentication is disabled @@ -837,8 +886,10 @@ class Authorization: # Check if IP is trusted. If this endpoint doesn't require authentication # then it is acceptable to return None trusted_user = await self._check_trusted_connection(ip) - if trusted_user is not None or not auth_required: - return trusted_user + if trusted_user is not None: + return trusted_user.as_dict() + if not auth_required: + return None raise HTTPError(401, "Unauthorized")