From f7f387dcf00bfc05f7a7d5734c7461de2ff01b00 Mon Sep 17 00:00:00 2001 From: Luna Mendes Date: Sun, 17 Jun 2018 04:09:29 -0300 Subject: [PATCH] litecord.blueprints: add auth blueprint - Pipfile: add bcrypt and itsdangerous - litecord: add errors module - litecord: add snowflake module - run: add error handlers - schema: add UNIQUE constraint to users.email - remove users.password_salt --- Pipfile | 2 + Pipfile.lock | 140 ++++++++++++-------------------- litecord/__init__.py | 0 litecord/blueprints/__init__.py | 1 + litecord/blueprints/auth.py | 92 +++++++++++++++++++++ litecord/errors.py | 10 +++ litecord/snowflake.py | 95 ++++++++++++++++++++++ run.py | 37 +++++++-- schema.sql | 5 +- 9 files changed, 285 insertions(+), 97 deletions(-) create mode 100644 litecord/__init__.py create mode 100644 litecord/blueprints/auth.py create mode 100644 litecord/errors.py create mode 100644 litecord/snowflake.py diff --git a/Pipfile b/Pipfile index 60aa02f..cb632ed 100644 --- a/Pipfile +++ b/Pipfile @@ -4,6 +4,8 @@ verify_ssl = true name = "pypi" [packages] +bcrypt = "==3.1.4" +itsdangerous = "==0.24" asyncpg = "==0.16.0" Quart = "==0.6.0" diff --git a/Pipfile.lock b/Pipfile.lock index a26446e..94d965d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,20 @@ { "_meta": { "hash": { - "sha256": "027981db9b70eaab2e7c72ffc69501db32404f00676eb0229c2b0370d3b50ed8" + "sha256": "e8807281b43032a3fde4598f48c98436b8ef50fe332a413e71c37062a16b61d9" + }, + "host-environment-markers": { + "implementation_name": "cpython", + "implementation_version": "3.6.5", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_python_implementation": "CPython", + "platform_release": "4.16.13-2-ARCH", + "platform_system": "Linux", + "platform_version": "#1 SMP PREEMPT Fri Jun 1 18:46:11 UTC 2018", + "python_full_version": "3.6.5", + "python_version": "3.6", + "sys_platform": "linux" }, "pipfile-spec": 6, "requires": { @@ -17,142 +30,91 @@ }, "default": { "aiofiles": { - "hashes": [ - "sha256:25c66ea3872d05d53292a6b3f7fa0f86691512076446d83a505d227b5e76f668", - "sha256:852a493a877b73e11823bfd4e8e5ef2610d70d12c9eaed961bcd9124d8de8c10" - ], + "hashes": [], "version": "==0.3.2" }, "asyncpg": { - "hashes": [ - "sha256:166c8e094de78ccbfc598a5342037a6ca5d7ee1e8144b3cfade244dd591b1ed0", - "sha256:2913b7cffdfb5bf1da5ed751485b559d1f1990be005d6d63d3ca0bf09a9d8ee6", - "sha256:31d5a9d993ce97924d9601bf6a37bb8b542d63bc8716b36238511e5e5915b14c", - "sha256:440dc17ec98c2e69f58947a591eed5967724794c876b1d6e53950e9b0b561788", - "sha256:5791554375c71ef339ee01fafb931f593c9f3ec85a5db9753c185199cee6c87e", - "sha256:600e6e14078be26e2322dfded808af55248301633592a27337b192ca2137bf04", - "sha256:cca8de381ffca375dd7cbf13f918dd68ca493e9082e7fe3f5827c08e3cfd2432", - "sha256:d201b4851a39c1f2303d99f4199974d8e01d48cec7512b59e532979ba6277def", - "sha256:d70fee2708e538a7333bca94170da8ab9233e5eae136143e2275f7b2d9bb4c24", - "sha256:e0387c4a584394997335375e897b9d63a7a31a1c77482d8b94f9a1be77bcfd08", - "sha256:e6755dd3318c0b170d4727db0e310c26e569faa101c7506b6a3e041f16ef8df9" - ], - "index": "pypi", + "hashes": [], "version": "==0.16.0" }, + "bcrypt": { + "hashes": [], + "version": "==3.1.4" + }, "blinker": { - "hashes": [ - "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6" - ], + "hashes": [], "version": "==1.4" }, + "cffi": { + "hashes": [], + "version": "==1.11.5" + }, "click": { - "hashes": [ - "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", - "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" - ], + "hashes": [], "version": "==6.7" }, "h11": { - "hashes": [ - "sha256:1c0fbb1cba6f809fe3e6b27f8f6d517ca171f848922708871403636143d530d9", - "sha256:af77d5d82fa027c032650fb8afdef3cd0a3735ba01480bee908cddad9be1bdce" - ], + "hashes": [], "version": "==0.7.0" }, "h2": { - "hashes": [ - "sha256:4be613e35caad5680dc48f98f3bf4e7338c7c429e6375a5137be7fbe45219981", - "sha256:b2962f883fa392a23cbfcc4ad03c335bcc661be0cf9627657b589f0df2206e64" - ], + "hashes": [], "version": "==3.0.1" }, "hpack": { - "hashes": [ - "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", - "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2" - ], + "hashes": [], "version": "==3.0.0" }, "hypercorn": { - "hashes": [ - "sha256:4c7cbc92e134d913d23815155fc190c8f52425fc2e0ce8131d192c310a43b91e", - "sha256:5a56a2e56f157516ea95fa589ee72d996894efba0530b411752d16e461af62e0" - ], + "hashes": [], "version": "==0.1.0" }, "hyperframe": { - "hashes": [ - "sha256:87567c9eb1540de1e7f48805adf00e87856409342fdebd0cd20cf5d381c38b69", - "sha256:a25944539db36d6a2e47689e7915dcee562b3f8d10c6cdfa0d53c91ed692fb04" - ], + "hashes": [], "version": "==5.1.0" }, "itsdangerous": { - "hashes": [ - "sha256:cbb3fcf8d3e33df861709ecaf89d9e6629cff0a217bc2848f1b41cd30d360519" - ], + "hashes": [], "version": "==0.24" }, "jinja2": { - "hashes": [ - "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", - "sha256:f84be1bb0040caca4cea721fcbbbbd61f9be9464ca236387158b0feea01914a4" - ], + "hashes": [], "version": "==2.10" }, "markupsafe": { - "hashes": [ - "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" - ], + "hashes": [], "version": "==1.0" }, "multidict": { - "hashes": [ - "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0", - "sha256:1d6e191965505652f194bc4c40270a842922685918a4f45e6936a6b15cc5816d", - "sha256:295961a6a88f1199e19968e15d9b42f3a191c89ec13034dbc212bf9c394c3c82", - "sha256:2be5af084de6c3b8e20d6421cb0346378a9c867dcf7c86030d6b0b550f9888e4", - "sha256:2eb99617c7a0e9f2b90b64bc1fb742611718618572747d6f3d6532b7b78755ab", - "sha256:4ba654c6b5ad1ae4a4d792abeb695b29ce981bb0f157a41d0fd227b385f2bef0", - "sha256:5ba766433c30d703f6b2c17eb0b6826c6f898e5f58d89373e235f07764952314", - "sha256:a59d58ee85b11f337b54933e8d758b2356fcdcc493248e004c9c5e5d11eedbe4", - "sha256:a6e35d28900cf87bcc11e6ca9e474db0099b78f0be0a41d95bef02d49101b5b2", - "sha256:b4df7ca9c01018a51e43937eaa41f2f5dce17a6382fda0086403bcb1f5c2cf8e", - "sha256:bbd5a6bffd3ba8bfe75b16b5e28af15265538e8be011b0b9fddc7d86a453fd4a", - "sha256:d870f399fcd58a1889e93008762a3b9a27cf7ea512818fc6e689f59495648355", - "sha256:e9404e2e19e901121c3c5c6cffd5a8ae0d1d67919c970e3b3262231175713068" - ], + "hashes": [], "version": "==4.3.1" }, + "pycparser": { + "hashes": [], + "version": "==2.18" + }, "quart": { - "hashes": [ - "sha256:a5f64f15ffa5e063c07ad3675c7fe82d3945a4d48c4e3f8d0bda0494c4964a1d", - "sha256:d6da4f0e99403918874ad9e7124f13f3c0a1bb4da10021c56a89433527909d52" - ], - "index": "pypi", + "hashes": [], "version": "==0.6.0" }, + "six": { + "hashes": [], + "version": "==1.11.0" + }, "sortedcontainers": { - "hashes": [ - "sha256:607294c6e291a270948420f7ffa1fb3ed47384a4c08db6d1e9c92d08a6981982", - "sha256:ef38b128302ee8f65d81e31c9d8fbf10d81df4d6d06c9c0b66f01d33747525bb" - ], + "hashes": [], "version": "==2.0.4" }, + "typing": { + "hashes": [], + "version": "==3.6.4" + }, "typing-extensions": { - "hashes": [ - "sha256:1c0a8e3b4ce55207a03dd0dcb98bc47a704c71f14fe4311ec860cc8af8f4bd27", - "sha256:8b0962ecb92847974514b1724c8ae2b6dd1ffe86bcdfac429517f5e583ada658", - "sha256:be7b05ddab71727fabf1f071365043cf034e4cdac9cade1f1d61a6cc526aaafe" - ], + "hashes": [], "version": "==3.6.5" }, "wsproto": { - "hashes": [ - "sha256:02f214f6bb43cda62a511e2e8f1d5fa4703ed83d376d18d042bd2bbf2e995824", - "sha256:d2a7f718ab3144ec956a3267d57b5c172f0668827f5803e7d670837b0125b9fa" - ], + "hashes": [], "version": "==0.11.0" } }, diff --git a/litecord/__init__.py b/litecord/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/litecord/blueprints/__init__.py b/litecord/blueprints/__init__.py index 6114c6c..9099edc 100644 --- a/litecord/blueprints/__init__.py +++ b/litecord/blueprints/__init__.py @@ -1 +1,2 @@ from .gateway import bp as gateway +from .auth import bp as auth diff --git a/litecord/blueprints/auth.py b/litecord/blueprints/auth.py new file mode 100644 index 0000000..992a8ec --- /dev/null +++ b/litecord/blueprints/auth.py @@ -0,0 +1,92 @@ +import random +import base64 + +import asyncpg +import itsdangerous +import bcrypt +from quart import Blueprint, jsonify, request, current_app as app + +from ..snowflake import get_snowflake +from ..errors import AuthError + + +bp = Blueprint('auth', __name__) + + +async def hash_data(data: str) -> str: + """Hash information with bcrypt.""" + data = bytes(data, 'utf-8') + + future = app.loop.run_in_executor( + None, bcrypt.hashpw, data, bcrypt.gensalt(14)) + + hashed = await future + return hashed.decode('utf-8') + + +async def check_password(pwd_hash, given_password) -> bool: + """Check if a given password matches the given hash.""" + pwd_hash = pwd_hash.encode('utf-8') + given_password = given_password.encode('utf-8') + + future = app.loop.run_in_executor( + None, bcrypt.checkpw, pwd_hash, given_password) + + return await future + + +def make_token(user_id, user_pwd_hash) -> str: + """Generate a single token for a user.""" + signer = itsdangerous.Signer(user_pwd_hash) + user_id = base64.b64encode(str(user_id).encode('utf-8')) + + return signer.sign(user_id).decode('utf-8') + + +@bp.route('/register', methods=['POST']) +async def register(): + """Register a user on litecord""" + j = await request.get_json() + email, password, username = j['email'], j['password'], j['username'] + + new_id = get_snowflake() + new_discrim = str(random.randint(1, 9999)) + pwd_hash = await hash_data(password) + + try: + await app.db.execute(""" + INSERT INTO users (id, email, username, + discriminator, password_hash) + VALUES ($1, $2, $3, $4, $5) + """, new_id, email, username, new_discrim, pwd_hash) + except asyncpg.UniqueViolationError: + raise AuthError('Email already used.') + + return jsonify({ + 'token': make_token(new_id, pwd_hash) + }) + + +@bp.route('/login', methods=['POST']) +async def login(): + """Login one user into Litecord.""" + j = await request.get_json() + email, password = j['email'], j['password'] + + row = await app.db.fetchrow(""" + SELECT id, password_hash + FROM users + WHERE email = $1 + """, email) + + if not row: + return jsonify({'email': ['User not found.']}) + + user_id, pwd_hash = row + + if not await check_password(pwd_hash, password): + return jsonify({'password': ['Password does not match.']}) + + return jsonify({ + 'token': make_token(user_id, pwd_hash) + }) diff --git a/litecord/errors.py b/litecord/errors.py new file mode 100644 index 0000000..936d6ec --- /dev/null +++ b/litecord/errors.py @@ -0,0 +1,10 @@ +class LitecordError(Exception): + status_code = 500 + + @property + def message(self): + return self.args[0] + + +class AuthError(LitecordError): + status_code = 403 diff --git a/litecord/snowflake.py b/litecord/snowflake.py new file mode 100644 index 0000000..f1c473f --- /dev/null +++ b/litecord/snowflake.py @@ -0,0 +1,95 @@ +""" +snowflake.py - snowflake helper functions + + These functions generate discord-like snowflakes. + File brought in from + litecord-reference(https://github.com/lnmds/litecord-reference) +""" +import time +import hashlib +import os +import base64 + +# encoded in ms +EPOCH = 1420070400000 + +# internal state +_generated_ids = 0 +PROCESS_ID = 1 +WORKER_ID = 1 + +Snowflake = int + + +def get_invite_code() -> str: + """Get a random invite code.""" + random_stuff = hashlib.sha512(os.urandom(1024)).digest() + code = base64.urlsafe_b64encode(random_stuff).decode().replace('=', '5') \ + .replace('_', 'W').replace('-', 'm') + return code[:6] + + +def _snowflake(timestamp: int) -> Snowflake: + """Get a snowflake from a specific timestamp + + This function relies on modifying internal variables + to generate unique snowflakes. Because of that every call + to this function will generate a different snowflake, + even with the same timestamp. + + Arguments + --------- + timestamp: int + Timestamp to be feed in to the snowflake algorithm. + This timestamp has to be an UNIX timestamp + with millisecond precision. + """ + # Yes, using global variables aren't the best idea + # Maybe we could distribute the work of snowflake generation + # to actually separated servers? :thinking: + global _generated_ids + + # bits 0-12 encode _generated_ids (size 12) + genid_b = '{0:012b}'.format(_generated_ids) + + # bits 12-17 encode PROCESS_ID (size 5) + procid_b = '{0:05b}'.format(PROCESS_ID) + + # bits 17-22 encode WORKER_ID (size 5) + workid_b = '{0:05b}'.format(WORKER_ID) + + # bits 22-64 encode (timestamp - EPOCH) (size 42) + epochized = timestamp - EPOCH + epoch_b = '{0:042b}'.format(epochized) + + snowflake_b = f'{epoch_b}{workid_b}{procid_b}{genid_b}' + _generated_ids += 1 + + return int(snowflake_b, 2) + + +def snowflake_time(snowflake: Snowflake) -> float: + """Get the UNIX timestamp(with millisecond precision, as a float) + from a specific snowflake. + """ + + # the total size for a snowflake is 64 bits, + # considering it is a string, position 0 to 42 will give us + # the `epochized` variable + snowflake_b = '{0:064b}'.format(snowflake) + epochized_b = snowflake_b[:42] + epochized = int(epochized_b, 2) + + # since epochized is the time *since* the EPOCH + # the unix timestamp will be the time *plus* the EPOCH + timestamp = epochized + EPOCH + + # convert it to seconds + # since we don't want to break the entire + # snowflake interface + return timestamp / 1000 + + +def get_snowflake(): + """Generate a snowflake""" + return _snowflake(int(time.time() * 1000)) diff --git a/run.py b/run.py index d561487..0b4a1ca 100644 --- a/run.py +++ b/run.py @@ -1,10 +1,13 @@ import logging +import asyncio import asyncpg -from quart import Quart, g, Blueprint +from quart import Quart, g, jsonify import config -from litecord.blueprints import gateway + +from litecord.blueprints import gateway, auth +from litecord.errors import LitecordError logging.basicConfig(level=logging.INFO) log = logging.getLogger(__name__) @@ -18,20 +21,44 @@ def make_app(): app = make_app() app.register_blueprint(gateway, url_prefix='/api/v6') +app.register_blueprint(auth, url_prefix='/api/v6') @app.before_serving async def app_before_serving(): log.info('opening db') - app.db_pool = await asyncpg.create_pool(**app.config['POSTGRES']) + app.db = await asyncpg.create_pool(**app.config['POSTGRES']) + g.app = app + + app.loop = asyncio.get_event_loop() + g.loop = asyncio.get_event_loop() @app.after_serving async def app_after_serving(): log.info('closing db') - await app.db_pool.close() + await app.db.close() + + +@app.errorhandler(LitecordError) +async def handle_litecord_err(err): + return jsonify({ + 'error': True, + # 'code': err.code, + 'status': err.status_code, + 'message': err.message, + }), err.status_code + + +@app.errorhandler(Exception) +def handle_exception(err): + log.exception('Error happened in the app') + return jsonify({ + 'error': True, + 'message': repr(err) + }), 500 @app.route('/') async def index(): - return 'hai' + return 'hewwo' diff --git a/schema.sql b/schema.sql index 0b89b1d..dfa9a9a 100644 --- a/schema.sql +++ b/schema.sql @@ -57,13 +57,13 @@ CREATE TABLE IF NOT EXISTS users ( id bigint UNIQUE NOT NULL, username varchar(32) NOT NULL, discriminator varchar(4) NOT NULL, - avatar bigint REFERENCES files (id), + email varchar(255) NOT NULL UNIQUE, -- user properties bot boolean DEFAULT FALSE, mfa_enabled boolean DEFAULT FALSE, verified boolean DEFAULT FALSE, - email varchar(255) NOT NULL, + avatar bigint REFERENCES files (id) DEFAULT NULL, -- user badges, discord dev, etc flags int DEFAULT 0, @@ -74,7 +74,6 @@ CREATE TABLE IF NOT EXISTS users ( -- private info phone varchar(60) DEFAULT '', password_hash text NOT NULL, - password_salt text NOT NULL, PRIMARY KEY (id, username, discriminator) );