summaryrefslogtreecommitdiff
path: root/pse-server/src/main/java/org/psesquared/server/config
diff options
context:
space:
mode:
Diffstat (limited to 'pse-server/src/main/java/org/psesquared/server/config')
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java125
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java95
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java16
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java143
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/JwtService.java283
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java117
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java17
-rw-r--r--pse-server/src/main/java/org/psesquared/server/config/package-info.java8
8 files changed, 804 insertions, 0 deletions
diff --git a/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java
new file mode 100644
index 0000000..a67e53d
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/ApplicationConfig.java
@@ -0,0 +1,125 @@
+package org.psesquared.server.config;
+
+import lombok.RequiredArgsConstructor;
+import org.psesquared.server.authentication.api.data.access.AuthenticationDao;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.lang.NonNull;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * The application configuration class declaring several beans.
+ */
+@Configuration
+@EnableScheduling
+@EnableTransactionManagement
+@EnableAsync
+@RequiredArgsConstructor
+public class ApplicationConfig implements WebMvcConfigurer {
+
+ /**
+ * The message passed on to {@link UsernameNotFoundException}.
+ */
+ private static final String USERNAME_NOT_FOUND
+ = "No user with the given username was found.";
+
+ /**
+ * The JPA repository that handles user related database requests.
+ */
+ private final AuthenticationDao authenticationDao;
+
+ /**
+ * Returns a {@link UserDetailsService} bean for retrieving users via username
+ * from the database.
+ *
+ * @return {@link UserDetailsService}
+ */
+ @Bean
+ public UserDetailsService userDetailsService() {
+ return username -> authenticationDao.findByUsername(username)
+ .orElseThrow(() -> new UsernameNotFoundException(USERNAME_NOT_FOUND));
+ }
+
+ /**
+ * Returns an {@link AuthenticationProvider} bean for authenticating
+ * {@link org.springframework.security.core.userdetails.User}s with username
+ * and password using {@link #userDetailsService()} and
+ * {@link #passwordEncoder()}.
+ *
+ * @return {@link AuthenticationProvider}
+ */
+ @Bean
+ public AuthenticationProvider authenticationProvider() {
+ DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
+ authProvider.setUserDetailsService(userDetailsService());
+ authProvider.setPasswordEncoder(passwordEncoder());
+ return authProvider;
+ }
+
+ /**
+ * Returns a {@link BCryptPasswordEncoder} bean for password encryption.
+ *
+ * @return {@link PasswordEncoder}
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ /**
+ * Returns an {@link AuthenticationManager} bean for processing authentication
+ * requests from the given {@link AuthenticationConfiguration}.
+ *
+ * @param config The application's authentication configuration
+ * @return {@link AuthenticationManager}
+ * @throws Exception When the authentication manager couldn't be retrieved
+ * from the given configuration
+ */
+ @Bean
+ public AuthenticationManager authenticationManager(
+ final AuthenticationConfiguration config) throws Exception {
+ return config.getAuthenticationManager();
+ }
+
+ /**
+ * Returns a {@link WebMvcConfigurer} bean with CORS enabled globally.
+ *
+ * @return {@link WebMvcConfigurer}
+ */
+ @Bean
+ public WebMvcConfigurer corsConfigurer() {
+ return new WebMvcConfigurer() {
+ @Override
+ public void addCorsMappings(@NonNull final CorsRegistry registry) {
+ registry
+ .addMapping("/**")
+ .allowedOrigins("*")
+ .allowedMethods("*");
+ }
+ };
+ }
+
+ /**
+ * Registers an {@link AuthenticationValidatorInterceptor}.
+ *
+ * @param registry The {@link InterceptorRegistry}
+ */
+ @Override
+ public void addInterceptors(final InterceptorRegistry registry) {
+ registry.addInterceptor(new AuthenticationValidatorInterceptor());
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java
new file mode 100644
index 0000000..3482164
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/AuthenticationValidatorInterceptor.java
@@ -0,0 +1,95 @@
+package org.psesquared.server.config;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.Map;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.HandlerMapping;
+
+/**
+ * This interceptor class intercepts requests between the DispatcherServlet and
+ * the Controller (i.e. when already mapped to Controller method).
+ * It checks if the currently authenticated
+ * {@link org.psesquared.server.model.User} is the same user for whom the
+ * request is sent.
+ */
+public class AuthenticationValidatorInterceptor implements HandlerInterceptor {
+
+ /**
+ * The return value for aborting the execution of the Controller method.
+ */
+ private static final boolean ABORT_EXECUTION = false;
+
+ /**
+ * The return value for resuming the execution of the Controller method.
+ */
+ private static final boolean RESUME_EXECUTION = true;
+
+ /**
+ * The name of the username URL path variable.
+ */
+ private static final String PATH_VARIABLE_USERNAME = "username";
+
+ /**
+ * The default name associated with authentication.
+ */
+ private static final String USERNAME_NO_AUTH = "anonymousUser";
+
+ /**
+ * Checks if the currently authenticated
+ * {@link org.psesquared.server.model.User} is the same user specified in the
+ * URL path variable of the request.
+ *
+ * @param request The {@link HttpServletRequest}
+ * @param response The {@link HttpServletResponse}
+ * @param handler The chosen handler
+ * @return {@code true} if the users match,
+ * <br>
+ * {@code false} otherwise
+ */
+ @Override
+ public boolean preHandle(@NonNull final HttpServletRequest request,
+ @NonNull final HttpServletResponse response,
+ @NonNull final Object handler) {
+
+ final String usernamePathVariable = extractUsernamePathVariable(request);
+ final AbstractAuthenticationToken auth
+ = (AbstractAuthenticationToken) SecurityContextHolder.getContext()
+ .getAuthentication();
+ final String usernameAuthenticated;
+
+ if (usernamePathVariable == null || auth == null) {
+ return RESUME_EXECUTION;
+ }
+
+ usernameAuthenticated = auth.getName();
+ if (usernameAuthenticated == null
+ || usernameAuthenticated.equals(USERNAME_NO_AUTH)
+ || usernameAuthenticated.equals(usernamePathVariable)) {
+ return RESUME_EXECUTION;
+ }
+
+ return ABORT_EXECUTION;
+ }
+
+ /**
+ * Extracts the username path variable from the {@link HttpServletRequest}.
+ *
+ * @param request The {@link HttpServletRequest}
+ * @return The value of the username path variable
+ */
+ private String extractUsernamePathVariable(final HttpServletRequest request) {
+ // returns HttpServletRequest attribute that contains the URI templates map,
+ // mapping variable names to values
+ final Map<String, String> pathVariables = (Map<String, String>) request
+ .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
+ // this attribute is of type Map<String, String> per definition,
+ // so no type checks are needed
+ return (pathVariables != null)
+ ? pathVariables.get(PATH_VARIABLE_USERNAME) : null;
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java
new file mode 100644
index 0000000..4b2c79e
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/EmailConfigProperties.java
@@ -0,0 +1,16 @@
+package org.psesquared.server.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * The properties class that is used to return some externally stored URLs.
+ *
+ * @param dashboardBaseUrl The base URL of the PSE-Dashboard
+ * @param verificationUrl The URL for account verification
+ * @param resetUrlPath The URL for resetting the password of a user
+ */
+@ConfigurationProperties("email")
+public record EmailConfigProperties(String dashboardBaseUrl,
+ String verificationUrl,
+ String resetUrlPath) {
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..bf00ecb
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/JwtAuthenticationFilter.java
@@ -0,0 +1,143 @@
+package org.psesquared.server.config;
+
+import io.jsonwebtoken.ExpiredJwtException;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.lang.NonNull;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.WebUtils;
+
+/**
+ * This filter class handles authentication via JWT.
+ * <br>
+ * Its method
+ * {@link
+ * #doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)}
+ * is invoked before the mapping of the request to the Controller happens.
+ */
+@Component
+@RequiredArgsConstructor
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ /**
+ * The URL for the unsecured register-API-endpoint.
+ */
+ private static final String REGISTER_URL
+ = "/api/2/auth/register.json";
+
+ /**
+ * The URL for the unsecured forgotPassword-API-endpoint.
+ */
+ private static final String FORGOT_URL
+ = "/api/2/auth/{email}/forgot.json";
+
+ /**
+ * The URL for the unsecured verify-API-endpoint.
+ */
+ private static final String VERIFY_URL
+ = "/api/2/auth/{username}/verify.json";
+
+ /**
+ * The URL for the unsecured resetPassword-API-endpoint.
+ */
+ private static final String RESET_PASSWORD_URL
+ = "/api/2/auth/{username}/resetpassword.json";
+
+ /**
+ * The name of the cookie used for JWT authentication.
+ */
+ private static final String COOKIE_NAME = "sessionid";
+
+ /**
+ * The service class used for managing JWTs.
+ */
+ private final JwtService jwtService;
+
+ /**
+ * The service class used for retrieving users from the database.
+ */
+ private final UserDetailsService userDetailsService;
+
+ /**
+ * The filter method does nothing for the specified unsecured URLs
+ * and otherwise calls
+ * {@link #authenticateIfValid(Cookie, HttpServletRequest)}.
+ *
+ * @param request The {@link HttpServletRequest}
+ * @param response The {@link HttpServletResponse}
+ * @param filterChain The {@link FilterChain}
+ * @throws ServletException If error occurs when processing request
+ * @throws IOException If I/O error occurs
+ */
+ @Override
+ protected void doFilterInternal(@NonNull final HttpServletRequest request,
+ @NonNull final HttpServletResponse response,
+ @NonNull final FilterChain filterChain)
+ throws ServletException, IOException {
+
+ final Cookie cookie = WebUtils.getCookie(request, COOKIE_NAME);
+ final String url = request.getRequestURI();
+
+ if (url.equals(REGISTER_URL) || url.equals(FORGOT_URL)
+ || url.equals(VERIFY_URL) || url.equals(RESET_PASSWORD_URL)
+ || cookie == null) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ authenticateIfValid(cookie, request);
+ filterChain.doFilter(request, response);
+ }
+
+ /**
+ * Authenticates the {@link org.psesquared.server.model.User} associated with
+ * the JWT from the cookie if it is valid.
+ *
+ * @param cookie The cookie containing the JWT
+ * @param request The {@link HttpServletRequest} for creating a new
+ * authentication details instance
+ */
+ private void authenticateIfValid(final Cookie cookie,
+ final HttpServletRequest request) {
+ final String jwt = cookie.getValue();
+ final String usernameFromToken;
+
+ try {
+ usernameFromToken = jwtService.extractAuthUsername(jwt);
+ } catch (ExpiredJwtException e) {
+ return;
+ }
+
+ if (usernameFromToken != null
+ && SecurityContextHolder
+ .getContext()
+ .getAuthentication() == null) {
+ UserDetails userDetails
+ = userDetailsService.loadUserByUsername(usernameFromToken);
+ if (jwtService.isAuthTokenValid(jwt, userDetails)) {
+ UsernamePasswordAuthenticationToken authToken
+ = new UsernamePasswordAuthenticationToken(
+ userDetails,
+ null,
+ userDetails.getAuthorities()
+ );
+ authToken.setDetails(
+ new WebAuthenticationDetailsSource().buildDetails(request)
+ );
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ }
+ }
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/JwtService.java b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java
new file mode 100644
index 0000000..157055a
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/JwtService.java
@@ -0,0 +1,283 @@
+package org.psesquared.server.config;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import io.jsonwebtoken.security.SignatureException;
+import java.security.Key;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.stereotype.Service;
+
+/**
+ * The service class responsible for creating, managing and validating JWTs.
+ */
+@Service
+@RequiredArgsConstructor
+public class JwtService {
+
+ /**
+ * The boolean value for expressing that a JWT is not valid.
+ */
+ private static final boolean INVALID = false;
+
+ /**
+ * The 1h lifespan of an access token.
+ */
+ private static final long ACCESS_TOKEN_LIFESPAN_MILLIS
+ = 1000 * 60 * (long) 60;
+
+ /**
+ * The 24h lifespan of a URL token (verification/resetting password).
+ */
+ private static final long URL_TOKEN_LIFESPAN_MILLIS
+ = ACCESS_TOKEN_LIFESPAN_MILLIS * 24;
+
+ /**
+ * The properties class that is used to return externally stored signing key.
+ */
+ private final SecurityConfigProperties securityConfigProperties;
+
+ /**
+ * Extracts the username from a JWT for authentication.
+ *
+ * @param token The JWT
+ * @return The extracted username
+ */
+ public String extractAuthUsername(final String token) {
+ return extractClaim(token, getAuthSigningKey(), Claims::getSubject);
+ }
+
+ /**
+ * Extracts a generic claim from the JWT.
+ *
+ * @param <T> The type of the claim
+ * @param token The JWT
+ * @param signingKey The JWT signing key
+ * @param claimsResolver The function to resolve the claim
+ * @return The extracted generic claim
+ * @throws ExpiredJwtException If the JWT has expired
+ * @throws UnsupportedJwtException If the JWT is not supported
+ * @throws MalformedJwtException If the JWT is malformed
+ * @throws SignatureException If the signature doesn't match
+ * @throws IllegalArgumentException If the token has an inappropriate format
+ */
+ public <T> T extractClaim(final String token,
+ final Key signingKey,
+ final Function<Claims, T> claimsResolver)
+ throws ExpiredJwtException,
+ UnsupportedJwtException,
+ MalformedJwtException,
+ SignatureException,
+ IllegalArgumentException {
+
+ final Claims claims = extractAllClaims(token, signingKey);
+ return claimsResolver.apply(claims);
+ }
+
+ /**
+ * Generates the JWT with additional claims and a lifespan for the
+ * {@link org.psesquared.server.model.User} with the given details.
+ *
+ * @param additionalClaims The {@link Map} with additional claims
+ * @param userDetails The user details
+ * @param tokenLifespan The lifespan of the token
+ * @param signingKey The JWT signing key
+ * @return The generated JWT
+ */
+ public String generateTokenString(final Map<String, Object> additionalClaims,
+ final UserDetails userDetails,
+ final long tokenLifespan,
+ final Key signingKey) {
+ return Jwts.builder()
+ .setClaims(additionalClaims)
+ .setSubject(userDetails.getUsername())
+ .setIssuedAt(new Date())
+ .setExpiration(new Date(System.currentTimeMillis() + tokenLifespan))
+ .signWith(signingKey, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ /**
+ * Generates a JWT access token for the
+ * {@link org.psesquared.server.model.User} with the given details.
+ * (no additional claims).
+ *
+ * @param userDetails The user details
+ * @return The generated JWT access token
+ */
+ public String generateAccessTokenString(final UserDetails userDetails) {
+ //no additional claims supported but open for extension with roles e.g.
+ return generateTokenString(new HashMap<>(),
+ userDetails,
+ ACCESS_TOKEN_LIFESPAN_MILLIS,
+ getAuthSigningKey());
+ }
+
+ /**
+ * Generates a JWT URL token required for authentication/resetting password
+ * for the {@link org.psesquared.server.model.User} with the given details
+ * (no additional claims).
+ *
+ * @param userDetails The user details
+ * @return The generated JWT access token
+ */
+ public String generateUrlTokenString(final UserDetails userDetails) {
+ return generateTokenString(new HashMap<>(),
+ userDetails,
+ URL_TOKEN_LIFESPAN_MILLIS,
+ getUrlSigningKey());
+ }
+
+ /**
+ * Validates the given JWT for authentication against the given
+ * {@link UserDetails} and checks if it has not expired.
+ *
+ * @param token The to be validated JWT
+ * @param userDetails The user details
+ * @return {@code true} if the JWT is valid,
+ * <br>
+ * {@code false} otherwise
+ */
+ public boolean isAuthTokenValid(final String token,
+ final UserDetails userDetails) {
+ return isTokenValid(token, userDetails, getAuthSigningKey());
+ }
+
+ /**
+ * Validates the given JWT for URLs against the given {@link UserDetails}
+ * and checks if it has not expired.
+ *
+ * @param token The to be validated JWT
+ * @param userDetails The user details
+ * @return {@code true} if the JWT is valid,
+ * <br>
+ * {@code false} otherwise
+ */
+ public boolean isUrlTokenValid(final String token,
+ final UserDetails userDetails) {
+ return isTokenValid(token, userDetails, getUrlSigningKey());
+ }
+
+ /**
+ * Validates the given JWT against the given {@link UserDetails}
+ * with the given signing key and checks if it has not expired.
+ *
+ * @param token The to be validated JWT
+ * @param userDetails The user details
+ * @param signingKey The JWT signing key
+ * @return {@code true} if the JWT is valid,
+ * <br>
+ * {@code false} otherwise
+ */
+ private boolean isTokenValid(final String token,
+ final UserDetails userDetails,
+ final Key signingKey) {
+ try {
+ final String username = extractUsername(token, signingKey);
+ return username.equals(userDetails.getUsername())
+ && !isTokenExpired(token, signingKey);
+ } catch (ExpiredJwtException
+ | UnsupportedJwtException
+ | MalformedJwtException
+ | SignatureException
+ | IllegalArgumentException e) {
+ return INVALID;
+ }
+ }
+
+ /**
+ * Checks if the given JWT is expired.
+ *
+ * @param token The JWT
+ * @param signingKey The JWT signing key
+ * @return {@code true} if the JWT is expired,
+ * <br>
+ * {@code false} otherwise
+ */
+ private boolean isTokenExpired(final String token, final Key signingKey) {
+ return extractExpiration(token, signingKey).before(new Date());
+ }
+
+ /**
+ * Extracts the username from a JWT.
+ *
+ * @param token The JWT
+ * @param signingKey The JWT signing key
+ * @return The extracted username
+ */
+ private String extractUsername(final String token, final Key signingKey) {
+ return extractClaim(token, signingKey, Claims::getSubject);
+ }
+
+ /**
+ * Extracts the expiration {@link Date} of the JWT.
+ *
+ * @param token The JWT
+ * @param signingKey The JWT signing key
+ * @return The expiration date
+ */
+ private Date extractExpiration(final String token, final Key signingKey) {
+ return extractClaim(token, signingKey, Claims::getExpiration);
+ }
+
+ /**
+ * Extracts all claims from the JWT in order for
+ * {@link #extractClaim(String, Key, Function)} to be able
+ * to filter out one claim.
+ *
+ * @param token The JWT
+ * @param signingKey The JWT signing key
+ * @return All claims of the JWT
+ * @throws ExpiredJwtException If the JWT has expired
+ * @throws UnsupportedJwtException If the JWT is not supported
+ * @throws MalformedJwtException If the JWT is malformed
+ * @throws SignatureException If the signature doesn't match
+ * @throws IllegalArgumentException If the token has an inappropriate format
+ */
+ private Claims extractAllClaims(final String token, final Key signingKey)
+ throws ExpiredJwtException,
+ UnsupportedJwtException,
+ MalformedJwtException,
+ SignatureException,
+ IllegalArgumentException {
+
+ return Jwts.parserBuilder()
+ .setSigningKey(signingKey)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+
+ /**
+ * Returns the signing {@link Key} for signing the JWT.
+ *
+ * @return The signing key
+ */
+ private Key getAuthSigningKey() {
+ byte[] keyBytes
+ = Decoders.BASE64.decode(securityConfigProperties.jwtAuthSigningKey());
+ return Keys.hmacShaKeyFor(keyBytes);
+ }
+
+ /**
+ * Returns the signing {@link Key} for signing the JWT.
+ *
+ * @return The signing key
+ */
+ private Key getUrlSigningKey() {
+ byte[] keyBytes
+ = Decoders.BASE64.decode(securityConfigProperties.jwtUrlSigningKey());
+ return Keys.hmacShaKeyFor(keyBytes);
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java
new file mode 100644
index 0000000..8005160
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfig.java
@@ -0,0 +1,117 @@
+package org.psesquared.server.config;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+/**
+ * This class is responsible for configuring the {@link SecurityFilterChain}
+ * which determines the way authentication is handled with the server.
+ */
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ /**
+ * The URL of the unsecured register-API-endpoint.
+ */
+ private static final String REGISTER_URL
+ = "/api/2/auth/register.json";
+
+ /**
+ * The URL of the unsecured forgotPassword-API-endpoint.
+ */
+ private static final String FORGOT_URL
+ = "/api/2/auth/{email}/forgot.json";
+
+ /**
+ * The URL of the unsecured verify-API-endpoint.
+ */
+ private static final String VERIFY_URL
+ = "/api/2/auth/{username}/verify.json";
+
+ /**
+ * The URL of the unsecured resetPassword-API-endpoint.
+ */
+ private static final String RESET_PASSWORD_URL
+ = "/api/2/auth/{username}/resetpassword.json";
+
+ /**
+ * The authentication filter for JWT authentication.
+ */
+ private final JwtAuthenticationFilter jwtAuthFilter;
+
+ /**
+ * The authentication provider specified in {@link ApplicationConfig}.
+ */
+ private final AuthenticationProvider authenticationProvider;
+
+ /**
+ * Configures the {@link SecurityFilterChain} with {@link HttpSecurity}
+ * in the following way:
+ * <br>
+ * 1. JWT authentication ("sessionid" cookie)
+ * <br>
+ * 2. HTTP basic authentication ("Authorization" header)
+ *
+ * @param http The HTTP security class
+ * @return The security filter chain
+ * @throws Exception If an error occurs
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(final HttpSecurity http)
+ throws Exception {
+ http
+ .cors()
+ .and()
+ .csrf()
+ .disable()
+ .authorizeHttpRequests()
+ .requestMatchers(
+ REGISTER_URL,
+ FORGOT_URL,
+ VERIFY_URL,
+ RESET_PASSWORD_URL)
+ .permitAll()
+ .anyRequest()
+ .authenticated()
+ .and()
+ .authenticationProvider(authenticationProvider)
+ .addFilterBefore(jwtAuthFilter,
+ UsernamePasswordAuthenticationFilter.class)
+ .httpBasic()
+ .and()
+ .sessionManagement()
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
+ return http.build();
+ }
+
+ /**
+ * Ensures CORS is processed before Spring Security.
+ *
+ * @return The specified CORS configuration source
+ */
+ @Bean
+ CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowCredentials(true);
+ configuration.addAllowedOriginPattern("*");
+ configuration.addAllowedHeader("*");
+ configuration.addAllowedMethod("*");
+ UrlBasedCorsConfigurationSource source
+ = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java
new file mode 100644
index 0000000..74303fe
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/SecurityConfigProperties.java
@@ -0,0 +1,17 @@
+package org.psesquared.server.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * The properties class that is used to return externally stored signing key.
+ *
+ * @param jwtAuthSigningKey The base64-encoded JWT signing key for
+ * authentication
+ * @param jwtUrlSigningKey The base64-encoded JWT signing key for URLs
+ * @param emailSigningKey The base64-encoded salt for email encryption
+ */
+@ConfigurationProperties("security")
+public record SecurityConfigProperties(String jwtAuthSigningKey,
+ String jwtUrlSigningKey,
+ String emailSigningKey) {
+}
diff --git a/pse-server/src/main/java/org/psesquared/server/config/package-info.java b/pse-server/src/main/java/org/psesquared/server/config/package-info.java
new file mode 100644
index 0000000..814be40
--- /dev/null
+++ b/pse-server/src/main/java/org/psesquared/server/config/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * This package features all relevant classes for the application
+ * configuration and security.
+ *
+ * @author PSE-Squared Team
+ * @version 1.0
+ */
+package org.psesquared.server.config;