From 07d08f6a5fdca8d9cee4ae162e5823767fd1a82e Mon Sep 17 00:00:00 2001 From: Reinoud van Leeuwen Date: Wed, 10 Jan 2024 15:26:17 +0100 Subject: [PATCH 1/4] add /api endpoint --- README.rst | 30 ++++++++++++++++++++ snappass/main.py | 72 +++++++++++++++++++++++++++++++----------------- 2 files changed, 77 insertions(+), 25 deletions(-) diff --git a/README.rst b/README.rst index cb20c9e2..03ac5eef 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,36 @@ 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 +--- + +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. + +To create a password, send a POST request to ``/api/set_password`` like so: + +:: + + $ curl -X POST -H "Content-Type: application/json" http://localhost:5000/api/set_password/my_password + +This will return a JSON response with the password link: + +:: + + { + "link": "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" http://localhost:5000/api/set_password/my_password/week + +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 50b51f01..5a6a215c 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -16,7 +16,6 @@ HOST_OVERRIDE = os.environ.get('HOST_OVERRIDE', None) TOKEN_SEPARATOR = '~' - # Initialize Flask Application app = Flask(__name__) if os.environ.get('DEBUG'): @@ -28,6 +27,7 @@ # Initialize Redis if os.environ.get('MOCK_REDIS'): from fakeredis import FakeStrictRedis + redis_client = FakeStrictRedis() elif os.environ.get('REDIS_URL'): redis_client = redis.StrictRedis.from_url(os.environ.get('REDIS_URL')) @@ -39,7 +39,8 @@ host=redis_host, port=redis_port, db=redis_db) REDIS_PREFIX = os.environ.get('REDIS_PREFIX', 'snappass') -TIME_CONVERSION = {'two weeks': 1209600, 'week': 604800, 'day': 86400, 'hour': 3600} +TIME_CONVERSION = {'two weeks': 1209600, 'week': 604800, 'day': 86400, + 'hour': 3600} def check_redis_alive(fn): @@ -54,6 +55,7 @@ def inner(*args, **kwargs): sys.exit(0) else: return abort(500) + return inner @@ -136,51 +138,71 @@ def empty(value): return True -def clean_input(): +def clean_input(password=None, ttl=None): """ Make sure we're not getting bad data from the front end, format data to be machine readable """ - if empty(request.form.get('password', '')): + if empty(password): abort(400) - - if empty(request.form.get('ttl', '')): + if empty(ttl): abort(400) - - time_period = request.form['ttl'].lower() + time_period = ttl.lower() if time_period not in TIME_CONVERSION: abort(400) + return ttl, - return TIME_CONVERSION[time_period], request.form['password'] - - -@app.route('/', methods=['GET']) -def index(): - return render_template('set_password.html') - - -@app.route('/', methods=['POST']) -def handle_password(): - ttl, password = clean_input() - token = set_password(password, ttl) +def set_base_url(req): if NO_SSL: if HOST_OVERRIDE: base_url = f'http://{HOST_OVERRIDE}/' else: - base_url = request.url_root + base_url = req.url_root else: if HOST_OVERRIDE: base_url = f'https://{HOST_OVERRIDE}/' else: - base_url = request.url_root.replace("http://", "https://") + base_url = req.url_root.replace("http://", "https://") if URL_PREFIX: base_url = base_url + URL_PREFIX.strip("/") + "/" - link = base_url + quote_plus(token) - if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: + return base_url + + +@app.route('/', methods=['GET']) +def index(): + return render_template('set_password.html') + + +@app.route('/', methods=['POST']) +def handle_password(): + password = request.form.get('password') + ttl = request.form.get('ttl') + if clean_input(password, ttl): + ttl = TIME_CONVERSION[ttl.lower()] + token = set_password(password, ttl) + base_url = set_base_url(request) + link = base_url + quote_plus(token) + if request.accept_mimetypes.accept_json and not \ + request.accept_mimetypes.accept_html: + return jsonify(link=link, ttl=ttl) + else: + return render_template('confirm.html', password_link=link) + else: + abort(500) + + +@app.route('/api/set_password/', methods=['POST']) +@app.route('/api/set_password//', methods=['POST']) +def api_handle_password(password: str, ttl: str = 'two weeks'): + if clean_input(password, ttl): + ttl = TIME_CONVERSION[ttl.lower()] + token = set_password(password, ttl) + base_url = set_base_url(request) + link = base_url + quote_plus(token) return jsonify(link=link, ttl=ttl) else: - return render_template('confirm.html', password_link=link) + abort(500) @app.route('/', methods=['GET']) From 2010d2311e2088490fd9e6b4de8821b0755262d5 Mon Sep 17 00:00:00 2001 From: Reinoud van Leeuwen Date: Wed, 21 Feb 2024 10:47:41 +0100 Subject: [PATCH 2/4] pass password in request body when using API --- README.rst | 10 +++++++--- snappass/main.py | 24 +++++++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 03ac5eef..af35b5e1 100644 --- a/README.rst +++ b/README.rst @@ -107,7 +107,7 @@ To create a password, send a POST request to ``/api/set_password`` like so: :: - $ curl -X POST -H "Content-Type: application/json" http://localhost:5000/api/set_password/my_password + $ curl -X POST -H "Content-Type: application/json" -d '{"password": "foobar"}' http://localhost:5000/api/set_password/ This will return a JSON response with the password link: @@ -122,9 +122,13 @@ the default TTL is 2 weeks (1209600 seconds), but you can override it by adding :: - $ curl -X POST -H "Content-Type: application/json" http://localhost:5000/api/set_password/my_password/week + $ curl -X POST -H "Content-Type: application/json" -d '{"password": "foobar", "ttl": 3600 }' http://localhost:5000/api/set_password/ -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. +Notes: + +- When using the API, 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 2b100722..47cb72c9 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -41,6 +41,8 @@ TIME_CONVERSION = {'two weeks': 1209600, 'week': 604800, 'day': 86400, 'hour': 3600} +DEFAULT_API_TTL = 1209600 +MAX_TTL = DEFAULT_API_TTL def check_redis_alive(fn): @@ -138,7 +140,7 @@ def empty(value): return True -def clean_input(password=None, ttl=None): +def clean_input(password: str = None, ttl: [str,int]=None): """ Make sure we're not getting bad data from the front end, format data to be machine readable @@ -147,10 +149,14 @@ def clean_input(password=None, ttl=None): abort(400) if empty(ttl): abort(400) - time_period = ttl.lower() - if time_period not in TIME_CONVERSION: - abort(400) - return ttl, + if isinstance(ttl, str): + time_period = ttl.lower() + if time_period not in TIME_CONVERSION: + abort(400) + else: + if ttl < 0 or ttl > MAX_TTL: + abort(400) + return True def set_base_url(req): @@ -192,11 +198,11 @@ def handle_password(): abort(500) -@app.route('/api/set_password/', methods=['POST']) -@app.route('/api/set_password//', methods=['POST']) -def api_handle_password(password: str, ttl: str = 'two weeks'): +@app.route('/api/set_password/', methods=['POST']) +def api_handle_password(): + password = request.json.get('password') + ttl = request.json.get('ttl', DEFAULT_API_TTL) if clean_input(password, ttl): - ttl = TIME_CONVERSION[ttl.lower()] token = set_password(password, ttl) base_url = set_base_url(request) link = base_url + quote_plus(token) From ed7a81755415caccc4abd0bab961db6af1a35eab Mon Sep 17 00:00:00 2001 From: Reinoud van Leeuwen Date: Fri, 23 Feb 2024 16:13:44 +0100 Subject: [PATCH 3/4] flake8 fixed; tests added --- snappass/main.py | 27 +++++++++++++-------------- tests.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/snappass/main.py b/snappass/main.py index 47cb72c9..35c97954 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -140,23 +140,22 @@ def empty(value): return True -def clean_input(password: str = None, ttl: [str,int]=None): +def clean_input(): """ Make sure we're not getting bad data from the front end, format data to be machine readable """ - if empty(password): + if empty(request.form.get('password', '')): abort(400) - if empty(ttl): + + if empty(request.form.get('ttl', '')): abort(400) - if isinstance(ttl, str): - time_period = ttl.lower() - if time_period not in TIME_CONVERSION: - abort(400) - else: - if ttl < 0 or ttl > MAX_TTL: - abort(400) - return True + + time_period = request.form['ttl'].lower() + if time_period not in TIME_CONVERSION: + abort(400) + + return TIME_CONVERSION[time_period], request.form['password'] def set_base_url(req): @@ -184,7 +183,7 @@ def index(): def handle_password(): password = request.form.get('password') ttl = request.form.get('ttl') - if clean_input(password, ttl): + if clean_input(): ttl = TIME_CONVERSION[ttl.lower()] token = set_password(password, ttl) base_url = set_base_url(request) @@ -201,8 +200,8 @@ def handle_password(): @app.route('/api/set_password/', methods=['POST']) def api_handle_password(): password = request.json.get('password') - ttl = request.json.get('ttl', DEFAULT_API_TTL) - if clean_input(password, ttl): + ttl = int(request.json.get('ttl', DEFAULT_API_TTL)) + if password and isinstance(ttl, int) and ttl <= MAX_TTL: token = set_password(password, ttl) base_url = set_base_url(request) link = base_url + quote_plus(token) diff --git a/tests.py b/tests.py index b92eeeb7..0cec7069 100644 --- a/tests.py +++ b/tests.py @@ -1,3 +1,4 @@ +import json import re import time import unittest @@ -144,6 +145,7 @@ def test_set_password(self): frozen_time.move_to("2020-05-22 12:00:00") self.assertIsNone(snappass.get_password(key)) + def test_set_password_json(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' @@ -163,6 +165,43 @@ def test_set_password_json(self): frozen_time.move_to("2020-05-22 12:00:00") self.assertIsNone(snappass.get_password(key)) + def test_set_password_api(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, '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_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)) if __name__ == '__main__': unittest.main() From 43a26feb95ac7738141221cf9040e43d2abe349b Mon Sep 17 00:00:00 2001 From: Reinoud van Leeuwen Date: Sat, 24 Feb 2024 00:52:16 +0100 Subject: [PATCH 4/4] flake8 fixed test.py --- tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests.py b/tests.py index 0cec7069..4ef7f0d9 100644 --- a/tests.py +++ b/tests.py @@ -1,4 +1,3 @@ -import json import re import time import unittest @@ -145,7 +144,6 @@ def test_set_password(self): frozen_time.move_to("2020-05-22 12:00:00") self.assertIsNone(snappass.get_password(key)) - def test_set_password_json(self): with freeze_time("2020-05-08 12:00:00") as frozen_time: password = 'my name is my passport. verify me.' @@ -203,5 +201,6 @@ def test_set_password_api_default_ttl(self): frozen_time.move_to("2020-05-22 12:00:00") self.assertIsNone(snappass.get_password(key)) + if __name__ == '__main__': unittest.main()