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:
Brad Larson
2012-07-25 11:41:22 -05:00
committed by Shawn O. Pearce
parent 513e86debe
commit 3a6f077932
12 changed files with 575 additions and 71 deletions

View File

@@ -2431,6 +2431,7 @@ Sample `etc/secure.config`:
----
[auth]
registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
restTokenPrivateKey = 7e40PzCjlUKOnXATvcBNXH6oyiu+r0dFk2c=
[database]
username = webuser

View File

@@ -42,6 +42,73 @@ public class RestApi {
*/
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 boolean hasQueryParams;
@@ -101,53 +168,7 @@ public class RestApi {
public <T extends JavaScriptObject> void send(final AsyncCallback<T> cb) {
RequestBuilder req = new RequestBuilder(RequestBuilder.GET, url.toString());
req.setHeader("Accept", JsonConstants.JSON_TYPE);
req.setCallback(new RequestCallback() {
@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);
}
}
});
req.setCallback(new MyRequestCallback<T>(true, cb));
try {
RpcStatus.INSTANCE.onRpcStart();
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) {
return isContentType(res, JsonConstants.JSON_TYPE);
}

View File

@@ -14,6 +14,9 @@
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.Strings;
import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -38,6 +41,7 @@ import java.util.Collections;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
@@ -80,18 +84,20 @@ public abstract class RestApiServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse res)
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 {
checkRequiresCapability();
super.service(req, res);
} catch (RequireCapabilityException err) {
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
noCache(res);
sendText(req, res, err.getMessage());
sendError(res, SC_FORBIDDEN, err.getMessage());
} catch (Error err) {
handleError(err, req, res);
handleException(err, req, res);
} 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) {
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");
}
private static void handleError(
Throwable err, HttpServletRequest req, HttpServletResponse res)
throws IOException {
private static void handleException(Throwable err, HttpServletRequest req,
HttpServletResponse res) throws IOException {
String uri = req.getRequestURI();
if (!Strings.isNullOrEmpty(req.getQueryString())) {
uri += "?" + req.getQueryString();
@@ -132,12 +130,16 @@ public abstract class RestApiServlet extends HttpServlet {
if (!res.isCommitted()) {
res.reset();
res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
noCache(res);
sendText(req, res, "Internal Server Error");
sendError(res, SC_INTERNAL_SERVER_ERROR, "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) {
String accept = req.getHeader("Accept");
if (accept == null) {
@@ -155,16 +157,17 @@ public abstract class RestApiServlet extends HttpServlet {
return false;
}
protected static void sendText(HttpServletRequest req,
protected static void sendText(@Nullable HttpServletRequest req,
HttpServletResponse res, String data) throws IOException {
res.setContentType("text/plain");
res.setCharacterEncoding("UTF-8");
send(req, res, data.getBytes("UTF-8"));
}
protected static void send(HttpServletRequest req, HttpServletResponse res,
byte[] data) throws IOException {
if (data.length > 256 && RPCServletUtils.acceptsGzipEncoding(req)) {
protected static void send(@Nullable HttpServletRequest req,
HttpServletResponse res, byte[] data) throws IOException {
if (data.length > 256 && req != null
&& RPCServletUtils.acceptsGzipEncoding(req)) {
res.setHeader("Content-Encoding", "gzip");
data = HtmlDomUtil.compress(data);
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -22,6 +22,7 @@ import com.google.gerrit.httpd.CacheBasedWebSession;
import com.google.gerrit.httpd.GitOverHttpModule;
import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
import com.google.gerrit.httpd.RequestContextFilter;
import com.google.gerrit.httpd.SignedTokenRestTokenVerifier;
import com.google.gerrit.httpd.WebModule;
import com.google.gerrit.httpd.WebSshGlueModule;
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 SmtpEmailSender.Module());
modules.add(new SignedTokenEmailTokenVerifier.Module());
modules.add(new SignedTokenRestTokenVerifier.Module());
modules.add(new PluginModule());
if (httpd) {
modules.add(new CanonicalWebUrlModule() {

View File

@@ -85,5 +85,9 @@ class InitAuth implements InitStep {
if (auth.getSecure("registerEmailPrivateKey") == null) {
auth.setSecure("registerEmailPrivateKey", SignedToken.generateRandomKey());
}
if (auth.getSecure("restTokenPrivateKey") == null) {
auth.setSecure("restTokenPrivateKey", SignedToken.generateRandomKey());
}
}
}

View File

@@ -45,6 +45,7 @@ public class AuthConfig {
private final String cookiePath;
private final boolean cookieSecure;
private final SignedToken emailReg;
private final SignedToken restToken;
private final boolean allowGoogleAccountUpgrade;
@@ -75,6 +76,15 @@ public class AuthConfig {
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) {
allowGoogleAccountUpgrade =
cfg.getBoolean("auth", "allowgoogleaccountupgrade", false);
@@ -129,6 +139,10 @@ public class AuthConfig {
return emailReg;
}
public SignedToken getRestToken() {
return restToken;
}
public boolean isAllowGoogleAccountUpgrade() {
return allowGoogleAccountUpgrade;
}

View File

@@ -32,7 +32,7 @@ import java.util.List;
/** A version of the database schema. */
public abstract class SchemaVersion {
/** 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 {
@Override

View File

@@ -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);
}
}

View File

@@ -203,6 +203,7 @@ public class WebAppInitializer extends GuiceServletContextListener {
modules.add(new DefaultCacheFactory.Module());
modules.add(new SmtpEmailSender.Module());
modules.add(new SignedTokenEmailTokenVerifier.Module());
modules.add(new SignedTokenRestTokenVerifier.Module());
modules.add(new PluginModule());
modules.add(new CanonicalWebUrlModule() {
@Override