Development
This commit is contained in:
parent
bb07e67d53
commit
69b5f00d5b
8 changed files with 165 additions and 26 deletions
|
|
@ -17,7 +17,7 @@ def options_save():
|
||||||
before = copy.deepcopy(cfg.get('captive_portal', {}))
|
before = copy.deepcopy(cfg.get('captive_portal', {}))
|
||||||
|
|
||||||
try:
|
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):
|
if not (1024 <= http_port <= 65535):
|
||||||
raise ValueError
|
raise ValueError
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -46,25 +46,32 @@ def portal_save():
|
||||||
flash('Captive portal VLAN not found.', 'error')
|
flash('Captive portal VLAN not found.', 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
before = {
|
existing = vlan.get('captive_portal', {})
|
||||||
'portal_splash_title': vlan.get('portal_splash_title', ''),
|
before = dict(existing)
|
||||||
'portal_splash_text': vlan.get('portal_splash_text', ''),
|
|
||||||
'portal_terms': vlan.get('portal_terms', []),
|
|
||||||
}
|
|
||||||
|
|
||||||
splash_title = sanitize.description(request.form.get('portal_splash_title', ''))
|
splash_title = sanitize.description(request.form.get('portal_splash_title', ''))
|
||||||
splash_text = sanitize.description(request.form.get('portal_splash_text', ''))
|
splash_text = sanitize.description(request.form.get('portal_splash_text', ''))
|
||||||
terms = [t.strip() for t in request.form.getlist('portal_terms') if t.strip()]
|
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
|
try:
|
||||||
vlan['portal_splash_text'] = splash_text
|
dur_n = int(request.form.get('default_duration_value', '0').strip() or '0')
|
||||||
vlan['portal_terms'] = terms
|
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 = {
|
after = {
|
||||||
|
**existing,
|
||||||
'portal_splash_title': splash_title,
|
'portal_splash_title': splash_title,
|
||||||
'portal_splash_text': splash_text,
|
'portal_splash_text': splash_text,
|
||||||
'portal_terms': terms,
|
'portal_terms': terms,
|
||||||
|
'require_username_password': require_upw,
|
||||||
|
'default_duration_seconds': duration,
|
||||||
}
|
}
|
||||||
|
vlan['captive_portal'] = after
|
||||||
|
|
||||||
changes = config_utils.diff_fields(before, after)
|
changes = config_utils.diff_fields(before, after)
|
||||||
flash(config_utils.record_group(
|
flash(config_utils.record_group(
|
||||||
cfg, 'vlans', 'portal', vlan_name, changes, 'core apply'
|
cfg, 'vlans', 'portal', vlan_name, changes, 'core apply'
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,12 @@
|
||||||
"label": "Terms",
|
"label": "Terms",
|
||||||
"field": "portal_terms_display",
|
"field": "portal_terms_display",
|
||||||
"class": "col-narrow"
|
"class": "col-narrow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "U/P Required",
|
||||||
|
"field": "require_upw",
|
||||||
|
"class": "col-narrow",
|
||||||
|
"render": "badge_yes_no"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"row_actions": [
|
"row_actions": [
|
||||||
|
|
@ -140,6 +146,41 @@
|
||||||
"item_placeholder": "e.g. I agree to the acceptable use policy.",
|
"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."
|
"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",
|
"type": "button_row",
|
||||||
"items": [
|
"items": [
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,22 @@ def collect_tokens(cfg):
|
||||||
|
|
||||||
display_rows = []
|
display_rows = []
|
||||||
for vlan in captive_vlans:
|
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)
|
n = len(terms)
|
||||||
display_rows.append({
|
display_rows.append({
|
||||||
'vlan_name': vlan['name'],
|
'vlan_name': vlan['name'],
|
||||||
'portal_splash_title': vlan.get('portal_splash_title', ''),
|
'portal_splash_title': title,
|
||||||
'portal_splash_text': vlan.get('portal_splash_text', ''),
|
'portal_splash_text': text,
|
||||||
'portal_terms': terms,
|
'portal_terms': terms,
|
||||||
'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--',
|
'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')
|
content = factory.load_json(f'{factory.PAGES_DIR}/captiveportal/content.json')
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,10 @@
|
||||||
"type": "raw_html",
|
"type": "raw_html",
|
||||||
"html": "</div>"
|
"html": "</div>"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "raw_html",
|
||||||
|
"html": "<div id=\"captive-portal-mode-note\" style=\"display:none\"></div>"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "hr"
|
"type": "hr"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,17 @@ def collect_tokens(cfg):
|
||||||
captive_vlans = [v for v in cfg.get('vlans', []) if v.get('restricted_vlan') == 'c']
|
captive_vlans = [v for v in cfg.get('vlans', []) if v.get('restricted_vlan') == 'c']
|
||||||
tokens['CAPTIVE_VLAN_OPTIONS'] = json.dumps(
|
tokens['CAPTIVE_VLAN_OPTIONS'] = json.dumps(
|
||||||
[{'value': '', 'label': '-- Select VLAN --'}] +
|
[{'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()
|
raw_rows = _load_credentials()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from flask import Blueprint, request, redirect
|
from flask import Blueprint, request, redirect
|
||||||
import config_utils
|
import config_utils
|
||||||
|
|
||||||
|
CREDENTIALS_DB = f'{config_utils.CONFIGS_DIR}/.client-credentials'
|
||||||
|
USER_TYPE_CAPTIVE = 0
|
||||||
|
HASH_BCRYPT = 2
|
||||||
|
|
||||||
bp = Blueprint('portal', __name__)
|
bp = Blueprint('portal', __name__)
|
||||||
|
|
||||||
PORTAL_HTML = """\
|
PORTAL_HTML = """\
|
||||||
|
|
@ -16,6 +24,9 @@ PORTAL_HTML = """\
|
||||||
h1 {{ font-size: 1.5rem; margin-bottom: .5rem; }}
|
h1 {{ font-size: 1.5rem; margin-bottom: .5rem; }}
|
||||||
.err {{ color: #c00; margin: .75rem 0; }}
|
.err {{ color: #c00; margin: .75rem 0; }}
|
||||||
.terms label {{ display: block; margin: .4rem 0; cursor: pointer; }}
|
.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; }}
|
button {{ margin-top: 1rem; padding: .55rem 1.4rem; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -25,6 +36,7 @@ PORTAL_HTML = """\
|
||||||
{error_html}
|
{error_html}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="hidden" name="next" value="{next_url}">
|
<input type="hidden" name="next" value="{next_url}">
|
||||||
|
{credentials_html}
|
||||||
<div class="terms">{terms_html}</div>
|
<div class="terms">{terms_html}</div>
|
||||||
<button type="submit">Continue</button>
|
<button type="submit">Continue</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -50,16 +62,60 @@ def _vlan_for_ip(client_ip):
|
||||||
return None
|
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=''):
|
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(
|
terms_html = ''.join(
|
||||||
f'<label><input type="checkbox" name="term_{i}" required> {t}</label>'
|
f'<label><input type="checkbox" name="term_{i}" required> {t}</label>'
|
||||||
for i, t in enumerate(terms)
|
for i, t in enumerate(terms)
|
||||||
) or '<p>No terms required.</p>'
|
) or '<p>No terms required.</p>'
|
||||||
|
require_upw = cp.get('require_username_password', vlan.get('require_username_password', False))
|
||||||
|
if require_upw:
|
||||||
|
credentials_html = (
|
||||||
|
'<div class="creds">'
|
||||||
|
'<label for="portal_username">Username</label>'
|
||||||
|
'<input type="text" id="portal_username" name="portal_username" autocomplete="username" required>'
|
||||||
|
'<label for="portal_password">Password</label>'
|
||||||
|
'<input type="password" id="portal_password" name="portal_password" autocomplete="current-password" required>'
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
|
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(
|
return PORTAL_HTML.format(
|
||||||
title=vlan.get('portal_splash_title', 'Guest Portal'),
|
title=title,
|
||||||
splash_html=f'<p>{vlan["portal_splash_text"]}</p>' if vlan.get('portal_splash_text') else '',
|
splash_html=f'<p>{splash_text}</p>' if splash_text else '',
|
||||||
error_html=f'<p class="err">{error}</p>' if error else '',
|
error_html=f'<p class="err">{error}</p>' if error else '',
|
||||||
|
credentials_html=credentials_html,
|
||||||
terms_html=terms_html,
|
terms_html=terms_html,
|
||||||
next_url=next_url,
|
next_url=next_url,
|
||||||
)
|
)
|
||||||
|
|
@ -73,12 +129,23 @@ def portal(path):
|
||||||
return 'Portal unavailable.', 404
|
return 'Portal unavailable.', 404
|
||||||
|
|
||||||
if request.method == 'POST':
|
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)):
|
for i in range(len(terms)):
|
||||||
if not request.form.get(f'term_{i}'):
|
if not request.form.get(f'term_{i}'):
|
||||||
return _render(vlan,
|
return _render(vlan, error='You must accept all terms to continue.',
|
||||||
error='You must accept all terms to continue.',
|
next_url=next_url), 200
|
||||||
next_url=request.form.get('next', '')), 200
|
|
||||||
try:
|
try:
|
||||||
with open(config_utils.CAPTIVE_QUEUE, 'a') as f:
|
with open(config_utils.CAPTIVE_QUEUE, 'a') as f:
|
||||||
f.write(f'allow {request.remote_addr}\n')
|
f.write(f'allow {request.remote_addr}\n')
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- $HOME/routlin/config.json:/routlin_location/config.json:ro
|
- $HOME/routlin/config.json:/routlin_location/config.json:ro
|
||||||
- $HOME/routlin/.captive-queue:/routlin_location/.captive-queue
|
- $HOME/routlin/.captive-queue:/routlin_location/.captive-queue
|
||||||
|
- $HOME/routlin/.client-credentials:/routlin_location/.client-credentials:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
flask
|
flask
|
||||||
|
bcrypt
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue