Introduce authorization token for SSH requests

According to [1] successful SSH request can result in authorization
token that is later passed by Git LFS client to authorize request.
This change introduces SSH token that is verified (against time, project
and operation) when Git LFS operation is performed.
Default token expiration time is 10s.
This is necessary fix for [2].

[1]
https://github.com/git-lfs/git-lfs/blob/master/docs/api/authentication.md
[2] Change I28864fdaaf701e06fa9f60e7e913bc4a15da7b1d
Change-Id: Ia7961a1032e7a4d12c985c9b6d525d9105571259
Signed-off-by: Jacek Centkowski <jcentkowski@collab.net>
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 b4aae73..67fe512 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsApiServlet.java
@@ -74,13 +74,15 @@
     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), state, request.getOperation());
+    authorizeUser(userProvider.getUser(auth, projName, request.getOperation()),
+        state, request.getOperation());
 
     if (request.getOperation().equals(UPLOAD)
         && state.getProject().getState() == READ_ONLY) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java
index 0c548c9..d1b3207 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthUserProvider.java
@@ -14,9 +14,15 @@
 
 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;
@@ -29,21 +35,41 @@
   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) {
+      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) {
-    if (!Strings.isNullOrEmpty(auth)
-        && auth.startsWith(BASIC_AUTH_PREFIX)
-        && authCfg.isGitBasicAuth()) {
-      return user.get();
+  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/LfsSshAuth.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsSshAuth.java
index 34a0937..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)
@@ -55,20 +53,19 @@
     try {
       URL url = new URL(canonicalWebUrl);
       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(args.get(0))
+          .append(project)
           .append("/info/lfs");
-      Response.Action response = new Response.Action();
-      response.href = href.toString();
-      response.header =
-          Collections.singletonMap(HDR_AUTHORIZATION, "not:required");
-
-      return gson.toJson(response);
-
+      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/LfsFsRequestAuthorizer.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
index 4163f2f..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
@@ -18,6 +18,7 @@
 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;
 
@@ -29,16 +30,6 @@
 
 @Singleton
 public class LfsFsRequestAuthorizer {
-  class AuthInfo {
-    public final String authToken;
-    public final String expiresAt;
-
-    AuthInfo(String authToken, String expiresAt) {
-      this.authToken = authToken;
-      this.expiresAt = expiresAt;
-    }
-  }
-
   private final Processor processor;
 
   @Inject
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 2607329..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,7 +74,7 @@
   @Override
   public Response.Action getDownloadAction(AnyLongObjectId id) {
     Response.Action action = super.getDownloadAction(id);
-    LfsFsRequestAuthorizer.AuthInfo authInfo =
+    AuthInfo authInfo =
         authorizer.generateAuthInfo(DOWNLOAD, id, expirationSeconds);
     return new ExpiringAction(action.href, authInfo);
   }
@@ -82,7 +82,7 @@
   @Override
   public Response.Action getUploadAction(AnyLongObjectId id, long size) {
     Response.Action action = super.getUploadAction(id, size);
-    LfsFsRequestAuthorizer.AuthInfo authInfo =
+    AuthInfo authInfo =
         authorizer.generateAuthInfo(UPLOAD, id, expirationSeconds);
     return new ExpiringAction(action.href, authInfo);
   }
@@ -120,14 +120,4 @@
 
     return ensured;
   }
-
-  class ExpiringAction extends Response.Action {
-    public final String expiresAt;
-
-    ExpiringAction(String href, LfsFsRequestAuthorizer.AuthInfo info) {
-      this.href = href;
-      this.header = Collections.singletonMap(HDR_AUTHORIZATION, info.authToken);
-      this.expiresAt = info.expiresAt;
-    }
-  }
 }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 4d4ae5d..d0b57b8 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -101,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
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 2e99ae5..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
@@ -17,8 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 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.AuthInfo;
 import com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.Processor;
 
 import org.eclipse.jgit.lfs.lib.LongObjectId;