Skip to content

Github Auth #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# ci-speed

[![Python application](https://github.com/thedumbterminal/ci-speed/actions/workflows/python-app.yml/badge.svg)](https://github.com/thedumbterminal/ci-speed/actions/workflows/python-app.yml)

Measure CI time

## Requirements
Expand All @@ -25,13 +28,26 @@ docker ps -a
## Run

```
DEBUG=1 python ./app/main.py
FLASK_DEBUG=1 PYTHONPATH=app FLASK_ENV=development flask run
```

Dev server available at:

http://127.0.0.1:5000


Or with github auth:

```
GITHUB_OAUTH_CLIENT_ID=xxx GITHUB_OAUTH_CLIENT_SECRET=xxx OAUTHLIB_INSECURE_TRANSPORT=true FLASK_DEBUG=1 PYTHONPATH=app FLASK_ENV=development flask run
```

## Listing routes

```
PYTHONPATH=app flask routes
```

### Production mode

```
Expand Down Expand Up @@ -80,9 +96,14 @@ heroku run 'PYTHONPATH=app FLASK_APP=main flask db upgrade'

## Environment Variables

See also the built in flask environment variables.

* DATABASE_URL - Set the PostgreSQL DSN to use other than the default.
* UI_URL_BASE - Set the prefix when creating URLs to the UI app.
* DEBUG - Set to `1` to enable debug mode.
* FLASK_SECRET_KEY - Use a random value for flask auth storage.
* GITHUB_OAUTH_CLIENT_ID - Set to github oauth app client ID.
* GITHUB_OAUTH_CLIENT_SECRET - Set to github oauth app client secret.
* OAUTHLIB_INSECURE_TRANSPORT - Set to `true` when testing auth locally.

## Tech
* Python
Expand Down
12 changes: 6 additions & 6 deletions app/f_app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from flask import Flask
from flask_cors import CORS
from os import environ
import os

app = Flask(__name__)

if environ.get('DEBUG', False) == '1':
print('Starting in debug mode...')
app.debug = True

app = Flask(__name__)
CORS(app)

app.secret_key = os.environ.get("FLASK_SECRET_KEY", "reallysecret")
app.config["GITHUB_OAUTH_CLIENT_ID"] = os.environ.get("GITHUB_OAUTH_CLIENT_ID")
app.config["GITHUB_OAUTH_CLIENT_SECRET"] = os.environ.get("GITHUB_OAUTH_CLIENT_SECRET")
16 changes: 14 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
from resources import api
from flask_marshmallow import Marshmallow
from f_app import app
from oauth_blueprint import blueprint
from models import security, user_datastore

ma = Marshmallow(app)

api.init_app(app)

if __name__ == '__main__':
app.run()
app.register_blueprint(blueprint, url_prefix="/oauth")

security.init_app(app, user_datastore)


@app.cli.command()
def routes():
'Display registered routes'
for rule in app.url_map.iter_rules():
methods = ','.join(sorted(rule.methods))
route = '{:50s} {:25s} {}'.format(rule.endpoint, methods, str(rule))
print(route)
37 changes: 37 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,37 @@
from db import db
from flask_sqlalchemy import SQLAlchemy
from flask_security import UserMixin, RoleMixin, SQLAlchemyUserDatastore, Security
from flask_dance.consumer.storage.sqla import OAuthConsumerMixin


roles_users = db.Table(
"roles_users",
db.Column("user_id", db.Integer(), db.ForeignKey("user.id")),
db.Column("role_id", db.Integer(), db.ForeignKey("role.id")),
)


class Role(db.Model, RoleMixin):
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))


class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())
roles = db.relationship(
Role, secondary=roles_users, backref=db.backref("users", lazy="dynamic")
)


class OAuth(OAuthConsumerMixin, db.Model):
provider_user_id = db.Column(db.String(256), unique=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False)
user = db.relationship(User)


class Project(db.Model):
Expand Down Expand Up @@ -60,3 +93,7 @@ class TestCase(db.Model):
def __init__(self, name, time):
self.name = name
self.time = time

# Setup Flask-Security
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(datastore=user_datastore)
76 changes: 76 additions & 0 deletions app/oauth_blueprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from flask_dance.contrib.github import make_github_blueprint, github
from flask_dance.consumer import oauth_authorized, oauth_error
from flask_security import current_user, login_user
from models import User, OAuth
from db import db
from sqlalchemy.orm.exc import NoResultFound

blueprint = make_github_blueprint(scope='user:email')

# create/login local user on successful OAuth login
@oauth_authorized.connect_via(blueprint)
def github_logged_in(blueprint, token):
if not token:
print("Failed to log in.")
return False

resp = github.get("/user")
print(resp)
info = resp.json()
print(info)
if not resp.ok:
print("Failed to fetch user info.")
return False

print('Login OK')

github_login = info['login']
github_id = str(info['id'])
github_name = info['name']

emails_resp = github.get('/user/emails')
if not emails_resp.ok:
print("Failed to fetch user emails.")
return False
print(emails_resp)
emails = emails_resp.json()
print(emails)
primary_emails = list(filter(lambda x: x['primary'], emails))
print(primary_emails)
primary_email = primary_emails[0]['email']
print(primary_email)

# Find this OAuth token in the database, or create it
query = OAuth.query.filter_by(provider=blueprint.name, provider_user_id=github_id)
try:
oauth = query.one()
except NoResultFound:
oauth = OAuth(provider=blueprint.name, provider_user_id=github_id, token=token)

if oauth.user:
login_user(oauth.user)
print("Successfully signed into existing account")

else:
# Create a new local user account for this user
user = User(email=primary_email, active=True)
# Associate the new local user account with the OAuth token
oauth.user = user
# Save and commit our database models
db.session.add_all([user, oauth])
db.session.commit()
# Log in the new local user account
login_user(user)
print("Successfully signed in to new account")


# Disable Flask-Dance's default behavior for saving the OAuth token
return False

# notify on OAuth provider error
@oauth_error.connect_via(blueprint)
def google_error(blueprint, message, response):
msg = "OAuth error from {name}! message={message} response={response}".format(
name=blueprint.name, message=message, response=response
)
print(msg)
22 changes: 22 additions & 0 deletions app/resources/Login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from flask_restx import Resource, Namespace
from flask_dance.contrib.github import github
from flask import redirect, url_for


api = Namespace("login", description="Login related operations")

def _log_response(resp):
print(dict(resp.headers))
print(resp.status_code, resp.json())

@api.route("/")
class Login(Resource):
@api.doc("Login to your account via github")
def get(self):
if not github.authorized:
print('not authorized')
return redirect(url_for("github.login"))
resp = github.get("/user")
_log_response(resp)
assert resp.ok
return "You are @{login} on GitHub".format(login=resp.json()["login"])
18 changes: 18 additions & 0 deletions app/resources/User.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from flask_restx import Resource, Namespace
from flask_security import auth_required, current_user
from schemas import UserSchema


api = Namespace("user", description="User related operations")

def _log_response(resp):
print(dict(resp.headers))
print(resp.status_code, resp.json())

@api.route("/")
class User(Resource):
@auth_required('session')
@api.doc("get the info of the current user")
def get(self):
user_schema = UserSchema()
return user_schema.dump(current_user)
4 changes: 4 additions & 0 deletions app/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from .TestSuiteList import api as test_suite_list_api
from .TestCase import api as test_case_api
from .TestCaseList import api as test_case_list_api
from .Login import api as login_api
from .User import api as user_api

api = Api(
title="CI-Speed API",
Expand All @@ -23,3 +25,5 @@
api.add_namespace(test_suite_list_api)
api.add_namespace(test_case_api)
api.add_namespace(test_case_list_api)
api.add_namespace(login_api)
api.add_namespace(user_api)
9 changes: 8 additions & 1 deletion app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from flask_marshmallow import Marshmallow
from models import Project, TestRun, TestSuite, TestCase
from models import Project, TestRun, TestSuite, TestCase, User
from flask_marshmallow.fields import fields

ma = Marshmallow()


class UserSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = User
load_instance = True
include_fk = True


class ProjectSchema(ma.SQLAlchemyAutoSchema):
test_runs = ma.auto_field()

Expand Down
75 changes: 75 additions & 0 deletions migrations/versions/4f6214cbae7f_supporting_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Supporting auth

Revision ID: 4f6214cbae7f
Revises: 0149f1bb11c5
Create Date: 2022-05-08 23:48:39.426711

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '4f6214cbae7f'
down_revision = '0149f1bb11c5'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('role',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=80), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('password', sa.String(length=255), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('flask_dance_oauth',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('provider', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('token', sa.JSON(), nullable=False),
sa.Column('provider_user_id', sa.String(length=256), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('provider_user_id')
)
op.create_table('roles_users',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], )
)
op.drop_constraint('test_case_test_suite_id_fkey', 'test_case', type_='foreignkey')
op.create_foreign_key(None, 'test_case', 'test_suite', ['test_suite_id'], ['id'])
op.drop_constraint('test_run_project_id_fkey', 'test_run', type_='foreignkey')
op.create_foreign_key(None, 'test_run', 'project', ['project_id'], ['id'])
op.drop_constraint('test_suite_test_run_id_fkey', 'test_suite', type_='foreignkey')
op.create_foreign_key(None, 'test_suite', 'test_run', ['test_run_id'], ['id'])
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'test_suite', type_='foreignkey')
op.create_foreign_key('test_suite_test_run_id_fkey', 'test_suite', 'test_run', ['test_run_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_constraint(None, 'test_run', type_='foreignkey')
op.create_foreign_key('test_run_project_id_fkey', 'test_run', 'project', ['project_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_constraint(None, 'test_case', type_='foreignkey')
op.create_foreign_key('test_case_test_suite_id_fkey', 'test_case', 'test_suite', ['test_suite_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_table('roles_users')
op.drop_table('flask_dance_oauth')
op.drop_table('user')
op.drop_table('role')
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ xmltodict
marshmallow-sqlalchemy
psycopg2-binary
Flask-Migrate
Flask-Dance
Flask-Security

#dev includes
flake8