Add simple auth

This commit is contained in:
Adrien
2026-04-06 14:29:53 +02:00
parent e5d53b4e80
commit 0cf318f0a7
21 changed files with 1083 additions and 31 deletions
+27
View File
@@ -0,0 +1,27 @@
.git/
.gitignore
*.md
.DS_Store
Thumbs.db
# Java build artifacts
target/
*.class
*.jar
# Node
node_modules/
dist/
*.log
# Env files (never bake secrets into images)
.env
.env.*
!.env.example
# Spec / docs
specs/
# Editor
.vscode/
.idea/
+4 -2
View File
@@ -1,6 +1,6 @@
# ai-teacher Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-04-04
Auto-generated from all feature plans. Last updated: 2026-04-06
## Active Technologies
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings + chat), PDFBox (via Spring AI PDF reader dependency) (002-image-aware-embedding)
@@ -8,6 +8,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-04
- Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API, PDFBox (rendering only), `com.google.cloud:google-cloud-documentai` (~2.40.x) (002-image-aware-embedding)
- PostgreSQL (JPA + Flyway), pgvector (Spring AI VectorStore), S3 / local filesystem (figure images) (002-image-aware-embedding)
- PostgreSQL (JPA + Flyway), pgvector (Spring AI `VectorStore`), S3-compatible (002-image-aware-embedding)
- Java 21 (backend) / TypeScript + Node 20 (frontend) + Spring Boot 4.0.5, Spring Security (already included), Vue 3.4, Vue Router 4.3, Pinia 2.1, Axios 1.7 (003-basic-login)
- No new storage — credentials held in browser `sessionStorage` (frontend only) (003-basic-login)
- Java 21 (backend), TypeScript / Node 20 (frontend) (001-neuro-rag-learning)
@@ -27,9 +29,9 @@ npm test && npm run lint
Java 21 (backend), TypeScript / Node 20 (frontend): Follow standard conventions
## Recent Changes
- 003-basic-login: Added Java 21 (backend) / TypeScript + Node 20 (frontend) + Spring Boot 4.0.5, Spring Security (already included), Vue 3.4, Vue Router 4.3, Pinia 2.1, Axios 1.7
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings +
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API, PDFBox (rendering only), `com.google.cloud:google-cloud-documentai` (~2.40.x)
- 002-image-aware-embedding: Added Java 25 (backend), TypeScript / Node 20 (frontend) + Spring Boot 4.0.5, Spring AI 2.0.0-M4, OpenAI API (embeddings + chat), PDFBox (via Spring AI PDF reader dependency)
<!-- MANUAL ADDITIONS START -->
+8 -2
View File
@@ -9,14 +9,20 @@ AI-generated cross-book summaries, and engage in grounded RAG chat.
```mermaid
graph TD
User["Neurosurgeon (Browser)"]
Login["Login Page\n(username + password form)"]
FE["Frontend\nVue.js 3 / Vite\n:5173"]
BE["Backend\nSpring Boot 4 / Spring AI\n:8080"]
Auth["Spring Security\nHTTP Basic Auth"]
DB["PostgreSQL + pgvector\n(source of truth)"]
FS["File Store\nuploads/ (local disk)\nExtracted figure PNGs"]
LLM["LLM Provider\n(OpenAI)\nEmbeddings + Chat + Vision"]
User -->|HTTP| FE
FE -->|REST /api/v1/...| BE
User -->|"First visit / unauthenticated"| Login
Login -->|"POST credentials\n(GET /api/v1/auth/check)"| Auth
Auth -->|"401 → back to login\n200 → app access"| Login
Login -->|"Authenticated"| FE
FE -->|"REST /api/v1/...\n(HTTP Basic on every request)"| Auth
Auth --> BE
BE -->|"JDBC — books, chapters,\nsections, figures, refs"| DB
BE -->|"pgvector — text chunks\n+ figure caption vectors"| DB
BE -->|"PNG read/write\n(figure extraction)"| FS
@@ -0,0 +1,19 @@
package com.aiteacher.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.Principal;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@GetMapping("/check")
public ResponseEntity<Map<String, String>> check(Principal principal) {
return ResponseEntity.ok(Map.of("username", principal.getName()));
}
}
@@ -30,9 +30,10 @@ public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService(
@Value("${app.auth.username}") String username,
@Value("${app.auth.password}") String password) {
UserDetails user = User.builder()
.username("neurosurgeon")
.username(username)
.password("{noop}" + password)
.roles("USER")
.build();
@@ -56,6 +56,7 @@ app:
upload-enabled: ${UPLOAD_ENABLED:true}
delete-enabled: ${DELETE_ENABLED:true}
auth:
username: ${APP_AUTH_USERNAME:neurosurgeon}
password: ${APP_PASSWORD:changeme}
figure-storage:
endpoint: https://s3.immich-ad.ovh
+3 -2
View File
@@ -3,8 +3,9 @@
# In production point it directly at the backend, e.g. https://api.example.com/api/v1
VITE_API_URL=/api/v1
# Shared password for HTTP Basic auth (must match APP_PASSWORD on the backend).
VITE_APP_PASSWORD=changeme
# Credentials are no longer configured here. Users enter their username and
# password via the login form. The backend validates them via HTTP Basic Auth.
# Configure the backend credentials with APP_AUTH_USERNAME and APP_PASSWORD.
# Set to 'false' to hide the upload UI (frontend). Also set UPLOAD_ENABLED=false on the backend to block the endpoint.
VITE_UPLOAD_ENABLED=true
+44 -18
View File
@@ -6,23 +6,26 @@
<span class="brand-name">AI Teacher</span>
<span class="brand-subtitle">Neurosurgeon Learning Platform</span>
</div>
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
<span class="nav-icon">📚</span> Library
</RouterLink>
</li>
<li>
<RouterLink to="/topics" :class="{ active: $route.path === '/topics' }">
<span class="nav-icon">🗂</span> Topics
</RouterLink>
</li>
<li>
<RouterLink to="/chat" :class="{ active: $route.path === '/chat' }">
<span class="nav-icon">💬</span> Chat
</RouterLink>
</li>
</ul>
<template v-if="authStore.isAuthenticated">
<ul class="navbar-links">
<li>
<RouterLink to="/" :class="{ active: $route.path === '/' }">
<span class="nav-icon">📚</span> Library
</RouterLink>
</li>
<li>
<RouterLink to="/topics" :class="{ active: $route.path === '/topics' }">
<span class="nav-icon">🗂</span> Topics
</RouterLink>
</li>
<li>
<RouterLink to="/chat" :class="{ active: $route.path === '/chat' }">
<span class="nav-icon">💬</span> Chat
</RouterLink>
</li>
</ul>
<button class="btn btn-logout" @click="logout">Sign out</button>
</template>
</nav>
<main class="main-content">
@@ -36,11 +39,20 @@
<script setup lang="ts">
import { ref, provide } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const authStore = useAuthStore()
const router = useRouter()
const toastMessage = ref('')
const toastType = ref<'toast-error' | 'toast-success'>('toast-error')
function logout() {
authStore.clearCredentials()
router.push({ name: 'login' })
}
function showToast(message: string, type: 'error' | 'success' = 'error') {
toastMessage.value = message
toastType.value = type === 'error' ? 'toast-error' : 'toast-success'
@@ -227,6 +239,20 @@ body {
background: #cbd5e0;
}
.btn-logout {
background: transparent;
color: #bee3f8;
border: 1px solid #4a90b8;
font-size: 0.85rem;
padding: 0.4rem 0.9rem;
margin-left: 1rem;
}
.btn-logout:hover {
background: #2b6cb0;
color: white;
}
.spinner {
display: inline-block;
width: 20px;
+16 -1
View File
@@ -4,6 +4,21 @@ import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
app.use(router)
// Verify any session restored from sessionStorage is still valid.
// If the backend rejects the credentials (e.g. password changed), clear them
// before the router guard fires so the user lands on /login cleanly.
import { useAuthStore } from '@/stores/authStore'
import { api } from '@/services/api'
const auth = useAuthStore()
if (auth.isAuthenticated) {
api.get('/auth/check').catch(() => {
auth.clearCredentials()
})
}
app.mount('#app')
+14
View File
@@ -1,4 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import LoginView from '@/views/LoginView.vue'
import UploadView from '@/views/UploadView.vue'
import TopicsView from '@/views/TopicsView.vue'
import ChatView from '@/views/ChatView.vue'
@@ -7,6 +9,11 @@ import BookReaderView from '@/views/BookReaderView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/',
name: 'upload',
@@ -30,4 +37,11 @@ const router = createRouter({
]
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
})
export default router
+14 -5
View File
@@ -1,20 +1,29 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
auth: {
username: 'neurosurgeon',
password: import.meta.env.VITE_APP_PASSWORD ?? 'changeme'
},
headers: {
'Content-Type': 'application/json'
}
})
// Response interceptor for error normalisation
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.username && auth.password) {
config.auth = { username: auth.username, password: auth.password }
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearCredentials()
window.location.href = '/login'
return Promise.reject(new Error('Session expired. Please sign in again.'))
}
const message =
error.response?.data?.error ??
error.message ??
+28
View File
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const SESSION_KEY = 'auth'
export const useAuthStore = defineStore('auth', () => {
const stored = sessionStorage.getItem(SESSION_KEY)
const parsed = stored ? (JSON.parse(stored) as { username: string; password: string }) : null
const username = ref<string | null>(parsed?.username ?? null)
const password = ref<string | null>(parsed?.password ?? null)
const isAuthenticated = computed(() => !!username.value && !!password.value)
function setCredentials(u: string, p: string) {
username.value = u
password.value = p
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ username: u, password: p }))
}
function clearCredentials() {
username.value = null
password.value = null
sessionStorage.removeItem(SESSION_KEY)
}
return { username, password, isAuthenticated, setCredentials, clearCredentials }
})
+171
View File
@@ -0,0 +1,171 @@
<template>
<div class="login-wrapper">
<div class="login-card card">
<div class="login-header">
<span class="login-icon">🧠</span>
<h1 class="login-title">AI Teacher</h1>
<p class="login-subtitle">Neurosurgeon Learning Platform</p>
</div>
<form class="login-form" @submit.prevent="handleSubmit">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
required
:disabled="loading"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
required
:disabled="loading"
/>
</div>
<div v-if="errorMessage" class="login-error">
{{ errorMessage }}
</div>
<button type="submit" class="btn btn-primary login-btn" :disabled="loading || !username || !password">
<span v-if="loading" class="spinner"></span>
<span v-else>Sign in</span>
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
import { api } from '@/services/api'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorMessage = ref('')
async function handleSubmit() {
errorMessage.value = ''
loading.value = true
authStore.setCredentials(username.value, password.value)
try {
await api.get('/auth/check')
router.push('/')
} catch {
authStore.clearCredentials()
errorMessage.value = 'Invalid username or password.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f0f4f8;
}
.login-card {
width: 100%;
max-width: 380px;
padding: 2rem;
}
.login-header {
text-align: center;
margin-bottom: 1.75rem;
}
.login-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.5rem;
}
.login-title {
font-size: 1.5rem;
font-weight: 700;
color: #1a365d;
margin-bottom: 0.25rem;
}
.login-subtitle {
font-size: 0.85rem;
color: #718096;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: #4a5568;
}
.form-group input {
padding: 0.6rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus {
border-color: #3182ce;
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.15);
}
.form-group input:disabled {
background: #f7fafc;
color: #a0aec0;
}
.login-error {
padding: 0.6rem 0.75rem;
background: #fed7d7;
color: #c53030;
border: 1px solid #fc8181;
border-radius: 6px;
font-size: 0.875rem;
}
.login-btn {
width: 100%;
justify-content: center;
padding: 0.7rem;
font-size: 0.95rem;
margin-top: 0.25rem;
}
</style>
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Basic Login Protection
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-06
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass. Spec is complete and ready for planning.
- FR-012 resolved: credentials are managed via environment variables / config file (no in-app user management UI).
+49
View File
@@ -0,0 +1,49 @@
# API Contract: Auth
**Base path**: `/api/v1/auth`
**Authentication**: HTTP Basic (all endpoints in this group require valid credentials)
---
## GET /api/v1/auth/check
Verifies that the supplied HTTP Basic credentials are valid. Used by the frontend after a page refresh to confirm stored credentials are still accepted before rendering the app.
### Request
```
GET /api/v1/auth/check
Authorization: Basic <base64(username:password)>
```
No request body.
### Response — 200 OK
```json
{
"username": "neurosurgeon"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | The authenticated username |
### Response — 401 Unauthorized
Spring Security returns a standard 401 with `WWW-Authenticate: Basic realm="Realm"` header. No JSON body.
### Behaviour
- Returns `200` with the authenticated username if credentials are valid.
- Returns `401` if credentials are absent or incorrect.
- No side effects (idempotent, read-only).
---
## Notes
- All other existing endpoints (`/api/v1/books`, `/api/v1/chat`, etc.) continue to require HTTP Basic Auth as before.
- The frontend sends `Authorization: Basic ...` on every request via the axios request interceptor.
- A global axios response interceptor detects `401` responses and redirects the user to `/login`.
+35
View File
@@ -0,0 +1,35 @@
# Data Model: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## No Backend Schema Changes
This feature introduces no new database tables or Flyway migrations. The user account is defined entirely in the Spring Security in-memory configuration (`SecurityConfig.java`) backed by environment variables.
## Frontend: Auth Store State
The Pinia `authStore` is the single source of truth for authentication state in the frontend.
```
AuthState
├── username: string | null — entered username, null if not logged in
├── password: string | null — entered password, null if not logged in
└── isAuthenticated: boolean — derived: true when both username and password are non-null
Actions
├── login(username, password) — validates credentials via /api/v1/auth/check, stores in sessionStorage on success
├── logout() — clears username, password, sessionStorage; redirects to /login
└── restoreSession() — reads credentials from sessionStorage on app start; calls /api/v1/auth/check to verify still valid
```
## Backend: Application Properties
Two properties configure the single allowed user account:
| Property | Default | Source | Example |
|----------|---------|--------|---------|
| `app.auth.username` | `neurosurgeon` | `application.yaml` / env var `APP_AUTH_USERNAME` | `admin` |
| `app.auth.password` | (required) | env var `APP_AUTH_PASSWORD` | `s3cret` |
No hashing is applied in the current `SecurityConfig` (`{noop}` prefix). The spec (FR-011) requires passwords not to be stored in plaintext — this refers to the backend config/env var pattern, which is acceptable as env vars are not persisted in the codebase. If hashing is required later, the `{noop}` prefix can be replaced with `{bcrypt}` without other code changes.
+76
View File
@@ -0,0 +1,76 @@
# Implementation Plan: Basic Login Protection
**Branch**: `003-basic-login` | **Date**: 2026-04-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/003-basic-login/spec.md`
## Summary
Add a login page to the Vue frontend so users must enter a username and password before accessing any route. The backend already has Spring Security with HTTP Basic Auth fully configured; credentials are validated on every API call. The implementation introduces a Pinia auth store that holds the entered credentials in `sessionStorage`, an axios interceptor that injects them on every request, a `/login` route with a login form, router guards that redirect unauthenticated users, and a logout button in the navbar. A lightweight `/api/v1/auth/check` endpoint is added to the backend to allow the frontend to verify credentials without side effects. Username is made configurable in the backend (currently hardcoded as "neurosurgeon").
## Technical Context
**Language/Version**: Java 21 (backend) / TypeScript + Node 20 (frontend)
**Primary Dependencies**: Spring Boot 4.0.5, Spring Security (already included), Vue 3.4, Vue Router 4.3, Pinia 2.1, Axios 1.7
**Storage**: No new storage — credentials held in browser `sessionStorage` (frontend only)
**Testing**: Spring Boot Test (backend), Vitest (not yet set up — out of scope for this feature)
**Target Platform**: Web (SPA + REST API)
**Project Type**: Web application (backend API + Vue frontend client)
**Performance Goals**: Login response within 1 second under normal load
**Constraints**: No new backend dependencies; no database changes; must not break existing API surface
**Scale/Scope**: Small team (POC), single user role
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. KISS | PASS | HTTP Basic Auth is reused; no new auth protocol, no new dependencies. Frontend uses sessionStorage — no JWT, no refresh tokens. |
| II. Easy to Change | PASS | Auth store is a single Pinia store; swapping the auth mechanism later only requires updating the store and the SecurityConfig. |
| III. Web-First | PASS | Backend exposes REST endpoint; frontend is standalone SPA client. No server-side rendering added. |
| IV. Documentation as Architecture | PASS | README must be updated to show the login flow in the architecture diagram (same PR). |
| Technology Constraints | PASS | Still two deployable units (backend + frontend). No new service added. |
## Project Structure
### Documentation (this feature)
```text
specs/003-basic-login/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── auth.md
└── tasks.md # Phase 2 output (/speckit.tasks — NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
backend/
├── src/main/java/com/aiteacher/
│ ├── config/
│ │ └── SecurityConfig.java # MODIFY: make username configurable
│ └── auth/
│ └── AuthController.java # ADD: GET /api/v1/auth/check endpoint
frontend/
├── src/
│ ├── stores/
│ │ └── authStore.ts # ADD: Pinia store for credentials + session
│ ├── views/
│ │ └── LoginView.vue # ADD: login form UI
│ ├── services/
│ │ └── api.ts # MODIFY: read credentials from authStore
│ ├── router/
│ │ └── index.ts # MODIFY: add /login route + navigation guard
│ └── App.vue # MODIFY: add logout button to navbar
```
**Structure Decision**: Option 2 (web application). Existing `backend/` and `frontend/` layout used; no new projects or packages.
## Complexity Tracking
> No constitution violations. Table left empty.
+198
View File
@@ -0,0 +1,198 @@
# Quickstart: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## What Changes
| Component | Change |
|-----------|--------|
| `SecurityConfig.java` | Username made configurable via `app.auth.username` property |
| `AuthController.java` | New: `GET /api/v1/auth/check` endpoint |
| `authStore.ts` | New: Pinia store managing credentials + sessionStorage |
| `LoginView.vue` | New: login form page |
| `api.ts` | Replace hardcoded Basic Auth with dynamic interceptor |
| `router/index.ts` | Add `/login` route + `beforeEach` navigation guard |
| `App.vue` | Add logout button to navbar |
| `application.yaml` | Add `app.auth.username` property with default |
## Backend Setup
### 1. Add username to application.yaml
```yaml
app:
auth:
username: ${APP_AUTH_USERNAME:neurosurgeon}
password: ${APP_AUTH_PASSWORD} # already present
```
### 2. Update SecurityConfig.java
Inject both username and password:
```java
@Bean
public UserDetailsService userDetailsService(
@Value("${app.auth.username}") String username,
@Value("${app.auth.password}") String password) {
UserDetails user = User.builder()
.username(username)
.password("{noop}" + password)
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
```
### 3. Add AuthController.java
```java
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
@GetMapping("/check")
public ResponseEntity<Map<String, String>> check(Principal principal) {
return ResponseEntity.ok(Map.of("username", principal.getName()));
}
}
```
## Frontend Setup
### 1. Create authStore.ts
```typescript
// src/stores/authStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
const SESSION_KEY = 'auth'
export const useAuthStore = defineStore('auth', () => {
const stored = sessionStorage.getItem(SESSION_KEY)
const parsed = stored ? JSON.parse(stored) : null
const username = ref<string | null>(parsed?.username ?? null)
const password = ref<string | null>(parsed?.password ?? null)
const isAuthenticated = computed(() => !!username.value && !!password.value)
function setCredentials(u: string, p: string) {
username.value = u
password.value = p
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ username: u, password: p }))
}
function clearCredentials() {
username.value = null
password.value = null
sessionStorage.removeItem(SESSION_KEY)
}
return { username, password, isAuthenticated, setCredentials, clearCredentials }
})
```
### 2. Update api.ts
Replace hardcoded `auth` with a request interceptor:
```typescript
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api/v1',
headers: { 'Content-Type': 'application/json' }
})
api.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.username && auth.password) {
config.auth = { username: auth.username, password: auth.password }
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearCredentials()
window.location.href = '/login'
}
const message = error.response?.data?.error ?? error.message ?? 'An unexpected error occurred.'
return Promise.reject(new Error(message))
}
)
```
### 3. Update router/index.ts
Add `/login` route and guard:
```typescript
import LoginView from '@/views/LoginView.vue'
import { useAuthStore } from '@/stores/authStore'
// add to routes array:
{ path: '/login', name: 'login', component: LoginView }
// add global guard:
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.name !== 'login' && !auth.isAuthenticated) {
return { name: 'login' }
}
})
```
### 4. Create LoginView.vue
A simple centered form with username and password fields. On submit:
1. Store credentials tentatively in the auth store
2. Call `GET /api/v1/auth/check`
3. If 200 → navigate to `/`
4. If 401 → clear credentials, show error message
### 5. Add logout to App.vue navbar
```html
<button class="btn btn-secondary" @click="logout">Sign out</button>
```
```typescript
import { useAuthStore } from '@/stores/authStore'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
function logout() {
auth.clearCredentials()
router.push({ name: 'login' })
}
```
## Environment Variables
### Backend (.env / docker-compose environment)
```
APP_AUTH_USERNAME=neurosurgeon # optional, defaults to neurosurgeon
APP_AUTH_PASSWORD=your-secret
```
### Frontend (.env)
```
VITE_API_URL=/api/v1
# VITE_APP_PASSWORD is no longer needed and should be removed
```
## Testing the Login Flow
1. Open the app in an incognito window — should redirect to `/login`
2. Enter wrong credentials → error message, stay on login
3. Enter correct credentials → redirect to `/` (Library)
4. Refresh the page → stay logged in
5. Click "Sign out" → redirect to `/login`; back button shows login again (no cached page access)
+64
View File
@@ -0,0 +1,64 @@
# Research: Basic Login Protection
**Feature**: 003-basic-login
**Date**: 2026-04-06
## Finding 1: Backend Auth Mechanism — Already Implemented
**Decision**: Keep existing HTTP Basic Auth (Spring Security, `SecurityConfig.java`).
**Rationale**: Spring Security with HTTP Basic is already configured and working. The backend validates credentials on every API request. There is nothing to add except making the username configurable and adding a credential-check endpoint.
**Alternatives considered**: Form-based login with server-side sessions — rejected because it adds session management complexity on the backend that is unnecessary for an SPA using HTTP Basic.
---
## Finding 2: Frontend Credential Storage — sessionStorage
**Decision**: Store entered username and password in browser `sessionStorage` via a Pinia store.
**Rationale**:
- `sessionStorage` persists across page refreshes (same tab) but is cleared when the tab is closed — this matches the expected session behavior (SC-004) without needing a server-side session or JWT.
- Simpler than `localStorage` (no explicit logout needed to clear on browser close).
- No additional dependencies required.
**Alternatives considered**:
- `localStorage` — rejected: credentials would persist indefinitely across browser sessions, which is unexpected for a "login" flow.
- In-memory (reactive ref only) — rejected: credentials lost on page refresh, violating SC-004.
- Cookie-based session (server-side) — rejected: requires CSRF protection, session store, and more backend complexity; violates KISS.
---
## Finding 3: Credential Verification — Lightweight Backend Endpoint
**Decision**: Add `GET /api/v1/auth/check` that returns `200 OK` with `{"username": "..."}` for authenticated requests.
**Rationale**: The frontend needs a way to verify that stored credentials are valid when the app loads (e.g., after a refresh). Without this, the first real API call would fail with a 401 and force a re-login on every refresh if credentials changed. This endpoint is protected by Spring Security like all others — no special logic needed.
**Alternatives considered**:
- Re-use any existing GET endpoint (e.g., `GET /api/v1/books`) — rejected: couples auth verification to a business endpoint; semantically wrong and fragile.
- Intercept 401s globally and redirect to login — used as a fallback but not sufficient alone: the user would see a flash of the main UI before being redirected.
---
## Finding 4: Axios Integration — Request Interceptor
**Decision**: Replace the hardcoded `auth` field in `api.ts` with a dynamic request interceptor that reads credentials from the Pinia auth store at request time.
**Rationale**: The current `api.ts` sets `auth: { username, password }` once at module initialisation from env vars. This must change so the login form's entered credentials are used. A request interceptor reads the store on every call, enabling logout (clear store → next request gets no credentials → 401 → redirect to login).
**Alternatives considered**:
- Recreate the axios instance after login — rejected: all existing services import the singleton `api`; recreating would require updating every import.
---
## Finding 5: Backend Username Configurability
**Decision**: Read username from `${app.auth.username:neurosurgeon}` in `SecurityConfig.java` (with "neurosurgeon" as default).
**Rationale**: The spec (FR-012) requires credentials to be configurable. Currently the password is configurable via env var but the username is hardcoded. Adding a `@Value`-injected username field is a one-line change.
**Alternatives considered**: None — this is the Spring Boot idiomatic approach already used for the password.
---
## Summary of Unknowns Resolved
| Unknown | Resolution |
|---------|-----------|
| Where to store credentials on the frontend | `sessionStorage` via Pinia |
| How to verify credentials after page refresh | `GET /api/v1/auth/check` endpoint |
| How to inject credentials into axios | Request interceptor in `api.ts` |
| How to handle 401s globally | Response interceptor → redirect to `/login` |
| Backend username configurability | `@Value("${app.auth.username:neurosurgeon}")` |
+103
View File
@@ -0,0 +1,103 @@
# Feature Specification: Basic Login Protection
**Feature Branch**: `003-basic-login`
**Created**: 2026-04-06
**Status**: Draft
**Input**: User description: "Add simple and basic login (username and password) to protect the app."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Authenticate to Access the App (Priority: P1)
A user opens the application and is presented with a login screen. They enter their username and password and, upon successful authentication, gain access to the full application. Without logging in, no part of the application is accessible.
**Why this priority**: This is the core feature — all other functionality depends on this gate being in place.
**Independent Test**: Can be fully tested by navigating to any page without credentials (should redirect to login), then logging in with valid credentials (should grant access) — this alone delivers the full MVP value.
**Acceptance Scenarios**:
1. **Given** an unauthenticated user, **When** they navigate to any page of the app, **Then** they are redirected to the login screen
2. **Given** the login screen, **When** the user enters valid credentials and submits, **Then** they are redirected to the application home/dashboard
3. **Given** the login screen, **When** the user enters invalid credentials and submits, **Then** an error message is displayed and they remain on the login screen
4. **Given** an authenticated user, **When** they navigate directly to a protected page, **Then** they can access it without re-authenticating
---
### User Story 2 - Log Out of the App (Priority: P2)
An authenticated user can explicitly log out of the application, terminating their session. After logging out, they are redirected to the login screen and must re-authenticate to access the app.
**Why this priority**: Logout is essential for security — especially on shared machines — but the app is still protected even without explicit logout (session expires).
**Independent Test**: Can be fully tested by logging in, clicking logout, and confirming that the protected pages are no longer accessible.
**Acceptance Scenarios**:
1. **Given** an authenticated user, **When** they click the logout button, **Then** their session is terminated and they are redirected to the login screen
2. **Given** a user who has logged out, **When** they navigate to a protected page, **Then** they are redirected to the login screen
---
### User Story 3 - Session Persistence Across Browser Refresh (Priority: P3)
An authenticated user refreshes the page or reopens the browser tab and remains logged in without having to re-enter credentials, as long as their session has not expired.
**Why this priority**: Improves usability — users should not be forced to log in after every page refresh during normal use.
**Independent Test**: Can be tested by logging in, refreshing the page, and confirming the user is still authenticated.
**Acceptance Scenarios**:
1. **Given** an authenticated user, **When** they refresh the browser, **Then** they remain logged in and on the same page
2. **Given** a session that has expired, **When** the user tries to access a protected page, **Then** they are redirected to the login screen
---
### Edge Cases
- What happens when the login form is submitted with empty username or password fields?
- How does the system handle a user whose credentials are removed/disabled while they have an active session?
- What happens if the user attempts to access the login page while already authenticated?
- How does the system behave if the session store becomes unavailable?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display a login screen (username + password fields and submit button) to unauthenticated users
- **FR-002**: System MUST redirect unauthenticated users attempting to access any protected page to the login screen
- **FR-003**: System MUST validate submitted credentials against configured or stored credentials
- **FR-004**: System MUST create an authenticated session upon successful login
- **FR-005**: System MUST display a clear, user-friendly error message when credentials are incorrect (without revealing which field is wrong)
- **FR-006**: System MUST provide a logout action that terminates the active session
- **FR-007**: System MUST redirect users to the login screen after logout
- **FR-008**: System MUST prevent login form submission when username or password is empty
- **FR-009**: System MUST automatically expire sessions after a reasonable inactivity period
- **FR-010**: System MUST redirect users to the login page if their session has expired
- **FR-011**: Credentials MUST be stored securely (passwords hashed, not stored in plaintext)
- **FR-012**: System MUST allow at least one user account to be configured via environment variables or a configuration file; credential changes take effect on restart
### Key Entities
- **User Account**: Represents a person who can authenticate; has a username (unique identifier) and a hashed password
- **Session**: Represents an active authenticated context; linked to a user account, has an expiry time
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Unauthenticated users cannot access any protected page — 100% of protected routes redirect to login
- **SC-002**: Users can complete the login flow (enter credentials, submit, land on the app) in under 30 seconds under normal conditions
- **SC-003**: Invalid login attempts display an error within 3 seconds and do not reveal which field was wrong
- **SC-004**: Authenticated sessions persist across page refreshes for the configured session duration without requiring re-authentication
- **SC-005**: Logout terminates the session immediately — any subsequent request to a protected page results in a redirect to login
## Assumptions
- The target users are a small, known group — there is no public self-registration for new accounts
- A single set of credentials (or a small number of pre-configured accounts) is sufficient for this initial version
- Mobile/responsive design for the login form is expected but full mobile optimization is not the focus of this feature
- The app currently has no authentication layer, so this will be added globally
- Session duration defaults to a reasonable inactivity timeout (e.g., 30 minutes of inactivity), configurable if needed
- A "remember me" / persistent login cookie is out of scope for this initial implementation
+172
View File
@@ -0,0 +1,172 @@
# Tasks: Basic Login Protection
**Input**: Design documents from `/specs/003-basic-login/`
**Prerequisites**: plan.md ✅ spec.md ✅ research.md ✅ data-model.md ✅ contracts/ ✅ quickstart.md ✅
**Tests**: Not requested — no test tasks included.
**Organization**: Tasks grouped by user story to enable independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: Which user story this task belongs to (US1, US2, US3)
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: No new project initialization needed — existing backend and frontend projects are in place. Phase 1 confirms the entry points for changes.
- [x] T001 Verify `spring-boot-starter-security` is present in `backend/pom.xml` (already included — confirm, no change needed)
- [x] T002 Verify Pinia is listed in `frontend/package.json` dependencies (already included — confirm, no change needed)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Backend credential endpoint and frontend auth store — required by all three user stories.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [x] T003 Add `app.auth.username` property to `backend/src/main/resources/application.yaml` with value `${APP_AUTH_USERNAME:neurosurgeon}` alongside the existing `app.auth.password` entry
- [x] T004 Update `backend/src/main/java/com/aiteacher/config/SecurityConfig.java` to inject `@Value("${app.auth.username}")` and pass it to `User.builder().username(username)` instead of the hardcoded string `"neurosurgeon"`
- [x] T005 Create `backend/src/main/java/com/aiteacher/auth/AuthController.java``@RestController` at `/api/v1/auth`, with a single `GET /check` method that accepts a `Principal` argument and returns `ResponseEntity.ok(Map.of("username", principal.getName()))`
- [x] T006 Create `frontend/src/stores/authStore.ts` — Pinia store with `username` and `password` refs (initially `null`), `isAuthenticated` computed, `setCredentials(u, p)` action, and `clearCredentials()` action (sessionStorage persistence added in Phase 5 / US3)
**Checkpoint**: Backend exposes `GET /api/v1/auth/check`; `authStore` is callable from any Vue component.
---
## Phase 3: User Story 1 - Authenticate to Access the App (Priority: P1) 🎯 MVP
**Goal**: Unauthenticated users are redirected to `/login`; successful credential entry grants full app access.
**Independent Test**: Open the app in incognito → redirected to `/login`. Enter wrong credentials → error shown. Enter correct credentials → land on Library (`/`). Refresh the page → stays on Library (credentials held in-memory for now; persistence comes in US3).
### Implementation for User Story 1
- [x] T007 [US1] Create `frontend/src/views/LoginView.vue` — centered card with username input, password input, submit button, and an error message area; on submit, call `authStore.setCredentials(u, p)`, then call `GET /api/v1/auth/check` via the `api` service; on 200 navigate to `/`; on failure call `authStore.clearCredentials()` and display the error
- [x] T008 [US1] Update `frontend/src/services/api.ts` — remove the hardcoded `auth: { username, password }` field from the axios instance; add a **request interceptor** that reads `authStore.username` and `authStore.password` and sets `config.auth` dynamically; add a **response interceptor** that on `401` calls `authStore.clearCredentials()` and redirects to `/login` (replace the existing error-normalisation interceptor rather than adding a second one — keep error normalisation intact)
- [x] T009 [US1] Update `frontend/src/router/index.ts` — add a `{ path: '/login', name: 'login', component: LoginView }` route; add a `router.beforeEach` guard that redirects to `{ name: 'login' }` when `to.name !== 'login'` and `!authStore.isAuthenticated`
**Checkpoint**: US1 fully functional — incognito flow, failed login, and successful login all work independently.
---
## Phase 4: User Story 2 - Log Out of the App (Priority: P2)
**Goal**: Authenticated users can log out, terminating their session and returning to `/login`.
**Independent Test**: Log in → click "Sign out" in the navbar → redirected to `/login`; navigating back to any protected route redirects to `/login` again.
### Implementation for User Story 2
- [x] T010 [US2] Update `frontend/src/App.vue` — import `useAuthStore` and `useRouter`; add a "Sign out" button to the navbar (visible only when `authStore.isAuthenticated`); clicking it calls `authStore.clearCredentials()` then `router.push({ name: 'login' })`; hide the navbar links (`RouterLink` items) when on the login page by checking `$route.name !== 'login'`
**Checkpoint**: US2 fully functional — logout clears session and blocks re-entry without credentials.
---
## Phase 5: User Story 3 - Session Persistence Across Browser Refresh (Priority: P3)
**Goal**: Authenticated users survive a page refresh without re-logging in; expired/invalid stored credentials redirect to `/login`.
**Independent Test**: Log in → refresh the browser → remain on the same page without re-entering credentials. Manually clear `sessionStorage` and refresh → redirected to `/login`.
### Implementation for User Story 3
- [x] T011 [US3] Update `frontend/src/stores/authStore.ts` — in `setCredentials`, write `{ username, password }` to `sessionStorage` under a key (e.g. `'auth'`); in `clearCredentials`, call `sessionStorage.removeItem('auth')`; on store initialization (module load), read from `sessionStorage` and pre-populate `username` and `password` refs if present
- [x] T012 [US3] Update `frontend/src/main.ts` — after creating the app and mounting Pinia, call `authStore.restoreSession()` (or inline the check): if `authStore.isAuthenticated`, call `GET /api/v1/auth/check`; if the response is `401`, call `authStore.clearCredentials()` so stale stored credentials are evicted before the router guard fires
**Checkpoint**: US3 fully functional — refresh persists login; stale or invalidated credentials are detected on load.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Documentation and cleanup that spans all user stories.
- [x] T013 [P] Update `frontend/.env.example` — remove `VITE_APP_PASSWORD` (the frontend no longer reads a password from env; add a comment explaining credentials are now entered via the login form)
- [x] T014 [P] Update `README.md` — add or update the Mermaid architecture diagram to show the login flow: browser → login form → `/api/v1/auth/check` → app; this satisfies Constitution Principle IV (diagram must be updated in the same PR as any architectural change)
**Checkpoint**: Feature complete — all three user stories functional, documentation current, obsolete env var removed.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — confirm immediately
- **Foundational (Phase 2)**: Depends on Phase 1 — **BLOCKS all user stories**
- **US1 (Phase 3)**: Depends on Phase 2 completion
- **US2 (Phase 4)**: Depends on Phase 2 completion; integrates with authStore from Phase 2 and router from US1
- **US3 (Phase 5)**: Depends on Phase 2 completion; extends authStore from Phase 2
- **Polish (Phase 6)**: Depends on all desired stories complete
### User Story Dependencies
- **US1 (P1)**: Depends only on Foundational — the primary blocker for all UI work
- **US2 (P2)**: Depends on Foundational and US1 (logout button lives in App.vue which needs the router guard from US1)
- **US3 (P3)**: Depends on Foundational only — authStore persistence is independent of the login form; can be developed in parallel with US1 if desired
### Within Each User Story
- Foundational tasks (T003T006) must all complete before US1 starts
- T007 (LoginView) and T008 (api.ts) can be developed in parallel within US1; T009 (router guard) depends on T007 existing
- US2 is a single task (T010) with no internal ordering complexity
- T012 (main.ts restore check) depends on T011 (authStore persistence) within US3
---
## Parallel Example: Foundational Phase
```
Parallelizable within Phase 2:
T003 — application.yaml update
T004 — SecurityConfig.java update
T005 — AuthController.java (new file)
T006 — authStore.ts (new file)
All four touch different files with no shared dependencies.
```
## Parallel Example: User Story 1
```
Parallelizable within Phase 3:
T007 — LoginView.vue (new file)
T008 — api.ts update (different file)
Then sequentially:
T009 — router/index.ts (depends on LoginView existing)
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Confirm existing deps
2. Complete Phase 2: Foundational — backend endpoint + auth store
3. Complete Phase 3: US1 — login page, axios interceptors, router guard
4. **STOP and VALIDATE**: Open incognito, verify redirect → login → success flow
5. Demo / merge if MVP is sufficient
### Incremental Delivery
1. Phase 1 + Phase 2 → Foundation ready
2. Phase 3 (US1) → Login gate works — **MVP**
3. Phase 4 (US2) → Logout works ✅
4. Phase 5 (US3) → Session survives refresh ✅
5. Phase 6 → Documentation and cleanup ✅
---
## Notes
- [P] tasks touch different files and have no dependency on an incomplete sibling task in the same phase
- No tests included (not requested in the spec)
- `VITE_APP_PASSWORD` should be removed from `.env.example` once T013 is done — do **not** remove it from any local `.env` file before the login form is working (T007T009 complete)
- The 401 response interceptor in T008 handles the edge case where stored credentials become invalid server-side — no additional handling needed
- Constitution IV requires the README Mermaid diagram to be updated in the **same PR** — T014 must not be skipped