diff --git a/app/src/main/java/org/apache/roller/weblogger/business/OAuthManager.java b/app/src/main/java/org/apache/roller/weblogger/business/OAuthManager.java index 329b225b00..80657bbe99 100644 --- a/app/src/main/java/org/apache/roller/weblogger/business/OAuthManager.java +++ b/app/src/main/java/org/apache/roller/weblogger/business/OAuthManager.java @@ -88,10 +88,40 @@ OAuthAccessor getAccessor(OAuthMessage requestMessage) throws IOException, OAuthProblemException; /** - * Set the access token + * Set the access token + * + * @deprecated Records approval against the consumer key alone, without + * naming the request token being approved and without + * requiring that it is still pending. Use + * {@link #authorizeRequestToken(String, String, String)}, + * which does both in one statement. No longer called from + * Roller; retained for callers outside the project. */ + @Deprecated void markAsAuthorized(OAuthAccessor accessor, String userId) - throws OAuthException; + throws OAuthException; + + /** + * Record a user's approval of one pending request token. + * + *
The whole transition happens in a single conditional statement: the
+ * record is claimed only if it still matches the consumer key and the
+ * exact request token and has not yet been exchanged for an access token.
+ * A pending token already approved by the same user is accepted as an
+ * idempotent retry; a different user cannot claim it. The transition stays
+ * in one statement, without a read-then-write window.
+ *
+ * @param consumerKey key of the consumer the token was issued to
+ * @param requestToken the pending request token being approved
+ * @param userName the approving user
+ * @return true if this call performed the approval or confirmed the same
+ * user's prior approval; false if the record did not match, belongs
+ * to another user, or was already exchanged. Callers should not
+ * distinguish these cases to the client.
+ * @throws OAuthException on persistence failure
+ */
+ boolean authorizeRequestToken(String consumerKey, String requestToken, String userName)
+ throws OAuthException;
/**
* Generate a fresh request token and secret for a consumer.
diff --git a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAOAuthManagerImpl.java b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAOAuthManagerImpl.java
index baebb320d1..373711c94a 100644
--- a/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAOAuthManagerImpl.java
+++ b/app/src/main/java/org/apache/roller/weblogger/business/jpa/JPAOAuthManagerImpl.java
@@ -22,6 +22,7 @@
import java.sql.Timestamp;
import java.util.Date;
import java.util.UUID;
+import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import net.oauth.OAuthAccessor;
import net.oauth.OAuthConsumer;
@@ -139,6 +140,25 @@ public void markAsAuthorized(OAuthAccessor accessor, String userId)
}
}
+ @Override
+ public boolean authorizeRequestToken(String consumerKey, String requestToken, String userName)
+ throws OAuthException {
+ if (consumerKey == null || requestToken == null || userName == null) {
+ return false;
+ }
+ try {
+ Query q = strategy.getNamedUpdate("OAuthAccessorRecord.authorizeRequestToken");
+ q.setParameter(1, userName);
+ q.setParameter(2, new Timestamp(new Date().getTime()));
+ q.setParameter(3, consumerKey);
+ q.setParameter(4, requestToken);
+ return q.executeUpdate() == 1;
+
+ } catch (WebloggerException ex) {
+ throw new OAuthException("ERROR: authorizing request token", ex);
+ }
+ }
+
/**
* Generate a fresh request token and secret for a consumer.
* @throws OAuthException
diff --git a/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/SaltValidator.java b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/SaltValidator.java
new file mode 100644
index 0000000000..84cd0676d2
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/ui/core/filters/SaltValidator.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License. For additional information regarding
+ * copyright in this work, please see the NOTICE file in the top level
+ * directory of this distribution.
+ */
+
+package org.apache.roller.weblogger.ui.core.filters;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.roller.weblogger.ui.core.RollerSession;
+import org.apache.roller.weblogger.ui.rendering.util.cache.SaltCache;
+
+/**
+ * Shared validation for salts submitted by UI forms.
+ */
+public final class SaltValidator {
+
+ private static final String MULTIPART_FORM_DATA = "multipart/form-data";
+
+ public static final String VALIDATED_REQUEST_ATTRIBUTE =
+ SaltValidator.class.getName() + ".validated";
+
+ private SaltValidator() {
+ }
+
+ /**
+ * Validates and consumes the salt submitted as a request parameter.
+ *
+ * @param request current request
+ * @return true when no Roller session is present or the submitted salt is valid
+ */
+ public static boolean consumeSubmittedSalt(HttpServletRequest request) {
+ RollerSession rollerSession = RollerSession.getRollerSession(request);
+ if (rollerSession == null) {
+ return true;
+ }
+
+ String userId = rollerSession.getAuthenticatedUser() != null
+ ? rollerSession.getAuthenticatedUser().getId() : "";
+ String salt = request.getParameter("salt");
+ if (salt == null) {
+ return false;
+ }
+
+ SaltCache saltCache = SaltCache.getInstance();
+ synchronized (saltCache) {
+ if (!Objects.equals(saltCache.get(salt), userId)) {
+ return false;
+ }
+ saltCache.remove(salt);
+ }
+ return true;
+ }
+
+ /**
+ * Validates and consumes the submitted salt or rejects the request.
+ *
+ * @param request current request
+ * @throws ServletException when the submitted salt is missing or invalid
+ */
+ public static void requireSubmittedSalt(HttpServletRequest request)
+ throws ServletException {
+ if (!consumeSubmittedSalt(request)) {
+ throw new ServletException("Security Violation");
+ }
+ }
+
+ /**
+ * Returns true for a multipart form POST, which Struts parses after the
+ * servlet filters have run.
+ *
+ * @param request current request
+ * @return true for multipart/form-data POST requests
+ */
+ public static boolean isMultipartFormPost(HttpServletRequest request) {
+ if (!"POST".equalsIgnoreCase(request.getMethod())) {
+ return false;
+ }
+
+ String contentType = request.getContentType();
+ if (contentType == null) {
+ return false;
+ }
+
+ int parameterStart = contentType.indexOf(';');
+ String mediaType = parameterStart >= 0
+ ? contentType.substring(0, parameterStart) : contentType;
+ return MULTIPART_FORM_DATA.equals(mediaType.trim().toLowerCase(Locale.ENGLISH));
+ }
+}
diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/oauth/AuthorizationServlet.java b/app/src/main/java/org/apache/roller/weblogger/webservices/oauth/AuthorizationServlet.java
index 7f4787642b..cc54c4702f 100644
--- a/app/src/main/java/org/apache/roller/weblogger/webservices/oauth/AuthorizationServlet.java
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/oauth/AuthorizationServlet.java
@@ -28,11 +28,16 @@
import net.oauth.OAuth;
import net.oauth.OAuthAccessor;
import net.oauth.OAuthMessage;
+import net.oauth.OAuthProblemException;
import net.oauth.server.OAuthServlet;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.business.OAuthManager;
import org.apache.roller.weblogger.business.WebloggerFactory;
+import org.apache.roller.weblogger.pojos.User;
+import org.apache.roller.weblogger.ui.core.RollerSession;
+import org.apache.roller.weblogger.ui.core.filters.SaltValidator;
+import org.springframework.security.web.savedrequest.SimpleSavedRequest;
/**
* Authorization request handler.
@@ -42,7 +47,14 @@
*/
public class AuthorizationServlet extends HttpServlet {
protected static final Log log = LogFactory.getFactory().getInstance(AuthorizationServlet.class);
-
+
+ /**
+ * One response for every refusal, so the endpoint reveals nothing about
+ * tokens the caller does not hold.
+ */
+ private static final String PERMISSION_DENIED = "permission_denied";
+
+
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
@@ -53,13 +65,30 @@ public void doGet(HttpServletRequest request, HttpServletResponse response)
OAuthManager omgr = WebloggerFactory.getWeblogger().getOAuthManager();
OAuthAccessor accessor = omgr.getAccessor(requestMessage);
+ if (accessor == null || accessor.consumer == null
+ || accessor.requestToken == null) {
+ denyPermission(response);
+ return;
+ }
+
if (Boolean.TRUE.equals(accessor.getProperty("authorized"))) {
// already authorized send the user back
returnToConsumer(request, response, accessor);
} else {
+ User user = getAuthenticatedUser(request);
+ if (user == null) {
+ sendToLogin(request, response, accessor);
+ return;
+ }
+ if (!Boolean.TRUE.equals(user.getEnabled())) {
+ denyPermission(response);
+ return;
+ }
sendToAuthorizePage(request, response, accessor);
}
-
+
+ } catch (OAuthProblemException e) {
+ denyPermission(response);
} catch (Exception e){
handleException(e, request, response, true);
}
@@ -71,40 +100,120 @@ public void doPost(HttpServletRequest request, HttpServletResponse response)
try{
OAuthMessage requestMessage = OAuthServlet.getMessage(request, null);
-
+
OAuthManager omgr = WebloggerFactory.getWeblogger().getOAuthManager();
OAuthAccessor accessor = omgr.getAccessor(requestMessage);
+ if (accessor == null || accessor.consumer == null || accessor.requestToken == null) {
+ denyPermission(response);
+ return;
+ }
- String userId = request.getParameter("userId");
- if (userId == null) {
- userId = request.getParameter("xoauth_requestor_id");
+ // The approving identity comes from the browser session, consistent
+ // with the rest of the UI. Without a session there is nobody to
+ // approve on behalf of, so send the caller through the login flow.
+ User user = getAuthenticatedUser(request);
+ if (user == null) {
+ sendToLogin(request, response, accessor);
+ return;
}
-
- if (userId == null) {
- // no user associted with the key, must be site-wide key,
- // so get user to login and do the authorization process
- sendToAuthorizePage(request, response, accessor);
-
- } else {
+ if (!Boolean.TRUE.equals(user.getEnabled())) {
+ denyPermission(response);
+ return;
+ }
+ String userId = user.getUserName();
- // if consumer key is for specific user, check username match
- String consumerUserId = (String)accessor.consumer.getProperty("userId");
- if (consumerUserId != null && !userId.equals(consumerUserId)) {
- throw new ServletException("ERROR: invalid or unspecified userId");
- }
+ // A consumer key bound to one user may only be approved by that
+ // user. A site-wide key has no bound user and is approved as
+ // whoever is logged in.
+ String consumerUserId = (String)accessor.consumer.getProperty("userId");
+ if (consumerUserId != null && !consumerUserId.equals(userId)) {
+ denyPermission(response);
+ return;
+ }
- // set userId in accessor and mark it as authorized
- omgr.markAsAuthorized(accessor, userId);
- WebloggerFactory.getWeblogger().flush();
+ // Older clients still post the identity; accept it only when it
+ // agrees with the session.
+ String submittedUserId = request.getParameter("userId");
+ if (submittedUserId == null) {
+ submittedUserId = request.getParameter("xoauth_requestor_id");
}
-
+ if (submittedUserId != null && !submittedUserId.equals(userId)) {
+ denyPermission(response);
+ return;
+ }
+
+ // A stale or already-used consent form is safe to show again. The
+ // forward passes through LoadSaltFilter and receives a fresh salt.
+ if (!hasValidSalt(request)) {
+ sendToAuthorizePage(request, response, accessor);
+ return;
+ }
+
+ // Claim the pending request token in one conditional statement, so
+ // approval is one-shot. A token that is missing, belongs to another
+ // consumer, or has already been approved or exchanged all produce
+ // the same answer here and the same response below.
+ if (!omgr.authorizeRequestToken(
+ accessor.consumer.consumerKey, accessor.requestToken, userId)) {
+ denyPermission(response);
+ return;
+ }
+ WebloggerFactory.getWeblogger().flush();
+
+ accessor.setProperty("userId", userId);
+ accessor.setProperty("authorized", Boolean.TRUE);
+
returnToConsumer(request, response, accessor);
-
+
+ } catch (OAuthProblemException e) {
+ denyPermission(response);
} catch (Exception e){
handleException(e, request, response, true);
}
}
-
+
+ /**
+ * The Roller user behind this request's session, or null if there is none.
+ */
+ private User getAuthenticatedUser(HttpServletRequest request) {
+ RollerSession rollerSession = RollerSession.getRollerSession(request);
+ return rollerSession == null ? null : rollerSession.getAuthenticatedUser();
+ }
+
+ boolean hasValidSalt(HttpServletRequest request) {
+ return SaltValidator.consumeSubmittedSalt(request);
+ }
+
+ private void sendToLogin(HttpServletRequest request,
+ HttpServletResponse response, OAuthAccessor accessor) throws IOException {
+ String resume = OAuth.addParameters(request.getRequestURL().toString(),
+ "oauth_token", accessor.requestToken);
+ String callback = request.getParameter("oauth_callback");
+ if (callback != null) {
+ resume = OAuth.addParameters(resume, "oauth_callback", callback);
+ }
+
+ SimpleSavedRequest savedRequest = new SimpleSavedRequest(resume);
+ savedRequest.setMethod("GET");
+ request.getSession(true).setAttribute(
+ "SPRING_SECURITY_SAVED_REQUEST", savedRequest);
+ response.sendRedirect(request.getContextPath() + "/roller-ui/login.rol");
+ }
+
+ /**
+ * Refuse the approval, in the OAuth problem-reporting form and with the
+ * same body for every reason. Written directly rather than thrown so the
+ * response does not vary with how the library happens to render a given
+ * exception.
+ */
+ private void denyPermission(HttpServletResponse response) throws IOException {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("text/plain");
+ try (PrintWriter out = response.getWriter()) {
+ out.println("oauth_problem=" + PERMISSION_DENIED);
+ }
+ }
+
private void sendToAuthorizePage(HttpServletRequest request,
HttpServletResponse response, OAuthAccessor accessor)
throws IOException, ServletException{
diff --git a/app/src/main/resources/org/apache/roller/weblogger/pojos/OAuthAccessorRecord.orm.xml b/app/src/main/resources/org/apache/roller/weblogger/pojos/OAuthAccessorRecord.orm.xml
index 3c710aff7e..59ba4a1cc6 100644
--- a/app/src/main/resources/org/apache/roller/weblogger/pojos/OAuthAccessorRecord.orm.xml
+++ b/app/src/main/resources/org/apache/roller/weblogger/pojos/OAuthAccessorRecord.orm.xml
@@ -16,6 +16,9 @@