From 05cd81c671b092cf0062445012af754ad4d51de0 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 11:46:56 +0100 Subject: [PATCH 01/11] :construction: Add a 'modern' REST API --- snappass/main.py | 41 ++++++++++++++ tests.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/snappass/main.py b/snappass/main.py index 597c797..0740519 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -217,6 +217,47 @@ def api_handle_password(): return jsonify(link=link, ttl=ttl) else: abort(500) + +@app.route('/api/v2/passwords/', methods=['POST']) +def api_v2_set_password(): + password = request.json.get('password') + ttl = int(request.json.get('ttl', DEFAULT_API_TTL)) + if not password: + # Add ProblemDetails expliciting issue with Password and/or TTL + abort(400) + + if not isinstance(ttl, int) or ttl > MAX_TTL: + else: + # Return ProblemDetails expliciting issue + abort(400) + + token = set_password(password, ttl) + base_url = set_base_url(request) + link = base_url + quote_plus(token) + return jsonify(link=link, ttl=ttl) + +@app.route('/api/v2/passwords/', methods=['HEAD']) +def api_v2_check_password(): + password_key = unquote_plus(password_key) + if not password_exists(password_key): + # Return NotFound, to indicate that password does not exists (anymore or at all) + # With ProblemDetails expliciting issue (just password not found) + abort(404) + else: + # Return OK, to indicate that password still exists + abort(200) + +@app.route('/api/v2/passwords/', methods=['GET']) +def api_v2_retrieve_password(): + password_key = unquote_plus(password_key) + password = get_password(password_key) + if not password: + # Return NotFound, to indicate that password does not exists (anymore or at all) + # With ProblemDetails expliciting issue (just password not found) + abort(404) + else: + # Return OK and the password in JSON message + return jsonify(passwork=passwork) @app.route('/', methods=['GET']) diff --git a/tests.py b/tests.py index 4ef7f0d..e143bca 100644 --- a/tests.py +++ b/tests.py @@ -201,6 +201,149 @@ class SnapPassRoutesTestCase(TestCase): frozen_time.move_to("2020-05-22 12:00:00") self.assertIsNone(snappass.get_password(key)) + def test_set_password_api_v2(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords/', + headers={'Accept': 'application/json'}, + json={'password': password, 'ttl': '1209600'}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + frozen_time.move_to("2020-05-22 11:59:59") + self.assertEqual(snappass.get_password(key), password) + + frozen_time.move_to("2020-05-22 12:00:00") + self.assertIsNone(snappass.get_password(key)) + + def test_set_password_api_v2_default_ttl(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/set_password/', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + frozen_time.move_to("2020-05-22 11:59:59") + self.assertEqual(snappass.get_password(key), password) + + frozen_time.move_to("2020-05-22 12:00:00") + self.assertIsNone(snappass.get_password(key)) + + def test_set_password_api_v2_no_password(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + rv = self.app.post( + '/api/set_password/', + headers={'Accept': 'application/json'}, + json={'password': None}, + ) + + self.assertEqual(rv.status, 400) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + # TODO : Search for ProblemDetails propreties about Password + + def test_set_password_api_v2_too_big_ttl(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords/', + headers={'Accept': 'application/json'}, + json={'password': password, 'ttl': '1209600000'}, + ) + + self.assertEqual(rv.status, 400) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + # TODO : Search for ProblemDetails propreties about TTL + + def test_check_password_api_v2(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/password/', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + rvc = self.app.head('/api/v2/password/' + quote(key)) + self.assertEqual(rv.status, 200) + + def test_check_password_api_v2_bad_keys(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/password/', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + rvc = self.app.head('/api/v2/password/' + quote(key + key)) + self.assertEqual(rv.status, 404) + + # TODO : Search for ProblemDetails propreties about Password + + def test_retrieve_password_api_v2(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/password/', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + rvc = self.app.get('/api/v2/password/' + quote(key)) + self.assertEqual(rv.status, 200) + + json_content_retrieved = rvc.get_json() + retrieved_password = json_content['password'] + self.assertEqual(retrieved_password, password) + + def test_retrieve_password_api_v2_bad_keys(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/password/', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) + key = unquote(key) + + rvc = self.app.head('/api/v2/password/' + quote(key + key)) + self.assertEqual(rv.status, 404) + + # TODO : Search for ProblemDetails propreties about Password + if __name__ == '__main__': unittest.main() From ed9e715b68acd99543d38afb132b26d8c857ad22 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 13:31:38 +0100 Subject: [PATCH 02/11] :construction: Add RFC7807 response type --- snappass/main.py | 60 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index 0740519..6d7d37e 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -100,6 +100,35 @@ def parse_token(token): decryption_key = None return storage_key, decryption_key + +def as_validation_problem(request, problem_type, problem_title, invalid_params): + base_url = set_base_url(request) + + problem = { + "type": base_url + problem_type, + "title": problem_title, + "invalid-params": invalid_params + } + return as_problem_response(problem) + +def as_not_found_problem(request, problem_type, problem_title, invalid_params): + base_url = set_base_url(request) + + problem = { + "type": base_url + problem_type, + "title": problem_title, + "invalid-params": invalid_params + } + return as_problem_response(problem, 404) + + +def as_problem_response(problem, status_code=None): + if not isinstance(status_code, int) or not status_code: + status_code = 400 + + response = make_response(jsonify(problem), status_code) + response.headers['Content-Type'] = 'application/problem+json' + return response @check_redis_alive @@ -222,14 +251,25 @@ def api_handle_password(): def api_v2_set_password(): password = request.json.get('password') ttl = int(request.json.get('ttl', DEFAULT_API_TTL)) + + invalid_params = [] + if not password: - # Add ProblemDetails expliciting issue with Password and/or TTL - abort(400) - + invalid_params.append({ + "name": "password", + "reason": "The password is required and should not be null or empty." + }) + if not isinstance(ttl, int) or ttl > MAX_TTL: - else: - # Return ProblemDetails expliciting issue - abort(400) + invalid_params.append({ + "name": "ttl", + "reason": "The specified TTL is longer than the maximum supported." + }) + + if len(invalid_params) > 0: + # Return a ProblemDetails expliciting issue with Password and/or TTL + return as_validation_problem(request, "set-password-validation-error", "The password and/or the TTL are invalid.", invalid_params) + token = set_password(password, ttl) base_url = set_base_url(request) @@ -241,11 +281,10 @@ def api_v2_check_password(): password_key = unquote_plus(password_key) if not password_exists(password_key): # Return NotFound, to indicate that password does not exists (anymore or at all) - # With ProblemDetails expliciting issue (just password not found) - abort(404) + return as_not_found_problem(request, "check-password-error", "The password doesn't exists.", [{ "name": "password_key"}]) else: # Return OK, to indicate that password still exists - abort(200) + return ('', 200) @app.route('/api/v2/passwords/', methods=['GET']) def api_v2_retrieve_password(): @@ -253,8 +292,7 @@ def api_v2_retrieve_password(): password = get_password(password_key) if not password: # Return NotFound, to indicate that password does not exists (anymore or at all) - # With ProblemDetails expliciting issue (just password not found) - abort(404) + return as_not_found_problem(request, "get-password-error", "The password doesn't exist.", [{ "name": "password_key"}]) else: # Return OK and the password in JSON message return jsonify(passwork=passwork) From ff35bb649054b38ee69f3c10e7f0b26e5294cf16 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 22:24:52 +0100 Subject: [PATCH 03/11] :construction: Import missing parts --- snappass/main.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index 6d7d37e..0cdb8cf 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -5,10 +5,11 @@ import uuid import redis from cryptography.fernet import Fernet -from flask import abort, Flask, render_template, request, jsonify +from flask import abort, Flask, render_template, request, jsonify, make_response from redis.exceptions import ConnectionError from urllib.parse import quote_plus from urllib.parse import unquote_plus +from urllib.parse import urljoin from distutils.util import strtobool from flask_babel import Babel @@ -270,24 +271,23 @@ def api_v2_set_password(): # Return a ProblemDetails expliciting issue with Password and/or TTL return as_validation_problem(request, "set-password-validation-error", "The password and/or the TTL are invalid.", invalid_params) - token = set_password(password, ttl) base_url = set_base_url(request) - link = base_url + quote_plus(token) + link = urljoin(base_url, request.path + quote_plus(token)) return jsonify(link=link, ttl=ttl) @app.route('/api/v2/passwords/', methods=['HEAD']) -def api_v2_check_password(): +def api_v2_check_password(password_key): password_key = unquote_plus(password_key) if not password_exists(password_key): # Return NotFound, to indicate that password does not exists (anymore or at all) - return as_not_found_problem(request, "check-password-error", "The password doesn't exists.", [{ "name": "password_key"}]) + return ('', 404) else: # Return OK, to indicate that password still exists return ('', 200) @app.route('/api/v2/passwords/', methods=['GET']) -def api_v2_retrieve_password(): +def api_v2_retrieve_password(password_key): password_key = unquote_plus(password_key) password = get_password(password_key) if not password: @@ -295,7 +295,7 @@ def api_v2_retrieve_password(): return as_not_found_problem(request, "get-password-error", "The password doesn't exist.", [{ "name": "password_key"}]) else: # Return OK and the password in JSON message - return jsonify(passwork=passwork) + return jsonify(password=password) @app.route('/', methods=['GET']) From ad5a7de2925c2838dcd5f05d3f194e8a861d2634 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 22:24:58 +0100 Subject: [PATCH 04/11] :art: Cleanup --- snappass/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index 0cdb8cf..1ae77a4 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -101,7 +101,7 @@ def parse_token(token): decryption_key = None return storage_key, decryption_key - + def as_validation_problem(request, problem_type, problem_title, invalid_params): base_url = set_base_url(request) @@ -247,12 +247,12 @@ def api_handle_password(): return jsonify(link=link, ttl=ttl) else: abort(500) - + @app.route('/api/v2/passwords/', methods=['POST']) def api_v2_set_password(): password = request.json.get('password') ttl = int(request.json.get('ttl', DEFAULT_API_TTL)) - + invalid_params = [] if not password: From 82d3a61afd462dbec22031850cc7a614803c2c46 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 23:47:18 +0100 Subject: [PATCH 05/11] :recycle: Use token as name for password_key --- snappass/main.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index 1ae77a4..beed48c 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -276,23 +276,23 @@ def api_v2_set_password(): link = urljoin(base_url, request.path + quote_plus(token)) return jsonify(link=link, ttl=ttl) -@app.route('/api/v2/passwords/', methods=['HEAD']) -def api_v2_check_password(password_key): - password_key = unquote_plus(password_key) - if not password_exists(password_key): +@app.route('/api/v2/passwords/', methods=['HEAD']) +def api_v2_check_password(token): + token = unquote_plus(token) + if not password_exists(token): # Return NotFound, to indicate that password does not exists (anymore or at all) return ('', 404) else: # Return OK, to indicate that password still exists return ('', 200) -@app.route('/api/v2/passwords/', methods=['GET']) -def api_v2_retrieve_password(password_key): - password_key = unquote_plus(password_key) - password = get_password(password_key) +@app.route('/api/v2/passwords/', methods=['GET']) +def api_v2_retrieve_password(token): + token = unquote_plus(token) + password = get_password(token) if not password: # Return NotFound, to indicate that password does not exists (anymore or at all) - return as_not_found_problem(request, "get-password-error", "The password doesn't exist.", [{ "name": "password_key"}]) + return as_not_found_problem(request, "get-password-error", "The password doesn't exist.", [{ "name": "token"}]) else: # Return OK and the password in JSON message return jsonify(password=password) From 2023c9dc35e9fcb509ba90d4b111a60d6ca56bad Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 23:47:30 +0100 Subject: [PATCH 06/11] :technologist: Use HATEHOAS style --- snappass/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index beed48c..ab52f0c 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -248,7 +248,7 @@ def api_handle_password(): else: abort(500) -@app.route('/api/v2/passwords/', methods=['POST']) +@app.route('/api/v2/passwords', methods=['POST']) def api_v2_set_password(): password = request.json.get('password') ttl = int(request.json.get('ttl', DEFAULT_API_TTL)) @@ -272,9 +272,18 @@ def api_v2_set_password(): return as_validation_problem(request, "set-password-validation-error", "The password and/or the TTL are invalid.", invalid_params) token = set_password(password, ttl) + url_token= quote_plus(token) base_url = set_base_url(request) - link = urljoin(base_url, request.path + quote_plus(token)) - return jsonify(link=link, ttl=ttl) + link = urljoin(base_url, request.path + "/" + url_token) + response_content = { + "token": url_token, + "links": [{ + "rel": "self", + "href": link + }], + "ttl": ttl + } + return jsonify(response_content) @app.route('/api/v2/passwords/', methods=['HEAD']) def api_v2_check_password(token): From 3cfd5f03c09383f61b793feb758608d9763a63ab Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 23:47:42 +0100 Subject: [PATCH 07/11] :white_check_mark: Finish test suite implementation --- tests.py | 102 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/tests.py b/tests.py index e143bca..fa24902 100644 --- a/tests.py +++ b/tests.py @@ -4,6 +4,7 @@ import unittest import uuid from unittest import TestCase from unittest import mock +from urllib.parse import quote from urllib.parse import unquote from cryptography.fernet import Fernet @@ -205,14 +206,13 @@ class SnapPassRoutesTestCase(TestCase): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/passwords/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password, 'ttl': '1209600'}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) frozen_time.move_to("2020-05-22 11:59:59") self.assertEqual(snappass.get_password(key), password) @@ -224,14 +224,13 @@ class SnapPassRoutesTestCase(TestCase): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/set_password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) frozen_time.move_to("2020-05-22 11:59:59") self.assertEqual(snappass.get_password(key), password) @@ -242,107 +241,124 @@ class SnapPassRoutesTestCase(TestCase): def test_set_password_api_v2_no_password(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: rv = self.app.post( - '/api/set_password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, - json={'password': None}, + json={'password': ''}, ) - self.assertEqual(rv.status, 400) + self.assertEqual(rv.status_code, 400) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) - - # TODO : Search for ProblemDetails propreties about Password + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_password = invalid_params[0] + self.assertEqual(bad_password['name'], 'password') def test_set_password_api_v2_too_big_ttl(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/passwords/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password, 'ttl': '1209600000'}, ) - self.assertEqual(rv.status, 400) + self.assertEqual(rv.status_code, 400) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_ttl = invalid_params[0] + self.assertEqual(bad_ttl['name'], 'ttl') + + def test_set_password_api_v2_no_password_and_too_big_ttl(self): + with freeze_time("2020-05-08 12:00:00") as frozen_time: + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': '', 'ttl': '1209600000'}, + ) - # TODO : Search for ProblemDetails propreties about TTL + self.assertEqual(rv.status_code, 400) + + json_content = rv.get_json() + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 2) + bad_password = invalid_params[0] + self.assertEqual(bad_password['name'], 'password') + bad_ttl = invalid_params[1] + self.assertEqual(bad_ttl['name'], 'ttl') def test_check_password_api_v2(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) - rvc = self.app.head('/api/v2/password/' + quote(key)) - self.assertEqual(rv.status, 200) + rvc = self.app.head('/api/v2/passwords/' + quote(key)) + self.assertEqual(rv.status_code, 200) def test_check_password_api_v2_bad_keys(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) - rvc = self.app.head('/api/v2/password/' + quote(key + key)) - self.assertEqual(rv.status, 404) - - # TODO : Search for ProblemDetails propreties about Password + rvc = self.app.head('/api/v2/passwords/' + quote(key[::-1])) + self.assertEqual(rvc.status_code, 404) def test_retrieve_password_api_v2(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) - rvc = self.app.get('/api/v2/password/' + quote(key)) - self.assertEqual(rv.status, 200) + rvc = self.app.get('/api/v2/passwords/' + quote(key)) + self.assertEqual(rv.status_code, 200) json_content_retrieved = rvc.get_json() - retrieved_password = json_content['password'] + retrieved_password = json_content_retrieved['password'] self.assertEqual(retrieved_password, password) def test_retrieve_password_api_v2_bad_keys(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' rv = self.app.post( - '/api/v2/password/', + '/api/v2/passwords', headers={'Accept': 'application/json'}, json={'password': password}, ) json_content = rv.get_json() - key = re.search(r'https://localhost/([^"]+)', json_content['link']).group(1) - key = unquote(key) + key = unquote(json_content['token']) - rvc = self.app.head('/api/v2/password/' + quote(key + key)) - self.assertEqual(rv.status, 404) - - # TODO : Search for ProblemDetails propreties about Password + rvc = self.app.get('/api/v2/passwords/' + quote(key[::-1])) + self.assertEqual(rvc.status_code, 404) + + json_content_retrieved = rvc.get_json() + invalid_params = json_content_retrieved['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_token = invalid_params[0] + self.assertEqual(bad_token['name'], 'token') if __name__ == '__main__': From 26b26f9c2542bc0324dcaee39ab596f1593a6513 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 23:49:56 +0100 Subject: [PATCH 08/11] :memo: Add documentation about new APIs --- README.rst | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 145 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index af35b5e..3f45ba8 100644 --- a/README.rst +++ b/README.rst @@ -96,12 +96,17 @@ need to change this. ``HOST_OVERRIDE``: (optional) Used to override the base URL if the app is unaware. Useful when running behind reverse proxies like an identity-aware SSO. Example: ``sub.domain.com`` -API ---- +APIs +---- -SnapPass also has a simple API that can be used to create passwords links. The advantage of using the API is that -you can create a password and retrieve the link without having to open the web interface. This is useful if you want to -embed it in a script or use it in a CI/CD pipeline. +SnapPass has 2 APIs : +1. A simple API : That can be used to create passwords links, and then share them with users +2. A more REST-y API : Which facilitate programmatic interactions with SnapPass, without having to parse HTML content when retrieving the password + +Simple API +^^^^^^^^^^ + +The advantage of using the simple API is that you can create a password and retrieve the link without having to open the web interface. This is useful if you want to embed it in a script or use it in a CI/CD pipeline. To create a password, send a POST request to ``/api/set_password`` like so: @@ -124,12 +129,146 @@ the default TTL is 2 weeks (1209600 seconds), but you can override it by adding $ curl -X POST -H "Content-Type: application/json" -d '{"password": "foobar", "ttl": 3600 }' http://localhost:5000/api/set_password/ + +REST API +^^^^^^^^ + +The advantage of using the REST API is that you can fully manage the lifecycle of the password stored in SnapPass without having to interact with any web user interface. + +This is useful if you want to embed it in a script, use it in a CI/CD pipeline or share it between multiple client applications. + +Create a password +""""""""""""""""" + +To create a password, send a POST request to ``/api/v2/passwords`` like so: + +:: + + $ curl -X POST -H "Content-Type: application/json" -d '{"password": "foobar"}' http://localhost:5000/api/v2/passwords + +This will return a JSON response with a token and the password link: + +:: + + { + "token": "snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", + "links": [{ + "rel": "self", + "href": "http://127.0.0.1:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", + }], + "ttl":1209600 + } + +The default TTL is 2 weeks (1209600 seconds), but you can override it by adding a expiration parameter: + +:: + + $ curl -X POST -H "Content-Type: application/json" -d '{"password": "foobar", "ttl": 3600 }' http://localhost:5000/api/v2/passwords + +If the password is null or empty, and the TTL is larger than the max TTL of the application, the API will return an error like this: + + +Otherwise, the API will return a 404 (Not Found) response like so: + +:: + + { + "invalid-params": [{ + "name": "password", + "reason": "The password is required and should not be null or empty." + }, { + "name": "ttl", + "reason": "The specified TTL is longer than the maximum supported." + }], + "title": "The password and/or the TTL are invalid.", + "type": "https://127.0.0.1:5000/set-password-validation-error" + } + +Check if a password exists +"""""""""""""""""""""""""" + +To check if a password exists, send a HEAD request to ``/api/v2/passwords/``, where ```` is the token of the API response when a password is created, or simply use the `self` link: + +:: + + $ curl --head http://localhost:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D + +If : +- the passwork_key is valid +- the password : + - exists, + - has not been read + - is not expired + +Then the API will return a 200 (OK) response like so: + +:: + + HTTP/1.1 200 OK + Server: Werkzeug/3.0.1 Python/3.12.2 + Date: Fri, 29 Mar 2024 22:15:54 GMT + Content-Type: text/html; charset=utf-8 + Content-Length: 0 + Connection: close + +Otherwise, the API will return a 404 (Not Found) response like so: + +:: + + HTTP/1.1 404 NOT FOUND + Server: Werkzeug/3.0.1 Python/3.12.2 + Date: Fri, 29 Mar 2024 22:19:29 GMT + Content-Type: text/html; charset=utf-8 + Content-Length: 0 + Connection: close + + +Read a password +""""""""""""""" + +To read a password, send a GET request to ``/api/v2/passwords/``, where ```` is the token of the API response when a password is created, or simply use the `self` link: + +:: + + $ curl -X GET http://localhost:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D + +If : +- the passwork_key is valid +- the password : + - exists, + - has not been read + - is not expired + +Then the API will return a 200 (OK) with a JSON response containing the password : + +:: + + { + "password": "foobar" + } + +Otherwise, the API will return a 404 (Not Found) response like so: + +:: + + { + "invalid-params": [{ + "name": "token" + }], + "title": "The password doesn't exist.", + "type": "https://127.0.0.1:5000/get-password-error" + } + +Notes on APIs +^^^^^^^^^^^^^ + Notes: -- When using the API, you can specify any ttl, as long as it is lower than the default. +- When using the APIs, you can specify any ttl, as long as it is lower than the default. - The password is passed in the body of the request rather than in the URL. This is to prevent the password from being logged in the server logs. - Depending on the environment you are running it, you might want to expose the ``/api`` endpoint to your internal network only, and put the web interface behind authentication. + Docker ------ From 04235c1edc4a562a64cb5322bd4834571f1f0f38 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Fri, 29 Mar 2024 23:59:15 +0100 Subject: [PATCH 09/11] :art: flake8 --- snappass/main.py | 21 +++++- tests.py | 190 +++++++++++++++++++++++------------------------ 2 files changed, 109 insertions(+), 102 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index ab52f0c..9ef5a43 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -102,6 +102,7 @@ def parse_token(token): return storage_key, decryption_key + def as_validation_problem(request, problem_type, problem_title, invalid_params): base_url = set_base_url(request) @@ -112,6 +113,7 @@ def as_validation_problem(request, problem_type, problem_title, invalid_params): } return as_problem_response(problem) + def as_not_found_problem(request, problem_type, problem_title, invalid_params): base_url = set_base_url(request) @@ -248,6 +250,7 @@ def api_handle_password(): else: abort(500) + @app.route('/api/v2/passwords', methods=['POST']) def api_v2_set_password(): password = request.json.get('password') @@ -269,10 +272,15 @@ def api_v2_set_password(): if len(invalid_params) > 0: # Return a ProblemDetails expliciting issue with Password and/or TTL - return as_validation_problem(request, "set-password-validation-error", "The password and/or the TTL are invalid.", invalid_params) + return as_validation_problem( + request, + "set-password-validation-error", + "The password and/or the TTL are invalid.", + invalid_params + ) token = set_password(password, ttl) - url_token= quote_plus(token) + url_token = quote_plus(token) base_url = set_base_url(request) link = urljoin(base_url, request.path + "/" + url_token) response_content = { @@ -285,6 +293,7 @@ def api_v2_set_password(): } return jsonify(response_content) + @app.route('/api/v2/passwords/', methods=['HEAD']) def api_v2_check_password(token): token = unquote_plus(token) @@ -295,13 +304,19 @@ def api_v2_check_password(token): # Return OK, to indicate that password still exists return ('', 200) + @app.route('/api/v2/passwords/', methods=['GET']) def api_v2_retrieve_password(token): token = unquote_plus(token) password = get_password(token) if not password: # Return NotFound, to indicate that password does not exists (anymore or at all) - return as_not_found_problem(request, "get-password-error", "The password doesn't exist.", [{ "name": "token"}]) + return as_not_found_problem( + request, + "get-password-error", + "The password doesn't exist.", + [{"name": "token"}] + ) else: # Return OK and the password in JSON message return jsonify(password=password) diff --git a/tests.py b/tests.py index fa24902..b4b089e 100644 --- a/tests.py +++ b/tests.py @@ -239,126 +239,118 @@ class SnapPassRoutesTestCase(TestCase): self.assertIsNone(snappass.get_password(key)) def test_set_password_api_v2_no_password(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': ''}, - ) + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': ''}, + ) - self.assertEqual(rv.status_code, 400) + self.assertEqual(rv.status_code, 400) + + json_content = rv.get_json() + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_password = invalid_params[0] + self.assertEqual(bad_password['name'], 'password') - json_content = rv.get_json() - invalid_params = json_content['invalid-params'] - self.assertEqual(len(invalid_params), 1) - bad_password = invalid_params[0] - self.assertEqual(bad_password['name'], 'password') - def test_set_password_api_v2_too_big_ttl(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': password, 'ttl': '1209600000'}, - ) + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password, 'ttl': '1209600000'}, + ) - self.assertEqual(rv.status_code, 400) + self.assertEqual(rv.status_code, 400) + + json_content = rv.get_json() + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_ttl = invalid_params[0] + self.assertEqual(bad_ttl['name'], 'ttl') - json_content = rv.get_json() - invalid_params = json_content['invalid-params'] - self.assertEqual(len(invalid_params), 1) - bad_ttl = invalid_params[0] - self.assertEqual(bad_ttl['name'], 'ttl') - def test_set_password_api_v2_no_password_and_too_big_ttl(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': '', 'ttl': '1209600000'}, - ) + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': '', 'ttl': '1209600000'}, + ) - self.assertEqual(rv.status_code, 400) + self.assertEqual(rv.status_code, 400) - json_content = rv.get_json() - invalid_params = json_content['invalid-params'] - self.assertEqual(len(invalid_params), 2) - bad_password = invalid_params[0] - self.assertEqual(bad_password['name'], 'password') - bad_ttl = invalid_params[1] - self.assertEqual(bad_ttl['name'], 'ttl') + json_content = rv.get_json() + invalid_params = json_content['invalid-params'] + self.assertEqual(len(invalid_params), 2) + bad_password = invalid_params[0] + self.assertEqual(bad_password['name'], 'password') + bad_ttl = invalid_params[1] + self.assertEqual(bad_ttl['name'], 'ttl') def test_check_password_api_v2(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': password}, - ) + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) - json_content = rv.get_json() - key = unquote(json_content['token']) - - rvc = self.app.head('/api/v2/passwords/' + quote(key)) - self.assertEqual(rv.status_code, 200) + json_content = rv.get_json() + key = unquote(json_content['token']) + + rvc = self.app.head('/api/v2/passwords/' + quote(key)) + self.assertEqual(rvc.status_code, 200) def test_check_password_api_v2_bad_keys(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': password}, - ) + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) - json_content = rv.get_json() - key = unquote(json_content['token']) - - rvc = self.app.head('/api/v2/passwords/' + quote(key[::-1])) - self.assertEqual(rvc.status_code, 404) + json_content = rv.get_json() + key = unquote(json_content['token']) + + rvc = self.app.head('/api/v2/passwords/' + quote(key[::-1])) + self.assertEqual(rvc.status_code, 404) def test_retrieve_password_api_v2(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': password}, - ) + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) - json_content = rv.get_json() - key = unquote(json_content['token']) - - rvc = self.app.get('/api/v2/passwords/' + quote(key)) - self.assertEqual(rv.status_code, 200) + json_content = rv.get_json() + key = unquote(json_content['token']) - json_content_retrieved = rvc.get_json() - retrieved_password = json_content_retrieved['password'] - self.assertEqual(retrieved_password, password) + rvc = self.app.get('/api/v2/passwords/' + quote(key)) + self.assertEqual(rv.status_code, 200) + + json_content_retrieved = rvc.get_json() + retrieved_password = json_content_retrieved['password'] + self.assertEqual(retrieved_password, password) def test_retrieve_password_api_v2_bad_keys(self): - with freeze_time("2020-05-08 12:00:00") as frozen_time: - password = 'my name is my passport. verify me.' - rv = self.app.post( - '/api/v2/passwords', - headers={'Accept': 'application/json'}, - json={'password': password}, - ) + password = 'my name is my passport. verify me.' + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) - json_content = rv.get_json() - key = unquote(json_content['token']) - - rvc = self.app.get('/api/v2/passwords/' + quote(key[::-1])) - self.assertEqual(rvc.status_code, 404) - - json_content_retrieved = rvc.get_json() - invalid_params = json_content_retrieved['invalid-params'] - self.assertEqual(len(invalid_params), 1) - bad_token = invalid_params[0] - self.assertEqual(bad_token['name'], 'token') + json_content = rv.get_json() + key = unquote(json_content['token']) + + rvc = self.app.get('/api/v2/passwords/' + quote(key[::-1])) + self.assertEqual(rvc.status_code, 404) + + json_content_retrieved = rvc.get_json() + invalid_params = json_content_retrieved['invalid-params'] + self.assertEqual(len(invalid_params), 1) + bad_token = invalid_params[0] + self.assertEqual(bad_token['name'], 'token') if __name__ == '__main__': From b69290425a301366b26042577e74f18abcb20f3f Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Sat, 30 Mar 2024 20:46:02 +0100 Subject: [PATCH 10/11] :children_crossing: Remove URL encoding from token --- README.rst | 7 ++++--- snappass/main.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 3f45ba8..8c55ce5 100644 --- a/README.rst +++ b/README.rst @@ -152,6 +152,7 @@ This will return a JSON response with a token and the password link: { "token": "snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", + "token": "snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY=", "links": [{ "rel": "self", "href": "http://127.0.0.1:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", @@ -187,7 +188,7 @@ Otherwise, the API will return a 404 (Not Found) response like so: Check if a password exists """""""""""""""""""""""""" -To check if a password exists, send a HEAD request to ``/api/v2/passwords/``, where ```` is the token of the API response when a password is created, or simply use the `self` link: +To check if a password exists, send a HEAD request to ``/api/v2/passwords/``, where ```` is the token of the API response when a password is created (url encoded), or simply use the `self` link: :: @@ -233,9 +234,9 @@ To read a password, send a GET request to ``/api/v2/passwords/``, $ curl -X GET http://localhost:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D If : -- the passwork_key is valid +- the token is valid - the password : - - exists, + - exists - has not been read - is not expired diff --git a/snappass/main.py b/snappass/main.py index 9ef5a43..9b04690 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -285,6 +285,7 @@ def api_v2_set_password(): link = urljoin(base_url, request.path + "/" + url_token) response_content = { "token": url_token, + "token": token, "links": [{ "rel": "self", "href": link From e4803977c0dc6be1a88abfcbe5bd2d1accdd3b78 Mon Sep 17 00:00:00 2001 From: Emilien GUILMINEAU Date: Sat, 30 Mar 2024 20:47:03 +0100 Subject: [PATCH 11/11] :children_crossing: Add a link to web view Allowing usage of the password into a subsystem which target human --- README.rst | 4 +++- snappass/main.py | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 8c55ce5..6e98da7 100644 --- a/README.rst +++ b/README.rst @@ -151,11 +151,13 @@ This will return a JSON response with a token and the password link: :: { - "token": "snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", "token": "snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY=", "links": [{ "rel": "self", "href": "http://127.0.0.1:5000/api/v2/passwords/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", + },{ + "rel": "web-view", + "href": "http://127.0.0.1:5000/snappassbedf19b161794fd288faec3eba15fa41~hHnILpQ50ZfJc3nurDfHCb_22rBr5gGEya68e_cZOrY%3D", }], "ttl":1209600 } diff --git a/snappass/main.py b/snappass/main.py index 9b04690..6f06572 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -282,13 +282,16 @@ def api_v2_set_password(): token = set_password(password, ttl) url_token = quote_plus(token) base_url = set_base_url(request) - link = urljoin(base_url, request.path + "/" + url_token) + api_link = urljoin(base_url, request.path + "/" + url_token) + web_link = urljoin(base_url, url_token) response_content = { - "token": url_token, "token": token, "links": [{ "rel": "self", - "href": link + "href": api_link + }, { + "rel": "web-view", + "href": web_link }], "ttl": ttl }