Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,11 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/


# poetry stuff, which isn't really what's being used in this project
poetry.lock
pyproject.toml

# the right way to store secrets
/config.py
54 changes: 43 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,70 @@
# Backend Infrastructure for the IITM BSc Discord
# Backend Infrastructure for the IITM B.Sc. Discord

## Setup

### 1. Discord OAuth Setup
1. Navigate to the [discord developer portal](https://discord.com/developers/applications) and create a new app
2. On the sidebar navigate to OAuth2 > General
3. Add `http://localhost:8081/discord/auth/login/callback` as the redirect URL
4. In the Default Authorization Link section, select custom URL from the drop down and add the same URL as above
5. It should look like this
![image](https://github.com/IITM-BS-Codebase/iitm-backend/assets/42805453/b735a18d-e9d0-4cbd-9352-4eca3f5ddc6e)
6. On the sidebar navigate to OAuth2 > URL generator
7. Pick appropriate scopes, in this case `identify` and `guilds` and choose the previously added URL as the redirect
8. Copy the generated Redirect URL

### 2. Google OAuth Setup
1. Navigate to the [Google developer console](https://console.developers.google.com)
2. Create a new project by going to Select a project > NEW PROJECT on the top left
3. To generate credentials, on the new page that appears, navigate to Credentials and
click on `+ CREATE CREDENTIALS` on the top and select OAuth Client ID
4. Follow the prompts and answer the questions.
5. Add `http://localhost:8081` to authorized JavaScript origins and
`http://localhost:8081/google/auth/login/callback` as an authorized redirect URI

### 2. Configuration Variables
1. Create a new file named `.env` and add the following
```env
#DISCORD OAUTH DETAILS
DISCORD_CLIENT_ID=PASTE CLIENT ID
DISCORD_CLIENT_SECRET="PASTE CLIENT SECRET"
DISCORD_OAUTH_REDIRECT="http://localhost:8081/discord/auth/login/callback"
DISCORD_OAUTH_URL="PASTE THE LONG GENERATED REDIRECT URL"

#GOOGLE OAUTH DETAILS
GOOGLE_CLIENT_ID=PASTE CLIENT ID
GOOGLE_CLIENT_SECRET=PASTE CLIENT SECRET

FRONTEND_URL="http://localhost:8080/"

#DATABASE
DB_CONNECTION_STRING="postgresql+psycopg2://user:password@hostname/database_name"

#SECURITY
SECURITY_KEY="something-secret"
JWT_SECRET_KEY="something-secret-but-for-jwt"
PASETO_PRIVATE_KEY="hex-of-private-key-bytes" # just run `scripts/generate_keys.py` to get this for first time setup.
```

### Database setup

We use [PostgreSQL](https://www.postgresql.org), so you can install it locally on your
own computer, or opt for a hosted option, whichever is convenient. The format of the
connection string remains the same.

If you use Docker, you can use provided `docker-compose.yml` to spin up a server
quickly.

> :warning: If you don't have Postgres or the Postgres client libraries installed on
> your machine, `psycopg2` will fail to install. To work around this, either install the
> required libraries for your system, or replace that package with `psycopg2-binary` of
> the same version.


## Running the backend

At least Python 3.10 is required.

### Virtual Environments

It is recommended to use a virtual environment to run this app, or any of your python
projects.

1. Create a virtual environment: `python3 -m venv .venv`
2. Activate the environment
- Unix: `source ./.venv/bin/activate`
- Windows: `.\.venv\Scripts\activate`

#### Make sure you set `debug=False` in `main.py` when running in prod

- Install the requirements by running `pip install -r requirements.txt`
Expand Down
16 changes: 16 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: '3'
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_HOST_AUTH_METHOD: trust # for demo purposes only, don't use in production unless your network is secured.
POSTGRES_DB: backend
ports:
- 5432:5432
volumes:
- postgres-data:/var/lib/postgresql/data

volumes:
postgres-data:
driver: local
3 changes: 1 addition & 2 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import logging
from src import create_app, setup_auth, setup_routes
from src import create_app, setup_routes

logger = logging.basicConfig()

app, api = create_app()
setup_auth(app)
setup_routes(app)

if __name__ == '__main__':
Expand Down
62 changes: 32 additions & 30 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
aniso8601==9.0.1
bcrypt==4.0.1
blinker==1.6.2
certifi==2023.5.7
charset-normalizer==3.1.0
click==8.1.3
configparser==5.3.0
Flask==2.3.2
Flask-Bcrypt==1.0.1
Flask-Cors==3.0.10
Flask-JWT-Extended==4.5.2
Flask-Login==0.6.2
Flask-RESTful==0.3.10
Flask-SQLAlchemy==3.0.3
greenlet==2.0.2
idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.2
mysqlclient==2.1.1
psycopg2==2.9.6
PyJWT==2.7.0
python-dotenv==1.0.0
pytz==2023.3
requests==2.31.0
six==1.16.0
SQLAlchemy==2.0.15
typing_extensions==4.6.3
urllib3==2.0.2
Werkzeug==2.3.4
--extra-index-url https://5ht2.me/pip

aniso8601==9.0.1 ; python_version >= "3.10" and python_version < "4.0"
blinker==1.6.2 ; python_version >= "3.10" and python_version < "4.0"
certifi==2023.5.7 ; python_version >= "3.10" and python_version < "4.0"
cffi==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.1.0 ; python_version >= "3.10" and python_version < "4.0"
click==8.1.3 ; python_version >= "3.10" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
cryptography==41.0.1 ; python_version >= "3.10" and python_version < "4.0"
flask-cors==3.0.10 ; python_version >= "3.10" and python_version < "4.0"
flask-login==0.6.2 ; python_version >= "3.10" and python_version < "4.0"
flask-restful==0.3.10 ; python_version >= "3.10" and python_version < "4.0"
flask-sqlalchemy==3.0.3 ; python_version >= "3.10" and python_version < "4.0"
flask==2.3.2 ; python_version >= "3.10" and python_version < "4.0"
greenlet==2.0.2 ; python_version >= "3.10" and python_version < "4.0" and (platform_machine == "win32" or platform_machine == "WIN32" or platform_machine == "AMD64" or platform_machine == "amd64" or platform_machine == "x86_64" or platform_machine == "ppc64le" or platform_machine == "aarch64")
idna==3.4 ; python_version >= "3.10" and python_version < "4.0"
itsdangerous==2.1.2 ; python_version >= "3.10" and python_version < "4.0"
jinja2==3.1.2 ; python_version >= "3.10" and python_version < "4.0"
markupsafe==2.1.3 ; python_version >= "3.10" and python_version < "4.0"
paseto-py==0.1.0 ; python_version >= "3.10" and python_version < "4.0"
psycopg2==2.9.6 ; python_version >= "3.10" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.10" and python_version < "4.0"
pycryptodomex==3.18.0 ; python_version >= "3.10" and python_version < "4.0"
python-dotenv==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
pytz==2023.3 ; python_version >= "3.10" and python_version < "4.0"
requests==2.31.0 ; python_version >= "3.10" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
sqlalchemy==2.0.16 ; python_version >= "3.10" and python_version < "4.0"
typing-extensions==4.6.3 ; python_version >= "3.10" and python_version < "4.0"
urllib3==2.0.3 ; python_version >= "3.10" and python_version < "4.0"
werkzeug==2.3.6 ; python_version >= "3.10" and python_version < "4.0"
13 changes: 13 additions & 0 deletions scripts/generate_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env python3

from paseto.v4 import Ed25519PrivateKey


def generate_private_key_hex() -> str:
priv = Ed25519PrivateKey.generate()
priv_bytes = priv.private_bytes_raw()
return priv_bytes.hex()


if __name__ == '__main__':
print(generate_private_key_hex())
25 changes: 5 additions & 20 deletions src/app.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
from flask import Flask
from flask_restful import Api
from flask_cors import CORS
from flask_bcrypt import Bcrypt
from flask_jwt_extended import JWTManager

from src.config import LocalDevelopmentConfig
from src.database import db
from src.models import User
from src.models import *

def create_app():
"""
Create flask app and setup default configuration
"""
app = Flask(__name__)
cors = CORS(app)
bcrypt = Bcrypt(app)
CORS(app)

#change this in prod
app.config.from_object(LocalDevelopmentConfig)
Expand All @@ -28,28 +25,16 @@ def create_app():

return app, api

def setup_auth(app):
"""
Setup JWT authentication
"""

jwt = JWTManager(app)

@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_data):
""" callback for fetching authenticated user from db """
identity = jwt_data["sub"]
return User.query.filter_by(id=int(identity)).one_or_none()


def setup_routes(app):
"""
Register blueprints and any API resources
"""

from .routes.auth import discord_bp
from .routes.auth.discord import discord_bp
from .routes.auth.google import google_bp
from .routes.basic import basic_bp

app.register_blueprint(discord_bp)
app.register_blueprint(basic_bp)

app.register_blueprint(google_bp)
5 changes: 1 addition & 4 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ class Config():
class LocalDevelopmentConfig(Config):
SQLALCHEMY_DATABASE_URI = os.environ.get("DB_CONNECTION_STRING")
DEBUG = True
SECRET_KEY = os.environ.get("SECRET_KEY")
SECURITY_PASSWORD_HASH = "bcrypt"
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1)
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
PASETO_PRIVATE_KEY = os.environ.get("PASETO_PRIVATE_KEY")


MAIN_GUILD_ID = 1104485753758687333
Expand Down
19 changes: 1 addition & 18 deletions src/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import requests
import time
from flask_login import UserMixin, AnonymousUserMixin
from flask_jwt_extended import verify_jwt_in_request, get_jwt, current_user
from flask_login import UserMixin
from .database import db
from .config import *


users_roles = db.Table(
'users_roles',
db.Column('user_id', db.BigInteger, db.ForeignKey('user.id')),
Expand Down Expand Up @@ -100,21 +98,6 @@ def get_roles(self):
return [role.name for role in self.roles]


@classmethod
def authenticate(cls):
"""
Function to get discord user from request.
"""

if verify_jwt_in_request():
data = get_jwt()
return User.get_from_token(DiscordOAuth(data))

if not isinstance(current_user, AnonymousUserMixin) and current_user:
return current_user
else:
raise Exception("No user logged in")

@classmethod
def get_from_token(cls, oauth_data: DiscordOAuth) -> "User":
"""
Expand Down
42 changes: 24 additions & 18 deletions src/routes/auth.py → src/routes/auth/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,32 @@
import os
import secrets
from urllib.parse import urlencode
from flask import Blueprint, redirect, request, make_response
from flask_jwt_extended import create_access_token
from flask_bcrypt import generate_password_hash, check_password_hash
from flask import Blueprint, Response, redirect, request

from src.config import *
from src.models import DiscordOAuth, User
from src.database import db
from src.utils import sign, validate_request_state

discord_bp = Blueprint("discord_bp", __name__, url_prefix='/discord/auth')
state = None

@discord_bp.route("/login")
def login():
"""
Redirect to discord auth
"""
global state
state = secrets.token_urlsafe()
state_param = urlencode({
"state": generate_password_hash(state)
nonce = secrets.token_urlsafe(32)
redirect_uri = request.base_url + "/callback"
params = urlencode({
"client_id": os.environ.get("DISCORD_CLIENT_ID"),
"redirect_uri": redirect_uri,
"state": sign({'nonce': nonce}),
"scope": "identify guilds",
"response_type": "code",
})

redirect_url = os.environ.get("DISCORD_OAUTH_URL") + f"&{state_param}"
return redirect(redirect_url)
auth_url = f'https://discord.com/api/oauth2/authorize?{params}'
cookie = f'nonce={nonce}; SameSite=Lax; Secure; HttpOnly; Max-Age=90000; Path=/'
return Response(status=302, headers={'Location': auth_url, 'Set-Cookie': cookie})


@discord_bp.route("/login/callback")
Expand All @@ -35,21 +37,24 @@ def callback():
"""
token_access_code = request.args.get("code", None)
state_hash = request.args.get("state")
if not state_hash or not check_password_hash(password=state,pw_hash=state_hash):
if token_access_code is None or state_hash is None:
return 'missing code or state',400
validated = validate_request_state(state_hash, request)
if validated is None:
return "invalid state",400

data = {
"client_id": os.environ.get("DISCORD_CLIENT_ID"),
"client_secret": os.environ.get("DISCORD_CLIENT_SECRET"),
"grant_type": "authorization_code",
"code": token_access_code,
"redirect_uri": os.environ.get("DISCORD_OAUTH_REDIRECT"),
"redirect_uri": request.base_url,
}

headers = {"Content-Type": "application/x-www-form-urlencoded"}

r = requests.post(
f"{DISCORD_API_ENDPOINT}/oauth2/token", data=data, headers=headers
f"https://discord.com/api/oauth2/token", data=data, headers=headers
)

oauth_data = r.json()
Expand All @@ -72,14 +77,15 @@ def callback():
user_oauth = DiscordOAuth.query.filter(DiscordOAuth.user_id == user.id).first()
user_oauth.update_oauth(oauth_data)

token = create_access_token(identity=user.id, additional_claims={
'roles': user.get_roles()})
validated['sub'] = user.id
validated['roles'] = user.get_roles()
signed = sign(validated)

db.session.add(user)
db.session.add(user_oauth)
db.session.flush()
db.session.commit()

return {"Token": token}
return {"Token": signed}

return redirect(os.environ.get("FRONTEND_URL"))
return redirect(os.environ["FRONTEND_URL"])
Loading