Skip to content

Commit 4202f3c

Browse files
authored
[s] BeeAuth: Session creation nonce checking, Better Input validation (#37)
* BeeAuth: Session creation nonces, better input validation * Reviews, tested expiry seconds and they work
1 parent 5c54d28 commit 4202f3c

3 files changed

Lines changed: 71 additions & 3 deletions

File tree

src/bapi/blueprints/discord.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ipaddress
2+
import re
23
import secrets
34
import urllib.parse
45

@@ -26,27 +27,34 @@
2627
@discord_blueprint.route("/auth", methods=["GET"])
2728
def discord_auth():
2829
ip = request.args.get("ip")
30+
ip_str = None
2931
if not isinstance(ip, str):
3032
return jsonify({"error": "provided IP address invalid"}), 400
3133
try:
34+
ip_str = ip
3235
ip = ipaddress.ip_address(ip)
3336
except ValueError:
3437
return jsonify({"error": "provided IP address invalid"}), 400
38+
if "," in ip_str:
39+
return jsonify({"error": "provided IP address invalid"}), 400
3540
if ip.version == 6:
3641
return jsonify({"error": "IPv6 address not allowed"}), 400
3742
if ip.is_multicast or ip.is_unspecified:
3843
return jsonify({"error": "multicast or unspecified address not allowed"}), 400
3944
seeker_port = request.args.get("seeker_port")
40-
if not isinstance(seeker_port, str) or not seeker_port.isdigit():
45+
if not isinstance(seeker_port, str) or not re.match("^[0-9]+$", seeker_port):
4146
seeker_port = ""
4247
try:
4348
seeker_port = int(seeker_port)
4449
except ValueError:
4550
seeker_port = ""
4651
if not isinstance(seeker_port, int) or seeker_port > 65535 or seeker_port < 1023:
4752
seeker_port = ""
53+
nonce = request.args.get("nonce")
54+
if not isinstance(nonce, str) or len(nonce) != 64 or not re.match("^[a-z0-9]+$", nonce):
55+
return jsonify({"error": "bad nonce"}), 400
4856
session["oauth2_state"] = (
49-
f"{urllib.parse.quote(ip.exploded, safe="", encoding="utf-8")},{seeker_port},{secrets.token_urlsafe(16)}"
57+
f"{urllib.parse.quote(ip_str, safe="", encoding="utf-8")},{seeker_port},{nonce},{secrets.token_urlsafe(16)}"
5058
)
5159
return redirect(discord_client.generate_uri(scope=["identify"], state=session["oauth2_state"]))
5260

@@ -65,8 +73,34 @@ def discord_callback():
6573
del session["oauth2_state"]
6674

6775
state_attrs = state.split(",")
76+
if len(state_attrs) != 4:
77+
return jsonify({"error": "bad state"}), 400
6878
ip = urllib.parse.unquote(state_attrs[0])
69-
seeker_port = state_attrs[1]
79+
try:
80+
seeker_port = int(state_attrs[1])
81+
except ValueError:
82+
return jsonify({"error": "bad state"}), 400
83+
nonce = state_attrs[2]
84+
if not isinstance(nonce, str) or len(nonce) != 64 or not re.match("^[a-z0-9]+$", nonce):
85+
return jsonify({"error": "bad state"}), 400
86+
nonce_duration = cfg.API.get("nonce-valid-duration")
87+
if nonce_duration is None:
88+
nonce_duration = 240
89+
try:
90+
nonce_valid, reason_invalid = db.SessionCreationNonce.is_valid_session_creation(
91+
ip, seeker_port, nonce, nonce_duration
92+
)
93+
if not nonce_valid:
94+
notice = ""
95+
if reason_invalid == "invalid":
96+
notice = " account security risk: check if connected to a genuine BeeStation game server."
97+
elif reason_invalid == "expired":
98+
notice = " log in within a shorter time period."
99+
return jsonify({"error": f"{reason_invalid or "invalid"} nonce.{notice}"}), 401
100+
except Exception as e:
101+
current_app.logger.error(f"error while checking nonce: {e}")
102+
return jsonify({"error": "error checking nonce"}), 500
103+
70104
discord_uid = None
71105
discord_username = None
72106

src/bapi/config/api.yml

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

77
request-source: "bapi"
88
game-session-duration: 90 # valid duration of created game session tokens, in days.
9+
nonce-valid-duration: 240 # valid duration of a session creation nonce, in seconds.

src/bapi/db.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sqlalchemy import SmallInteger
1616
from sqlalchemy import String
1717
from sqlalchemy import Text
18+
from sqlalchemy.orm import column_property
1819
from sqlalchemy.orm.exc import NoResultFound
1920
from sqlalchemy.sql.expression import text
2021

@@ -55,6 +56,38 @@ def create_session(cls, ip, external_method, external_uid, external_display_name
5556
return random_token
5657

5758

59+
class SessionCreationNonce(sqlalchemy_ext.Model):
60+
__bind_key__ = "session"
61+
__tablename__ = "SS13_session_creation_nonce"
62+
63+
created = Column("created", DateTime())
64+
id = Column("id", Integer(), primary_key=True)
65+
ip = Column("ip", String(32))
66+
session_nonce = Column("session_nonce", String(64))
67+
seeker_port = Column("seeker_port", Integer())
68+
seconds_since_creation = column_property(func.timestampdiff(text("SECOND"), created, func.now()))
69+
70+
@classmethod
71+
def is_valid_session_creation(cls, ip, seeker_port, nonce, valid_duration):
72+
valid_nonce = None
73+
try:
74+
valid_nonce = (
75+
db_session.query(cls)
76+
.filter(and_(cls.session_nonce == nonce, cls.ip == ip, cls.seeker_port == seeker_port))
77+
.one()
78+
)
79+
except NoResultFound:
80+
return (False, "invalid")
81+
if valid_nonce is None:
82+
return (False, "invalid")
83+
else:
84+
db_session.delete(valid_nonce)
85+
print(valid_nonce.seconds_since_creation)
86+
if valid_nonce.seconds_since_creation > (valid_duration or 240):
87+
return (False, "expired")
88+
return (True, "")
89+
90+
5891
class Player(sqlalchemy_ext.Model):
5992
__bind_key__ = "game"
6093
__tablename__ = "SS13_player"

0 commit comments

Comments
 (0)