Merge pull request #346 from XREvo/master
Adding APIs to manage password lifecycle
This commit is contained in:
commit
20136d9dc0
3 changed files with 407 additions and 7 deletions
154
README.rst
154
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``
|
``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
|
SnapPass has 2 APIs :
|
||||||
you can create a password and retrieve the link without having to open the web interface. This is useful if you want to
|
1. A simple API : That can be used to create passwords links, and then share them with users
|
||||||
embed it in a script or use it in a CI/CD pipeline.
|
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:
|
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/
|
$ 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/<token>``, where ``<token>`` 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/<password_key>``, where ``<password_key>`` 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:
|
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.
|
- 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.
|
- 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
|
Docker
|
||||||
------
|
------
|
||||||
|
|
||||||
|
|
109
snappass/main.py
109
snappass/main.py
|
@ -5,10 +5,11 @@ import uuid
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
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 redis.exceptions import ConnectionError
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
from urllib.parse import unquote_plus
|
from urllib.parse import unquote_plus
|
||||||
|
from urllib.parse import urljoin
|
||||||
from distutils.util import strtobool
|
from distutils.util import strtobool
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
|
|
||||||
|
@ -102,6 +103,37 @@ def parse_token(token):
|
||||||
return storage_key, decryption_key
|
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
|
@check_redis_alive
|
||||||
def set_password(password, ttl):
|
def set_password(password, ttl):
|
||||||
"""
|
"""
|
||||||
|
@ -219,6 +251,81 @@ def api_handle_password():
|
||||||
abort(500)
|
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/<token>', 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/<token>', 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('/<password_key>', methods=['GET'])
|
@app.route('/<password_key>', methods=['GET'])
|
||||||
def preview_password(password_key):
|
def preview_password(password_key):
|
||||||
password_key = unquote_plus(password_key)
|
password_key = unquote_plus(password_key)
|
||||||
|
|
151
tests.py
151
tests.py
|
@ -4,6 +4,7 @@ import unittest
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from urllib.parse import quote
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
@ -201,6 +202,156 @@ class SnapPassRoutesTestCase(TestCase):
|
||||||
frozen_time.move_to("2020-05-22 12:00:00")
|
frozen_time.move_to("2020-05-22 12:00:00")
|
||||||
self.assertIsNone(snappass.get_password(key))
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
Loading…
Reference in a new issue