Merge pull request #346 from XREvo/master

Adding APIs to manage password lifecycle
This commit is contained in:
Yuping Li 2024-04-08 15:53:35 -07:00 committed by GitHub
commit 20136d9dc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 407 additions and 7 deletions

View file

@ -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
------ ------

View file

@ -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
View file

@ -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()