From a78d42ef1be544d4752885036a61e1250b567ca9 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Thu, 9 Dec 2021 13:48:36 +0200 Subject: [PATCH 1/9] Moved @ensure_logged_in to the decorators file Also added docstrings to the decorators --- server/decorators.py | 42 ++++++++++++++++++++++++++++++++++++++++++ server/login.py | 29 +---------------------------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/server/decorators.py b/server/decorators.py index 9376615..f6bed20 100644 --- a/server/decorators.py +++ b/server/decorators.py @@ -1,7 +1,14 @@ +from flask import request from http import HTTPStatus from functools import wraps +import ram_db +import returns def no_content(fn): + ''' + Allows a Flask route to return None, which is converted into + HTTP 201 No Content. + ''' @wraps(fn) def wrapper(*args, **kargs): result = fn(*args, **kargs) @@ -10,3 +17,38 @@ def no_content(fn): else: return result return wrapper + +def ensure_logged_in(token=False, user_id=False): + ''' + Ensure the user is logged in by providing an Authorization: Bearer token + header. + + @param token whether the token should be supplied after validation + @param user_id whether the user_id should be supplied after validation + @return decorator which supplies the requested parameters + ''' + def decorator(fn): + pass_token = token + pass_user_id = user_id + @wraps(fn) + def wrapper(*args, **kargs): + token = request.headers.get('Authorization', None) + if token is None: + return returns.NO_AUTHORIZATION + if not token.startswith('Bearer '): + return returns.INVALID_AUTHORIZATION + token = token[7:] + user_id = ram_db.get_user(token) + if user_id is None: + return returns.INVALID_AUTHORIZATION + + if pass_user_id and pass_token: + return fn(user_id=user_id, token=token, *args, **kargs) + elif pass_user_id: + return fn(user_id=user_id, *args, **kargs) + elif pass_token: + return fn(token=token, *args, **kargs) + else: + return fn(*args, **kargs) + return wrapper + return decorator diff --git a/server/login.py b/server/login.py index e836249..6ef7758 100644 --- a/server/login.py +++ b/server/login.py @@ -4,7 +4,7 @@ from flask import Blueprint, request from pyotp import TOTP import db_utils -from decorators import no_content +from decorators import no_content, ensure_logged_in import models import ram_db import returns @@ -30,33 +30,6 @@ def make_login(): token = ram_db.login_user(user.id) return returns.success(token=token) -def ensure_logged_in(token=False, user_id=False): - def decorator(fn): - pass_token = token - pass_user_id = user_id - @wraps(fn) - def wrapper(*args, **kargs): - token = request.headers.get('Authorization', None) - if token is None: - return returns.NO_AUTHORIZATION - if not token.startswith('Bearer '): - return returns.INVALID_AUTHORIZATION - token = token[7:] - user_id = ram_db.get_user(token) - if user_id is None: - return returns.INVALID_AUTHORIZATION - - if pass_user_id and pass_token: - return fn(user_id=user_id, token=token, *args, **kargs) - elif pass_user_id: - return fn(user_id=user_id, *args, **kargs) - elif pass_token: - return fn(token=token, *args, **kargs) - else: - return fn(*args, **kargs) - return wrapper - return decorator - @login.post('/logout') @ensure_logged_in(token=True) @no_content From efb98ceb2ef4f56d6db336519726666db7bf3d52 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Thu, 9 Dec 2021 13:54:25 +0200 Subject: [PATCH 2/9] Allowed database file to be changed via env var --- server/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/db.py b/server/db.py index 9602532..cd29d6b 100644 --- a/server/db.py +++ b/server/db.py @@ -2,7 +2,8 @@ import sqlite3 from flask import current_app, g -DB_FILE = './data/db.sqlite' +import os +DB_FILE = os.environ('DB_FILE', './data/db.sqlite') get_return = sqlite3.Connection From f91b6be3a5773b11d82e632feae055611a0b8f3b Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 10:48:42 +0200 Subject: [PATCH 3/9] Implemented initial account support Also refatored into package Also added Swagger/OpenAPI --- FoxBank.code-workspace | 12 +- server/.vscode/launch.json | 3 +- server/Pipfile | 1 + server/Pipfile.lock | 45 ++++++- server/db_utils.py | 24 ---- server/decorators.py | 54 --------- server/foxbank_server/__init__.py | 34 ++++++ server/foxbank_server/apis/__init__.py | 23 ++++ server/foxbank_server/apis/accounts.py | 82 +++++++++++++ server/foxbank_server/apis/login.py | 54 +++++++++ server/{ => foxbank_server}/db.py | 6 +- server/foxbank_server/db_utils.py | 161 +++++++++++++++++++++++++ server/foxbank_server/decorators.py | 110 +++++++++++++++++ server/foxbank_server/models.py | 84 +++++++++++++ server/{ => foxbank_server}/ram_db.py | 1 - server/foxbank_server/returns.py | 78 ++++++++++++ server/foxbank_server/utils/iban.py | 31 +++++ server/foxbank_server/utils/string.py | 7 ++ server/login.py | 13 +- server/models.py | 25 ---- server/returns.py | 46 ------- server/server.py | 22 ++-- server/setup.cfg | 2 + 23 files changed, 745 insertions(+), 173 deletions(-) delete mode 100644 server/db_utils.py delete mode 100644 server/decorators.py create mode 100644 server/foxbank_server/__init__.py create mode 100644 server/foxbank_server/apis/__init__.py create mode 100644 server/foxbank_server/apis/accounts.py create mode 100644 server/foxbank_server/apis/login.py rename server/{ => foxbank_server}/db.py (92%) create mode 100644 server/foxbank_server/db_utils.py create mode 100644 server/foxbank_server/decorators.py create mode 100644 server/foxbank_server/models.py rename server/{ => foxbank_server}/ram_db.py (96%) create mode 100644 server/foxbank_server/returns.py create mode 100644 server/foxbank_server/utils/iban.py create mode 100644 server/foxbank_server/utils/string.py delete mode 100644 server/models.py delete mode 100644 server/returns.py mode change 100644 => 100755 server/server.py create mode 100644 server/setup.cfg diff --git a/FoxBank.code-workspace b/FoxBank.code-workspace index 512a324..4320c2a 100644 --- a/FoxBank.code-workspace +++ b/FoxBank.code-workspace @@ -10,5 +10,15 @@ "path": "." } ], - "settings": {} + "settings": { + "sqltools.connections": [ + { + "previewLimit": 50, + "driver": "SQLite", + "database": "${workspaceFolder:server}/data/db.sqlite", + "name": "Server DB" + } + ], + "sqltools.useNodeRuntime": true + } } \ No newline at end of file diff --git a/server/.vscode/launch.json b/server/.vscode/launch.json index a893403..d9be6a0 100644 --- a/server/.vscode/launch.json +++ b/server/.vscode/launch.json @@ -17,7 +17,8 @@ "run", "--no-debugger" ], - "jinja": true + "jinja": true, + "justMyCode": false } ] } \ No newline at end of file diff --git a/server/Pipfile b/server/Pipfile index e7e4b40..f5e8455 100644 --- a/server/Pipfile +++ b/server/Pipfile @@ -8,6 +8,7 @@ flask = "*" gunicorn = "*" pyotp = "*" flask-cors = "*" +flask-smorest = "*" [dev-packages] diff --git a/server/Pipfile.lock b/server/Pipfile.lock index 24d16de..d3a519b 100644 --- a/server/Pipfile.lock +++ b/server/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2ba252d63658abd009170d14705593521c57c99f82b643fcf232eeb51be35d10" + "sha256": "b70c68cd833afb9cc5b924eed2688784766705c1d4e008302bcc93d05f6bdbd4" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,17 @@ ] }, "default": { + "apispec": { + "extras": [ + "marshmallow" + ], + "hashes": [ + "sha256:5bc5404b19259aeeb307ce9956e2c1a97722c6a130ef414671dfc21acd622afc", + "sha256:d167890e37f14f3f26b588ff2598af35faa5c27612264ea1125509c8ff860834" + ], + "markers": "python_version >= '3.6'", + "version": "==5.1.1" + }, "click": { "hashes": [ "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", @@ -40,6 +51,14 @@ "index": "pypi", "version": "==3.0.10" }, + "flask-smorest": { + "hashes": [ + "sha256:b08b20fb15e505f4f032a82dd0d5471e431d1b8da9ae16e4a0099bb70d753c47", + "sha256:d97e114b972a0afae6a6c7883069c753c425ae1d8bb4c548028536465b1d1e19" + ], + "index": "pypi", + "version": "==0.35.0" + }, "gunicorn": { "hashes": [ "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", @@ -139,6 +158,14 @@ "markers": "python_version >= '3.6'", "version": "==2.0.1" }, + "marshmallow": { + "hashes": [ + "sha256:04438610bc6dadbdddb22a4a55bcc7f6f8099e69580b2e67f5a681933a1f4400", + "sha256:4c05c1684e0e97fe779c62b91878f173b937fe097b356cd82f793464f5bc6138" + ], + "markers": "python_version >= '3.6'", + "version": "==3.14.1" + }, "pyotp": { "hashes": [ "sha256:9d144de0f8a601d6869abe1409f4a3f75f097c37b50a36a3bf165810a6e23f28", @@ -149,11 +176,11 @@ }, "setuptools": { "hashes": [ - "sha256:6d10741ff20b89cd8c6a536ee9dc90d3002dec0226c78fb98605bfb9ef8a7adf", - "sha256:d144f85102f999444d06f9c0e8c737fd0194f10f2f7e5fdb77573f6e2fa4fad0" + "sha256:10d6eff7fc27ada30cc87e21abf324713b7169b97af1f81f8744d66260e91d10", + "sha256:89e8cb2d5ade19e9885e56cd110f2f1e80697f7cffa048886c585fe559ebbe32" ], - "markers": "python_version >= '3.6'", - "version": "==59.5.0" + "markers": "python_version >= '3.7'", + "version": "==60.1.1" }, "six": { "hashes": [ @@ -163,6 +190,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "webargs": { + "hashes": [ + "sha256:bb3530b0d37cdc5a5e29d30034dde4351811b9bc345eef21eb070a3ea7562093", + "sha256:bcce022250ee97cfbb0ad07b02388ac90a226ef4b479ec84317152345a565614" + ], + "markers": "python_version >= '3.6'", + "version": "==8.0.1" + }, "werkzeug": { "hashes": [ "sha256:63d3dc1cf60e7b7e35e97fa9861f7397283b75d765afcaefd993d6046899de8f", diff --git a/server/db_utils.py b/server/db_utils.py deleted file mode 100644 index 2d3ca5c..0000000 --- a/server/db_utils.py +++ /dev/null @@ -1,24 +0,0 @@ -from functools import wraps - -import db -import models - -def get_db(fn): - @wraps(fn) - def wrapper(*args, **kargs): - return fn(db.get(), *args, **kargs) - return wrapper - -@get_db -def get_user(db: db.get_return, username: str|None = None, user_id: int|None = None) -> models.User | None: - cur = db.cursor() - if username is not None: - cur.execute('select * from users where username=?', (username,)) - elif user_id is not None: - cur.execute('select * from users where id=?', (user_id,)) - else: - raise Exception('Neither username or user_id passed') - result = cur.fetchone() - if result is None: - return None - return models.User.from_query(result) diff --git a/server/decorators.py b/server/decorators.py deleted file mode 100644 index f6bed20..0000000 --- a/server/decorators.py +++ /dev/null @@ -1,54 +0,0 @@ -from flask import request -from http import HTTPStatus -from functools import wraps -import ram_db -import returns - -def no_content(fn): - ''' - Allows a Flask route to return None, which is converted into - HTTP 201 No Content. - ''' - @wraps(fn) - def wrapper(*args, **kargs): - result = fn(*args, **kargs) - if result is None: - return None, HTTPStatus.NO_CONTENT - else: - return result - return wrapper - -def ensure_logged_in(token=False, user_id=False): - ''' - Ensure the user is logged in by providing an Authorization: Bearer token - header. - - @param token whether the token should be supplied after validation - @param user_id whether the user_id should be supplied after validation - @return decorator which supplies the requested parameters - ''' - def decorator(fn): - pass_token = token - pass_user_id = user_id - @wraps(fn) - def wrapper(*args, **kargs): - token = request.headers.get('Authorization', None) - if token is None: - return returns.NO_AUTHORIZATION - if not token.startswith('Bearer '): - return returns.INVALID_AUTHORIZATION - token = token[7:] - user_id = ram_db.get_user(token) - if user_id is None: - return returns.INVALID_AUTHORIZATION - - if pass_user_id and pass_token: - return fn(user_id=user_id, token=token, *args, **kargs) - elif pass_user_id: - return fn(user_id=user_id, *args, **kargs) - elif pass_token: - return fn(token=token, *args, **kargs) - else: - return fn(*args, **kargs) - return wrapper - return decorator diff --git a/server/foxbank_server/__init__.py b/server/foxbank_server/__init__.py new file mode 100644 index 0000000..0de7b4a --- /dev/null +++ b/server/foxbank_server/__init__.py @@ -0,0 +1,34 @@ +from flask import Flask +from .apis import init_apis + +class Config: + OPENAPI_VERSION = "3.0.2" + OPENAPI_JSON_PATH = "api-spec.json" + OPENAPI_URL_PREFIX = "/" + OPENAPI_REDOC_PATH = "/redoc" + OPENAPI_REDOC_URL = ( + "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js" + ) + OPENAPI_SWAGGER_UI_PATH = "/swagger-ui" + OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + OPENAPI_RAPIDOC_PATH = "/rapidoc" + OPENAPI_RAPIDOC_URL = "https://unpkg.com/rapidoc/dist/rapidoc-min.js" + +def create_app(): + app = Flask(__name__) + app.config.from_object(Config()) + + init_db(app) + init_cors(app) + init_apis(app) + + return app + +def init_cors(app): + from flask_cors import CORS + cors = CORS() + cors.init_app(app) + +def init_db(app): + from .db import init_app + init_app(app) diff --git a/server/foxbank_server/apis/__init__.py b/server/foxbank_server/apis/__init__.py new file mode 100644 index 0000000..7d87e11 --- /dev/null +++ b/server/foxbank_server/apis/__init__.py @@ -0,0 +1,23 @@ +from flask import Flask +from flask_smorest import Api + +from .accounts import bp as acc_bp +from .login import bp as login_bp + +def init_apis(app: Flask): + api = Api(app, spec_kwargs={ + 'title': 'FoxBank', + 'version': '1', + 'openapi_version': '3.0.0', + 'components': { + 'securitySchemes': { + 'Token': { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'Token ', + } + } + }, + }) + api.register_blueprint(login_bp, url_prefix='/login') + api.register_blueprint(acc_bp, url_prefix='/accounts') diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py new file mode 100644 index 0000000..43318ec --- /dev/null +++ b/server/foxbank_server/apis/accounts.py @@ -0,0 +1,82 @@ +from http import HTTPStatus +from flask_smorest import Blueprint, abort +from marshmallow import Schema, fields +from ..decorators import ensure_logged_in +from ..models import Account +from .. import decorators +from .. import db_utils +from .. import returns + +bp = Blueprint('accounts', __name__, description='Bank Accounts operations') + +VALID_CURRENCIES = ['RON', 'EUR', 'USD'] +ACCOUNT_TYPES = ['Checking', 'Savings'] + +class MetaCurrenciesSchema(Schema): + status = fields.Constant('success') + currencies = fields.List(fields.Str()) + +class MetaAccountTypesSchema(Schema): + status = fields.Constant('success') + account_types = fields.List(fields.Str(), data_key='accountTypes') + +@bp.get('/meta/currencies') +@bp.response(200, MetaCurrenciesSchema) +def get_valid_currencies(): + return returns.success(currencies=VALID_CURRENCIES) + + +@bp.get('/meta/account_types') +@bp.response(200, MetaAccountTypesSchema) +def get_valid_account_types(): + return returns.success(account_types=ACCOUNT_TYPES) + + +@bp.get('/') +@ensure_logged_in +@bp.doc(security=[{'Token': []}]) +def get_account_id(account_id: int): + account = db_utils.get_account(account_id=account_id) + if account is None: + return returns.NOT_FOUND + if decorators.user_id != db_utils.whose_account(account): + return returns.UNAUTHORIZED + account = account.to_json() + return returns.success(account=account) + + +@bp.get('/IBAN_') +@ensure_logged_in +@bp.doc(security=[{'Token': []}]) +def get_account_iban(iban: str): + account = db_utils.get_account(iban=iban) + if account is None: + return returns.NOT_FOUND + if decorators.user_id != db_utils.whose_account(account): + return returns.UNAUTHORIZED + account = account.to_json() + return returns.success(account=account) + + + +class CreateAccountParams(Schema): + currency = fields.String() + account_type = fields.String(data_key='accountType') + custom_name = fields.String(data_key='customName') + +@bp.post('/') +@ensure_logged_in +@bp.arguments(CreateAccountParams, as_kwargs=True) +@bp.response(200, Account.Schema) +@bp.response(HTTPStatus.UNPROCESSABLE_ENTITY, description='Invalid currency or account type') +@bp.doc(security=[{'Token': []}]) +def create_account(currency: str, account_type: str, custom_name: str): + if currency not in VALID_CURRENCIES: + abort(HTTPStatus.UNPROCESSABLE_ENTITY) + if account_type not in ACCOUNT_TYPES: + abort(HTTPStatus.UNPROCESSABLE_ENTITY) + + account = Account(-1, '', currency, account_type, custom_name or '') + db_utils.insert_account(decorators.user_id, account) + return account.to_json() + diff --git a/server/foxbank_server/apis/login.py b/server/foxbank_server/apis/login.py new file mode 100644 index 0000000..71130b0 --- /dev/null +++ b/server/foxbank_server/apis/login.py @@ -0,0 +1,54 @@ +from flask.views import MethodView +from flask_smorest import Blueprint +from marshmallow import Schema, fields +from .. import returns, ram_db, decorators +from ..db_utils import get_user +from ..models import User +from ..decorators import ensure_logged_in + +from pyotp import TOTP + +bp = Blueprint('login', __name__, description='Login operations') + +class LoginParams(Schema): + username = fields.String() + code = fields.String() + +class LoginResult(returns.SuccessSchema): + token = fields.String() + +class LoginSuccessSchema(returns.SuccessSchema): + token = fields.String() + +@bp.route('/') +class Login(MethodView): + @bp.arguments(LoginParams, as_kwargs=True) + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.response(200, LoginSuccessSchema) + def post(self, username: str, code: str): + user: User | None = get_user(username=username) + if user is None: + return returns.INVALID_DETAILS + + otp = TOTP(user.otp) + if not otp.verify(code, valid_window=1): + return returns.INVALID_DETAILS + + token = ram_db.login_user(user.id) + return returns.success(token=token) + +@bp.route('/whoami') +class WhoAmI(MethodView): + class WhoAmISchema(returns.SuccessSchema): + user = fields.Nested(User.UserSchema) + + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.response(200, WhoAmISchema) + @bp.doc(security=[{'Token': []}]) + @ensure_logged_in + def get(self): + user: User | None = get_user(user_id=decorators.user_id) + if user is not None: + user = user.to_json() + + return returns.success(user=user) diff --git a/server/db.py b/server/foxbank_server/db.py similarity index 92% rename from server/db.py rename to server/foxbank_server/db.py index cd29d6b..e65162e 100644 --- a/server/db.py +++ b/server/foxbank_server/db.py @@ -3,10 +3,11 @@ import sqlite3 from flask import current_app, g import os -DB_FILE = os.environ('DB_FILE', './data/db.sqlite') +DB_FILE = os.environ.get('DB_FILE', './data/db.sqlite') get_return = sqlite3.Connection + def get() -> get_return: if 'db' not in g: g.db = sqlite3.connect( @@ -17,12 +18,14 @@ def get() -> get_return: return g.db + def close(e=None): db = g.pop('db', None) if db: db.close() + def init(): db = get() @@ -30,6 +33,7 @@ def init(): db.executescript(f.read().decode('utf8')) db.commit() + def init_app(app): app.teardown_appcontext(close) diff --git a/server/foxbank_server/db_utils.py b/server/foxbank_server/db_utils.py new file mode 100644 index 0000000..338c6d2 --- /dev/null +++ b/server/foxbank_server/db_utils.py @@ -0,0 +1,161 @@ +from functools import wraps +import sys +from types import ModuleType + +from . import db as _db +from . import models + +_db_global: None | tuple[_db.get_return, int] = None + + +def get_db(fn): + @wraps(fn) + def wrapper(*args, **kargs): + global _db_global + if _db_global is None: + _db_global = _db.get(), 1 + else: + _db_global = _db_global[0], _db_global[1] + 1 + result = fn(*args, **kargs) + _db_global = _db_global[0], _db_global[1] - 1 + if _db_global[1] == 0: + _db_global = None + return result + + return wrapper + + +class Module(ModuleType): + @property + def db(self) -> _db.get_return: + if _db_global is None: + raise Exception('Function not wrapped with @get_db, db unavailable') + return _db_global[0] + + + @get_db + def get_user(self, username: str | None = None, user_id: int | None = None) -> models.User | None: + cur = self.db.cursor() + if username is not None: + cur.execute('select * from users where username=?', (username,)) + elif user_id is not None: + cur.execute('select * from users where id=?', (user_id,)) + else: + raise Exception('Neither username or user_id passed') + result = cur.fetchone() + if result is None: + return None + return models.User.from_query(result) + + + @get_db + def insert_user(self, user: models.User): + # Prepare user + if not user.otp: + from pyotp import random_base32 + user.otp = random_base32() + + cur = self.db.cursor() + cur.execute( + 'insert into users(username, email, otp, fullname) values (?, ?, ?, ?)', + (user.username, user.email, user.otp, user.fullname), + ) + + cur.execute( + 'select id from users where username = ? and email = ? and otp = ? and fullname = ?', + (user.username, user.email, user.otp, user.fullname), + ) + + user.id = cur.fetchone()['id'] + + + @get_db + def get_accounts(self, user_id: int | None = None) -> list[models.Account]: + """ + Get all accounts. + If `user_id` is provided, get only the accounts for the matching user. + """ + cur = self.db.cursor() + if user_id: + cur.execute(''' + select id, iban, currency, account_type, custom_name from accounts + inner join users_accounts + on accounts.id = users_accounts.account_id + where users_accounts.user_id = ? + ''', (user_id,)) + else: + cur.execute('select id, iban, currency, account_type, custom_name from accounts') + return [models.Account.from_query(q) for q in cur.fetchall()] + + + @get_db + def get_account(self, account_id: int | None = None, iban: str | None = None) -> models.Account | None: + cur = self.db.cursor() + if account_id is not None: + cur.execute( + 'select * from accounts where id=?', + (account_id,), + ) + elif iban is not None: + cur.execute( + 'select * from accounts where iban=?', + (iban,), + ) + else: + raise Exception('Neither username or user_id passed') + result = cur.fetchone() + if result is None: + return None + return models.Account.from_query(result) + + + @get_db + def whose_account(self, account: int | models.Account) -> int | None: + try: + account_id = account.id + except AttributeError: + account_id = account + + cur = self.db.cursor() + cur.execute('select user_id from users_accounts where account_id = ?', (account_id,)) + result = cur.fetchone() + if not result: + return None + return result['user_id'] + + + @get_db + def insert_account(self, user_id: int, account: models.Account): + # Prepare account + ibans = [acc.iban for acc in self.get_accounts(user_id)] + if not account.iban: + from random import randint + while True: + iban = 'RO00FOXB0' + account.currency + iban += str(randint(0, 10 ** 13)).rjust(12, '0') + from .utils.iban import gen_check_digits + iban = gen_check_digits(iban) + if iban not in ibans: + break + account.iban = iban + + cur = self.db.cursor() + cur.execute( + 'insert into accounts(iban, currency, account_type, custom_name) values (?, ?, ?, ?)', + (account.iban, account.currency, account.account_type, account.custom_name), + ) + + cur.execute( + 'select id from accounts where iban = ?', + (account.iban,), + ) + account.id = cur.fetchone()['id'] + + cur.execute( + 'insert into users_accounts(user_id, account_id) VALUES (?, ?)', + (user_id, account.id) + ) + + self.db.commit() + +sys.modules[__name__] = Module(__name__) diff --git a/server/foxbank_server/decorators.py b/server/foxbank_server/decorators.py new file mode 100644 index 0000000..1fa8502 --- /dev/null +++ b/server/foxbank_server/decorators.py @@ -0,0 +1,110 @@ +import sys +from types import ModuleType +from flask import request +from http import HTTPStatus +from functools import wraps +from . import ram_db +from . import returns + +_token: str | None = None +_user_id: int | None = None + +class Module(ModuleType): + def no_content(self, fn): + """ + Allows a Flask route to return None, which is converted into + HTTP 201 No Content. + """ + @wraps(fn) + def wrapper(*args, **kargs): + result = fn(*args, **kargs) + if result is None: + return None, HTTPStatus.NO_CONTENT + else: + return result + return wrapper + + @property + def token(self) -> str: + if _token is None: + raise Exception('No token available') + return _token + + @property + def user_id(self) -> int: + if _user_id is None: + raise Exception('No user_id available') + return _user_id + + def ensure_logged_in(self, fn): + """ + Ensure the user is logged in by providing an Authorization: Bearer token + header. + + @param token whether the token should be supplied after validation + @param user_id whether the user_id should be supplied after validation + @return decorator which supplies the requested parameters + """ + @wraps(fn) + def wrapper(*args, **kargs): + token = request.headers.get('Authorization', None) + if token is None: + return returns.NO_AUTHORIZATION + if not token.startswith('Bearer '): + return returns.INVALID_AUTHORIZATION + token = token[7:] + user_id = ram_db.get_user(token) + if user_id is None: + return returns.INVALID_AUTHORIZATION + + global _token + _token = token + global _user_id + _user_id = user_id + + result = fn(*args, **kargs) + + _token = None + _user_id = None + + return result + + return wrapper + + # def ensure_logged_in(token=False, user_id=False): + # """ + # Ensure the user is logged in by providing an Authorization: Bearer token + # header. + # + # @param token whether the token should be supplied after validation + # @param user_id whether the user_id should be supplied after validation + # @return decorator which supplies the requested parameters + # """ + # def decorator(fn): + # pass_token = token + # pass_user_id = user_id + # + # @wraps(fn) + # def wrapper(*args, **kargs): + # token = request.headers.get('Authorization', None) + # if token is None: + # return returns.NO_AUTHORIZATION + # if not token.startswith('Bearer '): + # return returns.INVALID_AUTHORIZATION + # token = token[7:] + # user_id = ram_db.get_user(token) + # if user_id is None: + # return returns.INVALID_AUTHORIZATION + # + # if pass_user_id and pass_token: + # return fn(user_id=user_id, token=token, *args, **kargs) + # elif pass_user_id: + # return fn(user_id=user_id, *args, **kargs) + # elif pass_token: + # return fn(token=token, *args, **kargs) + # else: + # return fn(*args, **kargs) + # return wrapper + # return decorator + +sys.modules[__name__] = Module(__name__) diff --git a/server/foxbank_server/models.py b/server/foxbank_server/models.py new file mode 100644 index 0000000..0e01f7a --- /dev/null +++ b/server/foxbank_server/models.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from marshmallow import Schema, fields + +@dataclass +class User: + id: int + username: str + email: str + otp: str + fullname: str + + class UserSchema(Schema): + id = fields.Int(required=False) + username = fields.String() + email = fields.String() + otp = fields.String(load_only=True, required=False) + fullname = fields.String() + + @staticmethod + def new_user(username: str, email: str, fullname: str) -> 'User': + return User( + id=-1, + username=username, + email=email, + otp='', + fullname=fullname, + ) + + def to_json(self, include_otp=False, include_id=False): + result = { + 'username': self.username, + 'email': self.email, + 'fullname': self.fullname, + } + if include_id: + result['id'] = self.id + if include_otp: + result['otp'] = self.otp + return result + + @classmethod + def from_query(cls, query_result): + return cls(*query_result) + + +@dataclass +class Account: + id: int + iban: str + currency: str + account_type: str + custom_name: str + + class Schema(Schema): + id = fields.Int(required=False) + iban = fields.Str() + currency = fields.Str() + account_type = fields.Str(data_key='accountType') + custom_name = fields.Str(data_key='customName') + + @staticmethod + def new_account(currency: str, account_type: str, custom_name: str = '') -> 'Account': + return Account( + id=-1, + iban='', + currency=currency, + account_type=account_type, + custom_name=custom_name, + ) + + def to_json(self, include_id=True): + result = { + 'iban': self.iban, + 'currency': self.currency, + 'accountType': self.account_type, + 'customName': self.custom_name, + } + if include_id: + result['id'] = self.id + return result + + @classmethod + def from_query(cls, query_result): + return cls(*query_result) diff --git a/server/ram_db.py b/server/foxbank_server/ram_db.py similarity index 96% rename from server/ram_db.py rename to server/foxbank_server/ram_db.py index 631422c..dd452c6 100644 --- a/server/ram_db.py +++ b/server/foxbank_server/ram_db.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta -from types import TracebackType from uuid import uuid4 USED_TOKENS = set() diff --git a/server/foxbank_server/returns.py b/server/foxbank_server/returns.py new file mode 100644 index 0000000..cf7e1c5 --- /dev/null +++ b/server/foxbank_server/returns.py @@ -0,0 +1,78 @@ +from http import HTTPStatus as _HTTPStatus +from typing import Any + + +def _make_error(http_status, code: str, message: str | None = None): + try: + http_status = http_status[0] + except Exception: + pass + + payload = { + 'status': 'error', + 'code': code, + } + + if message is not None: + payload['message'] = message + + return payload, http_status + + +# General + +INVALID_REQUEST = _make_error( + _HTTPStatus.BAD_REQUEST, + 'general/invalid_request', +) + +NOT_FOUND = _make_error( + _HTTPStatus.NOT_FOUND, + 'general/not_found', +) + +# Login + +INVALID_DETAILS = _make_error( + _HTTPStatus.UNAUTHORIZED, + 'login/invalid_details', +) + +NO_AUTHORIZATION = _make_error( + _HTTPStatus.UNAUTHORIZED, + 'login/no_authorization', +) + +INVALID_AUTHORIZATION = _make_error( + _HTTPStatus.UNAUTHORIZED, + 'login/invalid_authorization', +) + +UNAUTHORIZED = _make_error( + _HTTPStatus.UNAUTHORIZED, + 'login/unauthorized', + "You are logged in but the resource you're trying to access isn't available to you", +) + + +# Success + +def success(http_status: Any = _HTTPStatus.OK, /, **kargs): + try: + http_status = http_status[0] + except Exception: + pass + + return dict(kargs, status='success'), http_status + +# Schemas + +from marshmallow import Schema, fields + +class ErrorSchema(Schema): + status = fields.Constant('error') + code = fields.Str() + message = fields.Str(required=False) + +class SuccessSchema(Schema): + status = fields.Constant('success') diff --git a/server/foxbank_server/utils/iban.py b/server/foxbank_server/utils/iban.py new file mode 100644 index 0000000..f0dec40 --- /dev/null +++ b/server/foxbank_server/utils/iban.py @@ -0,0 +1,31 @@ +from .string import str_range_replace + + +def c_to_iban_i(c: str) -> int: + a = ord(c) + if a in range(48, 58): + return a - 48 + elif a in range(65, 91): + return a - 65 + 10 + elif a in range(97, 123): + return a - 97 + 10 + else: + raise ValueError(f'Invalid IBAN character: {c} (ord: {a})') + + +def iban_to_int(iban: str) -> int: + iban = iban[4:] + iban[0:4] + return int(''.join(map(str, map(c_to_iban_i, iban)))) + + +def check_iban(iban: str) -> bool: + num = iban_to_int(iban) + return num % 97 == 1 + + +def gen_check_digits(iban: str) -> str: + iban = str_range_replace(iban, '00', 2, 4) + num = iban_to_int(iban) + check = 98 - (num % 97) + iban = str_range_replace(iban, str(check).rjust(2, '0'), 2, 4) + return iban diff --git a/server/foxbank_server/utils/string.py b/server/foxbank_server/utils/string.py new file mode 100644 index 0000000..5f9ab13 --- /dev/null +++ b/server/foxbank_server/utils/string.py @@ -0,0 +1,7 @@ +def str_range_replace( + input: str, + replace_with: str, + range_start: int | None = None, + range_end: int | None = None, +) -> str: + return input[:range_start] + replace_with + input[range_end:] diff --git a/server/login.py b/server/login.py index 6ef7758..d462351 100644 --- a/server/login.py +++ b/server/login.py @@ -4,7 +4,7 @@ from flask import Blueprint, request from pyotp import TOTP import db_utils -from decorators import no_content, ensure_logged_in +from decorators import no_content, ensure_logged_in, user_id, token import models import ram_db import returns @@ -31,16 +31,17 @@ def make_login(): return returns.success(token=token) @login.post('/logout') -@ensure_logged_in(token=True) +@ensure_logged_in @no_content -def logout(token: str): +def logout(): ram_db.logout_user(token) @login.get('/whoami') -@ensure_logged_in(user_id=True) -def whoami(user_id: int): +@ensure_logged_in +def whoami(): user: models.User | None = db_utils.get_user(user_id=user_id) if user is not None: user = user.to_json() - return returns.success(user=user) + return returns.successs(user=user) + diff --git a/server/models.py b/server/models.py deleted file mode 100644 index 163e599..0000000 --- a/server/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from dataclasses import dataclass - -@dataclass -class User: - id: int - username: str - email: str - otp: str - fullname: str - - def to_json(self, include_otp=False, include_id=False): - result = { - 'username': self.username, - 'email': self.email, - 'fullname': self.fullname, - } - if include_id: - result['id'] = self.id - if include_otp: - result['otp'] = self.otp - return result - - @classmethod - def from_query(cls, query_result): - return cls(*query_result) \ No newline at end of file diff --git a/server/returns.py b/server/returns.py deleted file mode 100644 index a3d212b..0000000 --- a/server/returns.py +++ /dev/null @@ -1,46 +0,0 @@ -from http import HTTPStatus as _HTTPStatus - -def _make_error(http_status, code: str): - try: - http_status = http_status[0] - except Exception: - pass - - return { - 'status': 'error', - 'code': code, - }, http_status - -# General - -INVALID_REQUEST = _make_error( - _HTTPStatus.BAD_REQUEST, - 'general/invalid_request', -) - -# Login - -INVALID_DETAILS = _make_error( - _HTTPStatus.UNAUTHORIZED, - 'login/invalid_details', -) - -NO_AUTHORIZATION = _make_error( - _HTTPStatus.UNAUTHORIZED, - 'login/no_authorization', -) - -INVALID_AUTHORIZATION = _make_error( - _HTTPStatus.UNAUTHORIZED, - 'login/invalid_authorization', -) - -# Success - -def success(http_status=_HTTPStatus.OK, /, **kargs): - try: - http_status = http_status[0] - except Exception: - pass - - return dict(kargs, status='success'), http_status diff --git a/server/server.py b/server/server.py old mode 100644 new mode 100755 index 9e437d5..be61be7 --- a/server/server.py +++ b/server/server.py @@ -1,14 +1,18 @@ -from flask import Flask -from flask_cors import CORS +#! /usr/bin/env python3 +from foxbank_server import create_app -import db +app = create_app() +# api = Api(app) +# CORS(app) +# db.init_app(app) -app = Flask(__name__) -CORS(app) -db.init_app(app) +# from login import login +# app.register_blueprint(login, url_prefix='/login') -from login import login -app.register_blueprint(login, url_prefix='/login') +# from bank_accounts import blueprint as ba_bp, namespace as ba_ns +# app.register_blueprint(ba_bp, url_prefix='/accounts') + +# accounts_ns = api.add_namespace(ba_ns, '/accounts') if __name__ == '__main__': - app.run() + app.run(debug=True) diff --git a/server/setup.cfg b/server/setup.cfg new file mode 100644 index 0000000..dc8a068 --- /dev/null +++ b/server/setup.cfg @@ -0,0 +1,2 @@ +[pycodestyle] +ignore = E402 \ No newline at end of file From f19aad8d3e2e1b4606586121ef9da7d0df4143d6 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 19:36:09 +0200 Subject: [PATCH 4/9] Added logout endpoint and documentation --- server/foxbank_server/apis/login.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/foxbank_server/apis/login.py b/server/foxbank_server/apis/login.py index 71130b0..fd6e903 100644 --- a/server/foxbank_server/apis/login.py +++ b/server/foxbank_server/apis/login.py @@ -26,6 +26,7 @@ class Login(MethodView): @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.response(200, LoginSuccessSchema) def post(self, username: str, code: str): + """Login via username and TOTP code""" user: User | None = get_user(username=username) if user is None: return returns.INVALID_DETAILS @@ -37,6 +38,23 @@ class Login(MethodView): token = ram_db.login_user(user.id) return returns.success(token=token) + @ensure_logged_in + @bp.doc(security=[{'Token': []}]) + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.response(204) + def delete(self): + """Logout""" + ram_db.logout_user(decorators.token) + +@bp.post('/logout') +@ensure_logged_in +@bp.doc(security=[{'Token': []}]) +@bp.response(401, returns.ErrorSchema, description='Login failure') +@bp.response(204) +def logout_route(): + """Logout""" + ram_db.logout_user(decorators.token) + @bp.route('/whoami') class WhoAmI(MethodView): class WhoAmISchema(returns.SuccessSchema): @@ -47,6 +65,7 @@ class WhoAmI(MethodView): @bp.doc(security=[{'Token': []}]) @ensure_logged_in def get(self): + """Get information about currently logged in user""" user: User | None = get_user(user_id=decorators.user_id) if user is not None: user = user.to_json() From 9ded9cc6043e94112b5be66f075b75576cb12f4b Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 19:36:47 +0200 Subject: [PATCH 5/9] Added GET /accounts endpoint --- server/foxbank_server/apis/accounts.py | 61 +++++++++++++++++--------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 43318ec..633d455 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from flask.views import MethodView from flask_smorest import Blueprint, abort from marshmallow import Schema, fields from ..decorators import ensure_logged_in @@ -34,6 +35,7 @@ def get_valid_account_types(): @bp.get('/') @ensure_logged_in +@bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) def get_account_id(account_id: int): account = db_utils.get_account(account_id=account_id) @@ -47,6 +49,7 @@ def get_account_id(account_id: int): @bp.get('/IBAN_') @ensure_logged_in +@bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) def get_account_iban(iban: str): account = db_utils.get_account(iban=iban) @@ -58,25 +61,41 @@ def get_account_iban(iban: str): return returns.success(account=account) - -class CreateAccountParams(Schema): - currency = fields.String() - account_type = fields.String(data_key='accountType') - custom_name = fields.String(data_key='customName') - -@bp.post('/') -@ensure_logged_in -@bp.arguments(CreateAccountParams, as_kwargs=True) -@bp.response(200, Account.Schema) -@bp.response(HTTPStatus.UNPROCESSABLE_ENTITY, description='Invalid currency or account type') -@bp.doc(security=[{'Token': []}]) -def create_account(currency: str, account_type: str, custom_name: str): - if currency not in VALID_CURRENCIES: - abort(HTTPStatus.UNPROCESSABLE_ENTITY) - if account_type not in ACCOUNT_TYPES: - abort(HTTPStatus.UNPROCESSABLE_ENTITY) - - account = Account(-1, '', currency, account_type, custom_name or '') - db_utils.insert_account(decorators.user_id, account) - return account.to_json() +@bp.route('/') +class AccountsList(MethodView): + class CreateAccountParams(Schema): + currency = fields.String() + account_type = fields.String(data_key='accountType') + custom_name = fields.String(data_key='customName') + + class CreateAccountResponseSchema(returns.SuccessSchema): + account = fields.Nested(Account.Schema) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.doc(security=[{'Token': []}]) + @bp.arguments(CreateAccountParams, as_kwargs=True) + @bp.response(200, CreateAccountResponseSchema) + @bp.response(HTTPStatus.UNPROCESSABLE_ENTITY, description='Invalid currency or account type') + def post(self, currency: str, account_type: str, custom_name: str): + """Create account""" + if currency not in VALID_CURRENCIES: + abort(HTTPStatus.UNPROCESSABLE_ENTITY) + if account_type not in ACCOUNT_TYPES: + abort(HTTPStatus.UNPROCESSABLE_ENTITY) + + account = Account(-1, '', currency, account_type, custom_name or '') + db_utils.insert_account(decorators.user_id, account) + return returns.success(account=account.to_json()) + + class AccountsResponseSchema(returns.SuccessSchema): + accounts = fields.List(fields.Nested(Account.Schema)) + + @ensure_logged_in + @bp.response(401, returns.ErrorSchema, description='Login failure') + @bp.doc(security=[{'Token': []}]) + @bp.response(200, AccountsResponseSchema) + def get(self): + """Get all accounts of user""" + return returns.success(accounts=db_utils.get_accounts(decorators.user_id)) From 31511f6004b92f8b68fd90bb9acdcf7fffe09ddc Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 20:16:58 +0200 Subject: [PATCH 6/9] Fixed error handling --- server/foxbank_server/apis/__init__.py | 8 ++++- server/foxbank_server/apis/accounts.py | 12 +++---- server/foxbank_server/apis/login.py | 4 +-- server/foxbank_server/decorators.py | 42 ++--------------------- server/foxbank_server/returns.py | 16 +++++++++ server/login.py | 47 -------------------------- 6 files changed, 34 insertions(+), 95 deletions(-) delete mode 100644 server/login.py diff --git a/server/foxbank_server/apis/__init__.py b/server/foxbank_server/apis/__init__.py index 7d87e11..e1d307b 100644 --- a/server/foxbank_server/apis/__init__.py +++ b/server/foxbank_server/apis/__init__.py @@ -4,8 +4,14 @@ from flask_smorest import Api from .accounts import bp as acc_bp from .login import bp as login_bp +class ApiWithErr(Api): + def handle_http_exception(self, error): + if error.data and error.data['response']: + return error.data['response'] + return super().handle_http_exception(error) + def init_apis(app: Flask): - api = Api(app, spec_kwargs={ + api = ApiWithErr(app, spec_kwargs={ 'title': 'FoxBank', 'version': '1', 'openapi_version': '3.0.0', diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 633d455..186c54d 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -40,9 +40,9 @@ def get_valid_account_types(): def get_account_id(account_id: int): account = db_utils.get_account(account_id=account_id) if account is None: - return returns.NOT_FOUND + return returns.abort(returns.NOT_FOUND) if decorators.user_id != db_utils.whose_account(account): - return returns.UNAUTHORIZED + return returns.abort(returns.UNAUTHORIZED) account = account.to_json() return returns.success(account=account) @@ -54,9 +54,9 @@ def get_account_id(account_id: int): def get_account_iban(iban: str): account = db_utils.get_account(iban=iban) if account is None: - return returns.NOT_FOUND + return returns.abort(returns.NOT_FOUND) if decorators.user_id != db_utils.whose_account(account): - return returns.UNAUTHORIZED + return returns.abort(returns.UNAUTHORIZED) account = account.to_json() return returns.success(account=account) @@ -80,9 +80,9 @@ class AccountsList(MethodView): def post(self, currency: str, account_type: str, custom_name: str): """Create account""" if currency not in VALID_CURRENCIES: - abort(HTTPStatus.UNPROCESSABLE_ENTITY) + return returns.abort(returns.invalid_argument('currency')) if account_type not in ACCOUNT_TYPES: - abort(HTTPStatus.UNPROCESSABLE_ENTITY) + return returns.abort(returns.invalid_argument('account_type')) account = Account(-1, '', currency, account_type, custom_name or '') db_utils.insert_account(decorators.user_id, account) diff --git a/server/foxbank_server/apis/login.py b/server/foxbank_server/apis/login.py index fd6e903..97ca243 100644 --- a/server/foxbank_server/apis/login.py +++ b/server/foxbank_server/apis/login.py @@ -29,11 +29,11 @@ class Login(MethodView): """Login via username and TOTP code""" user: User | None = get_user(username=username) if user is None: - return returns.INVALID_DETAILS + return returns.abort(returns.INVALID_DETAILS) otp = TOTP(user.otp) if not otp.verify(code, valid_window=1): - return returns.INVALID_DETAILS + return returns.abort(returns.INVALID_DETAILS) token = ram_db.login_user(user.id) return returns.success(token=token) diff --git a/server/foxbank_server/decorators.py b/server/foxbank_server/decorators.py index 1fa8502..05565b6 100644 --- a/server/foxbank_server/decorators.py +++ b/server/foxbank_server/decorators.py @@ -49,13 +49,13 @@ class Module(ModuleType): def wrapper(*args, **kargs): token = request.headers.get('Authorization', None) if token is None: - return returns.NO_AUTHORIZATION + return returns.abort(returns.NO_AUTHORIZATION) if not token.startswith('Bearer '): - return returns.INVALID_AUTHORIZATION + return returns.abort(returns.INVALID_AUTHORIZATION) token = token[7:] user_id = ram_db.get_user(token) if user_id is None: - return returns.INVALID_AUTHORIZATION + return returns.abort(returns.INVALID_AUTHORIZATION) global _token _token = token @@ -71,40 +71,4 @@ class Module(ModuleType): return wrapper - # def ensure_logged_in(token=False, user_id=False): - # """ - # Ensure the user is logged in by providing an Authorization: Bearer token - # header. - # - # @param token whether the token should be supplied after validation - # @param user_id whether the user_id should be supplied after validation - # @return decorator which supplies the requested parameters - # """ - # def decorator(fn): - # pass_token = token - # pass_user_id = user_id - # - # @wraps(fn) - # def wrapper(*args, **kargs): - # token = request.headers.get('Authorization', None) - # if token is None: - # return returns.NO_AUTHORIZATION - # if not token.startswith('Bearer '): - # return returns.INVALID_AUTHORIZATION - # token = token[7:] - # user_id = ram_db.get_user(token) - # if user_id is None: - # return returns.INVALID_AUTHORIZATION - # - # if pass_user_id and pass_token: - # return fn(user_id=user_id, token=token, *args, **kargs) - # elif pass_user_id: - # return fn(user_id=user_id, *args, **kargs) - # elif pass_token: - # return fn(token=token, *args, **kargs) - # else: - # return fn(*args, **kargs) - # return wrapper - # return decorator - sys.modules[__name__] = Module(__name__) diff --git a/server/foxbank_server/returns.py b/server/foxbank_server/returns.py index cf7e1c5..f78f852 100644 --- a/server/foxbank_server/returns.py +++ b/server/foxbank_server/returns.py @@ -31,6 +31,13 @@ NOT_FOUND = _make_error( 'general/not_found', ) +def invalid_argument(argname: str) -> tuple[Any, int]: + return _make_error( + _HTTPStatus.UNPROCESSABLE_ENTITY, + 'general/invalid_argument', + message=f'Invalid argument: {argname}', + ) + # Login INVALID_DETAILS = _make_error( @@ -76,3 +83,12 @@ class ErrorSchema(Schema): class SuccessSchema(Schema): status = fields.Constant('success') + +# smorest + +def abort(result: tuple[Any, int]): + try: + from flask_smorest import abort as _abort + _abort(result[1], response=result) + except ImportError: + return result diff --git a/server/login.py b/server/login.py deleted file mode 100644 index d462351..0000000 --- a/server/login.py +++ /dev/null @@ -1,47 +0,0 @@ -from functools import wraps -from flask import Blueprint, request - -from pyotp import TOTP - -import db_utils -from decorators import no_content, ensure_logged_in, user_id, token -import models -import ram_db -import returns - -login = Blueprint('login', __name__) - -@login.post('/') -def make_login(): - try: - username = request.json['username'] - code = request.json['code'] - except (TypeError, KeyError): - return returns.INVALID_REQUEST - - user: models.User | None = db_utils.get_user(username=username) - if user is None: - return returns.INVALID_DETAILS - - otp = TOTP(user.otp) - if not otp.verify(code, valid_window=1): - return returns.INVALID_DETAILS - - token = ram_db.login_user(user.id) - return returns.success(token=token) - -@login.post('/logout') -@ensure_logged_in -@no_content -def logout(): - ram_db.logout_user(token) - -@login.get('/whoami') -@ensure_logged_in -def whoami(): - user: models.User | None = db_utils.get_user(user_id=user_id) - if user is not None: - user = user.to_json() - - return returns.successs(user=user) - From 17d1cb240052b3e1cd38a8dc6fe02812f2fefc75 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 20:23:28 +0200 Subject: [PATCH 7/9] Added documentation for accounts endpoints --- server/foxbank_server/apis/accounts.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 186c54d..05b6f61 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -24,12 +24,14 @@ class MetaAccountTypesSchema(Schema): @bp.get('/meta/currencies') @bp.response(200, MetaCurrenciesSchema) def get_valid_currencies(): + """Get valid account currencies""" return returns.success(currencies=VALID_CURRENCIES) @bp.get('/meta/account_types') @bp.response(200, MetaAccountTypesSchema) def get_valid_account_types(): + """Get valid account types""" return returns.success(account_types=ACCOUNT_TYPES) @@ -38,6 +40,7 @@ def get_valid_account_types(): @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) def get_account_id(account_id: int): + """Get account by id""" account = db_utils.get_account(account_id=account_id) if account is None: return returns.abort(returns.NOT_FOUND) @@ -52,6 +55,7 @@ def get_account_id(account_id: int): @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) def get_account_iban(iban: str): + """Get account by IBAN""" account = db_utils.get_account(iban=iban) if account is None: return returns.abort(returns.NOT_FOUND) From 02cf164620509fed4b8d471dd19d11975d01b0db Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Wed, 29 Dec 2021 20:36:05 +0200 Subject: [PATCH 8/9] Fixed /accounts/... response --- server/foxbank_server/apis/accounts.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/foxbank_server/apis/accounts.py b/server/foxbank_server/apis/accounts.py index 05b6f61..9b4017e 100644 --- a/server/foxbank_server/apis/accounts.py +++ b/server/foxbank_server/apis/accounts.py @@ -35,10 +35,15 @@ def get_valid_account_types(): return returns.success(account_types=ACCOUNT_TYPES) +class AccountResponseSchema(returns.SuccessSchema): + account = fields.Nested(Account.Schema) + + @bp.get('/') @ensure_logged_in @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) +@bp.response(200, AccountResponseSchema) def get_account_id(account_id: int): """Get account by id""" account = db_utils.get_account(account_id=account_id) @@ -54,6 +59,7 @@ def get_account_id(account_id: int): @ensure_logged_in @bp.response(401, returns.ErrorSchema, description='Login failure') @bp.doc(security=[{'Token': []}]) +@bp.response(200, AccountResponseSchema) def get_account_iban(iban: str): """Get account by IBAN""" account = db_utils.get_account(iban=iban) From 18fe6e9355805d8275b8690995731582ac8970e4 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Thu, 30 Dec 2021 00:55:37 +0200 Subject: [PATCH 9/9] Fixed IBAN generation --- server/foxbank_server/__init__.py | 3 +-- server/foxbank_server/db_utils.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/server/foxbank_server/__init__.py b/server/foxbank_server/__init__.py index 0de7b4a..5b38cd7 100644 --- a/server/foxbank_server/__init__.py +++ b/server/foxbank_server/__init__.py @@ -26,8 +26,7 @@ def create_app(): def init_cors(app): from flask_cors import CORS - cors = CORS() - cors.init_app(app) + cors = CORS(app) def init_db(app): from .db import init_app diff --git a/server/foxbank_server/db_utils.py b/server/foxbank_server/db_utils.py index 338c6d2..3170413 100644 --- a/server/foxbank_server/db_utils.py +++ b/server/foxbank_server/db_utils.py @@ -132,7 +132,7 @@ class Module(ModuleType): from random import randint while True: iban = 'RO00FOXB0' + account.currency - iban += str(randint(0, 10 ** 13)).rjust(12, '0') + iban += str(randint(10, 10 ** 12 - 1)).rjust(12, '0') from .utils.iban import gen_check_digits iban = gen_check_digits(iban) if iban not in ibans: