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

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +99 -104
src/streamlit_app.py CHANGED
@@ -1,4 +1,5 @@
1
- # simple_lookup_secure.py
 
2
  import json
3
  from pathlib import Path
4
  from io import BytesIO
@@ -12,14 +13,14 @@ 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"],
@@ -31,6 +32,7 @@ ALIASES = {
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():
@@ -45,6 +47,7 @@ def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
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)
@@ -70,60 +73,91 @@ def make_qr_png(data: str) -> bytes:
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)
@@ -139,72 +173,43 @@ else:
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:
155
- st.session_state.creds = standardize_columns(pd.read_excel(PERSIST_FILE))
156
- st.info(f"Loaded saved credentials from: {PERSIST_FILE}")
157
- except Exception as e:
158
- st.error(f"Found {PERSIST_FILE} but failed to read it: {e}")
159
- st.session_state.creds = None
160
- else:
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)
174
- st.success(f"Saved to {PERSIST_FILE}. You won't need to upload again.")
175
  except Exception as e:
176
- st.warning(f"Loaded for this session, but could not save for persistence: {e}")
177
- except Exception as e:
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:
184
- try:
185
- df_new = standardize_columns(read_any(new_up))
186
- st.session_state.creds = df_new
187
- try:
188
- PERSIST_FILE.parent.mkdir(parents=True, exist_ok=True)
189
- df_new.to_excel(PERSIST_FILE, index=False)
190
- st.success(f"Replaced saved file at {PERSIST_FILE}.")
191
- except Exception as e:
192
- st.warning(f"Replaced in memory only, could not save: {e}")
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)
205
- | df["url"].str.lower().str.contains(Q, na=False)
206
  )
207
- results = df[mask]
208
  if results.empty:
209
  st.warning("No matches found.")
210
  else:
@@ -218,13 +223,3 @@ if q.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
- )
 
1
+ # simple_lookup_2fa.py
2
+ from __future__ import annotations
3
  import json
4
  from pathlib import Path
5
  from io import BytesIO
 
13
  from argon2.exceptions import VerifyMismatchError
14
 
15
  st.set_page_config(page_title="Simple Password Lookup (2FA)", page_icon="πŸ”", layout="centered")
16
+ st.title("πŸ” Simple Password Lookup β€” 2FA protected")
17
 
18
+ # ---------------- Persistence ----------------
19
  PERSIST_DIR = Path("/data") if Path("/data").exists() else Path(".")
20
+ PERSIST_FILE = PERSIST_DIR / "creds.xlsx" # saved credentials file (uploaded once)
21
+ CONFIG_FILE = PERSIST_DIR / "auth.json" # stores Argon2 password hash + TOTP secret + label
22
 
23
+ # ---------------- Globals / helpers ----------------
24
  ph = PasswordHasher()
