Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: integrate Spring Authorization Server #19907

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ static UserDetails createUserDetails(
.isTwoFactorEnabled(user.isTwoFactorEnabled())
.twoFactorType(user.getTwoFactorType())
.secret(user.getSecret())
.email(user.getEmail())
.isEmailVerified(user.isEmailVerified())
.firstName(user.getFirstName())
.surname(user.getSurname())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public class UserDetailsImpl implements UserDetails {
private final boolean isTwoFactorEnabled;
private final TwoFactorType twoFactorType;
private final String secret;
private final String email;
private final boolean isEmailVerified;
private final boolean enabled;
private final boolean accountNonExpired;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,22 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio
mappingClaimKey, claimValue));
}

if (claimValue != null) {
User user = userService.getUserByOpenId((String) claimValue);
if (user != null && user.isExternalAuth()) {
if (user.isDisabled() || !user.isAccountNonExpired()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("user_disabled"), "User is disabled");
try {
if (claimValue != null) {
User user = userService.getUserByOpenId((String) claimValue);
if (user != null && user.isExternalAuth()) {
if (user.isDisabled() || !user.isAccountNonExpired()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("user_disabled"), "User is disabled");
}
UserDetails userDetails = userService.createUserDetails(user);
return new DhisOidcUser(
userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken());
}

UserDetails userDetails = userService.createUserDetails(user);

return new DhisOidcUser(
userDetails, attributes, IdTokenClaimNames.SUB, oidcUser.getIdToken());
}
} catch (OAuth2AuthenticationException e) {
// throw new RuntimeException(e);
log.info("wtfffff", e);
}

String errorMessage =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public Authentication authenticate(Authentication auth) throws AuthenticationExc
UserDetails userDetails = (UserDetails) result.getPrincipal();

// Validate that the user is not configured for external auth only
checkExternalAuth(userDetails, username);
// checkExternalAuth(userDetails, username);

// If the user’s role requires 2FA enrollment but they haven’t set it up, throw an exception.
checkTwoFactorEnrolment(userDetails);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -622,9 +622,6 @@ public enum ConfigurationKey {
*/
AUDIT_ENABLED("system.audit.enabled", Constants.ON, false),

/** OAuth2 authorization server feature. Enable or disable. */
ENABLE_OAUTH2_AUTHORIZATION_SERVER("oauth2.authorization.server.enabled", Constants.ON, false),

/** JWT OIDC token authentication feature. Enable or disable. */
ENABLE_JWT_OIDC_TOKEN_AUTHENTICATION(
"oidc.jwt.token.authentication.enabled", Constants.OFF, false),
Expand Down
6 changes: 6 additions & 0 deletions dhis-2/dhis-web-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
</dependency>

<dependency>
<groupId>org.apache.jclouds</groupId>
<artifactId>jclouds-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -96,12 +98,22 @@ public void handleAuthenticationSuccess(AbstractAuthenticationEvent event) {

Object details = auth.getDetails();

if (TwoFactorWebAuthenticationDetails.class.isAssignableFrom(details.getClass())) {
if (details != null
&& TwoFactorWebAuthenticationDetails.class.isAssignableFrom(details.getClass())) {
TwoFactorWebAuthenticationDetails authDetails = (TwoFactorWebAuthenticationDetails) details;

log.debug(String.format("Login attempt succeeded for remote IP: %s", authDetails.getIp()));
}

if (OidcUserInfoAuthenticationToken.class.isAssignableFrom(auth.getClass())) {
OidcUserInfoAuthenticationToken oidcUserInfoAuthenticationToken =
(OidcUserInfoAuthenticationToken) auth;
JwtAuthenticationToken principal =
(JwtAuthenticationToken) oidcUserInfoAuthenticationToken.getPrincipal();
WebAuthenticationDetails principalDetails = (WebAuthenticationDetails) principal.getDetails();
String remoteAddress = principalDetails.getRemoteAddress();
log.debug(String.format("OIDC login attempt succeeded for remote IP: %s", remoteAddress));
}

if (OAuth2LoginAuthenticationToken.class.isAssignableFrom(auth.getClass())) {
OAuth2LoginAuthenticationToken authenticationToken = (OAuth2LoginAuthenticationToken) auth;
DhisOidcUser principal = (DhisOidcUser) authenticationToken.getPrincipal();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* Neither the name of the HISP project nor the names of its contributors may
* be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.webapi.security.config;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
import org.hisp.dhis.security.spring2fa.TwoFactorWebAuthenticationDetailsSource;
import org.hisp.dhis.user.User;
import org.hisp.dhis.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;

@Configuration
public class AuthorizationServerConfig {

@Autowired
private TwoFactorWebAuthenticationDetailsSource twoFactorWebAuthenticationDetailsSource;

@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
OAuth2AuthorizationServerConfigurer.authorizationServer();

http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(
authorizationServerConfigurer,
(authorizationServer) ->
authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0
)
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
.exceptionHandling(
(exceptions) ->
exceptions.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/XXX.html"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));

return http.build();
}

// @Bean
// @Order(2)
// public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
// // Form login handles the redirect to the login page from the
// // authorization server filter chain
// .formLogin(Customizer.withDefaults());

// http.formLogin(formLogin -> formLogin
// .authenticationDetailsSource(twoFactorWebAuthenticationDetailsSource)
// .loginPage("/XXX.html")
// .usernameParameter("j_username")
// .passwordParameter("j_password")
// .loginProcessingUrl("/loginAction")
// .defaultSuccessUrl("/dhis-web-dashboard", true)
// .failureUrl("/index.html?error=true")
// );
//
//// http.authorizeHttpRequests(
//// (authorize) ->
//// authorize.requestMatchers(new AntPathRequestMatcher("/login")).permitAll())
//
// // Form login handles the redirect to the login page from the
// // authorization server filter chain
// // http
// http.formLogin(
// form -> form.authenticationDetailsSource(twoFactorWebAuthenticationDetailsSource)
// .loginPage("/XXX.html")
// .loginProcessingUrl("/login")
// );
//
// // .logout(logout -> logout.logoutUrl("/dhis-web-commons-security/logout.action"));
//
// return http.build();
// }

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(UserService userService) {
return (context) -> {
OAuth2TokenType tokenType = context.getTokenType();
if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
String username = context.getPrincipal().getName();
User user = userService.getUserByUsername(username);
context.getClaims().claim("email", user.getEmail());
}

// if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// // Load the user info (this should include the email if available)
// // OidcUserInfo userInfo =
// userService.loadUser(context.getPrincipal().getName());
// // Merge all user info claims into the ID token claims
// // context.getClaims().claims((claims) ->
// claims.putAll(userInfo.getClaims()));
//
// String username = context.getPrincipal().getName();
// User user = userService.getUserByUsername(username);
//
// context.getClaims().claim("email", user.getEmail());
// }

if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// Load the user info (this should include the email if available)
// OidcUserInfo userInfo = userService.loadUser(context.getPrincipal().getName());
// Merge all user info claims into the ID token claims
// context.getClaims().claims((claims) -> claims.putAll(userInfo.getClaims()));

String username = context.getPrincipal().getName();
User user = userService.getUserByUsername(username);

context.getClaims().claim("email", user.getEmail());
}
};
}

@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient oidcClient =
RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("dhis2-client")
.clientSecret("$2a$12$FtWBAB.hWkR3SSul7.HWROr8/aEuUEjywnB86wrYz0HtHh4iam6/G")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// .redirectUri("http://localhost:9090/oauth2/code/dhis2-client")
.redirectUri("http://localhost:8080/oauth2/code/xxx")
.postLogoutRedirectUri("http://127.0.0.1:8080/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.scope(StandardClaimNames.EMAIL)
.scope(StandardClaimNames.EMAIL_VERIFIED)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();

return new InMemoryRegisteredClientRepository(oidcClient);
}

@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey =
new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}

private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}

@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,16 @@ private void configureMatchers(HttpSecurity http) throws Exception {
.hasAnyAuthority("ALL", "M_dhis-web-aggregate-data-entry")

/////////////////////////////////////////////////////////////////////////////////////////////////

.requestMatchers(new AntPathRequestMatcher("/oauth2/authorize"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/oauth2/token"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/login"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/loginAction"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/XXX.html"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/dhis-web-login/**"))
.permitAll()
.requestMatchers(new AntPathRequestMatcher("/login.html"))
Expand Down Expand Up @@ -448,6 +457,12 @@ public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
}
});

http.formLogin(
form ->
form.authenticationDetailsSource(twoFactorWebAuthenticationDetailsSource)
.loginPage("/XXX.html")
.loginProcessingUrl("/login"));

/// OIDC /////////
http.oauth2Login(
oauth2 ->
Expand Down Expand Up @@ -524,8 +539,7 @@ private void configureApiTokenAuthorizationFilter(HttpSecurity http) {
}

/**
* Enable either deprecated OAuth2 authorization filter or the new JWT OIDC token filter. They are
* mutually exclusive and can not both be added to the chain at the same time.
* Enable JWT OIDC token filter.
*
* @param http HttpSecurity config
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ public static void setupServlets(
.addServlet("RootPageServlet", LoginFallbackServlet.class)
.addMapping("/login.html", "/dhis-web-commons/security/login.action");

context.addServlet("XXXPageServlet", XXXFallbackServlet.class).addMapping("/XXX.html");

String profile = System.getProperty("spring.profiles.active");
if (profile == null || !profile.equals("embeddedJetty")) {
RequestContextListener requestContextListener = new RequestContextListener();
Expand Down
Loading
Loading