Tokenized REST API POST handler
POST requests typically modify server state, and are often vulnerable to XSRF attacks. For example, a site admin could be fooled into clicking a button on a rogue website which causes his browser to run REST commands against the server. To prevent against this, REST POST requests should include a token which is first retrieved by making a GET request to the same URL. This token allows us to verify that the user visited the Gerrit site and helps protect against XSRF. An example use-case of this new API: token = $(curl --anyauth -u [user] http://review/a/rest-api | tail -n 1) curl --anyauth -u [user] -d $token http://review/a/rest-api Signed-off-by: Brad Larson <bklarson@gmail.com> Change-Id: I18f3ad2b6be4df2e5a6fa3262de5a2f4601fccea
This commit is contained in:

committed by
Shawn O. Pearce

parent
513e86debe
commit
3a6f077932
@@ -2431,6 +2431,7 @@ Sample `etc/secure.config`:
|
|||||||
----
|
----
|
||||||
[auth]
|
[auth]
|
||||||
registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
|
registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
|
||||||
|
restTokenPrivateKey = 7e40PzCjlUKOnXATvcBNXH6oyiu+r0dFk2c=
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
username = webuser
|
username = webuser
|
||||||
|
@@ -42,6 +42,73 @@ public class RestApi {
|
|||||||
*/
|
*/
|
||||||
private static final String JSON_MAGIC = ")]}'\n";
|
private static final String JSON_MAGIC = ")]}'\n";
|
||||||
|
|
||||||
|
private class MyRequestCallback<T extends JavaScriptObject> implements
|
||||||
|
RequestCallback {
|
||||||
|
private final boolean wasGet;
|
||||||
|
private final AsyncCallback<T> cb;
|
||||||
|
|
||||||
|
public MyRequestCallback(boolean wasGet, AsyncCallback<T> cb) {
|
||||||
|
this.wasGet = wasGet;
|
||||||
|
this.cb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResponseReceived(Request req, Response res) {
|
||||||
|
int status = res.getStatusCode();
|
||||||
|
if (status != 200) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
if ((400 <= status && status < 600) && isTextBody(res)) {
|
||||||
|
cb.onFailure(new RemoteJsonException(res.getText(), status, null));
|
||||||
|
} else {
|
||||||
|
cb.onFailure(new StatusCodeException(status, res.getStatusText()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isJsonBody(res)) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String json = res.getText();
|
||||||
|
if (!json.startsWith(JSON_MAGIC)) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json = json.substring(JSON_MAGIC.length());
|
||||||
|
|
||||||
|
if (wasGet && json.startsWith("{\"_authkey\":")) {
|
||||||
|
RestApi.this.resendPost(cb, json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
T data;
|
||||||
|
try {
|
||||||
|
// javac generics bug
|
||||||
|
data = Natives.<T> parseJSON(json);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb.onSuccess(data);
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(Request req, Throwable err) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
if (err.getMessage().contains("XmlHttpRequest.status")) {
|
||||||
|
cb.onFailure(new ServerUnavailableException());
|
||||||
|
} else {
|
||||||
|
cb.onFailure(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private StringBuilder url;
|
private StringBuilder url;
|
||||||
private boolean hasQueryParams;
|
private boolean hasQueryParams;
|
||||||
|
|
||||||
@@ -101,53 +168,7 @@ public class RestApi {
|
|||||||
public <T extends JavaScriptObject> void send(final AsyncCallback<T> cb) {
|
public <T extends JavaScriptObject> void send(final AsyncCallback<T> cb) {
|
||||||
RequestBuilder req = new RequestBuilder(RequestBuilder.GET, url.toString());
|
RequestBuilder req = new RequestBuilder(RequestBuilder.GET, url.toString());
|
||||||
req.setHeader("Accept", JsonConstants.JSON_TYPE);
|
req.setHeader("Accept", JsonConstants.JSON_TYPE);
|
||||||
req.setCallback(new RequestCallback() {
|
req.setCallback(new MyRequestCallback<T>(true, cb));
|
||||||
@Override
|
|
||||||
public void onResponseReceived(Request req, Response res) {
|
|
||||||
RpcStatus.INSTANCE.onRpcComplete();
|
|
||||||
int status = res.getStatusCode();
|
|
||||||
if (status != 200) {
|
|
||||||
if ((400 <= status && status < 500) && isTextBody(res)) {
|
|
||||||
cb.onFailure(new RemoteJsonException(res.getText(), status, null));
|
|
||||||
} else {
|
|
||||||
cb.onFailure(new StatusCodeException(status, res.getStatusText()));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isJsonBody(res)) {
|
|
||||||
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String json = res.getText();
|
|
||||||
if (!json.startsWith(JSON_MAGIC)) {
|
|
||||||
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
T data;
|
|
||||||
try {
|
|
||||||
// javac generics bug
|
|
||||||
data = Natives.<T>parseJSON(json.substring(JSON_MAGIC.length()));
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
cb.onFailure(new RemoteJsonException("Invalid JSON"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb.onSuccess(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Request req, Throwable err) {
|
|
||||||
RpcStatus.INSTANCE.onRpcComplete();
|
|
||||||
if (err.getMessage().contains("XmlHttpRequest.status")) {
|
|
||||||
cb.onFailure(new ServerUnavailableException());
|
|
||||||
} else {
|
|
||||||
cb.onFailure(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
RpcStatus.INSTANCE.onRpcStart();
|
RpcStatus.INSTANCE.onRpcStart();
|
||||||
req.send();
|
req.send();
|
||||||
@@ -157,6 +178,21 @@ public class RestApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T extends JavaScriptObject> void resendPost(
|
||||||
|
final AsyncCallback<T> cb, String token) {
|
||||||
|
RequestBuilder req = new RequestBuilder(RequestBuilder.POST, url.toString());
|
||||||
|
req.setHeader("Accept", JsonConstants.JSON_TYPE);
|
||||||
|
req.setHeader("Content-Type", JsonConstants.JSON_TYPE);
|
||||||
|
req.setRequestData(token);
|
||||||
|
req.setCallback(new MyRequestCallback<T>(false, cb));
|
||||||
|
try {
|
||||||
|
req.send();
|
||||||
|
} catch (RequestException e) {
|
||||||
|
RpcStatus.INSTANCE.onRpcComplete();
|
||||||
|
cb.onFailure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isJsonBody(Response res) {
|
private static boolean isJsonBody(Response res) {
|
||||||
return isContentType(res, JsonConstants.JSON_TYPE);
|
return isContentType(res, JsonConstants.JSON_TYPE);
|
||||||
}
|
}
|
||||||
|
@@ -14,6 +14,9 @@
|
|||||||
|
|
||||||
package com.google.gerrit.httpd;
|
package com.google.gerrit.httpd;
|
||||||
|
|
||||||
|
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
|
||||||
|
import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
import com.google.common.base.Objects;
|
import com.google.common.base.Objects;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
import com.google.gerrit.extensions.annotations.RequiresCapability;
|
import com.google.gerrit.extensions.annotations.RequiresCapability;
|
||||||
@@ -38,6 +41,7 @@ import java.util.Collections;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import javax.servlet.ServletException;
|
import javax.servlet.ServletException;
|
||||||
import javax.servlet.http.HttpServlet;
|
import javax.servlet.http.HttpServlet;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@@ -80,18 +84,20 @@ public abstract class RestApiServlet extends HttpServlet {
|
|||||||
@Override
|
@Override
|
||||||
protected void service(HttpServletRequest req, HttpServletResponse res)
|
protected void service(HttpServletRequest req, HttpServletResponse res)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
noCache(res);
|
res.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
|
||||||
|
res.setHeader("Pragma", "no-cache");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
||||||
|
res.setHeader("Content-Disposition", "attachment");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checkRequiresCapability();
|
checkRequiresCapability();
|
||||||
super.service(req, res);
|
super.service(req, res);
|
||||||
} catch (RequireCapabilityException err) {
|
} catch (RequireCapabilityException err) {
|
||||||
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
sendError(res, SC_FORBIDDEN, err.getMessage());
|
||||||
noCache(res);
|
|
||||||
sendText(req, res, err.getMessage());
|
|
||||||
} catch (Error err) {
|
} catch (Error err) {
|
||||||
handleError(err, req, res);
|
handleException(err, req, res);
|
||||||
} catch (RuntimeException err) {
|
} catch (RuntimeException err) {
|
||||||
handleError(err, req, res);
|
handleException(err, req, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,16 +120,8 @@ public abstract class RestApiServlet extends HttpServlet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void noCache(HttpServletResponse res) {
|
private static void handleException(Throwable err, HttpServletRequest req,
|
||||||
res.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
|
HttpServletResponse res) throws IOException {
|
||||||
res.setHeader("Pragma", "no-cache");
|
|
||||||
res.setHeader("Cache-Control", "no-cache, must-revalidate");
|
|
||||||
res.setHeader("Content-Disposition", "attachment");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void handleError(
|
|
||||||
Throwable err, HttpServletRequest req, HttpServletResponse res)
|
|
||||||
throws IOException {
|
|
||||||
String uri = req.getRequestURI();
|
String uri = req.getRequestURI();
|
||||||
if (!Strings.isNullOrEmpty(req.getQueryString())) {
|
if (!Strings.isNullOrEmpty(req.getQueryString())) {
|
||||||
uri += "?" + req.getQueryString();
|
uri += "?" + req.getQueryString();
|
||||||
@@ -132,12 +130,16 @@ public abstract class RestApiServlet extends HttpServlet {
|
|||||||
|
|
||||||
if (!res.isCommitted()) {
|
if (!res.isCommitted()) {
|
||||||
res.reset();
|
res.reset();
|
||||||
res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
|
sendError(res, SC_INTERNAL_SERVER_ERROR, "Internal Server Error");
|
||||||
noCache(res);
|
|
||||||
sendText(req, res, "Internal Server Error");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static void sendError(HttpServletResponse res,
|
||||||
|
int statusCode, String msg) throws IOException {
|
||||||
|
res.setStatus(statusCode);
|
||||||
|
sendText(null, res, msg);
|
||||||
|
}
|
||||||
|
|
||||||
protected static boolean acceptsJson(HttpServletRequest req) {
|
protected static boolean acceptsJson(HttpServletRequest req) {
|
||||||
String accept = req.getHeader("Accept");
|
String accept = req.getHeader("Accept");
|
||||||
if (accept == null) {
|
if (accept == null) {
|
||||||
@@ -155,16 +157,17 @@ public abstract class RestApiServlet extends HttpServlet {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static void sendText(HttpServletRequest req,
|
protected static void sendText(@Nullable HttpServletRequest req,
|
||||||
HttpServletResponse res, String data) throws IOException {
|
HttpServletResponse res, String data) throws IOException {
|
||||||
res.setContentType("text/plain");
|
res.setContentType("text/plain");
|
||||||
res.setCharacterEncoding("UTF-8");
|
res.setCharacterEncoding("UTF-8");
|
||||||
send(req, res, data.getBytes("UTF-8"));
|
send(req, res, data.getBytes("UTF-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static void send(HttpServletRequest req, HttpServletResponse res,
|
protected static void send(@Nullable HttpServletRequest req,
|
||||||
byte[] data) throws IOException {
|
HttpServletResponse res, byte[] data) throws IOException {
|
||||||
if (data.length > 256 && RPCServletUtils.acceptsGzipEncoding(req)) {
|
if (data.length > 256 && req != null
|
||||||
|
&& RPCServletUtils.acceptsGzipEncoding(req)) {
|
||||||
res.setHeader("Content-Encoding", "gzip");
|
res.setHeader("Content-Encoding", "gzip");
|
||||||
data = HtmlDomUtil.compress(data);
|
data = HtmlDomUtil.compress(data);
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright (C) 2012 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed 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.
|
||||||
|
|
||||||
|
package com.google.gerrit.httpd;
|
||||||
|
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.server.mail.RegisterNewEmailSender;
|
||||||
|
|
||||||
|
/** Verifies the token sent by {@link RegisterNewEmailSender}. */
|
||||||
|
public interface RestTokenVerifier {
|
||||||
|
/**
|
||||||
|
* Construct a token to verify a REST PUT request.
|
||||||
|
*
|
||||||
|
* @param user the caller that wants to make a PUT request
|
||||||
|
* @param url the URL being requested
|
||||||
|
* @return an unforgeable string to send to the user as the body of a GET
|
||||||
|
* request. Presenting the string in a follow-up POST request provides
|
||||||
|
* proof the user has the ability to read messages sent to thier
|
||||||
|
* browser and they likely aren't making the request via XSRF.
|
||||||
|
*/
|
||||||
|
public String sign(Account.Id user, String url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a token previously created.
|
||||||
|
*
|
||||||
|
* @param user the user making the verify request.
|
||||||
|
* @param url the url user is attempting to access.
|
||||||
|
* @param token the string created by sign.
|
||||||
|
* @throws InvalidTokenException the token is invalid, expired, malformed,
|
||||||
|
* etc.
|
||||||
|
*/
|
||||||
|
public void verify(Account.Id user, String url, String token)
|
||||||
|
throws InvalidTokenException;
|
||||||
|
|
||||||
|
/** Exception thrown when a token does not parse correctly. */
|
||||||
|
public static class InvalidTokenException extends Exception {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public InvalidTokenException() {
|
||||||
|
super("Invalid token");
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvalidTokenException(Throwable cause) {
|
||||||
|
super("Invalid token", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright (C) 2012 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed 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.
|
||||||
|
|
||||||
|
package com.google.gerrit.httpd;
|
||||||
|
|
||||||
|
import com.google.gerrit.reviewdb.client.Account;
|
||||||
|
import com.google.gerrit.server.config.AuthConfig;
|
||||||
|
import com.google.gwtjsonrpc.server.SignedToken;
|
||||||
|
import com.google.gwtjsonrpc.server.ValidToken;
|
||||||
|
import com.google.gwtjsonrpc.server.XsrfException;
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
|
||||||
|
import org.eclipse.jgit.util.Base64;
|
||||||
|
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
|
||||||
|
/** Verifies the token sent by {@link RestApiServlet}. */
|
||||||
|
public class SignedTokenRestTokenVerifier implements RestTokenVerifier {
|
||||||
|
private final SignedToken restToken;
|
||||||
|
|
||||||
|
public static class Module extends AbstractModule {
|
||||||
|
@Override
|
||||||
|
protected void configure() {
|
||||||
|
bind(RestTokenVerifier.class).to(SignedTokenRestTokenVerifier.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SignedTokenRestTokenVerifier(AuthConfig config) {
|
||||||
|
restToken = config.getRestToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String sign(Account.Id user, String url) {
|
||||||
|
try {
|
||||||
|
String payload = String.format("%s:%s", user, url);
|
||||||
|
byte[] utf8 = payload.getBytes("UTF-8");
|
||||||
|
String base64 = Base64.encodeBytes(utf8);
|
||||||
|
return restToken.newToken(base64);
|
||||||
|
} catch (XsrfException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
} catch (UnsupportedEncodingException e) {
|
||||||
|
throw new IllegalArgumentException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void verify(Account.Id user, String url, String tokenString)
|
||||||
|
throws InvalidTokenException {
|
||||||
|
ValidToken token;
|
||||||
|
try {
|
||||||
|
token = restToken.checkToken(tokenString, null);
|
||||||
|
} catch (XsrfException err) {
|
||||||
|
throw new InvalidTokenException(err);
|
||||||
|
}
|
||||||
|
if (token == null || token.getData() == null || token.getData().isEmpty()) {
|
||||||
|
throw new InvalidTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
String payload;
|
||||||
|
try {
|
||||||
|
payload = new String(Base64.decode(token.getData()), "UTF-8");
|
||||||
|
} catch (UnsupportedEncodingException err) {
|
||||||
|
throw new InvalidTokenException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
int colonPos = payload.indexOf(':');
|
||||||
|
if (colonPos == -1) {
|
||||||
|
throw new InvalidTokenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
Account.Id tokenUser;
|
||||||
|
try {
|
||||||
|
tokenUser = Account.Id.parse(payload.substring(0, colonPos));
|
||||||
|
} catch (IllegalArgumentException err) {
|
||||||
|
throw new InvalidTokenException(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
String tokenUrl = payload.substring(colonPos+1);
|
||||||
|
|
||||||
|
if (!tokenUser.equals(user) || !tokenUrl.equals(url)) {
|
||||||
|
throw new InvalidTokenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,263 @@
|
|||||||
|
// Copyright (C) 2012 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed 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.
|
||||||
|
|
||||||
|
package com.google.gerrit.httpd;
|
||||||
|
|
||||||
|
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
|
||||||
|
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
|
||||||
|
|
||||||
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.common.collect.Iterators;
|
||||||
|
import com.google.common.collect.Maps;
|
||||||
|
import com.google.gerrit.httpd.RestTokenVerifier.InvalidTokenException;
|
||||||
|
import com.google.gerrit.server.CurrentUser;
|
||||||
|
import com.google.gerrit.server.IdentifiedUser;
|
||||||
|
import com.google.gerrit.server.OutputFormat;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParseException;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Provider;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import javax.servlet.ServletException;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletRequestWrapper;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
public abstract class TokenVerifiedRestApiServlet extends RestApiServlet {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
private static final String FORM_ENCODED = "application/x-www-form-urlencoded";
|
||||||
|
private static final String UTF_8 = "UTF-8";
|
||||||
|
private static final String AUTHKEY_NAME = "_authkey";
|
||||||
|
private static final String AUTHKEY_HEADER = "X-authkey";
|
||||||
|
|
||||||
|
private final Gson gson;
|
||||||
|
private final Provider<CurrentUser> userProvider;
|
||||||
|
private final RestTokenVerifier verifier;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
protected TokenVerifiedRestApiServlet(Provider<CurrentUser> userProvider,
|
||||||
|
RestTokenVerifier verifier) {
|
||||||
|
super(userProvider);
|
||||||
|
this.gson = OutputFormat.JSON_COMPACT.newGson();
|
||||||
|
this.userProvider = userProvider;
|
||||||
|
this.verifier = verifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the (possibly state changing) request.
|
||||||
|
*
|
||||||
|
* @param req incoming HTTP request.
|
||||||
|
* @param res outgoing response.
|
||||||
|
* @param requestData JSON object representing the HTTP request parameters.
|
||||||
|
* Null if the request body was not supplied in JSON format.
|
||||||
|
* @throws IOException
|
||||||
|
* @throws ServletException
|
||||||
|
*/
|
||||||
|
protected abstract void doRequest(HttpServletRequest req,
|
||||||
|
HttpServletResponse res,
|
||||||
|
@Nullable JsonObject requestData) throws IOException, ServletException;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final void doGet(HttpServletRequest req, HttpServletResponse res)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
CurrentUser user = userProvider.get();
|
||||||
|
if (!(user instanceof IdentifiedUser)) {
|
||||||
|
sendError(res, SC_UNAUTHORIZED, "API requires authentication");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenInfo info = new TokenInfo();
|
||||||
|
info._authkey = verifier.sign(
|
||||||
|
((IdentifiedUser) user).getAccountId(),
|
||||||
|
computeUrl(req));
|
||||||
|
|
||||||
|
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||||
|
String type;
|
||||||
|
buf.write(JSON_MAGIC);
|
||||||
|
if (acceptsJson(req)) {
|
||||||
|
type = JSON_TYPE;
|
||||||
|
buf.write(gson.toJson(info).getBytes(UTF_8));
|
||||||
|
} else {
|
||||||
|
type = FORM_ENCODED;
|
||||||
|
buf.write(String.format("%s=%s",
|
||||||
|
AUTHKEY_NAME,
|
||||||
|
URLEncoder.encode(info._authkey, UTF_8)).getBytes(UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setContentType(type);
|
||||||
|
res.setCharacterEncoding(UTF_8);
|
||||||
|
res.setHeader("Content-Disposition", "attachment");
|
||||||
|
send(req, res, buf.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final void doPost(HttpServletRequest req, HttpServletResponse res)
|
||||||
|
throws IOException, ServletException {
|
||||||
|
CurrentUser user = userProvider.get();
|
||||||
|
if (!(user instanceof IdentifiedUser)) {
|
||||||
|
sendError(res, SC_UNAUTHORIZED, "API requires authentication");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedBody body;
|
||||||
|
if (JSON_TYPE.equals(req.getContentType())) {
|
||||||
|
body = parseJson(req, res);
|
||||||
|
} else if (FORM_ENCODED.equals(req.getContentType())) {
|
||||||
|
body = parseForm(req, res);
|
||||||
|
} else {
|
||||||
|
sendError(res, SC_BAD_REQUEST, String.format(
|
||||||
|
"Expected Content-Type: %s or %s",
|
||||||
|
JSON_TYPE, FORM_ENCODED));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Strings.isNullOrEmpty(body._authkey)) {
|
||||||
|
String h = req.getHeader(AUTHKEY_HEADER);
|
||||||
|
if (Strings.isNullOrEmpty(h)) {
|
||||||
|
sendError(res, SC_BAD_REQUEST, String.format(
|
||||||
|
"Expected %s in request body or %s in HTTP headers",
|
||||||
|
AUTHKEY_NAME, AUTHKEY_HEADER));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body._authkey = URLDecoder.decode(h, UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
verifier.verify(
|
||||||
|
((IdentifiedUser) user).getAccountId(),
|
||||||
|
computeUrl(req),
|
||||||
|
body._authkey);
|
||||||
|
} catch (InvalidTokenException err) {
|
||||||
|
sendError(res, SC_BAD_REQUEST,
|
||||||
|
String.format("Invalid or expired %s", AUTHKEY_NAME));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
doRequest(body.req, res, body.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedBody parseJson(HttpServletRequest req,
|
||||||
|
HttpServletResponse res) throws IOException {
|
||||||
|
try {
|
||||||
|
JsonElement element = new JsonParser().parse(req.getReader());
|
||||||
|
if (!element.isJsonObject()) {
|
||||||
|
sendError(res, SC_BAD_REQUEST, "Expected JSON object in request body");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedBody body = new ParsedBody();
|
||||||
|
body.req = req;
|
||||||
|
body.json = (JsonObject) element;
|
||||||
|
JsonElement authKey = body.json.remove(AUTHKEY_NAME);
|
||||||
|
if (authKey != null
|
||||||
|
&& authKey.isJsonPrimitive()
|
||||||
|
&& authKey.getAsJsonPrimitive().isString()) {
|
||||||
|
body._authkey = authKey.getAsString();
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} catch (JsonParseException e) {
|
||||||
|
sendError(res, SC_BAD_REQUEST, "Invalid JSON object in request body");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedBody parseForm(HttpServletRequest req,
|
||||||
|
HttpServletResponse res) throws IOException {
|
||||||
|
ParsedBody body = new ParsedBody();
|
||||||
|
body.req = new WrappedRequest(req);
|
||||||
|
body._authkey = req.getParameter(AUTHKEY_NAME);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String computeUrl(HttpServletRequest req) {
|
||||||
|
StringBuffer url = req.getRequestURL();
|
||||||
|
String qs = req.getQueryString();
|
||||||
|
if (!Strings.isNullOrEmpty(qs)) {
|
||||||
|
url.append('?').append(qs);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class TokenInfo {
|
||||||
|
String _authkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ParsedBody {
|
||||||
|
HttpServletRequest req;
|
||||||
|
String _authkey;
|
||||||
|
JsonObject json;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class WrappedRequest extends HttpServletRequestWrapper {
|
||||||
|
@SuppressWarnings("rawtypes")
|
||||||
|
private Map parameters;
|
||||||
|
|
||||||
|
WrappedRequest(HttpServletRequest req) {
|
||||||
|
super(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getParameter(String name) {
|
||||||
|
if (AUTHKEY_NAME.equals(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return super.getParameter(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getParameterValues(String name) {
|
||||||
|
if (AUTHKEY_NAME.equals(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return super.getParameterValues(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
@Override
|
||||||
|
public Map getParameterMap() {
|
||||||
|
Map m = parameters;
|
||||||
|
if (m == null) {
|
||||||
|
m = super.getParameterMap();
|
||||||
|
if (m.containsKey(AUTHKEY_NAME)) {
|
||||||
|
m = Maps.newHashMap(m);
|
||||||
|
m.remove(AUTHKEY_NAME);
|
||||||
|
}
|
||||||
|
parameters = m;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
@Override
|
||||||
|
public Enumeration getParameterNames() {
|
||||||
|
return Iterators.asEnumeration(getParameterMap().keySet().iterator());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@@ -22,6 +22,7 @@ import com.google.gerrit.httpd.CacheBasedWebSession;
|
|||||||
import com.google.gerrit.httpd.GitOverHttpModule;
|
import com.google.gerrit.httpd.GitOverHttpModule;
|
||||||
import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
|
import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
|
||||||
import com.google.gerrit.httpd.RequestContextFilter;
|
import com.google.gerrit.httpd.RequestContextFilter;
|
||||||
|
import com.google.gerrit.httpd.SignedTokenRestTokenVerifier;
|
||||||
import com.google.gerrit.httpd.WebModule;
|
import com.google.gerrit.httpd.WebModule;
|
||||||
import com.google.gerrit.httpd.WebSshGlueModule;
|
import com.google.gerrit.httpd.WebSshGlueModule;
|
||||||
import com.google.gerrit.httpd.auth.openid.OpenIdModule;
|
import com.google.gerrit.httpd.auth.openid.OpenIdModule;
|
||||||
@@ -294,6 +295,7 @@ public class Daemon extends SiteProgram {
|
|||||||
modules.add(new DefaultCacheFactory.Module());
|
modules.add(new DefaultCacheFactory.Module());
|
||||||
modules.add(new SmtpEmailSender.Module());
|
modules.add(new SmtpEmailSender.Module());
|
||||||
modules.add(new SignedTokenEmailTokenVerifier.Module());
|
modules.add(new SignedTokenEmailTokenVerifier.Module());
|
||||||
|
modules.add(new SignedTokenRestTokenVerifier.Module());
|
||||||
modules.add(new PluginModule());
|
modules.add(new PluginModule());
|
||||||
if (httpd) {
|
if (httpd) {
|
||||||
modules.add(new CanonicalWebUrlModule() {
|
modules.add(new CanonicalWebUrlModule() {
|
||||||
|
@@ -85,5 +85,9 @@ class InitAuth implements InitStep {
|
|||||||
if (auth.getSecure("registerEmailPrivateKey") == null) {
|
if (auth.getSecure("registerEmailPrivateKey") == null) {
|
||||||
auth.setSecure("registerEmailPrivateKey", SignedToken.generateRandomKey());
|
auth.setSecure("registerEmailPrivateKey", SignedToken.generateRandomKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auth.getSecure("restTokenPrivateKey") == null) {
|
||||||
|
auth.setSecure("restTokenPrivateKey", SignedToken.generateRandomKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -45,6 +45,7 @@ public class AuthConfig {
|
|||||||
private final String cookiePath;
|
private final String cookiePath;
|
||||||
private final boolean cookieSecure;
|
private final boolean cookieSecure;
|
||||||
private final SignedToken emailReg;
|
private final SignedToken emailReg;
|
||||||
|
private final SignedToken restToken;
|
||||||
|
|
||||||
private final boolean allowGoogleAccountUpgrade;
|
private final boolean allowGoogleAccountUpgrade;
|
||||||
|
|
||||||
@@ -75,6 +76,15 @@ public class AuthConfig {
|
|||||||
emailReg = null;
|
emailReg = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
key = cfg.getString("auth", null, "restTokenPrivateKey");
|
||||||
|
if (key != null && !key.isEmpty()) {
|
||||||
|
int age = (int) ConfigUtil.getTimeUnit(cfg,
|
||||||
|
"auth", null, "maxRestTokenAge", 60, TimeUnit.SECONDS);
|
||||||
|
restToken = new SignedToken(age, key);
|
||||||
|
} else {
|
||||||
|
restToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (authType == AuthType.OPENID) {
|
if (authType == AuthType.OPENID) {
|
||||||
allowGoogleAccountUpgrade =
|
allowGoogleAccountUpgrade =
|
||||||
cfg.getBoolean("auth", "allowgoogleaccountupgrade", false);
|
cfg.getBoolean("auth", "allowgoogleaccountupgrade", false);
|
||||||
@@ -129,6 +139,10 @@ public class AuthConfig {
|
|||||||
return emailReg;
|
return emailReg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SignedToken getRestToken() {
|
||||||
|
return restToken;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isAllowGoogleAccountUpgrade() {
|
public boolean isAllowGoogleAccountUpgrade() {
|
||||||
return allowGoogleAccountUpgrade;
|
return allowGoogleAccountUpgrade;
|
||||||
}
|
}
|
||||||
|
@@ -32,7 +32,7 @@ import java.util.List;
|
|||||||
/** A version of the database schema. */
|
/** A version of the database schema. */
|
||||||
public abstract class SchemaVersion {
|
public abstract class SchemaVersion {
|
||||||
/** The current schema version. */
|
/** The current schema version. */
|
||||||
public static final Class<Schema_71> C = Schema_71.class;
|
public static final Class<Schema_72> C = Schema_72.class;
|
||||||
|
|
||||||
public static class Module extends AbstractModule {
|
public static class Module extends AbstractModule {
|
||||||
@Override
|
@Override
|
||||||
|
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (C) 2012 The Android Open Source Project
|
||||||
|
//
|
||||||
|
// Licensed 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.
|
||||||
|
|
||||||
|
package com.google.gerrit.server.schema;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Provider;
|
||||||
|
|
||||||
|
public class Schema_72 extends SchemaVersion {
|
||||||
|
@Inject
|
||||||
|
Schema_72(Provider<Schema_71> prior) {
|
||||||
|
super(prior);
|
||||||
|
}
|
||||||
|
}
|
@@ -203,6 +203,7 @@ public class WebAppInitializer extends GuiceServletContextListener {
|
|||||||
modules.add(new DefaultCacheFactory.Module());
|
modules.add(new DefaultCacheFactory.Module());
|
||||||
modules.add(new SmtpEmailSender.Module());
|
modules.add(new SmtpEmailSender.Module());
|
||||||
modules.add(new SignedTokenEmailTokenVerifier.Module());
|
modules.add(new SignedTokenEmailTokenVerifier.Module());
|
||||||
|
modules.add(new SignedTokenRestTokenVerifier.Module());
|
||||||
modules.add(new PluginModule());
|
modules.add(new PluginModule());
|
||||||
modules.add(new CanonicalWebUrlModule() {
|
modules.add(new CanonicalWebUrlModule() {
|
||||||
@Override
|
@Override
|
||||||
|
Reference in New Issue
Block a user