From f0779623e28175a56b3715b90522b43827f19816 Mon Sep 17 00:00:00 2001 From: Bogdan Kostov Date: Fri, 3 May 2024 18:31:33 +0200 Subject: [PATCH] Update security configuration and security related spring beans - remove old Jwt implementation --- .../kbss/analysis/config/SecurityConfig.java | 151 ++++++++++++++---- .../exception/BadRequestException.java | 19 +++ .../exception/EntityNotFoundException.java | 4 +- .../analysis/exception/FtaFmeaException.java | 25 +++ .../cz/cvut/kbss/analysis/model/User.java | 27 ++++ .../security/AuthenticationFailure.java | 30 ++++ .../security/AuthenticationSuccess.java | 120 ++++++++++++++ .../analysis/security/CsrfHeaderFilter.java | 31 ++++ .../security/CustomSwitchUserFilter.java | 23 +++ .../kbss/analysis/security/JwtConfigurer.java | 25 --- .../analysis/security/JwtTokenFilter.java | 60 ------- .../security/OAuth2SecurityConfig.java | 85 ++++++++++ .../OntologyAuthenticationProvider.java | 50 ++++++ .../analysis/security/model/LoginStatus.java | 19 +++ .../analysis/service/JwtTokenProvider.java | 72 --------- .../util/OidcGrantedAuthoritiesExtractor.java | 52 ++++++ 16 files changed, 604 insertions(+), 189 deletions(-) create mode 100644 src/main/java/cz/cvut/kbss/analysis/exception/BadRequestException.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/exception/FtaFmeaException.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/AuthenticationFailure.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/AuthenticationSuccess.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/CsrfHeaderFilter.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/CustomSwitchUserFilter.java delete mode 100755 src/main/java/cz/cvut/kbss/analysis/security/JwtConfigurer.java delete mode 100755 src/main/java/cz/cvut/kbss/analysis/security/JwtTokenFilter.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/OAuth2SecurityConfig.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/OntologyAuthenticationProvider.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/security/model/LoginStatus.java delete mode 100755 src/main/java/cz/cvut/kbss/analysis/service/JwtTokenProvider.java create mode 100644 src/main/java/cz/cvut/kbss/analysis/util/OidcGrantedAuthoritiesExtractor.java diff --git a/src/main/java/cz/cvut/kbss/analysis/config/SecurityConfig.java b/src/main/java/cz/cvut/kbss/analysis/config/SecurityConfig.java index cfd500db..b6817e8e 100755 --- a/src/main/java/cz/cvut/kbss/analysis/config/SecurityConfig.java +++ b/src/main/java/cz/cvut/kbss/analysis/config/SecurityConfig.java @@ -1,75 +1,166 @@ package cz.cvut.kbss.analysis.config; -import cz.cvut.kbss.analysis.security.JwtConfigurer; -import cz.cvut.kbss.analysis.service.JwtTokenProvider; -import cz.cvut.kbss.analysis.service.security.SecurityUtils; +import cz.cvut.kbss.analysis.config.conf.SecurityConf; +import cz.cvut.kbss.analysis.exception.FtaFmeaException; +import cz.cvut.kbss.analysis.security.CsrfHeaderFilter; +import cz.cvut.kbss.analysis.security.CustomSwitchUserFilter; +import cz.cvut.kbss.analysis.security.SecurityConstants; +import cz.cvut.kbss.analysis.util.ConfigParam; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.authentication.switchuser.SwitchUserFilter; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.*; + +@ConditionalOnProperty(prefix = "security", name = "provider", havingValue = "internal", matchIfMissing = true) @Configuration @EnableWebSecurity @Slf4j @EnableMethodSecurity public class SecurityConfig { - private final JwtTokenProvider jwtTokenProvider; - private final SecurityUtils securityUtils; + private final AuthenticationProvider ontologyAuthenticationProvider; + + private final AuthenticationSuccessHandler authenticationSuccessHandler; + + private final AuthenticationFailureHandler authenticationFailureHandler; + + private final LogoutSuccessHandler logoutSuccessHandler; + + + private static final String[] COOKIES_TO_DESTROY = { + SecurityConstants.SESSION_COOKIE_NAME, + SecurityConstants.REMEMBER_ME_COOKIE_NAME, + SecurityConstants.CSRF_COOKIE_NAME + }; + @Autowired - public SecurityConfig(JwtTokenProvider jwtTokenProvider, SecurityUtils securityUtils) { - this.jwtTokenProvider = jwtTokenProvider; - this.securityUtils = securityUtils; + public SecurityConfig(AuthenticationProvider ontologyAuthenticationProvider, AuthenticationSuccessHandler authenticationSuccessHandler, AuthenticationFailureHandler authenticationFailureHandler, LogoutSuccessHandler logoutSuccessHandler) { + this.ontologyAuthenticationProvider = ontologyAuthenticationProvider; + this.authenticationSuccessHandler = authenticationSuccessHandler; + this.authenticationFailureHandler = authenticationFailureHandler; + this.logoutSuccessHandler = logoutSuccessHandler; } @Bean public AuthenticationManager buildAuthenticationManager(HttpSecurity http) throws Exception { final AuthenticationManagerBuilder ab = http.getSharedObject(AuthenticationManagerBuilder.class); + ab.authenticationProvider(ontologyAuthenticationProvider); return ab.build(); } - protected CorsConfigurationSource corsConfigurationSource() { + @Bean + CorsConfigurationSource corsConfigurationSource(SecurityConf config) { + return createCorsConfiguration(config); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http, SecurityConf config, UserDetailsService userDetailsService) throws Exception { + log.debug("Using internal security mechanisms."); + final AuthenticationManager authManager = buildAuthenticationManager(http); + http.authorizeHttpRequests(auth -> + auth.requestMatchers("/rest/users/impersonate"). + hasAuthority(SecurityConstants.ROLE_ADMIN). + anyRequest().permitAll()) + .cors(auth -> auth.configurationSource(corsConfigurationSource(config))) + .csrf(AbstractHttpConfigurer::disable) + .addFilterAfter(new CsrfHeaderFilter(), CsrfFilter.class) + .exceptionHandling(ehc -> ehc.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .formLogin(form -> + form.loginProcessingUrl(SecurityConstants.SECURITY_CHECK_URI) + .successHandler(authenticationSuccessHandler) + .failureHandler(authenticationFailureHandler)) + .logout(auth -> + auth.logoutUrl(SecurityConstants.LOGOUT_URI) + .logoutSuccessHandler(logoutSuccessHandler) + .invalidateHttpSession(true).deleteCookies(COOKIES_TO_DESTROY)) + .sessionManagement(auth -> auth.maximumSessions(1)) + .addFilterAfter(switchUserFilter(userDetailsService), AuthorizationFilter.class) + .authenticationManager(authManager); + return http.build(); + } - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + @Bean + public SwitchUserFilter switchUserFilter(UserDetailsService userDetailsService) { + final SwitchUserFilter filter = new CustomSwitchUserFilter(); + filter.setUserDetailsService(userDetailsService); + filter.setUsernameParameter("username"); + filter.setSwitchUserUrl("/rest/users/impersonate"); + filter.setExitUserUrl("/rest/users/impersonate/logout"); + filter.setSuccessHandler(authenticationSuccessHandler); + return filter; + } - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.addExposedHeader("Location"); - corsConfiguration.applyPermitDefaultValues(); + public static CorsConfigurationSource createCorsConfiguration(SecurityConf configReader) { + final CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues(); corsConfiguration.setAllowedMethods(Arrays.asList("GET", "PUT", "POST", "PATCH", "DELETE", "OPTIONS")); + configureAllowedOrigins(corsConfiguration, configReader); + corsConfiguration.addExposedHeader(HttpHeaders.AUTHORIZATION); + corsConfiguration.addExposedHeader(HttpHeaders.LOCATION); + corsConfiguration.addExposedHeader(HttpHeaders.CONTENT_DISPOSITION); + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfiguration); return source; } + private static Optional getApplicationUrlOrigin(SecurityConf configReader) { + String appUrlConfig = configReader.getAppContext(); - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - log.debug("Using internal security mechanisms."); - http - .cors(auth -> auth.configurationSource(corsConfigurationSource())) - .httpBasic(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(AntPathRequestMatcher.antMatcher("/auth/register")).permitAll() - .requestMatchers(AntPathRequestMatcher.antMatcher("/auth/signin")).permitAll() - .anyRequest().authenticated()) - .apply(new JwtConfigurer(jwtTokenProvider, securityUtils)); - return http.build(); + if (appUrlConfig.isBlank()) { + return Optional.empty(); + } + try { + final URL appUrl = new URL(appUrlConfig); + return Optional.of(appUrl.getProtocol() + "://" + appUrl.getAuthority()); + } catch (MalformedURLException e) { + throw new FtaFmeaException("Invalid configuration parameter " + ConfigParam.APP_CONTEXT + ".", e); + } } + + private static void configureAllowedOrigins(CorsConfiguration corsConfig, SecurityConf config) { + final Optional appUrlOrigin = getApplicationUrlOrigin(config); + final List allowedOrigins = new ArrayList<>(); + appUrlOrigin.ifPresent(allowedOrigins::add); + final String allowedOriginsConfig = config.getAllowedOrigins(); + if (!allowedOrigins.isEmpty() && allowedOriginsConfig != null) { + Arrays.stream(allowedOriginsConfig.split(",")).filter(s -> !s.isBlank()).forEach(allowedOrigins::add); + corsConfig.setAllowedOrigins(allowedOrigins); + corsConfig.setAllowCredentials(true); + } else { + corsConfig.setAllowedOrigins(null); + } + log.debug( + "Using response header Access-Control-Allow-Origin with value {}.", + corsConfig.getAllowedOrigins() + ); + } + } \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/exception/BadRequestException.java b/src/main/java/cz/cvut/kbss/analysis/exception/BadRequestException.java new file mode 100644 index 00000000..f6566200 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/exception/BadRequestException.java @@ -0,0 +1,19 @@ +package cz.cvut.kbss.analysis.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Generic exception for bad requests. + */ +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } + + public BadRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/cz/cvut/kbss/analysis/exception/EntityNotFoundException.java b/src/main/java/cz/cvut/kbss/analysis/exception/EntityNotFoundException.java index 1faf8eb2..b2d432ea 100755 --- a/src/main/java/cz/cvut/kbss/analysis/exception/EntityNotFoundException.java +++ b/src/main/java/cz/cvut/kbss/analysis/exception/EntityNotFoundException.java @@ -1,6 +1,6 @@ package cz.cvut.kbss.analysis.exception; -public class EntityNotFoundException extends RuntimeException { +public class EntityNotFoundException extends FtaFmeaException { public EntityNotFoundException(String message) { super(message); @@ -10,4 +10,4 @@ public static EntityNotFoundException create(String resourceName, Object identif return new EntityNotFoundException(resourceName + " identified by " + identifier + " not found."); } -} +} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/exception/FtaFmeaException.java b/src/main/java/cz/cvut/kbss/analysis/exception/FtaFmeaException.java new file mode 100644 index 00000000..876fbcc0 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/exception/FtaFmeaException.java @@ -0,0 +1,25 @@ +package cz.cvut.kbss.analysis.exception; + + +/** + * Application-specific exception. + *

+ * All exceptions related to the application should be subclasses of this one. + */ +public class FtaFmeaException extends RuntimeException { + + protected FtaFmeaException() { + } + + public FtaFmeaException(String message) { + super(message); + } + + public FtaFmeaException(String message, Throwable cause) { + super(message, cause); + } + + public FtaFmeaException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/cz/cvut/kbss/analysis/model/User.java b/src/main/java/cz/cvut/kbss/analysis/model/User.java index 23d1dde0..737e4528 100755 --- a/src/main/java/cz/cvut/kbss/analysis/model/User.java +++ b/src/main/java/cz/cvut/kbss/analysis/model/User.java @@ -16,6 +16,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Objects; import static java.util.stream.Collectors.toList; @@ -67,4 +68,30 @@ public boolean isEnabled() { public String toString() { return "User <" + getUri() + "/>"; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User that = (User) o; + return Objects.equals(getUri(), that.getUri()); + } + + @Override + public int hashCode() { + return Objects.hash(getUri()); + } + + /** + * @return A copy of this user. + */ + public User copy() { + final User copy = new User(); + copy.setUri(getUri()); + copy.setUsername(getUsername()); + copy.setPassword(getPassword()); + copy.setRoles(getRoles()); + return copy; + } + } diff --git a/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationFailure.java b/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationFailure.java new file mode 100644 index 00000000..d800c838 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationFailure.java @@ -0,0 +1,30 @@ +package cz.cvut.kbss.analysis.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import cz.cvut.kbss.analysis.security.model.LoginStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Service; + +import java.io.IOException; + +@Service +@Slf4j +public class AuthenticationFailure implements AuthenticationFailureHandler { + private final ObjectMapper mapper; + + public AuthenticationFailure(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + AuthenticationException e) throws IOException { + log.atTrace().log("Login failed for user {}.", httpServletRequest.getParameter(SecurityConstants.USERNAME_PARAM)); + final LoginStatus status = new LoginStatus(false, false, null, e.getMessage()); + mapper.writeValue(httpServletResponse.getOutputStream(), status); + } +} diff --git a/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationSuccess.java b/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationSuccess.java new file mode 100644 index 00000000..24c97c39 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/AuthenticationSuccess.java @@ -0,0 +1,120 @@ +package cz.cvut.kbss.analysis.security; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import cz.cvut.kbss.analysis.exception.FtaFmeaException; +import cz.cvut.kbss.analysis.security.model.LoginStatus; +import cz.cvut.kbss.analysis.service.ConfigReader; +import cz.cvut.kbss.analysis.util.ConfigParam; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; + +/** + * Writes basic login/logout information into the response. + */ +@Service +@Slf4j +public class AuthenticationSuccess implements AuthenticationSuccessHandler, LogoutSuccessHandler { + + private final ObjectMapper mapper; + + private final ConfigReader config; + + public AuthenticationSuccess(ObjectMapper mapper, ConfigReader config) { + this.mapper = mapper; + this.config = config; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + Authentication authentication) throws IOException { + final String username = getUsername(authentication); + log.atTrace().log("Successfully authenticated user {}", username); + addSameSiteCookieAttribute(httpServletResponse); + final LoginStatus loginStatus = new LoginStatus(true, authentication.isAuthenticated(), username, null); + mapper.writeValue(httpServletResponse.getOutputStream(), loginStatus); + } + + private String getUsername(Authentication authentication) { + if (authentication == null) { + return ""; + } + return authentication.getName(); + } + + @Override + public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + Authentication authentication) throws IOException { + log.atTrace().log("Successfully logged out user {}", getUsername(authentication)); + final LoginStatus loginStatus = new LoginStatus(false, true, null, null); + mapper.writeValue(httpServletResponse.getOutputStream(), loginStatus); + } + + enum SameSiteValue { + STRICT("Strict"), + LAX("Lax"), + NONE("None"); + + private final String name; + + SameSiteValue(String name) { + this.name = name; + } + + public static Optional getValue(String value) { + return Arrays.stream(SameSiteValue.values()) + .filter(v -> v.name.equals(value)) + .findFirst(); + } + + @Override + public String toString() { + return name; + } + } + + private void addSameSiteCookieAttribute(HttpServletResponse response) { + String configValue = config.getConfig(ConfigParam.SECURITY_SAME_SITE, ""); + + log.debug("SameSite attribute for set-cookie header configured to {}.", configValue); + + SameSiteValue sameSiteValue = SameSiteValue.getValue(configValue) + .orElseThrow( + () -> new FtaFmeaException( + "Could not recognize " + ConfigParam.SECURITY_SAME_SITE + " parameter value '" + + configValue + "', as it is not one of the values " + + Arrays.toString(SameSiteValue.values()) + "." + ) + ); + + StringBuilder headerValues = new StringBuilder(); + if (sameSiteValue.equals(SameSiteValue.NONE)) { + headerValues.append("Secure; "); + } + headerValues.append("SameSite=").append(sameSiteValue); + + Collection headers = response.getHeaders(HttpHeaders.SET_COOKIE); + boolean firstHeader = true; + // there can be multiple Set-Cookie attributes + for (String header : headers) { + if (firstHeader) { + response.setHeader(HttpHeaders.SET_COOKIE, String.format("%s; %s", header, headerValues)); + firstHeader = false; + continue; + } + response.addHeader(HttpHeaders.SET_COOKIE, String.format("%s; %s", header, headerValues)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/security/CsrfHeaderFilter.java b/src/main/java/cz/cvut/kbss/analysis/security/CsrfHeaderFilter.java new file mode 100644 index 00000000..e8e69dfb --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/CsrfHeaderFilter.java @@ -0,0 +1,31 @@ +package cz.cvut.kbss.analysis.security; + +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.WebUtils; + +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; + +public class CsrfHeaderFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + FilterChain filterChain) throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) httpServletRequest.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + Cookie cookie = WebUtils.getCookie(httpServletRequest, SecurityConstants.CSRF_COOKIE_NAME); + String token = csrfToken.getToken(); + if (cookie == null || token != null && !token.equals(cookie.getValue())) { + cookie = new Cookie(SecurityConstants.CSRF_COOKIE_NAME, token); + cookie.setPath(SecurityConstants.COOKIE_URI); + httpServletResponse.addCookie(cookie); + } + } + filterChain.doFilter(httpServletRequest, httpServletResponse); + } +} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/security/CustomSwitchUserFilter.java b/src/main/java/cz/cvut/kbss/analysis/security/CustomSwitchUserFilter.java new file mode 100644 index 00000000..d28ac6f5 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/CustomSwitchUserFilter.java @@ -0,0 +1,23 @@ +package cz.cvut.kbss.analysis.security; + +import cz.cvut.kbss.analysis.exception.BadRequestException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.switchuser.SwitchUserFilter; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Extends default user switching logic by preventing switching to an admin account. + */ +public class CustomSwitchUserFilter extends SwitchUserFilter { + + @Override + protected Authentication attemptSwitchUser(HttpServletRequest request) throws AuthenticationException { + final Authentication switchTo = super.attemptSwitchUser(request); + if (switchTo.getAuthorities().stream().anyMatch(a -> SecurityConstants.ROLE_ADMIN.equals(a.getAuthority()))) { + throw new BadRequestException("Cannot impersonate admin."); + } + return switchTo; + } +} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/security/JwtConfigurer.java b/src/main/java/cz/cvut/kbss/analysis/security/JwtConfigurer.java deleted file mode 100755 index 59a2d7f4..00000000 --- a/src/main/java/cz/cvut/kbss/analysis/security/JwtConfigurer.java +++ /dev/null @@ -1,25 +0,0 @@ -package cz.cvut.kbss.analysis.security; - -import cz.cvut.kbss.analysis.service.JwtTokenProvider; -import cz.cvut.kbss.analysis.service.security.SecurityUtils; -import org.springframework.security.config.annotation.SecurityConfigurerAdapter; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.DefaultSecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -public class JwtConfigurer extends SecurityConfigurerAdapter { - - private final JwtTokenProvider jwtTokenProvider; - private final SecurityUtils securityUtils; - - public JwtConfigurer(JwtTokenProvider jwtTokenProvider, SecurityUtils securityUtils) { - this.jwtTokenProvider = jwtTokenProvider; - this.securityUtils = securityUtils; - } - - @Override - public void configure(HttpSecurity http) { - JwtTokenFilter customFilter = new JwtTokenFilter(jwtTokenProvider, securityUtils); - http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); - } -} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/security/JwtTokenFilter.java b/src/main/java/cz/cvut/kbss/analysis/security/JwtTokenFilter.java deleted file mode 100755 index cc4b3cf3..00000000 --- a/src/main/java/cz/cvut/kbss/analysis/security/JwtTokenFilter.java +++ /dev/null @@ -1,60 +0,0 @@ -package cz.cvut.kbss.analysis.security; - -import cz.cvut.kbss.analysis.exception.InvalidJwtAuthenticationException; -import cz.cvut.kbss.analysis.service.JwtTokenProvider; -import cz.cvut.kbss.analysis.service.security.SecurityUtils; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.header.HeaderWriterFilter; -import org.springframework.web.filter.GenericFilterBean; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; - -@Slf4j -public class JwtTokenFilter extends GenericFilterBean { - - private final JwtTokenProvider jwtTokenProvider; - private final SecurityUtils securityUtils; - - public JwtTokenFilter(JwtTokenProvider jwtTokenProvider, SecurityUtils securityUtils) { - this.jwtTokenProvider = jwtTokenProvider; - this.securityUtils = securityUtils; - } - - @Override - public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain) - throws IOException, ServletException { - - String REGISTER_PATH = "/auth/register"; - String LOGIN_PATH ="/auth/signin"; - - String path = ((HttpServletRequest) req).getRequestURI(); - String authHeader = ((HttpServletRequest) req).getHeader("Authorization"); - - if (path.endsWith(LOGIN_PATH) || (path.endsWith(REGISTER_PATH) && authHeader.startsWith("Bearer undefined"))) { - filterChain.doFilter(req, res); - return; - } - - try { - String token = jwtTokenProvider.resolveToken((HttpServletRequest) req); - if (token != null && jwtTokenProvider.validateToken(token)) { - Authentication auth = jwtTokenProvider.getAuthentication(token); - securityUtils.setCurrentUser(auth); - } - } catch (InvalidJwtAuthenticationException e) { - log.error("Unauthorized request", e); - ((HttpServletResponse) res).setStatus(HttpServletResponse.SC_UNAUTHORIZED); - return; - } - filterChain.doFilter(req, res); - } -} diff --git a/src/main/java/cz/cvut/kbss/analysis/security/OAuth2SecurityConfig.java b/src/main/java/cz/cvut/kbss/analysis/security/OAuth2SecurityConfig.java new file mode 100644 index 00000000..e8fd35c8 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/OAuth2SecurityConfig.java @@ -0,0 +1,85 @@ +package cz.cvut.kbss.analysis.security; + + +import cz.cvut.kbss.analysis.config.SecurityConfig; +import cz.cvut.kbss.analysis.config.conf.SecurityConf; +import cz.cvut.kbss.analysis.service.ConfigReader; +import cz.cvut.kbss.analysis.util.OidcGrantedAuthoritiesExtractor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +@ConditionalOnProperty(prefix = "security", name = "provider", havingValue = "oidc") +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@Slf4j +public class OAuth2SecurityConfig { + + private final AuthenticationSuccess authenticationSuccess; + + private final SecurityConf config; + + @Autowired + public OAuth2SecurityConfig(AuthenticationSuccess authenticationSuccess, SecurityConf config) { + this.authenticationSuccess = authenticationSuccess; + this.config = config; + } + + @Bean + protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { + return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl()); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + log.debug("Using OAuth2/OIDC security."); + http.oauth2ResourceServer( + auth -> auth.jwt(jwt -> jwt.jwtAuthenticationConverter(grantedAuthoritiesExtractor()))) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .exceptionHandling(ehc -> ehc.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .cors(auth -> auth.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .logout(auth -> auth.logoutUrl(SecurityConstants.LOGOUT_URI) + .logoutSuccessHandler(authenticationSuccess)); + return http.build(); + } + + CorsConfigurationSource corsConfigurationSource() { + return SecurityConfig.createCorsConfiguration(config); + } + + private Converter grantedAuthoritiesExtractor() { + return source -> { + final Collection extractedRoles = + new OidcGrantedAuthoritiesExtractor(config).convert(source); + assert extractedRoles != null; + final Set authorities = new HashSet<>(extractedRoles); + // Add default role if it is not present + authorities.add(new SimpleGrantedAuthority(SecurityConstants.ROLE_USER)); + return new JwtAuthenticationToken(source, authorities); + }; + } +} diff --git a/src/main/java/cz/cvut/kbss/analysis/security/OntologyAuthenticationProvider.java b/src/main/java/cz/cvut/kbss/analysis/security/OntologyAuthenticationProvider.java new file mode 100644 index 00000000..1513075c --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/OntologyAuthenticationProvider.java @@ -0,0 +1,50 @@ +package cz.cvut.kbss.analysis.security; + +import cz.cvut.kbss.analysis.service.security.SecurityUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +/** + * The class is used for local authentication instead of OAuth2. + */ +@Service +@Slf4j +public class OntologyAuthenticationProvider implements AuthenticationProvider { + + private final UserDetailsService userDetailsService; + + private final PasswordEncoder passwordEncoder; + + public OntologyAuthenticationProvider(UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder) { + this.userDetailsService = userDetailsService; + this.passwordEncoder = passwordEncoder; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + final String username = authentication.getPrincipal().toString(); + log.atDebug().log("Authenticating user {}", username); + + final UserDetails userDetails = userDetailsService.loadUserByUsername(username); + final String password = (String) authentication.getCredentials(); + if (!passwordEncoder.matches(password, userDetails.getPassword())) { + log.trace("Provided password for username '{}' doesn't match.", username); + throw new BadCredentialsException("Provided password for username '" + username + "' doesn't match."); + } + return SecurityUtils.setCurrentUser(userDetails); + } + + @Override + public boolean supports(Class aClass) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass); + } +} diff --git a/src/main/java/cz/cvut/kbss/analysis/security/model/LoginStatus.java b/src/main/java/cz/cvut/kbss/analysis/security/model/LoginStatus.java new file mode 100644 index 00000000..037f999f --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/security/model/LoginStatus.java @@ -0,0 +1,19 @@ +package cz.cvut.kbss.analysis.security.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class LoginStatus { + + private boolean loggedIn; + private boolean success; + private String username; + private String errorMessage; + +} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/service/JwtTokenProvider.java b/src/main/java/cz/cvut/kbss/analysis/service/JwtTokenProvider.java deleted file mode 100755 index 79581121..00000000 --- a/src/main/java/cz/cvut/kbss/analysis/service/JwtTokenProvider.java +++ /dev/null @@ -1,72 +0,0 @@ -package cz.cvut.kbss.analysis.service; - -import cz.cvut.kbss.analysis.config.conf.JwtConf; -import cz.cvut.kbss.analysis.exception.InvalidJwtAuthenticationException; -import io.jsonwebtoken.*; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.stereotype.Component; - -import jakarta.servlet.http.HttpServletRequest; -import java.util.Date; -import java.util.List; - -@Component -@RequiredArgsConstructor(onConstructor = @__(@Autowired)) -@Slf4j -public class JwtTokenProvider { - - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_HEADER = "Bearer "; - - private final JwtConf jwtConf; - private final UserDetailsService userDetailsService; - - public String createToken(String username, List roles) { - Claims claims = Jwts.claims().setSubject(username); - claims.put("roles", roles); - Date now = new Date(); - Date validity = new Date(now.getTime() + jwtConf.getExpiryMs()); - - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(validity) - .signWith(SignatureAlgorithm.HS512, jwtConf.getSecretKey()) - .compact(); - } - - public Authentication getAuthentication(String token) { - UserDetails userDetails = userDetailsService.loadUserByUsername(getUsername(token)); - return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); - } - - public String resolveToken(HttpServletRequest req) { - String bearerToken = req.getHeader(AUTHORIZATION_HEADER); - if (bearerToken != null && bearerToken.startsWith(BEARER_HEADER)) { - return bearerToken.substring(BEARER_HEADER.length()); - } - - log.warn("< resolveToken - failed to resolve token"); - return null; - } - - public boolean validateToken(String token) { - try { - Jws claims = Jwts.parser().setSigningKey(jwtConf.getSecretKey()).parseClaimsJws(token); - return !claims.getBody().getExpiration().before(new Date()); - } catch (JwtException | IllegalArgumentException e) { - throw new InvalidJwtAuthenticationException("Expired or invalid JWT token"); - } - } - - private String getUsername(String token) { - return Jwts.parser().setSigningKey(jwtConf.getSecretKey()).parseClaimsJws(token) - .getBody().getSubject(); - } -} \ No newline at end of file diff --git a/src/main/java/cz/cvut/kbss/analysis/util/OidcGrantedAuthoritiesExtractor.java b/src/main/java/cz/cvut/kbss/analysis/util/OidcGrantedAuthoritiesExtractor.java new file mode 100644 index 00000000..cba64947 --- /dev/null +++ b/src/main/java/cz/cvut/kbss/analysis/util/OidcGrantedAuthoritiesExtractor.java @@ -0,0 +1,52 @@ +package cz.cvut.kbss.analysis.util; + +import cz.cvut.kbss.analysis.config.conf.SecurityConf; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class OidcGrantedAuthoritiesExtractor implements Converter> { + + private final SecurityConf config; + + public OidcGrantedAuthoritiesExtractor(SecurityConf config) { + this.config = config; + } + + @Override + public Collection convert(Jwt source) { + return extractRoles(source).stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + public List extractRoles(ClaimAccessor source) { + final String rolesClaim = config.getRoleClaim(); + final String[] parts = rolesClaim.split("\\."); + assert parts.length > 0; + final List roles; + if (parts.length == 1) { + roles = source.getClaimAsStringList(rolesClaim); + } else { + Map map = source.getClaimAsMap(parts[0]); + for (int i = 1; i < parts.length - 1; i++) { + if (map.containsKey(parts[i]) && !(map.get(parts[i]) instanceof Map)) { + throw new IllegalArgumentException("Access token does not contain roles under the expected claim '" + rolesClaim + "'."); + } + map = (Map) map.getOrDefault(parts[i], Collections.emptyMap()); + } + if (map.containsKey(parts[parts.length - 1]) && !(map.get(parts[parts.length - 1]) instanceof List)) { + throw new IllegalArgumentException("Roles claim does not contain a list."); + } + roles = (List) map.getOrDefault(parts[parts.length - 1], Collections.emptyList()); + } + return roles; + } +}