diff --git a/docker/routlin-dash/app/pages/captiveportal/action.py b/docker/routlin-dash/app/pages/captiveportal/action.py index fc486a1..1b12b18 100644 --- a/docker/routlin-dash/app/pages/captiveportal/action.py +++ b/docker/routlin-dash/app/pages/captiveportal/action.py @@ -17,7 +17,7 @@ def options_save(): before = copy.deepcopy(cfg.get('captive_portal', {})) try: - http_port = int(request.form.get('http_port', '8081')) + http_port = int(request.form.get('http_port', '25328')) if not (1024 <= http_port <= 65535): raise ValueError except (ValueError, TypeError): @@ -46,25 +46,32 @@ def portal_save(): flash('Captive portal VLAN not found.', 'error') return redirect(f'/{_PAGE}') - before = { - 'portal_splash_title': vlan.get('portal_splash_title', ''), - 'portal_splash_text': vlan.get('portal_splash_text', ''), - 'portal_terms': vlan.get('portal_terms', []), - } + existing = vlan.get('captive_portal', {}) + before = dict(existing) splash_title = sanitize.description(request.form.get('portal_splash_title', '')) splash_text = sanitize.description(request.form.get('portal_splash_text', '')) terms = [t.strip() for t in request.form.getlist('portal_terms') if t.strip()] + require_upw = 'require_username_password' in request.form - vlan['portal_splash_title'] = splash_title - vlan['portal_splash_text'] = splash_text - vlan['portal_terms'] = terms + try: + dur_n = int(request.form.get('default_duration_value', '0').strip() or '0') + dur_unit = request.form.get('default_duration_unit', 'hours') + mult = {'hours': 3600, 'days': 86400}.get(dur_unit, 3600) + duration = dur_n * mult if dur_n > 0 else 0 + except (ValueError, TypeError): + duration = 0 after = { - 'portal_splash_title': splash_title, - 'portal_splash_text': splash_text, - 'portal_terms': terms, + **existing, + 'portal_splash_title': splash_title, + 'portal_splash_text': splash_text, + 'portal_terms': terms, + 'require_username_password': require_upw, + 'default_duration_seconds': duration, } + vlan['captive_portal'] = after + changes = config_utils.diff_fields(before, after) flash(config_utils.record_group( cfg, 'vlans', 'portal', vlan_name, changes, 'core apply' diff --git a/docker/routlin-dash/app/pages/captiveportal/content.json b/docker/routlin-dash/app/pages/captiveportal/content.json index 1eb86b7..c192abf 100644 --- a/docker/routlin-dash/app/pages/captiveportal/content.json +++ b/docker/routlin-dash/app/pages/captiveportal/content.json @@ -86,6 +86,12 @@ "label": "Terms", "field": "portal_terms_display", "class": "col-narrow" + }, + { + "label": "U/P Required", + "field": "require_upw", + "class": "col-narrow", + "render": "badge_yes_no" } ], "row_actions": [ @@ -140,6 +146,41 @@ "item_placeholder": "e.g. I agree to the acceptable use policy.", "hint": "Each term renders as a required checkbox the user must tick before submitting credentials. Leave empty for no terms." }, + { + "type": "hr" + }, + { + "type": "field", + "label": "", + "name": "require_username_password", + "input_type": "checkbox", + "checkbox_label": "Require username and password" + }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Default Session Duration", + "name": "default_duration_value", + "input_type": "number", + "min": 0, + "value": "0", + "hint": "How long portal access lasts after authentication. 0 = no expiration." + }, + { + "type": "field", + "label": "Unit", + "name": "default_duration_unit", + "input_type": "select", + "options": [ + {"value": "hours", "label": "Hours"}, + {"value": "days", "label": "Days"} + ] + } + ] + }, { "type": "button_row", "items": [ diff --git a/docker/routlin-dash/app/pages/captiveportal/view.py b/docker/routlin-dash/app/pages/captiveportal/view.py index 5fedfb9..b360a20 100644 --- a/docker/routlin-dash/app/pages/captiveportal/view.py +++ b/docker/routlin-dash/app/pages/captiveportal/view.py @@ -23,14 +23,22 @@ def collect_tokens(cfg): display_rows = [] for vlan in captive_vlans: - terms = vlan.get('portal_terms', []) + cp = vlan.get('captive_portal', {}) + title = cp.get('portal_splash_title', vlan.get('portal_splash_title', '')) + text = cp.get('portal_splash_text', vlan.get('portal_splash_text', '')) + terms = cp.get('portal_terms', vlan.get('portal_terms', [])) + require_upw = cp.get('require_username_password', vlan.get('require_username_password', False)) + duration = cp.get('default_duration_seconds', vlan.get('default_duration_seconds', 0)) n = len(terms) display_rows.append({ - 'vlan_name': vlan['name'], - 'portal_splash_title': vlan.get('portal_splash_title', ''), - 'portal_splash_text': vlan.get('portal_splash_text', ''), - 'portal_terms': terms, - 'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--', + 'vlan_name': vlan['name'], + 'portal_splash_title': title, + 'portal_splash_text': text, + 'portal_terms': terms, + 'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--', + 'require_upw': require_upw, + 'require_username_password': require_upw, + 'default_duration_seconds': duration, }) content = factory.load_json(f'{factory.PAGES_DIR}/captiveportal/content.json') diff --git a/docker/routlin-dash/app/pages/clientcredentials/content.json b/docker/routlin-dash/app/pages/clientcredentials/content.json index 8b2492b..1991e71 100644 --- a/docker/routlin-dash/app/pages/clientcredentials/content.json +++ b/docker/routlin-dash/app/pages/clientcredentials/content.json @@ -126,6 +126,10 @@ "type": "raw_html", "html": "" }, + { + "type": "raw_html", + "html": "
" + }, { "type": "hr" }, diff --git a/docker/routlin-dash/app/pages/clientcredentials/view.py b/docker/routlin-dash/app/pages/clientcredentials/view.py index 119eec8..7c0d5a0 100644 --- a/docker/routlin-dash/app/pages/clientcredentials/view.py +++ b/docker/routlin-dash/app/pages/clientcredentials/view.py @@ -78,7 +78,17 @@ def collect_tokens(cfg): captive_vlans = [v for v in cfg.get('vlans', []) if v.get('restricted_vlan') == 'c'] tokens['CAPTIVE_VLAN_OPTIONS'] = json.dumps( [{'value': '', 'label': '-- Select VLAN --'}] + - [{'value': v['name'], 'label': f"{v['name']} (VLAN {v['vlan_id']})"} for v in captive_vlans] + [ + { + 'value': v['name'], + 'label': f"{v['name']} (VLAN {v['vlan_id']})", + 'require_upw': v.get('captive_portal', {}).get('require_username_password', + v.get('require_username_password', False)), + 'default_duration_seconds': v.get('captive_portal', {}).get('default_duration_seconds', + v.get('default_duration_seconds', 0)), + } + for v in captive_vlans + ] ) raw_rows = _load_credentials() diff --git a/docker/routlin-portal/app/page.py b/docker/routlin-portal/app/page.py index 982dc9c..8f0ffd1 100644 --- a/docker/routlin-portal/app/page.py +++ b/docker/routlin-portal/app/page.py @@ -1,7 +1,15 @@ import ipaddress +import sqlite3 +import time + +import bcrypt from flask import Blueprint, request, redirect import config_utils +CREDENTIALS_DB = f'{config_utils.CONFIGS_DIR}/.client-credentials' +USER_TYPE_CAPTIVE = 0 +HASH_BCRYPT = 2 + bp = Blueprint('portal', __name__) PORTAL_HTML = """\ @@ -16,6 +24,9 @@ PORTAL_HTML = """\ h1 {{ font-size: 1.5rem; margin-bottom: .5rem; }} .err {{ color: #c00; margin: .75rem 0; }} .terms label {{ display: block; margin: .4rem 0; cursor: pointer; }} + .creds {{ margin: 1rem 0; }} + .creds label {{ display: block; margin-bottom: .2rem; font-size: .9rem; }} + .creds input {{ width: 100%; box-sizing: border-box; padding: .4rem .5rem; margin-bottom: .75rem; font-size: 1rem; }} button {{ margin-top: 1rem; padding: .55rem 1.4rem; }} @@ -25,6 +36,7 @@ PORTAL_HTML = """\ {error_html}
+ {credentials_html}
{terms_html}
@@ -50,16 +62,60 @@ def _vlan_for_ip(client_ip): return None +def _cp(vlan): + return vlan.get('captive_portal', {}) + + +def _verify_credential(username, password, vlan_name): + try: + conn = sqlite3.connect(CREDENTIALS_DB) + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM credentials WHERE username=? COLLATE NOCASE" + " AND user_type=? AND vlan=? AND enabled=1", + (username, USER_TYPE_CAPTIVE, vlan_name), + ).fetchone() + conn.close() + except Exception: + return False + if row is None: + return False + if row['valid_for'] is not None and (row['date_set'] + row['valid_for']) < int(time.time()): + return False + if row['hash_type'] == HASH_BCRYPT: + try: + return bcrypt.checkpw(password.encode(), row['password'].encode()) + except Exception: + return False + return False + + def _render(vlan, error=None, next_url=''): - terms = vlan.get('portal_terms', []) + cp = _cp(vlan) + terms = cp.get('portal_terms', vlan.get('portal_terms', [])) terms_html = ''.join( f'' for i, t in enumerate(terms) ) or '

