From 10d6be090d1832217cf5814b978fa00f82ff6d2d Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 12:55:56 +0200 Subject: [PATCH 1/2] fix: report a rejected sign-in as bad credentials, not an expired session --- frontend/src/api/client.test.ts | 35 +++++++++++++++++++++++++++++++++ frontend/src/api/client.ts | 13 +++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index d9117cb..3959a32 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -109,6 +109,41 @@ describe('authentication', () => { expect(getToken()).toBeNull() }) + /** + * A wrong password is not an expired session. Reporting it as one sends the reader looking for a + * session that never existed, and bounced them to the page they were already on. + */ + it('reports a rejected sign-in as bad credentials, not an expired session', async () => { + fetchMock.mockResolvedValue(respond(401, 'Invalid credentials', 'text/plain')) + + const error = (await api.post('/api/login', { username: 'admin', password: 'nope' }) + .catch((e: unknown) => e)) as ApiError + + expect(error.message).toBe('Invalid credentials') + expect(error.message).not.toMatch(/session/i) + expect(error.name).not.toBe('UnauthorizedError') + }) + + it('keeps whatever token exists when a sign-in is refused', async () => { + setToken('Bearer someone-elses-session') + fetchMock.mockResolvedValue(respond(401, 'Invalid credentials', 'text/plain')) + + await expect(api.post('/api/login', {})).rejects.toBeInstanceOf(ApiError) + + // Nothing was signed out: the failed attempt was not this session's. + expect(getToken()).toBe('Bearer someone-elses-session') + }) + + it('still treats a 401 elsewhere as an expired session', async () => { + setToken('Bearer stale') + fetchMock.mockResolvedValue(respond(401, '')) + + const error = (await api.get('/books/paginated').catch((e: unknown) => e)) as Error + + expect(error.name).toBe('UnauthorizedError') + expect(getToken()).toBeNull() + }) + /** 403 means the token is fine but the door is closed - signing the user out would be wrong. */ it('keeps the token on 403', async () => { setToken('Bearer good-token') diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 73d839a..70d4450 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -16,6 +16,9 @@ const BASE = import.meta.env.VITE_API_BASE_URL ?? '/backend' */ export const DEMO_MODE = import.meta.env.VITE_DEMO === 'true' +/** Where a 401 means "those credentials are wrong", not "your session ended". */ +const SIGN_IN_PATHS = ['/api/login', '/api/register'] + const TOKEN_KEY = 'library.jwt' const SESSION_KEY = 'library.session' @@ -115,8 +118,16 @@ async function request(path: string, init: RequestInit = {}): Promise { } if (response.status === 401) { + const message = await readError(response) + + // Signing in or registering is not a session expiring - there is no session yet - so say what + // actually happened and leave the caller on the page instead of bouncing it to /login. + if (SIGN_IN_PATHS.some((candidate) => path.startsWith(candidate))) { + throw new ApiError(401, message) + } + clearToken() - throw new UnauthorizedError() + throw new UnauthorizedError(message) } if (response.status === 403) { From 65ebd248b2c4558500ed4b0d200722cdba26666e Mon Sep 17 00:00:00 2001 From: "Popov, Kristian" Date: Thu, 20 Aug 2026 12:55:56 +0200 Subject: [PATCH 2/2] fix: generate an admin password when none is set, and let configuration reset it --- .../config/database/DataInitializer.java | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/database/DataInitializer.java b/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/database/DataInitializer.java index 63696aa..43d3118 100644 --- a/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/database/DataInitializer.java +++ b/Library-Management-System-Version-2/src/main/java/app/infrastructure/config/database/DataInitializer.java @@ -9,42 +9,96 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Optional; + /** * Creates the bootstrap administrator, the one account self-registration cannot produce. * - *

Credentials come from configuration; set {@code LIBRARY_ADMIN_PASSWORD} outside local runs. + *

A configured {@code library.admin.password} is authoritative and is reapplied on every + * start-up. That matters now the database is file-backed: the account outlives the process, so + * creating it only when missing would mean setting the variable later had no effect at all. + * + *

With nothing configured a password is generated and written to the log, so there is always a + * way in without a well-known one being baked into a public repository. */ @Component @Slf4j public class DataInitializer { - private static final String DEFAULT_PASSWORD = "admin"; + private static final SecureRandom RANDOM = new SecureRandom(); @Value("${library.admin.username:admin}") private String adminUsername; - @Value("${library.admin.password:" + DEFAULT_PASSWORD + "}") + @Value("${library.admin.password:}") private String adminPassword; @Bean public CommandLineRunner initDatabase(UserRepository repository, PasswordEncoder passwordEncoder) { return args -> { - if (adminPassword == null || adminPassword.isBlank()) { - log.warn("library.admin.password is blank - no administrator account was created."); - return; - } - if (repository.findByUsername(adminUsername).isPresent()) { + Optional existing = repository.findByUsername(adminUsername); + boolean configured = adminPassword != null && !adminPassword.isBlank(); + + if (existing.isPresent()) { + reconcile(existing.get(), configured, repository, passwordEncoder); return; } - repository.save(new UserEntity(adminUsername, passwordEncoder.encode(adminPassword), "ADMIN")); + String password = configured ? adminPassword : generatePassword(); + repository.save(new UserEntity(adminUsername, passwordEncoder.encode(password), "ADMIN")); - if (DEFAULT_PASSWORD.equals(adminPassword)) { - log.warn("Administrator '{}' created with the default password. " - + "Set LIBRARY_ADMIN_PASSWORD before deploying this anywhere.", adminUsername); + if (configured) { + log.info("Administrator '{}' created from configuration.", adminUsername); } else { - log.info("Administrator '{}' created.", adminUsername); + announceGenerated(password); } }; } + + /** + * Brings a stored account back in line with configuration. + * + *

Only when a password is configured: a generated one must not be reapplied, or it would + * change on every restart and lock out whoever had just been told it. + */ + private void reconcile(UserEntity admin, boolean configured, + UserRepository repository, PasswordEncoder passwordEncoder) { + if (!configured) { + log.info("Administrator '{}' already exists. Set LIBRARY_ADMIN_PASSWORD to choose its " + + "password; the stored one is left alone.", adminUsername); + return; + } + + if (passwordEncoder.matches(adminPassword, admin.getPassword())) { + return; + } + + admin.setPassword(passwordEncoder.encode(adminPassword)); + repository.save(admin); + log.info("Administrator '{}' password reset to the configured one.", adminUsername); + } + + private static String generatePassword() { + byte[] bytes = new byte[12]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private void announceGenerated(String password) { + // Deliberately loud, and the only place this is ever readable: the alternative is either a + // password everyone knows or no way to sign in at all. + log.warn(""" + + ---------------------------------------------------------------- + No library.admin.password was set, so one has been generated: + + username: {} + password: {} + + It is only shown here. Set LIBRARY_ADMIN_PASSWORD to choose one. + ---------------------------------------------------------------- + """, adminUsername, password); + } }