From 84e1e152f87e71798bb5d099cdc6257f718041c6 Mon Sep 17 00:00:00 2001 From: hansi Date: Sun, 30 Nov 2025 12:35:21 +0100 Subject: [PATCH] Add session-state and cookies --- app/auth.py | 144 ++++++++++++++++-- app/auth_core.py | 113 +++++++++++++- app/auth_old.py | 94 ++++++++++++ app/main.py | 49 +++--- app/main_authenticator.py | 87 +++++++++++ app/main_old.py | 70 +++++++++ config/auth.yaml | 7 + data/app.db | Bin 24576 -> 32768 bytes .../20251129_162900_add_table_sessions.sql | 10 ++ 9 files changed, 524 insertions(+), 50 deletions(-) create mode 100644 app/auth_old.py create mode 100644 app/main_authenticator.py create mode 100644 app/main_old.py create mode 100644 config/auth.yaml create mode 100644 migrations/20251129_162900_add_table_sessions.sql diff --git a/app/auth.py b/app/auth.py index ff57039..d7921e4 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,4 +1,7 @@ +# app/auth.py import sqlite3 +import time +import secrets from contextlib import closing import bcrypt @@ -6,6 +9,38 @@ import streamlit as st from db import get_conn + +SESSION_LIFETIME_SECONDS = 8 * 60 * 60 # 8 Stunden + + +# ---------- User-DB ---------- +def init_auth_db(): + """Legt die users-Tabelle an, falls sie noch nicht existiert.""" + with closing(get_conn()) as conn, conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash BLOB NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + ) + """ + ) + # Sessions-Tabelle + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE + ) + """ + ) + + def create_user(username: str, password: str, role: str = "user") -> bool: pw_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) try: @@ -32,15 +67,64 @@ def verify_user(username: str, password: str): return (ok, role) if ok else (False, None) -# ---------- Session / Helper ---------- -def set_session_user(username: str, role: str): +# ---------- Session-DB ---------- +def create_session(username: str) -> str: + """Erzeugt eine neue Session-ID und speichert sie in der DB.""" + sid = secrets.token_urlsafe(32) # schön zufällig, URL-tauglich + now = int(time.time()) + expires_at = now + SESSION_LIFETIME_SECONDS + with closing(get_conn()) as conn, conn: + conn.execute( + """ + INSERT INTO sessions (id, username, created_at, expires_at) + VALUES (?, ?, ?, ?) + """, + (sid, username, now, expires_at), + ) + return sid + + +def get_session(sid: str): + """Liefert (username, role), wenn Session existiert und gültig ist, sonst (None, None).""" + now = int(time.time()) + with closing(get_conn()) as conn: + row = conn.execute( + """ + SELECT s.username, u.role + FROM sessions s + JOIN users u ON u.username = s.username + WHERE s.id = ? AND s.expires_at > ? + """, + (sid, now), + ).fetchone() + if not row: + return None, None + return row[0], row[1] + + +def delete_session(sid: str): + with closing(get_conn()) as conn, conn: + conn.execute("DELETE FROM sessions WHERE id = ?", (sid,)) + + +# Optional: abgelaufene Sessions aufräumen +def cleanup_sessions(): + now = int(time.time()) + with closing(get_conn()) as conn, conn: + conn.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,)) + + +# ---------- SessionState-Helper ---------- +def set_session_user(username: str, role: str, sid: str | None = None): st.session_state["auth"] = True st.session_state["username"] = username st.session_state["role"] = role + if sid is not None: + st.session_state["session_id"] = sid def clear_session_user(): - for k in ["auth", "username", "role"]: + for k in ["auth", "username", "role", "session_id"]: st.session_state.pop(k, None) @@ -49,18 +133,34 @@ def is_authenticated() -> bool: def current_user(): - """(username, role) oder (None, None)""" - return st.session_state.get("username"), st.session_state.get("role") + return ( + st.session_state.get("username"), + st.session_state.get("role"), + ) def ensure_logged_in(): """ Am Anfang jeder Page aufrufen. - Wenn kein Login: Login-View anzeigen und Script stoppen. + Prüft erst st.session_state, dann ?session_id=... in der URL. """ - if not is_authenticated(): - login_view() - st.stop() + if is_authenticated(): + return + + # Versuch: session_id aus URL + params = st.experimental_get_query_params() + sid_list = params.get("session_id") + sid = sid_list[0] if sid_list else None + + if sid: + username, role = get_session(sid) + if username: + set_session_user(username, role, sid) + return + + # Wenn wir hier landen: kein gültiger Login → Login-View anzeigen + login_view() + st.stop() # ---------- UI ---------- @@ -73,9 +173,18 @@ def login_view(): submitted = st.form_submit_button("Anmelden") if submitted: - ok, role = verify_user(u.strip(), p) + username = u.strip() + ok, role = verify_user(username, p) if ok: - set_session_user(u.strip(), role) + # neue Session in DB + sid = create_session(username) + + # SessionState setzen + set_session_user(username, role, sid) + + # URL-Parameter setzen (Arme-Leute-Cookie) + st.experimental_set_query_params(session_id=sid) + st.success("Erfolgreich angemeldet.") st.rerun() else: @@ -88,7 +197,16 @@ def authed_header(): return st.sidebar.write(f"Angemeldet als **{username}** ({role})") + if st.sidebar.button("Logout"): - st.session_state.pop("app_started_logged", None) + # Session in DB löschen, falls vorhanden + sid = st.session_state.get("session_id") + if sid: + delete_session(sid) + clear_session_user() - st.rerun() \ No newline at end of file + + # URL-Parameter entfernen + st.experimental_set_query_params() + + st.rerun() diff --git a/app/auth_core.py b/app/auth_core.py index ca5e678..4e9fc5a 100644 --- a/app/auth_core.py +++ b/app/auth_core.py @@ -1,28 +1,127 @@ +# app/auth_core.py + from contextlib import closing import bcrypt -from app.db import get_conn +from db import get_conn + + +# --------------------------------------------------------------------------- +# DB Initialisierung: `users`-Tabelle +# --------------------------------------------------------------------------- + +def init_auth_db(): + """ + Legt die users-Tabelle an. + WICHTIG: password_hash wird als TEXT gespeichert, damit sowohl + streamlit-authenticator als auch dein eigener bcrypt-Code kompatibel sind. + """ + with closing(get_conn()) as conn, conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + ) + """ + ) + + +# --------------------------------------------------------------------------- +# Benutzer anlegen +# --------------------------------------------------------------------------- + +def create_user(username: str, password: str, role: str = "user", email: str | None = None) -> bool: + """ + Passwort wird als bcrypt-Hash (TEXT) gespeichert. + """ + pw_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + try: + with closing(get_conn()) as conn, conn: + conn.execute( + "INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)", + (username, email, pw_hash, role), + ) + return True + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Benutzer überprüfen (z.B. für deine alte Streamlit-Login-Maske) +# --------------------------------------------------------------------------- def verify_user(username: str, password: str): - """Prüft Username/Passwort gegen die users-Tabelle.""" + """ + Vergleicht eingegebenes Passwort mit dem gespeicherten bcrypt-Hash. + Rückgabe: (True, role) oder (False, None) + """ with closing(get_conn()) as conn: row = conn.execute( "SELECT password_hash, role FROM users WHERE username = ?", (username,), ).fetchone() + if not row: return False, None + stored_hash, role = row - # stored_hash ist BLOB -> bytes - ok = bcrypt.checkpw(password.encode("utf-8"), stored_hash) + # stored_hash ist TEXT → zurück nach bytes + ok = bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8")) + return (ok, role) if ok else (False, None) -def get_role_for_user(username: str) -> str | None: - """Liest nur die Rolle aus der DB (z.B. wenn du später OIDC nimmst).""" + +# --------------------------------------------------------------------------- +# Rolle für einen Benutzer holen (für Streamlit-Inhalte) +# --------------------------------------------------------------------------- + +def get_role_for_user(username: str) -> str: with closing(get_conn()) as conn: row = conn.execute( "SELECT role FROM users WHERE username = ?", (username,), ).fetchone() - return row[0] if row else None \ No newline at end of file + return row[0] if row else "user" + + +# --------------------------------------------------------------------------- +# Credential-Lader für streamlit-authenticator +# --------------------------------------------------------------------------- + +def load_credentials_from_db() -> dict: + """ + Baut das credentials-Dict so: + { + "usernames": { + "hansi": { + "email": "hansi@example.com", + "name": "hansi", + "password": "$2b$12$...", + }, + ... + } + } + streamlit-authenticator prüft dann Login + Cookies automatisch. + """ + creds = {"usernames": {}} + + with closing(get_conn()) as conn: + rows = conn.execute( + "SELECT username, email, password_hash FROM users" + ).fetchall() + + for username, email, pw_hash in rows: + # pw_hash kommt als TEXT + creds["usernames"][username] = { + "name": username, + "email": email or f"{username}@example.local", + "password": pw_hash, # TEXT → streamlit-authenticator-kompatibel + } + + return creds diff --git a/app/auth_old.py b/app/auth_old.py new file mode 100644 index 0000000..ff57039 --- /dev/null +++ b/app/auth_old.py @@ -0,0 +1,94 @@ +import sqlite3 +from contextlib import closing + +import bcrypt +import streamlit as st + +from db import get_conn + +def create_user(username: str, password: str, role: str = "user") -> bool: + pw_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + try: + with closing(get_conn()) as conn, conn: + conn.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + (username, pw_hash, role), + ) + return True + except sqlite3.IntegrityError: + return False + + +def verify_user(username: str, password: str): + with closing(get_conn()) as conn: + row = conn.execute( + "SELECT password_hash, role FROM users WHERE username = ?", + (username,), + ).fetchone() + if not row: + return False, None + stored_hash, role = row + ok = bcrypt.checkpw(password.encode("utf-8"), stored_hash) + return (ok, role) if ok else (False, None) + + +# ---------- Session / Helper ---------- +def set_session_user(username: str, role: str): + st.session_state["auth"] = True + st.session_state["username"] = username + st.session_state["role"] = role + + +def clear_session_user(): + for k in ["auth", "username", "role"]: + st.session_state.pop(k, None) + + +def is_authenticated() -> bool: + return bool(st.session_state.get("auth")) + + +def current_user(): + """(username, role) oder (None, None)""" + return st.session_state.get("username"), st.session_state.get("role") + + +def ensure_logged_in(): + """ + Am Anfang jeder Page aufrufen. + Wenn kein Login: Login-View anzeigen und Script stoppen. + """ + if not is_authenticated(): + login_view() + st.stop() + + +# ---------- UI ---------- +def login_view(): + st.title("Intranet Login") + + with st.form("login"): + u = st.text_input("Username") + p = st.text_input("Passwort", type="password") + submitted = st.form_submit_button("Anmelden") + + if submitted: + ok, role = verify_user(u.strip(), p) + if ok: + set_session_user(u.strip(), role) + st.success("Erfolgreich angemeldet.") + st.rerun() + else: + st.error("Login fehlgeschlagen.") + + +def authed_header(): + username, role = current_user() + if not username: + return + + st.sidebar.write(f"Angemeldet als **{username}** ({role})") + if st.sidebar.button("Logout"): + st.session_state.pop("app_started_logged", None) + clear_session_user() + st.rerun() \ No newline at end of file diff --git a/app/main.py b/app/main.py index af80c29..3240a60 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,13 @@ +# app/main.py import streamlit as st -from version import __version__ -from logging_config import setup_logging -import os -from auth import login_view, authed_header, current_user, create_user, clear_session_user - -APP_ENV = os.environ.get("APP_ENV", "dev") -logger = setup_logging(APP_ENV) +from auth import ( + init_auth_db, + ensure_logged_in, + authed_header, + current_user, + create_user, # falls du Admin-Funktion brauchst +) def content_for(role: str, username: str): @@ -31,40 +32,28 @@ def content_for(role: str, username: str): st.subheader("Dein Bereich") st.write(f"Personalisierter Content für **{username}**.") - # hier: weitere Seite(n), DB-Zugriffe etc. + # fachliche Inhalte … + def main(): - if "app_started_logged" not in st.session_state: - logger.info(f"Starting app in {APP_ENV} mode - APP-Version {__version__}") - st.session_state["app_started_logged"] = True - # logger.info(f"User Starting app in {APP_ENV} mode - APP-Version {__version__}") st.set_page_config( page_title="Intranet-Portal", page_icon="🔒", layout="centered", ) - # Falls kein Login: Login-View anzeigen und raus - if not st.session_state.get("auth"): - login_view() - return - # Ab hier sind wir eingeloggt + init_auth_db() + + # hier wird entweder: + # - st.session_state genutzt, oder + # - ?session_id=... aus URL geprüft, oder + # - Login-Form gezeigt (und Script gestoppt) + ensure_logged_in() + authed_header() username, role = current_user() content_for(role, username) - st.write(st.session_state) - - if st.sidebar.button("set state"): - st.session_state["app_started_logged"] = True - #clear_session_user() - st.write(st.session_state) - #st.rerun() - if st.sidebar.button("delete state"): - st.session_state.pop("app_started_logged", None) - #clear_session_user() - st.write(st.session_state) - #st.rerun() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/app/main_authenticator.py b/app/main_authenticator.py new file mode 100644 index 0000000..323ca19 --- /dev/null +++ b/app/main_authenticator.py @@ -0,0 +1,87 @@ +# app/main.py +import streamlit as st +import yaml +from yaml.loader import SafeLoader +import streamlit_authenticator as stauth + +from auth_core import ( + init_auth_db, + load_credentials_from_db, + get_role_for_user, + create_user, +) +from version import __version__ + + +def content_for(username: str, role: str): + st.header("Dashboard") + st.info(f"Willkommen, {username}!") + + if role == "admin": + st.subheader("Admin-Bereich") + st.write("Nur Admins sehen das hier.") + + with st.expander("Neuen Nutzer anlegen"): + new_u = st.text_input("Neuer Username", key="new_u") + new_email = st.text_input("E-Mail", key="new_email") + new_p = st.text_input("Neues Passwort", type="password", key="new_p") + new_role = st.selectbox("Rolle", ["user", "admin"], key="new_role") + + if st.button("Anlegen"): + if new_u and new_p: + ok = create_user(new_u.strip(), new_p, new_role, new_email.strip() or None) + st.success("Nutzer angelegt.") if ok else st.error("Username bereits vorhanden oder Fehler.") + else: + st.warning("Bitte Username und Passwort eingeben.") + + st.subheader("Dein Bereich") + st.write(f"Personalisierter Content für **{username}**.") + + +def main(): + st.set_page_config( + page_title=f"Intranet-Portal v{__version__}", + page_icon="🔒", + layout="centered", + ) + + # DB-Struktur sicherstellen + init_auth_db() + + # --- Config laden (Cookie, etc.) --- + with open("config/auth.yaml", "r", encoding="utf-8") as f: + base_config = yaml.load(f, Loader=SafeLoader) + + # --- Credentials dynamisch aus DB laden --- + db_creds = load_credentials_from_db() + base_config["credentials"] = db_creds + + authenticator = stauth.Authenticate( + base_config["credentials"], + base_config["cookie"]["name"], + base_config["cookie"]["key"], + base_config["cookie"]["expiry_days"], + base_config.get("preauthorized", {}), + ) + + name, auth_status, username = authenticator.login("Login", "main") + + if auth_status is False: + st.error("Login fehlgeschlagen.") + return + + if auth_status is None: + st.warning("Bitte Benutzername und Passwort eingeben.") + return + + # ---- Ab hier eingeloggt (persistenter Cookie) ---- + role = get_role_for_user(username) + + authenticator.logout("Logout", "sidebar") + st.sidebar.write(f"Angemeldet als **{name}** ({username}, Rolle: {role})") + + content_for(username, role) + + +if __name__ == "__main__": + main() diff --git a/app/main_old.py b/app/main_old.py new file mode 100644 index 0000000..83ea26e --- /dev/null +++ b/app/main_old.py @@ -0,0 +1,70 @@ +import streamlit as st +from version import __version__ +from logging_config import setup_logging +import os +from auth import init_auth_db, ensure_logged_in, login_view, authed_header, current_user, create_user, clear_session_user + +APP_ENV = os.environ.get("APP_ENV", "dev") +logger = setup_logging(APP_ENV) + + + +def content_for(role: str, username: str): + st.header("Dashboard") + st.info(f"Willkommen, {username}!") + + if role == "admin": + st.subheader("Admin-Bereich") + st.write("Nur Admins sehen das hier.") + with st.expander("Neuen Nutzer anlegen"): + new_u = st.text_input("Neuer Username", key="new_u") + new_p = st.text_input("Neues Passwort", type="password", key="new_p") + new_role = st.selectbox("Rolle", ["user", "admin"], key="new_role") + if st.button("Anlegen"): + if new_u and new_p: + ok = create_user(new_u.strip(), new_p, new_role) + st.success("Nutzer angelegt.") if ok else st.error( + "Username bereits vorhanden." + ) + else: + st.warning("Bitte Username und Passwort eingeben.") + + st.subheader("Dein Bereich") + st.write(f"Personalisierter Content für **{username}**.") + # hier: weitere Seite(n), DB-Zugriffe etc. + +def main(): + if "app_started_logged" not in st.session_state: + logger.info(f"Starting app in {APP_ENV} mode - APP-Version {__version__}") + st.session_state["app_started_logged"] = True + # logger.info(f"User Starting app in {APP_ENV} mode - APP-Version {__version__}") + st.set_page_config( + page_title="Intranet-Portal", + page_icon="🔒", + layout="centered", + ) + # Falls kein Login: Login-View anzeigen und raus + if not st.session_state.get("auth"): + login_view() + return + + # Ab hier sind wir eingeloggt + authed_header() + username, role = current_user() + content_for(role, username) + + st.write(st.session_state) + + if st.sidebar.button("set state"): + st.session_state["app_started_logged"] = True + #clear_session_user() + st.write(st.session_state) + #st.rerun() + if st.sidebar.button("delete state"): + st.session_state.pop("app_started_logged", None) + #clear_session_user() + st.write(st.session_state) + #st.rerun() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/config/auth.yaml b/config/auth.yaml new file mode 100644 index 0000000..cd0733b --- /dev/null +++ b/config/auth.yaml @@ -0,0 +1,7 @@ +cookie: + expiry_days: 7 + key: "please_change_this_cookie_signing_key" + name: "intranet_session" + +preauthorized: + emails: [] diff --git a/data/app.db b/data/app.db index 8e32fc2df264bdb9c40b2a29587d986f738f0cdd..da176b0e1215578af57b07bb8fc326f5f57f5a74 100644 GIT binary patch delta 491 zcmZoTz}V2hG(lRBoq>UY1BhXOd7_T7G&_S{RUt3`4+eIgG6sHW{@uJE`9AQ@=PBdO z+t~Pr+pe*cja^(;ma*BkBrz!`wK%o7I5R)57)G-@2e~?ixGID=I{CONz(f@^xDKKE8TC{}2VgP#+&1utaiEYGO%hN_=98f~Q}ItGjEE0;+=4ih|6d)MDHU z-28)FJ>C5jyj>#|G)jw8i}Dh4Q#BQWT-{uQT>YF~gB3u6#V{!upq#&-f{UwqPv0yk(9LgU!NeSB zY-C_$YG`O=8EZGNgRZNS9~iZuZS2L6Ma1p}V&Pn;mc#RU_(2onMTuS|(c delta 96 zcmZo@U}`wPI6+#FnSp_U4TxcYX`+s?I5UG@RUt3`4+d7=`3(Hh{JVKS@_pc)zgbW~ rlXvrc-c%+wM*fQo{1-P1IvnSpd{N#Q$bZVf{}jl-&A<7nzO(@V*Mu0Q diff --git a/migrations/20251129_162900_add_table_sessions.sql b/migrations/20251129_162900_add_table_sessions.sql new file mode 100644 index 0000000..41e28fc --- /dev/null +++ b/migrations/20251129_162900_add_table_sessions.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE +); +INSERT INTO schema_version (version) VALUES ('20251129_162900_add_table_sessions'); + +COMMIT; \ No newline at end of file