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

generic oauth2 #17

Merged
merged 4 commits into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
183 changes: 123 additions & 60 deletions frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from datetime import datetime, timezone
from secrets import token_hex
from typing import Iterable
from urllib.parse import urlencode

import iso8601
import requests
from flask import (
Flask,
abort,
flash,
g,
jsonify,
redirect,
Expand All @@ -17,7 +19,6 @@
session,
url_for,
)
from flask_github import GitHub
from prometheus_client import generate_latest
from prometheus_client.core import REGISTRY, GaugeMetricFamily
from prometheus_client.metrics_core import Metric
Expand All @@ -38,11 +39,10 @@
get_assets_awaiting_moderation,
get_random,
get_user_assets,
login_disabled_for_user,
is_within_timeframe,
login_required,
user_is_admin,
user_without_limits,
)
from util.sso import SSO_CONFIG

app = Flask(
__name__,
Expand All @@ -52,8 +52,6 @@
app.wsgi_app = ProxyFix(app.wsgi_app)

for copy_key in (
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"MAX_UPLOADS",
"ROOMS",
"TIME_MAX",
Expand Down Expand Up @@ -112,30 +110,49 @@ def collect(self) -> Iterable[Metric]:
REGISTRY.register(SubmissionsCollector())
REGISTRY.register(InfobeamerCollector())

github = GitHub(app)

app.session_interface = RedisSessionStore()


@app.before_request
def before_request():
user = session.get("gh_login")
g.user_is_admin = user_is_admin(user)
g.user_without_limits = user_without_limits(user)
provider = session.get("oauth2_provider")
userinfo = session.get("oauth2_userinfo")

g.user_is_admin = False
g.user_without_limits = False
g.userid = ""
g.username = ""

if not provider or not userinfo:
return

username = SSO_CONFIG[provider]["functions"]["username"](userinfo)
user_is_admin = SSO_CONFIG[provider]["functions"]["is_admin"](userinfo)
user_without_limits = SSO_CONFIG[provider]["functions"]["no_limit"](userinfo)

if login_disabled_for_user(user):
g.user = None
g.avatar = None
if not (user_is_admin or user_without_limits or is_within_timeframe()):
return

g.user = user
g.avatar = session.get("gh_avatar")
g.user_is_admin = user_is_admin
g.user_without_limits = user_without_limits
g.userid = f"{provider}:{username}"
g.username = username


@app.context_processor
def login_providers():
result = {}

for provider, config in CONFIG["oauth2_providers"].items():
result[provider] = SSO_CONFIG[provider]["display_name"]

return {"login_providers": result}


@app.context_processor
def start_time_alert():
# if g.user is set, the user was successfully logged in (see above)
if g.user:
if g.userid:
return {"start_time": None}

start_time = datetime.fromtimestamp(CONFIG["TIME_MIN"], timezone.utc)
Expand All @@ -146,50 +163,93 @@ def start_time_alert():
return {"start_time": start_time.strftime("%F %T")}


@app.route("/github-callback")
@github.authorized_handler
def authorized(access_token):
if access_token is None:
return redirect(url_for("index"))
@app.route("/login/<provider>")
def login(provider):
if g.userid:
return redirect(url_for("dashboard"))

state = request.args.get("state")
if state is None or state != session.get("state"):
return redirect(url_for("index"))
session.pop("state")
provider_config = CONFIG["oauth2_providers"].get(provider, {})
if not provider_config or provider not in SSO_CONFIG:
abort(404)

github_user = github.get("user", access_token=access_token)
if github_user["type"] != "User":
return redirect(url_for("faq", _anchor="signup"))
session["oauth2_state"] = state = get_random()

if login_disabled_for_user(github_user["login"]):
return render_template("time_error.jinja")
qs = urlencode(
{
"client_id": provider_config["client_id"],
"redirect_uri": url_for(
"oauth2_callback", provider=provider, _external=True
),
"response_type": "code",
"scope": " ".join(SSO_CONFIG[provider]["scopes"]),
"state": state,
}
)
return redirect("{}?{}".format(SSO_CONFIG[provider]["authorize_url"], qs))


@app.route("/login/callback/<provider>")
def oauth2_callback(provider):
if g.userid:
return redirect(url_for("dashboard"))

provider_config = CONFIG["oauth2_providers"].get(provider, {})
if not provider_config or provider not in SSO_CONFIG:
abort(404)

if "error" in request.args:
for k, v in request.args.items():
if k.startswith("error"):
flash(f"{k}: {v}", "danger")
return redirect(url_for("index"))

age = datetime.utcnow() - iso8601.parse_date(github_user["created_at"]).replace(
tzinfo=None
if request.args["state"] != session.get("oauth2_state"):
abort(401)

if "code" not in request.args:
abort(400)

r = requests.post(
SSO_CONFIG[provider]["token_url"],
data={
"client_id": provider_config["client_id"],
"client_secret": provider_config["client_secret"],
"code": request.args["code"],
"grant_type": "authorization_code",
"redirect_uri": url_for(
"oauth2_callback", provider=provider, _external=True
),
},
headers={"Accept": "application/json"},
)
if r.status_code != 200:
abort(400)
oauth2_token = r.json().get("access_token")

r = requests.get(
SSO_CONFIG[provider]["userinfo_url"],
headers={
"Authorization": f"Bearer {oauth2_token}",
"Accept": "application/json",
},
)
userinfo_json = r.json()

app.logger.info(f"user is {age.days} days old")
app.logger.info("user has {} followers".format(github_user["followers"]))
if age.days < 31 and github_user["followers"] < 10:
if not SSO_CONFIG[provider]["functions"]["login_allowed"](userinfo_json):
flash("You are not allowed to log in at this time.", "warning")
return redirect(url_for("faq", _anchor="signup"))

session["gh_login"] = github_user["login"]
session["oauth2_provider"] = provider
session["oauth2_userinfo"] = userinfo_json
if "redirect_after_login" in session:
return redirect(session["redirect_after_login"])
return redirect(url_for("dashboard"))


@app.route("/login")
def login():
if g.user:
return redirect(url_for("dashboard"))
session["state"] = state = get_random()
return github.authorize(state=state)


@app.route("/logout")
def logout():
session.clear()
flash("You have been logged out", "info")
return redirect(url_for("index"))


Expand All @@ -213,7 +273,7 @@ def saal():
auth = CONFIG.get("INTERRUPT_KEY")
if not auth:
abort(404)
if not user_is_admin(g.user) and request.args.get("auth") != auth:
if not g.user_is_admin and request.args.get("auth") != auth:
abort(401)

interrupt_key = get_scoped_api_key(
Expand Down Expand Up @@ -271,13 +331,16 @@ def content_upload():
extension = "jpg" if filetype == "image" else "mp4"

filename = "user/{}/{}_{}.{}".format(
g.user, datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), token_hex(8), extension
g.userid,
datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
token_hex(8),
extension,
)
condition = {
"StringEquals": {
"asset:filename": filename,
"asset:filetype": filetype,
"userdata:user": g.user,
"userdata:user": g.userid,
},
"NotExists": {
"userdata:state": True,
Expand Down Expand Up @@ -311,7 +374,7 @@ def content_upload():
)
return jsonify(
filename=filename,
user=g.user,
user=g.userid,
upload_key=get_scoped_api_key(
[{"Action": "asset:upload", "Condition": condition, "Effect": "allow"}],
uses=1,
Expand All @@ -327,30 +390,30 @@ def content_request_review(asset_id):
except Exception:
abort(404)

if asset["userdata"].get("user") != g.user:
if asset["userdata"].get("user") != g.userid:
return error("Cannot review")

if "state" in asset["userdata"]: # not in new state?
return error("Cannot review")

moderation_message = "{asset} uploaded by {user}. ".format(
user=g.user,
user=g.userid,
asset=asset["filetype"].capitalize(),
)

if g.user_is_admin:
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.user)
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.userid)
app.logger.warn(
"auto-confirming {} because it was uploaded by admin {}".format(
asset["id"], g.user
asset["id"], g.userid
)
)
moderation_message += "It was automatically confirmed because user is an admin."
elif g.user_without_limits:
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.user)
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.userid)
app.logger.warn(
"auto-confirming {} because it was uploaded by no-limits user {}".format(
asset["id"], g.user
asset["id"], g.userid
)
)
moderation_message += (
Expand Down Expand Up @@ -413,10 +476,10 @@ def content_moderate_result(asset_id, result):

if result == "confirm":
app.logger.info("Asset {} was confirmed".format(asset["id"]))
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.user)
update_asset_userdata(asset, state=State.CONFIRMED, moderated_by=g.userid)
else:
app.logger.info("Asset {} was rejected".format(asset["id"]))
update_asset_userdata(asset, state=State.REJECTED, moderated_by=g.user)
update_asset_userdata(asset, state=State.REJECTED, moderated_by=g.userid)

return jsonify(ok=True)

Expand All @@ -432,7 +495,7 @@ def content_update(asset_id):
starts = request.values.get("starts", type=int)
ends = request.values.get("ends", type=int)

if asset["userdata"].get("user") != g.user:
if asset["userdata"].get("user") != g.userid:
return error("Cannot update")

try:
Expand All @@ -452,7 +515,7 @@ def content_delete(asset_id):
except Exception:
abort(404)

if asset["userdata"].get("user") != g.user:
if asset["userdata"].get("user") != g.userid:
return error("Cannot delete")

try:
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ click==8.1.7
Deprecated==1.2.14
Flask==3.0.3
gevent==24.2.1
GitHub-Flask==3.2.0
greenlet==3.0.3
gunicorn==22.0.0
httplib2==0.22.0
Expand Down
13 changes: 10 additions & 3 deletions templates/layout.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,26 @@
<li><a href="{{ url_for("slideshow") }}">Slideshow</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
{% if g.user %}
{% if g.userid %}
<li {%if request.path=="/dashboard"%}class="active"{%endif%}><a href="/dashboard">
{{g.user}}'s projects
{{g.username}}'s projects
</a></li>
<li><a href="/logout">Logout</a></li>
{% else %}
<li><a href="/login">Login / Sign up using Github</a></li>
{% for slug, name in login_providers.items() %}
<li><a href="/login/{{ slug }}">Login / Sign up using {{ name }}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
</nav>
<div class="container" id='main'>
<busy-indicator></busy-indicator>
{% for messages in get_flashed_messages(with_categories=True) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="alert">{{ message }}</div>
{% endfor %}
{% endfor %}
{% if start_time %}
<div class="alert alert-info" role="alert">
<h2>Submissions are not yet open</h2>
Expand Down
Loading
Loading