Merge branch 'stable-2.13'

* stable-2.13:
  Introduce authorization token for SSH requests
  Authorize Git LFS HTTP requests
  Refactor auth token generation and handling
  Change token data delimiter to `~`
  Fix Amazon S3 backend links in configuration doc
  LfsSshAuth: Prevent repeated slash in response href

Change-Id: Iab1bf1cb394b05ca922df1c4dbd804ad4725e851
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/AuthInfo.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/AuthInfo.java
new file mode 100644
index 0000000..187ce6f
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/AuthInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+public class AuthInfo {
+  public final String authToken;
+  public final String expiresAt;
+
+  public AuthInfo(String authToken, String expiresAt) {
+    this.authToken = authToken;
+    this.expiresAt = expiresAt;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/ExpiringAction.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/ExpiringAction.java
new file mode 100644
index 0000000..48bf7d7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/ExpiringAction.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
+
+import org.eclipse.jgit.lfs.server.Response;
+
+import java.util.Collections;
+
+public class ExpiringAction extends Response.Action {
+  public final String expiresAt;
+
+  public ExpiringAction(String href, AuthInfo info) {
+    this.href = href;
+    this.header = Collections.singletonMap(HDR_AUTHORIZATION, info.authToken);
+    this.expiresAt = info.expiresAt;
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java
index dfc7f58..67fe512 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java
@@ -18,9 +18,13 @@
 import static com.google.gerrit.extensions.client.ProjectState.READ_ONLY;
 import static com.google.gerrit.httpd.plugins.LfsPluginServlet.URL_REGEX;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -28,50 +32,59 @@
 import org.eclipse.jgit.lfs.errors.LfsException;
 import org.eclipse.jgit.lfs.errors.LfsRepositoryNotFound;
 import org.eclipse.jgit.lfs.errors.LfsRepositoryReadOnly;
+import org.eclipse.jgit.lfs.errors.LfsUnauthorized;
 import org.eclipse.jgit.lfs.errors.LfsUnavailable;
 import org.eclipse.jgit.lfs.errors.LfsValidationError;
 import org.eclipse.jgit.lfs.server.LargeFileRepository;
+import org.eclipse.jgit.lfs.server.LfsGerritProtocolServlet;
 import org.eclipse.jgit.lfs.server.LfsObject;
-import org.eclipse.jgit.lfs.server.LfsProtocolServlet;
 
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 @Singleton
-public class LfsApiServlet extends LfsProtocolServlet {
+public class LfsApiServlet extends LfsGerritProtocolServlet {
   private static final long serialVersionUID = 1L;
   private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX);
+  private static final String DOWNLOAD = "download";
+  private static final String UPLOAD = "upload";
 
   private final ProjectCache projectCache;
   private final LfsConfigurationFactory lfsConfigFactory;
   private final LfsRepositoryResolver repoResolver;
+  private final LfsAuthUserProvider userProvider;
 
   @Inject
   LfsApiServlet(ProjectCache projectCache,
       LfsConfigurationFactory lfsConfigFactory,
-      LfsRepositoryResolver repoResolver) {
+      LfsRepositoryResolver repoResolver,
+      LfsAuthUserProvider userProvider) {
     this.projectCache = projectCache;
     this.lfsConfigFactory = lfsConfigFactory;
     this.repoResolver = repoResolver;
+    this.userProvider = userProvider;
   }
 
   @Override
   protected LargeFileRepository getLargeFileRepository(
-      LfsRequest request, String path) throws LfsException {
+      LfsRequest request, String path, String auth)
+          throws LfsException {
     String pathInfo = path.startsWith("/") ? path : "/" + path;
     Matcher matcher = URL_PATTERN.matcher(pathInfo);
     if (!matcher.matches()) {
       throw new LfsException("no repository at " + pathInfo);
     }
+    String projName = matcher.group(1);
     Project.NameKey project = Project.NameKey.parse(
-        ProjectUtil.stripGitSuffix(matcher.group(1)));
+        ProjectUtil.stripGitSuffix(projName));
     ProjectState state = projectCache.get(project);
-
     if (state == null || state.getProject().getState() == HIDDEN) {
       throw new LfsRepositoryNotFound(project.get());
     }
+    authorizeUser(userProvider.getUser(auth, projName, request.getOperation()),
+        state, request.getOperation());
 
-    if (request.getOperation().equals("upload")
+    if (request.getOperation().equals(UPLOAD)
         && state.getProject().getState() == READ_ONLY) {
       throw new LfsRepositoryReadOnly(project.get());
     }
@@ -82,7 +95,7 @@
     // No config means we default to "not enabled".
     if (config != null && config.isEnabled()) {
       // For uploads, check object sizes against limit if configured
-      if (request.getOperation().equals("upload")) {
+      if (request.getOperation().equals(UPLOAD)) {
         if (config.isReadOnly()) {
           throw new LfsRepositoryReadOnly(project.get());
         }
@@ -104,4 +117,17 @@
 
     throw new LfsUnavailable(project.get());
   }
+
+  private void authorizeUser(CurrentUser user, ProjectState state,
+      String operation) throws LfsUnauthorized {
+    ProjectControl control = state.controlFor(user);
+    if ((operation.equals(DOWNLOAD) && !control.isReadable()) ||
+        (operation.equals(UPLOAD) && Capable.OK != control.canPushToAtLeastOneRef())) {
+      throw new LfsUnauthorized(
+          String.format("User %s is not authorized to perform %s operation",
+              Strings.isNullOrEmpty(user.getUserName())
+                ? "anonymous" :  user.getUserName(),
+              operation.toLowerCase()));
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java
new file mode 100644
index 0000000..1ab35c4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java
@@ -0,0 +1,93 @@
+//Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.joda.time.format.DateTimeFormatter;
+import org.joda.time.format.ISODateTimeFormat;
+
+import java.util.List;
+
+public abstract class LfsAuthToken {
+  public static abstract class Processor<T extends LfsAuthToken> {
+    private static final char DELIMETER = '~';
+
+    protected final LfsCipher cipher;
+
+    protected Processor(LfsCipher cipher) {
+      this.cipher = cipher;
+    }
+
+    public String serialize(T token) {
+      return cipher.encrypt(Joiner.on(DELIMETER).join(getValues(token)));
+    }
+
+    public Optional<T> deserialize(String input) {
+      Optional<String> decrypted = cipher.decrypt(input);
+      if (!decrypted.isPresent()) {
+        return Optional.absent();
+      }
+
+      return createToken(Splitter.on(DELIMETER).splitToList(decrypted.get()));
+    }
+
+    protected abstract List<String> getValues(T token);
+    protected abstract Optional<T> createToken(List<String> values);
+  }
+
+  public static abstract class Verifier<T extends LfsAuthToken> {
+    protected final T token;
+
+    protected Verifier(T token) {
+      this.token = token;
+    }
+
+    public boolean verify() {
+      return onTime(token.expiresAt)
+          && verifyTokenValues();
+    }
+
+    protected abstract boolean verifyTokenValues();
+
+    static boolean onTime(String dateTime) {
+      String now = LfsAuthToken.ISO.print(now());
+      return now.compareTo(dateTime) <= 0;
+    }
+  }
+
+  static final DateTimeFormatter ISO = ISODateTimeFormat.dateTime();
+  public final String expiresAt;
+
+  protected LfsAuthToken(int expirationSeconds) {
+    this(timeout(expirationSeconds));
+  }
+
+  protected LfsAuthToken(String expiresAt) {
+    this.expiresAt = expiresAt;
+  }
+
+  static String timeout(int expirationSeconds) {
+    return LfsAuthToken.ISO.print(now().plusSeconds(expirationSeconds));
+  }
+
+  static DateTime now() {
+    return DateTime.now().toDateTime(DateTimeZone.UTC);
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java
new file mode 100644
index 0000000..d1b3207
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import static com.googlesource.gerrit.plugins.lfs.LfsSshRequestAuthorizer.SSH_AUTH_PREFIX;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+class LfsAuthUserProvider {
+  private static final String BASIC_AUTH_PREFIX = "Basic ";
+
+  private final Provider<AnonymousUser> anonymous;
+  private final Provider<CurrentUser> user;
+  private final AuthConfig authCfg;
+  private final LfsSshRequestAuthorizer sshAuth;
+  private final AccountCache accounts;
+  private final IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  LfsAuthUserProvider(Provider<AnonymousUser> anonymous,
+      Provider<CurrentUser> user,
+      AuthConfig authCfg,
+      LfsSshRequestAuthorizer sshAuth,
+      AccountCache accounts,
+      IdentifiedUser.GenericFactory userFactory) {
+    this.anonymous = anonymous;
+    this.user = user;
+    this.authCfg = authCfg;
+    this.sshAuth = sshAuth;
+    this.accounts = accounts;
+    this.userFactory = userFactory;
+  }
+
+  CurrentUser getUser(String auth, String project, String operation) {
+    if (!Strings.isNullOrEmpty(auth)) {
+      if (auth.startsWith(BASIC_AUTH_PREFIX) && authCfg.isGitBasicAuth()) {
+        return user.get();
+      }
+
+      if (auth.startsWith(SSH_AUTH_PREFIX)) {
+        Optional<String> user = sshAuth.getUserFromValidToken(
+            auth.substring(SSH_AUTH_PREFIX.length()), project, operation);
+        if (user.isPresent()) {
+          AccountState acc = accounts.getByUsername(user.get());
+          if (acc != null) {
+            return userFactory.create(acc);
+          }
+        }
+      }
+    }
+    return anonymous.get();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java
new file mode 100644
index 0000000..25238da
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.primitives.Bytes;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.util.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.AlgorithmParameters;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+@Singleton
+public class LfsCipher {
+  private static final Logger log =
+      LoggerFactory.getLogger(LfsCipher.class);
+  private static final int IV_LENGTH = 16;
+  private static final String ALGORITHM = "AES";
+  private static final String CIPHER_TYPE = ALGORITHM + "/CBC/PKCS5PADDING";
+  private static final int KEY_SIZE = 128;
+
+  private final SecureRandom random;
+  private final SecretKey key;
+
+  public LfsCipher() {
+    this.random = new SecureRandom();
+    this.key = generateKey();
+  }
+
+  public String encrypt(String input) {
+    try {
+      byte[] initVector = new byte[IV_LENGTH];
+      random.nextBytes(initVector);
+      Cipher cipher = cipher(initVector, Cipher.ENCRYPT_MODE);
+      return Base64.encodeBytes(
+          Bytes.concat(initVector, cipher.doFinal(input.getBytes(UTF_8))));
+    } catch (GeneralSecurityException e) {
+      log.error("Token generation failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+
+  public Optional<String> decrypt(String input) {
+    if (Strings.isNullOrEmpty(input)) {
+      return Optional.absent();
+    }
+
+    byte[] bytes = Base64.decode(input);
+    byte[] initVector = Arrays.copyOf(bytes, IV_LENGTH);
+    try {
+      Cipher cipher = cipher(initVector, Cipher.DECRYPT_MODE);
+      return Optional.of(new String(
+          cipher.doFinal(Arrays.copyOfRange(bytes, IV_LENGTH, bytes.length)),
+          UTF_8));
+    } catch (GeneralSecurityException e) {
+      log.error("Exception was thrown during token verification", e);
+    }
+
+    return Optional.absent();
+  }
+
+  private Cipher cipher(byte[] initVector, int mode)
+      throws GeneralSecurityException {
+    IvParameterSpec spec = new IvParameterSpec(initVector);
+    AlgorithmParameters params = AlgorithmParameters.getInstance(ALGORITHM);
+    params.init(spec);
+    Cipher cipher = Cipher.getInstance(CIPHER_TYPE);
+    cipher.init(mode, key, params);
+    return cipher;
+  }
+
+  private SecretKey generateKey() {
+    try {
+      KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
+      generator.init(KEY_SIZE, random);
+      return generator.generateKey();
+    } catch (NoSuchAlgorithmException e) {
+      log.error("Generating key failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshAuth.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshAuth.java
index d82abff..22d7236 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshAuth.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshAuth.java
@@ -14,8 +14,6 @@
 
 package com.googlesource.gerrit.plugins.lfs;
 
-import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
-
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.sshd.BaseCommand.Failure;
@@ -28,20 +26,20 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-import org.eclipse.jgit.lfs.server.Response;
-
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.util.Collections;
 import java.util.List;
 
 @Singleton
 public class LfsSshAuth implements LfsPluginAuthCommand.LfsSshPluginAuth {
+  private final LfsSshRequestAuthorizer auth;
   private final String canonicalWebUrl;
   private final Gson gson;
 
   @Inject
-  LfsSshAuth(@CanonicalWebUrl Provider<String> canonicalWebUrl) {
+  LfsSshAuth(LfsSshRequestAuthorizer auth,
+      @CanonicalWebUrl Provider<String> canonicalWebUrl) {
+    this.auth = auth;
     this.canonicalWebUrl = canonicalWebUrl.get();
     this.gson = new GsonBuilder()
         .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
@@ -54,15 +52,20 @@
       throws UnloggedFailure, Failure {
     try {
       URL url = new URL(canonicalWebUrl);
-      String href = url.getProtocol() + "://" + url.getAuthority()
-          + url.getPath() + "/" + args.get(0) + "/info/lfs";
-      Response.Action response = new Response.Action();
-      response.href = href;
-      response.header =
-          Collections.singletonMap(HDR_AUTHORIZATION, "not:required");
-
-      return gson.toJson(response);
-
+      String path = url.getPath();
+      String project = args.get(0);
+      String operation = args.get(1);
+      StringBuilder href = new StringBuilder(url.getProtocol())
+          .append("://")
+          .append(url.getAuthority())
+          .append(path)
+          .append(path.endsWith("/") ? "" : "/")
+          .append(project)
+          .append("/info/lfs");
+      LfsSshRequestAuthorizer.SshAuthInfo info =
+          auth.generateAuthInfo(user, project, operation);
+      ExpiringAction action = new ExpiringAction(href.toString(), info);
+      return gson.toJson(action);
     } catch (MalformedURLException e) {
       throw new Failure(1, "Server configuration error: "
           + "forming Git LFS endpoint URL from canonicalWebUrl ["
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshRequestAuthorizer.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshRequestAuthorizer.java
new file mode 100644
index 0000000..208f30e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshRequestAuthorizer.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import com.google.common.base.Optional;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+class LfsSshRequestAuthorizer {
+  static class SshAuthInfo extends AuthInfo {
+    SshAuthInfo(String authToken, String expiresAt) {
+      super(SSH_AUTH_PREFIX + authToken, expiresAt);
+    }
+  }
+
+  private static final Logger log =
+      LoggerFactory.getLogger(LfsSshRequestAuthorizer.class);
+  private static final int DEFAULT_SSH_TIMEOUT = 10;
+  static final String SSH_AUTH_PREFIX = "Ssh: ";
+
+  private final Processor processor;
+  private final int expirationSeconds;
+
+  @Inject
+  LfsSshRequestAuthorizer(Processor processor,
+      LfsConfigurationFactory configFactory) {
+    this.processor = processor;
+    int timeout = DEFAULT_SSH_TIMEOUT;
+    try {
+      timeout = configFactory.getGlobalConfig().getInt("auth",
+        null, "sshExpirationSeconds", DEFAULT_SSH_TIMEOUT);
+    } catch (IllegalArgumentException e) {
+      log.warn("Reading expiration timeout failed with error."
+          + " Falling back to default {}", DEFAULT_SSH_TIMEOUT, e);
+    }
+    this.expirationSeconds = timeout;
+  }
+
+  SshAuthInfo generateAuthInfo(CurrentUser user, String project,
+      String operation) {
+    LfsSshAuthToken token = new LfsSshAuthToken(user.getUserName(), project,
+        operation, expirationSeconds);
+    return new SshAuthInfo(processor.serialize(token), token.expiresAt);
+  }
+
+  Optional<String> getUserFromValidToken(String authToken,
+      String project, String operation) {
+    Optional<LfsSshAuthToken> token = processor.deserialize(authToken);
+    if (!token.isPresent()) {
+      return Optional.absent();
+    }
+
+    Verifier verifier = new Verifier(token.get(), project, operation);
+    if (!verifier.verify()) {
+      log.error("Invalid data was provided with auth token {}.", authToken);
+      return Optional.absent();
+    }
+
+    return Optional.of(token.get().user);
+  }
+
+  static class Processor extends LfsAuthToken.Processor<LfsSshAuthToken> {
+    @Inject
+    protected Processor(LfsCipher cipher) {
+      super(cipher);
+    }
+
+    @Override
+    protected List<String> getValues(LfsSshAuthToken token) {
+      List<String> values = new ArrayList<>(4);
+      values.add(token.user);
+      values.add(token.project);
+      values.add(token.operation);
+      values.add(token.expiresAt);
+      return values;
+    }
+
+    @Override
+    protected Optional<LfsSshAuthToken> createToken(List<String> values) {
+      if (values.size() != 4) {
+        return Optional.absent();
+      }
+
+      return Optional.of(new LfsSshAuthToken(values.get(0), values.get(1),
+          values.get(2), values.get(3)));
+    }
+  }
+
+  private static class Verifier extends LfsAuthToken.Verifier<LfsSshAuthToken> {
+    private final String project;
+    private final String operation;
+
+    protected Verifier(LfsSshAuthToken token, String project,
+        String operation) {
+      super(token);
+      this.project = project;
+      this.operation = operation;
+    }
+
+    @Override
+    protected boolean verifyTokenValues() {
+      return project.equals(token.project)
+          && operation.equals(token.operation);
+    }
+  }
+
+  private static class LfsSshAuthToken extends LfsAuthToken {
+    private final String user;
+    private final String project;
+    private final String operation;
+
+    LfsSshAuthToken(String user, String project, String operation,
+        int expirationSeconds) {
+      super(expirationSeconds);
+      this.user = user;
+      this.project = project;
+      this.operation = operation;
+    }
+
+    LfsSshAuthToken(String user, String project, String operation,
+        String expiresAt) {
+      super(expiresAt);
+      this.user = user;
+      this.project = project;
+      this.operation = operation;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java
index 445a148..810fe60 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java
@@ -70,7 +70,7 @@
       return;
     }
 
-    if (!authorizer.verifyAgainstToken(req.getHeader(HDR_AUTHORIZATION),
+    if (!authorizer.verifyAuthInfo(req.getHeader(HDR_AUTHORIZATION),
         DOWNLOAD, obj)) {
       sendError(rsp, HttpStatus.SC_UNAUTHORIZED, MessageFormat.format(
           LfsServerText.get().failedToCalcSignature, "Invalid authorization token"));
@@ -91,7 +91,7 @@
       return;
     }
 
-    if (!authorizer.verifyAgainstToken(
+    if (!authorizer.verifyAuthInfo(
         req.getHeader(HDR_AUTHORIZATION), UPLOAD, id)) {
       sendError(rsp, HttpStatus.SC_UNAUTHORIZED,
           MessageFormat.format(LfsServerText.get().failedToCalcSignature,
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
index 8b67591..87eb349 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
@@ -14,130 +14,104 @@
 
 package com.googlesource.gerrit.plugins.lfs.fs;
 
-import com.google.common.base.Strings;
-import com.google.common.primitives.Bytes;
+import com.google.common.base.Optional;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import com.googlesource.gerrit.plugins.lfs.AuthInfo;
+import com.googlesource.gerrit.plugins.lfs.LfsAuthToken;
+import com.googlesource.gerrit.plugins.lfs.LfsCipher;
+
 import org.eclipse.jgit.lfs.lib.AnyLongObjectId;
-import org.eclipse.jgit.util.Base64;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.lfs.lib.LongObjectId;
 
-import java.nio.charset.StandardCharsets;
-import java.security.AlgorithmParameters;
-import java.security.GeneralSecurityException;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.security.spec.InvalidParameterSpecException;
-import java.util.Arrays;
-
-import javax.crypto.Cipher;
-import javax.crypto.KeyGenerator;
-import javax.crypto.NoSuchPaddingException;
-import javax.crypto.SecretKey;
-import javax.crypto.spec.IvParameterSpec;
+import java.util.ArrayList;
+import java.util.List;
 
 @Singleton
 public class LfsFsRequestAuthorizer {
-  private static final Logger log = LoggerFactory.getLogger(LfsFsRequestAuthorizer.class);
-  private static final int IV_LENGTH = 16;
-  private static final String ALGORITHM = "AES";
-  static final DateTimeFormatter DATE_TIME =
-      DateTimeFormat.forPattern("YYYYMMDDHHmmss");
-
-  private final SecureRandom rndm;
-  private final SecretKey key;
+  private final Processor processor;
 
   @Inject
-  LfsFsRequestAuthorizer() {
-    this.rndm = new SecureRandom();
-    this.key = generateKey();
+  LfsFsRequestAuthorizer(Processor processor) {
+    this.processor = processor;
   }
 
-  public String generateToken(String operation, AnyLongObjectId id,
+  public AuthInfo generateAuthInfo(String operation, AnyLongObjectId id,
       int expirationSeconds) {
-    try {
-      byte[] initVector = new byte[IV_LENGTH];
-      rndm.nextBytes(initVector);
-      Cipher cipher = cipher(initVector, Cipher.ENCRYPT_MODE);
-      return Base64.encodeBytes(Bytes.concat(initVector,
-          cipher.doFinal(String.format("%s-%s-%s", operation,
-              id.name(), timeout(expirationSeconds))
-              .getBytes(StandardCharsets.UTF_8))));
-    } catch (GeneralSecurityException e) {
-      log.error("Token generation failed with error", e);
-      throw new RuntimeException(e);
-    }
+    LfsFsAuthToken token = new LfsFsAuthToken(operation, id, expirationSeconds);
+    return new AuthInfo(processor.serialize(token), token.expiresAt);
   }
 
-  public boolean verifyAgainstToken(String token, String operation,
+  public boolean verifyAuthInfo(String authToken, String operation,
       AnyLongObjectId id) {
-    if (Strings.isNullOrEmpty(token)) {
+    Optional<LfsFsAuthToken> token = processor.deserialize(authToken);
+    if (!token.isPresent()) {
       return false;
     }
 
-    byte[] bytes = Base64.decode(token);
-    byte[] initVector = Arrays.copyOf(bytes, IV_LENGTH);
-    try {
-      Cipher cipher = cipher(initVector, Cipher.DECRYPT_MODE);
-      String data = new String(
-          cipher.doFinal(Arrays.copyOfRange(bytes, IV_LENGTH, bytes.length)),
-          StandardCharsets.UTF_8);
-      String oid = id.name();
-      String prefix = String.format("%s-%s-", operation, oid);
-      return data.startsWith(prefix)
-          && onTime(data.substring(prefix.length()), operation, oid);
-    } catch (GeneralSecurityException e) {
-      log.error("Exception was thrown during token verification", e);
+    return new Verifier(token.get(), operation, id).verify();
+  }
+
+  static class Processor extends LfsAuthToken.Processor<LfsFsAuthToken> {
+    @Inject
+    protected Processor(LfsCipher cipher) {
+      super(cipher);
     }
 
-    return false;
-  }
-
-  boolean onTime(String dateTime, String operation, String id) {
-    String now = DATE_TIME.print(now());
-    if (now.compareTo(dateTime) > 0) {
-      log.info("Operation {} on id {} timed out", operation, id);
-      return false;
+    @Override
+    protected List<String> getValues(LfsFsAuthToken token) {
+      List<String> values = new ArrayList<>(3);
+      values.add(token.operation);
+      values.add(token.id.getName());
+      values.add(token.expiresAt);
+      return values;
     }
 
-    return true;
+    @Override
+    protected Optional<LfsFsAuthToken> createToken(List<String> values) {
+      if (values.size() != 3) {
+        return Optional.absent();
+      }
+
+      return Optional.of(new LfsFsAuthToken(values.get(0),
+          LongObjectId.fromString(values.get(1)), values.get(2)));
+    }
   }
 
-  private String timeout(int expirationSeconds) {
-    return DATE_TIME.print(now().plusSeconds(expirationSeconds));
+  private static class Verifier extends LfsAuthToken.Verifier<LfsFsAuthToken> {
+    private final String operation;
+    private final AnyLongObjectId id;
+
+    protected Verifier(LfsFsAuthToken token,
+        String operation, AnyLongObjectId id) {
+      super(token);
+      this.operation = operation;
+      this.id = id;
+    }
+
+    @Override
+    protected boolean verifyTokenValues() {
+      return operation.equals(token.operation)
+          && id.getName().equals(token.id.getName());
+    }
   }
 
-  private DateTime now() {
-    return DateTime.now().toDateTime(DateTimeZone.UTC);
-  }
+  private static class LfsFsAuthToken extends LfsAuthToken {
+    private final String operation;
+    private final AnyLongObjectId id;
 
-  private Cipher cipher(byte[] initVector, int mode) throws NoSuchAlgorithmException,
-      NoSuchPaddingException, InvalidParameterSpecException,
-      InvalidKeyException, InvalidAlgorithmParameterException {
-    IvParameterSpec spec = new IvParameterSpec(initVector);
-    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
-    AlgorithmParameters params = AlgorithmParameters.getInstance(ALGORITHM);
-    params.init(spec);
-    cipher.init(mode, key, params);
-    return cipher;
-  }
+    LfsFsAuthToken(String operation, AnyLongObjectId id,
+        int expirationSeconds) {
+      super(expirationSeconds);
+      this.operation = operation;
+      this.id = id;
+    }
 
-  private SecretKey generateKey() {
-    try {
-      KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
-      generator.init(128, rndm);
-      return generator.generateKey();
-    } catch (NoSuchAlgorithmException e) {
-      log.error("Generating key failed with error", e);
-      throw new RuntimeException(e);
+    LfsFsAuthToken(String operation, AnyLongObjectId id, String expiresAt) {
+      super(expiresAt);
+      this.operation = operation;
+      this.id = id;
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
index a810943..71a51af 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
@@ -15,7 +15,6 @@
 package com.googlesource.gerrit.plugins.lfs.fs;
 
 import static com.googlesource.gerrit.plugins.lfs.LfsBackend.DEFAULT;
-import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
@@ -23,6 +22,8 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
+import com.googlesource.gerrit.plugins.lfs.AuthInfo;
+import com.googlesource.gerrit.plugins.lfs.ExpiringAction;
 import com.googlesource.gerrit.plugins.lfs.LfsBackend;
 import com.googlesource.gerrit.plugins.lfs.LfsConfigurationFactory;
 import com.googlesource.gerrit.plugins.lfs.LfsGlobalConfig;
@@ -35,7 +36,6 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.Collections;
 
 public class LocalLargeFileRepository extends FileLfsRepository {
   public interface Factory {
@@ -74,17 +74,17 @@
   @Override
   public Response.Action getDownloadAction(AnyLongObjectId id) {
     Response.Action action = super.getDownloadAction(id);
-    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
-        authorizer.generateToken(DOWNLOAD, id, expirationSeconds));
-    return action;
+    AuthInfo authInfo =
+        authorizer.generateAuthInfo(DOWNLOAD, id, expirationSeconds);
+    return new ExpiringAction(action.href, authInfo);
   }
 
   @Override
   public Response.Action getUploadAction(AnyLongObjectId id, long size) {
     Response.Action action = super.getUploadAction(id, size);
-    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
-        authorizer.generateToken(UPLOAD, id, expirationSeconds));
-    return action;
+    AuthInfo authInfo =
+        authorizer.generateAuthInfo(UPLOAD, id, expirationSeconds);
+    return new ExpiringAction(action.href, authInfo);
   }
 
   private static String getContentUrl(String url, LfsBackend backend) {
diff --git a/src/main/java/org/eclipse/jgit/lfs/errors/LfsUnauthorized.java b/src/main/java/org/eclipse/jgit/lfs/errors/LfsUnauthorized.java
new file mode 100644
index 0000000..896eb9f
--- /dev/null
+++ b/src/main/java/org/eclipse/jgit/lfs/errors/LfsUnauthorized.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 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 org.eclipse.jgit.lfs.errors;
+
+
+public class LfsUnauthorized extends LfsException {
+  private static final long serialVersionUID = 1L;
+
+  public LfsUnauthorized(String message) {
+    super(message);
+  }
+}
diff --git a/src/main/java/org/eclipse/jgit/lfs/server/LfsGerritProtocolServlet.java b/src/main/java/org/eclipse/jgit/lfs/server/LfsGerritProtocolServlet.java
new file mode 100644
index 0000000..849d789
--- /dev/null
+++ b/src/main/java/org/eclipse/jgit/lfs/server/LfsGerritProtocolServlet.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2015, Sasa Zivkov <sasa.zivkov@sap.com>
+ * and other copyright owners as documented in the project's IP log.
+ *
+ * This program and the accompanying materials are made available
+ * under the terms of the Eclipse Distribution License v1.0 which
+ * accompanies this distribution, is reproduced below, and is
+ * available at http://www.eclipse.org/org/documents/edl-v10.php
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above
+ *   copyright notice, this list of conditions and the following
+ *   disclaimer in the documentation and/or other materials provided
+ *   with the distribution.
+ *
+ * - Neither the name of the Eclipse Foundation, Inc. nor the
+ *   names of its contributors may be used to endorse or promote
+ *   products derived from this software without specific prior
+ *   written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+ * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
+ * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+ * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+ * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.eclipse.jgit.lfs.server;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.http.HttpStatus.SC_FORBIDDEN;
+import static org.apache.http.HttpStatus.SC_INSUFFICIENT_STORAGE;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+import static org.apache.http.HttpStatus.SC_OK;
+import static org.apache.http.HttpStatus.SC_SERVICE_UNAVAILABLE;
+import static org.apache.http.HttpStatus.SC_UNAUTHORIZED;
+import static org.apache.http.HttpStatus.SC_UNPROCESSABLE_ENTITY;
+import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import org.eclipse.jgit.lfs.errors.LfsBandwidthLimitExceeded;
+import org.eclipse.jgit.lfs.errors.LfsException;
+import org.eclipse.jgit.lfs.errors.LfsInsufficientStorage;
+import org.eclipse.jgit.lfs.errors.LfsRateLimitExceeded;
+import org.eclipse.jgit.lfs.errors.LfsRepositoryNotFound;
+import org.eclipse.jgit.lfs.errors.LfsRepositoryReadOnly;
+import org.eclipse.jgit.lfs.errors.LfsUnauthorized;
+import org.eclipse.jgit.lfs.errors.LfsUnavailable;
+import org.eclipse.jgit.lfs.errors.LfsValidationError;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+import java.util.List;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * LFS protocol handler implementing the LFS batch API [1]
+ *
+ * [1] https://github.com/github/git-lfs/blob/master/docs/api/v1/http-v1-batch.md
+ *
+ * @since 4.3
+ */
+// TODO this is copy of org.eclipse.jgit.lfs.server.LfsProtocolServlet with small improvements
+// that allows user's auth - donate it back to JGit and get rid of it once Gerrit moves to it
+public abstract class LfsGerritProtocolServlet extends HttpServlet {
+
+	private static final long serialVersionUID = 1L;
+
+	private static final String CONTENTTYPE_VND_GIT_LFS_JSON =
+			"application/vnd.git-lfs+json; charset=utf-8"; //$NON-NLS-1$
+
+	private static final int SC_RATE_LIMIT_EXCEEDED = 429;
+
+	private static final int SC_BANDWIDTH_LIMIT_EXCEEDED = 509;
+
+	private Gson gson = createGson();
+
+	/**
+	 * Get the large file repository for the given request and path.
+	 *
+	 * @param request
+	 *            the request
+	 * @param path
+	 *            the path
+	 * @param auth
+     *            the authorization info
+	 *
+	 * @return the large file repository storing large files.
+	 * @throws LfsException
+	 *             implementations should throw more specific exceptions to
+	 *             signal which type of error occurred:
+	 *             <dl>
+	 *             <dt>{@link LfsValidationError}</dt>
+	 *             <dd>when there is a validation error with one or more of the
+	 *             objects in the request</dd>
+	 *             <dt>{@link LfsRepositoryNotFound}</dt>
+	 *             <dd>when the repository does not exist for the user</dd>
+	 *             <dt>{@link LfsRepositoryReadOnly}</dt>
+	 *             <dd>when the user has read, but not write access. Only
+	 *             applicable when the operation in the request is "upload"</dd>
+	 *             <dt>{@link LfsRateLimitExceeded}</dt>
+	 *             <dd>when the user has hit a rate limit with the server</dd>
+	 *             <dt>{@link LfsBandwidthLimitExceeded}</dt>
+	 *             <dd>when the bandwidth limit for the user or repository has
+	 *             been exceeded</dd>
+	 *             <dt>{@link LfsInsufficientStorage}</dt>
+	 *             <dd>when there is insufficient storage on the server</dd>
+	 *             <dt>{@link LfsUnauthorized}</dt>
+	 *             <dd>when user is not authorized to perform LFS operation</dd>
+	 *             <dt>{@link LfsUnavailable}</dt>
+	 *             <dd>when LFS is not available</dd>
+	 *             <dt>{@link LfsException}</dt>
+	 *             <dd>when an unexpected internal server error occurred</dd>
+	 *             </dl>
+	 * @since 4.5
+	 */
+	protected abstract LargeFileRepository getLargeFileRepository(
+			LfsRequest request, String path, String auth) throws LfsException;
+
+	/**
+	 * LFS request.
+	 *
+	 * @since 4.5
+	 */
+	protected static class LfsRequest {
+		private String operation;
+
+		private List<LfsObject> objects;
+
+		/**
+		 * Get the LFS operation.
+		 *
+		 * @return the operation
+		 */
+		public String getOperation() {
+			return operation;
+		}
+
+		/**
+		 * Get the LFS objects.
+		 *
+		 * @return the objects
+		 */
+		public List<LfsObject> getObjects() {
+			return objects;
+		}
+	}
+
+	@Override
+	protected void doPost(HttpServletRequest req, HttpServletResponse res)
+			throws ServletException, IOException {
+		Writer w = new BufferedWriter(
+				new OutputStreamWriter(res.getOutputStream(), UTF_8));
+
+		Reader r = new BufferedReader(
+				new InputStreamReader(req.getInputStream(), UTF_8));
+		LfsRequest request = gson.fromJson(r, LfsRequest.class);
+		String path = req.getPathInfo();
+
+		res.setContentType(CONTENTTYPE_VND_GIT_LFS_JSON);
+		LargeFileRepository repo = null;
+		try {
+			repo = getLargeFileRepository(request, path,
+					req.getHeader(HDR_AUTHORIZATION));
+			if (repo == null) {
+				throw new LfsException("unexpected error"); //$NON-NLS-1$
+			}
+			res.setStatus(SC_OK);
+			TransferHandler handler = TransferHandler
+					.forOperation(request.operation, repo, request.objects);
+			gson.toJson(handler.process(), w);
+		} catch (LfsValidationError e) {
+			sendError(res, w, SC_UNPROCESSABLE_ENTITY, e.getMessage());
+		} catch (LfsRepositoryNotFound e) {
+			sendError(res, w, SC_NOT_FOUND, e.getMessage());
+		} catch (LfsRepositoryReadOnly e) {
+			sendError(res, w, SC_FORBIDDEN, e.getMessage());
+		} catch (LfsRateLimitExceeded e) {
+			sendError(res, w, SC_RATE_LIMIT_EXCEEDED, e.getMessage());
+		} catch (LfsBandwidthLimitExceeded e) {
+			sendError(res, w, SC_BANDWIDTH_LIMIT_EXCEEDED, e.getMessage());
+		} catch (LfsInsufficientStorage e) {
+			sendError(res, w, SC_INSUFFICIENT_STORAGE, e.getMessage());
+		} catch (LfsUnavailable e) {
+			sendError(res, w, SC_SERVICE_UNAVAILABLE, e.getMessage());
+		} catch (LfsUnauthorized e) {
+			sendError(res, w, SC_UNAUTHORIZED, e.getMessage());
+		} catch (LfsException e) {
+			sendError(res, w, SC_INTERNAL_SERVER_ERROR, e.getMessage());
+		} finally {
+			w.flush();
+		}
+	}
+
+	static class Error {
+		String message;
+
+		Error(String m) {
+			this.message = m;
+		}
+	}
+
+	private void sendError(HttpServletResponse rsp, Writer writer, int status,
+			String message) {
+		rsp.setStatus(status);
+		gson.toJson(new Error(message), writer);
+	}
+
+	private Gson createGson() {
+		return new GsonBuilder()
+				.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+				.disableHtmlEscaping()
+				.create();
+	}
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 7cf8603..d0b57b8 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -2,13 +2,21 @@
 
 ## Core Gerrit Settings
 
-The following option must be set in `$GERRIT_SITE/etc/gerrit.config`.
+The following options must be set in `$GERRIT_SITE/etc/gerrit.config`.
 
 ### Section `lfs`
 
 lfs.plugin = @PLUGIN@
 : With this option set LFS requests are forwarded to the @PLUGIN@ plugin.
 
+### Section `auth`
+
+auth.gitBasicAuth = true
+: Git LFS client uses Basic HTTP auth with LFS requests. When this option
+is not enabled (not set or equals to `false`) Git LFS HTTP requests are treated
+as anonymous requests. Therefore requests will be successfully authorized only
+for projects that allows anonymous to perform requested operation.
+
 ## Per Project Settings
 
 The following options can be configured in `@PLUGIN@.config` on the
@@ -93,6 +101,15 @@
 The following options can be configured in `$GERRIT_SITE/etc/@PLUGIN@.config`
 and `$GERRIT_SITE/etc/@PLUGIN@.secure.config.`
 
+### Section `auth`
+
+auth.sshExpirationSeconds
+: Validity, in seconds, of authentication token for SSH requests.
+[Git LFS Authentication](https://github.com/git-lfs/git-lfs/blob/master/docs/api/authentication.md)
+specifies that SSH might be used to authenticate the user. Successful authentication
+provides token that is later used for Git LFS requests.
+: Default is `10` seconds.
+
 ### Section `storage`
 
 storage.backend
@@ -120,16 +137,16 @@
 The following configuration options are only used when the backend is `s3`.
 
 s3.region
-: link:http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions
-[Amazon region] the S3 storage bucket is residing in.
+: [Amazon region](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html#concepts-available-regions)
+the S3 storage bucket is residing in.
 
 s3.bucket
-: Name of the link:http://docs.aws.amazon.com/AmazonS3/latest/UG/CreatingaBucket.html
-[Amazon S3 storage bucket] which will store large objects.
+: Name of the [Amazon S3 storage bucket](http://docs.aws.amazon.com/AmazonS3/latest/UG/CreatingaBucket.html)
+ which will store large objects.
 
 s3.storageClass
-: link:http://docs.aws.amazon.com/AmazonS3/latest/dev/storage-class-intro.html
-[Amazon S3 storage class] used for storing large objects.
+: [Amazon S3 storage class](http://docs.aws.amazon.com/AmazonS3/latest/dev/storage-class-intro.html)
+ used for storing large objects.
 : Default is `REDUCED_REDUNDANCY`
 
 s3.expirationSeconds
@@ -146,13 +163,13 @@
 : Default is `false`.
 
 s3.accessKey
-: The link:http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
-[Amazon IAM accessKey] for authenticating to S3. It is recommended to place this
+: The [Amazon IAM accessKey](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)
+for authenticating to S3. It is recommended to place this
 setting in `$GERRIT_SITE/etc/@PLUGIN@.secure.config`.
 
 s3.secretKey
-: The link:http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
-[Amazon IAM secretKey] for authenticating to S3. It is recommended to place this
+: The [Amazon IAM secretKey](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html)
+ for authenticating to S3. It is recommended to place this
 setting in `$GERRIT_SITE/etc/@PLUGIN@.secure.config`.
 
 ### Multiple LFS backends
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java
new file mode 100644
index 0000000..5b5c532
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.lfs.LfsAuthToken.ISO;
+import static com.googlesource.gerrit.plugins.lfs.LfsAuthToken.Verifier.onTime;
+
+import com.google.common.base.Optional;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LfsAuthTokenTest {
+  private final LfsCipher cipher = new LfsCipher();
+
+  @Test
+  public void testExpiredTime() throws Exception {
+    DateTime now = now();
+    // test that even 1ms expiration is enough
+    assertThat(onTime(ISO.print(now.minusMillis(1)))).isFalse();
+  }
+
+  @Test
+  public void testOnTime() throws Exception {
+    DateTime now = now();
+    // if there is at least 1ms before there is no timeout
+    assertThat(onTime(ISO.print(now.plusMillis(1)))).isTrue();
+  }
+
+  @Test
+  public void testTokenSerializationDeserialization() throws Exception {
+    TestTokenProessor processor = new TestTokenProessor(cipher);
+    TestToken token = new TestToken(0);
+    String serialized = processor.serialize(token);
+
+    assertThat(serialized).isNotEmpty();
+
+    Optional<TestToken> deserialized = processor.deserialize(serialized);
+    assertThat(deserialized.isPresent()).isTrue();
+    assertThat(token.expiresAt).isEqualTo(deserialized.get().expiresAt);
+  }
+
+  @Test
+  public void testTokenOnTime() throws Exception {
+    TestToken token = new TestToken(1);
+    TestTokenVerifier verifier = new TestTokenVerifier(token);
+    assertThat(verifier.verify()).isTrue();
+  }
+
+  @Test
+  public void testTokenExpired() throws Exception {
+    TestToken token = new TestToken(-1);
+    TestTokenVerifier verifier = new TestTokenVerifier(token);
+    assertThat(verifier.verify()).isFalse();
+  }
+
+  private DateTime now() {
+    return DateTime.now().toDateTime(DateTimeZone.UTC);
+  }
+
+  private class TestToken extends LfsAuthToken {
+    TestToken(int expirationSeconds) {
+      super(expirationSeconds);
+    }
+
+    TestToken(String expiresAt) {
+      super(expiresAt);
+    }
+  }
+
+  private class TestTokenProessor extends LfsAuthToken.Processor<TestToken> {
+    TestTokenProessor(LfsCipher cipher) {
+      super(cipher);
+    }
+
+    @Override
+    protected List<String> getValues(TestToken token) {
+      List<String> values = new ArrayList<>(2);
+      values.add(token.expiresAt);
+      return values;
+    }
+
+    @Override
+    protected Optional<TestToken> createToken(List<String> values) {
+      return Optional.of(new TestToken(values.get(0)));
+    }
+  }
+
+  private class TestTokenVerifier extends LfsAuthToken.Verifier<TestToken> {
+    protected TestTokenVerifier(TestToken token) {
+      super(token);
+    }
+
+    @Override
+    protected boolean verifyTokenValues() {
+      return true;
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java
new file mode 100644
index 0000000..5f51f4a
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 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.googlesource.gerrit.plugins.lfs;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Optional;
+
+import org.junit.Test;
+
+public class LfsCipherTest {
+  private final LfsCipher cipher = new LfsCipher();
+
+  @Test
+  public void testCipherTextIsDifferentThanInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    assertThat(encrypted).isNotEmpty();
+    assertThat(encrypted).isNotEqualTo(plain);
+  }
+
+  @Test
+  public void testVerifyDecodeAgainstEncodedInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    Optional<String> decrypted = cipher.decrypt(encrypted);
+    assertThat(decrypted.isPresent()).isTrue();
+    assertThat(decrypted.get()).isEqualTo(plain);
+  }
+
+  @Test
+  public void testVerifyDecodeAgainstInvalidInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    // there is a chance that two first chars in token are the same
+    // in such case re-generate the token
+    while(encrypted.charAt(0) == encrypted.charAt(1)) {
+      encrypted = cipher.encrypt(plain);
+    }
+
+    Optional<String> decrypted = cipher.decrypt(encrypted.substring(1, 2)
+        + encrypted.substring(0, 1) + encrypted.substring(2));
+    assertThat(decrypted.isPresent()).isTrue();
+    assertThat(decrypted.get()).isNotEqualTo(plain);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
index 8cab1a3..8513efb 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
@@ -15,74 +15,36 @@
 package com.googlesource.gerrit.plugins.lfs.fs;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.DATE_TIME;
 import static org.eclipse.jgit.lfs.lib.LongObjectId.zeroId;
 
+import com.googlesource.gerrit.plugins.lfs.AuthInfo;
+import com.googlesource.gerrit.plugins.lfs.LfsCipher;
+import com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.Processor;
+
 import org.eclipse.jgit.lfs.lib.LongObjectId;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
 import org.junit.Test;
 
 public class LfsFsRequestAuthorizerTest {
-  private final LfsFsRequestAuthorizer auth = new LfsFsRequestAuthorizer();
+  private final LfsFsRequestAuthorizer auth =
+      new LfsFsRequestAuthorizer(new Processor(new LfsCipher()));
 
   @Test
-  public void testExpiredTime() throws Exception {
-    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
-    // test that even 1s expiration is enough
-    assertThat(auth.onTime(DATE_TIME.print(now.minusSeconds(1)), "o", "id"))
-            .isFalse();
-  }
-
-  @Test
-  public void testOnTime() throws Exception {
-    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
-    // if there is at least 1s before there is no timeout
-    assertThat(auth.onTime(DATE_TIME.print(now.plusSeconds(1)), "o", "id"))
-            .isTrue();
-  }
-
-  @Test
-  public void testVerifyAgainstMissingToken() throws Exception {
-    assertThat(auth.verifyAgainstToken("", "o", zeroId())).isFalse();
-    assertThat(auth.verifyAgainstToken(null, "o", zeroId())).isFalse();
-  }
-
-  @Test
-  public void testVerifyAgainstToken() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isTrue();
-  }
-
-  @Test
-  public void testVerifyAgainstInvalidToken() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    // replace 1st and 2nd token letters with each other
-    assertThat(auth.verifyAgainstToken(
-        token.substring(1, 2) + token.substring(0, 1) + token.substring(2), "o",
-        zeroId())).isFalse();
+  public void testVerifyAuthInfo() throws Exception {
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "o", zeroId())).isTrue();
   }
 
   @Test
   public void testVerifyAgainstDifferentOperation() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "p", zeroId())).isFalse();
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "p", zeroId())).isFalse();
   }
 
   @Test
   public void testVerifyAgainstDifferentObjectId() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "o",
-        LongObjectId.fromString(
-            "123456789012345678901234567890"
-            + "123456789012345678901234567890"
-            + "1234"))).isFalse();
-  }
-
-  @Test
-  public void testVerifyAgainstExpiredToken() throws Exception {
-    // generate already expired token
-    String token = auth.generateToken("o", zeroId(), -1);
-    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isFalse();
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "o",
+        LongObjectId.fromString("123456789012345678901234567890"
+            + "123456789012345678901234567890" + "1234"))).isFalse();
   }
 }