Development
This commit is contained in:
parent
8303eb5397
commit
a4eb431f22
11 changed files with 744 additions and 1 deletions
301
docker/routlin-dash/app/pages/clientcredentials/action.py
Normal file
301
docker/routlin-dash/app/pages/clientcredentials/action.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import hashlib
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import CREDENTIALS_DB
|
||||
import sanitize
|
||||
import settings as settings
|
||||
|
||||
_PAGE = 'clientcredentials'
|
||||
PRO_LICENSE = settings.is_pro()
|
||||
|
||||
bp = Blueprint(_PAGE, __name__)
|
||||
|
||||
# Enum values stored in the database
|
||||
USER_TYPE_CAPTIVE = 0
|
||||
USER_TYPE_SUPPLICANT = 1
|
||||
|
||||
HASH_CLEARTEXT = 0
|
||||
HASH_NT = 1
|
||||
HASH_BCRYPT = 2
|
||||
|
||||
VALID_USER_TYPES = {USER_TYPE_CAPTIVE, USER_TYPE_SUPPLICANT}
|
||||
VALID_HASH_TYPES = {HASH_CLEARTEXT, HASH_NT, HASH_BCRYPT}
|
||||
|
||||
# Compatible hash types per user type
|
||||
COMPATIBLE_HASHES = {
|
||||
USER_TYPE_CAPTIVE: {HASH_CLEARTEXT, HASH_BCRYPT},
|
||||
USER_TYPE_SUPPLICANT: {HASH_CLEARTEXT, HASH_NT},
|
||||
}
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# DB helpers
|
||||
# ===================================================================
|
||||
|
||||
def _db_conn():
|
||||
conn = sqlite3.connect(CREDENTIALS_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
user_type INTEGER NOT NULL,
|
||||
hash_type INTEGER NOT NULL,
|
||||
vlan TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
date_set INTEGER NOT NULL,
|
||||
valid_for INTEGER DEFAULT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
credential_id INTEGER REFERENCES credentials(id) ON DELETE CASCADE,
|
||||
started_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
mac TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _get_by_index(conn, row_index):
|
||||
rows = conn.execute("SELECT * FROM credentials ORDER BY id").fetchall()
|
||||
if row_index < 0 or row_index >= len(rows):
|
||||
return None
|
||||
return rows[row_index]
|
||||
|
||||
|
||||
def _hash_password(plaintext, hash_type):
|
||||
if hash_type == HASH_CLEARTEXT:
|
||||
return plaintext
|
||||
if hash_type == HASH_NT:
|
||||
try:
|
||||
return hashlib.new('md4', plaintext.encode('utf-16-le')).hexdigest()
|
||||
except ValueError:
|
||||
raise ValueError("NT-Password requires MD4 support. It may be disabled on this system's OpenSSL build.")
|
||||
if hash_type == HASH_BCRYPT:
|
||||
return bcrypt.hashpw(plaintext.encode(), bcrypt.gensalt()).decode()
|
||||
raise ValueError(f"Unknown hash_type: {hash_type}")
|
||||
|
||||
|
||||
def _parse_valid_for(value_str, unit_str):
|
||||
"""Return valid_for in seconds (int) or None for no expiry."""
|
||||
unit_str = (unit_str or '').strip()
|
||||
if unit_str == 'never' or not value_str or not value_str.strip():
|
||||
return None
|
||||
try:
|
||||
n = int(value_str.strip())
|
||||
if n < 1:
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
multipliers = {'hours': 3600, 'days': 86400}
|
||||
mult = multipliers.get(unit_str)
|
||||
if mult is None:
|
||||
return None
|
||||
return n * mult
|
||||
|
||||
|
||||
def _valid_for_to_display(valid_for):
|
||||
"""Return (value_str, unit_str) for form pre-population."""
|
||||
if valid_for is None:
|
||||
return '', 'never'
|
||||
if valid_for % 86400 == 0:
|
||||
return str(valid_for // 86400), 'days'
|
||||
if valid_for % 3600 == 0:
|
||||
return str(valid_for // 3600), 'hours'
|
||||
return str(valid_for // 3600 or 1), 'hours'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Routes
|
||||
# ===================================================================
|
||||
|
||||
@bp.route('/action/clientcredentials/addedit', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def addedit():
|
||||
if not PRO_LICENSE:
|
||||
flash('Client Credentials requires a Routlin Pro license.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
ri = _row_index()
|
||||
is_edit = ri is not None
|
||||
username = sanitize.name(request.form.get('username', ''))
|
||||
password = request.form.get('password', '').strip()
|
||||
description = sanitize.description(request.form.get('description', ''))
|
||||
|
||||
try:
|
||||
user_type = int(request.form.get('user_type', ''))
|
||||
hash_type = int(request.form.get('hash_type', ''))
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid user type or hash type.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
if user_type not in VALID_USER_TYPES:
|
||||
flash('Invalid user type.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
if hash_type not in VALID_HASH_TYPES:
|
||||
flash('Invalid hash type.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
if hash_type not in COMPATIBLE_HASHES[user_type]:
|
||||
flash('Selected hash type is not compatible with the selected user type.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
vlan = sanitize.name(request.form.get('vlan', '')) if user_type == USER_TYPE_SUPPLICANT else ''
|
||||
if user_type == USER_TYPE_SUPPLICANT and not vlan:
|
||||
flash('VLAN is required for 802.1X supplicant credentials.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
enabled = 'enabled' in request.form
|
||||
valid_for = _parse_valid_for(
|
||||
request.form.get('valid_for_value', ''),
|
||||
request.form.get('valid_for_unit', 'never'),
|
||||
)
|
||||
|
||||
if not username:
|
||||
flash('Username is required.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
conn = _db_conn()
|
||||
|
||||
if is_edit:
|
||||
existing = _get_by_index(conn, ri)
|
||||
if not existing:
|
||||
conn.close()
|
||||
flash('Credential not found.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
if password:
|
||||
try:
|
||||
hashed = _hash_password(password, hash_type)
|
||||
except ValueError as exc:
|
||||
conn.close()
|
||||
flash(str(exc), 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
stored_password = hashed
|
||||
stored_hash_type = hash_type
|
||||
date_set = int(time.time())
|
||||
else:
|
||||
stored_password = existing['password']
|
||||
stored_hash_type = existing['hash_type']
|
||||
date_set = existing['date_set']
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""UPDATE credentials
|
||||
SET username=?, password=?, description=?, user_type=?, hash_type=?,
|
||||
vlan=?, enabled=?, date_set=?, valid_for=?
|
||||
WHERE id=?""",
|
||||
(username, stored_password, description, user_type, stored_hash_type,
|
||||
vlan, int(enabled), date_set, valid_for, existing['id']),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
flash(f"Username '{username}' is already taken.", 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
conn.close()
|
||||
flash(f"Credential '{username}' updated.", 'success')
|
||||
|
||||
else:
|
||||
if not password:
|
||||
flash('Password is required when adding a credential.', 'error')
|
||||
conn.close()
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
try:
|
||||
hashed = _hash_password(password, hash_type)
|
||||
except ValueError as exc:
|
||||
conn.close()
|
||||
flash(str(exc), 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO credentials
|
||||
(username, password, description, user_type, hash_type, vlan, enabled, date_set, valid_for)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(username, hashed, description, user_type, hash_type,
|
||||
vlan, int(enabled), int(time.time()), valid_for),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
flash(f"Username '{username}' already exists.", 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
conn.close()
|
||||
flash(f"Credential '{username}' added.", 'success')
|
||||
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
|
||||
@bp.route('/action/clientcredentials/delete', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete():
|
||||
if not PRO_LICENSE:
|
||||
flash('Client Credentials requires a Routlin Pro license.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
ri = _row_index()
|
||||
if ri is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
conn = _db_conn()
|
||||
row = _get_by_index(conn, ri)
|
||||
if not row:
|
||||
conn.close()
|
||||
flash('Credential not found.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
conn.execute("DELETE FROM credentials WHERE id=?", (row['id'],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash(f"Credential '{row['username']}' deleted.", 'success')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
|
||||
@bp.route('/action/clientcredentials/toggle', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle():
|
||||
if not PRO_LICENSE:
|
||||
flash('Client Credentials requires a Routlin Pro license.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
ri = _row_index()
|
||||
if ri is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
conn = _db_conn()
|
||||
row = _get_by_index(conn, ri)
|
||||
if not row:
|
||||
conn.close()
|
||||
flash('Credential not found.', 'error')
|
||||
return redirect(f'/{_PAGE}')
|
||||
|
||||
new_enabled = 0 if row['enabled'] else 1
|
||||
conn.execute("UPDATE credentials SET enabled=? WHERE id=?", (new_enabled, row['id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
state = 'enabled' if new_enabled else 'disabled'
|
||||
flash(f"Credential '{row['username']}' {state}.", 'success')
|
||||
return redirect(f'/{_PAGE}')
|
||||
Loading…
Add table
Add a link
Reference in a new issue