Compare commits

...

4 Commits

Author SHA1 Message Date
3599e80e77
docs/authentication.rst: added docs for password change 2023-05-22 21:01:21 -04:00
ca0c6512fe
/users/password: implemented endpoint
also added uuid to JTIs in JWTs to prevent issues in tests
2023-05-22 20:52:46 -04:00
0bd36b9a95
tests/test_auth.py: remove checks on forbidden names 2023-05-22 19:56:35 -04:00
2d56823b17
sachet/server/users/manage.py: remove checks on forbidden names 2023-05-22 12:23:58 -04:00
5 changed files with 134 additions and 19 deletions

View File

@ -28,6 +28,12 @@ The server will respond like this:
"username": "user" "username": "user"
} }
.. warning::
Ensure that you are indeed using ``POST``.
Otherwise, you are querying the user with the name ``login``.
This will result in a "not authorized" error.
Save the token in ``auth_token``. Save the token in ``auth_token``.
.. _authentication_usage: .. _authentication_usage:
@ -69,7 +75,7 @@ You can now use the new token in ``auth_token`` for future authentication.
This does not revoke your old token. This does not revoke your old token.
See :ref:`authentication_log_out` for information on revoking tokens. See :ref:`authentication_log_out` for information on revoking tokens.
.. note:: .. warning::
Remember to use the ``POST`` HTTP method and not ``GET``. Remember to use the ``POST`` HTTP method and not ``GET``.
If you use ``GET`` by accident, the server will assume you're trying to read the information of a user called 'extend'. If you use ``GET`` by accident, the server will assume you're trying to read the information of a user called 'extend'.
This will result in a "not authorized" error. This will result in a "not authorized" error.
@ -87,3 +93,33 @@ Use the following request body:
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODUwNTk3NjIsImlhdCI6MTY4NDQ1NDk2Miwic3ViIjoidXNlciIsImp0aSI6InJlbmV3In0.ZITIK8L5FzLtm-ASwIf6TkTb69z4bsZ8FF0mWee4YI4" "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2ODUwNTk3NjIsImlhdCI6MTY4NDQ1NDk2Miwic3ViIjoidXNlciIsImp0aSI6InJlbmV3In0.ZITIK8L5FzLtm-ASwIf6TkTb69z4bsZ8FF0mWee4YI4"
} }
.. warning::
Ensure that you are indeed using ``POST``.
Otherwise, you are querying the user with the name ``logout``.
This will result in a "not authorized" error.
.. _authentication_password_change:
Password change
---------------
.. note::
Administrators can change a user's password via the ``PATCH/PUT /users/<username>`` endpoint.
See :ref:`user_info_api`.
A user can change their own password via the password change API::
POST /users/password
Use the following request body:
.. code-block:: json
{
"old": "old_password",
"new": "new_password"
}
Send the user's current password in ``old``, and Sachet will change it to the password in ``new``.

View File

