22 changed files with 465 additions and 4 deletions
@ -0,0 +1,27 @@ |
|||||||
|
name: Build Docker image |
||||||
|
on: |
||||||
|
push: |
||||||
|
branches: |
||||||
|
- master |
||||||
|
- Backend |
||||||
|
jobs: |
||||||
|
push_to_ghcr: |
||||||
|
name: Push to ghcr.io |
||||||
|
runs-on: ubuntu-latest |
||||||
|
steps: |
||||||
|
- name: Checkout repo |
||||||
|
uses: actions/checkout@v2 |
||||||
|
- name: Login to GitHub Container Registry |
||||||
|
uses: docker/login-action@v1 |
||||||
|
with: |
||||||
|
registry: ghcr.io |
||||||
|
username: ${{ github.actor }} |
||||||
|
password: ${{ secrets.GITHUB_TOKEN }} |
||||||
|
- name: Publish |
||||||
|
env: |
||||||
|
tags: ${{ format('{0},{1}', format('ghcr.io/{0}:latest', github.repository), format('ghcr.io/{0}:{1}', github.repository, github.ref_name)) }} |
||||||
|
uses: docker/build-push-action@v2 |
||||||
|
with: |
||||||
|
context: ./server |
||||||
|
tags: ${{ env.tags }} |
||||||
|
push: true |
@ -0,0 +1,6 @@ |
|||||||
|
{ |
||||||
|
"files.exclude": { |
||||||
|
"client": true, |
||||||
|
"server": true |
||||||
|
} |
||||||
|
} |
@ -1,5 +1,3 @@ |
|||||||
{ |
{ |
||||||
"recommendations": [ |
"recommendations": [] |
||||||
"ms-python.python" |
|
||||||
] |
|
||||||
} |
} |
@ -0,0 +1,23 @@ |
|||||||
|
{ |
||||||
|
// Use IntelliSense to learn about possible attributes. |
||||||
|
// Hover to view descriptions of existing attributes. |
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 |
||||||
|
"version": "0.2.0", |
||||||
|
"configurations": [ |
||||||
|
{ |
||||||
|
"name": "Python: Flask", |
||||||
|
"type": "python", |
||||||
|
"request": "launch", |
||||||
|
"module": "flask", |
||||||
|
"env": { |
||||||
|
"FLASK_APP": "server.py", |
||||||
|
"FLASK_ENV": "development" |
||||||
|
}, |
||||||
|
"args": [ |
||||||
|
"run", |
||||||
|
"--no-debugger" |
||||||
|
], |
||||||
|
"jinja": true |
||||||
|
} |
||||||
|
] |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
{ |
||||||
|
"python.pythonPath": "/home/kbruen/.local/share/virtualenvs/server-4otskhbj/bin/python" |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
FROM python:3.10-slim |
||||||
|
|
||||||
|
RUN pip3 install pipenv |
||||||
|
|
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
COPY Pipfile Pipfile.lock ./ |
||||||
|
RUN pipenv install |
||||||
|
|
||||||
|
COPY . . |
||||||
|
|
||||||
|
EXPOSE 5000 |
||||||
|
CMD [ "pipenv", "run", "gunicorn", "-b", "0.0.0.0:5000", "--access-logfile", "-", "server:app" ] |
@ -0,0 +1,38 @@ |
|||||||
|
import sqlite3 |
||||||
|
|
||||||
|
from flask import current_app, g |
||||||
|
|
||||||
|
DB_FILE = './data/db.sqlite' |
||||||
|
|
||||||
|
get_return = sqlite3.Connection |
||||||
|
|
||||||
|
def get() -> get_return: |
||||||
|
if 'db' not in g: |
||||||
|
g.db = sqlite3.connect( |
||||||
|
DB_FILE, |
||||||
|
detect_types=sqlite3.PARSE_DECLTYPES, |
||||||
|
) |
||||||
|
g.db.row_factory = sqlite3.Row |
||||||
|
|
||||||
|
return g.db |
||||||
|
|
||||||
|
def close(e=None): |
||||||
|
db = g.pop('db', None) |
||||||
|
|
||||||
|
if db: |
||||||
|
db.close() |
||||||
|
|
||||||
|
def init(): |
||||||
|
db = get() |
||||||
|
|
||||||
|
with current_app.open_resource('init.sql') as f: |
||||||
|
db.executescript(f.read().decode('utf8')) |
||||||
|
db.commit() |
||||||
|
|
||||||
|
def init_app(app): |
||||||
|
app.teardown_appcontext(close) |
||||||
|
|
||||||
|
import os.path |
||||||
|
if not os.path.exists(DB_FILE): |
||||||
|
with app.app_context(): |
||||||
|
init() |
@ -0,0 +1,24 @@ |
|||||||
|
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) |
@ -0,0 +1,12 @@ |
|||||||
|
from http import HTTPStatus |
||||||
|
from functools import wraps |
||||||
|
|
||||||
|
def no_content(fn): |
||||||
|
@wraps(fn) |
||||||
|
def wrapper(*args, **kargs): |
||||||
|
result = fn(*args, **kargs) |
||||||
|
if result is None: |
||||||
|
return None, HTTPStatus.NO_CONTENT |
||||||
|
else: |
||||||
|
return result |
||||||
|
return wrapper |
@ -0,0 +1,9 @@ |
|||||||
|
version: '3.9' |
||||||
|
services: |
||||||
|
web: |
||||||
|
build: . |
||||||
|
image: foxbank-server |
||||||
|
ports: |
||||||
|
- ${PORT:-5000}:5000 |
||||||
|
volumes: |
||||||
|
- ./data:/app/data |
@ -0,0 +1,61 @@ |
|||||||
|
drop table if exists users; |
||||||
|
drop table if exists accounts; |
||||||
|
drop table if exists users_accounts; |
||||||
|
drop table if exists transactions; |
||||||
|
drop table if exists accounts_transactions; |
||||||
|
drop table if exists notifications; |
||||||
|
drop table if exists users_notifications; |
||||||
|
|
||||||
|
create table users ( |
||||||
|
id integer primary key autoincrement, |
||||||
|
username text unique not null, |
||||||
|
email text unique not null, |
||||||
|
otp text not null, |
||||||
|
fullname text not null |
||||||
|
); |
||||||
|
|
||||||
|
create table accounts ( |
||||||
|
id integer primary key autoincrement, |
||||||
|
iban text unique not null, -- RO16 FOXB 0000 0000 0000 0000 |
||||||
|
currency text not null, -- EUR, RON, USD, ? |
||||||
|
account_type text not null, -- checking, savings, ? |
||||||
|
custom_name text -- 'Car Savings'; name set by user |
||||||
|
); |
||||||
|
|
||||||
|
create table users_accounts ( |
||||||
|
user_id integer not null, -- one user can have multiple accounts |
||||||
|
account_id integer UNIQUE not null, -- one account can only have one user |
||||||
|
foreign key (user_id) references users (id), |
||||||
|
foreign key (account_id) references accounts (id) |
||||||
|
); |
||||||
|
|
||||||
|
create table transactions ( |
||||||
|
id integer primary key autoincrement, |
||||||
|
datetime text not null, |
||||||
|
other_party text not null, -- JSON data describing sender/recipient/etc |
||||||
|
-- depending on transaction type |
||||||
|
status text not null, -- processed, failed, reverted, pending, etc |
||||||
|
type text not null, -- send_transfer, receive_transfer, card_payment, fee, ... |
||||||
|
extra text -- depending on type, JSON data describing extra info |
||||||
|
); |
||||||
|
|
||||||
|
create table accounts_transactions ( |
||||||
|
account_id integer not null, |
||||||
|
transaction_id integer UNIQUE not null, |
||||||
|
foreign key (account_id) references accounts (id), |
||||||
|
foreign key (transaction_id) references transactions (id) |
||||||
|
); |
||||||
|
|
||||||
|
create table notifications ( |
||||||
|
id integer primary key autoincrement, |
||||||
|
body text not null, |
||||||
|
datetime text not null, |
||||||
|
read integer not null |
||||||
|
); |
||||||
|
|
||||||
|
create table users_notifications ( |
||||||
|
user_id integer not null, |
||||||
|
notification_id integer UNIQUE not null, |
||||||
|
foreign key (user_id) references users (id), |
||||||
|
foreign key (notification_id) references notifications (id) |
||||||
|
); |
@ -0,0 +1,73 @@ |
|||||||
|
from functools import wraps |
||||||
|
from flask import Blueprint, request |
||||||
|
|
||||||
|
from pyotp import TOTP |
||||||
|
|
||||||
|
import db_utils |
||||||
|
from decorators import no_content |
||||||
|
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) |
||||||
|
|
||||||
|
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 |
||||||
|
def logout(token: str): |
||||||
|
ram_db.logout_user(token) |
||||||
|
|
||||||
|
@login.get('/whoami') |
||||||
|
@ensure_logged_in(user_id=True) |
||||||
|
def whoami(user_id: int): |
||||||
|
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) |
@ -0,0 +1,25 @@ |
|||||||
|
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) |
@ -0,0 +1,36 @@ |
|||||||
|
from datetime import datetime, timedelta |
||||||
|
from types import TracebackType |
||||||
|
from uuid import uuid4 |
||||||
|
|
||||||
|
USED_TOKENS = set() |
||||||
|
LOGGED_IN_USERS: dict[str, (int, datetime)] = {} |
||||||
|
|
||||||
|
def login_user(user_id: int) -> str: |
||||||
|
''' |
||||||
|
Creates token for user |
||||||
|
''' |
||||||
|
token = str(uuid4()) |
||||||
|
while token in USED_TOKENS: |
||||||
|
token = str(uuid4()) |
||||||
|
if len(USED_TOKENS) > 10_000_000: |
||||||
|
USED_TOKENS.clear() |
||||||
|
for token in LOGGED_IN_USERS: |
||||||
|
USED_TOKENS.add(token) |
||||||
|
USED_TOKENS.add(token) |
||||||
|
LOGGED_IN_USERS[token] = user_id, datetime.now() |
||||||
|
return token |
||||||
|
|
||||||
|
def logout_user(token: str): |
||||||
|
if token in LOGGED_IN_USERS: |
||||||
|
del LOGGED_IN_USERS[token] |
||||||
|
|
||||||
|
def get_user(token: str) -> int | None: |
||||||
|
if token not in LOGGED_IN_USERS: |
||||||
|
return None |
||||||
|
|
||||||
|
user_id, login_date = LOGGED_IN_USERS[token] |
||||||
|
time_since_login: timedelta = datetime.now() - login_date |
||||||
|
if time_since_login.total_seconds() > (60 * 30): # 30 mins |
||||||
|
del LOGGED_IN_USERS[token] |
||||||
|
return None |
||||||
|
return user_id |
@ -0,0 +1,46 @@ |
|||||||
|
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 |
@ -0,0 +1,3 @@ |
|||||||
|
#! /usr/bin/env sh |
||||||
|
docker-compose stop |
||||||
|
PORT=14000 docker-compose up -d --build |
Loading…
Reference in new issue