diff --git a/contrib/testcmds.fish b/contrib/testcmds.fish index f8ab3e0..1aafdbb 100644 --- a/contrib/testcmds.fish +++ b/contrib/testcmds.fish @@ -21,10 +21,18 @@ function sachet_set_perms -d "setup permissions" http --session=sachet-admin patch $BASENAME/users/user permissions:='["READ", "CREATE", "LIST", "DELETE"]' end +function sachet_anon -d "setup anonymous user's permissions" + if test -z $argv + set argv '["READ", "CREATE", "LIST", "DELETE"]' + end + http --session=sachet-admin patch $BASENAME/admin/settings default_permissions:=$argv +end + function sachet_upload -d "uploads a file" + argparse 's/session=?' -- $argv set FNAME (basename $argv) - set URL (http --session=sachet-user post $BASENAME/files file_name=$FNAME | jq -r .url) - http --session=sachet-user -f post $BASENAME/$URL/content upload@$argv + set URL (http --session=$_flag_session post $BASENAME/files file_name=$FNAME | jq -r .url) + http --session=$_flag_session -f post $BASENAME/$URL/content upload@$argv end function sachet_upload_meme -d "uploads a random meme" @@ -33,19 +41,21 @@ function sachet_upload_meme -d "uploads a random meme" end function sachet_list -d "lists files on a given page" - argparse 'P/per-page=!_validate_int' -- $argv + argparse 'P/per-page=!_validate_int' 's/session=?' -- $argv if not set -q _flag_per_page set _flag_per_page 5 end - http --session=sachet-user get localhost:5000/files per_page=$_flag_per_page page=$argv[1] + http --session=$_flag_session get localhost:5000/files per_page=$_flag_per_page page=$argv[1] end function sachet_download -d "downloads a given file id" - http --session=sachet-user -f -d get $BASENAME/files/$argv/content + argparse 's/session=?' -- $argv + http --session=$_flag_session -f -d get $BASENAME/files/$argv/content end function sachet_delete -d "delete given file ids" + argparse 's/session=?' -- $argv for file in $argv - http --session=sachet-user delete $BASENAME/files/$file + http --session=$_flag_session delete $BASENAME/files/$file end end diff --git a/sachet/server/files/views.py b/sachet/server/files/views.py index c8724f5..c617b73 100644 --- a/sachet/server/files/views.py +++ b/sachet/server/files/views.py @@ -10,22 +10,22 @@ files_blueprint = Blueprint("files_blueprint", __name__) class FilesMetadataAPI(ModelAPI): - @auth_required(required_permissions=(Permissions.READ,)) + @auth_required(required_permissions=(Permissions.READ,), allow_anonymous=True) def get(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=share_id).first() return super().get(share) - @auth_required(required_permissions=(Permissions.MODIFY,)) + @auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True) def patch(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=share_id).first() return super().patch(share) - @auth_required(required_permissions=(Permissions.MODIFY,)) + @auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True) def put(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=share_id).first() return super().put(share) - @auth_required(required_permissions=(Permissions.DELETE,)) + @auth_required(required_permissions=(Permissions.DELETE,), allow_anonymous=True) def delete(self, share_id, auth_user=None): try: uuid.UUID(share_id) @@ -43,13 +43,14 @@ files_blueprint.add_url_rule( class FilesAPI(ModelListAPI): - @auth_required(required_permissions=(Permissions.CREATE,)) + @auth_required(required_permissions=(Permissions.CREATE,), allow_anonymous=True) def post(self, auth_user=None): data = request.get_json() - data["owner_name"] = auth_user.username + if auth_user: + data["owner_name"] = auth_user.username return super().post(Share, data) - @auth_required(required_permissions=(Permissions.LIST,)) + @auth_required(required_permissions=(Permissions.LIST,), allow_anonymous=True) def get(self, auth_user=None): return super().get(Share) @@ -62,7 +63,7 @@ files_blueprint.add_url_rule( class FileContentAPI(ModelAPI): - @auth_required(required_permissions=(Permissions.CREATE,)) + @auth_required(required_permissions=(Permissions.CREATE,), allow_anonymous=True) def post(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=uuid.UUID(share_id)).first() @@ -108,7 +109,7 @@ class FileContentAPI(ModelAPI): 201, ) - @auth_required(required_permissions=(Permissions.MODIFY,)) + @auth_required(required_permissions=(Permissions.MODIFY,), allow_anonymous=True) def put(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=share_id).first() if not share: @@ -139,7 +140,7 @@ class FileContentAPI(ModelAPI): 200, ) - @auth_required(required_permissions=(Permissions.READ,)) + @auth_required(required_permissions=(Permissions.READ,), allow_anonymous=True) def get(self, share_id, auth_user=None): share = Share.query.filter_by(share_id=share_id).first() if not share: diff --git a/sachet/server/models.py b/sachet/server/models.py index 1c6c6c8..a270a52 100644 --- a/sachet/server/models.py +++ b/sachet/server/models.py @@ -236,9 +236,10 @@ class Share(db.Model): file_name = db.Column(db.String, nullable=False) - def __init__(self, owner_name, file_name=None): + def __init__(self, owner_name=None, file_name=None): self.owner = User.query.filter_by(username=owner_name).first() - self.owner_name = self.owner.username + if self.owner: + self.owner_name = self.owner.username self.share_id = uuid.uuid4() self.url = url_for("files_blueprint.files_metadata_api", share_id=self.share_id) self.create_date = datetime.datetime.now() diff --git a/sachet/server/views_common.py b/sachet/server/views_common.py index ca84fab..cbad803 100644 --- a/sachet/server/views_common.py +++ b/sachet/server/views_common.py @@ -1,6 +1,6 @@ from flask import request, jsonify from flask.views import MethodView -from sachet.server.models import Permissions, User, BlacklistToken +from sachet.server.models import Permissions, User, BlacklistToken, ServerSettings from sachet.server import db from functools import wraps from marshmallow import ValidationError @@ -8,20 +8,21 @@ from bitmask import Bitmask import jwt -def auth_required(func=None, *, required_permissions=()): - """Decorator to require authentication. +# https://stackoverflow.com/questions/3888158/making-decorators-with-optional-arguments +def auth_required(func=None, *, required_permissions=(), allow_anonymous=False): + """Require specific authentication. - Passes an argument 'user' to the function, with a User object corresponding + Passes an argument `user` to the function, with a User object corresponding to the authenticated session. Parameters ---------- - - required_permissions : tuple of Permissions + required_permissions : tuple of Permissions, optional Permissions required to access this endpoint. + allow_anonymous : bool, optional + Allow anonymous authentication. This means the `user` parameter might be None. """ - # see https://stackoverflow.com/questions/3888158/making-decorators-with-optional-arguments def _decorate(f): @wraps(f) def decorator(*args, **kwargs): @@ -38,7 +39,28 @@ def auth_required(func=None, *, required_permissions=()): return jsonify(resp), 401 if not token: - return jsonify({"status": "fail", "message": "Missing auth token"}), 401 + if allow_anonymous: + server_settings = ServerSettings.query.first() + if ( + Bitmask(AllFlags=Permissions, *required_permissions) + not in server_settings.default_permissions + ): + return ( + jsonify( + { + "status": "fail", + "message": "Missing permissions to access this page.", + } + ), + 403, + ) + kwargs["auth_user"] = None + return f(*args, **kwargs) + else: + return ( + jsonify({"status": "fail", "message": "Missing auth token"}), + 401, + ) try: data, user = User.read_token(token) diff --git a/tests/test_anonymous.py b/tests/test_anonymous.py new file mode 100644 index 0000000..70ccc00 --- /dev/null +++ b/tests/test_anonymous.py @@ -0,0 +1,117 @@ +import pytest +from io import BytesIO +from werkzeug.datastructures import FileStorage +import uuid + +"""Test anonymous authentication to endpoints.""" + + +def test_files(client, auth, rand): + # set create perm for anon users + resp = client.patch( + "/admin/settings", + headers=auth("administrator"), + json={"default_permissions": ["CREATE"]}, + ) + assert resp.status_code == 200 + + # create share + resp = client.post("/files", json={"file_name": "content.bin"}) + assert resp.status_code == 201 + + data = resp.get_json() + url = data.get("url") + + assert url is not None + assert "/files/" in url + + upload_data = rand.randbytes(4000) + + # upload file to share + resp = client.post( + url + "/content", + data={"upload": FileStorage(stream=BytesIO(upload_data), filename="upload")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 201 + + # set read perm for anon users + resp = client.patch( + "/admin/settings", + headers=auth("administrator"), + json={"default_permissions": ["READ"]}, + ) + assert resp.status_code == 200 + + # read file + resp = client.get( + url + "/content", + ) + assert resp.data == upload_data + assert "filename=content.bin" in resp.headers["Content-Disposition"].split("; ") + + # set modify perm for anon users + resp = client.patch( + "/admin/settings", + headers=auth("administrator"), + json={"default_permissions": ["MODIFY"]}, + ) + assert resp.status_code == 200 + + # modify share + upload_data = rand.randbytes(4000) + resp = client.put( + url + "/content", + data={"upload": FileStorage(stream=BytesIO(upload_data), filename="upload")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 200 + resp = client.patch( + url, + json={"file_name": "new_bin.bin"}, + ) + assert resp.status_code == 200 + resp = client.get( + url + "/content", + ) + assert resp.data == upload_data + assert "filename=new_bin.bin" in resp.headers["Content-Disposition"].split("; ") + + # set list perm for anon users + resp = client.patch( + "/admin/settings", + headers=auth("administrator"), + json={"default_permissions": ["LIST"]}, + ) + assert resp.status_code == 200 + + # test listing file + resp = client.get( + "/files", + json={}, + ) + data = resp.get_json() + assert resp.status_code == 200 + listed = data.get("data") + assert len(listed) == 1 + assert listed[0].get("initialized") is True + + # set delete perm for anon users + resp = client.patch( + "/admin/settings", + headers=auth("administrator"), + json={"default_permissions": ["DELETE"]}, + ) + assert resp.status_code == 200 + + # test deletion + resp = client.delete( + url, + ) + assert resp.status_code == 200 + + # file shouldn't exist anymore + resp = client.get( + url + "/content", + ) + assert resp.status_code == 404