diff --git a/bemani/common/__init__.py b/bemani/common/__init__.py index 4dba980..ad4ebd5 100644 --- a/bemani/common/__init__.py +++ b/bemani/common/__init__.py @@ -1,6 +1,6 @@ from bemani.common.model import Model from bemani.common.validateddict import ValidatedDict, Profile, PlayStatistics, intish -from bemani.common.http import HTTP +from bemani.common.http import HTTP, format_recovery_link from bemani.common.constants import ( APIConstants, GameConstants, @@ -41,4 +41,5 @@ __all__ = [ "InvalidOffsetException", "cache", "debugonly", + "format_recovery_link", ] diff --git a/bemani/common/http.py b/bemani/common/http.py index a020bde..e3ca3f1 100644 --- a/bemani/common/http.py +++ b/bemani/common/http.py @@ -1,4 +1,7 @@ -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from bemani.data import Config class HTTP: @@ -160,3 +163,12 @@ class HTTP: # Concatenate it with the binary data return "\r\n".join(out).encode("ascii") + b"\r\n\r\n" + data + + +def format_recovery_link(config: "Config", token: str) -> str: + url = f"{config.server.uri}/recover/{token}" + while "//" in url: + url = url.replace("//", "/") + url = url.replace("http:/", "http://") + url = url.replace("https:/", "https://") + return url diff --git a/bemani/data/mysql/base.py b/bemani/data/mysql/base.py index 9f9de76..1d7ff03 100644 --- a/bemani/data/mysql/base.py +++ b/bemani/data/mysql/base.py @@ -1,5 +1,6 @@ import json import random +import string from typing import Final, Dict, Any, Optional, cast from bemani.common import Time @@ -37,7 +38,8 @@ class _BytesEncoder(json.JSONEncoder): class BaseData: - SESSION_LENGTH: Final[int] = 32 + # Chosen to have 512 unique bits of information. + SESSION_LENGTH: Final[int] = 24 def __init__(self, config: Config, conn: scoped_session) -> None: """ @@ -158,7 +160,7 @@ class BaseData: """ # Create a new session that is unique while True: - session = "".join(random.choice("0123456789ABCDEF") for _ in range(BaseData.SESSION_LENGTH)) + session = "".join(random.choice(string.digits + string.ascii_lowercase) for _ in range(BaseData.SESSION_LENGTH)) sql = "SELECT session FROM session WHERE session = :session" cursor = self.execute(sql, {"session": session}) if cursor.rowcount == 0: diff --git a/bemani/data/mysql/user.py b/bemani/data/mysql/user.py index 92f4709..6d82916 100644 --- a/bemani/data/mysql/user.py +++ b/bemani/data/mysql/user.py @@ -284,6 +284,26 @@ class UserData(BaseData): return UserID(userid) + def from_recovery(self, recovery: str) -> Optional[UserID]: + """ + Given a previously-generated recovery token, look up a user ID. + + Parameters: + recovery - String identifying a password recovery token that was returned by create_recovery. + + Returns: + User ID as an integer if found, or None if the recovery is expired or doesn't exist. + """ + userid = self._from_session(recovery, "recovery") + if userid is None: + return None + sql = "SELECT id FROM user WHERE id = :userid LIMIT 1" + cursor = self.execute(sql, {"userid": userid}) + if cursor.rowcount != 1: + return None + + return UserID(userid) + def get_user(self, userid: UserID) -> Optional[User]: """ Given a userid, look up details about the account. @@ -1344,6 +1364,28 @@ class UserData(BaseData): """ self._destroy_session(session, "userid") + def create_recovery(self, userid: UserID, expiration: int = (1 * 86400)) -> str: + """ + Given a user ID, create a recovery string for password recovery. + + Parameters: + userid - User ID we wish to generate a recovery token for. + expiration - Number of seconds before this recovery token is invalid. + + Returns: + A string that can be used as a recovery token. + """ + return self._create_session(userid, "recovery", expiration) + + def destroy_recovery(self, recovery: str) -> None: + """ + Destroy a previously-created recovery token. + + Parameters: + recovery - A recovery token as returned from create_recovery. + """ + self._destroy_session(recovery, "recovery") + def create_refid(self, game: GameConstants, version: int, userid: UserID) -> str: """ Given a game/version/userid, create a RefID and an ExtID if necessary. diff --git a/bemani/frontend/account/account.py b/bemani/frontend/account/account.py index f2e9843..0842647 100644 --- a/bemani/frontend/account/account.py +++ b/bemani/frontend/account/account.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing import Dict, Any, Optional from flask import ( Blueprint, request, @@ -75,6 +75,66 @@ def viewlogin() -> Response: return Response(render_template("account/login.html", **{"title": "Log In", "show_navigation": False})) +def recover_display(username: str, token: Optional[str]) -> Response: + return Response(render_template( + "account/recover.html", + **{"title": "Recover Password", "show_navigation": False, "token": token, "username": username}, + )) + + +@account_pages.route("/recover", methods=["POST"]) +@loginprohibited +def recover() -> Response: + username = request.form["username"] + token = request.form.get("token", "") + password1 = request.form["password1"] + password2 = request.form["password2"] + + # Now, make sure this account recovery token is valid. + if token: + userid = g.data.local.user.from_recovery(token) + else: + userid = None + if userid is None: + error("Recovery token is invalid or expired!") + return recover_display(username, token) + + # And make sure the user is valid and matches this recovery token. + user = g.data.local.user.get_user(userid) + if user is None: + error("Recovery token is invalid or expired!") + return recover_display(username, token) + + # Be a little lenient with username spelling here. + if user.username.lower() != username.lower(): + error("Recovery token is not for this account!") + return recover_display(username, token) + + # Now, make sure that the passwords match + if password1 != password2: + error("Passwords do not match each other!") + return recover_display(username, token) + + # Now, make sure passwords are long enough + if len(password1) < 6: + error("Password is not long enough!") + return recover_display(username, token) + + # Now, update the account. + g.data.local.user.update_password(userid, password1) + g.data.local.user.destroy_recovery(token) + success("Successfully updated password!") + response = make_response(redirect(url_for("account_pages.login"))) + return response + + +@account_pages.route("/recover/", defaults={"recovery": None}) +@account_pages.route("/recover/") +@loginprohibited +def viewrecover(recovery: Optional[str]) -> Response: + return recover_display("", recovery) + + def register_display(card_number: str, username: str, email: str) -> Response: return Response( render_template( diff --git a/bemani/frontend/templates/account/recover.html b/bemani/frontend/templates/account/recover.html new file mode 100644 index 0000000..be279e7 --- /dev/null +++ b/bemani/frontend/templates/account/recover.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +
+

Recover Account Password

+ +
+ {% if token %} + + {% endif %} +
+
Username
+
+
Desired Password
+
+
Desired Password (again)
+
+
+
+
+
+
+{% endblock %} diff --git a/bemani/utils/dbutils.py b/bemani/utils/dbutils.py index 0bd4fe8..04a2a96 100644 --- a/bemani/utils/dbutils.py +++ b/bemani/utils/dbutils.py @@ -3,6 +3,7 @@ import getpass import sys from typing import Optional +from bemani.common import format_recovery_link from bemani.data import Config, Data, DBCreateException from bemani.utils.config import load_config @@ -30,18 +31,30 @@ def upgrade(config: Config) -> None: def change_password(config: Config, username: Optional[str]) -> None: if username is None: raise Exception("Please provide a username!") - password1 = getpass.getpass("Password: ") - password2 = getpass.getpass("Re-enter password: ") - if password1 != password2: - raise Exception("Passwords don't match!") data = Data(config) userid = data.local.user.from_username(username) if userid is None: raise Exception("User not found!") + password1 = getpass.getpass("Password: ") + password2 = getpass.getpass("Re-enter password: ") + if password1 != password2: + raise Exception("Passwords don't match!") data.local.user.update_password(userid, password1) print(f"User {username} changed password.") +def generate_recovery(config: Config, username: Optional[str]) -> None: + if username is None: + raise Exception("Please provide a username!") + data = Data(config) + userid = data.local.user.from_username(username) + if userid is None: + raise Exception("User not found!") + token = data.local.user.create_recovery(userid) + url = format_recovery_link(config, token) + print(f"User {username} can use the following URL for password recovery: {url}") + + def add_admin(config: Config, username: Optional[str]) -> None: if username is None: raise Exception("Please provide a username!") @@ -72,7 +85,7 @@ def main() -> None: parser = argparse.ArgumentParser(description="A utility for working with databases created with this codebase.") parser.add_argument( "operation", - help="Operation to perform, options include 'create', 'generate', 'upgrade', 'change-password', 'add-admin' and 'remove-admin'.", + help="Operation to perform, options include 'create', 'generate', 'upgrade', 'change-password', 'generate-recovery', 'add-admin' and 'remove-admin'.", type=str, ) parser.add_argument( @@ -118,6 +131,8 @@ def main() -> None: remove_admin(config, args.username) elif args.operation == "change-password": change_password(config, args.username) + elif args.operation == "generate-recovery": + generate_recovery(config, args.username) else: raise Exception(f"Unknown operation '{args.operation}'") except DBCreateException as e: