Skip to content

Commit 987d029

Browse files
committed
Enhance admin login security
1 parent cc8d481 commit 987d029

7 files changed

Lines changed: 270 additions & 54 deletions

File tree

README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Static landing page + Supabase Edge Function backend for pilot onboarding. The U
77
```
88
Visitor ─► GitHub Pages (index.html, admin.html)
99
10-
fetch https://<project>.functions.supabase.co/applications
10+
fetch https://<project>.functions.supabase.co/functions/v1/applications
1111
1212
Supabase Edge Function
1313
@@ -27,27 +27,34 @@ If the function is unreachable, the UI falls back to localStorage so leads are n
2727
```
2828
2. Configure secrets (service role key, admin password, Mailgun, etc.):
2929
```bash
30-
supabase secrets set \
31-
ADMIN_PASSWORD="set-a-strong-password" \
32-
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" \
33-
SUPABASE_SERVICE_ROLE_KEY="service-role-key" \
34-
MAIL_FROM="MB3R Lab <noreply@mb3r-lab.org>" \
35-
MAILGUN_API_KEY="key-..." \
36-
MAILGUN_DOMAIN="mg.example.com"
37-
# Optional: MAILGUN_API_BASE_URL=https://api.eu.mailgun.net/v3
30+
supabase secrets set \
31+
ADMIN_PASSWORD="set-a-strong-password" \
32+
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" \
33+
SUPABASE_SERVICE_ROLE_KEY="service-role-key" \
34+
MAIL_FROM="MB3R Lab <noreply@mb3r-lab.org>" \
35+
MAILGUN_API_KEY="key-..." \
36+
MAILGUN_DOMAIN="mg.example.com" \
37+
ALLOWED_ORIGINS="https://mb3r-lab.github.io,http://localhost:5500"
38+
# Optional: MAILGUN_API_BASE_URL=https://api.eu.mailgun.net/v3
39+
# Optional admin brute-force controls:
40+
# ADMIN_MAX_FAILED_ATTEMPTS=8
41+
# ADMIN_ATTEMPT_WINDOW_MS=600000
42+
# ADMIN_BLOCK_MS=600000
43+
# ADMIN_BASE_DELAY_MS=400
44+
# ADMIN_MAX_DELAY_MS=5000
3845
```
3946
3. Deploy the function:
4047
```bash
4148
supabase functions deploy applications --project-ref YOUR_PROJECT_REF
4249
```
43-
The function ensures the `public.applications` table exists, so no manual migration is required.
50+
The function expects an existing `public.applications` table (see SQL below).
4451

4552
### 2. Point the frontend at the function
4653

4754
Edit `assets/js/config.js` and set the function URL (no trailing slash):
4855

4956
```js
50-
window.__MB3R_API_BASE__ = 'https://YOUR_PROJECT_ID.functions.supabase.co';
57+
window.__MB3R_API_BASE__ = 'https://YOUR_PROJECT_ID.functions.supabase.co/functions/v1';
5158
```
5259

5360
Push the static site (e.g., to GitHub Pages). The landing page and `/admin.html` will now send all API calls to the Supabase function.
@@ -65,8 +72,8 @@ Push the static site (e.g., to GitHub Pages). The landing page and `/admin.html`
6572
The deployed function handles:
6673

6774
- `POST /applications` — validate payload, insert into `applications`, trigger Mailgun email, respond with the created ID.
68-
- `GET /applications` — require `x-admin-pass` header, return ordered submissions.
69-
- `OPTIONS` — CORS preflight (`*` origin, `content-type` + `x-admin-pass` headers).
75+
- `GET /applications` — require `x-admin-pass` header, validate request `Origin` against `ALLOWED_ORIGINS`, apply per-client failed-login throttling (delay + temporary block), return ordered submissions.
76+
- `OPTIONS` — CORS preflight for allowlisted origins (`content-type` + `x-admin-pass` headers).
7077
- **Schema requirement** — create the table once in Supabase (SQL editor):
7178
```sql
7279
create table if not exists public.applications (
@@ -87,6 +94,6 @@ The deployed function handles:
8794
```bash
8895
supabase functions serve applications --env-file .env.functions
8996
```
90-
3. Update `assets/js/config.js` to point at the local URL printed by the CLI (e.g., `http://127.0.0.1:54321/functions/v1`), then open `index.html` directly and test the flow.
97+
3. Update `assets/js/config.js` to point at the local URL printed by the CLI (e.g., `http://127.0.0.1:54321/functions/v1`), run a local static server (for example `python -m http.server 5500`), and test via `http://localhost:5500/index.html`.
9198

