Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Created December 6, 2024 10:39
Show Gist options
  • Save thomasdarimont/75a55e8b4b61ef3aa841b4232af93b6c to your computer and use it in GitHub Desktop.
Save thomasdarimont/75a55e8b4b61ef3aa841b4232af93b6c to your computer and use it in GitHub Desktop.
@Component
public class CookieSecurityContextRepository implements SecurityContextRepository {
private static final String EMPTY_CREDENTIALS = "";
private static final String ANONYMOUS_USER = "anonymousUser";
private final String cookieHmacKey;
public CookieSecurityContextRepository(@Value("${auth.cookie.hmac-key}") String cookieHmacKey) {
this.cookieHmacKey = cookieHmacKey;
}
@Override
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
requestResponseHolder.setResponse(new SaveToCookieResponseWrapper(request, response));
SecurityContext context = SecurityContextHolder.createEmptyContext();
readUserInfoFromCookie(request).ifPresent(userInfo ->
context.setAuthentication(new UsernamePasswordAuthenticationToken(userInfo, EMPTY_CREDENTIALS, userInfo.getAuthorities())));
return context;
}
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
SaveToCookieResponseWrapper responseWrapper = (SaveToCookieResponseWrapper) response;
if (!responseWrapper.isContextSaved()) {
responseWrapper.saveContext(context);
}
}
@Override
public boolean containsContext(HttpServletRequest request) {
return readUserInfoFromCookie(request).isPresent();
}
private Optional<UserInfo> readUserInfoFromCookie(HttpServletRequest request) {
return readCookieFromRequest(request)
.map(this::createUserInfo);
}
private Optional<Cookie> readCookieFromRequest(HttpServletRequest request) {
if (request.getCookies() == null) {
return Optional.empty();
}
Optional<Cookie> maybeCookie = Stream.of(request.getCookies())
.filter(c -> SignedUserInfoCookie.NAME.equals(c.getName()))
.findFirst();
return maybeCookie;
}
private UserInfo createUserInfo(Cookie cookie) {
return new SignedUserInfoCookie(cookie, cookieHmacKey).getUserInfo();
}
private class SaveToCookieResponseWrapper extends SaveContextOnUpdateOrErrorResponseWrapper {
private final HttpServletRequest request;
SaveToCookieResponseWrapper(HttpServletRequest request, HttpServletResponse response) {
super(response, true);
this.request = request;
}
@Override
protected void saveContext(SecurityContext securityContext) {
HttpServletResponse response = (HttpServletResponse) getResponse();
Authentication authentication = securityContext.getAuthentication();
// some checks, see full sample code
UserInfo userInfo = (UserInfo) authentication.getPrincipal();
SignedUserInfoCookie cookie = new SignedUserInfoCookie(userInfo, cookieHmacKey);
cookie.setSecure(request.isSecure());
response.addCookie(cookie);
}
}
}
public class SignedUserInfoCookie extends Cookie {
public static final String NAME = "UserInfo";
private static final String PATH = "/";
private static final Pattern UID_PATTERN = Pattern.compile("uid=([A-Za-z0-9]*)");
private static final Pattern ROLES_PATTERN = Pattern.compile("roles=([A-Z0-9_|]*)");
private static final Pattern COLOUR_PATTERN = Pattern.compile("colour=([A-Z]*)");
private static final Pattern HMAC_PATTERN = Pattern.compile("hmac=([A-Za-z0-9+/=]*)");
private static final String HMAC_SHA_512 = "HmacSHA512";
private final Payload payload;
private final String hmac;
public SignedUserInfoCookie(UserInfo userInfo, String cookieHmacKey) {
super(NAME, "");
this.payload = new Payload(
userInfo.getUsername(),
userInfo.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(toList()),
userInfo.getColour().orElse(null));
this.hmac = calculateHmac(this.payload, cookieHmacKey);
this.setPath(PATH);
this.setMaxAge((int) Duration.of(1, ChronoUnit.HOURS).toSeconds());
this.setHttpOnly(true);
}
public SignedUserInfoCookie(Cookie cookie, String cookieHmacKey) {
super(NAME, "");
if (!NAME.equals(cookie.getName()))
throw new IllegalArgumentException("No " + NAME + " Cookie");
this.hmac = parse(cookie.getValue(), HMAC_PATTERN).orElse(null);
if (hmac == null)
throw new CookieVerificationFailedException("Cookie not signed (no HMAC)");
String username = parse(cookie.getValue(), UID_PATTERN).orElseThrow(() -> new IllegalArgumentException(NAME + " Cookie contains no UID"));
List<String> roles = parse(cookie.getValue(), ROLES_PATTERN).map(s -> List.of(s.split("\\|"))).orElse(List.of());
String colour = parse(cookie.getValue(), COLOUR_PATTERN).orElse(null);
this.payload = new Payload(username, roles, colour);
if (!hmac.equals(calculateHmac(payload, cookieHmacKey)))
throw new CookieVerificationFailedException("Cookie signature (HMAC) invalid");
this.setPath(cookie.getPath());
this.setMaxAge(cookie.getMaxAge());
this.setHttpOnly(cookie.isHttpOnly());
}
private static Optional<String> parse(String value, Pattern pattern) {
Matcher matcher = pattern.matcher(value);
if (!matcher.find())
return Optional.empty();
if (matcher.groupCount() < 1)
return Optional.empty();
String match = matcher.group(1);
if (match == null || match.trim().isEmpty())
return Optional.empty();
return Optional.of(match);
}
@Override
public String getValue() {
return payload.toString() + "&hmac=" + hmac;
}
public UserInfo getUserInfo() {
return new UserInfo(
payload.username,
payload.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()),
payload.colour);
}
private String calculateHmac(Payload payload, String secretKey) {
byte[] secretKeyBytes = Objects.requireNonNull(secretKey).getBytes(StandardCharsets.UTF_8);
byte[] valueBytes = Objects.requireNonNull(payload).toString().getBytes(StandardCharsets.UTF_8);
try {
Mac mac = Mac.getInstance(HMAC_SHA_512);
SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, HMAC_SHA_512);
mac.init(secretKeySpec);
byte[] hmacBytes = mac.doFinal(valueBytes);
return Base64.getEncoder().encodeToString(hmacBytes);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e);
}
}
private static class Payload {
private final String username;
private final List<String> roles;
private final String colour;
private Payload(String username, List<String> roles, String colour) {
this.username = username;
this.roles = roles;
this.colour = colour;
}
@Override
public String toString() {
return "uid=" + username +
"&roles=" + String.join("|", roles) +
(colour != null ? "&colour=" + colour : "");
}
}
}
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
static final String LOGIN_FORM_URL = "/login";
static final String TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM = "target";
static final String COLOUR_PARAM = "colour";
private final CookieSecurityContextRepository cookieSecurityContextRepository;
private final LoginWithTargetUrlAuthenticationEntryPoint loginWithTargetUrlAuthenticationEntryPoint;
private final RedirectToOriginalUrlAuthenticationSuccessHandler redirectToOriginalUrlAuthenticationSuccessHandler;
private final InMemoryAuthenticationProvider inMemoryAuthenticationProvider;
protected WebSecurityConfig(CookieSecurityContextRepository cookieSecurityContextRepository,
LoginWithTargetUrlAuthenticationEntryPoint loginWithTargetUrlAuthenticationEntryPoint,
RedirectToOriginalUrlAuthenticationSuccessHandler redirectToOriginalUrlAuthenticationSuccessHandler,
InMemoryAuthenticationProvider inMemoryAuthenticationProvider) {
super();
this.cookieSecurityContextRepository = cookieSecurityContextRepository;
this.loginWithTargetUrlAuthenticationEntryPoint = loginWithTargetUrlAuthenticationEntryPoint;
this.redirectToOriginalUrlAuthenticationSuccessHandler = redirectToOriginalUrlAuthenticationSuccessHandler;
this.inMemoryAuthenticationProvider = inMemoryAuthenticationProvider;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// deactivate session creation
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().csrf().disable()
// store SecurityContext in Cookie / delete Cookie on logout
.securityContext().securityContextRepository(cookieSecurityContextRepository)
.and().logout().permitAll().deleteCookies(SignedUserInfoCookie.NAME)
// deactivate RequestCache and append originally requested URL as query parameter to login form request
.and().requestCache().disable()
.exceptionHandling().authenticationEntryPoint(loginWithTargetUrlAuthenticationEntryPoint)
// configure form-based login
.and().formLogin()
.loginPage(LOGIN_FORM_URL)
// after successful login forward user to originally requested URL
.successHandler(redirectToOriginalUrlAuthenticationSuccessHandler)
.and().authorizeRequests()
.antMatchers(LOGIN_FORM_URL).permitAll()
.antMatchers("/**").authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(inMemoryAuthenticationProvider);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment