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``
|
||||
|
||||
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/<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:
|
||||
|
||||
- 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
|
||||
------
|
||||
|
||||
|
|
109
snappass/main.py
109
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/<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'])
|
||||
def preview_password(password_key):
|
||||
password_key = unquote_plus(password_key)
|
||||
|
|
151
tests.py
151
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()
|
||||
|
|
Loading…
Reference in a new issue