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 @@ -8,9 +8,6 @@
@SpringBootApplication
@EnableFeignClients
@EnableScheduling
// The catalogue seed runs off the main thread: stocking the shelves from a public catalogue must
// not hold up serving requests.
/** Entry point for the library backend. */
@EnableAsync
public class Application {
public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import app.infrastructure.exceptions.BorrowNotAllowedException;
import app.infrastructure.exceptions.ResourceNotFoundException;
import jakarta.persistence.EntityNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
Expand All @@ -17,6 +19,7 @@

/** Turns exceptions into the JSON error bodies the API promises. */
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

/** Matching the base type, not each subclass, is what stops a new one falling through as a 500. */
Expand All @@ -36,10 +39,26 @@ private Map<String, String> messageBody(Exception ex, String fallback) {
return Map.of("message", ex.getMessage() == null ? fallback : ex.getMessage());
}

/**
* A path that matches nothing is a 404, not a server error. The catch-all below would otherwise
* turn every mistyped URL - and every disabled endpoint, such as Swagger in production - into a
* 500, which reads as "we broke" rather than "that is not here".
*/
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<Map<String, String>> handleNoResource(NoResourceFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", "We could not find what you were looking for."));
}

/**
* The last resort. The detail goes to the log, not to the caller: an exception message can
* carry a query, a file path or a class name, and none of that is the client's business.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGenericException(Exception ex) {
return new ResponseEntity<>("An unexpected error occurred: " + ex.getMessage(),
HttpStatus.INTERNAL_SERVER_ERROR);
public ResponseEntity<Map<String, String>> handleGenericException(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message", "Something went wrong on our side."));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
import app.domain.dto.ChangePasswordRequest;
import app.domain.model.AccountCredentials;
import app.domain.services.JwtService;
import app.domain.services.TokenRevocationService;
import app.domain.services.LoginAttemptService;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.validation.BindingResult;
Expand All @@ -40,14 +44,34 @@ public class LoginController {
private final JwtService jwtService;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttempts;
private final TokenRevocationService revocationService;

@PostMapping("/login")
public ResponseEntity<Map<String, Object>> getToken(@RequestBody AccountCredentials credentials) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
credentials.getUsername(), credentials.getPassword()
)
);
String username = credentials.getUsername();

// Refuse before touching the password: guessing has to cost time, or the only thing
// protecting an account is how good its password is.
if (loginAttempts.isLockedOut(username)) {
long seconds = loginAttempts.secondsRemaining(username);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(seconds))
.body(Map.of("message", "Too many sign-in attempts. Try again in "
+ Math.max(1, seconds / 60) + " minute(s)."));
}

Authentication auth;
try {
auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, credentials.getPassword())
);
} catch (AuthenticationException e) {
loginAttempts.recordFailure(username);
throw e;
}

loginAttempts.recordSuccess(username);

String jwt = jwtService.getToken(auth.getName(), roleOf(auth));

Expand All @@ -61,6 +85,19 @@ public ResponseEntity<Map<String, Object>> getToken(@RequestBody AccountCredenti
.body(body);
}

/**
* Signs the caller out and refuses their token from now on.
*
* <p>Spring's own logout handler clears the session, which a stateless token never had. Without
* this the token in the browser stays valid until it expires, so "sign out" would only mean the
* client agreeing to forget it.
*/
@PostMapping("/revoke")
public ResponseEntity<Map<String, String>> revoke(HttpServletRequest request) {
revocationService.revoke(jwtService.getClaims(request));
return ResponseEntity.ok(Map.of("message", "Signed out."));
}

/** Lets the client check on start-up whether its stored token is still valid, and who it belongs to. */
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> currentUser(Authentication auth) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;

/** Issues and verifies the tokens the API authenticates with. */
@Component
Expand Down Expand Up @@ -62,8 +63,11 @@ public JwtService(@Value("${library.jwt.secret:}") String secret) {
*/
public String getToken(String username, String role) {
return Jwts.builder()
// An id, so a token can be named in the revocation list when its owner signs out.
.id(UUID.randomUUID().toString())
.subject(username)
.claim(ROLE_CLAIM, role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key)
.compact();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package app.domain.services;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Counts failed sign-ins and locks an account out for a while once there have been too many.
*
* <p>Without this a password can be guessed at network speed, which makes the strength of the
* password the only thing standing in the way. Attempts are counted per username rather than per
* address: an attacker can change address far more easily than they can change whose account they
* are trying to open.
*
* <p>Held in memory, so the count resets when the application does. That is a deliberate limit
* rather than an oversight - surviving a restart means a shared store, and the point here is to
* turn an instant guessing loop into a slow one.
*/
@Service
@Slf4j
public class LoginAttemptService {

private record Attempts(int count, Instant firstFailure, Instant lockedUntil) {
}

private final Map<String, Attempts> attempts = new ConcurrentHashMap<>();

@Value("${library.login.max-attempts:5}")
private int maxAttempts;

@Value("${library.login.lockout:PT15M}")
private Duration lockout;

/** How long attempts are remembered for; a slow trickle of failures should not add up forever. */
@Value("${library.login.window:PT15M}")
private Duration window;

public boolean isLockedOut(String username) {
Attempts current = attempts.get(key(username));
if (current == null || current.lockedUntil() == null) {
return false;
}
if (Instant.now().isAfter(current.lockedUntil())) {
attempts.remove(key(username));
return false;
}
return true;
}

/** Seconds left on the lockout, for telling the caller when to come back. */
public long secondsRemaining(String username) {
Attempts current = attempts.get(key(username));
if (current == null || current.lockedUntil() == null) {
return 0;
}
return Math.max(0, Duration.between(Instant.now(), current.lockedUntil()).toSeconds());
}

public void recordFailure(String username) {
String key = key(username);
Instant now = Instant.now();

attempts.compute(key, (ignored, current) -> {
if (current == null || now.isAfter(current.firstFailure().plus(window))) {
return new Attempts(1, now, null);
}

int count = current.count() + 1;
if (count >= maxAttempts) {
log.warn("Sign-in locked for '{}' after {} failed attempts.", username, count);
return new Attempts(count, current.firstFailure(), now.plus(lockout));
}
return new Attempts(count, current.firstFailure(), null);
});
}

/** A success clears the slate, so a typo before a correct password costs nothing. */
public void recordSuccess(String username) {
attempts.remove(key(username));
}

private static String key(String username) {
return username == null ? "" : username.toLowerCase(java.util.Locale.ROOT);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package app.domain.services;

import io.jsonwebtoken.Claims;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* Remembers tokens that have been signed out, so a bearer token stops working the moment its owner
* says so rather than when it happens to expire.
*
* <p>A signed JWT is valid until its expiry by design: nothing about it is looked up, which is what
* makes it cheap. The cost is that signing out can only ever be a client-side gesture unless the
* server keeps a list like this one, so a copied token would keep working for the rest of the day.
*
* <p>Entries are dropped once the token would have expired anyway, so the list stays the size of
* however many people signed out recently. It is in memory: a restart forgets it, but a restart
* also mints a new signing key unless one is configured, which invalidates everything regardless.
*/
@Service
public class TokenRevocationService {

/** Token id to the moment it expires, after which remembering it serves no purpose. */
private final Map<String, Instant> revoked = new ConcurrentHashMap<>();

public void revoke(Claims claims) {
if (claims == null) {
return;
}
String id = claims.getId();
if (id == null) {
return;
}
purgeExpired();
revoked.put(id, claims.getExpiration() == null ? Instant.now() : claims.getExpiration().toInstant());
}

public boolean isRevoked(Claims claims) {
if (claims == null || claims.getId() == null) {
return false;
}
Instant expiry = revoked.get(claims.getId());
if (expiry == null) {
return false;
}
if (Instant.now().isAfter(expiry)) {
revoked.remove(claims.getId());
return false;
}
return true;
}

private void purgeExpired() {
Instant now = Instant.now();
revoked.entrySet().removeIf(entry -> now.isAfter(entry.getValue()));
}
}
Loading
Loading