User authentication in the Web Interface (#483)

* Add flask-login library

* Add simplepam lib to requirements

* We don't use flask-login after all

* User authentication in the web app using simplepam

* Allow only users in the sudo group to log in

* Tweak string

* This way to enforce authenticated state doesn't work here

* Open links to github in new tab

* Disallow uploads when not authenticated

* Check for the rascsi group on the system to enable webapp auth. Allow only users in the rascsi group to authenticate.

* Make the AUTH_GROUP a global constant.

* Add easyinstall option for web interface auth

* Make AUTH_GROUP a constant

* More accurate change scope
This commit is contained in:
Daniel Markstedt 2021-11-26 20:41:10 -08:00 committed by GitHub
parent 1ad1242fad
commit 5346d110a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 229 additions and 8 deletions

View File

@ -700,6 +700,21 @@ function notifyBackup {
fi fi
} }
# Creates the group and modifies current user for Web Interface auth
function enableWebInterfaceAuth {
AUTH_GROUP="rascsi"
if [ $(getent group "$AUTH_GROUP") ]; then
echo "The '$AUTH_GROUP' group already exists."
else
echo "Creating the '$AUTH_GROUP' group."
sudo groupadd "$AUTH_GROUP"
fi
echo "Adding user '$USER' to the '$AUTH_GROUP' group."
sudo usermod -a -G "$AUTH_GROUP" "$USER"
}
# Executes the keyword driven scripts for a particular action in the main menu # Executes the keyword driven scripts for a particular action in the main menu
function runChoice() { function runChoice() {
case $1 in case $1 in
@ -855,6 +870,15 @@ function runChoice() {
echo "Configuring RaSCSI Web Interface stand-alone - Complete!" echo "Configuring RaSCSI Web Interface stand-alone - Complete!"
echo "Launch the Web Interface with the 'start.sh' script. To use a custom port for the web server: 'start.sh --port=8081" echo "Launch the Web Interface with the 'start.sh' script. To use a custom port for the web server: 'start.sh --port=8081"
;; ;;
12)
echo "Enabling authentication for the RaSCSI Web Interface"
echo "This script will make the following changes to your system:"
echo "- Modify users and user groups"
sudoCheck
enableWebInterfaceAuth
echo "Enabling authentication for the RaSCSI Web Interface - Complete!"
echo "Use the credentials for user '$USER' to log in to the Web Interface."
;;
-h|--help|h|help) -h|--help|h|help)
showMenu showMenu
;; ;;
@ -868,7 +892,7 @@ function runChoice() {
function readChoice() { function readChoice() {
choice=-1 choice=-1
until [ $choice -ge "0" ] && [ $choice -le "11" ]; do until [ $choice -ge "0" ] && [ $choice -le "12" ]; do
echo -n "Enter your choice (0-9) or CTRL-C to exit: " echo -n "Enter your choice (0-9) or CTRL-C to exit: "
read -r choice read -r choice
done done
@ -897,6 +921,7 @@ function showMenu() {
echo "ADVANCED OPTIONS" echo "ADVANCED OPTIONS"
echo " 10) compile and install RaSCSI stand-alone" echo " 10) compile and install RaSCSI stand-alone"
echo " 11) configure the RaSCSI Web Interface stand-alone" echo " 11) configure the RaSCSI Web Interface stand-alone"
echo " 12) enable authentication for the RaSCSI Web Interface"
} }
# parse arguments passed to the script # parse arguments passed to the script
@ -919,7 +944,7 @@ while [ "$1" != "" ]; do
;; ;;
esac esac
case $VALUE in case $VALUE in
FULLSPEC | STANDARD | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11) FULLSPEC | STANDARD | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12)
;; ;;
*) *)
echo "ERROR: unknown option \"$VALUE\"" echo "ERROR: unknown option \"$VALUE\""

View File

