Development
This commit is contained in:
parent
44261e5b5c
commit
8a8e947fcf
9 changed files with 289 additions and 33 deletions
|
|
@ -9,7 +9,7 @@ APP_DIR = _os.path.dirname(_os.path.abspath(__file__))
|
|||
CONFIGS_DIR = _settings.routlin_location()
|
||||
DATA_DIR = '/data'
|
||||
WWW_DIR = '/www'
|
||||
ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts'
|
||||
ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-singleaccount' if _settings.is_single_user() else f'{DATA_DIR}/.dashboard-accounts'
|
||||
CONFIG_FILE = f'{CONFIGS_DIR}/config.json'
|
||||
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
|
||||
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
|
||||
|
|
@ -81,6 +81,23 @@ def init_accounts_db():
|
|||
pass
|
||||
con.close()
|
||||
|
||||
def init_single_user_session_db():
|
||||
con = _sqlite3.connect(ACCOUNTS_DB, timeout=5)
|
||||
con.execute('PRAGMA journal_mode=WAL')
|
||||
con.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
logged_in INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT '',
|
||||
preferences_json TEXT NOT NULL DEFAULT '{}',
|
||||
flashes_json TEXT,
|
||||
session_started_ts INTEGER NOT NULL,
|
||||
last_seen_ts INTEGER NOT NULL
|
||||
);
|
||||
''')
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
|
||||
_LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,13 @@ from api_apply_health import bp as api_apply_health_bp
|
|||
from session_interface import SqliteSessionInterface
|
||||
|
||||
app = Flask(__name__)
|
||||
app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB)
|
||||
config_utils.init_accounts_db()
|
||||
if settings.is_single_user():
|
||||
from session_interface import SingleUserSessionInterface
|
||||
config_utils.init_single_user_session_db()
|
||||
app.session_interface = SingleUserSessionInterface(config_utils.ACCOUNTS_DB)
|
||||
else:
|
||||
app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB)
|
||||
config_utils.init_accounts_db()
|
||||
|
||||
# Static www/ serving =================================================
|
||||
|
||||
|
|
@ -83,6 +88,9 @@ def serve_view(page_name):
|
|||
if not factory.passes(view_req, level):
|
||||
return redirect('/overview' if level > 0 else '/accountlogin')
|
||||
|
||||
if settings.is_single_user() and page_name in ('accountmanage', 'accountcreate', 'accountverifyemail'):
|
||||
return redirect('/overview' if level > 0 else '/accountlogin')
|
||||
|
||||
cfg = config_utils.load_config()
|
||||
|
||||
if level >= factory.LEVEL_RANK['administrator']:
|
||||
|
|
@ -138,10 +146,11 @@ app.register_blueprint(physicalinterfaces_bp)
|
|||
app.register_blueprint(portforwarding_bp)
|
||||
app.register_blueprint(portwrangling_bp)
|
||||
app.register_blueprint(preferences_bp)
|
||||
app.register_blueprint(accountverifyemail_bp)
|
||||
if not settings.is_single_user():
|
||||
app.register_blueprint(accountverifyemail_bp)
|
||||
app.register_blueprint(accountcreate_bp)
|
||||
app.register_blueprint(accountmanage_bp)
|
||||
app.register_blueprint(vpn_bp)
|
||||
app.register_blueprint(accountcreate_bp)
|
||||
app.register_blueprint(accountmanage_bp)
|
||||
app.register_blueprint(accountlogout_bp)
|
||||
app.register_blueprint(mdns_bp)
|
||||
app.register_blueprint(radius_bp)
|
||||
|
|
@ -151,6 +160,8 @@ app.register_blueprint(api_apply_health_bp)
|
|||
|
||||
|
||||
def _seed_initial_account():
|
||||
if settings.is_single_user():
|
||||
return
|
||||
import uuid as _uuid, time as _t
|
||||
email = settings.get_initial_manager_email()
|
||||
if not email:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,21 @@ def form_login():
|
|||
flash('Email address and password are required.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
if settings.is_single_user():
|
||||
stored_hash = settings.get_initial_manager_password_hash()
|
||||
if email != settings.get_initial_manager_email() or not stored_hash:
|
||||
flash('Invalid email address or password.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
|
||||
flash('Invalid email address or password.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
session.clear()
|
||||
session['logged_in'] = True
|
||||
session['timezone'] = settings.get_host_timezone()
|
||||
session['apply_changes_immediately'] = False
|
||||
session.permanent = True
|
||||
return redirect('/overview')
|
||||
|
||||
account = config_utils.get_account_by_email(email)
|
||||
|
||||
if account is None:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import bcrypt
|
|||
import auth
|
||||
import config_utils
|
||||
import sanitize
|
||||
import settings
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
@ -25,9 +26,62 @@ def accountdetails_save():
|
|||
return redirect(f'/{_PAGE}')
|
||||
|
||||
|
||||
@bp.route('/action/preferences/email_change_direct', methods=['POST'])
|
||||
@auth.require_level('viewer')
|
||||
def email_change_direct():
|
||||
if session.get('email_address', '').lower() != settings.get_initial_manager_email():
|
||||
flash('Not authorised.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
new_email = sanitize.email(request.form.get('new_email', '').strip())
|
||||
if not new_email:
|
||||
flash('A valid email address is required.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
current_email = session.get('email_address', '').lower()
|
||||
if new_email == current_email:
|
||||
flash('That is already your current email address.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
if not settings.is_single_user() and config_utils.get_account_by_email(new_email):
|
||||
flash('That email address is already in use.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
if not settings.is_single_user():
|
||||
try:
|
||||
con = config_utils.open_accounts_db()
|
||||
con.execute(
|
||||
'UPDATE accounts SET email=?, requested_email=NULL WHERE account_id=?',
|
||||
(new_email, session.get('account_id', ''))
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception as exc:
|
||||
flash(f'Could not update account: {exc}', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
try:
|
||||
import json as _json
|
||||
cfg_path = Path(settings._APP_CONFIG_PATH)
|
||||
cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
|
||||
cfg['initial_manager_email'] = new_email
|
||||
cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n')
|
||||
settings._app_config_cache = None
|
||||
except Exception as exc:
|
||||
flash(f'Could not update app_config.json: {exc}', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
session['email_address'] = new_email
|
||||
flash('Email address updated.', 'success')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
|
||||
@bp.route('/action/preferences/email_change_request', methods=['POST'])
|
||||
@auth.require_level('viewer')
|
||||
def email_change_request():
|
||||
if settings.is_single_user():
|
||||
flash('Not available in single-user mode.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
new_email = sanitize.email(request.form.get('new_email', '').strip())
|
||||
if not new_email:
|
||||
flash('A valid email address is required.', 'error')
|
||||
|
|
@ -77,6 +131,26 @@ def changepassword_save():
|
|||
flash('New password must be at least 8 characters.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
if settings.is_single_user():
|
||||
stored_hash = settings.get_initial_manager_password_hash()
|
||||
if not stored_hash or not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash.encode('utf-8')):
|
||||
flash('Current password is incorrect.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
try:
|
||||
import json as _json
|
||||
cfg_path = Path(settings._APP_CONFIG_PATH)
|
||||
cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
|
||||
cfg['initial_manager_password'] = hashed
|
||||
cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n')
|
||||
settings._app_config_cache = None
|
||||
except Exception as exc:
|
||||
flash(f'Could not update password: {exc}', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
account = config_utils.get_account_by_id(session.get('account_id', ''))
|
||||
if account is None:
|
||||
flash('Account not found. Please log in again.', 'error')
|
||||
|
|
@ -86,8 +160,6 @@ def changepassword_save():
|
|||
flash('Current password is incorrect.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
try:
|
||||
con = config_utils.open_accounts_db()
|
||||
con.execute(
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/preferences/email_change_request",
|
||||
"action": "%EMAIL_CHANGE_ACTION%",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"text": "Submit Request"
|
||||
"text": "%EMAIL_CHANGE_BTN_TEXT%"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from flask import session
|
|||
import sanitize
|
||||
import config_utils
|
||||
import factory
|
||||
import settings
|
||||
|
||||
|
||||
def collect_tokens(cfg):
|
||||
|
|
@ -12,8 +13,19 @@ def collect_tokens(cfg):
|
|||
tokens['PREF_TIMEZONE'] = session.get('timezone', '')
|
||||
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
|
||||
|
||||
account = config_utils.get_account_by_id(session.get('account_id', ''))
|
||||
requested = (account or {}).get('requested_email', '')
|
||||
is_initial_manager = session.get('email_address', '').lower() == settings.get_initial_manager_email()
|
||||
if is_initial_manager:
|
||||
tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_direct'
|
||||
tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Change Email'
|
||||
else:
|
||||
tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_request'
|
||||
tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Submit Request'
|
||||
|
||||
if not settings.is_single_user():
|
||||
account = config_utils.get_account_by_id(session.get('account_id', ''))
|
||||
requested = (account or {}).get('requested_email', '')
|
||||
else:
|
||||
requested = ''
|
||||
if requested:
|
||||
tokens['PENDING_EMAIL_BAR'] = (
|
||||
f'<div class="info-bar info-bar-inline info-bar-warning">'
|
||||
|
|
|
|||
|
|
@ -134,3 +134,97 @@ class SqliteSessionInterface(SessionInterface):
|
|||
secure=self.get_cookie_secure(app),
|
||||
samesite=self.get_cookie_samesite(app),
|
||||
)
|
||||
|
||||
|
||||
class SingleUserSessionInterface(SessionInterface):
|
||||
def __init__(self, db_path):
|
||||
self.db_path = db_path
|
||||
|
||||
def _connect(self):
|
||||
con = sqlite3.connect(self.db_path, timeout=5)
|
||||
con.execute('PRAGMA journal_mode=WAL')
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
def open_session(self, app, request):
|
||||
name = app.config.get('SESSION_COOKIE_NAME', 'session')
|
||||
sid = request.cookies.get(name)
|
||||
if not sid:
|
||||
return SqliteSession(sid=str(uuid.uuid4()), new=True)
|
||||
try:
|
||||
con = self._connect()
|
||||
row = con.execute('SELECT * FROM sessions WHERE session_id=?', (sid,)).fetchone()
|
||||
con.close()
|
||||
if row:
|
||||
prefs = json.loads(row['preferences_json'] or '{}')
|
||||
flashes = json.loads(row['flashes_json'] or '[]')
|
||||
data = {'_flashes': flashes}
|
||||
if row['logged_in']:
|
||||
tz = str(row['timezone'] or '') or _settings.get_host_timezone()
|
||||
data.update({
|
||||
'email_address': _settings.get_initial_manager_email(),
|
||||
'access_level': 'manager',
|
||||
'timezone': tz,
|
||||
'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)),
|
||||
'_permanent': True,
|
||||
'logged_in': True,
|
||||
})
|
||||
return SqliteSession(data, sid=sid, new=False)
|
||||
except Exception:
|
||||
pass
|
||||
return SqliteSession(sid=sid, new=False)
|
||||
|
||||
def save_session(self, app, session, response):
|
||||
name = app.config.get('SESSION_COOKIE_NAME', 'session')
|
||||
domain = self.get_cookie_domain(app)
|
||||
path = self.get_cookie_path(app)
|
||||
|
||||
if not session and session.modified and not session.new:
|
||||
try:
|
||||
con = self._connect()
|
||||
con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,))
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
response.delete_cookie(name, domain=domain, path=path)
|
||||
return
|
||||
|
||||
prefs = json.dumps({
|
||||
'apply_changes_immediately': bool(session.get('apply_changes_immediately', False)),
|
||||
})
|
||||
flashes_json = json.dumps(session.get('_flashes', []))
|
||||
now = int(time.time())
|
||||
logged_in = 1 if session.get('logged_in') else 0
|
||||
|
||||
try:
|
||||
con = self._connect()
|
||||
con.execute(
|
||||
'''INSERT INTO sessions
|
||||
(session_id, logged_in, timezone, preferences_json,
|
||||
flashes_json, session_started_ts, last_seen_ts)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
logged_in=excluded.logged_in,
|
||||
timezone=excluded.timezone,
|
||||
preferences_json=excluded.preferences_json,
|
||||
flashes_json=excluded.flashes_json,
|
||||
last_seen_ts=excluded.last_seen_ts''',
|
||||
(session.sid, logged_in,
|
||||
session.get('timezone', ''),
|
||||
prefs, flashes_json, now, now)
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.set_cookie(
|
||||
name, session.sid,
|
||||
expires=self.get_expiration_time(app, session),
|
||||
httponly=self.get_cookie_httponly(app),
|
||||
domain=domain,
|
||||
path=path,
|
||||
secure=self.get_cookie_secure(app),
|
||||
samesite=self.get_cookie_samesite(app),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ def get_initial_manager_email():
|
|||
return str(cfg.get('initial_manager_email') or os.environ.get('INITIAL_MANAGER_EMAIL', '')).strip().lower()
|
||||
|
||||
|
||||
def is_single_user():
|
||||
return 'initial_manager_password' in _load_app_config()
|
||||
|
||||
|
||||
def get_initial_manager_password_hash():
|
||||
return _load_app_config().get('initial_manager_password', '')
|
||||
|
||||
|
||||
def get_credentials_key():
|
||||
"""Return a Fernet-compatible key derived from the credentials_key in app_config.json
|
||||
(or CREDENTIALS_KEY env var as fallback), or None if not set. SHA-256 hashes the raw
|
||||
|
|
|
|||
|
|
@ -327,11 +327,6 @@ def setup_docker_compose(reuse_config=False):
|
|||
print(" Dashboard container started.")
|
||||
return
|
||||
|
||||
print()
|
||||
print(" SMTP is used to send email verification codes for new accounts.")
|
||||
print(" (Gmail users: use an App Password, not your account password.)")
|
||||
print()
|
||||
|
||||
manager_email = prompt_str("Initial manager account email")
|
||||
while not manager_email or "@" not in manager_email:
|
||||
print(" Please enter a valid email address.")
|
||||
|
|
@ -345,23 +340,55 @@ def setup_docker_compose(reuse_config=False):
|
|||
credentials_key = _sec.token_urlsafe(48)
|
||||
print(f" Generated key: {credentials_key}")
|
||||
|
||||
smtp_host = prompt_str("SMTP host", default="smtp.gmail.com")
|
||||
smtp_port = prompt_str("SMTP port", default="587")
|
||||
smtp_user = prompt_str("SMTP username (email)")
|
||||
smtp_password = prompt_str("SMTP password", secret=True)
|
||||
smtp_from = prompt_str("SMTP From address", default=smtp_user)
|
||||
print()
|
||||
multi_user = prompt_yn(
|
||||
f"Enable multi-user access? (requires SMTP to send account verification emails)",
|
||||
default="n"
|
||||
)
|
||||
|
||||
app_config = {
|
||||
"initial_manager_email": manager_email,
|
||||
"credentials_key": credentials_key,
|
||||
"smtp": {
|
||||
"host": smtp_host,
|
||||
"port": int(smtp_port),
|
||||
"user": smtp_user,
|
||||
"password": smtp_password,
|
||||
"from": smtp_from,
|
||||
},
|
||||
}
|
||||
if multi_user:
|
||||
print()
|
||||
print(" (Gmail users: use an App Password, not your account password.)")
|
||||
print()
|
||||
smtp_host = prompt_str("SMTP host", default="smtp.gmail.com")
|
||||
smtp_port = prompt_str("SMTP port", default="587")
|
||||
smtp_user = prompt_str("SMTP username (email)")
|
||||
smtp_password = prompt_str("SMTP password", secret=True)
|
||||
smtp_from = prompt_str("SMTP From address", default=smtp_user)
|
||||
app_config = {
|
||||
"initial_manager_email": manager_email,
|
||||
"credentials_key": credentials_key,
|
||||
"smtp": {
|
||||
"host": smtp_host,
|
||||
"port": int(smtp_port),
|
||||
"user": smtp_user,
|
||||
"password": smtp_password,
|
||||
"from": smtp_from,
|
||||
},
|
||||
}
|
||||
else:
|
||||
print()
|
||||
print(f" Single-user mode: only {manager_email} can log in.")
|
||||
print(f" Password must be at least 8 characters.")
|
||||
print()
|
||||
while True:
|
||||
import getpass as _gp
|
||||
pw = _gp.getpass(f" Password for {manager_email}: ")
|
||||
pw2 = _gp.getpass(f" Confirm password: ")
|
||||
if pw != pw2:
|
||||
print(" Passwords do not match. Try again.")
|
||||
continue
|
||||
if len(pw) < 8:
|
||||
print(" Password must be at least 8 characters.")
|
||||
continue
|
||||
break
|
||||
import bcrypt as _bcrypt
|
||||
pw_hash = _bcrypt.hashpw(pw.encode('utf-8'), _bcrypt.gensalt()).decode('utf-8')
|
||||
app_config = {
|
||||
"initial_manager_email": manager_email,
|
||||
"credentials_key": credentials_key,
|
||||
"initial_manager_password": pw_hash,
|
||||
}
|
||||
|
||||
APP_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
APP_CONFIG_FILE.write_text(json.dumps(app_config, indent=2) + "\n")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue