Skip to content

Commit

Permalink
Introduce GitHub OAuth Authentication
Browse files Browse the repository at this point in the history
This implements the long waited GitHub Auth
Closes #2132
  • Loading branch information
peregrineshahin committed Aug 23, 2024
1 parent 12981ff commit 82028ee
Show file tree
Hide file tree
Showing 7 changed files with 281 additions and 27 deletions.
4 changes: 4 additions & 0 deletions server/fishtest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,5 +177,9 @@ def group_finder(username, request):
config.add_route("api_actions", "/api/actions")
config.add_route("api_calc_elo", "/api/calc_elo")

# GitHub OAuth Routes
config.add_route("github_oauth", "/github/oauth")
config.add_route("github_callback", "/github/callback")

config.scan()
return config.make_wsgi_app()
31 changes: 19 additions & 12 deletions server/fishtest/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,25 @@ def size_is_length(x):
size_is_length,
)

user_schema = {
"_id?": ObjectId,
"username": username,
"password": str,
"registration_time": datetime_utc,
"pending": bool,
"blocked": bool,
"email": email,
"groups": [str, ...],
"tests_repo": union("", url),
"machine_limit": uint,
}
user_schema = intersect(
{
"_id?": ObjectId,
"username": username,
"password?": str,
"registration_time": datetime_utc,
"pending": bool,
"blocked": bool,
"email?": email,
"github_id?": str,
"linked_github_username?": str,
"github_access_token?": str,
"groups": [str, ...],
"tests_repo?": union("", url),
"machine_limit": uint,
},
at_least_one_of("email", "github_id"),
at_least_one_of("password", "github_access_token"),
)


