| /* |
| * Copyright 2016 gitblit.com. |
| * |
| * 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.gitblit.transport.ssh; |
| |
| import java.io.IOException; |
| import java.security.GeneralSecurityException; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.TreeMap; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import org.apache.sshd.common.config.keys.KeyUtils; |
| import org.apache.sshd.common.util.GenericUtils; |
| import org.apache.sshd.server.config.keys.AuthorizedKeyEntry; |
| |
| import com.gitblit.IStoredSettings; |
| import com.gitblit.Keys; |
| import com.gitblit.Constants.AccessPermission; |
| import com.gitblit.ldap.LdapConnection; |
| import com.gitblit.models.UserModel; |
| import com.gitblit.utils.StringUtils; |
| import com.google.common.base.Joiner; |
| import com.google.inject.Inject; |
| import com.unboundid.ldap.sdk.BindResult; |
| import com.unboundid.ldap.sdk.ResultCode; |
| import com.unboundid.ldap.sdk.SearchResult; |
| import com.unboundid.ldap.sdk.SearchResultEntry; |
| |
| /** |
| * LDAP-only public key manager |
| * |
| * Retrieves public keys from user's LDAP entries. Using this key manager, |
| * no SSH keys can be edited, i.e. added, removed, permissions changed, etc. |
| * |
| * This key manager supports SSH key entries in LDAP of the following form: |
| * [<prefix>:] [<options>] <type> <key> [<comment>] |
| * This follows the required form of entries in the authenticated_keys file, |
| * with an additional optional prefix. Key entries must have a key type |
| * (like "ssh-rsa") and a key, and may have a comment at the end. |
| * |
| * An entry may specify login options as specified for the authorized_keys file. |
| * The 'environment' option may be used to set the permissions for the key |
| * by setting a 'gbPerm' environment variable. The key manager will interpret |
| * such a environment variable option and use the set permission string to set |
| * the permission on the key in Gitblit. Example: |
| * environment="gbPerm=V",pty ssh-rsa AAAxjka.....dv= Clone only key |
| * Above entry would create a RSA key with the comment "Clone only key" and |
| * set the key permission to CLONE. All other options are ignored. |
| * |
| * In Active Directory SSH public keys are sometimes stored in the attribute |
| * 'altSecurityIdentity'. The attribute value is usually prefixed by a type |
| * identifier. LDAP entries could have the following attribute values: |
| * altSecurityIdentity: X.509: ADKEJBAKDBZUPABBD... |
| * altSecurityIdentity: SshKey: ssh-dsa AAAAknenazuzucbhda... |
| * This key manager supports this by allowing an optional prefix to identify |
| * SSH keys. The prefix to be used should be set in the 'realm.ldap.sshPublicKey' |
| * setting by separating it from the attribute name with a colon, e.g.: |
| * realm.ldap.sshPublicKey = altSecurityIdentity:SshKey |
| * |
| * @author Florian Zschocke |
| * |
| */ |
| public class LdapKeyManager extends IPublicKeyManager { |
| |
| /** |
| * Pattern to find prefixes like 'SSHKey:' in key entries. |
| * These prefixes describe the type of an altSecurityIdentity. |
| * The pattern accepts anything but quote and colon up to the |
| * first colon at the start of a string. |
| */ |
| private static final Pattern PREFIX_PATTERN = Pattern.compile("^([^\":]+):"); |
| /** |
| * Pattern to find the string describing Gitblit permissions for a SSH key. |
| * The pattern matches on a string starting with 'gbPerm', matched case-insensitive, |
| * followed by '=' with optional whitespace around it, followed by a string of |
| * upper and lower case letters and '+' and '-' for the permission, which can optionally |
| * be enclosed in '"' or '\"' (only the leading quote is matched in the pattern). |
| * Only the group describing the permission is a capturing group. |
| */ |
| private static final Pattern GB_PERM_PATTERN = Pattern.compile("(?i:gbPerm)\\s*=\\s*(?:\\\\\"|\")?\\s*([A-Za-z+-]+)"); |
| |
| |
| private final IStoredSettings settings; |
| |
| |
| |
| @Inject |
| public LdapKeyManager(IStoredSettings settings) { |
| this.settings = settings; |
| } |
| |
| |
| @Override |
| public String toString() { |
| return getClass().getSimpleName(); |
| } |
| |
| @Override |
| public LdapKeyManager start() { |
| log.info(toString()); |
| return this; |
| } |
| |
| @Override |
| public boolean isReady() { |
| return true; |
| } |
| |
| @Override |
| public LdapKeyManager stop() { |
| return this; |
| } |
| |
| @Override |
| protected boolean isStale(String username) { |
| // always return true so we gets keys from LDAP every time |
| return true; |
| } |
| |
| @Override |
| protected List<SshKey> getKeysImpl(String username) { |
| try (LdapConnection conn = new LdapConnection(settings)) { |
| if (conn.connect()) { |
| log.info("loading ssh key for {} from LDAP directory", username); |
| |
| BindResult bindResult = conn.bind(); |
| if (bindResult == null) { |
| conn.close(); |
| return null; |
| } |
| |
| // Search the user entity |
| |
| // Support prefixing the key data, e.g. when using altSecurityIdentities in AD. |
| String pubKeyAttribute = settings.getString(Keys.realm.ldap.sshPublicKey, "sshPublicKey"); |
| String pkaPrefix = null; |
| int idx = pubKeyAttribute.indexOf(':'); |
| if (idx > 0) { |
| pkaPrefix = pubKeyAttribute.substring(idx +1); |
| pubKeyAttribute = pubKeyAttribute.substring(0, idx); |
| } |
| |
| SearchResult result = conn.searchUser(getSimpleUsername(username), Arrays.asList(pubKeyAttribute)); |
| conn.close(); |
| |
| if (result != null && result.getResultCode() == ResultCode.SUCCESS) { |
| if ( result.getEntryCount() > 1) { |
| log.info("Found more than one entry for user {} in LDAP. Cannot retrieve SSH key.", username); |
| return null; |
| } else if ( result.getEntryCount() < 1) { |
| log.info("Found no entry for user {} in LDAP. Cannot retrieve SSH key.", username); |
| return null; |
| } |
| |
| // Retrieve the SSH key attributes |
| SearchResultEntry foundUser = result.getSearchEntries().get(0); |
| String[] attrs = foundUser.getAttributeValues(pubKeyAttribute); |
| if (attrs == null ||attrs.length == 0) { |
| log.info("found no keys for user {} under attribute {} in directory", username, pubKeyAttribute); |
| return null; |
| } |
| |
| |
| // Filter resulting list to match with required special prefix in entry |
| List<GbAuthorizedKeyEntry> authorizedKeys = new ArrayList<>(attrs.length); |
| Matcher m = PREFIX_PATTERN.matcher(""); |
| for (int i = 0; i < attrs.length; ++i) { |
| // strip out line breaks |
| String keyEntry = Joiner.on("").join(attrs[i].replace("\r\n", "\n").split("\n")); |
| m.reset(keyEntry); |
| try { |
| if (m.lookingAt()) { // Key is prefixed in LDAP |
| if (pkaPrefix == null) { |
| continue; |
| } |
| String prefix = m.group(1).trim(); |
| if (! pkaPrefix.equalsIgnoreCase(prefix)) { |
| continue; |
| } |
| String s = keyEntry.substring(m.end()); // Strip prefix off |
| authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); |
| |
| } else { // Key is not prefixed in LDAP |
| if (pkaPrefix != null) { |
| continue; |
| } |
| String s = keyEntry; // Strip prefix off |
| authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); |
| } |
| } catch (IllegalArgumentException e) { |
| log.info("Failed to parse key entry={}:", keyEntry, e.getMessage()); |
| } |
| } |
| |
| List<SshKey> keyList = new ArrayList<>(authorizedKeys.size()); |
| for (GbAuthorizedKeyEntry keyEntry : authorizedKeys) { |
| try { |
| SshKey key = new SshKey(keyEntry.resolvePublicKey()); |
| key.setComment(keyEntry.getComment()); |
| setKeyPermissions(key, keyEntry); |
| keyList.add(key); |
| } catch (GeneralSecurityException | IOException e) { |
| log.warn("Error resolving key entry for user {}. Entry={}", username, keyEntry, e); |
| } |
| } |
| return keyList; |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| |
| @Override |
| public boolean addKey(String username, SshKey key) { |
| return false; |
| } |
| |
| @Override |
| public boolean removeKey(String username, SshKey key) { |
| return false; |
| } |
| |
| @Override |
| public boolean removeAllKeys(String username) { |
| return false; |
| } |
| |
| |
| public boolean supportsWritingKeys(UserModel user) { |
| return false; |
| } |
| |
| public boolean supportsCommentChanges(UserModel user) { |
| return false; |
| } |
| |
| public boolean supportsPermissionChanges(UserModel user) { |
| return false; |
| } |
| |
| |
| private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) { |
| List<String> env = keyEntry.getLoginOptionValues("environment"); |
| if (env != null && !env.isEmpty()) { |
| // Walk over all entries and find one that sets 'gbPerm'. The last one wins. |
| for (String envi : env) { |
| Matcher m = GB_PERM_PATTERN.matcher(envi); |
| if (m.find()) { |
| String perm = m.group(1).trim(); |
| AccessPermission ap = AccessPermission.fromCode(perm); |
| if (ap == AccessPermission.NONE) { |
| ap = AccessPermission.valueOf(perm.toUpperCase()); |
| } |
| |
| if (ap != null && ap != AccessPermission.NONE) { |
| try { |
| key.setPermission(ap); |
| } catch (IllegalArgumentException e) { |
| log.warn("Incorrect permissions ({}) set for SSH key entry {}.", ap, envi, e); |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| |
| /** |
| * Returns a simple username without any domain prefixes. |
| * |
| * @param username |
| * @return a simple username |
| */ |
| private String getSimpleUsername(String username) { |
| int lastSlash = username.lastIndexOf('\\'); |
| if (lastSlash > -1) { |
| username = username.substring(lastSlash + 1); |
| } |
| |
| return username; |
| } |
| |
| |
| /** |
| * Extension of the AuthorizedKeyEntry from Mina SSHD with better option parsing. |
| * |
| * The class makes use of code from the two methods copied from the original |
| * Mina SSHD AuthorizedKeyEntry class. The code is rewritten to improve user login |
| * option support. Options are correctly parsed even if they have whitespace within |
| * double quotes. Options can occur multiple times, which is needed for example for |
| * the "environment" option. Thus for an option a list of strings is kept, holding |
| * multiple option values. |
| */ |
| private static class GbAuthorizedKeyEntry extends AuthorizedKeyEntry { |
| |
| private static final long serialVersionUID = 1L; |
| /** |
| * Pattern to extract the first part of the key entry without whitespace or only with quoted whitespace. |
| * The pattern essentially splits the line in two parts with two capturing groups. All other groups |
| * in the pattern are non-capturing. The first part is a continuous string that only includes double quoted |
| * whitespace and ends in whitespace. The second part is the rest of the line. |
| * The first part is at the beginning of the line, the lead-in. For a SSH key entry this can either be |
| * login options (see authorized keys file description) or the key type. Since options, other than the |
| * key type, can include whitespace and escaped double quotes within double quotes, the pattern takes |
| * care of that by searching for either "characters that are not whitespace and not double quotes" |
| * or "a double quote, followed by 'characters that are not a double quote or backslash, or a backslash |
| * and then a double quote, or a backslash', followed by a double quote". |
| */ |
| private static final Pattern LEADIN_PATTERN = Pattern.compile("^((?:[^\\s\"]*|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))*\\s+)(.+)"); |
| /** |
| * Pattern to split a comma separated list of options. |
| * Since an option could contain commas (as well as escaped double quotes) within double quotes |
| * in the option value, a simple split on comma is not enough. So the pattern searches for multiple |
| * occurrences of: |
| * characters that are not double quotes or a comma, or |
| * a double quote followed by: characters that are not a double quote or backslash, or |
| * a backslash and then a double quote, or |
| * a backslash, |
| * followed by a double quote. |
| */ |
| private static final Pattern OPTION_PATTERN = Pattern.compile("([^\",]+|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))+"); |
| |
| // for options that have no value, "true" is used |
| private Map<String, List<String>> loginOptionsMulti = Collections.emptyMap(); |
| |
| |
| List<String> getLoginOptionValues(String option) { |
| return loginOptionsMulti.get(option); |
| } |
| |
| |
| |
| /** |
| * @param line Original line from an <code>authorized_keys</code> file |
| * @return {@link GbAuthorizedKeyEntry} or {@code null} if the line is |
| * {@code null}/empty or a comment line |
| * @throws IllegalArgumentException If failed to parse/decode the line |
| * @see #COMMENT_CHAR |
| */ |
| public static GbAuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException { |
| line = GenericUtils.trimToEmpty(line); |
| if (StringUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) { |
| return null; |
| } |
| |
| Matcher m = LEADIN_PATTERN.matcher(line); |
| if (! m.lookingAt()) { |
| throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); |
| } |
| |
| String keyType = m.group(1).trim(); |
| final GbAuthorizedKeyEntry entry; |
| if (KeyUtils.getPublicKeyEntryDecoder(keyType) == null) { // assume this is due to the fact that it starts with login options |
| entry = parseAuthorizedKeyEntry(m.group(2)); |
| if (entry == null) { |
| throw new IllegalArgumentException("Bad format (no key data after login options): " + line); |
| } |
| |
| entry.parseAndSetLoginOptions(keyType); |
| } else { |
| int startPos = line.indexOf(' '); |
| if (startPos <= 0) { |
| throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); |
| } |
| |
| int endPos = line.indexOf(' ', startPos + 1); |
| if (endPos <= startPos) { |
| endPos = line.length(); |
| } |
| |
| String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line; |
| String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null; |
| entry = parsePublicKeyEntry(new GbAuthorizedKeyEntry(), encData); |
| entry.setComment(comment); |
| } |
| |
| return entry; |
| } |
| |
| private void parseAndSetLoginOptions(String options) { |
| Matcher m = OPTION_PATTERN.matcher(options); |
| if (! m.find()) { |
| loginOptionsMulti = Collections.emptyMap(); |
| } |
| Map<String, List<String>> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); |
| |
| do { |
| String p = m.group(); |
| p = GenericUtils.trimToEmpty(p); |
| if (StringUtils.isEmpty(p)) { |
| continue; |
| } |
| |
| int pos = p.indexOf('='); |
| String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos)); |
| CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1)); |
| value = GenericUtils.stripQuotes(value); |
| |
| // For options without value the value is set to TRUE. |
| if (value == null) { |
| value = Boolean.TRUE.toString(); |
| } |
| |
| List<String> opts = optsMap.get(name); |
| if (opts == null) { |
| opts = new ArrayList<String>(); |
| optsMap.put(name, opts); |
| } |
| opts.add(value.toString()); |
| } while(m.find()); |
| |
| loginOptionsMulti = optsMap; |
| } |
| } |
| |
| } |