diff --git a/README.rst b/README.rst index af35b5e..6e98da7 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,149 @@ 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=", + "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 + } + +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 (url encoded), 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 token 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 ------ diff --git a/snappass/main.py b/snappass/main.py index 597c797..6f06572 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 @@ -102,6 +103,37 @@ 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) + + 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 def set_password(password, ttl): """ @@ -219,6 +251,81 @@ def api_handle_password(): 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: + 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: + 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) + url_token = quote_plus(token) + base_url = set_base_url(request) + api_link = urljoin(base_url, request.path + "/" + url_token) + web_link = urljoin(base_url, url_token) + response_content = { + "token": token, + "links": [{ + "rel": "self", + "href": api_link + }, { + "rel": "web-view", + "href": web_link + }], + "ttl": ttl + } + return jsonify(response_content) + + +@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(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"}] + ) + else: + # Return OK and the password in JSON message + return jsonify(password=password) + + @app.route('/', methods=['GET']) def preview_password(password_key): password_key = unquote_plus(password_key) diff --git a/tests.py b/tests.py index 4ef7f0d..b4b089e 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 @@ -201,6 +202,156 @@ 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 = unquote(json_content['token']) + + 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/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': password}, + ) + + json_content = rv.get_json() + key = unquote(json_content['token']) + + 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): + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': ''}, + ) + + 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') + + def test_set_password_api_v2_too_big_ttl(self): + 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) + + 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): + rv = self.app.post( + '/api/v2/passwords', + headers={'Accept': 'application/json'}, + json={'password': '', 'ttl': '1209600000'}, + ) + + 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): + 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(rvc.status_code, 200) + + def test_check_password_api_v2_bad_keys(self): + 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) + + def test_retrieve_password_api_v2(self): + 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_retrieved = rvc.get_json() + retrieved_password = json_content_retrieved['password'] + self.assertEqual(retrieved_password, password) + + def test_retrieve_password_api_v2_bad_keys(self): + 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') + if __name__ == '__main__': unittest.main()