worker_schema = {
Expand Down
10 changes: 10 additions & 0 deletions server/fishtest/templates/login.mak
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@

<button type="submit" class="btn btn-primary w-100">Login</button>
</form>

<div class="text-md-center my-4">
<p>Or</p>
<form action="${request.route_url('github_oauth')}" method="post">
<input type="hidden" name="action" value="login">
<button type="submit" class="btn btn-secondary w-100">
<i class="fa-brands fa-github"></i> Log in with GitHub
</button>
</form>
</div>
</div>

<script
Expand Down
9 changes: 9 additions & 0 deletions server/fishtest/templates/signup.mak
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@

<button type="submit" class="btn btn-primary w-100">Register</button>
</form>
<div class="text-md-center my-4">
<p>Or</p>
<form action="${request.route_url('github_oauth')}" method="post">
<input type="hidden" name="action" value="signup">
<button type="submit" class="btn btn-secondary w-100">
<i class="fa-brands fa-github"></i> Sign up with GitHub
</button>
</form>
</div>
</div>

<script
Expand Down
18 changes: 17 additions & 1 deletion server/fishtest/templates/user.mak
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
<li class="list-group-item bg-transparent text-break">Registered: ${format_date(user['registration_time'] if 'registration_time' in user else 'Unknown')}</li>
% if not profile:
<li class="list-group-item bg-transparent text-break">Tests Repository:
% if user['tests_repo']:
% if 'tests_repo' in user and user['tests_repo']:
<a class="alert-link" href="${user['tests_repo']}">${extract_repo_from_link(user['tests_repo'])}</a>
% else:
<span>-</span>
Expand All @@ -55,6 +55,14 @@
<li class="list-group-item bg-transparent text-break">
Groups: ${format_group(user['groups'])}
</li>
<li class="list-group-item bg-transparent text-break">
Linked GitHub user:
% if 'linked_github_username' in user and user['linked_github_username']:
<a class="alert-link" href="https://github.com/${user["linked_github_username"]}">GitHub/${user["linked_github_username"]}</a>
% else:
<span>No accounts linked</span>
% endif
</li>
<li class="list-group-item bg-transparent text-break">Machine Limit: ${limit}</li>
<li class="list-group-item bg-transparent text-break">CPU-Hours: ${hours}</li>
</ul>
Expand Down Expand Up @@ -273,6 +281,14 @@
% endif
% endif
</form>
% if profile and not 'linked_github_username' in user:
<form action="${request.route_url('github_oauth')}" method="post">
<input type="hidden" name="action" value="link">
<button type="submit" class="btn btn-secondary w-100">
<i class="fa-brands fa-github"></i> Link GitHub Account
</button>
</form>
% endif
</div>

<script
Expand Down
53 changes: 40 additions & 13 deletions server/fishtest/userdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ def get_blocked(self):
def get_user(self, username):
return self.find_by_username(username)

def get_user_by_github_id(self, github_id):
return self.users.find_one({"github_id": github_id})

def get_user_groups(self, username):
user = self.get_user(username)
if user is not None:
Expand All @@ -105,27 +108,51 @@ def add_user_group(self, username, group):
self.users.replace_one({"_id": user["_id"]}, user)
self.clear_cache()

def create_user(self, username, password, email, tests_repo):
def create_user(
self,
username,
password=None,
email="",
tests_repo="",
linked_github_username=None,
github_id=None,
github_access_token=None,
):
try:
if self.find_by_username(username) or self.find_by_email(email):
return False
# insert the new user in the db
user = {
"username": username,
"password": password,
"registration_time": datetime.now(timezone.utc),
"pending": True,
"blocked": False,
"email": email,
"groups": [],
"tests_repo": tests_repo,
"machine_limit": DEFAULT_MACHINE_LIMIT,
}
if github_id is not None and github_access_token is not None:
user = {
"username": username,
"registration_time": datetime.now(timezone.utc),
"pending": True,
"blocked": False,
"github_id": github_id,
"github_access_token": github_access_token,
"linked_github_username": linked_github_username,
"email": email,
"groups": [],
"tests_repo": tests_repo,
"machine_limit": DEFAULT_MACHINE_LIMIT,
}
else:
user = {
"username": username,
"password": password,
"registration_time": datetime.now(timezone.utc),
"pending": True,
"blocked": False,
"email": email,
"groups": [],
"tests_repo": tests_repo,
"machine_limit": DEFAULT_MACHINE_LIMIT,
}

validate_user(user)
self.users.insert_one(user)
self.last_pending_time = 0
self.last_blocked_time = 0

return True
except:
return None
Expand Down
183 changes: 182 additions & 1 deletion server/fishtest/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import re
import subprocess
import threading
import time
from datetime import datetime, timezone
Expand All @@ -25,7 +26,7 @@
password_strength,
update_residuals,
)
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPNotFound
from pyramid.security import forget, remember
from pyramid.view import forbidden_view_config, notfound_view_config, view_config
from requests.exceptions import ConnectionError, HTTPError
Expand Down Expand Up @@ -156,6 +157,186 @@ def login(request):
return {}


# Hide these
GITHUB_CLIENT_ID = ""
GITHUB_CLIENT_SECRET = ""


def get_github_access_token(code, redirect_uri):
token_url = "https://github.com/login/oauth/access_token"
curl_command = [
"curl",
"-X",
"POST",
token_url,
"-H",
"Accept: application/json",
"-d",
f"client_id={GITHUB_CLIENT_ID}",
"-d",
f"client_secret={GITHUB_CLIENT_SECRET}",
"-d",
f"code={code}",
"-d",
f"redirect_uri={redirect_uri}",
]
result = subprocess.run(curl_command, stdout=subprocess.PIPE, text=True)
return json.loads(result.stdout)["access_token"]


def extract_primary_email(emails):
if isinstance(emails, list):
return next(
(
email.get("email")
for email in emails
if isinstance(email, dict) and email.get("primary")
),
None,
)
return None


def get_github_user_data(access_token):
user_url = "https://api.github.com/user"
emails_url = "https://api.github.com/user/emails"

