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

Added Okta OAuth Authentication #14

Merged
merged 4 commits into from
Jun 28, 2023
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* View Consumer Groups — view per-partition parked offsets, combined and per-partition lag
* Browse Messages — browse messages with JSON, plain text, Protobuf, and Avro encoding
* Topic Configuration — create and configure new topics and edit existing one
* Support for Okta OAuth Authentication

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
final String basePath = exchange.getRequest().getPath().contextPath().value();
final String path = exchange.getRequest().getPath().pathWithinApplication().value();

if (!path.startsWith("/api") && !path.startsWith("/assets")) {
if (
!path.startsWith("/api") &&
!path.startsWith("/assets") &&
!path.startsWith("/logout") &&
!path.startsWith("/authentication")
) {
return chain.filter(
exchange
.mutate()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* This file is part of the Tansen project.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
package com.ideasbucket.tansen.configuration.auth;

import static jakarta.validation.constraints.Pattern.Flag.CASE_INSENSITIVE;

import com.ideasbucket.tansen.entity.OAuth2;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ConfigurationProperties(prefix = "auth")
@Validated
public class AuthenticationProperties {

@Pattern(
regexp = "^(?:disabled|oauth2|DISABLED|OAUTH2)$",
message = "Only OAUTH2 and DISABLED is supported.",
flags = CASE_INSENSITIVE
)
private final String type;

@Valid
private final OAuth2 oauth2;

public AuthenticationProperties(String type, OAuth2 oauth2) {
this.type = type == null ? "disabled" : type.toLowerCase();
this.oauth2 = oauth2;
}

public String getType() {
return type;
}

public OAuth2 getOauth2() {
return oauth2;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* This file is part of the Tansen project.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
package com.ideasbucket.tansen.configuration.auth;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
@ConditionalOnProperty(value = "auth.type", havingValue = "disabled")
public class DisabledSecurityConfiguration {

@Bean
public SecurityWebFilterChain configure(ServerHttpSecurity http) {
return http
.logout((ServerHttpSecurity.LogoutSpec::disable))
.httpBasic((ServerHttpSecurity.HttpBasicSpec::disable))
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.cors(Customizer.withDefaults())
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.authorizeExchange(authorizeExchangeSpec -> {
authorizeExchangeSpec.pathMatchers(HttpMethod.OPTIONS).permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.POST, "/**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.PUT, "/**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.PATCH, "/**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.DELETE, "/**").permitAll();
authorizeExchangeSpec.anyExchange().permitAll();
})
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* This file is part of the Tansen project.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
package com.ideasbucket.tansen.configuration.auth;

import static com.ideasbucket.tansen.util.JsonConverter.createObjectNode;
import static com.ideasbucket.tansen.util.RequestResponseUtil.*;

import java.net.URI;
import java.util.Map;
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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.client.oidc.web.server.logout.OidcClientInitiatedServerLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
import org.springframework.security.web.server.csrf.CsrfToken;
import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler;
import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler;
import org.springframework.web.server.WebFilter;
import reactor.core.publisher.Mono;

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
@ConditionalOnProperty(value = "auth.type", havingValue = "oauth2")
public class OAuth2SecurityConfiguration {

private final AuthenticationProperties authenticationProperties;
private final ReactiveClientRegistrationRepository clientRegistrationRepository;

@Autowired
public OAuth2SecurityConfiguration(AuthenticationProperties authenticationProperties) {
this.authenticationProperties = authenticationProperties;
this.clientRegistrationRepository = new InMemoryReactiveClientRegistrationRepository(oktaClientRegistration());
}

@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
CookieServerCsrfTokenRepository csrfTokenRepository = CookieServerCsrfTokenRepository.withHttpOnlyFalse();
XorServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler();
// Use only the handle() method of XorServerCsrfTokenRequestAttributeHandler and the
// default implementation of resolveCsrfTokenValue() from ServerCsrfTokenRequestHandler
ServerCsrfTokenRequestHandler csrfTokenRequestHandler = delegate::handle;

return http
.authorizeExchange(authorizeExchangeSpec -> {
authorizeExchangeSpec.pathMatchers(HttpMethod.OPTIONS).permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/insight/health/readiness").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/insight/health/liveness").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/authentication").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/login**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/oauth2/**").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/favicon.ico").permitAll();
authorizeExchangeSpec.pathMatchers(HttpMethod.GET, "/assets/**").permitAll();
authorizeExchangeSpec.anyExchange().authenticated();
})
.formLogin(formLoginSpec -> formLoginSpec.loginPage("/login"))
.csrf(csrfSpec -> {
csrfSpec.csrfTokenRepository(csrfTokenRepository);
csrfSpec.csrfTokenRequestHandler(csrfTokenRequestHandler);
})
.logout(logoutSpec -> {
logoutSpec.logoutUrl("/logout");
logoutSpec.logoutSuccessHandler(oidcLogoutSuccessHandler(this.clientRegistrationRepository));
})
.oauth2Login(Customizer.withDefaults())
.exceptionHandling(exceptionHandlingSpec -> {
exceptionHandlingSpec.authenticationEntryPoint((serverWebExchange, ex) -> {
if (isAjaxRequest(serverWebExchange.getRequest().getHeaders())) {
var rootNode = createObjectNode();
rootNode.put("success", false);
var errorsNode = createObjectNode();
errorsNode.put("loginUrl", getLoginPath(authenticationProperties));
errorsNode.put("message", "Login required.");
rootNode.replace("errors", errorsNode);

return setJsonResponse(rootNode, serverWebExchange, HttpStatus.UNAUTHORIZED);
}

return Mono.fromRunnable(() -> {
ServerHttpResponse response = serverWebExchange.getResponse();
response.setStatusCode(HttpStatus.TEMPORARY_REDIRECT);
response.getHeaders().set("Referer", serverWebExchange.getRequest().getPath().value());
response.getHeaders().setLocation(URI.create(getLoginPath(authenticationProperties)));
});
});

exceptionHandlingSpec.accessDeniedHandler((serverWebExchange, ex) -> {
var rootNode = createObjectNode();
rootNode.put("success", false);
var errorsNode = createObjectNode();
errorsNode.put("access", "You do not have access to this action.");
rootNode.replace("errors", errorsNode);

return setJsonResponse(rootNode, serverWebExchange, HttpStatus.FORBIDDEN);
});
})
.build();
}

@Bean
public ReactiveClientRegistrationRepository clientRegistrationRepository() {
return this.clientRegistrationRepository;
}

@Bean
WebFilter csrfCookieWebFilter() {
return (exchange, chain) -> {
Mono<CsrfToken> csrfToken = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty());
return csrfToken
.doOnSuccess(token -> {
/* Ensures the token is subscribed to. */
})
.then(chain.filter(exchange));
};
}

private ServerLogoutSuccessHandler oidcLogoutSuccessHandler(final ReactiveClientRegistrationRepository repository) {
var successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(repository);
successHandler.setPostLogoutRedirectUri("{baseUrl}");

return successHandler;
}

private ClientRegistration oktaClientRegistration() {
return CommonOAuth2Provider.OKTA
.getBuilder("okta")
.clientId(authenticationProperties.getOauth2().getClient("okta").getClientId())
.clientSecret(authenticationProperties.getOauth2().getClient("okta").getClientSecret())
.authorizationUri(authenticationProperties.getOauth2().getClient("okta").getAuthorizationUri())
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope(authenticationProperties.getOauth2().getClient("okta").getScope())
.tokenUri(authenticationProperties.getOauth2().getClient("okta").getTokenUri())
.userInfoUri(authenticationProperties.getOauth2().getClient("okta").getUserInfoUri())
.jwkSetUri(authenticationProperties.getOauth2().getClient("okta").getJwkSetUri())
.userNameAttributeName(IdTokenClaimNames.SUB)
.clientName("Okta")
.providerConfigurationMetadata(
Map.of(
"end_session_endpoint",
authenticationProperties.getOauth2().getClient("okta").getSessionEndPointUri()
)
)
.build();
}
}
105 changes: 105 additions & 0 deletions backend/main/java/com/ideasbucket/tansen/entity/AuthStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* This file is part of the Tansen project.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
package com.ideasbucket.tansen.entity;

import com.fasterxml.jackson.annotation.*;
import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({ "loginRequired", "loggedIn", "firstName", "lastName", "initials" })
public class AuthStatus {

@JsonProperty("loginRequired")
private final Boolean loginRequired;

@JsonProperty("loggedIn")
private final Boolean loggedIn;

@JsonProperty("firstName")
private final String firstName;

@JsonProperty("lastName")
private final String lastName;

@JsonProperty("initials")
private final String initials;

@JsonProperty("loginOptions")
private final List<LoginOptions> loginOptions;

@JsonCreator
public AuthStatus(
@JsonProperty("loginRequired") Boolean loginRequired,
@JsonProperty("loggedIn") Boolean loggedIn,
@JsonProperty("firstName") String firstName,
@JsonProperty("lastName") String lastName,
@JsonProperty("loginOptions") List<LoginOptions> loginOptions
) {
this.loginRequired = loginRequired;
this.loggedIn = loggedIn;
this.firstName = firstName;
this.lastName = lastName;
this.initials = getNameInitials(firstName, lastName);
this.loginOptions = loginOptions;
}

public Boolean getLoginRequired() {
return loginRequired;
}

public Boolean getLoggedIn() {
return loggedIn;
}

public String getFirstName() {
return firstName;
}

public String getLastName() {
return lastName;
}

public String getInitials() {
return initials;
}

public List<LoginOptions> getLoginOptions() {
return loginOptions;
}

@JsonIgnore
public static AuthStatus noLoginRequired() {
return new AuthStatus(false, true, null, null, null);
}

@JsonIgnore
public static AuthStatus notLoggedIn(List<LoginOptions> loginOptions) {
return new AuthStatus(true, false, null, null, loginOptions);
}

@JsonIgnore
public static AuthStatus loggedIn(String firstName, String lastName) {
return new AuthStatus(true, true, firstName, lastName, null);
}

@JsonIgnore
private String getNameInitials(String firstName, String lastName) {
if ((firstName == null) && (lastName == null)) {
return null;
}

if (lastName == null) {
return firstName.substring(0, 1).toUpperCase();
}

if (firstName == null) {
return lastName.substring(0, 1).toUpperCase();
}

return firstName.substring(0, 1).toUpperCase() + lastName.substring(0, 1).toUpperCase();
}
}
Loading