diff --git a/Pipfile b/Pipfile index 9a7f597ecb8e333c9e181141fd32d511045d8f96..d1a67df51cdde9c86c390ea0b8a8228a527f87f8 100644 --- a/Pipfile +++ b/Pipfile @@ -1,27 +1,27 @@ -[[source]] - -url = "https://pypi.python.org/simple" -verify_ssl = true -name = "pypi" - - -[packages] - -flask = "*" -flask-sqlalchemy = "*" -pytest = "*" -flask-restful = "*" -marshmallow = "*" -flask-jwt-extended = "*" -marshmallow-enum = "*" -storage = { git="git@gitlab.fi.muni.cz:grp-kontr2/kontr-storage-module.git", editable='true' } -gitpython = "*" - - -[dev-packages] - - - -[requires] - -python_version = "3.6" +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[packages] + +flask = "*" +flask-sqlalchemy = "*" +pytest = "*" +flask-restful = "*" +marshmallow = "*" +flask-jwt-extended = "*" +marshmallow-enum = "*" +storage = { git="git@gitlab.fi.muni.cz:grp-kontr2/kontr-storage-module.git", editable='true' } +gitpython = "*" + + +[dev-packages] + + + +[requires] + +python_version = "3.6" \ No newline at end of file diff --git a/portal/__init__.py b/portal/__init__.py index f677dbc03604b00e8d573d204b068afb492997ac..c1b11443a2d76a2948c83d2aee0da1f6daa4bca3 100644 --- a/portal/__init__.py +++ b/portal/__init__.py @@ -1,13 +1,14 @@ from flask import Flask +from flask_jwt_extended import JWTManager + from portal.config import CONFIGURATIONS from flask_sqlalchemy import SQLAlchemy import os from storage import Storage - db = SQLAlchemy() - +jwt = JWTManager() # TODO - urgent storage = Storage({ "submissions_dir": "todo", @@ -32,11 +33,8 @@ def create_app(): config_app(app) # database bind to app db.init_app(app) - + # init the jwt + jwt.init_app(app) return app - -# app = create_app() - import portal.database.models - diff --git a/portal/database/models.py b/portal/database/models.py index 1424d00dc5c931ad8c6b6d1b833e9c557aad3bfd..49729eeebb59ec9cd5cb3b2c416c074556ff5448 100644 --- a/portal/database/models.py +++ b/portal/database/models.py @@ -4,11 +4,14 @@ import datetime from sqlalchemy import event from sqlalchemy.ext.hybrid import hybrid_property +from werkzeug.security import generate_password_hash, check_password_hash from portal import db from portal.database.mixins import EntityBase from portal.database.exceptions import PortalDbError from portal.tools import time + + # uuid as primary key source: # https://stackoverflow.com/questions/36806403/cant-render-element-of-type-class-sqlalchemy-dialects-postgresql-base-uuid @@ -29,12 +32,30 @@ class User(db.Model, EntityBase): username = db.Column(db.String(15), unique=True, nullable=False) name = db.Column(db.String(50)) is_admin = db.Column(db.Boolean, default=False) - password_hash = db.Column(db.String(50), default=None) # optional - after password change + password_hash = db.Column(db.String(60), default=None) # optional - after password change submissions = db.relationship("Submission", back_populates="user", cascade="all, delete-orphan", passive_deletes=True) review_items = db.relationship("ReviewItem", back_populates="user") + def set_password(self, password: str): + """Sets password for the user + + Args: + password(str): Unhashed password + """ + self.password_hash = generate_password_hash(password) + + def verify_password(self, password: str) -> bool: + """ + Args: + password(str): Unhashed password + + Returns(bool): True if the password is valid, False otherwise + + """ + return check_password_hash(self.password_hash, password) + @property def courses(self): result = [] @@ -65,9 +86,12 @@ class Course(db.Model, EntityBase): # Info on cascades: http://docs.sqlalchemy.org/en/latest/orm/cascades.html # Cascades here simulate a composition - roles, groups and projects exist only when associated to a course - roles = db.relationship("Role", back_populates="course", cascade="all, delete-orphan", passive_deletes=True) - groups = db.relationship("Group", back_populates="course", cascade="all, delete-orphan", passive_deletes=True) - projects = db.relationship("Project", back_populates="course", cascade="all, delete-orphan", passive_deletes=True) + roles = db.relationship("Role", back_populates="course", cascade="all, delete-orphan", + passive_deletes=True) + groups = db.relationship("Group", back_populates="course", cascade="all, delete-orphan", + passive_deletes=True) + projects = db.relationship("Project", back_populates="course", cascade="all, delete-orphan", + passive_deletes=True) def __init__(self, name, codename) -> None: self.name = name @@ -96,7 +120,8 @@ class Project(db.Model, EntityBase): course_id = db.Column(db.Integer, db.ForeignKey('course.id'), nullable=False) course = db.relationship("Course", back_populates="projects", uselist=False) - submissions = db.relationship("Submission", back_populates="project", cascade="all, delete-orphan", + submissions = db.relationship("Submission", back_populates="project", + cascade="all, delete-orphan", passive_deletes=True) __table_args__ = ( @@ -113,9 +138,10 @@ class Project(db.Model, EntityBase): def set_config(self, **kwargs): for k, w in kwargs.items(): - if k in ('test_files_source', 'file_whitelist', 'pre_submit_script', 'post_submit_script', - 'submission_parameters', 'submission_scheduler_config', 'submissions_allowed_from', - 'submissions_allowed_to', 'archive_from'): + if k in ( + 'test_files_source', 'file_whitelist', 'pre_submit_script', 'post_submit_script', + 'submission_parameters', 'submission_scheduler_config', 'submissions_allowed_from', + 'submissions_allowed_to', 'archive_from'): setattr(self.config, k, w) def __init__(self, course, name, test_files_source) -> None: @@ -203,7 +229,8 @@ class Role(db.Model, EntityBase): course_id = db.Column(db.Integer, db.ForeignKey('course.id'), nullable=False) course = db.relationship("Course", back_populates="roles", uselist=False) - permissions = db.relationship("RolePermissions", back_populates='role', cascade="all, delete-orphan", + permissions = db.relationship("RolePermissions", back_populates='role', + cascade="all, delete-orphan", passive_deletes=True, lazy='joined', uselist=False) def set_permissions(self, **kwargs): @@ -357,7 +384,8 @@ class ReviewItem(db.Model, EntityBase): review_id = db.Column(db.Integer, db.ForeignKey('review.id'), nullable=False) review = db.relationship("Review", uselist=False, back_populates="review_items") user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - user = db.relationship("User", uselist=False, back_populates="review_items") # the review item's author + user = db.relationship("User", uselist=False, + back_populates="review_items") # the review item's author content = db.Column(db.Text) file = db.Column(db.String(100), nullable=False) line = db.Column(db.Integer, nullable=False) diff --git a/portal/rest/auth/login.py b/portal/rest/auth/login.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e981d0d55da8cc79f8c90cd05ad76edc61072ff4 100644 --- a/portal/rest/auth/login.py +++ b/portal/rest/auth/login.py @@ -0,0 +1,61 @@ +from flask_jwt_extended import create_access_token, create_refresh_token, \ + jwt_refresh_token_required, get_jwt_identity, jwt_required +from flask_restful import Resource, Api +from flask import Blueprint, request, jsonify + +from portal.service.auth import login_user +from portal.service.errors import UnauthorizedError + +auth = Blueprint('courses', __name__, url_prefix='/auth') +auth_api = Api(auth) + + +class Login(Resource): + def post(self): + username = request.json.get('username', None) + password = request.json.get('password', None) + gitlab_access_token = request.json.get('gitlab_access_token', None) + user = login_user(gitlab_access_token, password, username) + + ret = { + 'access_token': create_access_token(identity=user.id), + 'refresh_token': create_refresh_token(identity=user.id) + } + + return jsonify(ret), 200 + + +class Refresh(Resource): + @jwt_refresh_token_required + def post(self): + current_user = get_jwt_identity() + if not current_user: + raise UnauthorizedError() + + ret = { + 'access_token': create_access_token(identity=current_user) + } + + return jsonify(ret), 200 + + +class Logout(Resource): + @jwt_required + def post(self): + # TODO + current_user = get_jwt_identity() + + if not current_user: + raise UnauthorizedError() + + ret = { + 'access_token': None, + 'refresh_token': None + } + + return jsonify(ret), 200 + + +auth_api.add_resource(Login, '/login') +auth_api.add_resource(Refresh, '/refresh') +auth_api.add_resource(Logout, '/logout') diff --git a/portal/rest/courses/courses.py b/portal/rest/courses/courses.py index 70e3741586fd89aff19a203db2154f1586f32847..979eab41c8185c2cf77d67560dc3de968a54c371 100644 --- a/portal/rest/courses/courses.py +++ b/portal/rest/courses/courses.py @@ -22,16 +22,12 @@ class CourseResource(Resource): def get(self, cid): log.info(f"GET to {request.url}") course = service.find_course(cid) - if not course: - abort(404, message=f"Could not find course {cid}.") return course_schema.dump(course) @error_handler def delete(self, cid): log.info(f"DELETE to {request.url}") course = service.find_course(cid) - if not course: - abort(404, message=f"Could not find course {cid}.") service.delete_entity(course) return '', 204 @@ -39,7 +35,6 @@ class CourseResource(Resource): def put(self, cid): log.info(f"PUT to {request.url}") course = service.find_course(cid) - json_data = request.get_json() if not json_data: abort(400, message='No data provided for course update.') diff --git a/portal/service/auth.py b/portal/service/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..a57cc398419a6084201002ef3654a3bb39f6e111 --- /dev/null +++ b/portal/service/auth.py @@ -0,0 +1,36 @@ +from portal.service.errors import IncorrectPasswordError +from portal.service.service import find_user + + +def login_user(gitlab_access_token=None, password=None, username=None): + if gitlab_access_token: + return auth_gitlab_access_token( + username=username, + gitlab_access_token=gitlab_access_token + ) + + return auth_username_password(username=username, password=password) + + +def auth_gitlab_access_token(username, gitlab_access_token): + """Authentication using gitlab token + + Steps: + Send token to gitlab + Verify that token is for user + + Args: + gitlab_access_token: + + Returns: + + """ + + +def auth_username_password(username, password): + user = find_user(username) + + if user.verify_password(password=password): + return user + + raise IncorrectPasswordError() diff --git a/portal/service/errors.py b/portal/service/errors.py index e813436149d07a8e814da22217659bbcd7b5c9f7..b0170110de0a37ceca9a7bbaa7d8608d54ef7570 100644 --- a/portal/service/errors.py +++ b/portal/service/errors.py @@ -61,3 +61,10 @@ class ForbiddenError(PortalAPIError): message['note'] = note super().__init__(code=401, message=message) + + +class IncorrectPasswordError(UnauthorizedError): + def __init__(self): + super().__init__(note="Password is incorrect") + + diff --git a/sample_data/data_init.py b/sample_data/data_init.py index 89aca8c6b8e642c2a9322ef387addcdca3b6446b..cc407509c7e010d47c17712619fb6978c010400f 100644 --- a/sample_data/data_init.py +++ b/sample_data/data_init.py @@ -1,4 +1,4 @@ -from portal.database.models import User, Course, Project, Group, Role, Submission, Review +from portal.database.models import User, Course, Project, Group, Role, Submission def init_data(app, db): @@ -13,8 +13,10 @@ def init_data(app, db): def create_users(db): - db.session.add(User(uco=445, email="foo@foo.cz", username="xfoo")) - db.session.add(User(uco=123, email="bar@bar.com", username="xbar")) + foo_user = User(uco=445, email="foo@bar.cz", username="xfoo") + foo_user.set_password('123456') + db.session.add(foo_user) + db.session.add(User(uco=123, email="bar@baz.cz", username="xbar")) db.session.commit() diff --git a/tests/rest/test_user_rest.py b/tests/rest/test_user_rest.py index e78d0b1973c8d4c4ffe914f42e0f93b4ccc984c9..fb2041a65a276548045b71e81e6ffb869d26712e 100644 --- a/tests/rest/test_user_rest.py +++ b/tests/rest/test_user_rest.py @@ -39,8 +39,6 @@ import json - reviews associated with the user's submissions: accessible through the user's submissions ''' -user_schema = UserSchema() -users_schema = UserSchema(many=True, only=('id', 'uco', 'username', 'email')) def assert_user(expected: User, actual: dict): @@ -88,7 +86,6 @@ def test_create(client): assert_user_in(db_users, resp_new_user) -#follow_redirects=True def test_list(client): response = client.get('/users/') assert response.status_code == 200 @@ -107,7 +104,6 @@ def test_read(client): assert response.mimetype == 'application/json' data = json.loads(str(response.get_data().decode("utf-8"))) - print(f"-----------{data}--------------------") assert_user(user, data)