Development

This commit is contained in:
Matthew Grotke 2026-06-07 21:32:46 -04:00
parent bb07e67d53
commit 69b5f00d5b
8 changed files with 165 additions and 26 deletions

View file

@ -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; }}
</style>
</head>
@ -25,6 +36,7 @@ PORTAL_HTML = """\
{error_html}
<form method="post">
<input type="hidden" name="next" value="{next_url}">
{credentials_html}
<div class="terms">{terms_html}</div>
<button type="submit">Continue</button>
</form>
@ -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'<label><input type="checkbox" name="term_{i}" required> {t}</label>'
for i, t in enumerate(terms)
) 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(
title=vlan.get('portal_splash_title', 'Guest Portal'),
splash_html=f'<p>{vlan["portal_splash_text"]}</p>' if vlan.get('portal_splash_text') else '',
title=title,
splash_html=f'<p>{splash_text}</p>' if splash_text else '',
error_html=f'<p class="err">{error}</p>' 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')