diff --git a/Dockerfile b/Dockerfile index 70e18c50..ee33ea7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,10 @@ WORKDIR $APP_DIR COPY ["setup.py", "requirements.txt", "MANIFEST.in", "README.rst", "AUTHORS.rst", "$APP_DIR/"] COPY ["./snappass", "$APP_DIR/snappass"] +RUN pip install -r requirements.txt + +RUN pybabel compile -d snappass/translations + RUN python setup.py install && \ chown -R snappass $APP_DIR && \ chgrp -R snappass $APP_DIR diff --git a/MANIFEST.in b/MANIFEST.in index ec67e989..e919377d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include *.rst LICENSE recursive-include snappass/static * recursive-include snappass/templates * +recursive-include snappass/translations * diff --git a/README.rst b/README.rst index af35b5e1..251b50fb 100644 --- a/README.rst +++ b/README.rst @@ -96,12 +96,21 @@ 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_BIND_ADDRESS``: (optional) Used to override the default bind address of 0.0.0.0 for flask app Example: ``127.0.0.1`` -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_PORT``: (optional) Used to override the default port of 5000 Example: ``6000`` + +APIs +---- + +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 +133,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/dev-requirements.txt b/dev-requirements.txt index 177d57eb..1f11ce37 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,9 +1,9 @@ -coverage==7.4.2 -fakeredis==2.21.1 -flake8==7.0.0 -freezegun==1.4.0 -pytest==8.1.0 -pytest-cov==4.1.0 -tox==4.13.0 +coverage==7.6.0 +fakeredis==2.25.1 +flake8==7.1.1 +freezegun==1.5.1 +pytest==8.3.2 +pytest-cov==5.0.0 +tox==4.23.0 bumpversion==0.6.0 -wheel==0.42.0 +wheel==0.44.0 diff --git a/docker-compose.yml b/docker-compose.yml index 859af497..c2c7f159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: snappass: - image: snappass:v1.6.2 + image: trackabout/snappass:main ports: - "5000:5000" stop_signal: SIGINT diff --git a/requirements.txt b/requirements.txt index bad23300..53305fc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -cryptography==42.0.4 +cryptography==43.0.1 Flask==3.0.0 -itsdangerous==2.1.2 +itsdangerous==2.2.0 Jinja2==3.1.4 MarkupSafe==2.1.1 -redis==5.0.1 +redis==5.1.1 Werkzeug==3.0.3 flask-babel diff --git a/snappass/main.py b/snappass/main.py index 597c7970..09867e42 100644 --- a/snappass/main.py +++ b/snappass/main.py @@ -5,12 +5,14 @@ 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 +# _ is required to get the Jinja templates translated +from flask_babel import Babel, _ # noqa: F401 NO_SSL = bool(strtobool(os.environ.get('NO_SSL', 'False'))) URL_PREFIX = os.environ.get('URL_PREFIX', None) @@ -102,6 +104,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 +252,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) @@ -246,7 +354,8 @@ def health_check(): @check_redis_alive def main(): - app.run(host='0.0.0.0') + app.run(host=os.environ.get('SNAPPASS_BIND_ADDRESS', '0.0.0.0'), + port=os.environ.get('SNAPPASS_PORT', 5000)) if __name__ == '__main__': diff --git a/tests.py b/tests.py index 4ef7f0d9..b4b089e9 100644 --- a/tests.py +++ b/tests.py @@ -4,6 +4,7 @@ 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 @@ def test_set_password_api_default_ttl(self): 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()