diff --git a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java index c123b0f..5bc7574 100644 --- a/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java +++ b/src/main/java/se/su/dsv/oauth2/AuthorizationServer.java @@ -9,12 +9,17 @@ import org.springframework.context.annotation.Profile; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; +import org.springframework.security.config.ObjectPostProcessor; 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.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; @@ -27,10 +32,12 @@ import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; import se.su.dsv.oauth2.shibboleth.Entitlement; import se.su.dsv.oauth2.shibboleth.ShibbolethConfigurer; import se.su.dsv.oauth2.shibboleth.ShibbolethTokenPopulator; +import se.su.dsv.oauth2.staging.SkipConsentForDevelopers; import se.su.dsv.oauth2.staging.StagingSecurityConfigurer; +import se.su.dsv.oauth2.web.client.DeveloperAccessCheck; +import java.util.List; import java.util.Map; -import java.util.Optional; @SpringBootApplication(proxyBeanMethods = false) @EnableWebSecurity @@ -70,7 +77,9 @@ public class AuthorizationServer extends SpringBootServletInitializer { /// the session and allow the flow to start. @Bean @Order(1) - public SecurityFilterChain oauth2Endpoints(HttpSecurity http, Optional customizer) + public SecurityFilterChain oauth2Endpoints( + HttpSecurity http, + List customizer) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = @@ -79,6 +88,9 @@ public class AuthorizationServer extends SpringBootServletInitializer { http .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, authorizationServer -> authorizationServer + .authorizationEndpoint(authorizeEndpoint -> authorizeEndpoint + .consentPage("/oauth2/consent")) + .withObjectPostProcessor(enableGivingConsentWithNoScopes()) .oidc(Customizer.withDefaults())) .with(new ShibbolethConfigurer(), Customizer.withDefaults()) .sessionManagement(session -> session @@ -92,7 +104,7 @@ public class AuthorizationServer extends SpringBootServletInitializer { new MediaTypeRequestMatcher(MediaType.TEXT_HTML) )); - customizer.ifPresent(c -> { + customizer.forEach(c -> { try { c.customize(http); } catch (Exception e) { @@ -103,6 +115,46 @@ public class AuthorizationServer extends SpringBootServletInitializer { return http.build(); } + /// The [OAuth2AuthorizationConsentAuthenticationProvider], which is the component + /// that handles incoming consent, requires there to be at least one scope that is + /// approved by the user. This is a problem since we want to allow the user to give + /// consent without any scopes to enable, for example, just using this service as a + /// way to authenticate the end user (getting access to the "sub" claim). + /// + /// To get around this limitation, a dummy scope is added that is always approved. + private ObjectPostProcessor enableGivingConsentWithNoScopes() { + return new ObjectPostProcessor<>() { + @Override + public O postProcess(final O object) { + object.setAuthorizationConsentCustomizer(consentContext -> + consentContext.getAuthorizationConsent() + .authority(new SimpleGrantedAuthority("CONSENT"))); + return object; + } + }; + } + + /// Disable consent in staging. With the custom authorization in place, the current user is always the developer + /// while the authorization request token may contain a custom principal. When Spring Authorization Server attempts + /// to validate the submitted consent, it checks that the current user is the same as the authorization request + /// token - which it is not. The easiest solution is to disable consent in staging for developers. + @Bean + @Profile("staging") + public HttpSecurityCustomizer disableConsentInStaging( + DeveloperAccessCheck developerAccessCheck) + { + ObjectPostProcessor postprocessor = new ObjectPostProcessor<>() { + @Override + public O postProcess(final O object) { + object.setAuthorizationConsentRequired(new SkipConsentForDevelopers(developerAccessCheck)); + return object; + } + }; + return http -> http + .getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .withObjectPostProcessor(postprocessor); + } + public interface HttpSecurityCustomizer { void customize(HttpSecurity http) throws Exception; } @@ -123,14 +175,11 @@ public class AuthorizationServer extends SpringBootServletInitializer { */ @Bean @Order(2) - public SecurityFilterChain defaultSecurityFilterChain( - HttpSecurity http, - Config config) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize - .requestMatchers("/admin/**").hasAuthority(Entitlement.asAuthority(config.adminEntitlement())) .anyRequest().authenticated()); http.exceptionHandling(exceptions -> exceptions @@ -162,4 +211,21 @@ public class AuthorizationServer extends SpringBootServletInitializer { return delegatingPasswordEncoder; } + + @Bean + public DeveloperAccessCheck developerAccessCheck(Config config) { + final String developerAuthority = Entitlement.asAuthority(config.developerEntitlement()); + return authentication -> { + if (!authentication.isAuthenticated()) { + return false; + } + for (GrantedAuthority authority : authentication.getAuthorities()) { + if (developerAuthority.equals(authority.getAuthority())) { + return true; + } + } + return false; + }; + } + } diff --git a/src/main/java/se/su/dsv/oauth2/Config.java b/src/main/java/se/su/dsv/oauth2/Config.java index a503af2..6920554 100644 --- a/src/main/java/se/su/dsv/oauth2/Config.java +++ b/src/main/java/se/su/dsv/oauth2/Config.java @@ -3,7 +3,7 @@ package se.su.dsv.oauth2; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties("se.su.dsv.oauth2") -public record Config(String adminEntitlement, String developerEntitlement, RSAKeyPair rsaKeyPair) { +public record Config(String developerEntitlement, RSAKeyPair rsaKeyPair) { record RSAKeyPair(String kid, String modulus, String privateExponent, String publicExponent) { } } diff --git a/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java index 95b8dfe..17c99cd 100644 --- a/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java +++ b/src/main/java/se/su/dsv/oauth2/EmbeddedConfiguration.java @@ -3,6 +3,8 @@ package se.su.dsv.oauth2; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import se.su.dsv.oauth2.admin.repository.ClientRepository; import se.su.dsv.oauth2.admin.repository.ClientRow; @@ -15,6 +17,11 @@ import java.util.Optional; @Configuration @Profile("embedded") public class EmbeddedConfiguration { + @Bean + public OAuth2AuthorizationService authorizationService() { + return new InMemoryOAuth2AuthorizationService(); + } + @Bean public ClientRepository clientRepository() { ArrayList clients = new ArrayList<>(); @@ -34,7 +41,7 @@ public class EmbeddedConfiguration { String scopeString = System.getenv("CLIENT_SCOPES"); return new ClientRow(clientId, clientId, clientId, "dev@localhost", - redirectUri, scopeString, clientSecret); + redirectUri, scopeString, clientSecret, false); } private static class InMemoryClientrepository implements ClientRepository { diff --git a/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java b/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java index 696ac37..73232bd 100644 --- a/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java +++ b/src/main/java/se/su/dsv/oauth2/JDBCClientRepository.java @@ -29,7 +29,7 @@ public class JDBCClientRepository implements ClientRepository { @Override public List getClients(final Principal owner) { - return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM v2_client WHERE id IN (SELECT client_id FROM v2_client_owner WHERE owner = :owner)") + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret, requires_consent FROM v2_client WHERE id IN (SELECT client_id FROM v2_client_owner WHERE owner = :owner)") .param("owner", owner.getName()) .query(ClientRow.class) .list(); @@ -45,7 +45,7 @@ public class JDBCClientRepository implements ClientRepository { @Override public Optional getClientRowById(final String id) { - return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM v2_client WHERE id = :id") + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret, requires_consent FROM v2_client WHERE id = :id") .param("id", id) .query(ClientRow.class) .optional(); @@ -53,7 +53,7 @@ public class JDBCClientRepository implements ClientRepository { @Override public Optional getClientRowByClientId(final String clientId) { - return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret FROM v2_client WHERE client_id = :clientId") + return getJdbc().sql("SELECT id, client_id, name, contact_email, redirect_uri, scopes, client_secret, requires_consent FROM v2_client WHERE client_id = :clientId") .param("clientId", clientId) .query(ClientRow.class) .optional(); @@ -62,14 +62,15 @@ public class JDBCClientRepository implements ClientRepository { @Override public void addNewClient(final ClientRow clientRow) { getJdbc().sql(""" - INSERT INTO v2_client (id, client_id, client_secret, name, redirect_uri, contact_email, scopes) - VALUES (:id, :clientId, :clientSecret, :name, :redirectUri, :contactEmail, :scopes) + INSERT INTO v2_client (id, client_id, client_secret, name, redirect_uri, contact_email, scopes, requires_consent) + VALUES (:id, :clientId, :clientSecret, :name, :redirectUri, :contactEmail, :scopes, :requiresConsent) ON DUPLICATE KEY UPDATE client_id = VALUES(client_id), client_secret = VALUES(client_secret), name = VALUES(name), redirect_uri = VALUES(redirect_uri), contact_email = VALUES(contact_email), + requires_consent = VALUES(requires_consent), scopes = VALUES(scopes); """) .paramSource(clientRow) diff --git a/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java index 2f3adb4..d79a01e 100644 --- a/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java +++ b/src/main/java/se/su/dsv/oauth2/PersistentConfiguration.java @@ -3,8 +3,12 @@ package se.su.dsv.oauth2; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; @Configuration @Profile("!embedded") @@ -18,4 +22,12 @@ public class PersistentConfiguration { public OAuth2AuthorizationService authorizationService(JdbcClient jdbcClient) { return new SerializingJDBCOAuth2AuthorizationService(jdbcClient); } + + @Bean + public OAuth2AuthorizationConsentService authorizationConsentService( + JdbcOperations jdbcOperations, + RegisteredClientRepository registeredClientRepository) + { + return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository); + } } diff --git a/src/main/java/se/su/dsv/oauth2/admin/Client.java b/src/main/java/se/su/dsv/oauth2/admin/Client.java index 7368e02..1bc45ea 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/Client.java +++ b/src/main/java/se/su/dsv/oauth2/admin/Client.java @@ -11,6 +11,7 @@ public record Client( String redirectUri, boolean isPublic, Set scopes, - List owners) + List owners, + boolean requiresConsent) { } diff --git a/src/main/java/se/su/dsv/oauth2/admin/ClientData.java b/src/main/java/se/su/dsv/oauth2/admin/ClientData.java index 6e6213d..7613335 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/ClientData.java +++ b/src/main/java/se/su/dsv/oauth2/admin/ClientData.java @@ -8,10 +8,11 @@ public record ClientData( URI redirectURI, boolean isPublic, Set scopes, - String contactEmail) + String contactEmail, + boolean requiresConsent) { // When creating a resource server client public ClientData(String clientName, String contactEmail) { - this(clientName, null, false, Set.of(), contactEmail); + this(clientName, null, false, Set.of(), contactEmail, true); } } diff --git a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java index afcae5f..484ce0a 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java +++ b/src/main/java/se/su/dsv/oauth2/admin/ClientManager.java @@ -44,7 +44,7 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme String scopeString = toScopeString(clientData); ClientRow clientRow = new ClientRow(id, clientId, clientData.clientName(), clientData.contactEmail(), - redirectURI, scopeString, encodedClientSecret); + redirectURI, scopeString, encodedClientSecret, clientData.requiresConsent()); clientRepository.addNewClient(clientRow); @@ -66,7 +66,7 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme ClientRow updated = new ClientRow(id, currentClient.clientId(), clientData.clientName(), clientData.contactEmail(), getRedirectUri(clientData), - toScopeString(clientData), currentClient.clientSecret()); + toScopeString(clientData), currentClient.clientSecret(), clientData.requiresConsent()); clientRepository.update(updated); return toClient(updated); } @@ -133,7 +133,8 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme clientRow.redirectUri(), isPublic, scopes, - owners); + owners, + clientRow.requiresConsent()); } // Used by various components of the OAuth 2.0 infrastructure to upgrade @@ -193,7 +194,10 @@ public class ClientManager implements RegisteredClientRepository, ClientManageme redirectUris.add(clientRow.redirectUri()); } }) - .clientSettings(ClientSettings.builder().requireProofKey(clientRow.isPublic()).build()) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(clientRow.requiresConsent()) + .requireProofKey(clientRow.isPublic()) + .build()) .scopes(currentScopes -> currentScopes.addAll(clientRow.scopeSet())) .build(); } diff --git a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java index 2764de8..69de20c 100644 --- a/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java +++ b/src/main/java/se/su/dsv/oauth2/admin/repository/ClientRow.java @@ -12,7 +12,8 @@ public record ClientRow( String contactEmail, String redirectUri, String scopes, - String clientSecret) + String clientSecret, + boolean requiresConsent) { public Set scopeSet() { if (scopes == null) { diff --git a/src/main/java/se/su/dsv/oauth2/dev/DevConfiguration.java b/src/main/java/se/su/dsv/oauth2/dev/DevConfiguration.java index 05a81bd..914d585 100644 --- a/src/main/java/se/su/dsv/oauth2/dev/DevConfiguration.java +++ b/src/main/java/se/su/dsv/oauth2/dev/DevConfiguration.java @@ -13,7 +13,7 @@ import se.su.dsv.oauth2.Config; public class DevConfiguration { @Bean public FilterRegistrationBean fakeSSO(SecurityProperties securityProperties, Config config) { - var filter = new FilterRegistrationBean(new FakeSSOFilter(config.adminEntitlement(), config.developerEntitlement())); + var filter = new FilterRegistrationBean(new FakeSSOFilter(config.developerEntitlement())); filter.setOrder(securityProperties.getFilter().getOrder() - 1); return filter; } diff --git a/src/main/java/se/su/dsv/oauth2/staging/CustomAuthorizationEndpointFilter.java b/src/main/java/se/su/dsv/oauth2/staging/CustomAuthorizationEndpointFilter.java index b49c94a..08fe3c1 100644 --- a/src/main/java/se/su/dsv/oauth2/staging/CustomAuthorizationEndpointFilter.java +++ b/src/main/java/se/su/dsv/oauth2/staging/CustomAuthorizationEndpointFilter.java @@ -18,10 +18,12 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationToken; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.util.RedirectUrlBuilder; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.util.StringUtils; import org.springframework.web.util.UriComponentsBuilder; @@ -117,15 +119,21 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter { Authentication authenticatedPrincipal = authenticationManager.authenticate(principal); Authentication normalCodeRequest = authenticationConverter.convert(withGetMethod(request)); - Authentication codeRequest = overridePrincipal(authenticatedPrincipal, (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest); + OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken = (OAuth2AuthorizationCodeRequestAuthenticationToken) normalCodeRequest; + Authentication codeRequest = overridePrincipal(authenticatedPrincipal, codeRequestAuthenticationToken); Authentication authenticatedCodeRequest = authenticationManager.authenticate(codeRequest); if (!authenticatedCodeRequest.isAuthenticated()) { String authorizationUrl = getAuthorizationUrl(request); respondWithTemplate(response, templates.authorize(authorizationUrl, loggedInUser.getName(), (ShibbolethAuthenticationDetails) authenticatedPrincipal)); } else { - sendAuthorizationResponse(request, response, authenticatedCodeRequest); - + if (authenticatedCodeRequest instanceof OAuth2AuthorizationCodeRequestAuthenticationToken authenticatedCodeRequestAuthenticationToken) { + sendAuthorizationResponse(request, response, authenticatedCodeRequestAuthenticationToken); + } else if (authenticatedCodeRequest instanceof OAuth2AuthorizationConsentAuthenticationToken consent) { + sendConsentResponse(request, response, codeRequestAuthenticationToken, consent); + } else { + response.sendError(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Invalid authentication token"); + } } } @@ -188,10 +196,12 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter { output.writeTo(response.getOutputStream()); } - private void sendAuthorizationResponse(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - - OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = (OAuth2AuthorizationCodeRequestAuthenticationToken) authentication; + private void sendAuthorizationResponse( + HttpServletRequest request, + HttpServletResponse response, + OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) + throws IOException + { UriComponentsBuilder uriBuilder = UriComponentsBuilder .fromUriString(authorizationCodeRequestAuthentication.getRedirectUri()) .queryParam(OAuth2ParameterNames.CODE, @@ -240,4 +250,31 @@ public class CustomAuthorizationEndpointFilter extends HttpFilter { String redirectUri = uriBuilder.build(true).toUriString(); this.redirectStrategy.sendRedirect(request, response, redirectUri); } + + private void sendConsentResponse( + HttpServletRequest request, + HttpServletResponse response, + OAuth2AuthorizationCodeRequestAuthenticationToken codeRequestAuthenticationToken, + OAuth2AuthorizationConsentAuthenticationToken consent) + throws IOException + { + Set requestedScopes = codeRequestAuthenticationToken.getScopes(); + String consentUri = resolveConsentUri(request, "/oauth2/consent"); + String redirectUri = UriComponentsBuilder.fromUriString(consentUri) + .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes)) + .queryParam(OAuth2ParameterNames.CLIENT_ID, consent.getClientId()) + .queryParam(OAuth2ParameterNames.STATE, consent.getState()) + .toUriString(); + this.redirectStrategy.sendRedirect(request, response, redirectUri); + } + + private String resolveConsentUri(HttpServletRequest request, String consentPage) { + RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); + urlBuilder.setScheme(request.getScheme()); + urlBuilder.setServerName(request.getServerName()); + urlBuilder.setPort(request.getServerPort()); + urlBuilder.setContextPath(request.getContextPath()); + urlBuilder.setPathInfo(consentPage); + return urlBuilder.getUrl(); + } } diff --git a/src/main/java/se/su/dsv/oauth2/staging/SkipConsentForDevelopers.java b/src/main/java/se/su/dsv/oauth2/staging/SkipConsentForDevelopers.java new file mode 100644 index 0000000..cb842f2 --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/staging/SkipConsentForDevelopers.java @@ -0,0 +1,26 @@ +package se.su.dsv.oauth2.staging; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationContext; + +import java.util.function.Predicate; + +public class SkipConsentForDevelopers implements Predicate { + private final Predicate developerAccessCheck; + + public SkipConsentForDevelopers(final Predicate developerAccessCheck) { + this.developerAccessCheck = developerAccessCheck; + } + + @Override + public boolean test(final OAuth2AuthorizationCodeRequestAuthenticationContext context) { + Authentication currentUser = SecurityContextHolder.getContext().getAuthentication(); + if (developerAccessCheck.test(currentUser)) { + return false; + } else { + // TODO maybe check if consent is already given? useful to always request consent during testing? + return context.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent(); + } + } +} diff --git a/src/main/java/se/su/dsv/oauth2/web/client/ClientAdminController.java b/src/main/java/se/su/dsv/oauth2/web/client/ClientAdminController.java index c9b3848..71f1e5c 100644 --- a/src/main/java/se/su/dsv/oauth2/web/client/ClientAdminController.java +++ b/src/main/java/se/su/dsv/oauth2/web/client/ClientAdminController.java @@ -1,6 +1,7 @@ package se.su.dsv.oauth2.web.client; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.stereotype.Controller; @@ -28,13 +29,16 @@ public class ClientAdminController { private final ClientManagementService clientManagementService; private final AuthorizationServerSettings authorizationServerSettings; + private final DeveloperAccessCheck developerAccessCheck; public ClientAdminController( final ClientManagementService clientManagementService, - AuthorizationServerSettings authorizationServerSettings) + AuthorizationServerSettings authorizationServerSettings, + DeveloperAccessCheck developerAccessCheck) { this.clientManagementService = clientManagementService; this.authorizationServerSettings = authorizationServerSettings; + this.developerAccessCheck = developerAccessCheck; } @GetMapping @@ -46,13 +50,13 @@ public class ClientAdminController { @GetMapping("/new") public String newClient(Model model) { - model.addAttribute("newClient", new NewClientRequest(null, null, null, null, null)); + model.addAttribute("newClient", NewClientRequest.empty()); return "admin/client/new"; } @PostMapping("/new") public String createClient( - Principal principal, + Authentication principal, Model model, RedirectAttributes redirectAttributes, @ModelAttribute("newClient") NewClientRequest newClientRequest, @@ -62,12 +66,14 @@ public class ClientAdminController { model.addAttribute("errors", bindingResult.getAllErrors()); return "admin/client/new"; } + boolean requiresConsent = determineConsentRequired(principal, newClientRequest); ClientData clientData = new ClientData( newClientRequest.name(), newClientRequest.redirectUri(), newClientRequest.isPublic(), newClientRequest.scopes(), - newClientRequest.contact()); + newClientRequest.contact(), + requiresConsent); NewClient newClient = clientManagementService.createClient(principal, clientData); redirectAttributes.addFlashAttribute("message", "New client created"); redirectAttributes.addFlashAttribute("clientId", newClient.clientId()); @@ -132,7 +138,7 @@ public class ClientAdminController { @PostMapping("/{id}/edit") public String editClient( @PathVariable("id") String id, - Principal principal, + Authentication principal, Model model, RedirectAttributes redirectAttributes, @ModelAttribute("newClient") NewClientRequest newClientRequest, @@ -142,17 +148,29 @@ public class ClientAdminController { model.addAttribute("errors", bindingResult.getAllErrors()); return "admin/client/edit"; } + boolean requiresConsent = determineConsentRequired(principal, newClientRequest); ClientData clientData = new ClientData( newClientRequest.name(), newClientRequest.redirectUri(), newClientRequest.isPublic(), newClientRequest.scopes(), - newClientRequest.contact()); + newClientRequest.contact(), + requiresConsent); final Client updatedClient = clientManagementService.updateClient(principal, id, clientData); redirectAttributes.addFlashAttribute("message", "Client updated"); return "redirect:/admin/client/" + updatedClient.id(); } + private boolean determineConsentRequired(final Authentication principal, final NewClientRequest newClientRequest) { + if (hasDeveloperAccess(principal)) { + // Allow developers to decide if consent is required + return newClientRequest.requiresConsentBoolean(); + } else { + // For everyone else it's always required + return true; + } + } + @ModelAttribute public CsrfToken csrfToken(CsrfToken token) { return token; @@ -162,4 +180,9 @@ public class ClientAdminController { public AuthorizationServerSettings authorizationServerSettings() { return authorizationServerSettings; } + + @ModelAttribute(name = "developerAccess") + public boolean hasDeveloperAccess(Authentication principal) { + return developerAccessCheck.test(principal); + } } diff --git a/src/main/java/se/su/dsv/oauth2/web/client/DeveloperAccessCheck.java b/src/main/java/se/su/dsv/oauth2/web/client/DeveloperAccessCheck.java new file mode 100644 index 0000000..6677a39 --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/web/client/DeveloperAccessCheck.java @@ -0,0 +1,9 @@ +package se.su.dsv.oauth2.web.client; + +import org.springframework.security.core.Authentication; + +import java.util.function.Predicate; + +@FunctionalInterface +public interface DeveloperAccessCheck extends Predicate { +} diff --git a/src/main/java/se/su/dsv/oauth2/web/client/NewClientRequest.java b/src/main/java/se/su/dsv/oauth2/web/client/NewClientRequest.java index 935dd31..058f1c5 100644 --- a/src/main/java/se/su/dsv/oauth2/web/client/NewClientRequest.java +++ b/src/main/java/se/su/dsv/oauth2/web/client/NewClientRequest.java @@ -14,7 +14,8 @@ public record NewClientRequest( String contact, URI redirectUri, String public_, - String scope) + String scope, + String requiresConsent) { public static NewClientRequest from(final Client client) { return new NewClientRequest( @@ -22,7 +23,12 @@ public record NewClientRequest( client.contact(), URI.create(client.redirectUri()), client.isPublic() ? "on" : null, - String.join("\r\n", client.scopes())); + String.join("\r\n", client.scopes()), + client.requiresConsent() ? "on" : null); + } + + public static NewClientRequest empty() { + return new NewClientRequest(null, null, null, null, null, null); } public boolean isPublic() { @@ -40,4 +46,8 @@ public record NewClientRequest( public String redirectUriString() { return redirectUri == null ? "" : redirectUri.toString(); } + + public boolean requiresConsentBoolean() { + return "on".equals(requiresConsent); + } } diff --git a/src/main/java/se/su/dsv/oauth2/web/oauth2/ConsentController.java b/src/main/java/se/su/dsv/oauth2/web/oauth2/ConsentController.java new file mode 100644 index 0000000..0dfd03e --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/web/oauth2/ConsentController.java @@ -0,0 +1,150 @@ +package se.su.dsv.oauth2.web.oauth2; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.ErrorResponseException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; +import se.su.dsv.oauth2.shibboleth.ShibbolethAuthenticationDetails; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +@Controller +public class ConsentController { + + private final AuthorizationServerSettings authorizationServerSettings; + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + + public ConsentController( + final AuthorizationServerSettings authorizationServerSettings, + final RegisteredClientRepository registeredClientRepository, + final OAuth2AuthorizationService authorizationService) + { + this.authorizationServerSettings = authorizationServerSettings; + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + } + + @GetMapping("/oauth2/consent") + public String showConsentForm( + Authentication authentication, + Model model, + UriComponentsBuilder uriComponentsBuilder, + @RequestParam("scope") String scopeString, + @RequestParam("client_id") String clientId, + @RequestParam("state") String state) + { + RegisteredClient client = registeredClientRepository.findByClientId(clientId); + if (client == null) { + throw new ErrorResponseException(HttpStatus.BAD_REQUEST); + } + + model.addAttribute("clientName", client.getClientName()); + model.addAttribute("clientDomain", getClientDomain(client)); + + String authorizationUrl = uriComponentsBuilder + .path(authorizationServerSettings.getAuthorizationEndpoint()) + .toUriString(); + model.addAttribute("authorizationUrl", authorizationUrl); + + Set scopes = getRequestScopes(scopeString); + model.addAttribute("scopes", scopes); + + model.addAttribute("clientId", clientId); + model.addAttribute("state", state); + + PersonalInformation personalInformation = getPersonalInformation(authentication); + model.addAttribute("personalInformation", personalInformation); + + return "consent"; + } + + private PersonalInformation getPersonalInformation(Authentication authentication) { + if (authentication.getDetails() instanceof ShibbolethAuthenticationDetails details) { + return new PersonalInformation( + details.givenName(), + details.familyName(), + details.displayName(), + details.emailAddress()); + } + else { + return new PersonalInformation(null, null, null, null); + } + } + + @PostMapping("/oauth2/consent") + public String denyConsent(@RequestParam("state") String state) { + OAuth2Authorization authorization = authorizationService.findByToken( + state, + new OAuth2TokenType(OAuth2ParameterNames.STATE)); + if (authorization == null) { + return "redirect:/"; + } + else { + String registeredClientId = authorization.getRegisteredClientId(); + RegisteredClient registeredClient = registeredClientRepository.findById(registeredClientId); + authorizationService.remove(authorization); + if (registeredClient == null) { + return "redirect:/"; + } + String redirectUri = registeredClient.getRedirectUris() + .stream() + .findFirst() + .orElseThrow(() -> new ErrorResponseException(HttpStatus.BAD_REQUEST)); + return "redirect:" + redirectUri + + "?error=" + OAuth2ErrorCodes.ACCESS_DENIED + + "&state=" + state; + } + } + + private static String getClientDomain(final RegisteredClient client) { + return client.getRedirectUris() + .stream() + .map(UriComponentsBuilder::fromUriString) + .map(UriComponentsBuilder::build) + .map(uri -> { + String scheme = uri.getScheme(); + String host = uri.getHost(); + return scheme + "://" + host; + }) + .findAny() + .orElseThrow(() -> new ErrorResponseException(HttpStatus.BAD_REQUEST)); + } + + private static Set getRequestScopes(final String scopeString) { + if (scopeString == null) { + return Set.of(); + } + return Arrays.stream(scopeString.split(" ")) + .filter(s -> !s.isBlank()) + .filter(scope -> !scope.equals("openid")) + .collect(Collectors.toSet()); + } + + @ModelAttribute("currentUser") + public Authentication authentication(Authentication authentication) { + return authentication; + } + + @ModelAttribute("csrfToken") + public CsrfToken csrfToken(CsrfToken csrfToken) { + return csrfToken; + } +} diff --git a/src/main/java/se/su/dsv/oauth2/web/oauth2/PersonalInformation.java b/src/main/java/se/su/dsv/oauth2/web/oauth2/PersonalInformation.java new file mode 100644 index 0000000..d9fffb4 --- /dev/null +++ b/src/main/java/se/su/dsv/oauth2/web/oauth2/PersonalInformation.java @@ -0,0 +1,9 @@ +package se.su.dsv.oauth2.web.oauth2; + +public record PersonalInformation( + String givenName, + String familyName, + String displayName, + String email) +{ +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 13d6951..1361fdc 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,4 +1,3 @@ -se.su.dsv.oauth2.admin-entitlement=oauth2-admin se.su.dsv.oauth2.developer-entitlement=oauth2-developer gg.jte.templateLocation=src/main/resources/templates gg.jte.developmentMode=true diff --git a/src/main/resources/db/migration/V5__resource_owner_consent.sql b/src/main/resources/db/migration/V5__resource_owner_consent.sql new file mode 100644 index 0000000..b0d2f67 --- /dev/null +++ b/src/main/resources/db/migration/V5__resource_owner_consent.sql @@ -0,0 +1,6 @@ +ALTER TABLE `v2_client` + # Defaulting user consent to true so that future clients will require user consent + ADD COLUMN `requires_consent` BOOLEAN NOT NULL DEFAULT TRUE; + +# Making sure current clients stay the same +UPDATE `v2_client` SET `requires_consent` = FALSE; diff --git a/src/main/resources/db/migration/V6__user_consent.sql b/src/main/resources/db/migration/V6__user_consent.sql new file mode 100644 index 0000000..f31bdca --- /dev/null +++ b/src/main/resources/db/migration/V6__user_consent.sql @@ -0,0 +1,7 @@ +CREATE TABLE oauth2_authorization_consent +( + registered_client_id varchar(100) NOT NULL, + principal_name varchar(200) NOT NULL, + authorities varchar(1000) NOT NULL, + PRIMARY KEY (registered_client_id, principal_name) +); diff --git a/src/main/resources/templates/admin/client/edit.jte b/src/main/resources/templates/admin/client/edit.jte index aa09249..2366410 100644 --- a/src/main/resources/templates/admin/client/edit.jte +++ b/src/main/resources/templates/admin/client/edit.jte @@ -7,6 +7,7 @@ @param Client client @param String feedback @param List errors +@param boolean developerAccess @template.base(title = "Edit client " + client.name(), content = @`