25
  ALIASES = {
26
  "name": ["name", "title", "site", "account", "platform", "service", "app"],
 
32
  EXPECTED = ["name", "username", "url", "password", "note"]
33
 
34
  def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
35
+ """Map common header aliases -> EXPECTED, create missing columns, clean strings."""
36
  df.columns = [str(c).strip().lower() for c in df.columns]
37
  colmap = {}
38
  for target, alias_list in ALIASES.items():
 
47
  out = df[[colmap[c] for c in EXPECTED]].rename(columns={colmap[c]: c for c in colmap})
48
  for c in EXPECTED:
49
  out[c] = out[c].astype(str).fillna("").replace("nan", "")
50
+ # keep rows with at least one meaningful field
51
  key_cols = ["name", "username", "url", "password"]
52
  mask = out[key_cols].applymap(lambda x: str(x).strip() != "").any(axis=1)
53
  return out[mask].reset_index(drop=True)
 
73
  img.save(buf, format="PNG")
74
  return buf.getvalue()
75
 
76
+ # ---------------- Session keys ----------------
77
  if "authed" not in st.session_state:
78
  st.session_state.authed = False
79
  if "failures" not in st.session_state:
80
  st.session_state.failures = 0
81
+ # setup state (used only during first-time 2FA setup)
82
+ if "setup_secret" not in st.session_state:
83
+ st.session_state.setup_secret = None
84
+ if "setup_label" not in st.session_state:
85
+ st.session_state.setup_label = None
86
+ if "setup_pw" not in st.session_state:
87
+ st.session_state.setup_pw = None
88
+
89
+ # ---------------- Step 1: Upload (only if not saved yet) ----------------
90
+ if not PERSIST_FILE.exists():
91
+ st.subheader("Step 1 β€” Upload your Excel/CSV (only once)")
92
+ up = st.file_uploader("Choose file", type=["xlsx", "xls", "csv"], accept_multiple_files=False)
93
+ if not up:
94
+ st.stop()
95
+ try:
96
+ df = standardize_columns(read_any(up))
97
+ PERSIST_FILE.parent.mkdir(parents=True, exist_ok=True)
98
+ df.to_excel(PERSIST_FILE, index=False)
99
+ st.success(f"Saved credentials to {PERSIST_FILE}.")
100
+ st.info("Continue below to set up 2FA.")
101
+ except Exception as e:
102
+ st.error(f"Failed to read/save file: {e}")
103
+ st.stop()
104
 
105
+ # ---------------- Step 2: 2FA setup (first time) ----------------
106
  cfg = load_config()
 
107
  if not cfg:
108
+ st.subheader("Step 2 β€” Set up 2FA (one-time)")
109
+ st.write("Create an admin password and pair a TOTP app (Google/Microsoft Authenticator).")
110
+
111
+ # Collect label + password; generate secret ONCE and keep it in session
112
+ with st.form("setup_form"):
113
+ label = st.text_input("Account label (as shown in your authenticator)", value=st.session_state.setup_label or "SimpleLookup")
114
+ pw1 = st.text_input("New admin password", type="password", value=st.session_state.setup_pw or "")
115
+ pw2 = st.text_input("Confirm password", type="password", value=st.session_state.setup_pw or "")
116
+ gen = st.form_submit_button("Generate QR code")
117
+ if gen:
118
+ if not pw1 or pw1 != pw2:
119
  st.error("Passwords are empty or do not match.")
120
  else:
121
+ # generate secret only if not already generated
122
+ if not st.session_state.setup_secret:
123
+ st.session_state.setup_secret = pyotp.random_base32()
124
+ st.session_state.setup_label = label
125
+ st.session_state.setup_pw = pw1
126
+
127
+ # If secret staged, show QR and verify input
128
+ if st.session_state.setup_secret:
129
+ secret = st.session_state.setup_secret
130
+ label = st.session_state.setup_label or "SimpleLookup"
131
+ totp = pyotp.TOTP(secret)
132
+ uri = totp.provisioning_uri(name=label, issuer_name="Simple Password Lookup")
133
+ st.image(make_qr_png(uri), caption="Scan in Google/Microsoft Authenticator")
134
+
135
+ code = st.text_input("Enter a current 6-digit code to verify", max_chars=6)
136
+ if st.button("Verify & Save"):
137
+ if totp.verify(code, valid_window=1):
138
+ # hash password and save config
139
+ hash_pw = ph.hash(st.session_state.setup_pw)
140
+ save_config({"password_hash": hash_pw, "totp_secret": secret, "label": label})
141
+ # clear setup state
142
+ st.session_state.setup_secret = None
143
+ st.session_state.setup_label = None
144
+ st.session_state.setup_pw = None
145
+ st.success("2FA setup complete. Please log in below.")
146
+ else:
147
+ st.error("Invalid code. Open your authenticator and try a fresh code.")
148
  st.stop()
149
+
150
+ # ---------------- Step 3: Login (password + 2FA) ----------------
151
+ if not st.session_state.authed:
152
  st.subheader("Login")
153
+ with st.form("login_form"):
154
  pw = st.text_input("Admin password", type="password")
155
  code = st.text_input("Authenticator code (6 digits)", max_chars=6)
156
  ok = st.form_submit_button("Sign in")
 
157
  if ok:
158
  try:
159
  ph.verify(cfg["password_hash"], pw or "")
160
+ # upgrade hash when needed (argon2 best practice)
161
  if ph.check_needs_rehash(cfg["password_hash"]):
162
  cfg["password_hash"] = ph.hash(pw or "")
163
  save_config(cfg)
 
173
  except Exception as e:
174
  st.session_state.failures += 1
175
  st.error(f"Login error: {e}")
 
176
  if not st.session_state.authed:
177
  if st.session_state.failures >= 5:
178
+ st.warning("Too many failed attempts. Wait ~30 seconds and try again.")
179
  st.stop()
180
 
181
+ # ---------------- Authenticated area ----------------
182
  st.success("Authenticated βœ…")
183
 
184
+ # Load credentials
185
+ try:
186
+ creds = standardize_columns(pd.read_excel(PERSIST_FILE))
187
+ except Exception as e:
188
+ st.error(f"Could not read credentials file at {PERSIST_FILE}: {e}")
189
+ st.stop()
 
 
 
 
 
190
 
191
+ # Optional: allow replacing saved file after auth
192
+ with st.expander("Replace saved credentials (optional)"):
193
+ new_up = st.file_uploader("Upload new Excel/CSV to replace saved file", type=["xlsx", "xls", "csv"], key="replacer")
194
+ if new_up is not None:
 
 
 
 
195
  try:
196
+ df_new = standardize_columns(read_any(new_up))
197
+ df_new.to_excel(PERSIST_FILE, index=False)
198
+ st.success(f"Replaced saved file at {PERSIST_FILE}. Reload to use the new data.")
199
  except Exception as e:
200
+ st.error(f"Failed to save new file: {e}")
201
+
202
+ # Search UI (same as before)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  st.subheader("Find your password")
204
  q = st.text_input("Search by platform/site/username")
205
  if q.strip():
206
  Q = q.lower().strip()
 
207
  mask = (
208
+ creds["name"].str.lower().str.contains(Q, na=False)
209
+ | creds["username"].str.lower().str.contains(Q, na=False)
210
+ | creds["url"].str.lower().str.contains(Q, na=False)
211
  )
212
+ results = creds[mask]
213
  if results.empty:
214
  st.warning("No matches found.")
215
  else:
 
223
  st.caption("Note: " + str(row["note"]))
224
  else:
225
  st.caption("Type a keyword above to search.")