diff --git a/project_amber/app.py b/project_amber/app.py index 4170d88..81fc2f3 100644 --- a/project_amber/app.py +++ b/project_amber/app.py @@ -5,7 +5,9 @@ from project_amber.config import config from project_amber.db import db from project_amber.errors import HTTPError -from project_amber.handlers.auth import login, logout, login_check +from project_amber.handlers.auth import login, logout +from project_amber.handlers.session import handle_session_req, \ + handle_session_id_req from project_amber.handlers.task import handle_task_id_request, \ handle_task_request from project_amber.handlers.users import signup @@ -16,12 +18,13 @@ app.add_url_rule("/api/login", "login", login, methods=["POST"]) app.add_url_rule("/api/logout", "logout", logout, methods=["POST"]) -app.add_url_rule("/api/login_check", "login_check", login_check, \ - methods=["GET"]) app.add_url_rule("/api/task", "task", handle_task_request, \ methods=["GET", "POST"]) app.add_url_rule("/api/task/", "task_id", handle_task_id_request, \ methods=["GET", "PATCH", "DELETE"]) +app.add_url_rule("/api/session", "session", handle_session_req, methods=["GET"]) +app.add_url_rule("/api/session/", "session_id", \ + handle_session_id_req, methods=["GET", "DELETE"]) if config["allow_signup"]: app.add_url_rule("/api/signup", "signup", signup, methods=["POST"]) diff --git a/project_amber/const.py b/project_amber/const.py index 4800ede..f0d8bb1 100644 --- a/project_amber/const.py +++ b/project_amber/const.py @@ -2,9 +2,16 @@ EMPTY_RESP = dumps({}) # Empty response, to be used in requests. +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_NO_TOKEN = "No X-Auth-Token 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" +MSG_IMMATURE_SESSION = "This session is too new, and cannot remove others" MSG_TASK_NOT_FOUND = "This task does not exist" +MSG_TEXT_NOT_SPECIFIED = "No text specified" diff --git a/project_amber/errors.py b/project_amber/errors.py index c5918d2..0d088ef 100644 --- a/project_amber/errors.py +++ b/project_amber/errors.py @@ -42,7 +42,7 @@ class NotFound(HTTPError): def __init__(self, message="Entity not found"): super().__init__(self.code, message) -class NoAccess(HTTPError): +class Forbidden(HTTPError): """ Exception class for restricted access areas. """ diff --git a/project_amber/handlers/auth.py b/project_amber/handlers/auth.py index edcdb76..13b258b 100644 --- a/project_amber/handlers/auth.py +++ b/project_amber/handlers/auth.py @@ -7,14 +7,6 @@ from project_amber.helpers.auth import handleChecks, removeSession, \ createSession -def login_check(): - """ - Essentially a heartbeat request that drops HTTP 401 when - unauthorized. Returns HTTP 200 with an empty response if otherwise. - """ - handleChecks() - return EMPTY_RESP - def login(): """ Login handler. Accepts this JSON: @@ -44,5 +36,5 @@ def logout(): Logout handler. Accepts empty JSON. Returns HTTP 200 on success. """ user = handleChecks() - removeSession(user.token) + removeSession(user.token, user.id) return EMPTY_RESP diff --git a/project_amber/handlers/session.py b/project_amber/handlers/session.py new file mode 100644 index 0000000..f128e51 --- /dev/null +++ b/project_amber/handlers/session.py @@ -0,0 +1,73 @@ +from json import dumps +from time import time + +from flask import request + +from project_amber.const import MATURE_SESSION, MSG_IMMATURE_SESSION, EMPTY_RESP +from project_amber.errors import Forbidden +from project_amber.helpers.auth import handleChecks, getSessions, getSession,\ + removeSessionById + +def handle_session_req(): + """ + Request handler for `/api/session`. Only accepts GET requests. Returns a + list of sessions like the one below: + ``` + { + "sessions": [ + { + "id": 1, + "login_time": 123456, // timestamp + "address": "127.0.0.1" + } + { + "id": 2, + "login_time": 123457, + "address": "10.0.0.1" + } + ] + } + ``` + """ + user = handleChecks() + sessions = getSessions(user.id) + sessionList = [] + for session in sessions: + sessionList.append({ + "id": session.id, + "login_time": session.login_time, + "address": session.address + }) + return dumps({ + "sessions": sessionList + }) + +def handle_session_id_req(session_id: int): + """ + Login handler for `/api/session/`. Accepts GET and DELETE + requests. Returns 404 if this session does not exist. On successful + GET, returns JSON like this: + ``` + { + "id": 1, + "login_time": 123456, // timestamp + "address": "127.0.0.1" + } + ``` + On DELETE, this will return HTTP 200 with empty JSON. There is a special + case here: if a client session is too recent, this will respond with + HTTP 403. + """ + user = handleChecks() + if request.method == "GET": + session = getSession(session_id, user.id) + return dumps({ + "id": session.id, + "login_time": session.login_time, + "address": session.address + }) + if request.method == "DELETE": + if (time() - user.login_time) < MATURE_SESSION: + raise Forbidden(MSG_IMMATURE_SESSION) + removeSessionById(session_id, user.id) + return EMPTY_RESP diff --git a/project_amber/handlers/task.py b/project_amber/handlers/task.py index 3eb3f7f..f973d01 100644 --- a/project_amber/handlers/task.py +++ b/project_amber/handlers/task.py @@ -2,7 +2,7 @@ from flask import request -from project_amber.const import EMPTY_RESP +from project_amber.const import EMPTY_RESP, MSG_TEXT_NOT_SPECIFIED from project_amber.errors import BadRequest from project_amber.helpers.auth import handleChecks from project_amber.helpers.task import addTask, getTask, getTasks, \ @@ -59,7 +59,7 @@ def handle_task_request(): if request.method == "POST": text = request.json.get("text") if text is None: - raise BadRequest("No text specified") + raise BadRequest(MSG_TEXT_NOT_SPECIFIED) status = request.json.get("status") # if only I could `get("status", d=0)` like we do that with dicts if status is None: diff --git a/project_amber/helpers/auth.py b/project_amber/helpers/auth.py index cf89467..5ae12b4 100644 --- a/project_amber/helpers/auth.py +++ b/project_amber/helpers/auth.py @@ -19,10 +19,11 @@ class LoginUser: and ID. The corresponding fields are `name` and `id`, respectively. Also contains a token field. """ - def __init__(self, name: str, uid: int, token: str): + 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 def handleChecks() -> LoginUser: """ @@ -45,7 +46,7 @@ def handleChecks() -> LoginUser: 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_details = LoginUser(user.name, user.id, token, user_session.login_time) return user_details def addUser(name: str, password: str) -> int: @@ -105,13 +106,43 @@ def createSession(name: str, password: str) -> str: return token raise Unauthorized -def removeSession(token: str) -> str: +def removeSession(token: str, uid: int) -> str: """ Removes a user session by token. Returns the token on success. """ - session = db.session.query(Session).filter_by(token=token).one_or_none() + session = db.session.query(Session).filter_by(token=token, user=uid)\ + .one_or_none() if session is None: raise NotFound db.session.delete(session) db.session.commit() return token + +def removeSessionById(session_id: int, uid: 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=uid)\ + .one_or_none() + if session is None: + raise NotFound + db.session.delete(session) + db.session.commit() + return session_id + +def getSessions(uid: int) -> list: + """ + Returns a list of sessions of a user (class `Session`). + """ + sessions = db.session.query(Session).filter_by(user=uid).all() + return sessions + +def getSession(session_id: int, uid: int) -> Session: + """ + Returns a single `Session` by its ID. + """ + session = db.session.query(Session).filter_by(id=session_id, user=uid)\ + .one_or_none() + if session is None: + raise NotFound + return session diff --git a/project_amber/models/auth.py b/project_amber/models/auth.py index 9c0c79f..ef22289 100644 --- a/project_amber/models/auth.py +++ b/project_amber/models/auth.py @@ -15,7 +15,8 @@ class Session(db.Model): """ Holds auth session details (auth token, the time of login, etc). """ - token = db.Column(db.String(256), primary_key=True) + id = db.Column(db.Integer, primary_key=True) + token = db.Column(db.String(256), unique=True, nullable=False) user = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) login_time = db.Column(db.Integer, nullable=False) address = db.Column(db.String(100), nullable=False)