diff --git a/.drone.yml b/.drone.yml index d7cc3d7..de6198c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -7,7 +7,7 @@ steps: commands: - pip install pylint - pip install -r requirements.txt - - pylint --rcfile .pylintrc project_amber project_amber/handlers project_amber/helpers project_amber/models + - pylint --rcfile .pylintrc project_amber project_amber/handlers project_amber/controllers project_amber/models when: event: - push diff --git a/Pipfile b/Pipfile index 000ff7c..ef1e740 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,3 @@ bcrypt = "==3.1.7" flask-sqlalchemy = "==2.4.1" flask-cors = "==3.0.8" flask = "==1.1.1" - -[requires] -python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index 82ed1cf..830a06f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,12 +1,10 @@ { "_meta": { "hash": { - "sha256": "6afeff85b520bb1e0849705c134bfc40ae10652bb422686f68ce626a237dea08" + "sha256": "87b5a4dcc32cf082b278aca72f227432025b8a8376983c5908f34b0aefb75fc2" }, "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, + "requires": {}, "sources": [ { "name": "pypi", @@ -158,7 +156,8 @@ }, "pycparser": { "hashes": [ - "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3" + "sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3", + "sha256:f5866dd330a1c288cbe63d5fea81b705cd7c75e872deac07f5e431c18b22f986" ], "version": "==2.19" }, @@ -265,6 +264,7 @@ }, "wrapt": { "hashes": [ + "sha256:3aa1f660d417c84d7e642f85000cf5bc5403550f64aadb198841018da076627a", "sha256:565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1" ], "version": "==1.11.2" diff --git a/project_amber/app.py b/project_amber/app.py index d7068a8..19a0e49 100644 --- a/project_amber/app.py +++ b/project_amber/app.py @@ -1,12 +1,11 @@ from json import dumps -from flask import Flask, request +from flask import Flask from flask_cors import CORS from project_amber.config import config from project_amber.db import db from project_amber.errors import HTTPError -from project_amber.helpers import handleLogin, middleware as check_request from project_amber.handlers.const import API_V0 from project_amber.handlers.auth import auth_handlers as auth from project_amber.handlers.session import session_handlers as session @@ -19,13 +18,6 @@ db.init_app(app) CORS(app, resources={r"/*": {"origins": config.domain}}) - -@app.before_request -def middleware(): - if check_request().authenticated: - request.user = handleLogin() - - for blueprint in (auth, session, misc, task, user): app.register_blueprint(blueprint, url_prefix=API_V0) diff --git a/project_amber/const.py b/project_amber/const.py index 2847704..9d2b5bc 100644 --- a/project_amber/const.py +++ b/project_amber/const.py @@ -2,13 +2,17 @@ EMPTY_RESP = dumps({}) # Empty response, to be used in requests. +AUTH_TOKEN_HEADER = "Authorization" +AUTH_TOKEN_SCHEME = "Bearer" + DAY_SECONDS = 60 * 60 * 24 MATURE_SESSION = DAY_SECONDS * 2 # The difference in times between the login # time and the time when a session is considered "mature" (e.g can remove other # sessions). +MSG_INVALID_JSON = "Payload needs to contain valid JSON" MSG_MISSING_AUTH_INFO = "Missing 'username' or 'password'" -MSG_NO_TOKEN = "No X-Auth-Token present" +MSG_NO_TOKEN = f"No {AUTH_TOKEN_HEADER} header present" MSG_INVALID_TOKEN = "Invalid token" MSG_USER_NOT_FOUND = "This user does not exist" MSG_USER_EXISTS = "The user with this name already exists" @@ -19,7 +23,4 @@ MSG_TEXT_NOT_SPECIFIED = "No text specified" MSG_TASK_DANGEROUS = "Potentially dangerous operation" -# A regex matching all paths that can be accessed without an auth token. -PUBLIC_PATHS = r"/v\d/(login|signup|version)" - -VERSION = "0.0.3" +VERSION = "0.0.4" diff --git a/project_amber/controllers/auth.py b/project_amber/controllers/auth.py new file mode 100644 index 0000000..c3db5c6 --- /dev/null +++ b/project_amber/controllers/auth.py @@ -0,0 +1,148 @@ +from hashlib import sha256 +from base64 import b64encode +from typing import List + +from bcrypt import hashpw, gensalt, checkpw + +from project_amber.const import MSG_USER_EXISTS +from project_amber.db import db +from project_amber.helpers import time +from project_amber.handlers import LoginUser +from project_amber.handlers.const import API_PASSWORD +from project_amber.errors import Unauthorized, NotFound, Conflict +from project_amber.logging import error +from project_amber.models.auth import User, Session + + +def prehash(password: str) -> bytes: + """ + Returns a "normalized" representation of the password that works + with bcrypt even when the password is longer than 72 chars. + """ + return b64encode(sha256(password.encode()).digest()) + + +def gen_hashed_pw(password: str) -> bytes: + """ + Returns a bcrypt password hash with random salt. + """ + return hashpw(prehash(password), gensalt()).decode() + + +def gen_token() -> str: + """ + Returns a new freshly generated auth token. + """ + return sha256(gensalt() + bytes(str(time()).encode())).hexdigest() + + +class UserController: + user: LoginUser = None + + def __init__(self, user: LoginUser): + self.user = user + + def add_user(self, name: str, password: str) -> int: + """ + Creates a new user. Returns their ID on success. + """ + # does a user with this name already exist? + if not db.session.query(User).filter_by(name=name).one_or_none() is None: + raise Conflict(MSG_USER_EXISTS) + hashed_pw = gen_hashed_pw(password) + user = User(name=name, password=hashed_pw) + db.session.add(user) + db.session.commit() + return user.id + + def update_user(self, **kwargs) -> int: + """ + Updates user data in the database. Returns their ID on success. + """ + user_record = db.session.query(User).filter_by(id=self.user.id).one() + for attribute in kwargs: + if attribute == API_PASSWORD: + user_record.password = gen_hashed_pw(kwargs[API_PASSWORD]) + db.session.commit() + return self.user.id + + def remove_user(self) -> int: + """ + Removes a user from the database. Returns their ID. + """ + user = db.session.query(User).filter_by(id=self.user.id).one_or_none() + try: + db.session.delete(user) + db.session.commit() + # pylint: disable=bare-except + except: + error("Failed to remove user %s!" % user.name) + return self.user.id + + def verify_pw(self, uid: int, password: str) -> bool: + """ + Verifies user's password with bcrypt's checkpw(). Returns `True`, if + the passwords match, and False otherwise. + """ + user = db.session.query(User).filter_by(id=uid).one() + user_pass = user.password + if isinstance(user_pass, str): + user_pass = user_pass.encode() + return checkpw(prehash(password), user_pass) + + def create_session(self, name: str, password: str, ip_addr: str) -> str: + """ + Creates a new user session. Returns an auth token. + """ + user = db.session.query(User).filter_by(name=name).one_or_none() + token: str + if user is None: + raise Unauthorized + if self.verify_pw(user.id, password): + token = gen_token() + session = Session(token=token, user=user.id, login_time=time(), address=ip_addr) + db.session.add(session) + db.session.commit() + else: + raise Unauthorized + return token + + def remove_session(self) -> str: + """ + Logs the user out by removing their token from the database. Returns + the token on success. + """ + session = db.session.query(Session).filter_by(token=self.user.token, + user=self.user.id).one_or_none() + if session is None: + raise NotFound + db.session.delete(session) + db.session.commit() + return self.user.token + + def remove_session_by_id(self, sid: int) -> int: + """ + Removes a user session by session ID. Returns the session ID on success. + """ + session = db.session.query(Session).filter_by(id=sid, user=self.user.id).one_or_none() + if session is None: + raise NotFound + db.session.delete(session) + db.session.commit() + return sid + + def get_sessions(self) -> List[Session]: + """ + Returns a list of sessions of a user (class `Session`). + """ + sessions = db.session.query(Session).filter_by(user=self.user.id).all() + return sessions + + def get_session(self, sid: int) -> Session: + """ + Returns a single `Session` by its ID. + """ + session = db.session.query(Session).filter_by(id=sid, user=self.user.id).one_or_none() + if session is None: + raise NotFound + return session diff --git a/project_amber/controllers/task.py b/project_amber/controllers/task.py new file mode 100644 index 0000000..d577a7f --- /dev/null +++ b/project_amber/controllers/task.py @@ -0,0 +1,110 @@ +from typing import List + +from project_amber.const import MSG_TASK_NOT_FOUND, MSG_TASK_DANGEROUS, \ + MSG_TEXT_NOT_SPECIFIED +from project_amber.db import db +from project_amber.errors import NotFound, BadRequest +from project_amber.handlers import LoginUser +from project_amber.helpers import time +from project_amber.models.task import Task + + +class TaskController: + user: LoginUser = None + + def __init__(self, user: LoginUser): + self.user = user + + def add_task(self, data: dict) -> int: + """ + Creates a new task. Returns its ID. + """ + task = Task(self.user.id, data) + if task.text is None: raise BadRequest(MSG_TEXT_NOT_SPECIFIED) + if task.status is None: task.status = 0 + parent_id = task.parent_id + if parent_id: + parent = db.session.query(Task).filter_by(id=parent_id, + owner=self.user.id).one_or_none() + if parent is None: + raise NotFound(MSG_TASK_NOT_FOUND) + task.add() + db.session.commit() + self.update_children(task.id) + # TODO: can we remove the second commit here? + db.session.commit() + return task.id + + def get_task(self, task_id: int) -> Task: + """ + Returns an instance of `Task`, given the ID. + """ + task = db.session.query(Task).filter_by(id=task_id, owner=self.user.id).one_or_none() + if task is None: + raise NotFound(MSG_TASK_NOT_FOUND) + return task + + def get_tasks(self, text: str = None) -> List[Task]: + """ + Returns a list containing tasks from a certain user. If the second + parameter is specified, this will return the tasks that have this text + in their description (`text in Task.text`). + """ + req = db.session.query(Task).filter_by(owner=self.user.id) + if text is None: + return req.all() + return req.filter(Task.text.ilike("%{0}%".format(text))).all() + + def update_children(self, task_id: int): + """ + Recursively updates children lists for the children nodes of + a task subtree. + """ + task = self.get_task(task_id) + if task.parent_id: + parent = self.get_task(task.parent_id) + parent_list = parent.getParents() + parent_list.append(parent.id) + task.setParents(parent_list) + else: + task.setParents(list()) + children = db.session.query(Task).filter_by(parent_id=task_id).all() + for child in children: + self.update_children(child.id) + + def update_task(self, task_id: int, data: dict) -> int: + """ + Updates the task details. Returns its ID. + """ + task = self.get_task(task_id) + new_details = Task(self.user.id, data) + task.merge(new_details) + if not new_details.parent_id is None: + if new_details.parent_id == 0: + # promote task to the top level + task.parent_id = None + self.update_children(task.id) + else: + new_parent = self.get_task(new_details.parent_id) + if task.id in new_parent.getParents() or task.id == new_parent.id: + raise BadRequest(MSG_TASK_DANGEROUS) + task.parent_id = new_parent.id + self.update_children(task.id) + task.last_mod_time = time() + db.session.commit() + return task_id + + def remove_task(self, task_id: int) -> List[int]: + """ + Removes a task, recursively removing its subtasks. Returns the list of + removed task IDs. + """ + removed = list() + children = db.session.query(Task).filter_by(parent_id=task_id).all() + for child in children: + removed.extend(self.remove_task(child.id)) + task = self.get_task(task_id) + task.delete() + db.session.commit() + removed.append(task.id) + return removed diff --git a/project_amber/errors.py b/project_amber/errors.py index 740eebf..7c97127 100644 --- a/project_amber/errors.py +++ b/project_amber/errors.py @@ -31,7 +31,7 @@ def __init__(self, message="Bad request payload"): class InternalServerError(HTTPError): """ - Exception class for DB errors. Probably going to be left unused. + Exception class for DB-level errors. """ code = HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/project_amber/handlers/__init__.py b/project_amber/handlers/__init__.py new file mode 100644 index 0000000..358433d --- /dev/null +++ b/project_amber/handlers/__init__.py @@ -0,0 +1,68 @@ +from functools import wraps +from re import fullmatch + +from flask import request + +from project_amber.db import db +from project_amber.const import MSG_NO_TOKEN, MSG_INVALID_TOKEN, MSG_USER_NOT_FOUND, \ + MSG_USER_EXISTS, MSG_INVALID_JSON, AUTH_TOKEN_HEADER, AUTH_TOKEN_SCHEME +from project_amber.errors import Unauthorized, BadRequest, InternalServerError +from project_amber.models.auth import User, Session + + +class LoginUser: + """ + Representational class for request checks. Contains the user name + and ID. The corresponding fields are `name` and `id`, respectively. + Also contains a token field. + """ + def __init__(self, name: str, uid: int, token: str, login_time: int, remote_addr: str): + self.name = name + self.id = uid + self.token = token + self.login_time = login_time + self.remote_addr = remote_addr + + +def accepts_json(f): + """ + Checks whether the request payload contains valid JSON, drops errors + on need. + """ + @wraps(f) + def decorated_json_checker(*args, **kwargs): + if not request.is_json and request.method in ("POST", "PUT", "PATCH"): + raise BadRequest(MSG_INVALID_JSON) + return f(*args, **kwargs) + + return decorated_json_checker + + +def login_required(f): + """ + Login handler. Works with Flask's `request`. Checks the auth token HTTP + header. Sets `request.user` object containing the user's name and their ID. + Raises an exception if the auth token is not valid. + """ + @wraps(f) + def decorated_login_function(*args, **kwargs): + token_header = request.headers.get(AUTH_TOKEN_HEADER) + if token_header is None: + raise Unauthorized(MSG_NO_TOKEN) + token_data = token_header.split(" ") + if len(token_data) < 2: + raise Unauthorized(MSG_INVALID_TOKEN) + if token_data[0] != AUTH_TOKEN_SCHEME: + raise Unauthorized(MSG_INVALID_TOKEN) + token = token_data[1] + user_s = db.session.query(Session).filter_by(token=token).one_or_none() + if user_s is None: + raise Unauthorized(MSG_INVALID_TOKEN) + user = db.session.query(User).filter_by(id=user_s.user).one_or_none() + if user is None: + raise InternalServerError(MSG_USER_NOT_FOUND) + user_details = LoginUser(user.name, user.id, token, user_s.login_time, request.remote_addr) + request.user = user_details + return f(*args, **kwargs) + + return decorated_login_function diff --git a/project_amber/handlers/auth.py b/project_amber/handlers/auth.py index faa1185..77c557f 100644 --- a/project_amber/handlers/auth.py +++ b/project_amber/handlers/auth.py @@ -4,14 +4,16 @@ from project_amber.const import EMPTY_RESP, MSG_MISSING_AUTH_INFO from project_amber.errors import BadRequest +from project_amber.handlers import login_required, accepts_json from project_amber.handlers.const import API_PASSWORD, API_USER, API_TOKEN -from project_amber.helpers.auth import removeSession, createSession +from project_amber.controllers.auth import UserController from project_amber.logging import log auth_handlers = Blueprint("auth_handlers", __name__) @auth_handlers.route("/login", methods=["POST"]) +@accepts_json def login(): """ Login handler. Accepts this JSON: @@ -29,17 +31,23 @@ def login(): ``` Drops HTTP 401 on fail. """ - if not API_USER in request.json or not API_PASSWORD in request.json: + username = request.json.get(API_USER) + password = request.json.get(API_PASSWORD) + if not username or not password: raise BadRequest(MSG_MISSING_AUTH_INFO) - token = createSession(request.json[API_USER], request.json[API_PASSWORD]) + uc = UserController(None) + token = uc.create_session(username, password, request.remote_addr) + log(f"User {username} logged in from {request.remote_addr}") return dumps({API_TOKEN: token}) @auth_handlers.route("/logout", methods=["POST"]) +@login_required def logout(): """ - Logout handler. Accepts empty JSON. Returns HTTP 200 on success. + Logout handler. Returns HTTP 200 on success. """ - removeSession(request.user.token) - log("User %s logged out" % request.user.name) + uc = UserController(request.user) + uc.remove_session() + log(f"User {request.user.name} logged out") return EMPTY_RESP diff --git a/project_amber/handlers/session.py b/project_amber/handlers/session.py index 2bf46f2..3262043 100644 --- a/project_amber/handlers/session.py +++ b/project_amber/handlers/session.py @@ -4,15 +4,16 @@ from project_amber.const import MATURE_SESSION, MSG_IMMATURE_SESSION, EMPTY_RESP from project_amber.errors import Forbidden -from project_amber.handlers.const import API_ID, API_LOGIN_TIME, API_ADDRESS +from project_amber.handlers import login_required from project_amber.helpers import time -from project_amber.helpers.auth import getSessions, getSession, removeSessionById +from project_amber.controllers.auth import UserController from project_amber.logging import log session_handlers = Blueprint("session_handlers", __name__) @session_handlers.route("/session", methods=["GET"]) +@login_required def get_sessions(): """ Request handler for `/api/session`. Only accepts GET requests. Returns a @@ -32,18 +33,16 @@ def get_sessions(): ] ``` """ - sessions = getSessions() - sessionList = [] + uc = UserController(request.user) + sessions = uc.get_sessions() + sessionList = list() for session in sessions: - sessionList.append({ - API_ID: session.id, - API_LOGIN_TIME: session.login_time, - API_ADDRESS: session.address - }) + sessionList.append(session.to_json()) return dumps(sessionList) @session_handlers.route("/session/", methods=["GET", "DELETE"]) +@login_required def session_by_id(session_id: int): """ Login handler for `/api/session/`. Accepts GET and DELETE @@ -60,20 +59,13 @@ def session_by_id(session_id: int): case here: if a client session is too recent, this will respond with HTTP 403. """ + uc = UserController(request.user) if request.method == "GET": - session = getSession(session_id) - return dumps({ - API_ID: session.id, - API_LOGIN_TIME: session.login_time, - API_ADDRESS: session.address - }) + session = uc.get_session(session_id) + return dumps(session.to_json()) if request.method == "DELETE": - if (time() - request.user.login_time) < MATURE_SESSION: + if (time() - uc.user.login_time) < MATURE_SESSION: raise Forbidden(MSG_IMMATURE_SESSION) - removeSessionById(session_id) - log( - "User {0} deleted session {1}".format( - request.user.name, session_id - ) - ) + uc.remove_session_by_id(session_id) + log(f"User {uc.user.name} deleted session {session_id}") return EMPTY_RESP diff --git a/project_amber/handlers/task.py b/project_amber/handlers/task.py index 71326a0..488c182 100644 --- a/project_amber/handlers/task.py +++ b/project_amber/handlers/task.py @@ -3,14 +3,16 @@ from flask import request, Blueprint from project_amber.const import EMPTY_RESP +from project_amber.handlers import login_required, accepts_json from project_amber.handlers.const import API_QUERY -from project_amber.helpers.task import addTask, getTask, getTasks, \ - updateTask, removeTask +from project_amber.controllers.task import TaskController task_handlers = Blueprint("task_handlers", __name__) @task_handlers.route("/task", methods=["GET", "POST"]) +@accepts_json +@login_required def task_request(): """ Handles requests to `/api/task`. Accepts GET and POST. @@ -46,21 +48,24 @@ def task_request(): ``` with a task ID (a literal integer value like `35213`). """ + tc = TaskController(request.user) if request.method == "GET": query = request.args.get(API_QUERY, None) # `query` is OK to be `None` - tasks = getTasks(query) - tasksList = [] + tasks = tc.get_tasks(query) + tasksList = list() for task in tasks: tasksList.append(task.toDict()) return dumps(tasksList) if request.method == "POST": - new_id = addTask(request.json) + new_id = tc.add_task(request.json) return dumps(new_id) return EMPTY_RESP @task_handlers.route("/task/", methods=["GET", "PATCH", "DELETE"]) +@accepts_json +@login_required def task_id_request(task_id: int): """ Handles requests to `/api/task/`. Accepts GET, PATCH, and DELETE. @@ -89,12 +94,13 @@ def task_id_request(task_id: int): } ``` """ + tc = TaskController(request.user) if request.method == "GET": - task = getTask(task_id) + task = tc.get_task(task_id) response = task.toDict() return dumps(response) if request.method == "PATCH": - updateTask(task_id, request.json) + tc.update_task(task_id, request.json) if request.method == "DELETE": - return dumps(removeTask(task_id)) + return dumps(tc.remove_task(task_id)) return EMPTY_RESP diff --git a/project_amber/handlers/users.py b/project_amber/handlers/users.py index ee57eda..9779a3c 100644 --- a/project_amber/handlers/users.py +++ b/project_amber/handlers/users.py @@ -3,13 +3,17 @@ from project_amber.config import config from project_amber.const import EMPTY_RESP, MSG_MISSING_AUTH_INFO, MSG_SIGNUP_FORBIDDEN from project_amber.errors import BadRequest, Forbidden +from project_amber.handlers import accepts_json, login_required from project_amber.handlers.const import API_PASSWORD, API_USER -from project_amber.helpers.auth import addUser, updateUser +from project_amber.controllers.auth import UserController +from project_amber.logging import log user_handlers = Blueprint("user_handlers", __name__) @user_handlers.route("/user", methods=["PATCH"]) +@accepts_json +@login_required def user_data(): """ User data PATCH request handler. Accepts JSON with these parameters: @@ -20,12 +24,14 @@ def user_data(): ``` Returns HTTP 200 on success. """ - if API_PASSWORD in request.json: - updateUser(password=request.json.get(API_PASSWORD)) + uc = UserController(request.user) + uc.update_user(**request.json) + log(f"User {uc.user.name} updated their data") return EMPTY_RESP @user_handlers.route("/signup", methods=["POST"]) +@accepts_json def signup(): """ Signup request handler. Accepts this JSON: @@ -40,7 +46,11 @@ def signup(): """ if not config.allow_signup: raise Forbidden(MSG_SIGNUP_FORBIDDEN) - if not API_USER in request.json or not API_PASSWORD in request.json: + username = request.json.get(API_USER) + password = request.json.get(API_PASSWORD) + if not username or not password: raise BadRequest(MSG_MISSING_AUTH_INFO) - addUser(request.json[API_USER], request.json[API_PASSWORD]) + uc = UserController(None) + uc.add_user(username, password) + log(f"User {username} signed up") return EMPTY_RESP diff --git a/project_amber/helpers.py b/project_amber/helpers.py new file mode 100644 index 0000000..53694ee --- /dev/null +++ b/project_amber/helpers.py @@ -0,0 +1,9 @@ +from time import time as time_lib + + +def time() -> int: + """ + Wrapper around `time.time()`. Converts the result to `int` to prevent + getting fractions of seconds on some platforms. + """ + return int(time_lib()) diff --git a/project_amber/helpers/__init__.py b/project_amber/helpers/__init__.py deleted file mode 100644 index 37cd1ed..0000000 --- a/project_amber/helpers/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -from time import time as time_lib -from functools import wraps -from re import fullmatch - -from flask import request - -from project_amber.db import db -from project_amber.const import MSG_NO_TOKEN, MSG_INVALID_TOKEN, \ - MSG_USER_NOT_FOUND, MSG_USER_EXISTS, PUBLIC_PATHS -from project_amber.errors import Unauthorized, BadRequest, NotFound, \ - InternalServerError, Conflict -from project_amber.models.auth import User, Session - - -class LoginUser: - """ - Representational class for request checks. Contains the user name - and ID. The corresponding fields are `name` and `id`, respectively. - Also contains a token field. - """ - def __init__(self, name: str, uid: int, token: str, login_time: int): - self.name = name - self.id = uid - self.token = token - self.login_time = login_time - - -class RequestParams: - """ - Representational class for request parameters. - """ - def __init__(self): - self.authenticated = False - - -def middleware() -> RequestParams: - """ - Simple middleware. Checks for invalid request payloads, drops errors - on need, etc. - Returns `True` if a request needs to be authenticated, `False` otherwise. - """ - if not request.is_json and request.method in ["POST", "PUT", "PATCH"]: - raise BadRequest - params = RequestParams() - if not fullmatch(PUBLIC_PATHS, request.path) \ - and request.method != "OPTIONS": - params.authenticated = True - return params - - -def handleLogin() -> LoginUser: - """ - Login handler. Works with Flask's `request`. Returns an object - containing the user's name and their ID. Raises an exception if - the auth token is not valid. - """ - token = request.headers.get("X-Auth-Token") - if token is None: - raise Unauthorized(MSG_NO_TOKEN) - user_session = db.session.query(Session).filter_by(token=token - ).one_or_none() - if user_session is None: - raise Unauthorized(MSG_INVALID_TOKEN) - user = db.session.query(User).filter_by(id=user_session.user).one_or_none() - if user is None: - raise InternalServerError(MSG_USER_NOT_FOUND) - user_details = LoginUser(user.name, user.id, token, user_session.login_time) - return user_details - - -def time() -> int: - """ - Wrapper around `time.time()`. Converts the result to `int` to prevent - getting fractions of seconds on some platforms. - """ - return int(time_lib()) diff --git a/project_amber/helpers/auth.py b/project_amber/helpers/auth.py deleted file mode 100644 index 080c985..0000000 --- a/project_amber/helpers/auth.py +++ /dev/null @@ -1,151 +0,0 @@ -from hashlib import sha256 -from base64 import b64encode - -from bcrypt import hashpw, gensalt, checkpw -from flask import request - -from project_amber.const import MSG_USER_NOT_FOUND, MSG_USER_EXISTS, \ - MSG_MISSING_AUTH_INFO -from project_amber.db import db -from project_amber.helpers import time, LoginUser -from project_amber.errors import Unauthorized, NotFound, Conflict, BadRequest -from project_amber.logging import log -from project_amber.models.auth import User, Session - - -def prehash(password: str) -> bytes: - """ - Returns a "normalized" representation of the password that works - with bcrypt even when the password is longer than 72 chars. - """ - return b64encode(sha256(password.encode()).digest()) - - -def addUser(name: str, password: str) -> int: - """ - Creates a new user. Returns their ID on success. - """ - if not name or not password: - raise BadRequest(MSG_MISSING_AUTH_INFO) - # does a user with this name already exist? - if not db.session.query(User).filter_by(name=name).one_or_none() is None: - raise Conflict(MSG_USER_EXISTS) - prehashed_pw = prehash(password) - hashed_pw = hashpw(prehashed_pw, gensalt()).decode() - user = User(name=name, password=hashed_pw) - log("Adding user %s..." % name) - db.session.add(user) - db.session.commit() - return user.id - - -def updateUser(**kwargs) -> int: - """ - Updates user data in the database. Returns their ID on success. - """ - user: LoginUser = request.user - user_record = db.session.query(User).filter_by(id=user.id).one() - for attribute in kwargs: - if attribute == "password": - user_record.password = hashpw( - prehash(kwargs["password"]), gensalt() - ).decode() - db.session.commit() - return user.id - - -def removeUser(uid: int) -> int: - """ - Removes a user given their ID. Returns their ID on success. - """ - user = db.session.query(User).filter_by(id=uid).one_or_none() - if user is None: - raise NotFound(MSG_USER_NOT_FOUND) - log("Removing user %s..." % user.name) - db.session.delete(user) - db.session.commit() - return uid - - -def verifyPassword(uid: int, password: str) -> bool: - """ - Verifies user's password with bcrypt's checkpw(). Returns `True`, if - the passwords match, and False otherwise. - """ - user = db.session.query(User).filter_by(id=uid).one() - user_pass = user.password - if isinstance(user_pass, str): - user_pass = user_pass.encode() - prehashed_pw = prehash(password) - return checkpw(prehashed_pw, user_pass) - - -def createSession(name: str, password: str) -> str: - """ - Creates a new user session. Returns an auth token. - """ - user = db.session.query(User).filter_by(name=name).one_or_none() - if user is None: - raise Unauthorized # this may present no sense, but the app doesn't - # have to reveal the presence or absence of a user in the system - if verifyPassword(user.id, password): - token = sha256(gensalt() + bytes(str(time()).encode())).hexdigest() - session = Session(token=token, user=user.id, login_time=time(), \ - address=request.remote_addr) - log( - "User {0} logged in from {1}".format( - user.name, request.remote_addr - ) - ) - db.session.add(session) - db.session.commit() - return token - raise Unauthorized - - -def removeSession(token: str) -> str: - """ - Removes a user session by token. Returns the token on success. - """ - session = db.session.query(Session - ).filter_by(token=token, - user=request.user.id).one_or_none() - if session is None: - raise NotFound - db.session.delete(session) - db.session.commit() - return token - - -def removeSessionById(session_id: int) -> int: - """ - Removes a user session by session ID. Returns the session ID on success. - """ - session = db.session.query(Session - ).filter_by(id=session_id, - user=request.user.id).one_or_none() - if session is None: - raise NotFound - db.session.delete(session) - db.session.commit() - return session_id - - -def getSessions() -> list: - """ - Returns a list of sessions of a user (class `Session`). - """ - sessions = db.session.query(Session).filter_by(user=request.user.id).all() - return sessions - - -def getSession(session_id: int) -> Session: - """ - Returns a single `Session` by its ID. - """ - session = db.session.query(Session - ).filter_by(id=session_id, - user=request.user.id).one_or_none() - if session is None: - raise NotFound - return session diff --git a/project_amber/helpers/task.py b/project_amber/helpers/task.py deleted file mode 100644 index b6047ad..0000000 --- a/project_amber/helpers/task.py +++ /dev/null @@ -1,111 +0,0 @@ -from typing import List - -from flask import request - -from project_amber.const import MSG_TASK_NOT_FOUND, MSG_TASK_DANGEROUS, \ - MSG_TEXT_NOT_SPECIFIED -from project_amber.db import db -from project_amber.errors import NotFound, BadRequest -from project_amber.helpers import time -from project_amber.models.task import Task - - -def addTask(data: dict) -> int: - """ - Creates a new task. Returns its ID. - """ - task = Task(request.user.id, data) - if task.text is None: raise BadRequest(MSG_TEXT_NOT_SPECIFIED) - if task.status is None: task.status = 0 - parent_id = task.parent_id - if parent_id: - parent = db.session.query(Task)\ - .filter_by(id=parent_id, owner=request.user.id).one_or_none() - if parent is None: - raise NotFound(MSG_TASK_NOT_FOUND) - task.add() - db.session.commit() - updateChildren(task.id) - # TODO: can we remove the second commit here? - db.session.commit() - return task.id - - -def getTask(task_id: int) -> Task: - """ - Returns an instance of `Task`, given the ID. Only returns tasks to - their owner. - """ - task = db.session.query(Task).filter_by(id=task_id, - owner=request.user.id).one_or_none() - if task is None: raise NotFound(MSG_TASK_NOT_FOUND) - return task - - -def getTasks(text: str = None) -> List[Task]: - """ - Returns a list containing tasks from a certain user. If the second - parameter is specified, this will return the tasks that have this text in - their description (`text in Task.text`). - """ - req = db.session.query(Task).filter_by(owner=request.user.id) - if text is None: - return req.all() - return req.filter(Task.text.ilike("%{0}%".format(text))).all() - - -def updateChildren(task_id: int): - """ - Updates children lists for the children nodes of a task subtree. This is - an expensive recursive operation. - """ - task = getTask(task_id) - if task.parent_id: - parent = getTask(task.parent_id) - parent_list = parent.getParents() - parent_list.append(parent.id) - task.setParents(parent_list) - else: - task.setParents(list()) - children = db.session.query(Task).filter_by(parent_id=task_id).all() - for child in children: - updateChildren(child.id) - - -def updateTask(task_id: int, data: dict) -> int: - """ - Updates the task details. Returns its ID. - """ - task = getTask(task_id) - new_details = Task(request.user.id, data) - task.merge(new_details) - if not new_details.parent_id is None: - if new_details.parent_id == 0: - # promote task to the top level - task.parent_id = None - updateChildren(task.id) - else: - new_parent = getTask(new_details.parent_id) - if task.id in new_parent.getParents() or task.id == new_parent.id: - raise BadRequest(MSG_TASK_DANGEROUS) - task.parent_id = new_parent.id - updateChildren(task.id) - task.last_mod_time = time() - db.session.commit() - return task_id - - -def removeTask(task_id: int) -> List[int]: - """ - Removes a task, recursively removing its subtasks. Returns the list of - removed task IDs. - """ - removed = list() - children = db.session.query(Task).filter_by(parent_id=task_id).all() - for child in children: - removed.extend(removeTask(child.id)) - task = getTask(task_id) - task.delete() - db.session.commit() - removed.append(task.id) - return removed diff --git a/project_amber/models/auth.py b/project_amber/models/auth.py index 825d811..d84236b 100644 --- a/project_amber/models/auth.py +++ b/project_amber/models/auth.py @@ -1,4 +1,5 @@ from project_amber.db import db +from project_amber.handlers.const import API_ID, API_LOGIN_TIME, API_ADDRESS class User(db.Model): @@ -27,3 +28,9 @@ class Session(db.Model): def __repr__(self): return "" % \ self.token, self.user, self.login_time, self.address + + def to_json(self) -> dict: + """ + Returns a dictionary containing a JSON representation of the session. + """ + return {API_ID: self.id, API_LOGIN_TIME: self.login_time, API_ADDRESS: self.address} diff --git a/project_amber/models/task.py b/project_amber/models/task.py index e16659f..b3fdcb2 100644 --- a/project_amber/models/task.py +++ b/project_amber/models/task.py @@ -19,10 +19,10 @@ class Task(db.Model): text = db.Column(db.String(65536)) parent_id = db.Column(db.Integer, db.ForeignKey("task.id")) status = db.Column(db.Integer, nullable=False) - creation_time = db.Column(db.Integer, nullable=False) - last_mod_time = db.Column(db.Integer, nullable=False) - deadline = db.Column(db.Integer) - reminder = db.Column(db.Integer) + creation_time = db.Column(db.BigInteger, nullable=False) + last_mod_time = db.Column(db.BigInteger, nullable=False) + deadline = db.Column(db.BigInteger) + reminder = db.Column(db.BigInteger) parents = db.Column(db.String(2048), nullable=False) def isChild(self) -> bool: diff --git a/setup.cfg b/setup.cfg index 9285e41..f53f614 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,4 +10,4 @@ dedent_closing_brackets = true indent_closing_brackets = false indent_width = 4 use_tabs = false -column_limit = 80 +column_limit = 100 diff --git a/setup.py b/setup.py index c15c539..f0591f3 100644 --- a/setup.py +++ b/setup.py @@ -4,17 +4,15 @@ setup( name="project_amber", - version="0.0.3", + version="0.0.4", description="The backend app of a note-taking app, Project Amber", url="https://git.tdem.in/tdemin/amber", author="Timur Demin", author_email="me@tdem.in", license="MIT", classifiers=[ - "Development Status :: 3 - Alpha", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", + "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8" "Programming Language :: Python :: 3" ],