-
Notifications
You must be signed in to change notification settings - Fork 1.3k
support custom consent handler #740
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
Comments
Hi @chenzhenjia, welcome to the project! I notice that you have opened a PR already. Please keep in mind it's always best to reach out and have a conversation prior to working on an issue, as there may be alternatives, feedback, and other conversation that needs to happen first. Can you please describe more about your use case? Also, have you seen the custom consent sample in this repository? Can you explain what you're trying to accomplish that the sample does not demonstrate? |
My front end is developed with react and can only use json request, so I need to return json content const data = new URLSearchParams();
data.append("password", "xxx");
data.append("username", "xxx");
fetch("/login/password", {
method: 'post',
body: data,
})
.then((data)=>{
let json = data.json();
// This page is the react page not the page returned by the servlet
history.push(`/consent?client_id=${json.client_id}&state=${json.state}&scope=${json.scope}`);
}); my constent handler if (response.isCommitted()) {
return;
}
String clientId = authorizationCodeRequestAuthenticationResult.getClientId();
String state = authorizationCodeRequestAuthenticationResult.getState();
Map<String, Object> result = new HashMap<>();
result.put("consent_required", authorizationCodeRequestAuthenticationResult.isConsentRequired());
result.put("consent", authorizationCodeRequestAuthenticationResult.isConsent());
result.put(OAuth2ParameterNames.CLIENT_ID, clientId);
result.put(OAuth2ParameterNames.STATE, state);
result.put(OAuth2ParameterNames.SCOPE, authorizationCodeRequestAuthentication.getScopes());
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(result));
response.getWriter().flush(); |
@chenzhenjia thanks for clarifying your use case. I'm curious why you are attempting to render a consent screen using react? The reason I ask is that the |
@sjohnr Because our current project is already developed using react, and loginProcessingUrl My CustomOAuth2AuthenticationEntryPoint public class CustomOAuth2AuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {
public CustomAuthenticationEntryPoint(String loginPageUrl) {
super(loginPageUrl);
}
@Override
protected String buildRedirectUrlToLoginPage(HttpServletRequest request,HttpServletResponse response, AuthenticationException authException) {
String httpUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(httpUrl);
// request.getQueryString() Will bring client_id, scope, redirect_uri and other parameters
return uriComponentsBuilder.query(Optional.ofNullable(request.getQueryString()).orElse("")).toUriString();
}
} My CustomAuthenticationFailureHandler public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
public final Map<Class<? extends AuthenticationException>, String> exceptionTypeMapping;
public CustomAuthenticationFailureHandler() {
this.exceptionTypeMapping = new HashMap<>();
this.exceptionTypeMapping.put(BadCredentialsException.class, "bad_credentials");
this.exceptionTypeMapping.put(AccountExpiredException.class, "account_expired");
this.exceptionTypeMapping.put(DisabledException.class, "account_disabled");
this.exceptionTypeMapping.put(UsernameNotFoundException.class, "username_not_found");
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
HttpStatus status = HttpStatus.UNAUTHORIZED;
response.setStatus(status.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
String type = Optional.ofNullable(exceptionTypeMapping.get(exception.getClass()))
.orElse("authentication_failed");
response.getWriter().write(
"{\"" + OAuth2ParameterNames.ERROR + ":\"" + type + "\"," +
OAuth2ParameterNames.ERROR_DESCRIPTION + "\":\""
+ exception.getMessage() + "\"}");
response.flushBuffer();
}
} My CustomAuthenticationSuccessHandler public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
clearAuthenticationAttributes(request);
if (response.isCommitted()) {
return;
}
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
protected final void clearAuthenticationAttributes(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}
} |
Hi again @chenzhenjia, your description helps clarify your situation for me, thanks! Regardless of whether you can or cannot render the frontend via the server, it's worth noting that the authorization server is responsible for deciding when consent is required, so the javascript application cannot assume that consent is always required. Looking over your provided sample javascript code, it looks as though the frontend assumes that the result of logging in is always a consent screen, which doesn't seem quite right. Combined with your sample Regarding this request, consent rendering should be customizable but I'm still stuck on whether it makes sense to customize using a "handler", as your PR does. I feel like it's redundant with the existing customization option of a However there are a number of other configurations required to make this work, and as I said it seems challenging (though hopefully possible). Do you need help working through these steps, or am I off base somehow still? I'd appreciate it if you could try this suggestion and see if it makes the custom consent handler unnecessary, which I believe to be the case. |
@sjohnr The example I gave is the previous json processing, and the current one with oauth2 is like this oauth2 OAuth2AuthenticationEntryPoint: public class OAuth2AuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {
private static final StringKeyGenerator DEFAULT_STATE_GENERATOR =
new Base64StringKeyGenerator(Base64.getUrlEncoder());
public OAuth2UrlAuthenticationEntryPoint(String loginFormUrl) {
super(loginFormUrl);
}
@Override
protected String buildRedirectUrlToLoginPage(HttpServletRequest request,
HttpServletResponse response, AuthenticationException authException) {
String httpUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(httpUrl)
.query(Optional.ofNullable(request.getQueryString()).orElse(""));
HttpSession session = request.getSession(false);
if (session != null) {
// add login_state query parma
String loginState = DEFAULT_STATE_GENERATOR.generateKey();
uriComponentsBuilder.queryParam("login_state", loginState);
session.setAttribute(OAuth2Attributes.LOGIN_STATE, loginState);
}
return uriComponentsBuilder
.toUriString();
}
} update login AuthenticationSuccessHandler: public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private PortResolver portResolver = new PortResolverImpl();
public void setPortResolver(PortResolver portResolver) {
this.portResolver = portResolver;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
clearAuthenticationAttributes(request);
if (response.isCommitted()) {
return;
}
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
HttpSession session = request.getSession(false);
if (session != null) {
Boolean oauth2Login = Optional.ofNullable(session.getAttribute(OAuth2Attributes.LOGIN_STATE))
.filter(String.class::isInstance)
.map(Object::toString)
.map(s -> {
String loginState = request.getParameter("loginState");
return s.equals(loginState);
})
.orElse(false);
// If it is oauth2, send authorize_url
if (oauth2Login) {
response.setStatus(HttpServletResponse.SC_OK);
int serverPort = portResolver.getServerPort(request);
String scheme = request.getScheme();
RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder();
urlBuilder.setScheme(scheme);
urlBuilder.setServerName(request.getServerName());
urlBuilder.setPort(serverPort);
urlBuilder.setContextPath(request.getContextPath());
urlBuilder.setPathInfo("/oauth2/authorize");
response.getWriter().write("{\"auth_type\":\"oauth2\",\"authorize_url\":\"" + urlBuilder.getUrl() + "\"}");
response.flushBuffer();
return;
}
}
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
protected final void clearAuthenticationAttributes(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);
}
}
} add OAuth2AuthenticationSuccessHandler public class OAuth2uthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
(OAuth2AuthorizationCodeRequestAuthenticationToken) authentication;
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(authorizationCodeRequestAuthentication.getRedirectUri())
.queryParam(OAuth2ParameterNames.CODE,
authorizationCodeRequestAuthentication.getAuthorizationCode().getTokenValue());
if (StringUtils.hasText(authorizationCodeRequestAuthentication.getState())) {
uriBuilder.queryParam(OAuth2ParameterNames.STATE, authorizationCodeRequestAuthentication.getState());
}
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"" + OAuth2ParameterNames.REDIRECT_URI + "\":\"" + uriBuilder.toUriString() + "\"}");
}
} oauth2 configuration @Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer
.authorizationEndpoint(authorizationEndpoint -> {
authorizationEndpoint.consentHandler(new JsonOAuth2ConsentHandler())
.authorizationResponseHandler(new OAuth2AuthenticationSuccessHandler())
.errorResponseHandler(new OAuth2AuthenticationFailureHandler());
})
;
RequestMatcher oauth2EndpointsMatcher = authorizationServerConfigurer
.getEndpointsMatcher();
return http
.requestMatchers(requestMatchers ->
requestMatchers.requestMatchers(oauth2EndpointsMatcher)
)
.authorizeRequests(authorizeRequests -> authorizeRequests
.anyRequest().authenticated()
)
.sessionManagement(
sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.csrf(AbstractHttpConfigurer::disable)
.formLogin()
.and()
.httpBasic()
.disable()
.exceptionHandling(exceptionHandling ->
exceptionHandling
.defaultAuthenticationEntryPointFor(
new OAuth2AuthenticationEntryPoint("/login"),
AnyRequestMatcher.INSTANCE)
)
.apply(authorizationServerConfigurer)
.and()
.build();
} const data = new URLSearchParams();
data.append("password", "xxx");
data.append("username", "xxx");
fetch("/login/password", {
method: 'post',
body: data,
})
.then((data)=>{
let json = data.json();
if(json.auth_type==='oauth2'){
return fetch(json.authorize_url, {
method: 'post'
}).then(data1=>data1.json());
}
return json;
})
.then(json=>{
// consent handler
if(json.consent_required){
// this consent page is react router page not servlet response page
history.push(`/consent?client_id=${json.client_id}&state=${json.state}&scope=${json.scope}`);
}
// oauth2 success handler
if(json.redirect_uri){
// Consent is not required
window.location.href=json.redirect_uri
}
// default login success handler
// xxxx
})
; use google translate,please understand |
Hi @chenzhenjia.
I apologize, I did not realize that. I will try and be clear and concise! Instead of a "consent handler" you can give this a try: Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer
.authorizationEndpoint(authorizationEndpoint -> {
authorizationEndpoint.consentPage("/consent")
.authorizationResponseHandler(new OAuth2AuthenticationSuccessHandler())
.errorResponseHandler(new OAuth2AuthenticationFailureHandler());
});
...
} @RestController
public class ConsentController {
@GetMapping("/consent")
public Map<String, String> jsonConsent(
@RequestParam("client_id") String clientId,
@RequestParam("state") String state,
@RequestParam("scope") String scope) {
...
}
} |
Hi @sjohnr Now consent is redirection this.redirectStrategy.sendRedirect(request, response, redirectUri); Request /oauth2/authorize redirect to /consent client is inconvenient to handle, now need to return json data instead of redirect Map<String, Object> result = new HashMap<>();
result.put("consent_required", authorizationCodeRequestAuthenticationResult.isConsentRequired());
result.put("consent", authorizationCodeRequestAuthenticationResult.isConsent());
result.put(OAuth2ParameterNames.CLIENT_ID, clientId);
result.put(OAuth2ParameterNames.STATE, state);
result.put(OAuth2ParameterNames.SCOPE, authorizationCodeRequestAuthentication.getScopes());
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(result)); javascript login success consent required handler if(json.consent_required){
// this consent page is react router page not servlet response page
history.push(`/consent?client_id=${json.client_id}&state=${json.state}&scope=${json.scope}`);
} java /oauth2/consent rest controller @GetMapping(value = "/oauth2/consent", consumes = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public ResponseEntity<?> consent(Principal principal,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state) {
// xxxx
return ResponseEntity.ok(model);
} javascript consent router page,This page can only be jumped through react routing let client_id = // from query params
let state = // from query params
let scope = // from query params
fetch(`/oauth2/consent?client_id=${client_id}&state=${state}&scope=${scope}`)
.then(data=>{
// react set state and render view
})
// User submits form
let handlerSubmit=()=>{
const data = new URLSearchParams();
data.append("client_id", "client_id");
data.append("state", "state");
data.append("scope", scopes);
fetch(`/oauth2/authorize`,{
method:"post",
data:data
})
.then(data=>{
// oauth2 success handler
if(json.redirect_uri){
// Consent is not required
window.location.href=json.redirect_uri
}
})
} |
I would like to confirm whether it is possible to handle a 302 redirect to a json consent endpoint, even if it is inconvenient. |
@chenzhenjia Regarding your comment:
Regarding...
It is extremely difficult to understand such a complicated (and unique) flow with cut-copy-paste code. Please put together a minimal sample and document the gaps in the sample. This way we can see exactly what extension points are missing (if any). I'm not sure if you saw gh-297? We've collaborated with front-end developers that are using Spring Authorization Server successfully so reading over that issue may help. @sjohnr Can you also please forward the samples for reference. As @sjohnr mentioned:
I'm going to close the PR as we're not looking to add a new API at this point. The next step is to review the minimal sample you need to provide so we can better understand your application requirements and it will allow us to determine if a new API is needed or if there is an alternative solution with an existing API. Please ensure the sample has the absolute minimal amount of code necessary to demonstrate the flows and it's well documented so we can be efficient in our analysis. |
@chenzhenjia here are some sample projects on a branch for you to review: For context, please see the Single Page Apps webinar Joe and I presented. The audio is in English but you can follow the video to see the code samples in action. |
Hello everyone, |
@jgrandja Here is my sample code chenzhenjia@745e6e7 @sjohnr The example you gave is to authorize the client, not the server |
@chenzhenjia Consent page must be controlled securely by Auth Server, |
@NotFound403 In which step will it be tampered with |
@chenzhenjia its an Open Redirect ,not be validated. |
@NotFound403 consent is the react routing page, instead of using localtion.href, it uses react router. Localtion.href will only be used if the authorization is successful. redirect_uri is returned to the client after verification by the server, so it can be trusted. |
@chenzhenjia Thanks for the sample. This was very helpful in determining your implementation strategy. I'll provide a few points on my findings based on your authorization server configuration: OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint
.consentHandler(new JsonOAuth2ConsentHandler())
.authorizationResponseHandler(new
OAuth2JsonAuthenticationSuccessHandler())
.errorResponseHandler(new OAuth2JsonAuthenticationFailureHandler())
); From my understanding, you're mainly looking to address the consent flow via The Here are some main references to refer to:
Even if you're able to get it working without redirects, you may encounter unexpected behaviour or potentially expose yourself to a vulnerability. The main point I want to relay here is that the protocol flow deviates away from the spec and is not something we would support. Our main goal is to implement to spec and provide extension points that applications can customize against. Moving to the @RestController
public class AuthorizationConsentController {
private final RegisteredClientRepository registeredClientRepository;
private final OAuth2AuthorizationConsentService authorizationConsentService;
public AuthorizationConsentController(RegisteredClientRepository registeredClientRepository,
OAuth2AuthorizationConsentService authorizationConsentService) {
this.registeredClientRepository = registeredClientRepository;
this.authorizationConsentService = authorizationConsentService;
}
@GetMapping(value = "/oauth2/consent")
public Map<String, Object> consent(Principal principal,
@RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
@RequestParam(OAuth2ParameterNames.SCOPE) String scope,
@RequestParam(OAuth2ParameterNames.STATE) String state) {
// Remove scopes that were already approved
Set<String> scopesToApprove = new HashSet<>();
Set<String> previouslyApprovedScopes = new HashSet<>();
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
OAuth2AuthorizationConsent currentAuthorizationConsent =
this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
Set<String> authorizedScopes;
if (currentAuthorizationConsent != null) {
authorizedScopes = currentAuthorizationConsent.getScopes();
} else {
authorizedScopes = Collections.emptySet();
}
for (String requestedScope : StringUtils.delimitedListToStringArray(scope, " ")) {
if (authorizedScopes.contains(requestedScope)) {
previouslyApprovedScopes.add(requestedScope);
} else {
scopesToApprove.add(requestedScope);
}
}
Map<String, Object> model = new HashMap<>();
model.put("clientId", clientId);
model.put("state", state);
model.put("scopes", withDescription(scopesToApprove));
model.put("previouslyApprovedScopes", withDescription(previouslyApprovedScopes));
model.put("principalName", principal.getName());
return model;
}
...
} The json response returned is: {
"clientId": "messaging-client",
"previouslyApprovedScopes": [],
"principalName": "user1",
"state": "Ug37PXnTJg1J_JrI_SeHcpPMatpfwJ1kQErEr-ln0YU=",
"scopes": [
{
"scope": "message.write",
"description": "This application will be able to add new messages. It will also be able to edit and delete existing messages."
},
{
"scope": "message.read",
"description": "This application will be able to read your message."
}
]
} As demonstrated with the above code sample, the configuration option |
if the authorization-server is behind of a gateway, the 302 redirect can not go to a correct uri. |
Hello, can you support custom sendAuthorizationConsent? response json or redirect
The text was updated successfully, but these errors were encountered: