Skip to content
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
716 changes: 703 additions & 13 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ uWSGI = "~2.0.28"
werkzeug = "~3.1.3"
marshmallow-sqlalchemy = "~1.1.0"
setuptools = "^75.6.0"
discord-oauth2-py = "^1.2.2"

[tool.poetry.group.dev.dependencies]
flake8 = "^7.1.1"
Expand Down
21 changes: 18 additions & 3 deletions src/bapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import secrets
from os import environ

from apispec import APISpec
Expand All @@ -18,6 +19,9 @@

app = Flask(__name__)

# Setup the ability to store session data (this is solely used for OAuth states)
app.secret_key = secrets.token_urlsafe(32)

if environ.get("DEBUG") == "True":
from werkzeug.debug import DebuggedApplication

Expand Down Expand Up @@ -76,6 +80,13 @@
port=cfg.PRIVATE["database"]["game"]["port"],
db=cfg.PRIVATE["database"]["game"]["db"],
),
"session": "mysql://{username}:{password}@{host}:{port}/{db}".format(
username=cfg.PRIVATE["database"]["session"]["user"],
password=cfg.PRIVATE["database"]["session"]["pass"],
host=cfg.PRIVATE["database"]["session"]["host"],
port=cfg.PRIVATE["database"]["session"]["port"],
db=cfg.PRIVATE["database"]["session"]["db"],
),
"site": "mysql://{username}:{password}@{host}:{port}/{db}".format(
username=cfg.PRIVATE["database"]["site"]["user"],
password=cfg.PRIVATE["database"]["site"]["pass"],
Expand All @@ -100,6 +111,7 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h


from bapi.resources.bans import BanListResource
from bapi.blueprints.discord import discord_blueprint
from bapi.resources.general import PlayerListResource
from bapi.resources.general import ServerListResource
from bapi.resources.general import ServerPlayerListResource
Expand All @@ -108,7 +120,7 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
from bapi.resources.library import BookResource
from bapi.resources.patreon import BudgetResource
from bapi.resources.patreon import LinkedPatreonListResource
from bapi.resources.patreon import PatreonOuathResource
from bapi.resources.patreon import PatreonOAuthResource
from bapi.resources.stats import ServerStatsResource
from bapi.resources.stats import StatsResource
from bapi.resources.stats import StatsTotalsResource
Expand All @@ -133,10 +145,10 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
docs_ext.register(BookResource)


api.add_resource(PatreonOuathResource, "/patreonauth")
api.add_resource(PatreonOAuthResource, "/patreonauth")
api.add_resource(LinkedPatreonListResource, "/linked_patreons")
api.add_resource(BudgetResource, "/budget")
docs_ext.register(PatreonOuathResource)
docs_ext.register(PatreonOAuthResource)
docs_ext.register(LinkedPatreonListResource)
docs_ext.register(BudgetResource)

Expand All @@ -151,6 +163,9 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
# Register the swagger docs blueprint
app.register_blueprint(get_swaggerui_blueprint("/docs", "/docs_json", config={"app_name": "BeeStation API"}))

# Register Discord blueprint
app.register_blueprint(discord_blueprint)


@app.route("/")
def docs_redirect():
Expand Down
92 changes: 92 additions & 0 deletions src/bapi/blueprints/discord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import ipaddress
import secrets
import urllib.parse

import discordoauth2
from bapi import cfg
from bapi import db
from flask import Blueprint
from flask import jsonify
from flask import redirect
from flask import render_template
from flask import request
from flask import session

discord_blueprint = Blueprint("discord", __name__, template_folder="templates", url_prefix="/discord")

discord_client = discordoauth2.Client(
cfg.PRIVATE["discord"]["client_id"],
secret=cfg.PRIVATE["discord"]["client_secret"],
redirect=f"{cfg.API['api-url']}/discord/callback",
)


@discord_blueprint.route("/auth", methods=["GET"])
def discord_auth():
ip = request.args.get("ip")
if not isinstance(ip, str):
return jsonify({"error": "provided IP address invalid"})
try:
ip = ipaddress.ip_address(ip)
except ValueError:
return jsonify({"error": "provided IP address invalid"})
if ip.version == 6:
return jsonify({"error": "IPv6 address not allowed"})
if ip.is_multicast or ip.is_unspecified:
return jsonify({"error": "multicast or unspecified address not allowed"})
seeker_port = request.args.get("seeker_port")
if not isinstance(seeker_port, str) or not seeker_port.isdigit():
seeker_port = ""
try:
seeker_port = int(seeker_port)
except ValueError:
seeker_port = ""
if not isinstance(seeker_port, int) or seeker_port > 65535 or seeker_port < 10000:
seeker_port = ""
session["oauth2_state"] = (
f"{urllib.parse.quote(ip.exploded, safe="", encoding="utf-8")},{seeker_port},{secrets.token_urlsafe(16)}"
)
return redirect(discord_client.generate_uri(scope=["identify"], state=session["oauth2_state"]))


@discord_blueprint.route("/callback", methods=["GET"])
def discord_callback():
code = request.args.get("code")
state = request.args.get("state")

if code is None:
return jsonify({"error": "bad oauth code"})

state_session = session.get("oauth2_state")
if state is None or state_session is None or state != state_session:
return jsonify({"error": "bad state"}) # let's not keep this around
del session["oauth2_state"]

state_attrs = state.split(",")
ip = urllib.parse.unquote(state_attrs[0])
seeker_port = state_attrs[1]
discord_uid = None
discord_username = None

try:
access = discord_client.exchange_code(code)
identify = access.fetch_identify()
discord_uid = identify["id"]
discord_username = identify["username"]
discriminator = identify["discriminator"]
# Handle non-unique usernames
if discriminator != "0":
discord_username = f"{discord_username}#{discriminator}"
except discordoauth2.exceptions.RateLimited:
return jsonify({"error": "too many requests"}), 429
except KeyError | discordoauth2.exceptions.HTTPException | discordoauth2.exceptions.Forbidden:
return jsonify({"error": "error authorizing with Discord"})
if discord_uid is None or discord_username is None:
return jsonify({"error": "error authorizing with Discord"})
token = db.Session.create_session(ip, "discord", discord_uid, discord_username, cfg.API["game-session-duration"])
if token is not None:
return render_template(
"token.html", token=token, token_duration=cfg.API["game-session-duration"], seeker_port=seeker_port
)
else:
return jsonify({"error": "error creating session"})
1 change: 1 addition & 0 deletions src/bapi/config/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ website-url: "https://beestation13.com"
api-url: "https://api.beestation13.com"

request-source: "bapi"
game-session-duration: 90 # valid duration of created game session tokens, in days.
41 changes: 41 additions & 0 deletions src/bapi/db.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import socket
import struct
from hashlib import sha256

from bapi import ma_ext
from bapi import sqlalchemy_ext
from bapi.util import generate_random_session_token
from sqlalchemy import and_
from sqlalchemy import Column
from sqlalchemy import Date
Expand All @@ -11,16 +16,52 @@
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.sql.expression import text

db_session = sqlalchemy_ext.session


class Session(sqlalchemy_ext.Model):
__bind_key__ = "session"
__tablename__ = "SS13_session"

id = Column("id", Integer(), primary_key=True)
ip = Column("ip", Integer())
session_token = Column("session_token", String(64))
external_method = Column("external_method", String(16))
external_uid = Column("external_uid", String(32))
external_display_name = Column("external_display_name", String(32))
valid_until = Column("valid_until", DateTime())

@classmethod
def create_session(cls, ip, external_method, external_uid, external_display_name, duration_days):
ip_num = struct.unpack("!L", socket.inet_aton(ip))[0]
duration_days = int(duration_days)
if duration_days <= 0:
duration_days = 90
random_token = generate_random_session_token()
# Store the sha256 hash of the token
random_token_hash = sha256(random_token.encode("utf-8")).hexdigest()
entry = cls(
ip=ip_num,
session_token=random_token_hash,
external_method=external_method,
external_uid=external_uid,
external_display_name=external_display_name,
valid_until=func.date_add(func.now(), text(f"INTERVAL {duration_days} DAY")),
)
db_session.add(entry)
db_session.commit()
return random_token


class Player(sqlalchemy_ext.Model):
__bind_key__ = "game"
__tablename__ = "SS13_player"

ckey = Column("ckey", String(32), primary_key=True)
byond_key = Column("byond_key", String(32))
discord_uid = Column("discord_uid", String(32))
firstseen = Column("firstseen", DateTime())
firstseen_round_id = Column("firstseen_round_id", Integer())
lastseen = Column("lastseen", DateTime())
Expand Down
2 changes: 1 addition & 1 deletion src/bapi/resources/patreon.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from marshmallow import Schema


class PatreonOuathResource(MethodResource):
class PatreonOAuthResource(MethodResource):
@doc(description="Patreon oauth callback.")
def get(self):
code = request.args.get("code")
Expand Down
72 changes: 72 additions & 0 deletions src/bapi/templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{% endblock %}</title>
<style>
html {
background-color: #333333;
color: #ffffff;
font-family: Helvetica, Arial, sans-serif;
overflow-x: hidden;
}
html,
body,
main {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
}
h1,
h2,
h3 {
color: #ffbf00;
font-weight: bold;
text-align: center;
font-size: 30px;
}
main {
max-width: 50rem;
margin: 0 auto;
}
section {
padding: 30px;
background-color: #1a1a1a;
box-shadow: 0 0 4px 4px #100f0e;
font-size: 20px;
}
.center-vertical {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
button,
.link-btn {
background-color: black;
border: 1px solid #ffbf00;
color: #ffbf00;
transition: background-color 0.1s linear;
padding: 5px 4px;
border-radius: 4px;
}
button:hover {
background-color: #333333;
cursor: pointer;
}
.light {
color: #ccc;
}
.link-btn {
display: inline-block;
text-decoration: none;
}
</style>
{% block head %} {% endblock %}
</head>
<body>
<main class="content">{% block content %}{% endblock %}</main>
</body>
</html>
Loading