Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add google auth, remove JWT and bcrypt #9

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
lmaotrigine marked this conversation as resolved.
Show resolved Hide resolved
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
lmaotrigine marked this conversation as resolved.
Show resolved Hide resolved

### 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