Skip to content

Commit 85ff51b

Browse files
itsmeowCopilot
andauthored
Discord OAuth for SS13 sessions (#33)
* Discord OAuth SS13 sessions * Update src/bapi/resources/discord.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/bapi/db.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix mispelled OAuth * return the token * Convert discord endpoints to blueprint, add page template for token disclosure * inet_aton * Automatic login via dreamseeker topic * Improved token page * Separate db for sessions, fix exception import * Copilot review fix --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 57707b6 commit 85ff51b

10 files changed

Lines changed: 1054 additions & 17 deletions

File tree

poetry.lock

Lines changed: 703 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ uWSGI = "~2.0.28"
2727
werkzeug = "~3.1.3"
2828
marshmallow-sqlalchemy = "~1.1.0"
2929
setuptools = "^75.6.0"
30+
discord-oauth2-py = "^1.2.2"
3031

3132
[tool.poetry.group.dev.dependencies]
3233
flake8 = "^7.1.1"

src/bapi/__init__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import secrets
12
from os import environ
23

34
from apispec import APISpec
@@ -18,6 +19,9 @@
1819

1920
app = Flask(__name__)
2021

22+
# Setup the ability to store session data (this is solely used for OAuth states)
23+
app.secret_key = secrets.token_urlsafe(32)
24+
2125
if environ.get("DEBUG") == "True":
2226
from werkzeug.debug import DebuggedApplication
2327

@@ -76,6 +80,13 @@
7680
port=cfg.PRIVATE["database"]["game"]["port"],
7781
db=cfg.PRIVATE["database"]["game"]["db"],
7882
),
83+
"session": "mysql://{username}:{password}@{host}:{port}/{db}".format(
84+
username=cfg.PRIVATE["database"]["session"]["user"],
85+
password=cfg.PRIVATE["database"]["session"]["pass"],
86+
host=cfg.PRIVATE["database"]["session"]["host"],
87+
port=cfg.PRIVATE["database"]["session"]["port"],
88+
db=cfg.PRIVATE["database"]["session"]["db"],
89+
),
7990
"site": "mysql://{username}:{password}@{host}:{port}/{db}".format(
8091
username=cfg.PRIVATE["database"]["site"]["user"],
8192
password=cfg.PRIVATE["database"]["site"]["pass"],
@@ -100,6 +111,7 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
100111

101112

102113
from bapi.resources.bans import BanListResource
114+
from bapi.blueprints.discord import discord_blueprint
103115
from bapi.resources.general import PlayerListResource
104116
from bapi.resources.general import ServerListResource
105117
from bapi.resources.general import ServerPlayerListResource
@@ -108,7 +120,7 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
108120
from bapi.resources.library import BookResource
109121
from bapi.resources.patreon import BudgetResource
110122
from bapi.resources.patreon import LinkedPatreonListResource
111-
from bapi.resources.patreon import PatreonOuathResource
123+
from bapi.resources.patreon import PatreonOAuthResource
112124
from bapi.resources.stats import ServerStatsResource
113125
from bapi.resources.stats import StatsResource
114126
from bapi.resources.stats import StatsTotalsResource
@@ -133,10 +145,10 @@ def handle_request_parsing_error(err, req, schema, *, error_status_code, error_h
133145
docs_ext.register(BookResource)
134146

135147

136-
api.add_resource(PatreonOuathResource, "/patreonauth")
148+
api.add_resource(PatreonOAuthResource, "/patreonauth")
137149
api.add_resource(LinkedPatreonListResource, "/linked_patreons")
138150
api.add_resource(BudgetResource, "/budget")
139-
docs_ext.register(PatreonOuathResource)
151+
docs_ext.register(PatreonOAuthResource)
140152
docs_ext.register(LinkedPatreonListResource)
141153
docs_ext.register(BudgetResource)
142154

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

166+
# Register Discord blueprint
167+
app.register_blueprint(discord_blueprint)
168+
154169

155170
@app.route("/")
156171
def docs_redirect():

src/bapi/blueprints/discord.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import ipaddress
2+
import secrets
3+
import urllib.parse
4+
5+
import discordoauth2
6+
from bapi import cfg
7+
from bapi import db
8+
from flask import Blueprint
9+
from flask import jsonify
10+
from flask import redirect
11+
from flask import render_template
12+
from flask import request
13+
from flask import session
14+
15+
discord_blueprint = Blueprint("discord", __name__, template_folder="templates", url_prefix="/discord")
16+
17+
discord_client = discordoauth2.Client(
18+
cfg.PRIVATE["discord"]["client_id"],
19+
secret=cfg.PRIVATE["discord"]["client_secret"],
20+
redirect=f"{cfg.API['api-url']}/discord/callback",
21+
)
22+
23+
24+
@discord_blueprint.route("/auth", methods=["GET"])
25+
def discord_auth():
26+
ip = request.args.get("ip")
27+
if not isinstance(ip, str):
28+
return jsonify({"error": "provided IP address invalid"})
29+
try:
30+
ip = ipaddress.ip_address(ip)
31+
except ValueError:
32+
return jsonify({"error": "provided IP address invalid"})
33+
if ip.version == 6:
34+
return jsonify({"error": "IPv6 address not allowed"})
35+
if ip.is_multicast or ip.is_unspecified:
36+
return jsonify({"error": "multicast or unspecified address not allowed"})
37+
seeker_port = request.args.get("seeker_port")
38+
if not isinstance(seeker_port, str) or not seeker_port.isdigit():
39+
seeker_port = ""
40+
try:
41+
seeker_port = int(seeker_port)
42+
except ValueError:
43+
seeker_port = ""
44+
if not isinstance(seeker_port, int) or seeker_port > 65535 or seeker_port < 10000:
45+
seeker_port = ""
46+
session["oauth2_state"] = (
47+
f"{urllib.parse.quote(ip.exploded, safe="", encoding="utf-8")},{seeker_port},{secrets.token_urlsafe(16)}"
48+
)
49+
return redirect(discord_client.generate_uri(scope=["identify"], state=session["oauth2_state"]))
50+
51+
52+
@discord_blueprint.route("/callback", methods=["GET"])
53+
def discord_callback():
54+
code = request.args.get("code")
55+
state = request.args.get("state")
56+
57+
if code is None:
58+
return jsonify({"error": "bad oauth code"})
59+
60+
state_session = session.get("oauth2_state")
61+
if state is None or state_session is None or state != state_session:
62+
return jsonify({"error": "bad state"}) # let's not keep this around
63+
del session["oauth2_state"]
64+
65+
state_attrs = state.split(",")
66+
ip = urllib.parse.unquote(state_attrs[0])
67+
seeker_port = state_attrs[1]
68+
discord_uid = None
69+
discord_username = None
70+
71+
try:
72+
access = discord_client.exchange_code(code)
73+
identify = access.fetch_identify()
74+
discord_uid = identify["id"]
75+
discord_username = identify["username"]
76+
discriminator = identify["discriminator"]
77+
# Handle non-unique usernames
78+
if discriminator != "0":
79+
discord_username = f"{discord_username}#{discriminator}"
80+
except discordoauth2.exceptions.RateLimited:
81+
return jsonify({"error": "too many requests"}), 429
82+
except KeyError | discordoauth2.exceptions.HTTPException | discordoauth2.exceptions.Forbidden:
83+
return jsonify({"error": "error authorizing with Discord"})
84+
if discord_uid is None or discord_username is None:
85+
return jsonify({"error": "error authorizing with Discord"})
86+
token = db.Session.create_session(ip, "discord", discord_uid, discord_username, cfg.API["game-session-duration"])
87+
if token is not None:
88+
return render_template(
89+
"token.html", token=token, token_duration=cfg.API["game-session-duration"], seeker_port=seeker_port
90+
)
91+
else:
92+
return jsonify({"error": "error creating session"})

src/bapi/config/api.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ website-url: "https://beestation13.com"
55
api-url: "https://api.beestation13.com"
66

77
request-source: "bapi"
8+
game-session-duration: 90 # valid duration of created game session tokens, in days.

src/bapi/db.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import socket
2+
import struct
3+
from hashlib import sha256
4+
15
from bapi import ma_ext
26
from bapi import sqlalchemy_ext
7+
from bapi.util import generate_random_session_token
38
from sqlalchemy import and_
49
from sqlalchemy import Column
510
from sqlalchemy import Date
@@ -11,16 +16,52 @@
1116
from sqlalchemy import String
1217
from sqlalchemy import Text
1318
from sqlalchemy.orm.exc import NoResultFound
19+
from sqlalchemy.sql.expression import text
1420

1521
db_session = sqlalchemy_ext.session
1622

1723

24+
class Session(sqlalchemy_ext.Model):
25+
__bind_key__ = "session"
26+
__tablename__ = "SS13_session"
27+
28+
id = Column("id", Integer(), primary_key=True)
29+
ip = Column("ip", Integer())
30+
session_token = Column("session_token", String(64))
31+
external_method = Column("external_method", String(16))
32+
external_uid = Column("external_uid", String(32))
33+
external_display_name = Column("external_display_name", String(32))
34+
valid_until = Column("valid_until", DateTime())
35+
36+
@classmethod
37+
def create_session(cls, ip, external_method, external_uid, external_display_name, duration_days):
38+
ip_num = struct.unpack("!L", socket.inet_aton(ip))[0]
39+
duration_days = int(duration_days)
40+
if duration_days <= 0:
41+
duration_days = 90
42+
random_token = generate_random_session_token()
43+
# Store the sha256 hash of the token
44+
random_token_hash = sha256(random_token.encode("utf-8")).hexdigest()
45+
entry = cls(
46+
ip=ip_num,
47+
session_token=random_token_hash,
48+
external_method=external_method,
49+
external_uid=external_uid,
50+
external_display_name=external_display_name,
51+
valid_until=func.date_add(func.now(), text(f"INTERVAL {duration_days} DAY")),
52+
)
53+
db_session.add(entry)
54+
db_session.commit()
55+
return random_token
56+
57+
1858
class Player(sqlalchemy_ext.Model):
1959
__bind_key__ = "game"
2060
__tablename__ = "SS13_player"
2161

2262
ckey = Column("ckey", String(32), primary_key=True)
2363
byond_key = Column("byond_key", String(32))
64+
discord_uid = Column("discord_uid", String(32))
2465
firstseen = Column("firstseen", DateTime())
2566
firstseen_round_id = Column("firstseen_round_id", Integer())
2667
lastseen = Column("lastseen", DateTime())

src/bapi/resources/patreon.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from marshmallow import Schema
1515

1616

17-
class PatreonOuathResource(MethodResource):
17+
class PatreonOAuthResource(MethodResource):
1818
@doc(description="Patreon oauth callback.")
1919
def get(self):
2020
code = request.args.get("code")

src/bapi/templates/base.html

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>{% block title %}{% endblock %}</title>
7+
<style>
8+
html {
9+
background-color: #333333;
10+
color: #ffffff;
11+
font-family: Helvetica, Arial, sans-serif;
12+
overflow-x: hidden;
13+
}
14+
html,
15+
body,
16+
main {
17+
height: 100%;
18+
width: 100%;
19+
margin: 0;
20+
padding: 0;
21+
}
22+
h1,
23+
h2,
24+
h3 {
25+
color: #ffbf00;
26+
font-weight: bold;
27+
text-align: center;
28+
font-size: 30px;
29+
}
30+
main {
31+
max-width: 50rem;
32+
margin: 0 auto;
33+
}
34+
section {
35+
padding: 30px;
36+
background-color: #1a1a1a;
37+
box-shadow: 0 0 4px 4px #100f0e;
38+
font-size: 20px;
39+
}
40+
.center-vertical {
41+
display: flex;
42+
align-items: center;
43+
justify-content: center;
44+
height: 100%;
45+
}
46+
button,
47+
.link-btn {
48+
background-color: black;
49+
border: 1px solid #ffbf00;
50+
color: #ffbf00;
51+
transition: background-color 0.1s linear;
52+
padding: 5px 4px;
53+
border-radius: 4px;
54+
}
55+
button:hover {
56+
background-color: #333333;
57+
cursor: pointer;
58+
}
59+
.light {
60+
color: #ccc;
61+
}
62+
.link-btn {
63+
display: inline-block;
64+
text-decoration: none;
65+
}
66+
</style>
67+
{% block head %} {% endblock %}
68+
</head>
69+
<body>
70+
<main class="content">{% block content %}{% endblock %}</main>
71+
</body>
72+
</html>

0 commit comments

Comments
 (0)