Skip to content

Commit

Permalink
Merge pull request #5 from akaene/main
Browse files Browse the repository at this point in the history
[23ava-distribution#6] Support OAuth2
  • Loading branch information
blcham authored Nov 2, 2023
2 parents 86d8e2d + 15f9a14 commit f82229d
Show file tree
Hide file tree
Showing 24 changed files with 476 additions and 151 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
target
node_modules
build
logs
**/generated-sources
**/npm-debug.log
.DS_store
Expand Down
16 changes: 16 additions & 0 deletions doc/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,19 @@ This repository is query parameter of Form service call specified in `sgovReposi

SForms service is configured in `formGenServiceUrl`, the call to the service should contain SGoV model repository as query parameter. Example call:
`formGenRepositoryUrl=`http://localhost:8080/s-pipes/service?_pId=transform&sgovRepositoryUrl=https%3A%2F%2Fgraphdb.onto.fel.cvut.cz%2Frepositories%2Fkodi-slovnik-gov-cz`

### OpenID Connect Authentication

RecordManager can work with an external authentication service implementing the OpenID Connect protocol. To use it,
set the `security.provider` (in `config.properties` or via `SECURITY_PROVIDER` via an environment variable) configuration to `oidc`
and configure the `spring.security.oauth2.resourceserver.jwt.issuer-uri` (in `application.properties` or using an environment variable)
parameter to the URI of the OAuth2 token issuer. When using Keycloak, this corresponds to the URI of the realm through
which Record Manager users authenticate their requests. For example, the value may be `http://localhost:8080/realms/record-manager`.
A client with confidential access and the corresponding valid redirect and origin URIs should be configured in the realm.

If needed, claim used to access user's roles can be configured via `oidc.roleClaim`. The default value corresponds to the
default role mapping in Keycloak. Record Manager will assign `ROLE_USER` to authenticated users by default, any other roles
must be available in the token.

Note also that it is expected that user metadata corresponding to the user extracted from the access token exist in the
repository. They are paired via the `prefferred_username` claim value (see `SecurityUtils`).
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.PropertySource;

@SpringBootApplication
@PropertySource("classpath:config.properties")
public class RecordManagerApplication {

public static void main(String[] args) {
Expand Down
86 changes: 86 additions & 0 deletions src/main/java/cz/cvut/kbss/study/config/OAuth2SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cz.cvut.kbss.study.config;

import cz.cvut.kbss.study.security.AuthenticationSuccess;
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.ConfigReader;
import cz.cvut.kbss.study.util.oidc.OidcGrantedAuthoritiesExtractor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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
public class OAuth2SecurityConfig {

private static final Logger LOG = LoggerFactory.getLogger(OAuth2SecurityConfig.class);

private final AuthenticationSuccess authenticationSuccess;

private final ConfigReader config;

@Autowired
public OAuth2SecurityConfig(AuthenticationSuccess authenticationSuccess, ConfigReader 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();
}

private CorsConfigurationSource corsConfigurationSource() {
return SecurityConfig.createCorsConfiguration(config);
}

private Converter<Jwt, AbstractAuthenticationToken> grantedAuthoritiesExtractor() {
return source -> {
final Collection<SimpleGrantedAuthority> extractedRoles =
new OidcGrantedAuthoritiesExtractor(config).convert(source);
assert extractedRoles != null;
final Set<SimpleGrantedAuthority> authorities = new HashSet<>(extractedRoles);
// Add default role if it is not present
authorities.add(new SimpleGrantedAuthority(SecurityConstants.ROLE_USER));
return new JwtAuthenticationToken(source, authorities);
};
}
}
15 changes: 13 additions & 2 deletions src/main/java/cz/cvut/kbss/study/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.ConfigReader;
import cz.cvut.kbss.study.util.ConfigParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
Expand All @@ -28,11 +31,14 @@
import java.util.Collections;
import java.util.List;

@ConditionalOnProperty(prefix = "security", name = "provider", havingValue = "internal", matchIfMissing = true)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

private static final Logger LOG = LoggerFactory.getLogger(SecurityConfig.class);

private static final String[] COOKIES_TO_DESTROY = {
SecurityConstants.SESSION_COOKIE_NAME,
SecurityConstants.REMEMBER_ME_COOKIE_NAME,
Expand All @@ -59,6 +65,7 @@ public SecurityConfig(AuthenticationFailureHandler authenticationFailureHandler,

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ConfigReader config) throws Exception {
LOG.debug("Using internal security mechanisms.");
final AuthenticationManager authManager = buildAuthenticationManager(http);
http.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.cors((auth) -> auth.configurationSource(corsConfigurationSource(config)))
Expand All @@ -83,11 +90,15 @@ private AuthenticationManager buildAuthenticationManager(HttpSecurity http) thro

@Bean
CorsConfigurationSource corsConfigurationSource(ConfigReader config) {
return createCorsConfiguration(config);
}

static CorsConfigurationSource createCorsConfiguration(ConfigReader configReader) {
// allowCredentials requires allowed origins to be configured (* is not supported)
final CorsConfiguration corsConfiguration = new CorsConfiguration().applyPermitDefaultValues();
corsConfiguration.setAllowedMethods(Collections.singletonList("*"));
if (!config.getConfig(ConfigParam.APP_CONTEXT, "").isBlank()) {
String appUrl = config.getConfig(ConfigParam.APP_CONTEXT);
if (!configReader.getConfig(ConfigParam.APP_CONTEXT, "").isBlank()) {
String appUrl = configReader.getConfig(ConfigParam.APP_CONTEXT);
appUrl = appUrl.substring(0, appUrl.lastIndexOf('/'));
corsConfiguration.setAllowedOrigins(List.of(appUrl));
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;

import java.util.Collections;
Expand All @@ -30,7 +29,6 @@
* Sets up persistence and provides {@link EntityManagerFactory} as Spring bean.
*/
@Configuration
@PropertySource("classpath:config.properties")
public class PersistenceFactory {

private static final String USERNAME_PROPERTY = "username";
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/cz/cvut/kbss/study/rest/OidcUserController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cz.cvut.kbss.study.rest;

import cz.cvut.kbss.study.model.User;
import cz.cvut.kbss.study.security.SecurityConstants;
import cz.cvut.kbss.study.service.UserService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* API for getting basic user info.
* <p>
* Enabled when OIDC security is used.
*/
@ConditionalOnProperty(prefix = "security", name = "provider", havingValue = "oidc")
@RestController
@RequestMapping("/users")
public class OidcUserController extends BaseController {

private final UserService userService;

public OidcUserController(UserService userService) {
this.userService = userService;
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_USER + "')")
@GetMapping(value = "/current", produces = MediaType.APPLICATION_JSON_VALUE)
public User getCurrent() {
return userService.getCurrentUser();
}
}
36 changes: 22 additions & 14 deletions src/main/java/cz/cvut/kbss/study/rest/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,33 @@
import cz.cvut.kbss.study.security.model.UserDetails;
import cz.cvut.kbss.study.service.InstitutionService;
import cz.cvut.kbss.study.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import cz.cvut.kbss.study.service.security.SecurityUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;
import java.util.List;
import java.util.Map;

/**
* User management API.
*
* Enabled when internal security is used.
*/
@ConditionalOnProperty(prefix = "security", name = "provider", havingValue = "internal", matchIfMissing = true)
@RestController
@RequestMapping("/users")
public class UserController extends BaseController {
Expand All @@ -51,9 +63,8 @@ public User getByUsername(@PathVariable("username") String username) {

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_USER + "')")
@GetMapping(value = "/current", produces = MediaType.APPLICATION_JSON_VALUE)
public User getCurrent(Principal principal) {
final String username = principal.getName();
return getByUsername(username);
public User getCurrent() {
return userService.getCurrentUser();
}

@PreAuthorize("hasRole('" + SecurityConstants.ROLE_ADMIN + "')")
Expand All @@ -73,8 +84,7 @@ public ResponseEntity<Void> create(@RequestBody User user) {
"or hasRole('" + SecurityConstants.ROLE_USER + "') and @securityUtils.isMemberOfInstitution(#institutionKey)")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<User> getUsers(@RequestParam(value = "institution", required = false) String institutionKey) {
final List<User> users = institutionKey != null ? getByInstitution(institutionKey) : userService.findAll();
return users;
return institutionKey != null ? getByInstitution(institutionKey) : userService.findAll();
}

private List<User> getByInstitution(String institutionKey) {
Expand Down Expand Up @@ -207,10 +217,8 @@ public void impersonate(@RequestBody String username) {
if (user.getTypes().contains(Vocabulary.s_c_administrator)) {
throw new BadRequestException("Cannot impersonate admin.");
}
final SecurityContext context = SecurityContextHolder.getContext();
UserDetails ud = new UserDetails(user);
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(ud, null, ud.getAuthorities());
context.setAuthentication(auth);
SecurityUtils.setCurrentUser(ud);
if (LOG.isTraceEnabled()) {
LOG.trace("User {} impersonated.", user.getUsername());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import cz.cvut.kbss.study.security.model.LoginStatus;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import cz.cvut.kbss.study.exception.FormManagerException;
import cz.cvut.kbss.study.security.model.LoginStatus;
import cz.cvut.kbss.study.security.model.UserDetails;
import cz.cvut.kbss.study.service.ConfigReader;
import cz.cvut.kbss.study.util.ConfigParam;
import jakarta.servlet.http.HttpServletRequest;
Expand Down Expand Up @@ -54,7 +53,7 @@ private String getUsername(Authentication authentication) {
if (authentication == null) {
return "";
}
return ((UserDetails) authentication.getPrincipal()).getUsername();
return authentication.getName();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
package cz.cvut.kbss.study.security;

import cz.cvut.kbss.study.security.model.AuthenticationToken;
import cz.cvut.kbss.study.security.model.UserDetails;
import cz.cvut.kbss.study.service.security.SecurityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -44,20 +41,11 @@ public Authentication authenticate(Authentication authentication) throws Authent
LOG.trace("Provided password for username '" + username + "' doesn't match.");
throw new BadCredentialsException("Provided password for username '" + username + "' doesn't match.");
}
userDetails.eraseCredentials(); // Don't pass credentials around in the user details object
final AuthenticationToken token = new AuthenticationToken(userDetails.getAuthorities(), userDetails);
token.setAuthenticated(true);
token.setDetails(userDetails);

final SecurityContext context = new SecurityContextImpl();
context.setAuthentication(token);
SecurityContextHolder.setContext(context);
return token;
return SecurityUtils.setCurrentUser(userDetails);
}

@Override
public boolean supports(Class<?> aClass) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass) ||
AuthenticationToken.class.isAssignableFrom(aClass);
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(aClass);
}
}
Loading

0 comments on commit f82229d

Please sign in to comment.