user_command = [
"curl",
"-H",
f"Authorization: bearer {access_token}",
"-H",
"Accept: application/json",
user_url,
]
user_result = subprocess.run(user_command, stdout=subprocess.PIPE, text=True)
user_data = json.loads(user_result.stdout)

emails_command = [
"curl",
"-H",
f"Authorization: bearer {access_token}",
"-H",
"Accept: application/json",
emails_url,
]
emails_result = subprocess.run(emails_command, stdout=subprocess.PIPE, text=True)

try:
emails = json.loads(emails_result.stdout)
except json.JSONDecodeError:
emails = []

primary_email = extract_primary_email(emails)

user_data["primary_email"] = primary_email
return user_data, emails


@view_config(route_name="github_oauth", request_method="POST")
def github_oauth(request):
action = request.POST.get("action")
request.session["github_action"] = action # Store action in session

github_authorize_url = "https://github.com/login/oauth/authorize"
redirect_uri = request.route_url("github_callback")

params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": "user:email",
}
url = requests.Request("GET", github_authorize_url, params=params).prepare().url
return HTTPFound(location=url)


@view_config(route_name="github_callback")
def github_callback(request):
code = request.params.get("code")
if not code:
return HTTPBadRequest("No code provided")

action = request.session.get("github_action", "login") # Default to login
github_access_token = get_github_access_token(
code, request.route_url("github_callback")
)
user_data, user_email_data = get_github_user_data(github_access_token)
username = user_data.get("login")
if not username:
request.session.flash("Failed to retrieve GitHub username", "error")
return HTTPFound(location=request.route_url("login"))

github_id = str(user_data.get("id"))
email = extract_primary_email(user_email_data)

if action == "login":
return handle_github_login(
request, github_id, username, email, github_access_token
)
elif action == "signup":
return handle_github_signup(
request, github_id, username, email, github_access_token
)
elif action == "link":
return handle_github_link(request, github_id, username, github_access_token)
else:
return HTTPBadRequest("Invalid action specified")


def handle_github_login(request, github_id, username, email, github_access_token):
user = request.userdb.get_user_by_github_id(github_id)
if user is None:
request.session.flash("User not found. Please register first.", "error")
return HTTPFound(location=request.route_url("signup"))

# Remember the user in the session
headers = remember(request, user["username"], max_age=60 * 60 * 24 * 365)
return HTTPFound(location=request.route_url("home"), headers=headers)


def handle_github_signup(request, github_id, username, email, github_access_token):
user = request.userdb.get_user_by_github_id(github_id)
if user:
request.session.flash("User already exists. Please login instead.", "error")
return HTTPFound(location=request.route_url("login"))

request.userdb.create_user(
username=username,
email=email,
github_id=github_id,
github_access_token=github_access_token,
linked_github_username=username,
)

headers = remember(request, username, max_age=60 * 60 * 24 * 365)
return HTTPFound(location=request.route_url("home"), headers=headers)


def handle_github_link(request, github_id, username, github_access_token):
existing_user = request.userdb.get_user_by_github_id(github_id)
if existing_user:
request.session.flash(
"This GitHub account is already linked with another user.", "error"
)
return HTTPFound(location=request.route_url("profile"))

auth_user_id = request.authenticated_userid
if not auth_user_id:
request.session.flash(
"You need to be logged in to link a GitHub account.", "error"
)
return HTTPFound(location=request.route_url("login"))

user = request.userdb.get_user(auth_user_id)
user["github_id"] = github_id
user["github_access_token"] = github_access_token
user["linked_github_username"] = username
request.userdb.save_user(user)

request.session.flash("GitHub account linked successfully.", "success")
return HTTPFound(location=request.route_url("profile"))


# Note that the allowed length of mailto URLs on Chrome/Windows is severely
# limited.

Expand Down

0 comments on commit 82028ee

Please sign in to comment.