From ac6cc976ebe1eb1afb18b5f9f62afb5f324500fc Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 8 Aug 2022 18:37:17 +0100 Subject: [PATCH 1/3] proof of concept jwt auth --- music/api/api.py | 6 ++--- music/api/decorators.py | 56 +++++++++++++++++++++++++++++++++++++++++ music/auth/auth.py | 41 +++++++++++++++++++++++++++++- music/auth/jwt_keys.py | 41 ++++++++++++++++++++++++++++++ music/model/config.py | 1 + poetry.lock | 20 ++++++++++++++- pyproject.toml | 1 + tests/test_auth.py | 33 ++++++++++++++++++++++++ 8 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 music/auth/jwt_keys.py create mode 100644 tests/test_auth.py diff --git a/music/api/api.py b/music/api/api.py index 041bf40..87d0b31 100644 --- a/music/api/api.py +++ b/music/api/api.py @@ -7,7 +7,7 @@ import json import logging 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_basic_auth, \ 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.tasks import update_all_user_playlists, update_playlists @@ -46,9 +46,9 @@ def all_playlists_route(user=None): @blueprint.route('/playlist', methods=['GET', 'DELETE']) -@login_or_basic_auth +@login_or_jwt @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) diff --git a/music/api/decorators.py b/music/api/decorators.py index e755f27..fd770b0 100644 --- a/music/api/decorators.py +++ b/music/api/decorators.py @@ -4,6 +4,7 @@ import logging from flask import session, request, jsonify from music.model.user import User +from music.auth.jwt_keys import validate_key logger = logging.getLogger(__name__) @@ -30,6 +31,19 @@ def is_basic_authed(): 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): @functools.wraps(func) def login_required_wrapper(*args, **kwargs): @@ -42,6 +56,48 @@ def login_required(func): 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): @functools.wraps(func) def login_or_basic_auth_wrapper(*args, **kwargs): diff --git a/music/auth/auth.py b/music/auth/auth.py index 8a12e91..373059e 100644 --- a/music/auth/auth.py +++ b/music/auth/auth.py @@ -1,7 +1,8 @@ -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 music.model.user import User from music.model.config import Config +from music.auth.jwt_keys import generate_key from urllib.parse import urlencode, urlunparse import datetime @@ -68,6 +69,44 @@ def logout(): flash('logged out') 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_login = datetime.datetime.utcnow() + user.update() + + logger.info(f'generating token for {username}') + + token = generate_key(user) + + 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']) def register(): diff --git a/music/auth/jwt_keys.py b/music/auth/jwt_keys.py new file mode 100644 index 0000000..097a15c --- /dev/null +++ b/music/auth/jwt_keys.py @@ -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 diff --git a/music/model/config.py b/music/model/config.py index c842160..b0f1d66 100644 --- a/music/model/config.py +++ b/music/model/config.py @@ -19,3 +19,4 @@ class Config(Model): """Determines whether playlist and tag update operations are done by Cloud Tasks or Functions """ secret_key = TextField() + jwt_secret_key = TextField() diff --git a/poetry.lock b/poetry.lock index b5eb4ff..ce1338f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -462,6 +462,20 @@ python-versions = "*" [package.dependencies] 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]] name = "pylint" version = "2.14.5" @@ -655,7 +669,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [metadata] lock-version = "1.1" python-versions = "~3.10" -content-hash = "e3a9a1b705fe06227a9e09263b2e84aa6ee1bc6e41096732f06715a109daca4e" +content-hash = "740e615c75f16d1d097c47b49a18fc3487be9541f257d5181d198693feb87380" [metadata.files] 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.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 = [ {file = "pylint-2.14.5-py3-none-any.whl", hash = "sha256:fabe30000de7d07636d2e82c9a518ad5ad7908590fe135ace169b44839c15f90"}, {file = "pylint-2.14.5.tar.gz", hash = "sha256:487ce2192eee48211269a0e976421f334cf94de1806ca9d0a99449adcdf0285e"}, diff --git a/pyproject.toml b/pyproject.toml index 949e520..8680ea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ google-cloud-logging = "^3.2.1" google-cloud-pubsub = "^2.13.4" google-cloud-tasks = "^2.10.0" requests = "^2.28.1" +PyJWT = "^2.4.0" spotframework = { git = "https://github.com/Sarsoo/spotframework.git" } fmframework = { git = "https://github.com/Sarsoo/pyfmframework.git" } diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..849389f --- /dev/null +++ b/tests/test_auth.py @@ -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) \ No newline at end of file From 4c9a17f2199ff92d4c4296c62000c77b5b58dbce Mon Sep 17 00:00:00 2001 From: andy Date: Mon, 8 Aug 2022 22:02:14 +0100 Subject: [PATCH 2/3] replacing all basic auth with jwt, adding config and expiry time --- music/api/admin.py | 6 +++--- music/api/api.py | 34 +++++++++++++++++----------------- music/api/fm.py | 6 +++--- music/api/player.py | 18 +++++++++--------- music/api/spotfm.py | 18 +++++++++--------- music/api/spotify.py | 6 +++--- music/api/tag.py | 14 +++++++------- music/auth/auth.py | 13 +++++++++++-- music/model/config.py | 2 ++ music/model/user.py | 1 + 10 files changed, 65 insertions(+), 53 deletions(-) diff --git a/music/api/admin.py b/music/api/admin.py index 63a7e61..b8842bf 100644 --- a/music/api/admin.py +++ b/music/api/admin.py @@ -5,7 +5,7 @@ from datetime import datetime 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__) @@ -16,9 +16,9 @@ logger = logging.getLogger(__name__) @blueprint.route('/tasks', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @admin_required -def get_tasks(user=None): +def get_tasks(auth=None, user=None): tasks = list(tasker.list_tasks(task_path)) diff --git a/music/api/api.py b/music/api/api.py index 87d0b31..7b0598e 100644 --- a/music/api/api.py +++ b/music/api/api.py @@ -7,7 +7,7 @@ import json import logging from datetime import datetime -from music.api.decorators import login_or_jwt, 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 from music.cloud import queue_run_user_playlist, offload_or_run_user_playlist from music.cloud.tasks import update_all_user_playlists, update_playlists @@ -28,8 +28,8 @@ logger = logging.getLogger(__name__) @blueprint.route('/playlists', methods=['GET']) -@login_or_basic_auth -def all_playlists_route(user=None): +@login_or_jwt +def all_playlists_route(auth=None, user=None): """Retrieve all playlists for a given user Args: @@ -64,9 +64,9 @@ def playlist_get_delete_route(auth=None,user=None): @blueprint.route('/playlist', methods=['POST', 'PUT']) -@login_or_basic_auth +@login_or_jwt @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() @@ -161,8 +161,8 @@ def playlist_post_put_route(user=None): @blueprint.route('/user', methods=['GET', 'POST']) -@login_or_basic_auth -def user_route(user=None): +@login_or_jwt +def user_route(auth=None, user=None): assert user is not None if request.method == 'GET': @@ -202,9 +202,9 @@ def user_route(user=None): @blueprint.route('/users', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @admin_required -def all_users_route(user=None): +def all_users_route(auth=None, user=None): return jsonify({ 'accounts': [i.to_dict() for i in User.collection.fetch()] }), 200 @@ -234,9 +234,9 @@ def change_password(user=None): @blueprint.route('/playlist/run', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @validate_args(('name', str)) -def run_playlist(user=None): +def run_playlist(auth=None, user=None): if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD': 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']) -@login_or_basic_auth -def run_user(user=None): +@login_or_jwt +def run_user(auth=None, user=None): if user.type == 'admin': user_name = request.args.get('username', user.username) @@ -288,19 +288,19 @@ def run_user_task(): @blueprint.route('/playlist/run/users', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @admin_required -def run_users(user=None): +def run_users(auth=None, user=None): update_all_user_playlists() return jsonify({'message': 'executed all users', 'status': 'success'}), 200 @blueprint.route('/playlist/image', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required @validate_args(('name', str)) -def image(user=None): +def image(auth=None, user=None): _playlist = user.get_playlist(request.args['name'], raise_error=False) if _playlist is None: diff --git a/music/api/fm.py b/music/api/fm.py index 8d783cd..6f41bdf 100644 --- a/music/api/fm.py +++ b/music/api/fm.py @@ -2,7 +2,7 @@ from flask import Blueprint, jsonify from datetime import date 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 @@ -11,9 +11,9 @@ logger = logging.getLogger(__name__) @blueprint.route('/today', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @lastfm_username_required -def daily_scrobbles(user=None): +def daily_scrobbles(auth=None, user=None): net = database.get_authed_lastfm_network(user) diff --git a/music/api/player.py b/music/api/player.py index 33ad6d5..54f8bbd 100644 --- a/music/api/player.py +++ b/music/api/player.py @@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify 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 from spotframework.net.network import SpotifyNetworkException @@ -16,9 +16,9 @@ logger = logging.getLogger(__name__) @blueprint.route('/play', methods=['POST']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required -def play(user=None): +def play(auth=None, user=None): request_json = request.get_json() if 'uri' in request_json: @@ -78,9 +78,9 @@ def play(user=None): @blueprint.route('/next', methods=['POST']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required -def next_track(user=None): +def next_track(auth=None, user=None): net = database.get_authed_spotify_network(user) player = Player(net) @@ -89,10 +89,10 @@ def next_track(user=None): @blueprint.route('/shuffle', methods=['POST']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required @validate_json(('state', bool)) -def shuffle(user=None): +def shuffle(auth=None, user=None): request_json = request.get_json() net = database.get_authed_spotify_network(user) @@ -103,10 +103,10 @@ def shuffle(user=None): @blueprint.route('/volume', methods=['POST']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required @validate_json(('volume', int)) -def volume(user=None): +def volume(auth=None, user=None): request_json = request.get_json() if 0 <= request_json['volume'] <= 100: diff --git a/music/api/spotfm.py b/music/api/spotfm.py index 04335df..0ca1948 100644 --- a/music/api/spotfm.py +++ b/music/api/spotfm.py @@ -3,7 +3,7 @@ import logging import json 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 import music.db.database as database 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']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required @lastfm_username_required -def count(user=None): +def count(auth=None, user=None): uri = request.args.get('uri', None) playlist_name = request.args.get('playlist_name', None) @@ -69,11 +69,11 @@ def count(user=None): @blueprint.route('/playlist/refresh', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required @lastfm_username_required @validate_args(('name', str)) -def playlist_refresh(user=None): +def playlist_refresh(auth=None, user=None): playlist_name = request.args['name'] @@ -133,16 +133,16 @@ def run_playlist_artist_task(): @blueprint.route('/playlist/refresh/users', methods=['GET']) -@login_or_basic_auth +@login_or_jwt @admin_required -def run_users(user=None): +def run_users(auth=None, user=None): refresh_all_user_playlist_stats() return jsonify({'message': 'executed all users', 'status': 'success'}), 200 @blueprint.route('/playlist/refresh/user', methods=['GET']) -@login_or_basic_auth -def run_user(user=None): +@login_or_jwt +def run_user(auth=None, user=None): if user.type == 'admin': user_name = request.args.get('username', user.username) diff --git a/music/api/spotify.py b/music/api/spotify.py index 11083da..20f497d 100644 --- a/music/api/spotify.py +++ b/music/api/spotify.py @@ -1,7 +1,7 @@ from flask import Blueprint, request, jsonify 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 from spotframework.engine.playlistengine import PlaylistEngine @@ -12,9 +12,9 @@ logger = logging.getLogger(__name__) @blueprint.route('/sort', methods=['POST']) -@login_or_basic_auth +@login_or_jwt @spotify_link_required -def sort(user=None): +def sort(auth=None, user=None): request_json = request.get_json() net = database.get_authed_spotify_network(user) diff --git a/music/api/tag.py b/music/api/tag.py index a030e78..b8f9aa4 100644 --- a/music/api/tag.py +++ b/music/api/tag.py @@ -4,7 +4,7 @@ import logging import os 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.tasks.update_tag import update_tag @@ -15,8 +15,8 @@ logger = logging.getLogger(__name__) @blueprint.route('/tag', methods=['GET']) -@login_or_basic_auth -def tags(user=None): +@login_or_jwt +def tags(auth=None, user=None): logger.info(f'retrieving tags for {user.username}') return jsonify({ 'tags': [i.to_dict() for i in Tag.collection.parent(user.key).fetch()] @@ -24,8 +24,8 @@ def tags(user=None): @blueprint.route('/tag/', methods=['GET', 'PUT', 'POST', "DELETE"]) -@login_or_basic_auth -def tag_route(tag_id, user=None): +@login_or_jwt +def tag_route(tag_id, auth=None, user=None): if request.method == 'GET': return get_tag(tag_id, user) elif request.method == 'PUT': @@ -126,8 +126,8 @@ def delete_tag(tag_id, user): @blueprint.route('/tag//update', methods=['GET']) -@login_or_basic_auth -def tag_refresh(tag_id, user=None): +@login_or_jwt +def tag_refresh(tag_id, auth=None, user=None): logger.info(f'updating {tag_id} tag for {user.username}') if os.environ.get('DEPLOY_DESTINATION', None) == 'PROD': diff --git a/music/auth/auth.py b/music/auth/auth.py index 373059e..22f3a20 100644 --- a/music/auth/auth.py +++ b/music/auth/auth.py @@ -6,6 +6,8 @@ from music.auth.jwt_keys import generate_key from urllib.parse import urlencode, urlunparse import datetime +from datetime import timedelta +from numbers import Number import logging from base64 import b64encode import requests @@ -95,12 +97,19 @@ def jwt_token(): logger.warning(f'locked account token attempt {username}') return jsonify({"message": 'user locked', "status": "error"}), 403 - user.last_login = datetime.datetime.utcnow() + user.last_keygen = datetime.datetime.utcnow() user.update() logger.info(f'generating token for {username}') - token = generate_key(user) + 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: diff --git a/music/model/config.py b/music/model/config.py index b0f1d66..4f3f8cf 100644 --- a/music/model/config.py +++ b/music/model/config.py @@ -20,3 +20,5 @@ class Config(Model): """ secret_key = TextField() jwt_secret_key = TextField() + jwt_max_length = NumberField() + jwt_default_length = NumberField() diff --git a/music/model/user.py b/music/model/user.py index 1272727..3ae6139 100644 --- a/music/model/user.py +++ b/music/model/user.py @@ -20,6 +20,7 @@ class User(Model): type = TextField(default="user") last_login = DateTime() + last_keygen = DateTime() last_refreshed = DateTime() locked = BooleanField(default=False, required=True) validated = BooleanField(default=True, required=True) From 018926361e93b8b921a647ebdf98ba6594c53301 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 9 Aug 2022 08:02:50 +0100 Subject: [PATCH 3/3] adding dispatch deploy to gcloud ci --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 491b26d..0f2f05f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,10 @@ jobs: - name: Set GCP 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 - name: Deploy App Engine Service run: python admin.py app -nb