diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..5b2c9c4 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,25 @@ +kind: pipeline +type: docker +name: default + +steps: + - name: code-analysis + image: aosapps/drone-sonar-plugin + settings: + sonar_host: + from_secret: SONAR_HOST + sonar_token: + from_secret: SONAR_CODE + + - name: kaniko + image: banzaicloud/drone-kaniko + settings: + registry: registry.kmlabz.com + repo: onspot/${DRONE_REPO_NAME} + username: + from_secret: DOCKER_USERNAME + password: + from_secret: DOCKER_PASSWORD + tags: + - latest + - ${DRONE_BUILD_NUMBER} diff --git a/.gitignore b/.gitignore index 13d1490..927f0b2 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,4 @@ dmypy.json # Pyre type checker .pyre/ - +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..717487a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.9-slim + +ENV TZ Europe/Budapest +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +ARG RELEASE_ID +ENV RELEASE_ID ${RELEASE_ID:-""} + +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt && rm -rf requirements.txt + +COPY ./src . + +EXPOSE 8080 + +ENTRYPOINT ["gunicorn", "-b", "0.0.0.0:8080", "--workers", "1", "--threads", "1", "app:app"] \ No newline at end of file diff --git a/k8s/backend.yml b/k8s/backend.yml new file mode 100644 index 0000000..a252da5 --- /dev/null +++ b/k8s/backend.yml @@ -0,0 +1,45 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: onspot + labels: + app: backend +spec: + replicas: 1 + selector: + matchLabels: + app: backend + strategy: + type: Recreate + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: registry.kmlabz.com/onspot/backend + imagePullPolicy: Always + ports: + - containerPort: 8080 + imagePullSecrets: + - name: regcred +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: onspot + labels: + app: backend +spec: + ports: + - name: backend + port: 80 + targetPort: 8080 + protocol: TCP + selector: + app: backend + type: ClusterIP \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4619f3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +musicbrainzngs +flask +flask-restful +gunicorn +flask-jwt-extended +sentry-sdk[flask] +py-healthcheck +flask-redis +marshmallow +flask-marshmallow +spotipy +pycryptodome \ No newline at end of file diff --git a/src/aes_encrypt.py b/src/aes_encrypt.py new file mode 100644 index 0000000..73a576a --- /dev/null +++ b/src/aes_encrypt.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +""" +AES Encryption methods +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "aes_encrypt" +__version__text__ = "1" + +import base64 +import json + +from Crypto.Cipher import AES + +from fred import flaskred +from schemas import UserSchema + + +class AESCrypto: + def __init__(self, encoded_secret_key: str, padding_character: bytes = 'a'.encode('UFT-8')): + self.padding_character = padding_character + self.encoded_secret_key = encoded_secret_key + + def encrypt_message(self, private_msg) -> bytes: + secret_key = base64.b64decode(self.encoded_secret_key) + cipher = AES.new(secret_key, AES.MODE_EAX) + padded_private_msg = private_msg + (self.padding_character.decode('UFT-8') * ((16 - len(private_msg)) % 16)) + encrypted_msg = cipher.encrypt(padded_private_msg) + encoded_encrypted_msg = base64.b64encode(encrypted_msg) + return encoded_encrypted_msg + + def decrypt_message(self, encoded_encrypted_msg) -> str: + secret_key = base64.b64decode(self.encoded_secret_key) + encrypted_msg = base64.b64decode(encoded_encrypted_msg) + cipher = AES.new(secret_key, AES.MODE_EAX) + decrypted_msg = cipher.decrypt(encrypted_msg) + unpadded_private_msg = decrypted_msg.rstrip(self.padding_character) + return unpadded_private_msg.decode('UTF-8') + + +class EncryptedUserRedis: + def __init__(self, encoded_secret_key: str): + self.aes = AESCrypto(encoded_secret_key) + self.userschema = UserSchema(many=False) + + def store(self, user: UserSchema) -> None: + plaindict = self.userschema.dump(user) + plaindict['password'] = self.aes.encrypt_message(user['password']) + flaskred.set(user['name'], json.dumps(plaindict).encode('UTF-8')) + + def load(self, username: str) -> UserSchema: + encryptedstr = flaskred.get(username).decode('UTF-8') + encrypteddict = json.loads(encryptedstr) + user = UserSchema(name=encrypteddict['name'], password=self.aes.decrypt_message(encrypteddict['password'])) + return user diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..f37bca1 --- /dev/null +++ b/src/app.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import os +import logging +from flask import Flask +from flask_restful import Api +import sentry_sdk +from sentry_sdk.integrations.flask import FlaskIntegration +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration +from healthcheck import HealthCheck + +from jwtman import jwtman +from marshm import ma +from fred import flaskred +from config import SENTRY_DSN, JWT_SECRET_KEY, RELEASEMODE, RELEASE_ID, PORT, DEBUG, REDIS_URL +from errorhandlers import register_all_error_handlers +from resources import LoginApi + +""" +Main Flask RESTful API +""" + +__author__ = "@tormakris" +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "app" +__version__text__ = "1" + +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[FlaskIntegration(), SqlalchemyIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + release=RELEASE_ID, + environment=RELEASEMODE, + _experiments={"auto_enabling_integrations": True} + ) + +app = Flask(__name__) +app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY +app.config['REDIS_URL'] = REDIS_URL +api = Api(app) +health = HealthCheck() +ma.init_app(app) +flaskred.init_app(app) +jwtman.init_app(app) + +formatter = logging.Formatter( + fmt="%(asctime)s - %(levelname)s - %(module)s - %(message)s" +) + +handler = logging.StreamHandler() +handler.setFormatter(formatter) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +logger.addHandler(handler) + +api.add_resource(LogoffApi, '/api/auth/logoff') +api.add_resource(LoginApi, '/api/auth/login') +api.add_resource(MeApi, '/api/auth/me') +api.add_resource(ListsApi, '/api/lists') +api.add_resource(SingleListApi, '/api/lists/') +api.add_resource(TrackApi, '/api/lists//') + +app.add_url_rule("/healthz", "healthcheck", view_func=lambda: health.run()) + +register_all_error_handlers(app) + + +if __name__ == "__main__": + app.run( + debug=bool(DEBUG), + host="0.0.0.0", + port=int(PORT), + ) \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..131ced6 --- /dev/null +++ b/src/config.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +import os +from uuid import uuid4 + +""" +Configuration +""" + + +__author__ = "@tormakris" +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "config" +__version__text__ = "1" + + +PORT = os.environ.get("ONSPOT_PORT", 8080) +DEBUG = os.environ.get("ONSPOT_DEBUG", True) + +SENTRY_DSN = os.environ.get("SENTRY_DSN") +RELEASE_ID = os.environ.get("RELEASE_ID", "test") +RELEASEMODE = os.environ.get("ONSPOT_RELEASEMODE", "dev") + +JWT_SECRET_KEY = os.getenv("ONSPOT_JWT_SECRET_KEY", str(uuid4())) + +REDIS_URL = os.getenv("ONSPOT_REDIS_URL") + +ENCODED_SECRET_KEY = os.getenv("ONSPOT_ENCODED_SECRET_KEY") diff --git a/src/errorhandlers.py b/src/errorhandlers.py new file mode 100644 index 0000000..a86b641 --- /dev/null +++ b/src/errorhandlers.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + +""" +Flask error handler functions +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "errorhandlers" +__version__text__ = "1" + + +def get_standard_error_handler(code: int): + def error_handler(err): + return {"msg": str(err)}, code + + return error_handler + + +# function to register all handlers + + +def register_all_error_handlers(app): + error_codes_to_override = [404, 403, 401, 405, 400, 409, 422] + + for code in error_codes_to_override: + app.register_error_handler(code, get_standard_error_handler(code)) diff --git a/src/fred.py b/src/fred.py new file mode 100644 index 0000000..581e339 --- /dev/null +++ b/src/fred.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from flask_redis import FlaskRedis + +""" +Redis +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "fred" +__version__text__ = "1" + +flaskred = FlaskRedis() diff --git a/src/jwtman.py b/src/jwtman.py new file mode 100644 index 0000000..ab4904b --- /dev/null +++ b/src/jwtman.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from flask_jwt_extended import JWTManager + +""" +JWTManager +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "jwtman" +__version__text__ = "1" + +jwtman = JWTManager() diff --git a/src/marshm.py b/src/marshm.py new file mode 100644 index 0000000..ae61dcb --- /dev/null +++ b/src/marshm.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 + +from flask_marshmallow import Marshmallow + +""" +Marshmallow +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "marshm" +__version__text__ = "1" + +ma = Marshmallow() diff --git a/src/resources.py b/src/resources.py new file mode 100644 index 0000000..82ef029 --- /dev/null +++ b/src/resources.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import datetime + +from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity +from flask_restful import Resource +from flask import request, current_app, abort + +from config import ENCODED_SECRET_KEY +from schemas import UserSchema, ListSchema, TrackSchema +from aes_encrypt import EncryptedUserRedis + +""" +Flask Restful endpoints +""" + +__author__ = '@tormakris' +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "resources" +__version__text__ = "1" + +INVALID_JSON_SCHEMA_MSG = "invalid json schema" + + +class LoginApi(Resource): + """ + See: https://swagger.kmlabz.com/?urls.primaryName=onSpot%20Backend#/backend/logon + """ + + userschema = UserSchema(many=False) + encryptor = EncryptedUserRedis(ENCODED_SECRET_KEY) + + def post(self): + body = request.get_json() + + try: + userobj = self.userschema.load(body) + except Exception as e: + current_app.logger.warning(e) + abort(417, INVALID_JSON_SCHEMA_MSG) + + self.encryptor.store(userobj) + + expires = datetime.timedelta(days=7) + access_token = create_access_token(identity=str(userobj['name']), expires_delta=expires) + return {'token': access_token}, 200 diff --git a/src/schemas.py b/src/schemas.py new file mode 100644 index 0000000..9e93af1 --- /dev/null +++ b/src/schemas.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +from marshm import ma +from marshmallow import fields + + +""" +Marshmallow schemas +""" + +__author__ = "@tormakris" +__copyright__ = "Copyright 2020, onSpot Team" +__module_name__ = "schemas" +__version__text__ = "1" + + +class UserSchema(ma.Schema): + """ + Parameters: + - name (string) + - passowrd (string) + """ + + name = fields.String(required=True) + password = fields.String(required=True) + + +class TrackSchema(ma.Schema): + """ + Parameters: + - id (integer) + - title (string) + - artist (string) + - album (string) + - spotify_id (string) + - cover_url (string) + """ + + id = fields.Integer(required=True) + title = fields.String(required=True) + artist = fields.String(required=True) + album = fields.String(required=True) + spotify_id = fields.String(required=True) + cover_url = fields.String(required=True) + + +class ListSchema(ma.Schema): + + id = fields.Integer(required=True) + tracklist = fields.Nested(TrackSchema, many=True)