@ -87,13 +87,17 @@ class User(db.Model):
permissions.AllFlags = Permissions permissions.AllFlags = Permissions
self.permissions = permissions self.permissions = permissions
self.password = bcrypt.generate_password_hash( self.password = self.gen_hash(password)
password, current_app.config.get("BCRYPT_LOG_ROUNDS")
).decode()
self.username = username self.username = username
self.url = url_for("users_blueprint.user_api", username=self.username) self.url = url_for("users_blueprint.user_api", username=self.username)
self.register_date = datetime.datetime.now() self.register_date = datetime.datetime.now()
def gen_hash(self, psswd):
"""Generates a hash from a password."""
return bcrypt.generate_password_hash(
psswd, current_app.config.get("BCRYPT_LOG_ROUNDS")
).decode()
def encode_token(self, jti=None): def encode_token(self, jti=None):
"""Generates an authentication token""" """Generates an authentication token"""
payload = { payload = {

View File

@ -3,12 +3,6 @@ from sachet.server.models import User
def create_user(permissions, username, password): def create_user(permissions, username, password):
# to reduce confusion with API endpoints
forbidden = {"login", "logout", "extend"}
if username in forbidden:
raise KeyError(f"Username '{username}' is reserved and can not be used.")
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if not user: if not user:
user = User(username=username, password=password, permissions=permissions) user = User(username=username, password=password, permissions=permissions)

View File

@ -8,6 +8,7 @@ from sachet.server.models import (
) )
from sachet.server.views_common import ModelAPI, ModelListAPI, auth_required from sachet.server.views_common import ModelAPI, ModelListAPI, auth_required
from sachet.server import bcrypt, db from sachet.server import bcrypt, db
import uuid
users_blueprint = Blueprint("users_blueprint", __name__) users_blueprint = Blueprint("users_blueprint", __name__)
@ -21,7 +22,7 @@ class LoginAPI(MethodView):
return jsonify(resp), 401 return jsonify(resp), 401
if bcrypt.check_password_hash(user.password, post_data.get("password", "")): if bcrypt.check_password_hash(user.password, post_data.get("password", "")):
token = user.encode_token() token = user.encode_token(jti=f"login_{uuid.uuid4()}")
resp = { resp = {
"status": "success", "status": "success",
"message": "Logged in.", "message": "Logged in.",
@ -88,12 +89,58 @@ users_blueprint.add_url_rule(
) )
class PasswordAPI(MethodView):
"""Endpoint to change passwords."""
@auth_required
def post(self, auth_user=None):
post_data = request.get_json()
old_psswd = post_data.get("old")
new_psswd = post_data.get("new")
if not old_psswd or not new_psswd:
return (
jsonify(
{
"status": "fail",
"message": "Specify the 'old' password and the 'new' password.",
}
),
400,
)
if not bcrypt.check_password_hash(auth_user.password, old_psswd):
return (
jsonify(
{
"status": "fail",
"message": "Invalid 'old' password.",
}
),
400,
)
else:
auth_user.password = auth_user.gen_hash(new_psswd)
db.session.commit()
return jsonify(
{
"status": "success",
"message": "Password changed.",
}
)
users_blueprint.add_url_rule(
"/users/password", view_func=PasswordAPI.as_view("password_api"), methods=["POST"]
)
class ExtendAPI(MethodView): class ExtendAPI(MethodView):
"""Endpoint to take a token and get a new one with a later expiry date.""" """Endpoint to take a token and get a new one with a later expiry date."""
@auth_required @auth_required
def post(self, auth_user=None): def post(self, auth_user=None):
token = auth_user.encode_token(jti="renew") token = auth_user.encode_token(jti=f"renew{uuid.uuid4()}")
resp = { resp = {
"status": "success", "status": "success",
"message": "Renewed token.", "message": "Renewed token.",

View File

@ -4,13 +4,6 @@ from sachet.server import db
from sachet.server.users import manage from sachet.server.users import manage
def test_reserved_users(client):
"""Test that the server prevents reserved endpoints from being registered as usernames."""
for user in ["login", "logout", "extend"]:
with pytest.raises(KeyError):
manage.create_user(False, user, "")
def test_unauth_perms(client): def test_unauth_perms(client):
"""Test endpoints to see if they allow unauthenticated users.""" """Test endpoints to see if they allow unauthenticated users."""
resp = client.get("/users/jeff") resp = client.get("/users/jeff")
@ -144,6 +137,47 @@ def test_logout(client, tokens, validate_info, auth):
assert resp.status_code == 401 assert resp.status_code == 401
def test_password_change(client, tokens, users, auth):
"""Test changing passwords."""
# test that we're logged in
resp = client.get("/users/jeff", headers=auth("jeff"))
assert resp.status_code == 200
# change password
resp = client.post(
"/users/password",
json=dict(old=users["jeff"]["password"], new="new_password"),
headers=auth("jeff"),
)
assert resp.status_code == 200
# revoke old token
resp = client.post(
"/users/logout", json=dict(token=tokens["jeff"]), headers=auth("jeff")
)
assert resp.status_code == 200
# test that we're logged out
resp = client.get(
"/users/jeff", headers=auth("jeff"), json=dict(token=tokens["jeff"])
)
assert resp.status_code == 401
# sign in with new token
resp = client.post(
"/users/login", json=dict(username="jeff", password="new_password")
)
assert resp.status_code == 200
data = resp.get_json()
new_token = data.get("auth_token")
assert new_token
# test that we're logged in
resp = client.get("/users/jeff", headers=dict(Authorization=f"bearer {new_token}"))
assert resp.status_code == 200
def test_admin_revoke(client, tokens, validate_info, auth): def test_admin_revoke(client, tokens, validate_info, auth):
"""Test that an admin can revoke any token from other users.""" """Test that an admin can revoke any token from other users."""