migrating from basic to JWT auth #34
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -170,6 +170,10 @@ jobs:
|
|||||||
- name: Set GCP Project
|
- name: Set GCP Project
|
||||||
run: python admin.py set_project
|
run: python admin.py set_project
|
||||||
|
|
||||||
|
# DEPLOY domain routes
|
||||||
|
- name: Deploy dispatch.yaml
|
||||||
|
run: gcloud app deploy dispatch.yaml --quiet
|
||||||
|
|
||||||
# DEPLOY app engine service, -nb for skipping compile
|
# DEPLOY app engine service, -nb for skipping compile
|
||||||
- name: Deploy App Engine Service
|
- name: Deploy App Engine Service
|
||||||
run: python admin.py app -nb
|
run: python admin.py app -nb
|
||||||
|
@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from google.cloud import tasks_v2
|
from google.cloud import tasks_v2
|
||||||
|
|
||||||
from music.api.decorators import login_or_basic_auth, admin_required
|
from music.api.decorators import login_or_jwt, admin_required
|
||||||
|
|
||||||
blueprint = Blueprint('admin-api', __name__)
|
blueprint = Blueprint('admin-api', __name__)
|
||||||
|
|
||||||
@ -16,9 +16,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/tasks', methods=['GET'])
|
@blueprint.route('/tasks', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_tasks(user=None):
|
def get_tasks(auth=None, user=None):
|
||||||
|
|
||||||
tasks = list(tasker.list_tasks(task_path))
|
tasks = list(tasker.list_tasks(task_path))
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from music.api.decorators import login_required, login_or_basic_auth, \
|
from music.api.decorators import login_or_jwt, login_required, login_or_jwt, \
|
||||||
admin_required, cloud_task, validate_json, validate_args, spotify_link_required
|
admin_required, cloud_task, validate_json, validate_args, spotify_link_required
|
||||||
from music.cloud import queue_run_user_playlist, offload_or_run_user_playlist
|
from music.cloud import queue_run_user_playlist, offload_or_run_user_playlist
|
||||||
from music.cloud.tasks import update_all_user_playlists, update_playlists
|
from music.cloud.tasks import update_all_user_playlists, update_playlists
|
||||||
@ -28,8 +28,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlists', methods=['GET'])
|
@blueprint.route('/playlists', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def all_playlists_route(user=None):
|
def all_playlists_route(auth=None, user=None):
|
||||||
"""Retrieve all playlists for a given user
|
"""Retrieve all playlists for a given user
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -46,9 +46,9 @@ def all_playlists_route(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist', methods=['GET', 'DELETE'])
|
@blueprint.route('/playlist', methods=['GET', 'DELETE'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@validate_args(('name', str))
|
@validate_args(('name', str))
|
||||||
def playlist_get_delete_route(user=None):
|
def playlist_get_delete_route(auth=None,user=None):
|
||||||
|
|
||||||
playlist = user.get_playlist(request.args['name'], raise_error=False)
|
playlist = user.get_playlist(request.args['name'], raise_error=False)
|
||||||
|
|
||||||
@ -64,9 +64,9 @@ def playlist_get_delete_route(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist', methods=['POST', 'PUT'])
|
@blueprint.route('/playlist', methods=['POST', 'PUT'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@validate_json(('name', str))
|
@validate_json(('name', str))
|
||||||
def playlist_post_put_route(user=None):
|
def playlist_post_put_route(auth=None, user=None):
|
||||||
|
|
||||||
request_json = request.get_json()
|
request_json = request.get_json()
|
||||||
|
|
||||||
@ -161,8 +161,8 @@ def playlist_post_put_route(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/user', methods=['GET', 'POST'])
|
@blueprint.route('/user', methods=['GET', 'POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def user_route(user=None):
|
def user_route(auth=None, user=None):
|
||||||
assert user is not None
|
assert user is not None
|
||||||
|
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
@ -202,9 +202,9 @@ def user_route(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/users', methods=['GET'])
|
@blueprint.route('/users', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@admin_required
|
@admin_required
|
||||||
def all_users_route(user=None):
|
def all_users_route(auth=None, user=None):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'accounts': [i.to_dict() for i in User.collection.fetch()]
|
'accounts': [i.to_dict() for i in User.collection.fetch()]
|
||||||
}), 200
|
}), 200
|
||||||
@ -234,9 +234,9 @@ def change_password(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/run', methods=['GET'])
|
@blueprint.route('/playlist/run', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@validate_args(('name', str))
|
@validate_args(('name', str))
|
||||||
def run_playlist(user=None):
|
def run_playlist(auth=None, user=None):
|
||||||
|
|
||||||
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
|
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
|
||||||
queue_run_user_playlist(user.username, request.args['name']) # pass to either cloud tasks or functions
|
queue_run_user_playlist(user.username, request.args['name']) # pass to either cloud tasks or functions
|
||||||
@ -264,8 +264,8 @@ def run_playlist_task(): # receives cloud tasks request for update
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/run/user', methods=['GET'])
|
@blueprint.route('/playlist/run/user', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def run_user(user=None):
|
def run_user(auth=None, user=None):
|
||||||
|
|
||||||
if user.type == 'admin':
|
if user.type == 'admin':
|
||||||
user_name = request.args.get('username', user.username)
|
user_name = request.args.get('username', user.username)
|
||||||
@ -288,19 +288,19 @@ def run_user_task():
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/run/users', methods=['GET'])
|
@blueprint.route('/playlist/run/users', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@admin_required
|
@admin_required
|
||||||
def run_users(user=None):
|
def run_users(auth=None, user=None):
|
||||||
|
|
||||||
update_all_user_playlists()
|
update_all_user_playlists()
|
||||||
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
|
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/image', methods=['GET'])
|
@blueprint.route('/playlist/image', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
@validate_args(('name', str))
|
@validate_args(('name', str))
|
||||||
def image(user=None):
|
def image(auth=None, user=None):
|
||||||
|
|
||||||
_playlist = user.get_playlist(request.args['name'], raise_error=False)
|
_playlist = user.get_playlist(request.args['name'], raise_error=False)
|
||||||
if _playlist is None:
|
if _playlist is None:
|
||||||
|
@ -4,6 +4,7 @@ import logging
|
|||||||
from flask import session, request, jsonify
|
from flask import session, request, jsonify
|
||||||
|
|
||||||
from music.model.user import User
|
from music.model.user import User
|
||||||
|
from music.auth.jwt_keys import validate_key
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -30,6 +31,19 @@ def is_basic_authed():
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def is_jwt_authed():
|
||||||
|
if request.headers.get('Authorization', None):
|
||||||
|
|
||||||
|
unparsed = request.headers.get('Authorization')
|
||||||
|
|
||||||
|
if unparsed.startswith('Bearer '):
|
||||||
|
|
||||||
|
token = validate_key(unparsed.removeprefix('Bearer ').strip())
|
||||||
|
|
||||||
|
if token is not None:
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
def login_required(func):
|
def login_required(func):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def login_required_wrapper(*args, **kwargs):
|
def login_required_wrapper(*args, **kwargs):
|
||||||
@ -42,6 +56,48 @@ def login_required(func):
|
|||||||
return login_required_wrapper
|
return login_required_wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def login_or_jwt(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def login_or_jwt_wrapper(*args, **kwargs):
|
||||||
|
if is_logged_in():
|
||||||
|
user = User.collection.filter('username', '==', session['username'].strip().lower()).get()
|
||||||
|
return func(*args, user=user, **kwargs)
|
||||||
|
else:
|
||||||
|
token = is_jwt_authed()
|
||||||
|
if token is not None:
|
||||||
|
user = User.collection.filter('username', '==', token['sub'].strip().lower()).get()
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
return func(*args, auth=token, user=user, **kwargs)
|
||||||
|
else:
|
||||||
|
logger.warning(f'user {token["sub"]} not found')
|
||||||
|
return jsonify({'error': f'user {token["sub"]} not found'}), 404
|
||||||
|
else:
|
||||||
|
logger.warning('user not authorised')
|
||||||
|
return jsonify({'error': 'not authorised'}), 401
|
||||||
|
|
||||||
|
return login_or_jwt_wrapper
|
||||||
|
|
||||||
|
def jwt_required(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def jwt_required_wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
token = is_jwt_authed()
|
||||||
|
if token is not None:
|
||||||
|
user = User.collection.filter('username', '==', token['sub'].strip().lower()).get()
|
||||||
|
|
||||||
|
if user is not None:
|
||||||
|
return func(*args, auth=token, user=user, **kwargs)
|
||||||
|
else:
|
||||||
|
logger.warning(f'user {token["sub"]} not found')
|
||||||
|
return jsonify({'error': f'user {token["sub"]} not found'}), 404
|
||||||
|
else:
|
||||||
|
logger.warning('user not authorised')
|
||||||
|
return jsonify({'error': 'not authorised'}), 401
|
||||||
|
|
||||||
|
return jwt_required_wrapper
|
||||||
|
|
||||||
|
|
||||||
def login_or_basic_auth(func):
|
def login_or_basic_auth(func):
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
def login_or_basic_auth_wrapper(*args, **kwargs):
|
def login_or_basic_auth_wrapper(*args, **kwargs):
|
||||||
|
@ -2,7 +2,7 @@ from flask import Blueprint, jsonify
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from music.api.decorators import login_or_basic_auth, lastfm_username_required
|
from music.api.decorators import login_or_jwt, lastfm_username_required
|
||||||
|
|
||||||
import music.db.database as database
|
import music.db.database as database
|
||||||
|
|
||||||
@ -11,9 +11,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/today', methods=['GET'])
|
@blueprint.route('/today', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@lastfm_username_required
|
@lastfm_username_required
|
||||||
def daily_scrobbles(user=None):
|
def daily_scrobbles(auth=None, user=None):
|
||||||
|
|
||||||
net = database.get_authed_lastfm_network(user)
|
net = database.get_authed_lastfm_network(user)
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from music.api.decorators import login_or_basic_auth, spotify_link_required, validate_json
|
from music.api.decorators import login_or_jwt, spotify_link_required, validate_json
|
||||||
import music.db.database as database
|
import music.db.database as database
|
||||||
|
|
||||||
from spotframework.net.network import SpotifyNetworkException
|
from spotframework.net.network import SpotifyNetworkException
|
||||||
@ -16,9 +16,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/play', methods=['POST'])
|
@blueprint.route('/play', methods=['POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
def play(user=None):
|
def play(auth=None, user=None):
|
||||||
request_json = request.get_json()
|
request_json = request.get_json()
|
||||||
|
|
||||||
if 'uri' in request_json:
|
if 'uri' in request_json:
|
||||||
@ -78,9 +78,9 @@ def play(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/next', methods=['POST'])
|
@blueprint.route('/next', methods=['POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
def next_track(user=None):
|
def next_track(auth=None, user=None):
|
||||||
net = database.get_authed_spotify_network(user)
|
net = database.get_authed_spotify_network(user)
|
||||||
player = Player(net)
|
player = Player(net)
|
||||||
|
|
||||||
@ -89,10 +89,10 @@ def next_track(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/shuffle', methods=['POST'])
|
@blueprint.route('/shuffle', methods=['POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
@validate_json(('state', bool))
|
@validate_json(('state', bool))
|
||||||
def shuffle(user=None):
|
def shuffle(auth=None, user=None):
|
||||||
request_json = request.get_json()
|
request_json = request.get_json()
|
||||||
|
|
||||||
net = database.get_authed_spotify_network(user)
|
net = database.get_authed_spotify_network(user)
|
||||||
@ -103,10 +103,10 @@ def shuffle(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/volume', methods=['POST'])
|
@blueprint.route('/volume', methods=['POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
@validate_json(('volume', int))
|
@validate_json(('volume', int))
|
||||||
def volume(user=None):
|
def volume(auth=None, user=None):
|
||||||
request_json = request.get_json()
|
request_json = request.get_json()
|
||||||
|
|
||||||
if 0 <= request_json['volume'] <= 100:
|
if 0 <= request_json['volume'] <= 100:
|
||||||
|
@ -3,7 +3,7 @@ import logging
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from music.api.decorators import admin_required, login_or_basic_auth, lastfm_username_required, \
|
from music.api.decorators import admin_required, login_or_jwt, lastfm_username_required, \
|
||||||
spotify_link_required, cloud_task, validate_args
|
spotify_link_required, cloud_task, validate_args
|
||||||
import music.db.database as database
|
import music.db.database as database
|
||||||
from music.cloud.tasks import refresh_all_user_playlist_stats, refresh_user_playlist_stats, refresh_playlist_task
|
from music.cloud.tasks import refresh_all_user_playlist_stats, refresh_user_playlist_stats, refresh_playlist_task
|
||||||
@ -20,10 +20,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/count', methods=['GET'])
|
@blueprint.route('/count', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
@lastfm_username_required
|
@lastfm_username_required
|
||||||
def count(user=None):
|
def count(auth=None, user=None):
|
||||||
|
|
||||||
uri = request.args.get('uri', None)
|
uri = request.args.get('uri', None)
|
||||||
playlist_name = request.args.get('playlist_name', None)
|
playlist_name = request.args.get('playlist_name', None)
|
||||||
@ -69,11 +69,11 @@ def count(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/refresh', methods=['GET'])
|
@blueprint.route('/playlist/refresh', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
@lastfm_username_required
|
@lastfm_username_required
|
||||||
@validate_args(('name', str))
|
@validate_args(('name', str))
|
||||||
def playlist_refresh(user=None):
|
def playlist_refresh(auth=None, user=None):
|
||||||
|
|
||||||
playlist_name = request.args['name']
|
playlist_name = request.args['name']
|
||||||
|
|
||||||
@ -133,16 +133,16 @@ def run_playlist_artist_task():
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/refresh/users', methods=['GET'])
|
@blueprint.route('/playlist/refresh/users', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@admin_required
|
@admin_required
|
||||||
def run_users(user=None):
|
def run_users(auth=None, user=None):
|
||||||
refresh_all_user_playlist_stats()
|
refresh_all_user_playlist_stats()
|
||||||
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
|
return jsonify({'message': 'executed all users', 'status': 'success'}), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/playlist/refresh/user', methods=['GET'])
|
@blueprint.route('/playlist/refresh/user', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def run_user(user=None):
|
def run_user(auth=None, user=None):
|
||||||
|
|
||||||
if user.type == 'admin':
|
if user.type == 'admin':
|
||||||
user_name = request.args.get('username', user.username)
|
user_name = request.args.get('username', user.username)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from music.api.decorators import login_or_basic_auth, spotify_link_required
|
from music.api.decorators import login_or_jwt, spotify_link_required
|
||||||
import music.db.database as database
|
import music.db.database as database
|
||||||
|
|
||||||
from spotframework.engine.playlistengine import PlaylistEngine
|
from spotframework.engine.playlistengine import PlaylistEngine
|
||||||
@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/sort', methods=['POST'])
|
@blueprint.route('/sort', methods=['POST'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
@spotify_link_required
|
@spotify_link_required
|
||||||
def sort(user=None):
|
def sort(auth=None, user=None):
|
||||||
request_json = request.get_json()
|
request_json = request.get_json()
|
||||||
|
|
||||||
net = database.get_authed_spotify_network(user)
|
net = database.get_authed_spotify_network(user)
|
||||||
|
@ -4,7 +4,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from music.api.decorators import login_or_basic_auth, cloud_task
|
from music.api.decorators import login_or_jwt, cloud_task
|
||||||
from music.cloud.function import update_tag as serverless_update_tag
|
from music.cloud.function import update_tag as serverless_update_tag
|
||||||
from music.tasks.update_tag import update_tag
|
from music.tasks.update_tag import update_tag
|
||||||
|
|
||||||
@ -15,8 +15,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/tag', methods=['GET'])
|
@blueprint.route('/tag', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def tags(user=None):
|
def tags(auth=None, user=None):
|
||||||
logger.info(f'retrieving tags for {user.username}')
|
logger.info(f'retrieving tags for {user.username}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'tags': [i.to_dict() for i in Tag.collection.parent(user.key).fetch()]
|
'tags': [i.to_dict() for i in Tag.collection.parent(user.key).fetch()]
|
||||||
@ -24,8 +24,8 @@ def tags(user=None):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/tag/<tag_id>', methods=['GET', 'PUT', 'POST', "DELETE"])
|
@blueprint.route('/tag/<tag_id>', methods=['GET', 'PUT', 'POST', "DELETE"])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def tag_route(tag_id, user=None):
|
def tag_route(tag_id, auth=None, user=None):
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return get_tag(tag_id, user)
|
return get_tag(tag_id, user)
|
||||||
elif request.method == 'PUT':
|
elif request.method == 'PUT':
|
||||||
@ -126,8 +126,8 @@ def delete_tag(tag_id, user):
|
|||||||
|
|
||||||
|
|
||||||
@blueprint.route('/tag/<tag_id>/update', methods=['GET'])
|
@blueprint.route('/tag/<tag_id>/update', methods=['GET'])
|
||||||
@login_or_basic_auth
|
@login_or_jwt
|
||||||
def tag_refresh(tag_id, user=None):
|
def tag_refresh(tag_id, auth=None, user=None):
|
||||||
logger.info(f'updating {tag_id} tag for {user.username}')
|
logger.info(f'updating {tag_id} tag for {user.username}')
|
||||||
|
|
||||||
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
|
if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD':
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
from flask import Blueprint, session, flash, request, redirect, url_for, render_template
|
from flask import Blueprint, session, flash, request, redirect, url_for, render_template, jsonify
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from music.model.user import User
|
from music.model.user import User
|
||||||
from music.model.config import Config
|
from music.model.config import Config
|
||||||
|
from music.auth.jwt_keys import generate_key
|
||||||
|
|
||||||
from urllib.parse import urlencode, urlunparse
|
from urllib.parse import urlencode, urlunparse
|
||||||
import datetime
|
import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
from numbers import Number
|
||||||
import logging
|
import logging
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
import requests
|
import requests
|
||||||
@ -68,6 +71,51 @@ def logout():
|
|||||||
flash('logged out')
|
flash('logged out')
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
@blueprint.route('/token', methods=['POST'])
|
||||||
|
def jwt_token():
|
||||||
|
"""Generate JWT
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTTP Response: token request on POST
|
||||||
|
"""
|
||||||
|
|
||||||
|
request_json = request.get_json()
|
||||||
|
|
||||||
|
username = request_json.get('username', None)
|
||||||
|
password = request_json.get('password', None)
|
||||||
|
|
||||||
|
if username is None or password is None:
|
||||||
|
return jsonify({"message": 'username and password fields required', "status": "error"}), 400
|
||||||
|
|
||||||
|
user = User.collection.filter('username', '==', username.strip().lower()).get()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return jsonify({"message": 'user not found', "status": "error"}), 404
|
||||||
|
|
||||||
|
if user.check_password(password):
|
||||||
|
if user.locked:
|
||||||
|
logger.warning(f'locked account token attempt {username}')
|
||||||
|
return jsonify({"message": 'user locked', "status": "error"}), 403
|
||||||
|
|
||||||
|
user.last_keygen = datetime.datetime.utcnow()
|
||||||
|
user.update()
|
||||||
|
|
||||||
|
logger.info(f'generating token for {username}')
|
||||||
|
|
||||||
|
config = Config.collection.get("config/music-tools")
|
||||||
|
|
||||||
|
if isinstance(expiry := request_json.get('expiry', None), Number):
|
||||||
|
expiry = min(expiry, config.jwt_max_length)
|
||||||
|
else:
|
||||||
|
expiry = config.jwt_default_length
|
||||||
|
|
||||||
|
token = generate_key(user, timeout=timedelta(seconds=expiry))
|
||||||
|
|
||||||
|
return jsonify({"token": token, "status": "success"}), 200
|
||||||
|
else:
|
||||||
|
logger.warning(f'failed token attempt {username}')
|
||||||
|
return jsonify({"message": 'authentication failed', "status": "error"}), 401
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/register', methods=['GET', 'POST'])
|
@blueprint.route('/register', methods=['GET', 'POST'])
|
||||||
def register():
|
def register():
|
||||||
|
41
music/auth/jwt_keys.py
Normal file
41
music/auth/jwt_keys.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from ctypes import Union
|
||||||
|
from datetime import timedelta, datetime, timezone
|
||||||
|
import jwt
|
||||||
|
from music.model.user import User
|
||||||
|
from music.model.config import Config
|
||||||
|
|
||||||
|
def get_jwt_secret_key() -> str:
|
||||||
|
|
||||||
|
config = Config.collection.get("config/music-tools")
|
||||||
|
|
||||||
|
if config.jwt_secret_key is None or len(config.jwt_secret_key) == 0:
|
||||||
|
raise KeyError("no jwt secret key found")
|
||||||
|
|
||||||
|
return config.jwt_secret_key
|
||||||
|
|
||||||
|
def generate_key(user: User, timeout: datetime | timedelta = timedelta(minutes=60)) -> str:
|
||||||
|
|
||||||
|
if isinstance(timeout, timedelta):
|
||||||
|
exp = timeout + datetime.now(tz=timezone.utc)
|
||||||
|
else:
|
||||||
|
exp = timeout
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"exp": exp,
|
||||||
|
"iss": "mixonomer-api",
|
||||||
|
"sub": user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.encode(payload, get_jwt_secret_key(), algorithm="HS512")
|
||||||
|
|
||||||
|
def validate_key(key: str) -> dict:
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = jwt.decode(key, get_jwt_secret_key(), algorithms=["HS512"], options={
|
||||||
|
"require": ["exp", "sub"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
@ -19,3 +19,6 @@ class Config(Model):
|
|||||||
"""Determines whether playlist and tag update operations are done by Cloud Tasks or Functions
|
"""Determines whether playlist and tag update operations are done by Cloud Tasks or Functions
|
||||||
"""
|
"""
|
||||||
secret_key = TextField()
|
secret_key = TextField()
|
||||||
|
jwt_secret_key = TextField()
|
||||||
|
jwt_max_length = NumberField()
|
||||||
|
jwt_default_length = NumberField()
|
||||||
|
@ -20,6 +20,7 @@ class User(Model):
|
|||||||
type = TextField(default="user")
|
type = TextField(default="user")
|
||||||
|
|
||||||
last_login = DateTime()
|
last_login = DateTime()
|
||||||
|
last_keygen = DateTime()
|
||||||
last_refreshed = DateTime()
|
last_refreshed = DateTime()
|
||||||
locked = BooleanField(default=False, required=True)
|
locked = BooleanField(default=False, required=True)
|
||||||
validated = BooleanField(default=True, required=True)
|
validated = BooleanField(default=True, required=True)
|
||||||
|
20
poetry.lock
generated
20
poetry.lock
generated
@ -462,6 +462,20 @@ python-versions = "*"
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
pyasn1 = ">=0.4.6,<0.5.0"
|
pyasn1 = ">=0.4.6,<0.5.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.4.0"
|
||||||
|
description = "JSON Web Token implementation in Python"
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
|
||||||
|
docs = ["zope.interface", "sphinx-rtd-theme", "sphinx"]
|
||||||
|
dev = ["pre-commit", "mypy", "coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)", "cryptography (>=3.3.1)", "zope.interface", "sphinx-rtd-theme", "sphinx"]
|
||||||
|
crypto = ["cryptography (>=3.3.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylint"
|
name = "pylint"
|
||||||
version = "2.14.5"
|
version = "2.14.5"
|
||||||
@ -655,7 +669,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "~3.10"
|
python-versions = "~3.10"
|
||||||
content-hash = "e3a9a1b705fe06227a9e09263b2e84aa6ee1bc6e41096732f06715a109daca4e"
|
content-hash = "740e615c75f16d1d097c47b49a18fc3487be9541f257d5181d198693feb87380"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
astroid = [
|
astroid = [
|
||||||
@ -958,6 +972,10 @@ pyasn1-modules = [
|
|||||||
{file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
|
{file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"},
|
||||||
{file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
|
{file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"},
|
||||||
]
|
]
|
||||||
|
pyjwt = [
|
||||||
|
{file = "PyJWT-2.4.0-py3-none-any.whl", hash = "sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf"},
|
||||||
|
{file = "PyJWT-2.4.0.tar.gz", hash = "sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba"},
|
||||||
|
]
|
||||||
pylint = [
|
pylint = [
|
||||||
{file = "pylint-2.14.5-py3-none-any.whl", hash = "sha256:fabe30000de7d07636d2e82c9a518ad5ad7908590fe135ace169b44839c15f90"},
|
{file = "pylint-2.14.5-py3-none-any.whl", hash = "sha256:fabe30000de7d07636d2e82c9a518ad5ad7908590fe135ace169b44839c15f90"},
|
||||||
{file = "pylint-2.14.5.tar.gz", hash = "sha256:487ce2192eee48211269a0e976421f334cf94de1806ca9d0a99449adcdf0285e"},
|
{file = "pylint-2.14.5.tar.gz", hash = "sha256:487ce2192eee48211269a0e976421f334cf94de1806ca9d0a99449adcdf0285e"},
|
||||||
|
@ -21,6 +21,7 @@ google-cloud-logging = "^3.2.1"
|
|||||||
google-cloud-pubsub = "^2.13.4"
|
google-cloud-pubsub = "^2.13.4"
|
||||||
google-cloud-tasks = "^2.10.0"
|
google-cloud-tasks = "^2.10.0"
|
||||||
requests = "^2.28.1"
|
requests = "^2.28.1"
|
||||||
|
PyJWT = "^2.4.0"
|
||||||
|
|
||||||
spotframework = { git = "https://github.com/Sarsoo/spotframework.git" }
|
spotframework = { git = "https://github.com/Sarsoo/spotframework.git" }
|
||||||
fmframework = { git = "https://github.com/Sarsoo/pyfmframework.git" }
|
fmframework = { git = "https://github.com/Sarsoo/pyfmframework.git" }
|
||||||
|
33
tests/test_auth.py
Normal file
33
tests/test_auth.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from ast import Assert
|
||||||
|
from time import sleep
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from music.model.user import User
|
||||||
|
from music.auth.jwt_keys import generate_key, validate_key
|
||||||
|
|
||||||
|
class TestAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_encode_decode(self):
|
||||||
|
|
||||||
|
test_user = User.collection.filter('username', '==', "test").get()
|
||||||
|
|
||||||
|
key = generate_key(test_user, timedelta(minutes=10))
|
||||||
|
|
||||||
|
decoded = validate_key(key)
|
||||||
|
|
||||||
|
self.assertEqual(decoded["sub"], test_user.username)
|
||||||
|
|
||||||
|
def test_timeout(self):
|
||||||
|
|
||||||
|
test_user = User.collection.filter('username', '==', "test").get()
|
||||||
|
|
||||||
|
key = generate_key(test_user, timedelta(seconds=2))
|
||||||
|
|
||||||
|
decoded = validate_key(key)
|
||||||
|
self.assertIsNotNone(decoded)
|
||||||
|
|
||||||
|
sleep(2.5)
|
||||||
|
|
||||||
|
decoded = validate_key(key)
|
||||||
|
self.assertIsNone(decoded)
|
Loading…
Reference in New Issue
Block a user