import os import json import secrets import smtplib import ssl from email.message import EmailMessage from datetime import datetime, timedelta from urllib.parse import quote import streamlit as st from sqlalchemy import ( create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean ) from sqlalchemy.orm import declarative_base, sessionmaker, relationship from passlib.context import CryptContext # ---------------------------- # App Config # ---------------------------- APP_NAME = "Bank AHR" # Base URL for email links (VERY IMPORTANT) # Example: https://your-space-name.hf.space APP_BASE_URL = os.getenv("APP_BASE_URL", "").strip() # Email / SMTP settings (set these in hosting Secrets/Env) SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) SMTP_USER = os.getenv("SMTP_USER", "") SMTP_PASS = os.getenv("SMTP_PASS", "") SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER) # Development mode: show link in UI if SMTP isn't configured DEV_MODE = os.getenv("DEV_MODE", "false").lower() in ("1", "true", "yes") # Verification settings LINK_TTL_MINUTES = int(os.getenv("LINK_TTL_MINUTES", "30")) # link expires after 30 min RESEND_COOLDOWN_SECONDS = int(os.getenv("RESEND_COOLDOWN_SECONDS", "60")) # resend cooldown # ---------------------------- # Config: DB path (persistent) # ---------------------------- DB_DIR = "/data" if os.path.isdir("/data") else "." DB_PATH = os.path.join(DB_DIR, "bank_ahr.db") engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) Base = declarative_base() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # ---------------------------- # Models # ---------------------------- class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) full_name = Column(String, nullable=False) email = Column(String, unique=True, index=True, nullable=False) password_hash = Column(String, nullable=False) created_at = Column(DateTime, default=datetime.utcnow) # Email verification (LINK token) email_verified = Column(Boolean, default=False, nullable=False) email_verification_token_hash = Column(String, nullable=True) email_verification_sent_at = Column(DateTime, nullable=True) # Password reset (LINK token) password_reset_token_hash = Column(String, nullable=True) password_reset_sent_at = Column(DateTime, nullable=True) accounts = relationship("BankAccount", back_populates="owner", cascade="all, delete-orphan") payees = relationship("Payee", back_populates="owner", cascade="all, delete-orphan") class BankAccount(Base): __tablename__ = "bank_accounts" id = Column(Integer, primary_key=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) account_number = Column(String, unique=True, index=True, nullable=False) nickname = Column(String, default="Checking") balance = Column(Float, default=0.0) created_at = Column(DateTime, default=datetime.utcnow) owner = relationship("User", back_populates="accounts") class Payee(Base): __tablename__ = "payees" id = Column(Integer, primary_key=True) owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) name = Column(String, nullable=False) memo = Column(String, default="") created_at = Column(DateTime, default=datetime.utcnow) owner = relationship("User", back_populates="payees") class Transaction(Base): __tablename__ = "transactions" id = Column(Integer, primary_key=True) created_at = Column(DateTime, default=datetime.utcnow, index=True) # DEPOSIT | TRANSFER | BILLPAY tx_type = Column(String, nullable=False) amount = Column(Float, nullable=False) from_account = Column(String, nullable=True) to_account = Column(String, nullable=True) meta = Column(Text, default="{}") Base.metadata.create_all(bind=engine) # ---------------------------- # Lightweight schema upgrade (older DB -> new columns) # ---------------------------- def ensure_schema(): session = SessionLocal() try: cols = session.execute("PRAGMA table_info(users);").fetchall() col_names = {c[1] for c in cols} def add_col(sql): session.execute(sql) if "email_verified" not in col_names: add_col("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT 0;") # New link-token columns (rename-safe: add if missing) if "email_verification_token_hash" not in col_names: add_col("ALTER TABLE users ADD COLUMN email_verification_token_hash VARCHAR;") if "email_verification_sent_at" not in col_names: add_col("ALTER TABLE users ADD COLUMN email_verification_sent_at DATETIME;") if "password_reset_token_hash" not in col_names: add_col("ALTER TABLE users ADD COLUMN password_reset_token_hash VARCHAR;") if "password_reset_sent_at" not in col_names: add_col("ALTER TABLE users ADD COLUMN password_reset_sent_at DATETIME;") # If you previously used *_code_hash columns, we don't remove them (SQLite limitations). session.commit() except Exception: session.rollback() finally: session.close() ensure_schema() # ---------------------------- # Helpers # ---------------------------- def db_session(): return SessionLocal() def norm_email(email: str) -> str: return (email or "").lower().strip() def hash_password(pw: str) -> str: return pwd_context.hash(pw) def verify_password(pw: str, hashed: str) -> bool: return pwd_context.verify(pw, hashed) def hash_token(token: str) -> str: return pwd_context.hash(token) def verify_token(token: str, hashed: str) -> bool: return pwd_context.verify(token, hashed) def generate_link_token() -> str: # ~43 chars, safe for bcrypt and URLs return secrets.token_urlsafe(32) def money(x: float) -> str: return f"${x:,.2f}" def require_login(): if not st.session_state.get("user_id"): st.warning("Please log in first.") st.stop() def get_user(session): return session.get(User, st.session_state["user_id"]) def get_user_accounts(session, user_id: int): return session.query(BankAccount).filter_by(owner_id=user_id).order_by(BankAccount.created_at.asc()).all() def can_resend(last_sent_at: datetime | None) -> tuple[bool, str]: if not last_sent_at: return True, "" delta = datetime.utcnow() - last_sent_at if delta.total_seconds() >= RESEND_COOLDOWN_SECONDS: return True, "" wait = int(RESEND_COOLDOWN_SECONDS - delta.total_seconds()) return False, f"Please wait {wait}s before requesting another email." def is_token_expired(sent_at: datetime | None) -> bool: if not sent_at: return True return datetime.utcnow() > (sent_at + timedelta(minutes=LINK_TTL_MINUTES)) def build_link(action: str, email: str, token: str) -> str: """ Creates link like: https://yourdomain/?action=verify&email=...&token=... """ q_email = quote(norm_email(email)) q_token = quote(token) if APP_BASE_URL: base = APP_BASE_URL.rstrip("/") return f"{base}/?action={action}&email={q_email}&token={q_token}" # Fallback (may not work inside email clients without absolute URL) return f"/?action={action}&email={q_email}&token={q_token}" def get_query_params() -> dict: # streamlit new API try: return dict(st.query_params) except Exception: return st.experimental_get_query_params() def clear_query_params(): try: st.query_params.clear() except Exception: st.experimental_set_query_params() # ---------------------------- # Email sending # ---------------------------- def smtp_configured() -> bool: return bool(SMTP_HOST and SMTP_PORT and SMTP_USER and SMTP_PASS and SMTP_FROM) def send_email(to_email: str, subject: str, body: str) -> tuple[bool, str]: if not smtp_configured(): if DEV_MODE: return False, "SMTP not configured (DEV_MODE enabled)." return False, "Email service is not configured. Please set SMTP secrets." try: msg = EmailMessage() msg["From"] = SMTP_FROM msg["To"] = to_email msg["Subject"] = subject msg.set_content(body) context = ssl.create_default_context() with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: server.starttls(context=context) server.login(SMTP_USER, SMTP_PASS) server.send_message(msg) return True, "Email sent." except Exception as e: return False, f"Failed to send email: {e}" # ---------------------------- # Link-based Verification & Reset # ---------------------------- def send_verification_link(email: str) -> tuple[bool, str, str | None]: session = db_session() raw_token = None try: user = session.query(User).filter_by(email=norm_email(email)).first() if not user: return False, "No account found for that email.", None if user.email_verified: return True, "Email already verified.", None ok_resend, msg_resend = can_resend(user.email_verification_sent_at) if not ok_resend: return False, msg_resend, None raw_token = generate_link_token() user.email_verification_token_hash = hash_token(raw_token) user.email_verification_sent_at = datetime.utcnow() session.commit() link = build_link("verify", user.email, raw_token) subject = f"{APP_NAME} - Verify your email" body = ( f"Welcome to {APP_NAME}!\n\n" f"Click this link to verify your email:\n{link}\n\n" f"This link expires in {LINK_TTL_MINUTES} minutes.\n" "If you did not request this, ignore this email." ) sent, send_msg = send_email(user.email, subject, body) if sent: return True, "Verification link sent to your email.", (link if DEV_MODE else None) if DEV_MODE: return True, f"{send_msg} (Showing link in DEV_MODE)", link return False, send_msg, None except Exception as e: session.rollback() return False, f"Error: {e}", None finally: session.close() def verify_email_by_link(email: str, token: str) -> tuple[bool, str]: session = db_session() try: user = session.query(User).filter_by(email=norm_email(email)).first() if not user: return False, "Invalid verification link." if user.email_verified: return True, "Email already verified. You can log in." if not user.email_verification_token_hash or not user.email_verification_sent_at: return False, "No verification request found. Please request a new link." if is_token_expired(user.email_verification_sent_at): return False, "Verification link expired. Please request a new link." if not verify_token(token, user.email_verification_token_hash): return False, "Invalid verification link." user.email_verified = True user.email_verification_token_hash = None user.email_verification_sent_at = None session.commit() return True, "Email verified successfully! You can now log in." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def send_password_reset_link(email: str) -> tuple[bool, str, str | None]: """ Privacy-safe: always returns generic success message. If user exists, it sends link + stores token hash. """ session = db_session() raw_token = None try: user = session.query(User).filter_by(email=norm_email(email)).first() generic_msg = "If an account exists for that email, a reset link has been sent." if not user: return True, generic_msg, None ok_resend, msg_resend = can_resend(user.password_reset_sent_at) if not ok_resend: return False, msg_resend, None raw_token = generate_link_token() user.password_reset_token_hash = hash_token(raw_token) user.password_reset_sent_at = datetime.utcnow() session.commit() link = build_link("reset", user.email, raw_token) subject = f"{APP_NAME} - Reset your password" body = ( f"Click this link to reset your password:\n{link}\n\n" f"This link expires in {LINK_TTL_MINUTES} minutes.\n" "If you did not request this,FEST, ignore this email." ) sent, send_msg = send_email(user.email, subject, body) if sent: return True, generic_msg, (link if DEV_MODE else None) if DEV_MODE: return True, f"{send_msg} (Showing link in DEV_MODE)", link return True, generic_msg, None except Exception as e: session.rollback() return False, f"Error: {e}", None finally: session.close() def reset_password_by_link(email: str, token: str, new_password: str) -> tuple[bool, str]: session = db_session() try: user = session.query(User).filter_by(email=norm_email(email)).first() if not user: return False, "Invalid reset link." if not user.password_reset_token_hash or not user.password_reset_sent_at: return False, "No reset request found. Please request a new reset link." if is_token_expired(user.password_reset_sent_at): return False, "Reset link expired. Please request a new one." if not verify_token(token, user.password_reset_token_hash): return False, "Invalid reset link." if len(new_password) < 6: return False, "Password must be at least 6 characters." user.password_hash = hash_password(new_password) user.password_reset_token_hash = None user.password_reset_sent_at = None session.commit() return True, "Password reset successfully! You can now log in." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() # ---------------------------- # Banking Core # ---------------------------- def generate_account_number(session) -> str: while True: acct = "".join(str(secrets.randbelow(10)) for _ in range(12)) exists = session.query(BankAccount).filter_by(account_number=acct).first() if not exists: return acct def open_account(full_name: str, email: str, password: str, nickname: str = "Checking"): session = db_session() try: email_clean = norm_email(email) if session.query(User).filter_by(email=email_clean).first(): return False, "Email already registered." if len(password) < 6: return False, "Password should be at least 6 characters." user = User( full_name=full_name.strip(), email=email_clean, password_hash=hash_password(password), email_verified=False, ) session.add(user) session.flush() acct_num = generate_account_number(session) acct = BankAccount( owner_id=user.id, account_number=acct_num, nickname=nickname.strip() if nickname.strip() else "Checking", balance=0.0, ) session.add(acct) session.commit() # Send verification link immediately ok, msg, dev_link = send_verification_link(user.email) if ok: return True, f"Account created! Account number: {acct_num}\n{msg}" + (f"\nDEV_LINK: {dev_link}" if dev_link else "") return True, f"Account created! Account number: {acct_num}\nNOTE: {msg}" except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def login(email: str, password: str): """ Returns: (ok: bool, msg: str, status: str) status: OK | INVALID | UNVERIFIED """ session = db_session() try: user = session.query(User).filter_by(email=norm_email(email)).first() if not user or not verify_password(password, user.password_hash): return False, "Invalid email or password.", "INVALID" if not user.email_verified: return False, "Email not verified. Please verify your email (link sent).", "UNVERIFIED" st.session_state["user_id"] = user.id st.session_state["user_name"] = user.full_name return True, f"Welcome, {user.full_name}!", "OK" finally: session.close() def logout(): for k in ["user_id", "user_name", "pending_verify_email"]: if k in st.session_state: del st.session_state[k] def change_password(user_id: int, current_pw: str, new_pw: str) -> tuple[bool, str]: session = db_session() try: user = session.get(User, user_id) if not user: return False, "User not found." if not verify_password(current_pw, user.password_hash): return False, "Current password is incorrect." if len(new_pw) < 6: return False, "New password must be at least 6 characters." user.password_hash = hash_password(new_pw) session.commit() return True, "Password changed successfully." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def deposit(user_id: int, account_number: str, amount: float, note: str = ""): if amount <= 0: return False, "Deposit must be greater than 0." session = db_session() try: acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=account_number).with_for_update().first() if not acct: return False, "Account not found." acct.balance += amount tx = Transaction( tx_type="DEPOSIT", amount=amount, from_account=None, to_account=account_number, meta=json.dumps({"note": note.strip()}), ) session.add(tx) session.commit() return True, f"Deposited {money(amount)} to {account_number}." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def transfer(user_id: int, from_acct_num: str, to_acct_num: str, amount: float, note: str = ""): if amount <= 0: return False, "Transfer amount must be greater than 0." if from_acct_num == to_acct_num: return False, "From and To accounts cannot be the same." session = db_session() try: from_acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=from_acct_num).with_for_update().first() if not from_acct: return False, "Source account not found." to_acct = session.query(BankAccount).filter_by(account_number=to_acct_num).with_for_update().first() if not to_acct: return False, "Destination account number not found." if from_acct.balance < amount: return False, "Insufficient funds." from_acct.balance -= amount to_acct.balance += amount tx = Transaction( tx_type="TRANSFER", amount=amount, from_account=from_acct_num, to_account=to_acct_num, meta=json.dumps({"note": note.strip()}), ) session.add(tx) session.commit() return True, f"Transferred {money(amount)} from {from_acct_num} to {to_acct_num}." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def add_payee(user_id: int, name: str, memo: str = ""): if not name.strip(): return False, "Payee name is required." session = db_session() try: payee = Payee(owner_id=user_id, name=name.strip(), memo=memo.strip()) session.add(payee) session.commit() return True, f"Payee '{payee.name}' added." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() def pay_bill(user_id: int, from_acct_num: str, payee_id: int, amount: float, note: str = ""): if amount <= 0: return False, "Payment amount must be greater than 0." session = db_session() try: acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=from_acct_num).with_for_update().first() if not acct: return False, "Account not found." payee = session.query(Payee).filter_by(owner_id=user_id, id=payee_id).first() if not payee: return False, "Payee not found." if acct.balance < amount: return False, "Insufficient funds." acct.balance -= amount tx = Transaction( tx_type="BILLPAY", amount=amount, from_account=from_acct_num, to_account=None, meta=json.dumps({"payee": payee.name, "note": note.strip()}), ) session.add(tx) session.commit() return True, f"Paid {money(amount)} to {payee.name} from {from_acct_num}." except Exception as e: session.rollback() return False, f"Error: {e}" finally: session.close() # ---------------------------- # UI # ---------------------------- st.set_page_config(page_title=APP_NAME, page_icon="🏦", layout="wide") st.markdown( """ """, unsafe_allow_html=True, ) # Handle verification/reset link clicks params = get_query_params() action = (params.get("action", [""]) if isinstance(params.get("action"), list) else params.get("action", "")).strip() q_email = (params.get("email", [""]) if isinstance(params.get("email"), list) else params.get("email", "")).strip() q_token = (params.get("token", [""]) if isinstance(params.get("token"), list) else params.get("token", "")).strip() if action == "verify" and q_email and q_token: ok, msg = verify_email_by_link(q_email, q_token) (st.success if ok else st.error)(msg) clear_query_params() if action == "reset" and q_email and q_token: st.info("Reset your password using the form below.") left, right = st.columns([3, 2], gap="large") with left: st.markdown(f'
Secure banking demo with link-based email verification
', unsafe_allow_html=True) with right: if st.session_state.get("user_id"): st.success(f"Logged in as: {st.session_state.get('user_name')}") if st.button("Log out"): logout() st.rerun() else: st.info("Not logged in") st.divider() # If user opened a reset link, show reset form (not logged in) if not st.session_state.get("user_id") and action == "reset" and q_email and q_token: st.markdown("### Reset Password") with st.form("reset_by_link_form", clear_on_submit=True): st.write(f"Email: **{norm_email(q_email)}**") new_pw = st.text_input("New password (min 6 chars)", type="password") new_pw2 = st.text_input("Confirm new password", type="password") submit = st.form_submit_button("Reset password") if submit: if new_pw != new_pw2: st.error("Passwords do not match.") else: ok, msg = reset_password_by_link(q_email, q_token, new_pw) (st.success if ok else st.error)(msg) if ok: clear_query_params() st.stop() # ---------------------------- # Auth Screens (not logged in) # ---------------------------- if not st.session_state.get("user_id"): colA, colB = st.columns(2, gap="large") with colA: st.markdown("### Log in") with st.form("login_form", clear_on_submit=False): email = st.text_input("Email", placeholder="you@example.com") password = st.text_input("Password", type="password") submit = st.form_submit_button("Log in") if submit: ok, msg, status = login(email, password) if ok: st.success(msg) st.rerun() else: st.error(msg) if status == "UNVERIFIED": st.session_state["pending_verify_email"] = norm_email(email) pending_email = st.session_state.get("pending_verify_email", "") if pending_email: st.markdown("#### Verify your email (link-based)") st.write(f"Email: **{pending_email}**") if st.button("Send verification link"): ok, msg, dev_link = send_verification_link(pending_email) (st.success if ok else st.error)(msg) if dev_link and DEV_MODE: st.markdown(f'