diff --git a/api/app.py b/api/app.py index 1cdd4c5..ade4692 100644 --- a/api/app.py +++ b/api/app.py @@ -10,22 +10,33 @@ from database.models import Flight, User, AuthLevel from mongoengine import connect, ValidationError, DoesNotExist from flask_jwt_extended import create_access_token, get_jwt , get_jwt_identity, unset_jwt_cookies, jwt_required, JWTManager +# Initialize Flask app api = Flask(__name__) +# Set JWT key from environment variable try: api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] except KeyError: api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") exit(1) +# Set JWT keys to expire after 1 hour api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) + +# Initialize JWT manager jwt = JWTManager(api) - +# Connect to MongoDB connect('tailfin') -def auth_level_required(level): +def auth_level_required(level: AuthLevel): + """ + Limit access to given authorization level. + + :param level: required authorization level to access this endpoint + :return: 403 Unauthorized upon auth failure or response of decorated function on auth success + """ def auth_inner(func): def auth_wrapper(*args, **kwargs): user = User.objects.get(username=get_jwt_identity()) @@ -41,6 +52,12 @@ def auth_level_required(level): @api.after_request def refresh_expiring_jwts(response): + """ + Refresh/reissue JWTs that are near expiry following each request containing a JWT + + :param response: Response given by previous request + :return: Original response with refreshed JWT + """ try: exp_timestamp = get_jwt()["exp"] now = datetime.now(timezone.utc) @@ -63,9 +80,15 @@ def refresh_expiring_jwts(response): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def add_user(): - username = request.json.get("username", None) - password = request.json.get("password", None) - auth_level = AuthLevel(request.json.get("auth_level", None)) + """ + Add user to database. + + :return: Failure message if user already exists, otherwise ID of newly created user + """ + body = request.get_json() + username = body.username + password = body.password + auth_level = AuthLevel(body.auth_level) try: existing_user = User.objects.get(username=username) @@ -85,6 +108,12 @@ def add_user(): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def remove_user(user_id): + """ + Delete given user from database + + :param user_id: ID of user to delete + :return: 200 if success, 401 if user does not exist + """ try: User.objects.get(id=user_id).delete() except DoesNotExist: @@ -98,12 +127,22 @@ def remove_user(user_id): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_users(): + """ + Get a list of all users + + :return: List of users in the database + """ users = User.objects.to_json() return users, 200 @api.route('/login', methods=["POST"]) def create_token(): + """ + Log in as given user and return JWT for API access + + :return: 401 if username or password invalid, else JWT + """ username = request.json.get("username", None) password = request.json.get("password", None) @@ -123,25 +162,77 @@ def create_token(): @api.route('/logout', methods=["POST"]) def logout(): + """ + Log out given user. Note that JWTs cannot be natively revoked so this must also be handled by the frontend + + :return: Message with JWT removed from headers + """ response = jsonify({"msg": "logout successful"}) unset_jwt_cookies(response) return response -@api.route('/profile', methods=["GET"]) +@api.route('/profile/', methods=["GET"]) @jwt_required() -def get_profile(): +@auth_level_required(AuthLevel.ADMIN) +def get_user_profile(user_id): + """ + Get profile of the given user + + :param user_id: ID of the requested user + :return: 401 is user does not exist, else username and auth level + """ try: - user = User.objects.get(username=get_jwt_identity()) + user = User.objects.get(id=user_id) 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() +@auth_level_required(AuthLevel.ADMIN) +def update_user_profile(user_id): + """ + Update the profile of the given user + :param user_id: ID of the user to update + :return: Error messages if request is invalid, else 200 + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"msg": "User not found"}), 401 + + body = request.get_json() + return update_profile(user.id, body.username, body.password, body.auth_level) + + +@api.route('/profile', methods=["GET"]) +@jwt_required() +def get_profile(): + """ + Return basic user information for the currently logged-in user + + :return: 401 if user not found, else username and auth level + """ + try: + user = User.objects.get(username=get_jwt_identity()) + except DoesNotExist: + api.logger.warning("User %s not found", get_jwt_identity()) + return jsonify({"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(): + """ + Update the profile of the currently logged-in user + + :return: Error messages if request is invalid, else 200 + """ try: user = User.objects.get(username=get_jwt_identity()) except DoesNotExist: @@ -149,28 +240,17 @@ def update_profile(): 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 + return update_profile(user.id, body.username, body.password, body.auth_level) @api.route('/flights', methods=['GET']) @jwt_required() def get_flights(): + """ + Get a list of the flights logged by the currently logged-in user + + :return: List of flights + """ user = User.objects.get(username=get_jwt_identity()).id flights = Flight.objects(user=user).to_json() return flights, 200 @@ -180,6 +260,11 @@ def get_flights(): @jwt_required() @auth_level_required(AuthLevel.ADMIN) def get_all_flights(): + """ + Get a list of all flights logged by any user + + :return: List of flights + """ flights = Flight.objects.to_json() return flights, 200 @@ -187,6 +272,12 @@ def get_all_flights(): @api.route('/flights/', methods=['GET']) @jwt_required() def get_flight(flight_id): + """ + Get all details of a given flight + + :param flight_id: ID of requested flight + :return: Flight details + """ user = User.objects.get(username=get_jwt_identity()).id flight = Flight.objects(id=flight_id).to_json() if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: @@ -198,6 +289,11 @@ def get_flight(flight_id): @api.route('/flights', methods=['POST']) @jwt_required() def add_flight(): + """ + Add a flight logbook entry + + :return: Error message if request invalid, else ID of newly created log + """ user = User.objects(username=get_jwt_identity()) body = request.get_json() @@ -210,19 +306,59 @@ def add_flight(): @api.route('/flights/', methods=['PUT']) +@jwt_required() def update_flight(flight_id): + """ + Update the given flight with new information + + :param flight_id: ID of flight to update + :return: Error messages if user not found or access unauthorized, else 200 + """ + 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 + + flight = Flight.objects(id=flight_id) + + 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 + body = request.get_json() - Flight.objects(id=flight_id).update(**body) + flight.update(**body) + return '', 200 @api.route('/flights/', methods=['DELETE']) def delete_flight(flight_id): - Flight.objects(id=flight_id).delete() + """ + Delete the given flight + + :param flight_id: ID of flight to delete + :return: Error messages if user not found or access unauthorized, else 200 + """ + 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 + + flight = Flight.objects(id=flight_id) + + 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 + + flight.delete() + return '', 200 if __name__ == '__main__': + # Create default admin user if no admin users found if User.objects(level=AuthLevel.ADMIN.value).count() == 0: api.logger.info("No admin users exist. Creating default admin user...") try: @@ -242,4 +378,5 @@ if __name__ == '__main__': User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN).save() api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username) + # Start the app api.run() diff --git a/api/database/utils.py b/api/database/utils.py new file mode 100644 index 0000000..6faead9 --- /dev/null +++ b/api/database/utils.py @@ -0,0 +1,40 @@ +import bcrypt +from flask import jsonify +from mongoengine import DoesNotExist + +from database.models import User, AuthLevel + + +def update_profile(user_id, username=None, password=None, auth_level=None): + """ + Update the profile of the given user + + :param user_id: ID of user to update + :param username: New username + :param password: New password + :param auth_level: New authorization level + :return: Error message if user not found or access unauthorized, else 200 + """ + try: + user = User.objects.get(id=user_id) + except DoesNotExist: + return {"msg": "user not found"}, 401 + + 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: + return jsonify({"msg": "Unauthorized attempt to change auth level"}), 403 + + if username: + user.update_one(username=username) + if password: + user.update_one(password=password) + if auth_level: + user.update_one(level=auth_level) + + return '', 200