// 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 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 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 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 optionalExtId = externalIds.get(who.getExternalIdKey()); if (!optionalExtId.isPresent()) { // New account, automatically create and return. return create(who); } ExternalId extId = optionalExtId.get(); Optional 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 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 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 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> 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 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 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. * *

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 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 extIdKeys) throws AccountException, OrmException, IOException, ConfigInvalidException { if (extIdKeys.isEmpty()) { return; } List extIds = new ArrayList<>(extIdKeys.size()); for (ExternalId.Key extIdKey : extIdKeys) { Optional 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); } }); } }