From f3012e652edef614d4f0ed169b320106a85d83b3 Mon Sep 17 00:00:00 2001 From: omnom62 Date: Wed, 5 Aug 2026 09:14:32 +1000 Subject: http-api: T8989: add REST Bearer token authentication Add JWT Bearer token support to the REST API, as an additional authentication method alongside the existing form-field key and X-API-Key header. - New POST /token endpoint mints a JWT for a valid API key - auth_required() accepts Authorization: Bearer alongside existing key/X-API-Key auth - New config nodes: service https api rest authentication {expiration, secret-length} (defaults: 3600s / 32 bytes) - REST tokens use an independent signing secret from GraphQL's, since GraphQL may not be enabled on all deployments and the two subsystems have different expiry requirements - nginx location regex updated to allow /token - service_https.py default-value merge generalized to also apply to the rest node, not just graphql, so REST authentication defaults populate correctly on commit --- src/conf_mode/service_https.py | 8 +++-- src/services/api/rest/libs/__init__.py | 0 src/services/api/rest/libs/token_auth.py | 54 ++++++++++++++++++++++++++++++++ src/services/api/rest/routers.py | 44 +++++++++++++++++++++----- src/services/api/session.py | 3 ++ src/services/vyos-http-api-server | 3 ++ 6 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 src/services/api/rest/libs/__init__.py create mode 100644 src/services/api/rest/libs/token_auth.py (limited to 'src') diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py index 4a9311bfb..28985ead9 100755 --- a/src/conf_mode/service_https.py +++ b/src/conf_mode/service_https.py @@ -77,9 +77,13 @@ def get_config(config=None): # We have gathered the dict representation of the CLI, but there are default # options which we need to update into the dictionary retrieved. default_values = conf.get_config_defaults(**https.kwargs, recursive=True) - if 'api' not in https or 'graphql' not in https['api']: + if 'api' in https: + if 'graphql' not in https['api']: + del default_values['api']['graphql'] + if 'rest' not in https['api']: + del default_values['api']['rest'] + else: del default_values['api'] - # merge CLI and default dictionary https = config_dict_merge(default_values, https) diff --git a/src/services/api/rest/libs/__init__.py b/src/services/api/rest/libs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/services/api/rest/libs/token_auth.py b/src/services/api/rest/libs/token_auth.py new file mode 100644 index 000000000..24c491fde --- /dev/null +++ b/src/services/api/rest/libs/token_auth.py @@ -0,0 +1,54 @@ +# Copyright VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import datetime +from secrets import token_hex + +import jwt + +from ...session import SessionState + + +def init_secret(): + state = SessionState() + if state.rest_secret is not None: + return + length = state.rest_secret_len or 32 + state.rest_secret = token_hex(length) + + +def generate_token(key_id: str) -> dict: + state = SessionState() + init_secret() + exp_interval = state.rest_token_exp or 3600 + expiration = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta( + seconds=exp_interval + ) + payload = {'iss': 'vyos-rest-api', 'sub': key_id, 'exp': expiration} + token = jwt.encode(payload=payload, key=state.rest_secret, algorithm='HS256') + return {'token': token, 'expires_in': exp_interval} + + +def verify_token(token: str): + state = SessionState() + if state.rest_secret is None: + return None + try: + payload = jwt.decode(token, state.rest_secret, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return None + except jwt.PyJWTError: + return None + return payload.get('sub') diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 7b0762084..0a43e856b 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -27,11 +27,13 @@ from threading import Lock from typing import Union from typing import Callable from typing import TYPE_CHECKING +from typing import Optional from fastapi import Depends from fastapi import Query from fastapi import Request from fastapi import Response +from fastapi import Header from fastapi import HTTPException from fastapi import APIRouter from fastapi import BackgroundTasks @@ -77,6 +79,8 @@ from .models import ImportPkiModel from .models import PingModel from .models import PoweroffModel from .models import TracerouteModel +from .libs.token_auth import generate_token +from .libs.token_auth import verify_token if TYPE_CHECKING: @@ -97,9 +101,19 @@ def check_auth(key_list, key): return key_id -def auth_required(data: ApiModel): +def auth_required(data: ApiModel, x_api_key: Optional[str] = Header(None), authorization: Optional[str] = Header(None)): session = SessionState() - key = data.key + + if authorization: + scheme, _, token = authorization.partition(' ') + if scheme.lower() == 'bearer' and token: + key_id = verify_token(token) + if key_id: + session.id = key_id + return + raise HTTPException(status_code=401, detail='Invalid or expired token') + + key = data.key or x_api_key api_keys = session.keys key_id = check_auth(api_keys, key) if not key_id: @@ -182,6 +196,14 @@ class MultipartRequest(Request): LOG.debug('processing form data') for k, v in form_data.multi_items(): forms[k] = v + if endpoint == '/token': + if 'key' not in forms: + self.form_err = (401, 'Valid API key is required') + return self._body + merge = {'key': forms['key']} + new_body = json.dumps(merge).encode() + self._body = new_body + return self._body if 'data' not in forms: self.form_err = (422, 'Non-empty data field is required') @@ -423,8 +445,8 @@ def _execute_configure_op( section = c.section elif isinstance(c, BaseConfigSectionTreeModel): - mask_dict = c.mask - config_dict = c.config + mask = c.mask + config = c.config if isinstance(c, BaseConfigureModel): if op == 'set': @@ -450,10 +472,9 @@ def _execute_configure_op( elif isinstance(c, BaseConfigSectionTreeModel): if op == 'set': - session.set_section_tree(config_dict) + session.set_section_tree(config) elif op == 'load': - config_tree = config.get_config_tree() - session.load_section_tree(config_tree, mask_dict, config_dict) + session.load_section_tree(mask, config) else: raise op_error # end for @@ -690,7 +711,7 @@ async def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTask case 'load': session.migrate_and_load_config(path) case 'merge': - session.merge_config(path, destructive=data.destructive) + session.merge_config(path) config = Config(session_env=env) d = get_config_diff(config) @@ -1007,6 +1028,13 @@ def traceroute_op(data: TracerouteModel): return success(res) +@router.post('/token') +def token_op(data: ApiModel): + session = SessionState() + key_id = check_auth(session.keys, data.key) + if not key_id: + raise HTTPException(status_code=401, detail='Valid API key is required') + return success(generate_token(key_id)) def rest_init(app: 'FastAPI'): if all(r in app.routes for r in router.routes): diff --git a/src/services/api/session.py b/src/services/api/session.py index c25a444e9..0b1c935c7 100644 --- a/src/services/api/session.py +++ b/src/services/api/session.py @@ -39,3 +39,6 @@ class SessionState: self.auth_type = None self.token_exp = None self.secret_len = None + self.rest_secret = None + self.rest_secret_len = None + self.rest_token_exp = None diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 94697ed4d..4039d17a2 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -184,6 +184,9 @@ def initialization(session: SessionState, app: FastAPI = app): rest_config = server_config.get('rest', {}) session.debug = bool('debug' in rest_config) session.strict = bool('strict' in rest_config) + if isinstance(rest_config, dict) and 'authentication' in rest_config: + session.rest_token_exp = rest_config['authentication']['expiration'] + session.rest_secret_len = rest_config['authentication']['secret_length'] graphql_config = server_config.get('graphql', {}) session.origins = graphql_config.get('cors', {}).get('allow_origin', []) -- cgit v1.2.3