Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Credentials come from configuration; set {@code LIBRARY_ADMIN_PASSWORD} outside local runs.
* <p>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.
*
* <p>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<UserEntity> 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.
*
* <p>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);
}
}
35 changes: 35 additions & 0 deletions frontend/src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -115,8 +118,16 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
}

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) {
Expand Down
Loading