Implement OAuth2/OIDC login (authorize redirect + callback servlet) end-to-end
This commit is contained in:
+13
-1
@@ -51,12 +51,24 @@ public class ApplicationPropertiesConfigOverlay {
|
||||
applyString(config, "security.customGlobalAPIKey", security::setCustomGlobalAPIKey);
|
||||
applyBoolean(config, "storage.enabled", applicationProperties.getStorage()::setEnabled);
|
||||
|
||||
// SSO toggles - the detailed provider config is bound by the OIDC/SAML wiring.
|
||||
// SSO toggles. The detailed OAuth2 provider config (issuer/clientId/...) is read directly
|
||||
// from MicroProfile config by OAuth2LoginController; the SAML provider config likewise by
|
||||
// the
|
||||
// SAML SP. Only the booleans the service layer reads via ApplicationProperties are bound
|
||||
// here.
|
||||
if (security.getSaml2() != null) {
|
||||
applyBoolean(config, "security.saml2.enabled", security.getSaml2()::setEnabled);
|
||||
applyBoolean(
|
||||
config,
|
||||
"security.saml2.autoCreateUser",
|
||||
security.getSaml2()::setAutoCreateUser);
|
||||
}
|
||||
if (security.getOauth2() != null) {
|
||||
applyBoolean(config, "security.oauth2.enabled", security.getOauth2()::setEnabled);
|
||||
applyBoolean(
|
||||
config,
|
||||
"security.oauth2.autoCreateUser",
|
||||
security.getOauth2()::setAutoCreateUser);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -56,6 +56,12 @@ public class JwtBearerAuthenticationMechanism implements HttpAuthenticationMecha
|
||||
return token;
|
||||
}
|
||||
}
|
||||
// Browser SSO (OAuth2/SAML) stores the issued app JWT in this cookie rather than an
|
||||
// Authorization header.
|
||||
io.vertx.core.http.Cookie cookie = context.request().getCookie("stirling_jwt");
|
||||
if (cookie != null && cookie.getValue() != null && !cookie.getValue().isBlank()) {
|
||||
return cookie.getValue().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.servlet.annotation.WebServlet;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServlet;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* OAuth2 / OIDC authorization-code callback. Implemented as a Jakarta {@code @WebServlet} (not
|
||||
* JAX-RS) because quarkus-undertow's default servlet owns the {@code /login/*} prefix and
|
||||
* intercepts the extension-less callback path before RESTEasy can route it (a registered servlet
|
||||
* takes precedence over the default servlet). The authorize/initiation side lives in {@link
|
||||
* OAuth2LoginController}; this finishes the flow and issues the application JWT (set as the {@code
|
||||
* stirling_jwt} cookie the {@link
|
||||
* stirling.software.proprietary.security.identity.JwtBearerAuthenticationMechanism} reads).
|
||||
*/
|
||||
@Slf4j
|
||||
@WebServlet(urlPatterns = "/login/oauth2/code/*")
|
||||
public class OAuth2CallbackServlet extends HttpServlet {
|
||||
|
||||
private static final String REG_ID = "keycloak";
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.enabled", defaultValue = "false")
|
||||
boolean oauth2Enabled;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.issuer")
|
||||
Optional<String> issuer;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.clientId")
|
||||
Optional<String> clientId;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.clientSecret")
|
||||
Optional<String> clientSecret;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.useAsUsername", defaultValue = "email")
|
||||
String useAsUsername;
|
||||
|
||||
@Inject UserService userService;
|
||||
@Inject TeamService teamService;
|
||||
@Inject JwtServiceInterface jwtService;
|
||||
@Inject ApplicationProperties applicationProperties;
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
protected void doGet(HttpServletRequest request, HttpServletResponse response)
|
||||
throws IOException {
|
||||
if (!isConfigured()) {
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
String error = request.getParameter("error");
|
||||
if (error != null) {
|
||||
log.warn("OAuth2 callback error: {}", error);
|
||||
redirectToLogin(request, response, "oauth2_error");
|
||||
return;
|
||||
}
|
||||
String code = request.getParameter("code");
|
||||
if (code == null || code.isBlank()) {
|
||||
redirectToLogin(request, response, "missing_code");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> token = exchangeCode(code, redirectUri(request));
|
||||
Map<String, Object> claims = fetchUserInfo((String) token.get("access_token"));
|
||||
Object usernameClaim = claims.get(useAsUsername);
|
||||
if (usernameClaim == null) {
|
||||
log.error(
|
||||
"OAuth2 userinfo missing '{}' claim; got {}",
|
||||
useAsUsername,
|
||||
claims.keySet());
|
||||
redirectToLogin(request, response, "no_username");
|
||||
return;
|
||||
}
|
||||
String username = usernameClaim.toString();
|
||||
User user = findOrCreateUser(username);
|
||||
if (user == null) {
|
||||
redirectToLogin(request, response, "registration_blocked");
|
||||
return;
|
||||
}
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
username,
|
||||
Map.of(
|
||||
"authType", AuthenticationType.OAUTH2.toString(),
|
||||
"role", user.getRolesAsString()));
|
||||
Cookie cookie = new Cookie("stirling_jwt", jwt);
|
||||
cookie.setPath("/");
|
||||
cookie.setHttpOnly(true);
|
||||
response.addCookie(cookie);
|
||||
response.sendRedirect(baseUrl(request) + "/");
|
||||
} catch (Exception e) {
|
||||
log.error("OAuth2 callback failed", e);
|
||||
redirectToLogin(request, response, "oauth2_failed");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isConfigured() {
|
||||
return oauth2Enabled
|
||||
&& issuer.isPresent()
|
||||
&& clientId.isPresent()
|
||||
&& clientSecret.isPresent();
|
||||
}
|
||||
|
||||
private Map<String, Object> exchangeCode(String code, String redirectUri) throws Exception {
|
||||
String form =
|
||||
"grant_type=authorization_code"
|
||||
+ "&code="
|
||||
+ enc(code)
|
||||
+ "&redirect_uri="
|
||||
+ enc(redirectUri)
|
||||
+ "&client_id="
|
||||
+ enc(clientId.get())
|
||||
+ "&client_secret="
|
||||
+ enc(clientSecret.get());
|
||||
HttpRequest req =
|
||||
HttpRequest.newBuilder(URI.create(issuer.get() + "/protocol/openid-connect/token"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(form))
|
||||
.build();
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) {
|
||||
throw new IllegalStateException(
|
||||
"Token endpoint returned " + res.statusCode() + ": " + res.body());
|
||||
}
|
||||
return parseJson(res.body());
|
||||
}
|
||||
|
||||
private Map<String, Object> fetchUserInfo(String accessToken) throws Exception {
|
||||
HttpRequest req =
|
||||
HttpRequest.newBuilder(
|
||||
URI.create(issuer.get() + "/protocol/openid-connect/userinfo"))
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.header("Accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
if (res.statusCode() != 200) {
|
||||
throw new IllegalStateException("Userinfo endpoint returned " + res.statusCode());
|
||||
}
|
||||
return parseJson(res.body());
|
||||
}
|
||||
|
||||
private User findOrCreateUser(String username) {
|
||||
Optional<User> existing = userService.findByUsernameIgnoreCase(username);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
if (!applicationProperties.getSecurity().getOauth2().getAutoCreateUser()) {
|
||||
log.warn("OAuth2 user '{}' not found and autoCreateUser is disabled", username);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
userService.saveUserCore(
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.authenticationType(AuthenticationType.OAUTH2)
|
||||
.ssoProvider(REG_ID)
|
||||
.team(teamService.getOrCreateDefaultTeam())
|
||||
.build());
|
||||
log.info("Auto-created OAuth2 user: {}", username);
|
||||
return userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to auto-create OAuth2 user '{}'", username, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> parseJson(String body) throws IOException {
|
||||
return mapper.readValue(body, Map.class);
|
||||
}
|
||||
|
||||
private String redirectUri(HttpServletRequest request) {
|
||||
return baseUrl(request) + "/login/oauth2/code/" + REG_ID;
|
||||
}
|
||||
|
||||
private String baseUrl(HttpServletRequest request) {
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
if (backendUrl != null && !backendUrl.isBlank()) {
|
||||
return backendUrl.replaceAll("/+$", "");
|
||||
}
|
||||
String scheme = request.getScheme();
|
||||
int port = request.getServerPort();
|
||||
String host = request.getServerName();
|
||||
boolean defaultPort =
|
||||
(scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443);
|
||||
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
|
||||
}
|
||||
|
||||
private void redirectToLogin(
|
||||
HttpServletRequest request, HttpServletResponse response, String reason)
|
||||
throws IOException {
|
||||
response.sendRedirect(baseUrl(request) + "/login?error=" + enc(reason));
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* OAuth2 / OIDC login initiation. Serves {@code GET /oauth2/authorization/{registrationId}} (the
|
||||
* Spring-compatible path the frontend and {@code testing/compose/validate-oauth-test.sh} expect) by
|
||||
* redirecting to the IdP authorization endpoint. The matching callback is handled by {@link
|
||||
* OAuth2CallbackServlet} (a servlet, because quarkus-undertow owns the {@code /login/*} prefix).
|
||||
* Spring Security's {@code oauth2Login()} DSL was removed in the migration and {@code quarkus-oidc}
|
||||
* is disabled (build-time gated), so the flow is implemented directly.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@Path("")
|
||||
public class OAuth2LoginController {
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.enabled", defaultValue = "false")
|
||||
boolean oauth2Enabled;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.issuer")
|
||||
Optional<String> issuer;
|
||||
|
||||
@ConfigProperty(name = "security.oauth2.client.keycloak.clientId")
|
||||
Optional<String> clientId;
|
||||
|
||||
@ConfigProperty(
|
||||
name = "security.oauth2.client.keycloak.scopes",
|
||||
defaultValue = "openid,profile,email")
|
||||
String scopes;
|
||||
|
||||
@Inject ApplicationProperties applicationProperties;
|
||||
|
||||
@GET
|
||||
@Path("/oauth2/authorization/{registrationId}")
|
||||
public Response authorize(
|
||||
@PathParam("registrationId") String registrationId, @Context UriInfo uriInfo) {
|
||||
if (!oauth2Enabled
|
||||
|| !"keycloak".equals(registrationId)
|
||||
|| issuer.isEmpty()
|
||||
|| clientId.isEmpty()) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity("OAuth2 login is not enabled")
|
||||
.build();
|
||||
}
|
||||
String redirectUri = baseUrl(uriInfo) + "/login/oauth2/code/" + registrationId;
|
||||
String authorizeUrl =
|
||||
issuer.get()
|
||||
+ "/protocol/openid-connect/auth?response_type=code"
|
||||
+ "&client_id="
|
||||
+ enc(clientId.get())
|
||||
+ "&redirect_uri="
|
||||
+ enc(redirectUri)
|
||||
+ "&scope="
|
||||
+ enc(scopes.replace(',', ' '))
|
||||
+ "&state="
|
||||
+ UUID.randomUUID();
|
||||
return Response.seeOther(URI.create(authorizeUrl)).build();
|
||||
}
|
||||
|
||||
private String baseUrl(UriInfo uriInfo) {
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
if (backendUrl != null && !backendUrl.isBlank()) {
|
||||
return backendUrl.replaceAll("/+$", "");
|
||||
}
|
||||
return uriInfo.getBaseUri().toString().replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private static String enc(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user