Skip to content

Commit

Permalink
Add authentication to Ibutsu
Browse files Browse the repository at this point in the history
- Add a login page
- Add a login controller
- Use JWT authentication
- Make HttpClient service object that transparently auths requests
- Add OAuth2 login to Ibutsu
- Add tests for the login controller
  • Loading branch information
rsnyman committed May 27, 2021
1 parent 13774fe commit 764946c
Show file tree
Hide file tree
Showing 61 changed files with 3,538 additions and 2,276 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ frontend/public/version.json

# Settings
backend/settings.yaml
backend/settings.local.yaml

# pytest-ibutsu stuff
*.tar.gz
Expand Down
5 changes: 1 addition & 4 deletions backend/ibutsu_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ def get_app(**extra_config):
config = app.app.config
config.setdefault("BCRYPT_LOG_ROUNDS", 20)
config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", True)
settings_path = Path("./settings.yaml").resolve()
if settings_path.exists():
# If there's a config file, load it
config.from_mapping(yaml_load(settings_path.open()))
config.from_file(str(Path("./settings.yaml").resolve()), yaml_load, silent=True)
# Now load config from environment variables
config.from_mapping(os.environ)
if config.get("POSTGRESQL_HOST") and config.get("POSTGRESQL_DATABASE"):
Expand Down
21 changes: 21 additions & 0 deletions backend/ibutsu_server/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
OAUTH_CONFIG = {
"google": {
"scope": ["https://www.googleapis.com/auth/userinfo.profile"],
},
"github": {
"scope": ["read:user"],
},
"dropbox": {
"scope": ["account_info.read"],
},
"facebook": {
"scope": ["email", "public_profile"],
},
"gitlab": {
"scope": ["read_user"],
"sep": "+",
"user_url": "/api/v4/user",
"auth_url": "/oauth/authorize",
"token_url": "/oauth/token",
},
}
ALLOWED_TRUE_BOOLEANS = ["y", "t", "1"]
ARRAY_FIELDS = ["metadata.tags", "metadata.markers", "metadata.annotations"]
NUMERIC_FIELDS = [
Expand Down
2 changes: 1 addition & 1 deletion backend/ibutsu_server/controllers/artifact_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def download_artifact(id_):


@validate_uuid
def get_artifact(id_):
def get_artifact(id_, token_info=None, user=None):
"""Return a single artifact
:param id: ID of the artifact
Expand Down
4 changes: 2 additions & 2 deletions backend/ibutsu_server/controllers/group_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def add_group(group=None):


@validate_uuid
def get_group(id_):
def get_group(id_, token_info=None, user=None):
"""Get a group
:param id: The ID of the group
Expand All @@ -35,7 +35,7 @@ def get_group(id_):
return "Group not found", 404


def get_group_list(page=1, page_size=25):
def get_group_list(page=1, page_size=25, token_info=None, user=None):
"""Get a list of groups
Expand Down
6 changes: 3 additions & 3 deletions backend/ibutsu_server/controllers/health_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
IS_CONNECTED = False


def get_health():
def get_health(token_info=None, user=None):
"""Get a health report
:rtype: Health
"""
return {"status": "OK", "message": "Service is running"}


def get_database_health():
def get_database_health(token_info=None, user=None):
"""Get a health report for the database
:rtype: Health
Expand All @@ -40,7 +40,7 @@ def get_database_health():
return response


def get_health_info():
def get_health_info(token_info=None, user=None):
"""Get the information about this server
:rtype: HealthInfo
Expand Down
98 changes: 98 additions & 0 deletions backend/ibutsu_server/controllers/login_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import json
import random
import string

import connexion
import requests
from flask import current_app
from flask import make_response
from ibutsu_server.db.models import User
from ibutsu_server.util.jwt import generate_token
from ibutsu_server.util.oauth import get_provider_config
from ibutsu_server.util.oauth import get_user_from_provider

AUTH_WINDOW = """<html>
<head></head>
<body>
<script>
window.addEventListener("message", function (event) {{
if (event.data.message === "requestResult") {{
event.source.postMessage({{"message": "deliverResult", result: {data} }}, "*");
}}
}});
</script>
</body>
</html>"""


def _generate_state():
allowed_chars = string.ascii_letters + string.punctuation
return "".join(random.choice(allowed_chars) for x in range(40))


def login(email=None, password=None):
"""login
:param email: The e-mail address of the user
:type email: str
:param password: The password for the user
:type password: str
:rtype: LoginToken
"""
if not connexion.request.is_json:
return "Bad request, JSON is required", 400
login = connexion.request.get_json()
if not login.get("email") or not login.get("password"):
return {"code": "EMPTY", "message": "Username and/or password are empty"}, 401
user = User.query.filter_by(email=login["email"]).first()
if user and user.check_password(login["password"]):
return {"name": user.name, "email": user.email, "token": generate_token(user.email)}
else:
return {"code": "INVALID", "message": "Username and/or password are invalid"}, 401


def support():
"""Return the authentication types that the server supports"""
return {
"user": True,
"redhat": current_app.config.get("REDHAT_CLIENT_ID") is not None,
"google": get_provider_config("google")["client_id"] is not None,
"github": get_provider_config("github")["client_id"] is not None,
"dropbox": get_provider_config("dropbox")["client_id"] is not None,
"facebook": get_provider_config("facebook")["client_id"] is not None,
"gitlab": get_provider_config("gitlab")["client_id"] is not None,
}


def config(provider):
"""Return the configuration for a particular login provider"""
if provider == "redhat":
return {}
else:
return get_provider_config(provider, is_private=False)


def oauth(provider):
"""OAuth redirect URL"""
if not connexion.request.args.get("code"):
return "Bad request", 400
provider_config = get_provider_config(provider, is_private=True)
payload = {
"client_id": provider_config["client_id"],
"client_secret": provider_config["client_secret"],
"code": connexion.request.args.get("code"),
"grant_type": "authorization_code",
"redirect_uri": provider_config["redirect_uri"],
}
r = requests.post(provider_config.get("token_url", "/oauth/token"), data=payload)
if r.status_code == 200:
user = get_user_from_provider(provider, r.json())
jwt_token = generate_token(user.id)
return make_response(
AUTH_WINDOW.format(
data=json.dumps({"email": user.email, "name": user.name, "token": jwt_token})
)
)
else:
return "Unauthorized", 401
8 changes: 4 additions & 4 deletions backend/ibutsu_server/controllers/project_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def add_project(project=None):
return project.to_dict(), 201


def get_project(id_):
def get_project(id_, token_info=None, user=None):
"""Get a single project by ID
:param id: ID of test project
Expand All @@ -41,11 +41,11 @@ def get_project(id_):
return project.to_dict()


def get_project_list(owner_id=None, group_id=None, page=1, page_size=25):
def get_project_list(
owner_id=None, group_id=None, page=1, page_size=25, token_info=None, user=None
):
"""Get a list of projects
:param owner_id: Filter projects by owner ID
:type owner_id: str
:param group_id: Filter projects by group ID
Expand Down
10 changes: 5 additions & 5 deletions backend/ibutsu_server/controllers/report_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def _build_report_response(id_):
return report, response


def get_report_types():
def get_report_types(token_info=None, user=None):
"""Get the types of reports that are available
:rtype: list
Expand Down Expand Up @@ -69,7 +69,7 @@ def add_report(report_parameters=None):


@validate_uuid
def get_report(id_):
def get_report(id_, token_info=None, user=None):
"""Get a report
:param id: The ID of the report
Expand All @@ -81,7 +81,7 @@ def get_report(id_):
return report.to_dict()


def get_report_list(page=1, page_size=25, project=None):
def get_report_list(page=1, page_size=25, project=None, token_info=None, user=None):
"""Get a list of reports
:param page: Set the page of items to return, defaults to 1
Expand Down Expand Up @@ -127,7 +127,7 @@ def delete_report(id_):
return "Not Found", 404


def view_report(id_, filename):
def view_report(id_, filename, token_info=None, user=None):
"""View the report file
:param id_: The ID of the report to view
Expand All @@ -138,7 +138,7 @@ def view_report(id_, filename):
return _build_report_response(id_)[1]


def download_report(id_, filename):
def download_report(id_, filename, token_info=None, user=None):
"""Download the report file
:param id_: The ID of the report to download
Expand Down
4 changes: 2 additions & 2 deletions backend/ibutsu_server/controllers/result_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def add_result(result=None):


@query_as_task
def get_result_list(filter_=None, page=1, page_size=25, estimate=False):
def get_result_list(filter_=None, page=1, page_size=25, estimate=False, token_info=None, user=None):
"""Gets all results
The `filter` parameter takes a list of filters to apply in the form of:
Expand Down Expand Up @@ -111,7 +111,7 @@ def get_result_list(filter_=None, page=1, page_size=25, estimate=False):


@validate_uuid
def get_result(id_):
def get_result(id_, token_info=None, user=None):
"""Get a single result
:param id: ID of Result to return
Expand Down
4 changes: 2 additions & 2 deletions backend/ibutsu_server/controllers/run_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@


@query_as_task
def get_run_list(filter_=None, page=1, page_size=25, estimate=False):
def get_run_list(filter_=None, page=1, page_size=25, estimate=False, token_info=None, user=None):
"""Get a list of runs
The `filter` parameter takes a list of filters to apply in the form of:
Expand Down Expand Up @@ -85,7 +85,7 @@ def get_run_list(filter_=None, page=1, page_size=25, estimate=False):


@validate_uuid
def get_run(id_):
def get_run(id_, token_info=None, user=None):
"""Get a run
:param id: The ID of the run
Expand Down
5 changes: 4 additions & 1 deletion backend/ibutsu_server/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ def password(self):

@password.setter
def _set_password(self, plaintext):
self._password = bcrypt.generate_password_hash(plaintext)
self._password = bcrypt.generate_password_hash(plaintext).decode("utf8")

def check_password(self, plaintext):
return bcrypt.check_password_hash(self.password, plaintext)


class Meta(Model):
Expand Down
2 changes: 2 additions & 0 deletions backend/ibutsu_server/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class IbutsuError(Exception):
"""Base exception for Ibutsu"""
Loading

0 comments on commit 764946c

Please sign in to comment.