199 lines
5.1 KiB
Markdown
199 lines
5.1 KiB
Markdown
# 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)
|