blob: 300a945fddcedd94cced417bdd595309760eccf6 [file] [log] [blame]
// Copyright (C) 2013 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.github.oauth;
import static com.googlesource.gerrit.plugins.github.oauth.CanonicalWebUrls.trimTrailingSlash;
import static com.googlesource.gerrit.plugins.github.oauth.GitHubOAuthConfig.KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSortedMap;
import com.google.gerrit.extensions.client.AuthType;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.github.oauth.OAuthProtocol.Scope;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.SortedMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.Getter;
import org.eclipse.jgit.lib.Config;
@Singleton
public class GitHubOAuthConfig {
private final Config config;
public static final String CONF_SECTION = "github";
public static final String CONF_KEY_SECTION = "github-key";
public static final String GITHUB_OAUTH_AUTHORIZE = "/login/oauth/authorize";
public static final String GITHUB_OAUTH_ACCESS_TOKEN = "/login/oauth/access_token";
public static final String GERRIT_OAUTH_FINAL = "/oauth";
public static final String GITHUB_URL_DEFAULT = "https://github.com";
public static final String GITHUB_API_URL_DEFAULT = "https://api.github.com";
public static final String GERRIT_LOGIN = "/login";
public static final String GERRIT_LOGOUT = "/logout";
public static final String GITHUB_PLUGIN_OAUTH_SCOPE = "/plugins/github-plugin/static/scope.html";
public static final ScopeKey GITHUB_DEFAULT_SCOPES_KEY = new ScopeKey("scopes", null, 0);
public final String gitHubUrl;
public final String gitHubApiUrl;
public final String gitHubClientId;
public final String gitHubClientSecret;
public final String logoutRedirectUrl;
public final String httpHeader;
public final String gitHubOAuthUrl;
public final String gitHubOAuthAccessTokenUrl;
public final String scopeSelectionUrl;
public final boolean enabled;
@Getter public final SortedMap<ScopeKey, List<OAuthProtocol.Scope>> scopes;
@Getter public final Map<String, SortedMap<ScopeKey, List<OAuthProtocol.Scope>>> virtualScopes;
public final int fileUpdateMaxRetryCount;
public final int fileUpdateMaxRetryIntervalMsec;
public final String oauthHttpHeader;
public final long httpConnectionTimeout;
public final long httpReadTimeout;
private final Map<String, KeyConfig> keyConfigMap;
private final KeyConfig currentKeyConfig;
private final Optional<String> cookieDomain;
@Inject
protected GitHubOAuthConfig(@GerritServerConfig Config config) {
this.config = config;
httpHeader =
Preconditions.checkNotNull(
config.getString("auth", null, "httpHeader"),
"HTTP Header for GitHub user must be provided");
gitHubUrl =
trimTrailingSlash(
MoreObjects.firstNonNull(
config.getString(CONF_SECTION, null, "url"), GITHUB_URL_DEFAULT));
gitHubApiUrl =
trimTrailingSlash(
MoreObjects.firstNonNull(
config.getString(CONF_SECTION, null, "apiUrl"), GITHUB_API_URL_DEFAULT));
gitHubClientId =
Preconditions.checkNotNull(
config.getString(CONF_SECTION, null, "clientId"), "GitHub `clientId` must be provided");
gitHubClientSecret =
Preconditions.checkNotNull(
config.getString(CONF_SECTION, null, "clientSecret"),
"GitHub `clientSecret` must be provided");
scopeSelectionUrl = config.getString(CONF_SECTION, null, "scopeSelectionUrl");
oauthHttpHeader = config.getString("auth", null, "httpExternalIdHeader");
gitHubOAuthUrl = gitHubUrl + GITHUB_OAUTH_AUTHORIZE;
gitHubOAuthAccessTokenUrl = gitHubUrl + GITHUB_OAUTH_ACCESS_TOKEN;
logoutRedirectUrl = config.getString(CONF_SECTION, null, "logoutRedirectUrl");
enabled = config.getString("auth", null, "type").equalsIgnoreCase(AuthType.HTTP.toString());
cookieDomain = Optional.ofNullable(config.getString("auth", null, "cookieDomain"));
scopes = getScopes(config);
virtualScopes = getVirtualScopes(config);
fileUpdateMaxRetryCount = config.getInt(CONF_SECTION, "fileUpdateMaxRetryCount", 3);
fileUpdateMaxRetryIntervalMsec =
config.getInt(CONF_SECTION, "fileUpdateMaxRetryIntervalMsec", 3000);
httpConnectionTimeout =
TimeUnit.MILLISECONDS.convert(
ConfigUtil.getTimeUnit(
config, CONF_SECTION, null, "httpConnectionTimeout", 30, TimeUnit.SECONDS),
TimeUnit.SECONDS);
httpReadTimeout =
TimeUnit.MILLISECONDS.convert(
ConfigUtil.getTimeUnit(
config, CONF_SECTION, null, "httpReadTimeout", 30, TimeUnit.SECONDS),
TimeUnit.SECONDS);
Map<String, KeyConfig> configuredKeyConfig =
config.getSubsections(CONF_KEY_SECTION).stream()
.map(KeyConfig::new)
.collect(Collectors.toMap(KeyConfig::getKeyId, Function.identity()));
keyConfigMap = configuredKeyConfig;
List<KeyConfig> currentKeyConfigs =
keyConfigMap.values().stream().filter(KeyConfig::isCurrent).collect(Collectors.toList());
if (currentKeyConfigs.size() != 1) {
throw new IllegalStateException(
String.format(
"Expected exactly 1 subsection of '%s' to be configured as 'current', %d found",
CONF_KEY_SECTION, currentKeyConfigs.size()));
}
currentKeyConfig = currentKeyConfigs.get(0);
}
private SortedMap<ScopeKey, List<Scope>> getScopes(Config config) {
return getScopesInSection(config, null);
}
private Map<String, SortedMap<ScopeKey, List<Scope>>> getVirtualScopes(Config config) {
return config.getSubsections(CONF_SECTION).stream()
.collect(Collectors.toMap(k -> k, v -> getScopesInSection(config, v)));
}
private SortedMap<ScopeKey, List<Scope>> getScopesInSection(Config config, String subsection) {
return config.getNames(CONF_SECTION, subsection, true).stream()
.filter(k -> k.startsWith("scopes"))
.filter(k -> !k.endsWith("Description"))
.filter(k -> !k.endsWith("Sequence"))
.collect(
ImmutableSortedMap.toImmutableSortedMap(
Comparator.comparing(ScopeKey::getSequence),
k ->
new ScopeKey(
k,
config.getString(CONF_SECTION, subsection, k + "Description"),
config.getInt(CONF_SECTION, subsection, k + "Sequence", 0)),
v -> parseScopesString(config.getString(CONF_SECTION, subsection, v))));
}
private List<Scope> parseScopesString(String scopesString) {
ArrayList<Scope> result = new ArrayList<>();
if (Strings.emptyToNull(scopesString) != null) {
String[] scopesStrings = scopesString.split(",");
for (String scope : scopesStrings) {
result.add(Enum.valueOf(Scope.class, scope.trim()));
}
}
return result;
}
public Scope[] getDefaultScopes() {
if (scopes == null || scopes.get(GITHUB_DEFAULT_SCOPES_KEY) == null) {
return new Scope[0];
}
return scopes.get(GITHUB_DEFAULT_SCOPES_KEY).toArray(new Scope[0]);
}
public KeyConfig getCurrentKeyConfig() {
return currentKeyConfig;
}
public KeyConfig getKeyConfig(String subsection) {
return keyConfigMap.get(subsection);
}
public Optional<String> getCookieDomain() {
return cookieDomain;
}
public class KeyConfig {
public static final int PASSWORD_LENGTH_DEFAULT = 16;
public static final String CIPHER_ALGORITHM_DEFAULT = "AES/ECB/PKCS5Padding";
public static final String SECRET_KEY_ALGORITHM_DEFAULT = "AES";
public static final boolean IS_CURRENT_DEFAULT = false;
public static final String KEY_ID_DEFAULT = "current";
public static final String PASSWORD_DEVICE_CONFIG_LABEL = "passwordDevice";
public static final String PASSWORD_LENGTH_CONFIG_LABEL = "passwordLength";
public static final String SECRET_KEY_CONFIG_LABEL = "secretKeyAlgorithm";
public static final String CIPHER_ALGO_CONFIG_LABEL = "cipherAlgorithm";
public static final String CURRENT_CONFIG_LABEL = "current";
public static final String KEY_DELIMITER = ":";
private final String passwordDevice;
private final Integer passwordLength;
private final String cipherAlgorithm;
private final String secretKeyAlgorithm;
private final String keyId;
private final Boolean isCurrent;
KeyConfig(String keyId) {
if (keyId.contains(KEY_DELIMITER)) {
throw new IllegalStateException(
String.format(
"Configuration error. %s.%s should not contain '%s'",
CONF_KEY_SECTION, keyId, KEY_DELIMITER));
}
this.passwordDevice = trimTrailingSlash(getPasswordDeviceOrThrow(config, keyId));
this.passwordLength =
config.getInt(
CONF_KEY_SECTION, keyId, PASSWORD_LENGTH_CONFIG_LABEL, PASSWORD_LENGTH_DEFAULT);
isCurrent =
config.getBoolean(CONF_KEY_SECTION, keyId, CURRENT_CONFIG_LABEL, IS_CURRENT_DEFAULT);
this.cipherAlgorithm =
MoreObjects.firstNonNull(
config.getString(CONF_KEY_SECTION, keyId, CIPHER_ALGO_CONFIG_LABEL),
CIPHER_ALGORITHM_DEFAULT);
this.secretKeyAlgorithm =
MoreObjects.firstNonNull(
config.getString(CONF_KEY_SECTION, keyId, SECRET_KEY_CONFIG_LABEL),
SECRET_KEY_ALGORITHM_DEFAULT);
this.keyId = keyId;
}
public byte[] readPassword() throws IOException {
Path devicePath = Paths.get(passwordDevice);
try (FileInputStream in = new FileInputStream(devicePath.toFile())) {
byte[] passphrase = new byte[passwordLength];
if (in.read(passphrase) < passwordLength) {
throw new IOException("End of password device has already been reached");
}
return passphrase;
}
}
public String getCipherAlgorithm() {
return cipherAlgorithm;
}
public String getSecretKeyAlgorithm() {
return secretKeyAlgorithm;
}
public Boolean isCurrent() {
return isCurrent;
}
public String getKeyId() {
return keyId;
}
}
/**
* Method returns the password device value for a given {@code keyId}.
*
* @throws {@link IllegalStateException} when password device is not configured for {@code keyId}
*/
private static String getPasswordDeviceOrThrow(Config config, String keyId) {
String passwordDevice =
config.getString(CONF_KEY_SECTION, keyId, KeyConfig.PASSWORD_DEVICE_CONFIG_LABEL);
if (Strings.isNullOrEmpty(passwordDevice)) {
throw new IllegalStateException(
String.format(
"Configuration error. Missing %s.%s for key-id '%s'",
CONF_KEY_SECTION, PASSWORD_DEVICE_CONFIG_LABEL, keyId));
}
return passwordDevice;
}
}