@ -3,6 +3,7 @@ Module for methods controlling and getting information about the Pi's Linux syst
""" """
import subprocess import subprocess
from settings import AUTH_GROUP
def systemd_service(service, action): def systemd_service(service, action):
@ -115,4 +116,20 @@ def introspect_file(file_path, re_term):
for line in ifile: for line in ifile:
if match(re_term, line): if match(re_term, line):
return True return True
return False return False
def auth_active():
"""
Inspects if the group defined in AUTH_GROUP exists on the system.
If it exists, tell the webapp to enable authentication.
Returns a (dict) with (bool) status and (str) msg
"""
from grp import getgrall
groups = [g.gr_name for g in getgrall()]
if AUTH_GROUP in groups:
return {
"status": True,
"msg": "You must log in to use this function!",
}
return {"status": False, "msg": ""}

View File

@ -6,3 +6,4 @@ Jinja2==3.0.1
MarkupSafe==2.0.1 MarkupSafe==2.0.1
protobuf==3.17.3 protobuf==3.17.3
requests==2.26.0 requests==2.26.0
simplepam==0.1.5

View File

@ -27,3 +27,6 @@ REMOVABLE_DEVICE_TYPES = ("SCCD", "SCRM", "SCMO")
# The RESERVATIONS list is used to keep track of the reserved ID memos. # The RESERVATIONS list is used to keep track of the reserved ID memos.
# Initialize with a list of 8 empty strings. # Initialize with a list of 8 empty strings.
RESERVATIONS = ["" for x in range(0, 8)] RESERVATIONS = ["" for x in range(0, 8)]
# The user group that is used for webapp authentication
AUTH_GROUP = "rascsi"

View File

