SmartHeal commited on
Commit
3e9c9ca
Β·
verified Β·
1 Parent(s): 4c403bd

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +123 -40
src/streamlit_app.py CHANGED
@@ -1,16 +1,26 @@
1
- # simple_lookup_persistent.py
 
 
 
 
2
  import streamlit as st
3
  import pandas as pd
4
- from pathlib import Path
 
 
 
 
5
 
6
- st.set_page_config(page_title="Simple Password Lookup", page_icon="πŸ”Ž", layout="centered")
7
- st.title("πŸ”Ž Simple Password Lookup")
8
- st.caption("Upload your Excel/CSV once β†’ it will be saved for future sessions β†’ search to reveal passwords.")
9
 
10
- # ---------------- Config ----------------
11
- # Hugging Face Spaces mounts /data as persistent storage; locally fall back to ./creds.xlsx
12
- PERSIST_FILE = Path("/data/creds.xlsx") if Path("/data").exists() else Path("creds.xlsx")
 
13
 
 
 
14
  ALIASES = {
15
  "name": ["name", "title", "site", "account", "platform", "service", "app"],
16
  "username": ["username", "user", "login", "email", "userid", "id"],
@@ -21,42 +31,124 @@ ALIASES = {
21
  EXPECTED = ["name", "username", "url", "password", "note"]
22
 
23
  def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
24
- """Map common header aliases -> EXPECTED, create missing columns, clean strings."""
25
- # Normalize headers
26
  df.columns = [str(c).strip().lower() for c in df.columns]
27
  colmap = {}
28
- # Map aliases
29
  for target, alias_list in ALIASES.items():
30
  for c in df.columns:
31
  if c in alias_list:
32
  colmap[target] = c
33
  break
34
- # Ensure all expected columns exist
35
  for col in EXPECTED:
36
  if col not in colmap:
37
  df[col] = ""
38
  colmap[col] = col
39
-
40
  out = df[[colmap[c] for c in EXPECTED]].rename(columns={colmap[c]: c for c in colmap})
41
- # Clean to strings
42
  for c in EXPECTED:
43
  out[c] = out[c].astype(str).fillna("").replace("nan", "")
44
-
45
- # βœ… Keep rows that have at least one non-empty (after strip) among key fields
46
  key_cols = ["name", "username", "url", "password"]
47
  mask = out[key_cols].applymap(lambda x: str(x).strip() != "").any(axis=1)
48
- out = out[mask].reset_index(drop=True)
49
- return out
50
 
51
  def read_any(file) -> pd.DataFrame:
52
- """Read CSV or Excel from an uploaded file-like object."""
53
  name = (getattr(file, "name", "") or "").lower()
54
  if name.endswith(".csv"):
55
  return pd.read_csv(file)
56
- # Default to Excel
57
  return pd.read_excel(file)
58
 
59
- # ---------------- Load or Upload once ----------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  if "creds" not in st.session_state:
61
  if PERSIST_FILE.exists():
62
  try:
@@ -69,14 +161,13 @@ if "creds" not in st.session_state:
69
  st.session_state.creds = None
70
 
71
  if st.session_state.creds is None:
72
- st.subheader("1) Upload your Excel/CSV (only once)")
73
  up = st.file_uploader("Choose file", type=["xlsx", "xls", "csv"], accept_multiple_files=False)
74
  if not up:
75
  st.stop()
76
  try:
77
  df = standardize_columns(read_any(up))
78
  st.session_state.creds = df
79
- # Try to persist for future sessions
80
  try:
81
  PERSIST_FILE.parent.mkdir(parents=True, exist_ok=True)
82
  df.to_excel(PERSIST_FILE, index=False)
@@ -87,7 +178,6 @@ if st.session_state.creds is None:
87
  st.error(f"Failed to read file: {e}")
88
  st.stop()
89
  else:
90
- # Optional: allow replacing the saved file
91
  with st.expander("Replace saved file (optional)"):
92
  new_up = st.file_uploader("Upload new Excel/CSV to replace saved file", type=["xlsx", "xls", "csv"], key="replacer")
93
  if new_up is not None:
@@ -103,14 +193,12 @@ else:
103
  except Exception as e:
104
  st.error(f"Failed to read new file: {e}")
105
 
106
- # ---------------- Search ----------------
107
- st.subheader("2) Find your password")
108
- q = st.text_input("Search by platform/site/username", placeholder="e.g., netflix, gmail.com, your@email.com")
109
-
110
  if q.strip():
111
  Q = q.lower().strip()
112
  df = st.session_state.creds
113
- # Robust contains with na=False to avoid NaN issues
114
  mask = (
115
  df["name"].str.lower().str.contains(Q, na=False)
116
  | df["username"].str.lower().str.contains(Q, na=False)
@@ -125,23 +213,18 @@ if q.strip():
125
  title = (row["name"] or row["username"] or row["url"]).strip()
126
  with st.expander(f"{title} β€” {row['username']} | {row['url']}"):
127
  show = st.checkbox("Show password", key=f"show_{idx}")
128
- st.text_input(
129
- "Password",
130
- value=row["password"],
131
- type=("default" if show else "password"),
132
- key=f"pw_{idx}",
133
- )
134
  if str(row.get("note", "")).strip():
135
  st.caption("Note: " + str(row["note"]))
136
  else:
137
  st.caption("Type a keyword above to search.")
138
 
139
- # ---------------- Footer ----------------
140
- with st.expander("Notes"):
141
  st.markdown(
142
  """
143
- - Accepted columns (case-insensitive; aliases auto-mapped): **name**, **username**, **url**, **password**, *(optional)* **note**.
144
- - Your first upload is saved to a persistent file so you **won’t have to re-upload again**.
145
- - Use the β€œReplace saved file” section above if you need to update your credentials later.
 
146
  """
147
  )
 
1
+ # simple_lookup_secure.py
2
+ import json
3
+ from pathlib import Path
4
+ from io import BytesIO
5
+
6
  import streamlit as st
7
  import pandas as pd
8
+ import pyotp
9
+ import qrcode
10
+ from PIL import Image
11
+ from argon2 import PasswordHasher
12
+ from argon2.exceptions import VerifyMismatchError
13
 
14
+ st.set_page_config(page_title="Simple Password Lookup (2FA)", page_icon="πŸ”", layout="centered")
15
+ st.title("πŸ” Simple Password Lookup β€” protected with 2FA")
 
16
 
17
+ # ---------- persistent locations ----------
18
+ PERSIST_DIR = Path("/data") if Path("/data").exists() else Path(".")
19
+ PERSIST_FILE = PERSIST_DIR / "creds.xlsx" # your saved credentials file (uploaded once)
20
+ CONFIG_FILE = PERSIST_DIR / "auth.json" # stores password hash + TOTP secret
21
 
22
+ # ---------- helpers ----------
23
+ ph = PasswordHasher()
24
  ALIASES = {
25
  "name": ["name", "title", "site", "account", "platform", "service", "app"],
26
  "username": ["username", "user", "login", "email", "userid", "id"],
 
31
  EXPECTED = ["name", "username", "url", "password", "note"]
32
 
33
  def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
 
 
34
  df.columns = [str(c).strip().lower() for c in df.columns]
35
  colmap = {}
 
36
  for target, alias_list in ALIASES.items():
37
  for c in df.columns:
38
  if c in alias_list:
39
  colmap[target] = c
40
  break
 
41
  for col in EXPECTED:
42
  if col not in colmap:
43
  df[col] = ""
44
  colmap[col] = col
 
45
  out = df[[colmap[c] for c in EXPECTED]].rename(columns={colmap[c]: c for c in colmap})
 
46
  for c in EXPECTED:
47
  out[c] = out[c].astype(str).fillna("").replace("nan", "")
 
 
48
  key_cols = ["name", "username", "url", "password"]
49
  mask = out[key_cols].applymap(lambda x: str(x).strip() != "").any(axis=1)
50
+ return out[mask].reset_index(drop=True)
 
51
 
52
  def read_any(file) -> pd.DataFrame:
 
53
  name = (getattr(file, "name", "") or "").lower()
54
  if name.endswith(".csv"):
55
  return pd.read_csv(file)
 
56
  return pd.read_excel(file)
57
 
58
+ def load_config():
59
+ if CONFIG_FILE.exists():
60
+ return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
61
+ return None
62
+
63
+ def save_config(cfg: dict):
64
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
65
+ CONFIG_FILE.write_text(json.dumps(cfg, separators=(",", ":")), encoding="utf-8")
66
+
67
+ def make_qr_png(data: str) -> bytes:
68
+ img = qrcode.make(data)
69
+ buf = BytesIO()
70
+ img.save(buf, format="PNG")
71
+ return buf.getvalue()
72
+
73
+ # ---------- auth gate (setup or login) ----------
74
+ if "authed" not in st.session_state:
75
+ st.session_state.authed = False
76
+ if "failures" not in st.session_state:
77
+ st.session_state.failures = 0
78
+
79
+ cfg = load_config()
80
+
81
+ if not cfg:
82
+ st.subheader("First-time setup")
83
+ st.write("Create an admin password and pair a TOTP app (Google Authenticator, Microsoft Authenticator, 1Password, etc.).")
84
+
85
+ with st.form("setup"):
86
+ admin_user = st.text_input("Account label (for your authenticator app)", value="SimpleLookup", help="Shown in your authenticator app; can be an email or name.")
87
+ new_pw = st.text_input("New admin password", type="password")
88
+ new_pw2 = st.text_input("Confirm password", type="password")
89
+ submitted = st.form_submit_button("Generate TOTP and Set Password")
90
+
91
+ if submitted:
92
+ if not new_pw or new_pw != new_pw2:
93
+ st.error("Passwords are empty or do not match.")
94
+ else:
95
+ # Create secret + QR
96
+ secret = pyotp.random_base32()
97
+ totp = pyotp.TOTP(secret)
98
+ # issuer shows up in the authenticator app
99
+ uri = totp.provisioning_uri(name=admin_user or "SimpleLookup", issuer_name="Simple Password Lookup")
100
+ qr_png = make_qr_png(uri)
101
+
102
+ # Show QR and ask to verify code once
103
+ st.success("Scan this QR in your authenticator app, then enter a 6-digit code to verify.")
104
+ st.image(qr_png, caption="Scan in Google Authenticator / Microsoft Authenticator", use_column_width=False)
105
+ verify_code = st.text_input("Enter a 6-digit code from your app to verify", max_chars=6)
106
+ if st.button("Verify & Save"):
107
+ if totp.verify(verify_code, valid_window=1):
108
+ hash_pw = ph.hash(new_pw)
109
+ save_config({"password_hash": hash_pw, "totp_secret": secret, "label": admin_user})
110
+ st.success("2FA setup complete. Please reload and log in.")
111
+ st.stop()
112
+ else:
113
+ st.error("Invalid code. Open your authenticator and try a fresh code.")
114
+ st.stop()
115
+ else:
116
+ # Login form
117
+ st.subheader("Login")
118
+ with st.form("login"):
119
+ pw = st.text_input("Admin password", type="password")
120
+ code = st.text_input("Authenticator code (6 digits)", max_chars=6)
121
+ ok = st.form_submit_button("Sign in")
122
+
123
+ if ok:
124
+ try:
125
+ ph.verify(cfg["password_hash"], pw or "")
126
+ # allow Argon2 hash upgrade if needed
127
+ if ph.check_needs_rehash(cfg["password_hash"]):
128
+ cfg["password_hash"] = ph.hash(pw or "")
129
+ save_config(cfg)
130
+ totp = pyotp.TOTP(cfg["totp_secret"])
131
+ if totp.verify(code, valid_window=1):
132
+ st.session_state.authed = True
133
+ else:
134
+ st.session_state.failures += 1
135
+ st.error("Invalid 2FA code.")
136
+ except VerifyMismatchError:
137
+ st.session_state.failures += 1
138
+ st.error("Invalid password.")
139
+ except Exception as e:
140
+ st.session_state.failures += 1
141
+ st.error(f"Login error: {e}")
142
+
143
+ if not st.session_state.authed:
144
+ if st.session_state.failures >= 5:
145
+ st.warning("Too many failed attempts. Wait 30 seconds and try again.")
146
+ st.stop()
147
+
148
+ # ---------- past this point: authenticated ----------
149
+ st.success("Authenticated βœ…")
150
+
151
+ # -------------- load creds or upload once --------------
152
  if "creds" not in st.session_state:
153
  if PERSIST_FILE.exists():
154
  try:
 
161
  st.session_state.creds = None
162
 
163
  if st.session_state.creds is None:
164
+ st.subheader("Upload your Excel/CSV (only once)")
165
  up = st.file_uploader("Choose file", type=["xlsx", "xls", "csv"], accept_multiple_files=False)
166
  if not up:
167
  st.stop()
168
  try:
169
  df = standardize_columns(read_any(up))
170
  st.session_state.creds = df
 
171
  try:
172
  PERSIST_FILE.parent.mkdir(parents=True, exist_ok=True)
173
  df.to_excel(PERSIST_FILE, index=False)
 
178
  st.error(f"Failed to read file: {e}")
179
  st.stop()
180
  else:
 
181
  with st.expander("Replace saved file (optional)"):
182
  new_up = st.file_uploader("Upload new Excel/CSV to replace saved file", type=["xlsx", "xls", "csv"], key="replacer")
183
  if new_up is not None:
 
193
  except Exception as e:
194
  st.error(f"Failed to read new file: {e}")
195
 
196
+ # -------------- search UI --------------
197
+ st.subheader("Find your password")
198
+ q = st.text_input("Search by platform/site/username")
 
199
  if q.strip():
200
  Q = q.lower().strip()
201
  df = st.session_state.creds
 
202
  mask = (
203
  df["name"].str.lower().str.contains(Q, na=False)
204
  | df["username"].str.lower().str.contains(Q, na=False)
 
213
  title = (row["name"] or row["username"] or row["url"]).strip()
214
  with st.expander(f"{title} β€” {row['username']} | {row['url']}"):
215
  show = st.checkbox("Show password", key=f"show_{idx}")
216
+ st.text_input("Password", value=row["password"], type=("default" if show else "password"), key=f"pw_{idx}")
 
 
 
 
 
217
  if str(row.get("note", "")).strip():
218
  st.caption("Note: " + str(row["note"]))
219
  else:
220
  st.caption("Type a keyword above to search.")
221
 
222
+ with st.expander("Security notes"):
 
223
  st.markdown(
224
  """
225
+ - This gate protects the UI with **password + TOTP** (Google/Microsoft Authenticator).
226
+ - Your credentials file is still a plain Excel you provided; this app **does not re-encrypt** it.
227
+ - On Hugging Face Spaces, data persists under `/data`. Locally it’s `./`.
228
+ - For stronger protection, store credentials in an encrypted vault (I can upgrade this to AES-GCM with a master password if you want).
229
  """
230
  )