From edf5495fe5b2dd2a11a9568fb3dc9029107fc623 Mon Sep 17 00:00:00 2001 From: Terry Geng Date: Mon, 18 May 2020 10:17:08 +0800 Subject: [PATCH 1/3] feat: several improvements to azlux's token auth scheme #154 1. 'auth_method' in config, where users can select between 'password' and 'token'. 2. create index for token, avoid iterating the entire user section when validating tokens. 3. only generate token for a user when there's no token for him in the db, avoid tokens fill the db. --- command.py | 15 ++++++-- configuration.default.ini | 13 +++---- configuration.example.ini | 24 ++++++------ interface.py | 80 ++++++++++++++++----------------------- static/js/custom.js | 1 + 5 files changed, 64 insertions(+), 69 deletions(-) diff --git a/command.py b/command.py index 4d29eba..ddd41e3 100644 --- a/command.py +++ b/command.py @@ -1183,9 +1183,18 @@ def cmd_web_access(bot, user, text, command, parameter): import secrets import datetime import json - token = secrets.token_urlsafe(5) - var.db.set("user", user, json.dumps({'token': token, 'datetime': str(datetime.datetime.now()), 'IP':''})) - bot.send_msg(constants.strings('webpage_token',token=token), text) + + user_info = var.db.get("user", user, fallback=None) + if user_info is not None: + user_dict = json.loads(user_info) + token = user_dict['token'] + else: + token = secrets.token_urlsafe(5) + var.db.set("web_token", token, user) + var.db.set("user", user, json.dumps({'token': token, 'datetime': str(datetime.datetime.now()), 'IP': ''})) + + access_address = var.config.get("webinterface", "access_address") + bot.send_msg(constants.strings('webpage_token', address=access_address, token=token), text) # Just for debug use def cmd_real_time_rms(bot, user, text, command, parameter): diff --git a/configuration.default.ini b/configuration.default.ini index 24ff1e5..38ab075 100644 --- a/configuration.default.ini +++ b/configuration.default.ini @@ -96,14 +96,13 @@ listening_addr = 127.0.0.1 listening_port = 8181 web_logfile = -# Set this option to True to enable password protection for the web interface -require_auth = False -user = -password = +auth_method = password +user = botamusique +password = mumble + +access_address = http://127.0.0.1:8181 -# Set this option to match mumble user with token on flask and add a password to encrypt/sign cookies flask_secret = ChangeThisPassword -match_mumble_user = False [debug] # Set ffmpeg to True if you want to display DEBUG level log of ffmpeg. @@ -259,7 +258,7 @@ cleared_tags = Removed all tags from {song}. cleared_tags_from_all = Removed all tags from songs on the playlist. shortlist_instruction = Use !sl {indexes} to play the item you want. auto_paused = Use !play to resume music! -webpage_token= Your token to access the Bot webpage is {token}, short URL +webpage_token= Your own address to access the web interface is {address}/?token={token} help =

Commands