9299
Remember to switch `config.js` back to the production URL before committing.

assets/i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@
160160
}
161161
},
162162
"start": {
163-
"title": "Start in 5 minutes",
163+
"title": "Quick start",
164164
"steps": [
165165
{
166166
"title": "Install Bering and generate a first model artifact",
@@ -348,6 +348,7 @@
348348
"enterPassword": "Enter the password.",
349349
"refreshing": "Refreshing request list...",
350350
"incorrectPassword": "Incorrect password. Try again.",
351+
"tooManyAttempts": "Too many failed attempts. Try again later.",
351352
"apiUnavailable": "API unavailable. Unable to load requests right now.",
352353
"apiUnavailableLater": "API unavailable. Try again later.",
353354
"showingCached": "API unavailable. Showing cached requests.",

assets/i18n/ru.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@
160160
}
161161
},
162162
"start": {
163-
"title": "Старт за 5 минут",
163+
"title": "Быстрый старт",
164164
"steps": [
165165
{
166166
"title": "Установите Bering и получите первый модельный артефакт",
@@ -348,6 +348,7 @@
348348
"enterPassword": "Введите пароль.",
349349
"refreshing": "Обновляем список заявок...",
350350
"incorrectPassword": "Неверный пароль. Попробуйте ещё раз.",
351+
"tooManyAttempts": "Слишком много неверных попыток. Попробуйте позже.",
351352
"apiUnavailable": "API недоступно. Сейчас не можем загрузить заявки.",
352353
"apiUnavailableLater": "API недоступно. Попробуйте позже.",
353354
"showingCached": "API недоступно. Показываем кэшированные заявки.",

assets/js/admin.js

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ document.addEventListener('DOMContentLoaded', () => {
88
const tableBody = document.getElementById('applications-body');
99
const tableWrapper = document.querySelector('.table-wrapper');
1010
const refreshButton = document.getElementById('refresh-button');
11-
const ADMIN_PASS_STORAGE_KEY = 'mb3r-admin-pass';
1211
const storage = window.MB3RStorage;
1312
const getI18n = () => window.MB3RI18n;
1413
const t = (key) => (getI18n()?.t ? getI18n().t(key) : key);
@@ -36,7 +35,6 @@ document.addEventListener('DOMContentLoaded', () => {
3635
}
3736
return null;
3837
};
39-
sessionStorage.removeItem(ADMIN_PASS_STORAGE_KEY);
4038
let currentPassword = '';
4139
let hasRemoteSuccess = false;
4240

@@ -171,7 +169,8 @@ document.addEventListener('DOMContentLoaded', () => {
171169

172170
try {
173171
const response = await fetch(endpoint, {
174-
headers: { 'x-admin-pass': password }
172+
headers: { 'x-admin-pass': password },
173+
cache: 'no-store'
175174
});
176175

177176
const data = await response.json().catch(() => ({}));
@@ -215,43 +214,58 @@ document.addEventListener('DOMContentLoaded', () => {
215214
try {
216215
const rows = await fetchApplications(password);
217216
currentPassword = password;
218-
sessionStorage.setItem(ADMIN_PASS_STORAGE_KEY, password);
219217
renderTable(rows);
220218
unlockTable();
221219
closeModal();
222220
hasRemoteSuccess = true;
223221
setOverlayMessage(t('admin.table.overlayLocked'), 'admin.table.overlayLocked');
224222
return;
225223
} catch (error) {
226-
if (error.status === 401 || error.status === 403) {
227-
sessionStorage.removeItem(ADMIN_PASS_STORAGE_KEY);
224+
if (error.status === 401 || error.status === 403 || error.status === 429) {
225+
const isIncorrectPassword = /incorrect password/i.test(String(error.message || ''));
226+
const isRateLimited = error.status === 429;
228227
currentPassword = '';
229228
lockTable();
230-
setAuthStatus(error.message, 'error');
231-
setOverlayMessage(t('admin.status.incorrectPassword'), 'admin.status.incorrectPassword');
229+
setAuthStatus(
230+
isRateLimited ? t('admin.status.tooManyAttempts') : error.message,
231+
'error'
232+
);
233+
if (isRateLimited) {
234+
setOverlayMessage(t('admin.status.tooManyAttempts'), 'admin.status.tooManyAttempts');
235+
} else if (isIncorrectPassword) {
236+
setOverlayMessage(t('admin.status.incorrectPassword'), 'admin.status.incorrectPassword');
237+
} else {
238+
setOverlayMessage(error.message || t('admin.errors.loadFailed'));
239+
}
232240
openModal();
233241
throw error;
234242
}
235243

236244
if (error.offline) {
245+
const cached = storage?.list?.() || [];
246+
if (cached.length) {
247+
renderTable(cached);
248+
unlockTable();
249+
closeModal();
250+
setAuthStatus(t('admin.status.showingCached'), 'error');
251+
return;
252+
}
253+
237254
if (!hasRemoteSuccess) {
238255
setAuthStatus(
239256
t('admin.status.apiUnavailable'),
240257
'error'
241258
);
242-
sessionStorage.removeItem(ADMIN_PASS_STORAGE_KEY);
243259
currentPassword = '';
244260
lockTable();
245261
setOverlayMessage(t('admin.status.apiUnavailableLater'), 'admin.status.apiUnavailableLater');
246262
openModal();
247263
throw error;
248264
}
249265

250-
const cached = storage?.list?.() || [];
251-
renderTable(cached);
252266
unlockTable();
253267
closeModal();
254-
setAuthStatus(t('admin.status.showingCached'), 'error');
268+
setAuthStatus(t('admin.status.apiUnavailableLater'), 'error');
255269
return;
256270
}
257271

@@ -291,15 +305,49 @@ document.addEventListener('DOMContentLoaded', () => {
291305
loadApplications({ silent: true }).catch(() => {});
292306
});
293307

308+
const isModalOpen = () => modal?.classList.contains('is-open');
309+
310+
document.addEventListener('keydown', (event) => {
311+
if (!isModalOpen()) {
312+
return;
313+
}
314+
315+
if (event.key === 'Escape') {
316+
closeModal();
317+
return;
318+
}
319+
320+
if (event.key !== 'Tab' || !modal) {
321+
return;
322+
}
323+
324+
const focusable = Array.from(
325+
modal.querySelectorAll(
326+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
327+
)
328+
);
329+
330+
if (!focusable.length) {
331+
return;
332+
}
333+
334+
const firstEl = focusable[0];
335+
const lastEl = focusable[focusable.length - 1];
336+
337+
if (event.shiftKey && document.activeElement === firstEl) {
338+
event.preventDefault();
339+
lastEl.focus();
340+
} else if (!event.shiftKey && document.activeElement === lastEl) {
341+
event.preventDefault();
342+
firstEl.focus();
343+
}
344+
});
345+
294346
if (!isApiConfigured) {
295347
whenI18nReady().then(() => {
296348
setTableMessage(t('admin.status.apiNotConfiguredHint'));
297349
});
298350
}
299351

300-
if (currentPassword && isApiConfigured) {
301-
loadApplications({ silent: true }).catch(() => {});
302-
} else {
303-
openModal();
304-
}
352+
openModal();
305353
});

assets/js/config.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
window.__MB3R_API_ENDPOINT__ =
2-
window.__MB3R_API_ENDPOINT__ ||
3-
'https://nkphoglftmnfikxzuyqr.supabase.co/functions/v1/database-access';
4-
window.__MB3R_API_BASE__ = window.__MB3R_API_BASE__ || '';
1+
window.__MB3R_API_ENDPOINT__ = window.__MB3R_API_ENDPOINT__ || '';
2+
window.__MB3R_API_BASE__ =
3+
window.__MB3R_API_BASE__ ||
4+
'https://nkphoglftmnfikxzuyqr.supabase.co/functions/v1';

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ <h3 data-i18n="audience.cards.enterprise.title">Larger organizations</h3>
307307

308308
<section id="start" class="section" aria-labelledby="start-title">
309309
<div class="container narrow">
310-
<h2 id="start-title" class="section-title" data-i18n="start.title">Start in 5 minutes</h2>
310+
<h2 id="start-title" class="section-title" data-i18n="start.title">Quick start</h2>
311311
<ol class="start-list">
312312
<li>
313313
<h3 data-i18n="start.steps.0.title">Install Bering and generate a first model artifact</h3>

0 commit comments

Comments
 (0)