diff --git a/api/app.py b/api/app.py index 32084a9..1cdd4c5 100644 --- a/api/app.py +++ b/api/app.py @@ -1,8 +1,10 @@ +import functools import json, os, sys from datetime import timedelta, datetime, timezone import bcrypt from flask import Flask, request, Response, jsonify, session +from pymongo import database from database.models import Flight, User, AuthLevel from mongoengine import connect, ValidationError, DoesNotExist @@ -16,7 +18,6 @@ except KeyError: api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") exit(1) -print(os.environ.get("TAILFIN_DB_KEY")) api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) jwt = JWTManager(api) @@ -24,6 +25,20 @@ jwt = JWTManager(api) connect('tailfin') +def auth_level_required(level): + def auth_inner(func): + def auth_wrapper(*args, **kwargs): + user = User.objects.get(username=get_jwt_identity()) + if AuthLevel(user.level) < level: + api.logger.warning("Attempted access to unauthorized resource by %s", user.username) + return '', 403 + else: + return func(*args, **kwargs) + auth_wrapper.__name__ = func.__name__ + return auth_wrapper + return auth_inner + + @api.after_request def refresh_expiring_jwts(response): try: @@ -31,6 +46,7 @@ def refresh_expiring_jwts(response): now = datetime.now(timezone.utc) target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) if target_timestamp > exp_timestamp: + api.logger.info("Refreshing expiring JWT") access_token = create_access_token(identity=get_jwt_identity()) data = response.get_json() if type(data) is dict: @@ -39,36 +55,49 @@ def refresh_expiring_jwts(response): return response except (RuntimeError, KeyError): # No valid JWT, return original response + api.logger.info("No valid JWT, cannot refresh expiry") return response -@api.route('/add_user', methods=["POST"]) +@api.route('/users', methods=["POST"]) @jwt_required() +@auth_level_required(AuthLevel.ADMIN) def add_user(): - user = User.objects.get(username=get_jwt_identity()) - if user.level != AuthLevel.ADMIN: - return '', 401 - username = request.json.get("username", None) password = request.json.get("password", None) - auth_level = request.json.get("auth_level", None) + auth_level = AuthLevel(request.json.get("auth_level", None)) try: existing_user = User.objects.get(username=username) + api.logger.info("User %s already exists at auth level %s", existing_user.username, existing_user.level) return jsonify({"msg": "Username already exists"}) except DoesNotExist: + api.logger.info("Creating user %s with auth level %s", username, auth_level) + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) - user = User(username=username, password=hashed_password, level=auth_level).save() - return jsonify({"id": user.id}), 200 + user = User(username=username, password=hashed_password, level=auth_level.value) + user.save() + + return jsonify({"id": user.id}), 201 + + +@api.route('/users/', methods=['DELETE']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def remove_user(user_id): + try: + User.objects.get(id=user_id).delete() + except DoesNotExist: + api.logger.info("Attempt to delete nonexistent user %s by %s", user_id, get_jwt_identity()) + return {"msg": "User does not exist"}, 401 + Flight.objects(user=user_id).delete() + return '', 200 @api.route('/users', methods=["GET"]) @jwt_required() +@auth_level_required(AuthLevel.ADMIN) def get_users(): - user = User.objects.get(username=get_jwt_identity()) - if user.level != AuthLevel.ADMIN: - return '', 401 - users = User.objects.to_json() return users, 200 @@ -85,8 +114,10 @@ def create_token(): else: if bcrypt.checkpw(password.encode('utf-8'), user.password.encode('utf-8')): access_token = create_access_token(identity=username) + api.logger.info("%s successfully logged in", username) response = {"access_token": access_token} return jsonify(response), 200 + api.logger.info("Failed login attempt from %s", request.remote_addr) return jsonify({"msg": "Invalid username or password"}), 401 @@ -100,11 +131,43 @@ def logout(): @api.route('/profile', methods=["GET"]) @jwt_required() def get_profile(): - user = User.objects.get(username=get_jwt_identity()) - print(user.to_json()) + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "User not found"}, 401 return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 +@api.route('/profile', methods=["PUT"]) +@jwt_required() +def update_profile(): + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return {"msg": "user not found"}, 401 + body = request.get_json() + + username = request.json.get("username", None) + password = request.json.get("password", None) + auth_level = request.json.get("level", None) + + if username: + existing_users = User.objects(username=username).count() + if existing_users != 0: + return jsonify({"msg": "Username not available"}) + if password: + hashed_password = bcrypt.hashpw(password.encode('UTF-8'), bcrypt.gensalt()) + if auth_level: + if AuthLevel(user.level) < AuthLevel.ADMIN: + api.logger.warning("Unauthorized attempt to change auth level of %s", user.username) + return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 + + user.update(**body) + return '', 200 + + @api.route('/flights', methods=['GET']) @jwt_required() def get_flights(): @@ -113,13 +176,22 @@ def get_flights(): return flights, 200 +@api.route('/flights/all', methods=['GET']) +@jwt_required() +@auth_level_required(AuthLevel.ADMIN) +def get_all_flights(): + flights = Flight.objects.to_json() + return flights, 200 + + @api.route('/flights/', methods=['GET']) @jwt_required() def get_flight(flight_id): user = User.objects.get(username=get_jwt_identity()).id flight = Flight.objects(id=flight_id).to_json() - if flight.user != user: - return '', 401 + if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: + api.logger.warning("Attempted access to unauthorized flight by %s", user.username) + return {"msg": "Unauthorized access"}, 403 return flight, 200 @@ -144,14 +216,14 @@ def update_flight(flight_id): return '', 200 -@api.route('/flights/', methods=['DELETE']) -def delete_flight(index): - Flight.objects(id=id).delete() +@api.route('/flights/', methods=['DELETE']) +def delete_flight(flight_id): + Flight.objects(id=flight_id).delete() return '', 200 if __name__ == '__main__': - if User.objects(level=AuthLevel.ADMIN).count() == 0: + if User.objects(level=AuthLevel.ADMIN.value).count() == 0: api.logger.info("No admin users exist. Creating default admin user...") try: admin_username = os.environ["TAILFIN_ADMIN_USERNAME"] diff --git a/api/database/models.py b/api/database/models.py index 9cb971d..5140ca9 100644 --- a/api/database/models.py +++ b/api/database/models.py @@ -8,11 +8,29 @@ class AuthLevel(Enum): USER = 1 ADMIN = 2 + def __lt__(self, other): + if self.__class__ is other.__class__: + return self.value < other.value + return NotImplemented + + def __gt__(self, other): + if self.__class__ is other.__class__: + return self.value > other.value + return NotImplemented + + def __eq__(self, other): + if self.__class__ is other.__class__: + return self.value == other.value + return NotImplemented + class User(Document): username = StringField(required=True, unique=True) password = StringField(required=True) - level = EnumField(AuthLevel, default=AuthLevel.USER) + + # EnumField validation is currently broken, replace workaround if MongoEngine is updated to fix it + level = IntField(choices=[l.value for l in AuthLevel], default=1) + # level = EnumField(AuthLevel, default=AuthLevel.USER) class Flight(Document):