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
This commit is contained in:
Luna Mendes 2018-06-17 04:09:29 -03:00
parent 6b066bba05
commit f7f387dcf0
9 changed files with 285 additions and 97 deletions

View File

@ -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"

140
Pipfile.lock generated
View File

@ -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"
}
},

0
litecord/__init__.py Normal file
View File

View File

@ -1 +1,2 @@
from .gateway import bp as gateway
from .auth import bp as auth

View File

@ -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)
})

10
litecord/errors.py Normal file
View File

@ -0,0 +1,10 @@
class LitecordError(Exception):
status_code = 500
@property
def message(self):
return self.args[0]
class AuthError(LitecordError):
status_code = 403

95
litecord/snowflake.py Normal file
View File

@ -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))

37
run.py
View File

@ -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'

View File

@ -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)
);