Minor restructuring and add documentation

This commit is contained in:
april 2023-12-19 11:43:29 -06:00
parent 3ec92573e1
commit 43e90f8b07
2 changed files with 204 additions and 27 deletions

View File

@ -10,22 +10,33 @@ from database.models import Flight, User, AuthLevel
from mongoengine import connect, ValidationError, DoesNotExist 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 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__) api = Flask(__name__)
# Set JWT key from environment variable
try: try:
api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"] api.config["JWT_SECRET_KEY"] = os.environ["TAILFIN_DB_KEY"]
except KeyError: except KeyError:
api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable") api.logger.error("Please set 'TAILFIN_DB_KEY' environment variable")
exit(1) exit(1)
# Set JWT keys to expire after 1 hour
api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) api.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
# Initialize JWT manager
jwt = JWTManager(api) jwt = JWTManager(api)
# Connect to MongoDB
connect('tailfin') 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_inner(func):
def auth_wrapper(*args, **kwargs): def auth_wrapper(*args, **kwargs):
user = User.objects.get(username=get_jwt_identity()) user = User.objects.get(username=get_jwt_identity())
@ -41,6 +52,12 @@ def auth_level_required(level):
@api.after_request @api.after_request
def refresh_expiring_jwts(response): 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: try:
exp_timestamp = get_jwt()["exp"] exp_timestamp = get_jwt()["exp"]
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@ -63,9 +80,15 @@ def refresh_expiring_jwts(response):
@jwt_required() @jwt_required()
@auth_level_required(AuthLevel.ADMIN) @auth_level_required(AuthLevel.ADMIN)
def add_user(): def add_user():
username = request.json.get("username", None) """
password = request.json.get("password", None) Add user to database.
auth_level = AuthLevel(request.json.get("auth_level", None))
: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: try:
existing_user = User.objects.get(username=username) existing_user = User.objects.get(username=username)
@ -85,6 +108,12 @@ def add_user():
@jwt_required() @jwt_required()
@auth_level_required(AuthLevel.ADMIN) @auth_level_required(AuthLevel.ADMIN)
def remove_user(user_id): 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: try:
User.objects.get(id=user_id).delete() User.objects.get(id=user_id).delete()
except DoesNotExist: except DoesNotExist:
@ -98,12 +127,22 @@ def remove_user(user_id):
@jwt_required() @jwt_required()
@auth_level_required(AuthLevel.ADMIN) @auth_level_required(AuthLevel.ADMIN)
def get_users(): def get_users():
"""
Get a list of all users
:return: List of users in the database
"""
users = User.objects.to_json() users = User.objects.to_json()
return users, 200 return users, 200
@api.route('/login', methods=["POST"]) @api.route('/login', methods=["POST"])
def create_token(): 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) username = request.json.get("username", None)
password = request.json.get("password", None) password = request.json.get("password", None)
@ -123,25 +162,77 @@ def create_token():
@api.route('/logout', methods=["POST"]) @api.route('/logout', methods=["POST"])
def logout(): 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"}) response = jsonify({"msg": "logout successful"})
unset_jwt_cookies(response) unset_jwt_cookies(response)
return response return response
@api.route('/profile', methods=["GET"]) @api.route('/profile/<user_id>', methods=["GET"])
@jwt_required() @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: try:
user = User.objects.get(username=get_jwt_identity()) user = User.objects.get(id=user_id)
except DoesNotExist: except DoesNotExist:
api.logger.warning("User %s not found", get_jwt_identity()) api.logger.warning("User %s not found", get_jwt_identity())
return {"msg": "User not found"}, 401 return {"msg": "User not found"}, 401
return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200 return jsonify({"username": user.username, "auth_level:": str(user.level)}), 200
@api.route('/profile/<user_id>', 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"]) @api.route('/profile', methods=["PUT"])
@jwt_required() @jwt_required()
def update_profile(): def update_profile():
"""
Update the profile of the currently logged-in user
:return: Error messages if request is invalid, else 200
"""
try: try:
user = User.objects.get(username=get_jwt_identity()) user = User.objects.get(username=get_jwt_identity())
except DoesNotExist: except DoesNotExist:
@ -149,28 +240,17 @@ def update_profile():
return {"msg": "user not found"}, 401 return {"msg": "user not found"}, 401
body = request.get_json() body = request.get_json()
username = request.json.get("username", None) return update_profile(user.id, body.username, body.password, body.auth_level)
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']) @api.route('/flights', methods=['GET'])
@jwt_required() @jwt_required()
def get_flights(): 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 user = User.objects.get(username=get_jwt_identity()).id
flights = Flight.objects(user=user).to_json() flights = Flight.objects(user=user).to_json()
return flights, 200 return flights, 200
@ -180,6 +260,11 @@ def get_flights():
@jwt_required() @jwt_required()
@auth_level_required(AuthLevel.ADMIN) @auth_level_required(AuthLevel.ADMIN)
def get_all_flights(): def get_all_flights():
"""
Get a list of all flights logged by any user
:return: List of flights
"""
flights = Flight.objects.to_json() flights = Flight.objects.to_json()
return flights, 200 return flights, 200
@ -187,6 +272,12 @@ def get_all_flights():
@api.route('/flights/<flight_id>', methods=['GET']) @api.route('/flights/<flight_id>', methods=['GET'])
@jwt_required() @jwt_required()
def get_flight(flight_id): 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 user = User.objects.get(username=get_jwt_identity()).id
flight = Flight.objects(id=flight_id).to_json() flight = Flight.objects(id=flight_id).to_json()
if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN: if flight.user != user and AuthLevel(user.level) != AuthLevel.ADMIN:
@ -198,6 +289,11 @@ def get_flight(flight_id):
@api.route('/flights', methods=['POST']) @api.route('/flights', methods=['POST'])
@jwt_required() @jwt_required()
def add_flight(): 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()) user = User.objects(username=get_jwt_identity())
body = request.get_json() body = request.get_json()
@ -210,19 +306,59 @@ def add_flight():
@api.route('/flights/<flight_id>', methods=['PUT']) @api.route('/flights/<flight_id>', methods=['PUT'])
@jwt_required()
def update_flight(flight_id): 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() body = request.get_json()
Flight.objects(id=flight_id).update(**body) flight.update(**body)
return '', 200 return '', 200
@api.route('/flights/<flight_id>', methods=['DELETE']) @api.route('/flights/<flight_id>', methods=['DELETE'])
def delete_flight(flight_id): 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 return '', 200
if __name__ == '__main__': if __name__ == '__main__':
# Create default admin user if no admin users found
if User.objects(level=AuthLevel.ADMIN.value).count() == 0: if User.objects(level=AuthLevel.ADMIN.value).count() == 0:
api.logger.info("No admin users exist. Creating default admin user...") api.logger.info("No admin users exist. Creating default admin user...")
try: try:
@ -242,4 +378,5 @@ if __name__ == '__main__':
User(username=admin_username, password=hashed_password, level=AuthLevel.ADMIN).save() 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) api.logger.info("Default admin user created with username %s", User.objects.get(level=AuthLevel.ADMIN).username)
# Start the app
api.run() api.run()

40
api/database/utils.py Normal file
View File

@ -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