summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/account/AccountManager.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/account/AccountManager.java')
-rw-r--r--java/com/google/gerrit/server/account/AccountManager.java522
1 files changed, 522 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
new file mode 100644
index 0000000000..27945d1f07
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -0,0 +1,522 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.auth.NoSuchUserException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/** Tracks authentication related details for user accounts. */
+@Singleton
+public class AccountManager {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final Sequences sequences;
+ private final Accounts accounts;
+ private final Provider<AccountsUpdate> accountsUpdateProvider;
+ private final AccountCache byIdCache;
+ private final Realm realm;
+ private final IdentifiedUser.GenericFactory userFactory;
+ private final SshKeyCache sshKeyCache;
+ private final ProjectCache projectCache;
+ private final AtomicBoolean awaitsFirstAccountCheck;
+ private final ExternalIds externalIds;
+ private final GroupsUpdate.Factory groupsUpdateFactory;
+ private final boolean autoUpdateAccountActiveStatus;
+ private final SetInactiveFlag setInactiveFlag;
+
+ @Inject
+ AccountManager(
+ Sequences sequences,
+ @GerritServerConfig Config cfg,
+ Accounts accounts,
+ @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+ AccountCache byIdCache,
+ Realm accountMapper,
+ IdentifiedUser.GenericFactory userFactory,
+ SshKeyCache sshKeyCache,
+ ProjectCache projectCache,
+ ExternalIds externalIds,
+ GroupsUpdate.Factory groupsUpdateFactory,
+ SetInactiveFlag setInactiveFlag) {
+ this.sequences = sequences;
+ this.accounts = accounts;
+ this.accountsUpdateProvider = accountsUpdateProvider;
+ this.byIdCache = byIdCache;
+ this.realm = accountMapper;
+ this.userFactory = userFactory;
+ this.sshKeyCache = sshKeyCache;
+ this.projectCache = projectCache;
+ this.awaitsFirstAccountCheck =
+ new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
+ this.externalIds = externalIds;
+ this.groupsUpdateFactory = groupsUpdateFactory;
+ this.autoUpdateAccountActiveStatus =
+ cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
+ this.setInactiveFlag = setInactiveFlag;
+ }
+
+ /** @return user identified by this external identity string */
+ public Optional<Account.Id> lookup(String externalId) throws AccountException {
+ try {
+ return externalIds.get(ExternalId.Key.parse(externalId)).map(ExternalId::accountId);
+ } catch (IOException | ConfigInvalidException e) {
+ throw new AccountException("Cannot lookup account " + externalId, e);
+ }
+ }
+
+ /**
+ * Authenticate the user, potentially creating a new account if they are new.
+ *
+ * @param who identity of the user, with any details we received about them.
+ * @return the result of authenticating the user.
+ * @throws AccountException the account does not exist, and cannot be created, or exists, but
+ * cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
+ * added to the admin group (only for the first account).
+ */
+ public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
+ try {
+ who = realm.authenticate(who);
+ } catch (NoSuchUserException e) {
+ deactivateAccountIfItExists(who);
+ throw e;
+ }
+ try {
+ Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+ if (!optionalExtId.isPresent()) {
+ // New account, automatically create and return.
+ return create(who);
+ }
+
+ ExternalId extId = optionalExtId.get();
+ Optional<AccountState> accountState = byIdCache.get(extId.accountId());
+ if (!accountState.isPresent()) {
+ logger.atSevere().log(
+ "Authentication with external ID %s failed. Account %s doesn't exist.",
+ extId.key().get(), extId.accountId().get());
+ throw new AccountException("Authentication error, account not found");
+ }
+
+ // Account exists
+ Optional<Account> act = updateAccountActiveStatus(who, accountState.get().getAccount());
+ if (!act.isPresent()) {
+ // The account was deleted since we checked for it last time. This should never happen
+ // since we don't support deletion of accounts.
+ throw new AccountException("Authentication error, account not found");
+ }
+ if (!act.get().isActive()) {
+ throw new AccountException("Authentication error, account inactive");
+ }
+
+ // return the identity to the caller.
+ update(who, extId);
+ return new AuthResult(extId.accountId(), who.getExternalIdKey(), false);
+ } catch (OrmException | ConfigInvalidException e) {
+ throw new AccountException("Authentication error", e);
+ }
+ }
+
+ private void deactivateAccountIfItExists(AuthRequest authRequest) {
+ if (!shouldUpdateActiveStatus(authRequest)) {
+ return;
+ }
+ try {
+ Optional<ExternalId> extId = externalIds.get(authRequest.getExternalIdKey());
+ if (!extId.isPresent()) {
+ return;
+ }
+ setInactiveFlag.deactivate(extId.get().accountId());
+ } catch (Exception e) {
+ logger.atSevere().withCause(e).log(
+ "Unable to deactivate account %s",
+ authRequest
+ .getUserName()
+ .orElse(" for external ID key " + authRequest.getExternalIdKey().get()));
+ }
+ }
+
+ private Optional<Account> updateAccountActiveStatus(AuthRequest authRequest, Account account)
+ throws AccountException {
+ if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
+ return Optional.of(account);
+ }
+
+ if (authRequest.isActive()) {
+ try {
+ setInactiveFlag.activate(account.getId());
+ } catch (Exception e) {
+ throw new AccountException("Unable to activate account " + account.getId(), e);
+ }
+ } else {
+ try {
+ setInactiveFlag.deactivate(account.getId());
+ } catch (Exception e) {
+ throw new AccountException("Unable to deactivate account " + account.getId(), e);
+ }
+ }
+ return byIdCache.get(account.getId()).map(AccountState::getAccount);
+ }
+
+ private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
+ return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
+ }
+
+ private void update(AuthRequest who, ExternalId extId)
+ throws OrmException, IOException, ConfigInvalidException, AccountException {
+ IdentifiedUser user = userFactory.create(extId.accountId());
+ List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
+
+ // If the email address was modified by the authentication provider,
+ // update our records to match the changed email.
+ //
+ String newEmail = who.getEmailAddress();
+ String oldEmail = extId.email();
+ if (newEmail != null && !newEmail.equals(oldEmail)) {
+ ExternalId extIdWithNewEmail =
+ ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password());
+ checkEmailNotUsed(extIdWithNewEmail);
+ accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
+
+ if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
+ accountUpdates.add(u -> u.setPreferredEmail(newEmail));
+ }
+ }
+
+ if (!Strings.isNullOrEmpty(who.getDisplayName())
+ && !Objects.equals(user.getAccount().getFullName(), who.getDisplayName())) {
+ accountUpdates.add(u -> u.setFullName(who.getDisplayName()));
+ if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
+ accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
+ } else {
+ logger.atWarning().log(
+ "Not changing already set display name '%s' to '%s'",
+ user.getAccount().getFullName(), who.getDisplayName());
+ }
+ }
+
+ if (!realm.allowsEdit(AccountFieldName.USER_NAME)
+ && who.getUserName().isPresent()
+ && !who.getUserName().equals(user.getUserName())) {
+ if (user.getUserName().isPresent()) {
+ logger.atWarning().log(
+ "Not changing already set username %s to %s",
+ user.getUserName().get(), who.getUserName().get());
+ } else {
+ logger.atWarning().log("Not setting username to %s", who.getUserName().get());
+ }
+ }
+
+ if (!accountUpdates.isEmpty()) {
+ accountsUpdateProvider
+ .get()
+ .update(
+ "Update Account on Login",
+ user.getAccountId(),
+ AccountUpdater.joinConsumers(accountUpdates))
+ .orElseThrow(
+ () -> new OrmException("Account " + user.getAccountId() + " has been deleted"));
+ }
+ }
+
+ private AuthResult create(AuthRequest who)
+ throws OrmException, AccountException, IOException, ConfigInvalidException {
+ Account.Id newId = new Account.Id(sequences.nextAccountId());
+ logger.atFine().log("Assigning new Id %s to account", newId);
+
+ ExternalId extId =
+ ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
+ logger.atFine().log("Created external Id: %s", extId);
+ checkEmailNotUsed(extId);
+ ExternalId userNameExtId =
+ who.getUserName().isPresent() ? createUsername(newId, who.getUserName().get()) : null;
+
+ boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
+
+ AccountState accountState;
+ try {
+ accountState =
+ accountsUpdateProvider
+ .get()
+ .insert(
+ "Create Account on First Login",
+ newId,
+ u -> {
+ u.setFullName(who.getDisplayName())
+ .setPreferredEmail(extId.email())
+ .addExternalId(extId);
+ if (userNameExtId != null) {
+ u.addExternalId(userNameExtId);
+ }
+ });
+ } catch (DuplicateExternalIdKeyException e) {
+ throw new AccountException(
+ "Cannot assign external ID \""
+ + e.getDuplicateKey().get()
+ + "\" to account "
+ + newId
+ + "; external ID already in use.");
+ } finally {
+ // If adding the account failed, it may be that it actually was the
+ // first account. So we reset the 'check for first account'-guard, as
+ // otherwise the first account would not get administration permissions.
+ awaitsFirstAccountCheck.set(isFirstAccount);
+ }
+
+ if (userNameExtId != null) {
+ who.getUserName().ifPresent(sshKeyCache::evict);
+ }
+
+ IdentifiedUser user = userFactory.create(newId);
+
+ if (isFirstAccount) {
+ // This is the first user account on our site. Assume this user
+ // is going to be the site's administrator and just make them that
+ // to bootstrap the authentication database.
+ //
+ Permission admin =
+ projectCache
+ .getAllProjects()
+ .getConfig()
+ .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+ .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
+
+ AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
+ addGroupMember(adminGroupUuid, user);
+ }
+
+ realm.onCreateAccount(who, accountState.getAccount());
+ return new AuthResult(newId, extId.key(), true);
+ }
+
+ private ExternalId createUsername(Account.Id accountId, String username)
+ throws AccountUserNameException {
+ checkArgument(!Strings.isNullOrEmpty(username));
+
+ if (!ExternalId.isValidUsername(username)) {
+ throw new AccountUserNameException(
+ String.format(
+ "Cannot assign user name \"%s\" to account %s; name does not conform.",
+ username, accountId));
+ }
+ return ExternalId.create(SCHEME_USERNAME, username, accountId);
+ }
+
+ private void checkEmailNotUsed(ExternalId extIdToBeCreated) throws IOException, AccountException {
+ String email = extIdToBeCreated.email();
+ if (email == null) {
+ return;
+ }
+
+ Set<ExternalId> existingExtIdsWithEmail = externalIds.byEmail(email);
+ if (existingExtIdsWithEmail.isEmpty()) {
+ return;
+ }
+
+ logger.atWarning().log(
+ "Email %s is already assigned to account %s;"
+ + " cannot create external ID %s with the same email for account %s.",
+ email,
+ existingExtIdsWithEmail.iterator().next().accountId().get(),
+ extIdToBeCreated.key().get(),
+ extIdToBeCreated.accountId().get());
+ throw new AccountException("Email '" + email + "' in use by another account");
+ }
+
+ private void addGroupMember(AccountGroup.UUID groupUuid, IdentifiedUser user)
+ throws OrmException, IOException, ConfigInvalidException, AccountException {
+ // The user initiated this request by logging in. -> Attribute all modifications to that user.
+ GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
+ InternalGroupUpdate groupUpdate =
+ InternalGroupUpdate.builder()
+ .setMemberModification(
+ memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
+ .build();
+ try {
+ groupsUpdate.updateGroup(groupUuid, groupUpdate);
+ } catch (NoSuchGroupException e) {
+ throw new AccountException(String.format("Group %s not found", groupUuid));
+ }
+ }
+
+ /**
+ * Link another authentication identity to an existing account.
+ *
+ * @param to account to link the identity onto.
+ * @param who the additional identity.
+ * @return the result of linking the identity to the user.
+ * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+ * this time.
+ */
+ public AuthResult link(Account.Id to, AuthRequest who)
+ throws AccountException, OrmException, IOException, ConfigInvalidException {
+ Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+ if (optionalExtId.isPresent()) {
+ ExternalId extId = optionalExtId.get();
+ if (!extId.accountId().equals(to)) {
+ throw new AccountException(
+ "Identity '" + extId.key().get() + "' in use by another account");
+ }
+ update(who, extId);
+ } else {
+ ExternalId newExtId =
+ ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
+ checkEmailNotUsed(newExtId);
+ accountsUpdateProvider
+ .get()
+ .update(
+ "Link External ID",
+ to,
+ (a, u) -> {
+ u.addExternalId(newExtId);
+ if (who.getEmailAddress() != null && a.getAccount().getPreferredEmail() == null) {
+ u.setPreferredEmail(who.getEmailAddress());
+ }
+ });
+ }
+ return new AuthResult(to, who.getExternalIdKey(), false);
+ }
+
+ /**
+ * Update the link to another unique authentication identity to an existing account.
+ *
+ * <p>Existing external identities with the same scheme will be removed and replaced with the new
+ * one.
+ *
+ * @param to account to link the identity onto.
+ * @param who the additional identity.
+ * @return the result of linking the identity to the user.
+ * @throws OrmException
+ * @throws AccountException the identity belongs to a different account, or it cannot be linked at
+ * this time.
+ */
+ public AuthResult updateLink(Account.Id to, AuthRequest who)
+ throws OrmException, AccountException, IOException, ConfigInvalidException {
+ accountsUpdateProvider
+ .get()
+ .update(
+ "Delete External IDs on Update Link",
+ to,
+ (a, u) -> {
+ Collection<ExternalId> filteredExtIdsByScheme =
+ a.getExternalIds(who.getExternalIdKey().scheme());
+ if (filteredExtIdsByScheme.isEmpty()) {
+ return;
+ }
+
+ if (filteredExtIdsByScheme.size() > 1
+ || !filteredExtIdsByScheme.stream()
+ .anyMatch(e -> e.key().equals(who.getExternalIdKey()))) {
+ u.deleteExternalIds(filteredExtIdsByScheme);
+ }
+ });
+
+ return link(to, who);
+ }
+
+ /**
+ * Unlink an external identity from an existing account.
+ *
+ * @param from account to unlink the external identity from
+ * @param extIdKey the key of the external ID that should be deleted
+ * @throws AccountException the identity belongs to a different account, or the identity was not
+ * found
+ */
+ public void unlink(Account.Id from, ExternalId.Key extIdKey)
+ throws AccountException, OrmException, IOException, ConfigInvalidException {
+ unlink(from, ImmutableList.of(extIdKey));
+ }
+
+ /**
+ * Unlink an external identities from an existing account.
+ *
+ * @param from account to unlink the external identity from
+ * @param extIdKeys the keys of the external IDs that should be deleted
+ * @throws AccountException any of the identity belongs to a different account, or any of the
+ * identity was not found
+ */
+ public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
+ throws AccountException, OrmException, IOException, ConfigInvalidException {
+ if (extIdKeys.isEmpty()) {
+ return;
+ }
+
+ List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
+ for (ExternalId.Key extIdKey : extIdKeys) {
+ Optional<ExternalId> extId = externalIds.get(extIdKey);
+ if (extId.isPresent()) {
+ if (!extId.get().accountId().equals(from)) {
+ throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
+ }
+ extIds.add(extId.get());
+ } else {
+ throw new AccountException("Identity '" + extIdKey.get() + "' not found");
+ }
+ }
+
+ accountsUpdateProvider
+ .get()
+ .update(
+ "Unlink External ID" + (extIds.size() > 1 ? "s" : ""),
+ from,
+ (a, u) -> {
+ u.deleteExternalIds(extIds);
+ if (a.getAccount().getPreferredEmail() != null
+ && extIds.stream()
+ .anyMatch(e -> a.getAccount().getPreferredEmail().equals(e.email()))) {
+ u.setPreferredEmail(null);
+ }
+ });
+ }
+}