diff --git a/login/app.py b/login/app.py index ac27ff34fda3127563d70d5acaaacdf3ee9c4a33..1ea53adb68590735e544253184dd06d454b956b0 100644 --- a/login/app.py +++ b/login/app.py @@ -1,48 +1,111 @@ + + +import logging +import os +import click + + +# Flask from flask import abort, Flask, redirect, request, render_template -from os import urandom, environ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask.cli import AppGroup + +from sqlalchemy import select +from sqlalchemy.orm import Session +from sqlalchemy import create_engine # Hydra admin -from hydra_client import HydraAdmin import hydra_client # Kratos ? from kratos import User, KratosError from forms import LoginForm -# Flask -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate - -import logging -import os - -# Configure Hydra Admin port -HYDRA_ADMIN_URL = environ['HYDRA_ADMIN_URL'] -HYDRA = HydraAdmin(HYDRA_ADMIN_URL) +# Initaliaze the FLASK app app = Flask(__name__) -app.config.from_object(os.environ['APP_SETTINGS']) - -app.config['SECRET_KEY'] = urandom(16) -app.config['HYDRA_ADMIN_URL'] = environ['HYDRA_ADMIN_URL']; -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# Load config +app.config.from_object(os.environ['APP_SETTINGS']) -app.debug = True if "FLASK_ENV" in environ and environ["FLASK_ENV"] == "development" else False +# Move to config? app.logger.setLevel(logging.INFO) +# Creat HYDRA admin interface +HYDRA_ADMIN_URL = os.environ['HYDRA_ADMIN_URL'] +HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL) +# Create DB interface db = SQLAlchemy(app) +engine = create_engine(app.config["SQLALCHEMY_DATABASE_URI"]) + + +# Migrate interface migrate = Migrate(app, db) +# Import models +from models import User, App, AppRole + -from models import User +# Import CLI routes +# Flask CLI +user_cli = AppGroup('user') +app_cli = AppGroup('app') + +@app_cli.command('create') +@click.argument('slug') +@click.argument('name') +def create_app(slug, name): + app.logger.info("Creating app definition: {1} ({0})".format(slug, name)) + + obj = App() + obj.name = name + obj.slug = slug + + db.session.add(obj) + db.session.commit() + +@app_cli.command('list') +def list_app(): + app.logger.info("Listing apps") + + apps = App.query.all() + + for obj in apps: + print("App name: %s \t Slug: %s" %(obj.name, obj.slug)) + + +@app_cli.command('delete',) +@click.argument('slug') +def delete_app(slug): + app.logger.info("Trying to delete app: {0}".format(slug)) + obj = App.query.filter_by(slug=slug).first() + + if not obj: + app.logger.info("Not found") + return + + db.session.delete(obj) + db.session.commit() + app.logger.info("Success") + return + + +app.cli.add_command(app_cli) + + +@user_cli.command('create') +@click.argument('email') +def create_user(email): + app.logger.info("Creating user: {0}".format(email)) +app.cli.add_command(user_cli) @app.route('/login', methods=['GET', 'POST']) def login(): @@ -63,19 +126,19 @@ def login(): # TODO: Empty/short passwors is a form Error challenge = None - # Retrieve the challenge id from the request. Depending on the method it is saved in the - # form (POST) or in a GET variable. + + # Retrieve the challenge id from the request. Depending on the method it is + # saved in the form (POST) or in a GET variable. If this variable is not set + # we can not continue. if request.method == 'GET': challenge = request.args.get("login_challenge") - if not challenge: - return abort(400) - elif login_form.validate_on_submit(): - challenge = login_form.challenge.data - - + if request.method == 'POST': + challenge = request.args.post("login_challenge") + if not challenge: app.logger.error("No challange given. Error in request") - abort(401) + abort(404) + # Now that we have the challenge id, we can request the challenge object from the hydra # admin API @@ -88,6 +151,41 @@ def login(): app.logger.error("Conflict. Login request has been used already. challenge={0}".format(challenge)) abort(503) + + + # Skip, if true, let's us know that Hydra has already successfully authenticated + # the user. we don't need to check anything and we can accept the request right away. + if login_request.skip: + app.logger.info("{0} is already logged in. Skip authentication".format(login_request.subject)) + return redirect(login_request.accept(login_request.subject)) + + #skip = request.args.get("skip") + #logout = request.args.get("logout") + #if skip: + # app.logger.info("{0} is already logged in. Skip authentication".format(login_request.subject)) + # return redirect(login_request.accept(login_request.subject)) + #elif logout: + # login_form.challenge.data = challenge + # HYDRA.invalidate_login_sessions(login_request.subject); + # return redirect(login_request.reject( + # "Login cancelled", + # error_description="Login was cancelled and user session was terminated")) + #else: + # return render_template('skip.html', challenge=challenge, logo=login_request.client.logo_uri, application_name=login_request.client.client_name, username=login_request.subject) + else: + app.logger.info("Not yet logged in. Requesting login credentials") + + + + + if "ory_kratos_session" not in request.cookies: + app.logger.info("No kratos cookie, get kratos cookie") + kratos = ory_kratos_client.ApiClient(app.config["KRATOS_PUBLIC_URL"]) + + + login_flow = LoginFlow(app.config["KRATOS_PUBLIC_URL"]) + return_to = f"{app.config['PUBLIC_URL']}/login?login_challenge={challenge}" + # We need to decide here whether we want to accept or decline the login request. # if a login form was submitted, we need to confirm that the userdata, the agent # send us via POST is valid @@ -119,24 +217,6 @@ def login(): app.logger.info("{0} failed to login".format(user.username)) return redirect(redirect_to) - # Skip, if true, let's us know that Hydra has already successfully authenticated - # the user. we don't need to check anything and we can accept the request right away. - elif login_request.skip: - skip = request.args.get("skip") - logout = request.args.get("logout") - if skip: - app.logger.info("{0} is already logged in. Skip authentication".format(login_request.subject)) - return redirect(login_request.accept(login_request.subject)) - elif logout: - login_form.challenge.data = challenge - HYDRA.invalidate_login_sessions(login_request.subject); - return redirect(login_request.reject( - "Login cancelled", - error_description="Login was cancelled and user session was terminated")) - else: - return render_template('skip.html', challenge=challenge, logo=login_request.client.logo_uri, application_name=login_request.client.client_name, username=login_request.subject) - - # If Skip is not true and the user has not submitted any data via a form, we need # to display a login form for the user to type in their username and password. diff --git a/login/config.py b/login/config.py index 3231b95e1924e977e4f8ba980092df4530352d6f..a48114b2352f52dd35ad467c31c857664efcf159 100644 --- a/login/config.py +++ b/login/config.py @@ -8,8 +8,13 @@ class Config(object): DEBUG = False TESTING = False CSRF_ENABLED = True - SECRET_KEY = 'this-really-needs-to-be-changed' SQLALCHEMY_DATABASE_URI = os.environ['DATABASE_URL'] + HYDRA_ADMIN_URL = os.environ['HYDRA_ADMIN_URL']; + SECRET_KEY = os.urandom(16) + SQLALCHEMY_TRACK_MODIFICATIONS = False + HYDRA_ADMIN_URL = os.environ['HYDRA_ADMIN_URL'] + + class ProductionConfig(Config): diff --git a/login/migrations/versions/051274550b64_add_roles_and_apps.py b/login/migrations/versions/207081778bb2_.py similarity index 65% rename from login/migrations/versions/051274550b64_add_roles_and_apps.py rename to login/migrations/versions/207081778bb2_.py index e14d294598bb79cfcad746350548950b4fe3f2df..b9e3c65dacac6a91f83fdd0f886194adef2f8f51 100644 --- a/login/migrations/versions/051274550b64_add_roles_and_apps.py +++ b/login/migrations/versions/207081778bb2_.py @@ -1,17 +1,17 @@ -"""Add roles and apps +"""empty message -Revision ID: 051274550b64 -Revises: fc0307250700 -Create Date: 2021-11-15 15:40:04.662936 +Revision ID: 207081778bb2 +Revises: +Create Date: 2021-11-16 08:18:27.697307 """ from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql + # revision identifiers, used by Alembic. -revision = '051274550b64' -down_revision = 'fc0307250700' +revision = '207081778bb2' +down_revision = None branch_labels = None depends_on = None @@ -20,11 +20,16 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('apps', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('app', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=True), sa.Column('slug', sa.String(), nullable=True), - sa.Column('result_all', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('result_no_stop_words', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.PrimaryKeyConstraint('id') + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') ) op.create_table('user_app_roles', sa.Column('user_id', sa.Integer(), nullable=False), @@ -40,5 +45,6 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_table('user_app_roles') + op.drop_table('users') op.drop_table('apps') # ### end Alembic commands ### diff --git a/login/migrations/versions/fc0307250700_initial_migration.py b/login/migrations/versions/fc0307250700_initial_migration.py deleted file mode 100644 index 945e1b5d9c4721387bdaf9c7082389ef715eee10..0000000000000000000000000000000000000000 --- a/login/migrations/versions/fc0307250700_initial_migration.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Initial migration. - -Revision ID: fc0307250700 -Revises: -Create Date: 2021-11-15 15:29:32.420084 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = 'fc0307250700' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('email', sa.String(), nullable=True), - sa.Column('result_all', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('result_no_stop_words', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('users') - # ### end Alembic commands ### diff --git a/login/models.py b/login/models.py index 66cfe5e27971c68891d55462ded89d92981d2f0b..2a898cd5d90b51b8d9d469b8edc7df2c190e7c21 100644 --- a/login/models.py +++ b/login/models.py @@ -1,20 +1,19 @@ - from app import db from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.orm import relationship +from sqlalchemy import Column, Integer, String, ForeignKey + + + class User(db.Model): __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String()) - result_all = db.Column(JSON) - result_no_stop_words = db.Column(JSON) + id = db.Column(Integer, primary_key=True) + email = db.Column(String, unique=True) - def __init__(self, email, result_all, result_no_stop_words): - self.email = email - self.result_all = result_all - self.result_no_stop_words = result_no_stop_words + app_roles = relationship('AppRole', back_populates="user") def __repr__(self): return '<id {}>'.format(self.id) @@ -24,17 +23,9 @@ class User(db.Model): class App(db.Model): __tablename__ = 'apps' - id = db.Column(db.Integer, primary_key=True) - app = db.Column(db.String()) - slug = db.Column(db.String()) - result_all = db.Column(JSON) - result_no_stop_words = db.Column(JSON) - - def __init__(self, app, slug, result_all, result_no_stop_words): - self.app = app - self.slug = slug - self.result_all = result_all - self.result_no_stop_words = result_no_stop_words + id = db.Column(Integer, primary_key=True) + name = db.Column(String()) + slug = db.Column(db.String(), unique=True) def __repr__(self): return '<id {}>'.format(self.id) @@ -43,13 +34,13 @@ class App(db.Model): class AppRole(db.Model): __tablename__ = 'user_app_roles' - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) - user = db.relationship("User", back_populates="app_roles") + user_id = db.Column(Integer, ForeignKey('users.id'), primary_key=True) + user = relationship("User", back_populates="app_roles") - app_id = db.Column(db.Integer, db.ForeignKey('apps.id'), + app_id = db.Column(Integer, ForeignKey('apps.id'), primary_key=True) - app = db.relationship("App") + app = relationship("App") - role = db.Column(db.String()) + role = db.Column(String) diff --git a/login/requirements.txt b/login/requirements.txt index ebf62f551239743d54257d72a6fc634008da5197..15b4601fff892a432dd06a686e67770f3cb6a813 100644 --- a/login/requirements.txt +++ b/login/requirements.txt @@ -6,5 +6,4 @@ Flask-SQLAlchemy Flask-Migrate Flask-Script psycopg2 -bcrypt -graphqlclient +ory-kratos-client diff --git a/login/source_env b/login/source_env index 323f07c682971ef47d9a3e1abf37010d050953a5..8a03fb801b79e0bccd3cc13d532b1772629ca7b5 100755 --- a/login/source_env +++ b/login/source_env @@ -4,5 +4,8 @@ export FLASK_RUN_HOST=0.0.0.0 export FLASK_RUN_PORT=5000 export HYDRA_ADMIN_URL=http://localhost:4445 +export KRATOS_PUBLIC_URL=http://localhost:8080 +export KRATOS_ADMIN_URL=http://localhost:8000 + export DATABASE_URL="postgresql://stackspin:stackspin@localhost/stackspin" export APP_SETTINGS="config.DevelopmentConfig"