@ -31,12 +31,27 @@
<body> <body>
<div class="content"> <div class="content">
<div class="header"> <div class="header">
<span style="display: inline-block; width: 100%; color: white; background-color: green; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">Service Running</span> {% if auth_active %}
{% if username %}
<span style="display: inline-block; width: 100%; color: white; background-color: green; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">Logged in as <em>{{ username }}</em> &#8211; <a href="/logout">Log Out</a></span>
{% else %}
<span style="display: inline-block; width: 100%; color: white; background-color: red; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">
<form method="POST" action="/login">
<div>Log In to Use Web Interface</div>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<input type="submit" value="Login">
</form>
</span>
{% endif %}
{% else %}
<span style="display: inline-block; width: 100%; color: white; background-color: green; text-align: center; vertical-align: center; font-family: Arial, Helvetica, sans-serif;">Web Interface Authentication Disabled &#8211; See <a href="https://github.com/akuker/RASCSI/wiki/Web-Interface#Security_Notice" target="_blank">Wiki</a> for more information</span>
{% endif %}
<table width="100%"> <table width="100%">
<tbody> <tbody>
<tr style="background-color: black;"> <tr style="background-color: black;">
<td style="background-color: black;"> <td style="background-color: black;">
<a href="http://github.com/akuker/RASCSI"> <a href="http://github.com/akuker/RASCSI" target="_blank">
<h1>RaSCSI - 68kmla Edition</h1> <h1>RaSCSI - 68kmla Edition</h1>
</a> </a>
</td> </td>
@ -57,7 +72,7 @@
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</div> </div>
<div class="footer"> <div class="footer">
<center><tt>RaSCSI version: <strong>{{ version }} <a href="https://github.com/akuker/RASCSI/commit/{{ running_env['git'] }}">{{ running_env["git"][:7] }}</a></strong></tt></center> <center><tt>RaSCSI version: <strong>{{ version }} <a href="https://github.com/akuker/RASCSI/commit/{{ running_env['git'] }}" target="_blank">{{ running_env["git"][:7] }}</a></strong></tt></center>
<center><tt>Pi environment: {{ running_env["env"] }}</tt></center> <center><tt>Pi environment: {{ running_env["env"] }}</tt></center>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@ from flask import (
send_file, send_file,
send_from_directory, send_from_directory,
make_response, make_response,
session,
) )
from file_cmds import ( from file_cmds import (
@ -42,6 +43,7 @@ from pi_cmds import (
disk_space, disk_space,
get_ip_address, get_ip_address,
introspect_file, introspect_file,
auth_active,
) )
from ractl_cmds import ( from ractl_cmds import (
attach_image, attach_image,
@ -71,6 +73,7 @@ from settings import (
DRIVE_PROPERTIES_FILE, DRIVE_PROPERTIES_FILE,
REMOVABLE_DEVICE_TYPES, REMOVABLE_DEVICE_TYPES,
RESERVATIONS, RESERVATIONS,
AUTH_GROUP,
) )
APP = Flask(__name__) APP = Flask(__name__)
@ -111,6 +114,11 @@ def index():
[ARCHIVE_FILE_SUFFIX] [ARCHIVE_FILE_SUFFIX]
) )
if "username" in session:
username = session["username"]
else:
username = None
return render_template( return render_template(
"index.html", "index.html",
bridge_configured=is_bridge_setup(), bridge_configured=is_bridge_setup(),
@ -141,6 +149,8 @@ def index():
cdrom_file_suffix=tuple(server_info["sccd"]), cdrom_file_suffix=tuple(server_info["sccd"]),
removable_file_suffix=tuple(server_info["scrm"]), removable_file_suffix=tuple(server_info["scrm"]),
mo_file_suffix=tuple(server_info["scmo"]), mo_file_suffix=tuple(server_info["scmo"]),
username=username,
auth_active=auth_active()["status"],
ARCHIVE_FILE_SUFFIX=ARCHIVE_FILE_SUFFIX, ARCHIVE_FILE_SUFFIX=ARCHIVE_FILE_SUFFIX,
PROPERTIES_SUFFIX=PROPERTIES_SUFFIX, PROPERTIES_SUFFIX=PROPERTIES_SUFFIX,
REMOVABLE_DEVICE_TYPES=REMOVABLE_DEVICE_TYPES, REMOVABLE_DEVICE_TYPES=REMOVABLE_DEVICE_TYPES,
@ -192,6 +202,11 @@ def drive_list():
cd_conf = sorted(cd_conf, key=lambda x: x["name"].lower()) cd_conf = sorted(cd_conf, key=lambda x: x["name"].lower())
rm_conf = sorted(rm_conf, key=lambda x: x["name"].lower()) rm_conf = sorted(rm_conf, key=lambda x: x["name"].lower())
if "username" in session:
username = session["username"]
else:
username = None
return render_template( return render_template(
"drives.html", "drives.html",
files=sorted_image_files, files=sorted_image_files,
@ -203,15 +218,46 @@ def drive_list():
version=server_info["version"], version=server_info["version"],
free_disk=int(disk["free"] / 1024 / 1024), free_disk=int(disk["free"] / 1024 / 1024),
cdrom_file_suffix=tuple(server_info["sccd"]), cdrom_file_suffix=tuple(server_info["sccd"]),
username=username,
auth_active=auth_active()["status"],
) )
@APP.route('/pwa/<path:path>') @APP.route("/login", methods=["POST"])
def login():
"""
Uses simplepam to authenticate against Linux users
"""
username = request.form["username"]
password = request.form["password"]
from simplepam import authenticate
from grp import getgrall
groups = [g.gr_name for g in getgrall() if username in g.gr_mem]
if AUTH_GROUP in groups:
if authenticate(str(username), str(password)):
session["username"] = request.form["username"]
return redirect(url_for("index"))
flash(f"You must log in with credentials for a user in the '{AUTH_GROUP}' group!", "error")
return redirect(url_for("index"))
@APP.route("/logout")
def logout():
"""
Removes the logged in user from the session
"""
session.pop("username", None)
return redirect(url_for("index"))
@APP.route("/pwa/<path:path>")
def send_pwa_files(path): def send_pwa_files(path):
""" """
Sets up mobile web resources Sets up mobile web resources
""" """
return send_from_directory('pwa', path) return send_from_directory("pwa", path)
@APP.route("/drive/create", methods=["POST"]) @APP.route("/drive/create", methods=["POST"])
@ -219,6 +265,11 @@ def drive_create():
""" """
Creates the image and properties file pair Creates the image and properties file pair
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
vendor = request.form.get("vendor") vendor = request.form.get("vendor")
product = request.form.get("product") product = request.form.get("product")
revision = request.form.get("revision") revision = request.form.get("revision")
@ -257,6 +308,11 @@ def drive_cdrom():
""" """
Creates a properties file for a CD-ROM image Creates a properties file for a CD-ROM image
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
vendor = request.form.get("vendor") vendor = request.form.get("vendor")
product = request.form.get("product") product = request.form.get("product")
revision = request.form.get("revision") revision = request.form.get("revision")
@ -285,6 +341,11 @@ def config_save():
""" """
Saves a config file to disk Saves a config file to disk
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
file_name = request.form.get("name") or "default" file_name = request.form.get("name") or "default"
file_name = f"{file_name}.{CONFIG_FILE_SUFFIX}" file_name = f"{file_name}.{CONFIG_FILE_SUFFIX}"
@ -302,6 +363,11 @@ def config_load():
""" """
Loads a config file from disk Loads a config file from disk
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
file_name = request.form.get("name") file_name = request.form.get("name")
if "load" in request.form: if "load" in request.form:
@ -354,6 +420,11 @@ def log_level():
""" """
Sets RaSCSI backend log level Sets RaSCSI backend log level
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
level = request.form.get("level") or "info" level = request.form.get("level") or "info"
process = set_log_level(level) process = set_log_level(level)
@ -370,6 +441,11 @@ def daynaport_attach():
""" """
Attaches a DaynaPORT ethernet adapter device Attaches a DaynaPORT ethernet adapter device
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
interface = request.form.get("if") interface = request.form.get("if")
ip_addr = request.form.get("ip") ip_addr = request.form.get("ip")
@ -414,6 +490,11 @@ def attach():
""" """
Attaches a file image as a device Attaches a file image as a device
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
file_name = request.form.get("file_name") file_name = request.form.get("file_name")
file_size = request.form.get("file_size") file_size = request.form.get("file_size")
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
@ -466,6 +547,11 @@ def detach_all_devices():
""" """
Detaches all currently attached devices Detaches all currently attached devices
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
process = detach_all() process = detach_all()
if process["status"]: if process["status"]:
flash("Detached all SCSI devices") flash("Detached all SCSI devices")
@ -480,6 +566,11 @@ def detach():
""" """
Detaches a specified device Detaches a specified device
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
unit = request.form.get("unit") unit = request.form.get("unit")
process = detach_by_id(scsi_id, unit) process = detach_by_id(scsi_id, unit)
@ -497,6 +588,11 @@ def eject():
""" """
Ejects a specified removable device image, but keeps the device attached Ejects a specified removable device image, but keeps the device attached
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
unit = request.form.get("unit") unit = request.form.get("unit")
@ -549,6 +645,11 @@ def reserve_id():
""" """
Reserves a SCSI ID and stores the memo for that reservation Reserves a SCSI ID and stores the memo for that reservation
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
memo = request.form.get("memo") memo = request.form.get("memo")
reserved_ids = get_reserved_ids()["ids"] reserved_ids = get_reserved_ids()["ids"]
@ -567,6 +668,11 @@ def unreserve_id():
""" """
Removes the reservation of a SCSI ID as well as the memo for the reservation Removes the reservation of a SCSI ID as well as the memo for the reservation
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
reserved_ids = get_reserved_ids()["ids"] reserved_ids = get_reserved_ids()["ids"]
reserved_ids.remove(scsi_id) reserved_ids.remove(scsi_id)
@ -584,6 +690,11 @@ def restart():
""" """
Restarts the Pi Restarts the Pi
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
detach_all() detach_all()
flash("Safely detached all devices.") flash("Safely detached all devices.")
flash("Rebooting the Pi momentarily...") flash("Rebooting the Pi momentarily...")
@ -596,6 +707,11 @@ def rascsi_restart():
""" """
Restarts the RaSCSI backend service Restarts the RaSCSI backend service
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
detach_all() detach_all()
flash("Safely detached all devices.") flash("Safely detached all devices.")
flash("Restarting RaSCSI Service...") flash("Restarting RaSCSI Service...")
@ -609,6 +725,11 @@ def shutdown():
""" """
Shuts down the Pi Shuts down the Pi
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
detach_all() detach_all()
flash("Safely detached all devices.") flash("Safely detached all devices.")
flash("Shutting down the Pi momentarily...") flash("Shutting down the Pi momentarily...")
@ -621,6 +742,11 @@ def download_to_iso():
""" """
Downloads a remote file and creates a CD-ROM image formatted with HFS that contains the file Downloads a remote file and creates a CD-ROM image formatted with HFS that contains the file
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
scsi_id = request.form.get("scsi_id") scsi_id = request.form.get("scsi_id")
url = request.form.get("url") url = request.form.get("url")
@ -647,6 +773,11 @@ def download_img():
""" """
Downloads a remote file onto the images dir on the Pi Downloads a remote file onto the images dir on the Pi
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
url = request.form.get("url") url = request.form.get("url")
server_info = get_server_info() server_info = get_server_info()
process = download_to_dir(url, server_info["image_dir"]) process = download_to_dir(url, server_info["image_dir"])
@ -664,6 +795,11 @@ def download_afp():
""" """
Downloads a remote file onto the AFP shared dir on the Pi Downloads a remote file onto the AFP shared dir on the Pi
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
url = request.form.get("url") url = request.form.get("url")
process = download_to_dir(url, AFP_DIR) process = download_to_dir(url, AFP_DIR)
if process["status"]: if process["status"]:
@ -681,6 +817,10 @@ def upload_file():
Uploads a file from the local computer to the images dir on the Pi Uploads a file from the local computer to the images dir on the Pi
Depending on the Dropzone.js JavaScript library Depending on the Dropzone.js JavaScript library
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
return make_response(auth["msg"], 403)
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from os import path from os import path
@ -729,6 +869,11 @@ def create_file():
""" """
Creates an empty image file in the images dir Creates an empty image file in the images dir
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
file_name = request.form.get("file_name") file_name = request.form.get("file_name")
size = (int(request.form.get("size")) * 1024 * 1024) size = (int(request.form.get("size")) * 1024 * 1024)
file_type = request.form.get("type") file_type = request.form.get("type")
@ -750,6 +895,11 @@ def download():
""" """
Downloads a file from the Pi to the local computer Downloads a file from the Pi to the local computer
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
image = request.form.get("file") image = request.form.get("file")
return send_file(image, as_attachment=True) return send_file(image, as_attachment=True)
@ -759,6 +909,11 @@ def delete():
""" """
Deletes a specified file in the images dir Deletes a specified file in the images dir
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
file_name = request.form.get("image") file_name = request.form.get("image")
process = delete_image(file_name) process = delete_image(file_name)
@ -787,6 +942,11 @@ def unzip():
""" """
Unzips a specified zip file Unzips a specified zip file
""" """
auth = auth_active()
if auth["status"] and "username" not in session:
flash(auth["msg"], "error")
return redirect(url_for("index"))
zip_file = request.form.get("zip_file") zip_file = request.form.get("zip_file")
zip_member = request.form.get("zip_member") or False zip_member = request.form.get("zip_member") or False
zip_members = request.form.get("zip_members") or False zip_members = request.form.get("zip_members") or False