No terms required.

' + require_upw = cp.get('require_username_password', vlan.get('require_username_password', False)) + if require_upw: + credentials_html = ( + '
' + '' + '' + '' + '' + '
' + ) + else: + credentials_html = '' + title = cp.get('portal_splash_title', vlan.get('portal_splash_title', 'Guest Portal')) + splash_text = cp.get('portal_splash_text', vlan.get('portal_splash_text', '')) return PORTAL_HTML.format( - title=vlan.get('portal_splash_title', 'Guest Portal'), - splash_html=f'

{vlan["portal_splash_text"]}

' if vlan.get('portal_splash_text') else '', + title=title, + splash_html=f'

{splash_text}

' if splash_text else '', error_html=f'

{error}

' if error else '', + credentials_html=credentials_html, terms_html=terms_html, next_url=next_url, ) @@ -73,12 +129,23 @@ def portal(path): return 'Portal unavailable.', 404 if request.method == 'POST': - terms = vlan.get('portal_terms', []) + cp = _cp(vlan) + terms = cp.get('portal_terms', vlan.get('portal_terms', [])) + next_url = request.form.get('next', '') + + require_upw = cp.get('require_username_password', vlan.get('require_username_password', False)) + if require_upw: + username = request.form.get('portal_username', '').strip() + password = request.form.get('portal_password', '') + if not username: + return _render(vlan, error='Username is required.', next_url=next_url), 200 + if not _verify_credential(username, password, vlan['name']): + return _render(vlan, error='Invalid username or password.', next_url=next_url), 200 + for i in range(len(terms)): if not request.form.get(f'term_{i}'): - return _render(vlan, - error='You must accept all terms to continue.', - next_url=request.form.get('next', '')), 200 + return _render(vlan, error='You must accept all terms to continue.', + next_url=next_url), 200 try: with open(config_utils.CAPTIVE_QUEUE, 'a') as f: f.write(f'allow {request.remote_addr}\n') diff --git a/docker/routlin-portal/docker-compose.yml b/docker/routlin-portal/docker-compose.yml index 63c65f9..23a20d9 100644 --- a/docker/routlin-portal/docker-compose.yml +++ b/docker/routlin-portal/docker-compose.yml @@ -9,4 +9,5 @@ services: volumes: - $HOME/routlin/config.json:/routlin_location/config.json:ro - $HOME/routlin/.captive-queue:/routlin_location/.captive-queue + - $HOME/routlin/.client-credentials:/routlin_location/.client-credentials:ro restart: unless-stopped diff --git a/docker/routlin-portal/requirements.txt b/docker/routlin-portal/requirements.txt index 7e10602..bb2793b 100644 --- a/docker/routlin-portal/requirements.txt +++ b/docker/routlin-portal/requirements.txt @@ -1 +1,2 @@ flask +bcrypt