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 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);
+ }
}
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