diff --git a/tsdapiclient/authapi.py b/tsdapiclient/authapi.py index fff79e1..b90a20e 100644 --- a/tsdapiclient/authapi.py +++ b/tsdapiclient/authapi.py @@ -1,7 +1,7 @@ - """Module for the TSD Auth API.""" import json +from uuid import UUID import requests import time @@ -45,6 +45,36 @@ def get_jwt_basic_auth( msg = resp.text raise AuthnError(msg) + +def get_jwt_instance_auth( + env: str, + pnum: str, + api_key: str, + link_id: UUID, + secret_challenge: str|None = None, + token_type: str = "import", +) -> tuple: + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} + url = f'{auth_api_url(env, pnum, "instances")}?type={token_type}' + try: + debug_step(f"POST {url}") + request_body = {"id": str(link_id)} + if secret_challenge: + request_body["secret_challenge"] = secret_challenge + resp = requests.post(url, headers=headers, data=json.dumps(request_body)) + except Exception as e: + raise AuthnError from e + if resp.status_code in [200, 201]: + data = json.loads(resp.text) + return data.get("token"), data.get("refresh_token") + else: + if resp.status_code == 403: + msg = f"Instance auth not authorized from current IP address, contact USIT at {HELP_URL}" + else: + msg = resp.text + raise AuthnError(msg) + + @handle_request_errors def get_jwt_two_factor_auth( env: str, diff --git a/tsdapiclient/guide.py b/tsdapiclient/guide.py index c57cb81..1221c07 100644 --- a/tsdapiclient/guide.py +++ b/tsdapiclient/guide.py @@ -1,4 +1,3 @@ - from tsdapiclient.tools import HELP_URL topics = """ @@ -6,6 +5,7 @@ uploads downloads automation +instances debugging sync encryption @@ -137,6 +137,34 @@ Invoking tacl like this will over-ride any other local config. """ +instances = f""" + +In order to use the instances, you need to have a link ID and an api +key. To request an api key, you need to contact USIT at {HELP_URL}. +The link will be provided to you by the project owner. + +The instances are currently only used for uploading data. To upload +data to with an instance, you can use the following command: + + tacl p11 --api-key --link-id (--secret-challenge )? --upload myfile + +where is the link id provided to you by the project owner, +and is the secret challenge provided to you by the +project owner. The secret challenge is used to verify that the instance +is the correct one. The link id is used to identify the instance it can +be provided as UUID or a https link. Example of an https link is: + + tacl p11 --api-key --link-id https://data.tsd.usit.no/i/d3bd40e1-0a15-4575-9745-830ec52a4b3f --upload myfile + tacl p11 --api-key --link-id https://data.tsd.usit.no/c/1154a666-4ae3-49e5-b1dd-cf1ea2cc86f9 --secret-challenge --upload myfile + +where the 'c' and 'i' are the type of the link. The 'c' for instance +that requires a secret challenge and the 'i' for instance that does not +require a secret challenge. The UUID variant is the same as the https link +but without the https and the domain. + + tacl p11 --api-key --link-id d3bd40e1-0a15-4575-9745-830ec52a4b3f --upload myfile + tacl p11 --api-key --link-id 1154a666-4ae3-49e5-b1dd-cf1ea2cc86f9 --secret-challenge secret --upload myfile +""" debugging = f""" If you are having trouble while running a command, check the version, and run the problematic command in verbose mode: diff --git a/tsdapiclient/tacl.py b/tsdapiclient/tacl.py index 4b4d0c4..acbdffd 100644 --- a/tsdapiclient/tacl.py +++ b/tsdapiclient/tacl.py @@ -2,18 +2,20 @@ import getpass import os import platform +import re import sys from dataclasses import dataclass from textwrap import dedent from typing import Optional +import uuid import click import requests from tsdapiclient import __version__ from tsdapiclient.administrator import get_tsd_api_key -from tsdapiclient.authapi import get_jwt_two_factor_auth, get_jwt_basic_auth +from tsdapiclient.authapi import get_jwt_two_factor_auth, get_jwt_basic_auth, get_jwt_instance_auth from tsdapiclient.client_config import ENV, CHUNK_THRESHOLD, CHUNK_SIZE from tsdapiclient.configurer import ( read_config, update_config, print_config, delete_config, @@ -37,7 +39,7 @@ export_delete, ) from tsdapiclient.guide import ( - topics, config, uploads, downloads, debugging, automation, sync, encryption, + topics, config, uploads, downloads, debugging, automation, sync, encryption, instances ) from tsdapiclient.session import ( session_is_expired, @@ -116,6 +118,7 @@ 'downloads': downloads, 'debugging': debugging, 'automation': automation, + 'instances': instances, 'sync': sync, 'encryption': encryption, } @@ -467,6 +470,18 @@ def construct_correct_upload_path(path: str) -> str: default=None, help='Pass an explicit API key, pasting the key or as a path to a file: --api-key @path-to-file' ) +@click.option( + '--link-id', + required=False, + default=None, + help='Pass a download link obtained from the TSD API. This must be used with --api-key as well as it requires a specific client' +) +@click.option( + '--secret-challenge', + required=False, + default=None, + help='Pass a secret challenge for instance authentication' +) @click.option( '--encrypt', is_flag=True, @@ -522,6 +537,8 @@ def cli( keep_updated: bool, download_delete: str, api_key: str, + link_id: str, + secret_challenge: str, encrypt: bool, chunk_size: int, resumable_threshold: int, @@ -545,7 +562,7 @@ def cli( if basic or api_key: requires_user_credentials, token_type = False, TOKENS[env]['upload'] else: - requires_user_credentials, token_type = True, TOKENS[env]['upload'] + requires_user_credentials, token_type = False if link_id else True, TOKENS[env]['upload'] elif download or download_list or download_sync or download_delete: if env == 'alt' and basic: requires_user_credentials, token_type = False, TOKENS[env]['download'] @@ -619,7 +636,21 @@ def cli( debug_step("API key has expired") api_key = renew_api_key(env, pnum, api_key, key_file) debug_step('using basic authentication') - token, refresh_token = get_jwt_basic_auth(env, pnum, api_key, token_type) + if link_id: + + if link_id.startswith("https://"): + click.echo("extracting link_id from URL") + patten = r"https://(?P.+)/(?Pc|i)/(?P[a-f\d0-9-]{36})" + if match:=re.compile(patten).match(link_id): + link_id = uuid.UUID(match.group("link_id")) + if match.group("instance_type") == "c" and not secret_challenge: + click.echo("instance authentication requires a secret challenge") + sys.exit(1) + else: + link_id = uuid.UUID(link_id) + token, refresh_token = get_jwt_instance_auth(env, pnum, api_key, link_id, secret_challenge, token_type) + else: + token, refresh_token = get_jwt_basic_auth(env, pnum, api_key, token_type) if (requires_user_credentials or basic) and not token: click.echo('authentication failed') sys.exit(1) diff --git a/tsdapiclient/tools.py b/tsdapiclient/tools.py index b2239bc..c485265 100644 --- a/tsdapiclient/tools.py +++ b/tsdapiclient/tools.py @@ -46,6 +46,7 @@ def auth_api_url(env: str, pnum: str, auth_method: str) -> str: endpoints = { 'default': { 'basic': f'{pnum}/auth/basic/token', + 'instances': '/all/auth/instances/token', 'tsd': f'{pnum}/auth/tsd/token', 'iam': f'{pnum}/auth/iam/token', 'refresh': f'{pnum}/auth/refresh/token', @@ -53,6 +54,7 @@ def auth_api_url(env: str, pnum: str, auth_method: str) -> str: }, 'int': { 'basic': f'{pnum}/internal/basic/token', + 'instances': '/all/internal/auth/instances/token', 'tsd': f'{pnum}/internal/tsd/token', 'refresh': f'{pnum}/auth/refresh/token', 'renew': f'{pnum}/auth/client/secret', @@ -64,7 +66,7 @@ def auth_api_url(env: str, pnum: str, auth_method: str) -> str: } try: if auth_method not in [ - 'basic', 'tsd', 'iam', 'refresh', 'renew', + 'basic', 'tsd', 'iam', 'refresh', 'renew', 'instances' ]: raise Exception(f'Unrecognised auth_method: {auth_method}') host = HOSTS.get(env)