Control diff --git a/configuration.example.ini b/configuration.example.ini index 72e21f9..59462cc 100644 --- a/configuration.example.ini +++ b/configuration.example.ini @@ -108,33 +108,35 @@ port = 64738 # - "pause", # - "pause_resume" (pause and resume once somebody re-enters the channel) # - "stop" (also clears playlist) -# - "nothing" or leave empty (do nothing) -#when_nobody_in_channel = nothing +# - leave empty (do nothing) +#when_nobody_in_channel = # [webinterface] stores settings related to the web interface. [webinterface] # 'enable': Set 'enabled' to True if you'd like to use the web interface to manage # your playlist, upload files, etc. # The web interface is disable by default for security and performance reason. +# 'access_address': Used when user are questing the address to access the web interface. #enabled = False #listening_addr = 127.0.0.1 #listening_port = 8181 #is_web_proxified = True +#access_address = http://127.0.0.1:8181 # 'web_logfile': write access logs of the web server into this file. #web_logfile = -# 'required_auth': Set this to True to enable password protection for the web interface. -#require_auth = False -#user = -#password = +# 'auth_method': Method used to authenticate users accessing the web interface. +# Options are 'password', 'token', 'none' +#auth_method = password -# Set this option to match mumble user with user on the webinterface -# It's working with an unique token an user can ask to the bot with token and to add music to the bot. -# It's also allow users to know who have add a music from the webinterface -# match_mumble_user = True +# 'user', 'password': If auth_method set to 'password', you need to set the username and +# password. +#user = botamusique +#password = mumble -# To use token (need session) flask need a password to encrypt/sign cookies used. !! YOU NEED TO CHANGE IT IF PREVIOUS OPTION IS TRUE!! +# 'flask_secret': To use token, flask need a password to encrypt/sign cookies. +# !! YOU NEED TO CHANGE IT IF auth_method IS 'token'!! # flask_secret = ChangeThisPassword # [debug] stores some debug settings. diff --git a/interface.py b/interface.py index 99b4bda..c5c5de9 100644 --- a/interface.py +++ b/interface.py @@ -94,53 +94,41 @@ def authenticate(): def requires_auth(f): - @wraps(f) - def decorated(*args, **kwargs): - global log - auth = request.authorization - if var.config.getboolean("webinterface", "require_auth") and ( - not auth or not check_auth(auth.username, auth.password)): - if auth: - log.info("web: Failed login attempt, user: %s" % auth.username) - return authenticate() - return f(*args, **kwargs) - - return decorated - - -def set_cookie_token(f): - @wraps(f) - def decorated(*args, **kwargs): - global log - if var.config.getboolean("webinterface", "match_mumble_user"): - users = var.db.items('user') - if users: - for user in users: - tp = json.loads(user[1]) - log.info(tp) - if tp['token'] == request.args.get('token'): - t_user = user[0] - log.info(f"web: token validated for the user: {t_user}") - session['user']=t_user - return f(*args, **kwargs) - log.info("web: Bad token used") - abort(403) - - return f(*args, **kwargs) - return decorated - - -def requires_token(f): @wraps(f) def decorated(*args, **kwargs): global log, user - if var.config.getboolean("webinterface", "match_mumble_user"): - if 'user' in session: - log.debug(f"Request done by {session['user']}") - user = session['user'] + + auth_method = var.config.get("webinterface", "auth_method") + + if auth_method == 'password': + auth = request.authorization + if var.config.getboolean("webinterface", "require_auth") and ( + not auth or not check_auth(auth.username, auth.password)): + if auth: + log.warning("web: failed login attempt, user: %s" % auth.username) + return authenticate() + if auth_method == 'token': + if 'token' in session: + token = session['token'] + token_user = var.db.get("web_token", token, fallback=None) + if token_user is not None: + user = token_user + log.debug(f"web: token validated for the user: {token_user}") + return f(*args, **kwargs) else: - abort(403) + token = request.args.get('token') + token_user = var.db.get("web_token", token, fallback=None) + if token_user is not None: + user = token_user + log.info(f"web: new user access, token validated for the user: {token_user}") + session['token'] = token + return f(*args, **kwargs) + + log.info(f"web: bad token used: {token}") + abort(403) + return f(*args, **kwargs) + return decorated @@ -190,7 +178,6 @@ def get_all_dirs(): @web.route("/", methods=['GET']) @requires_auth -@set_cookie_token def index(): while var.cache.dir_lock.locked(): time.sleep(0.1) @@ -205,7 +192,6 @@ def index(): @web.route("/playlist", methods=['GET']) @requires_auth -@requires_token def playlist(): if len(var.playlist) == 0: return ('', 204) @@ -301,7 +287,6 @@ def status(): @web.route("/post", methods=['POST']) @requires_auth -@requires_token def post(): global log @@ -520,7 +505,6 @@ def build_library_query_condition(form): @web.route("/library", methods=['POST']) @requires_auth -@requires_token def library(): global log ITEM_PER_PAGE = 10 @@ -622,8 +606,7 @@ def library(): @web.route('/upload', methods=["POST"]) -#@requires_auth missing here ? -@requires_token +@requires_auth def upload(): global log @@ -672,6 +655,7 @@ def upload(): @web.route('/download', methods=["GET"]) +@requires_auth def download(): global log diff --git a/static/js/custom.js b/static/js/custom.js index 1064aab..8b5eec2 100644 --- a/static/js/custom.js +++ b/static/js/custom.js @@ -928,6 +928,7 @@ function uploadNextFile(){ form.append('targetdir', uploadTargetDir.value); req.open('POST', 'upload'); + req.withCredentials = true; req.send(form); file_progress_item.progress.classList.add("progress-bar-striped"); From 62a115b56ef038e2f531d8f8582cc1982f006d47 Mon Sep 17 00:00:00 2001 From: Terry Geng Date: Mon, 18 May 2020 13:30:18 +0800 Subject: [PATCH 2/3] feat: record IP. refresh cookie if new token is provided --- command.py | 3 ++- interface.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/command.py b/command.py index ddd41e3..dcf4da3 100644 --- a/command.py +++ b/command.py @@ -1191,7 +1191,8 @@ def cmd_web_access(bot, user, text, command, parameter): else: token = secrets.token_urlsafe(5) var.db.set("web_token", token, user) - var.db.set("user", user, json.dumps({'token': token, 'datetime': str(datetime.datetime.now()), 'IP': ''})) + + var.db.set("user", user, json.dumps({'token': token, 'datetime': str(datetime.datetime.now()), 'IP': ''})) access_address = var.config.get("webinterface", "access_address") bot.send_msg(constants.strings('webpage_token', address=access_address, token=token), text) diff --git a/interface.py b/interface.py index c5c5de9..21547d7 100644 --- a/interface.py +++ b/interface.py @@ -105,26 +105,32 @@ def requires_auth(f): if var.config.getboolean("webinterface", "require_auth") and ( not auth or not check_auth(auth.username, auth.password)): if auth: - log.warning("web: failed login attempt, user: %s" % auth.username) + log.warning(f"web: failed login attempt, user: {auth.username}, from ip {request.remote_addr}.") return authenticate() if auth_method == 'token': - if 'token' in session: + if 'token' in session and 'token' not in request.args: token = session['token'] token_user = var.db.get("web_token", token, fallback=None) if token_user is not None: user = token_user - log.debug(f"web: token validated for the user: {token_user}") + log.debug(f"web: token validated for the user: {token_user}, from ip {request.remote_addr}.") return f(*args, **kwargs) - else: + elif 'token' in request.args: token = request.args.get('token') token_user = var.db.get("web_token", token, fallback=None) if token_user is not None: user = token_user - log.info(f"web: new user access, token validated for the user: {token_user}") + + user_info = var.db.get("user", user, fallback=None) + user_dict = json.loads(user_info) + user_dict['IP'] = request.remote_addr + var.db.set("user", user, json.dumps(user_dict)) + + log.info(f"web: new user access, token validated for the user: {token_user}, from ip {request.remote_addr}.") session['token'] = token return f(*args, **kwargs) - log.info(f"web: bad token used: {token}") + log.info(f"web: bad token used: {token}, from ip {request.remote_addr}.") abort(403) return f(*args, **kwargs) From 174ec3e7ece88c13b3ca6b4c6bcf513c54f56471 Mon Sep 17 00:00:00 2001 From: Terry Geng Date: Mon, 18 May 2020 14:53:35 +0800 Subject: [PATCH 3/3] fix: bad token message --- interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface.py b/interface.py index 21547d7..4af9f9d 100644 --- a/interface.py +++ b/interface.py @@ -130,7 +130,7 @@ def requires_auth(f): session['token'] = token return f(*args, **kwargs) - log.info(f"web: bad token used: {token}, from ip {request.remote_addr}.") + log.info(f"web: bad token from ip {request.remote_addr}.") abort(403) return f(*args, **kwargs)