b913a2bf90
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
135 lines
4.2 KiB
Markdown
135 lines
4.2 KiB
Markdown
### Task 7: Pagina di login pubblica `/login`
|
|
|
|
**Files:**
|
|
- Create: `src/pages/login.astro`
|
|
- Create: `src/lib/safe-next.ts`
|
|
- Test: `tests/safe-next.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `login`, `SESSION_COOKIE` (auth.ts), `rateLimit` (rate-limit.ts).
|
|
- Produces: `safeNext(next: string | null): string` — ritorna un path interno sicuro o `'/'`.
|
|
|
|
- [ ] **Step 1: Test di `safeNext`**
|
|
|
|
Crea `tests/safe-next.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { safeNext } from '../src/lib/safe-next';
|
|
|
|
describe('safeNext', () => {
|
|
it('accetta path interni', () => {
|
|
expect(safeNext('/about')).toBe('/about');
|
|
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
|
|
});
|
|
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
|
|
expect(safeNext(null)).toBe('/');
|
|
expect(safeNext('')).toBe('/');
|
|
expect(safeNext('//evil.com')).toBe('/');
|
|
expect(safeNext('https://evil.com')).toBe('/');
|
|
expect(safeNext('/api/admin/posts')).toBe('/');
|
|
expect(safeNext('javascript:alert(1)')).toBe('/');
|
|
expect(safeNext('not-a-path')).toBe('/');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Esegui e verifica il fallimento**
|
|
|
|
Run: `npm test -- tests/safe-next.test.ts`
|
|
Expected: FAIL — modulo inesistente.
|
|
|
|
- [ ] **Step 3: Implementa `safeNext`**
|
|
|
|
Crea `src/lib/safe-next.ts`:
|
|
|
|
```typescript
|
|
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
|
|
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
|
|
export function safeNext(next: string | null): string {
|
|
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
|
if (next.startsWith('/api/')) return '/';
|
|
return next;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Esegui e verifica il successo**
|
|
|
|
Run: `npm test -- tests/safe-next.test.ts`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Crea la pagina `/login`**
|
|
|
|
Crea `src/pages/login.astro`:
|
|
|
|
```astro
|
|
---
|
|
import Base from '../layouts/Base.astro';
|
|
import { getDb } from '../lib/db';
|
|
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
|
import { rateLimit } from '../lib/rate-limit';
|
|
import { safeNext } from '../lib/safe-next';
|
|
export const prerender = false;
|
|
|
|
const dest = safeNext(Astro.url.searchParams.get('next'));
|
|
|
|
// Già loggato → via dal login.
|
|
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
|
|
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
|
|
|
|
let error = '';
|
|
if (Astro.request.method === 'POST') {
|
|
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
|
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
|
} else {
|
|
const form = await Astro.request.formData();
|
|
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
|
if (token) {
|
|
Astro.cookies.set(SESSION_COOKIE, token, {
|
|
httpOnly: true, sameSite: 'lax', path: '/',
|
|
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
|
});
|
|
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
|
|
}
|
|
error = 'Credenziali non valide.';
|
|
}
|
|
}
|
|
---
|
|
<Base title="Accedi" description="Area riservata InsanityLab">
|
|
<section class="section">
|
|
<div class="container login-wrap">
|
|
<h1 class="section-heading">Accedi</h1>
|
|
{error && <p class="login-err">{error}</p>}
|
|
<form method="post" class="login-form">
|
|
<input type="hidden" name="next" value={dest} />
|
|
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
|
|
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
|
<button class="btn btn--dark" type="submit">Entra</button>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
</Base>
|
|
|
|
<style>
|
|
.login-wrap { max-width: 380px; margin: 0 auto; }
|
|
.login-form { display: grid; gap: 14px; margin-top: 20px; }
|
|
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
|
|
.login-err { color: #a33; }
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 6: Verifica build**
|
|
|
|
Run: `npm run build`
|
|
Expected: build OK.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/pages/login.astro src/lib/safe-next.ts tests/safe-next.test.ts
|
|
git commit -m "feat(login): pagina pubblica /login con next sicuro"
|
|
```
|
|
|
|
---
|
|
|