Line data Source code
1 : // Copyright (C) 2009 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.account;
16 :
17 : import static com.google.common.base.Preconditions.checkArgument;
18 : import static com.google.common.collect.ImmutableSet.toImmutableSet;
19 : import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
20 :
21 : import com.google.common.annotations.VisibleForTesting;
22 : import com.google.common.base.Strings;
23 : import com.google.common.collect.ImmutableList;
24 : import com.google.common.collect.ImmutableSet;
25 : import com.google.common.collect.Sets;
26 : import com.google.common.flogger.FluentLogger;
27 : import com.google.gerrit.common.data.GlobalCapability;
28 : import com.google.gerrit.entities.AccessSection;
29 : import com.google.gerrit.entities.Account;
30 : import com.google.gerrit.entities.AccountGroup;
31 : import com.google.gerrit.entities.Permission;
32 : import com.google.gerrit.exceptions.NoSuchGroupException;
33 : import com.google.gerrit.exceptions.StorageException;
34 : import com.google.gerrit.extensions.client.AccountFieldName;
35 : import com.google.gerrit.server.IdentifiedUser;
36 : import com.google.gerrit.server.ServerInitiated;
37 : import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
38 : import com.google.gerrit.server.account.externalids.ExternalId;
39 : import com.google.gerrit.server.account.externalids.ExternalIdFactory;
40 : import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
41 : import com.google.gerrit.server.account.externalids.ExternalIds;
42 : import com.google.gerrit.server.auth.NoSuchUserException;
43 : import com.google.gerrit.server.config.GerritServerConfig;
44 : import com.google.gerrit.server.group.db.GroupDelta;
45 : import com.google.gerrit.server.group.db.GroupsUpdate;
46 : import com.google.gerrit.server.notedb.Sequences;
47 : import com.google.gerrit.server.project.ProjectCache;
48 : import com.google.gerrit.server.ssh.SshKeyCache;
49 : import com.google.inject.Inject;
50 : import com.google.inject.Provider;
51 : import com.google.inject.Singleton;
52 : import java.io.IOException;
53 : import java.util.ArrayList;
54 : import java.util.Collection;
55 : import java.util.List;
56 : import java.util.Objects;
57 : import java.util.Optional;
58 : import java.util.Set;
59 : import java.util.concurrent.atomic.AtomicBoolean;
60 : import java.util.function.Consumer;
61 : import org.eclipse.jgit.errors.ConfigInvalidException;
62 : import org.eclipse.jgit.lib.Config;
63 :
64 : /** Tracks authentication related details for user accounts. */
65 : @Singleton
66 : public class AccountManager {
67 152 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
68 :
69 : private final Sequences sequences;
70 : private final Accounts accounts;
71 : private final Provider<AccountsUpdate> accountsUpdateProvider;
72 : private final AccountCache byIdCache;
73 : private final Realm realm;
74 : private final IdentifiedUser.GenericFactory userFactory;
75 : private final SshKeyCache sshKeyCache;
76 : private final ProjectCache projectCache;
77 : private final AtomicBoolean awaitsFirstAccountCheck;
78 : private final ExternalIds externalIds;
79 : private final GroupsUpdate.Factory groupsUpdateFactory;
80 : private final boolean autoUpdateAccountActiveStatus;
81 : private final SetInactiveFlag setInactiveFlag;
82 : private final ExternalIdFactory externalIdFactory;
83 : private final ExternalIdKeyFactory externalIdKeyFactory;
84 :
85 : @VisibleForTesting
86 : @Inject
87 : public AccountManager(
88 : Sequences sequences,
89 : @GerritServerConfig Config cfg,
90 : Accounts accounts,
91 : @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
92 : AccountCache byIdCache,
93 : Realm accountMapper,
94 : IdentifiedUser.GenericFactory userFactory,
95 : SshKeyCache sshKeyCache,
96 : ProjectCache projectCache,
97 : ExternalIds externalIds,
98 : GroupsUpdate.Factory groupsUpdateFactory,
99 : SetInactiveFlag setInactiveFlag,
100 : ExternalIdFactory externalIdFactory,
101 151 : ExternalIdKeyFactory externalIdKeyFactory) {
102 151 : this.sequences = sequences;
103 151 : this.accounts = accounts;
104 151 : this.accountsUpdateProvider = accountsUpdateProvider;
105 151 : this.byIdCache = byIdCache;
106 151 : this.realm = accountMapper;
107 151 : this.userFactory = userFactory;
108 151 : this.sshKeyCache = sshKeyCache;
109 151 : this.projectCache = projectCache;
110 151 : this.awaitsFirstAccountCheck =
111 151 : new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
112 151 : this.externalIds = externalIds;
113 151 : this.groupsUpdateFactory = groupsUpdateFactory;
114 151 : this.autoUpdateAccountActiveStatus =
115 151 : cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
116 151 : this.setInactiveFlag = setInactiveFlag;
117 151 : this.externalIdFactory = externalIdFactory;
118 151 : this.externalIdKeyFactory = externalIdKeyFactory;
119 151 : }
120 :
121 : /** Returns a user identified by this external identity string */
122 : public Optional<Account.Id> lookup(String externalId) throws AccountException {
123 : try {
124 0 : return externalIds.get(externalIdKeyFactory.parse(externalId)).map(ExternalId::accountId);
125 0 : } catch (IOException e) {
126 0 : throw new AccountException("Cannot lookup account " + externalId, e);
127 : }
128 : }
129 :
130 : /**
131 : * Authenticate the user, potentially creating a new account if they are new.
132 : *
133 : * @param who identity of the user, with any details we received about them.
134 : * @return the result of authenticating the user.
135 : * @throws AccountException the account does not exist, and cannot be created, or exists, but
136 : * cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
137 : * added to the admin group (only for the first account).
138 : */
139 : public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
140 : try {
141 15 : who = realm.authenticate(who);
142 0 : } catch (NoSuchUserException e) {
143 0 : deactivateAccountIfItExists(who);
144 0 : throw e;
145 15 : }
146 : try {
147 15 : Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
148 15 : if (!optionalExtId.isPresent()) {
149 15 : logger.atFine().log(
150 : "External ID for account %s not found. A new account will be automatically created.",
151 15 : who.getUserName());
152 15 : return create(who);
153 : }
154 :
155 5 : ExternalId extId = optionalExtId.get();
156 5 : Optional<AccountState> accountState = byIdCache.get(extId.accountId());
157 5 : if (!accountState.isPresent()) {
158 1 : logger.atSevere().log(
159 : "Authentication with external ID %s failed. Account %s doesn't exist.",
160 1 : extId.key().get(), extId.accountId().get());
161 1 : throw new AccountException("Authentication error, account not found");
162 : }
163 :
164 : // Account exists
165 5 : Optional<Account> act = updateAccountActiveStatus(who, accountState.get().account());
166 5 : if (!act.isPresent()) {
167 : // The account was deleted since we checked for it last time. This should never happen
168 : // since we don't support deletion of accounts.
169 0 : throw new AccountException("Authentication error, account not found");
170 : }
171 5 : if (!act.get().isActive()) {
172 1 : throw new AccountException("Authentication error, account inactive");
173 : }
174 :
175 : // return the identity to the caller.
176 5 : update(who, extId);
177 5 : return new AuthResult(extId.accountId(), who.getExternalIdKey(), false);
178 0 : } catch (StorageException | ConfigInvalidException e) {
179 0 : throw new AccountException("Authentication error", e);
180 : }
181 : }
182 :
183 : private void deactivateAccountIfItExists(AuthRequest authRequest) {
184 0 : if (!shouldUpdateActiveStatus(authRequest)) {
185 0 : return;
186 : }
187 : try {
188 0 : Optional<ExternalId> extId = externalIds.get(authRequest.getExternalIdKey());
189 0 : if (!extId.isPresent()) {
190 0 : return;
191 : }
192 0 : setInactiveFlag.deactivate(extId.get().accountId());
193 0 : } catch (Exception e) {
194 0 : logger.atSevere().withCause(e).log(
195 : "Unable to deactivate account %s",
196 : authRequest
197 0 : .getUserName()
198 0 : .orElse(" for external ID key " + authRequest.getExternalIdKey().get()));
199 0 : }
200 0 : }
201 :
202 : private Optional<Account> updateAccountActiveStatus(AuthRequest authRequest, Account account)
203 : throws AccountException {
204 5 : if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
205 5 : return Optional.of(account);
206 : }
207 :
208 1 : if (authRequest.isActive()) {
209 : try {
210 1 : setInactiveFlag.activate(account.id());
211 0 : } catch (Exception e) {
212 0 : throw new AccountException("Unable to activate account " + account.id(), e);
213 1 : }
214 : } else {
215 : try {
216 1 : setInactiveFlag.deactivate(account.id());
217 0 : } catch (Exception e) {
218 0 : throw new AccountException("Unable to deactivate account " + account.id(), e);
219 1 : }
220 : }
221 1 : return byIdCache.get(account.id()).map(AccountState::account);
222 : }
223 :
224 : private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
225 5 : return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
226 : }
227 :
228 : private void update(AuthRequest who, ExternalId extId)
229 : throws IOException, ConfigInvalidException, AccountException {
230 5 : IdentifiedUser user = userFactory.create(extId.accountId());
231 5 : List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
232 :
233 : // If the email address was modified by the authentication provider,
234 : // update our records to match the changed email.
235 : //
236 5 : String newEmail = who.getEmailAddress();
237 5 : String oldEmail = extId.email();
238 5 : if (newEmail != null && !newEmail.equals(oldEmail)) {
239 5 : ExternalId extIdWithNewEmail =
240 5 : externalIdFactory.create(extId.key(), extId.accountId(), newEmail, extId.password());
241 5 : checkEmailNotUsed(extId.accountId(), extIdWithNewEmail);
242 5 : accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
243 :
244 5 : if (oldEmail != null && oldEmail.equals(user.getAccount().preferredEmail())) {
245 1 : accountUpdates.add(u -> u.setPreferredEmail(newEmail));
246 : }
247 : }
248 :
249 5 : if (!Strings.isNullOrEmpty(who.getDisplayName())
250 5 : && !Objects.equals(user.getAccount().fullName(), who.getDisplayName())) {
251 5 : accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
252 : }
253 :
254 5 : if (!realm.allowsEdit(AccountFieldName.USER_NAME)
255 0 : && who.getUserName().isPresent()
256 0 : && !who.getUserName().equals(user.getUserName())) {
257 0 : if (user.getUserName().isPresent()) {
258 0 : logger.atWarning().log(
259 : "Not changing already set username %s to %s",
260 0 : user.getUserName().get(), who.getUserName().get());
261 : } else {
262 0 : logger.atWarning().log("Not setting username to %s", who.getUserName().get());
263 : }
264 : }
265 :
266 5 : if (!accountUpdates.isEmpty()) {
267 5 : Optional<AccountState> updatedAccount =
268 : accountsUpdateProvider
269 5 : .get()
270 5 : .update(
271 : "Update Account on Login",
272 5 : user.getAccountId(),
273 5 : AccountsUpdate.joinConsumers(accountUpdates));
274 5 : if (!updatedAccount.isPresent()) {
275 0 : throw new StorageException("Account " + user.getAccountId() + " has been deleted");
276 : }
277 : }
278 5 : }
279 :
280 : private AuthResult create(AuthRequest who)
281 : throws AccountException, IOException, ConfigInvalidException {
282 15 : Account.Id newId = Account.id(sequences.nextAccountId());
283 15 : logger.atFine().log("Assigning new Id %s to account", newId);
284 :
285 15 : ExternalId extId =
286 15 : externalIdFactory.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
287 15 : logger.atFine().log("Created external Id: %s", extId);
288 15 : checkEmailNotUsed(newId, extId);
289 : ExternalId userNameExtId =
290 15 : who.getUserName().isPresent() ? createUsername(newId, who.getUserName().get()) : null;
291 :
292 15 : boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
293 :
294 : AccountState accountState;
295 : try {
296 15 : accountState =
297 : accountsUpdateProvider
298 15 : .get()
299 15 : .insert(
300 : "Create Account on First Login",
301 : newId,
302 : u -> {
303 15 : u.setFullName(who.getDisplayName())
304 15 : .setPreferredEmail(extId.email())
305 15 : .addExternalId(extId);
306 15 : if (userNameExtId != null) {
307 15 : u.addExternalId(userNameExtId);
308 : }
309 15 : });
310 0 : } catch (DuplicateExternalIdKeyException e) {
311 0 : throw new AccountException(
312 : "Cannot assign external ID \""
313 0 : + e.getDuplicateKey().get()
314 : + "\" to account "
315 : + newId
316 : + "; external ID already in use.");
317 : } finally {
318 : // If adding the account failed, it may be that it actually was the
319 : // first account. So we reset the 'check for first account'-guard, as
320 : // otherwise the first account would not get administration permissions.
321 15 : awaitsFirstAccountCheck.set(isFirstAccount);
322 : }
323 :
324 15 : if (userNameExtId != null) {
325 15 : who.getUserName().ifPresent(sshKeyCache::evict);
326 : }
327 :
328 15 : IdentifiedUser user = userFactory.create(newId);
329 :
330 15 : if (isFirstAccount) {
331 : // This is the first user account on our site. Assume this user
332 : // is going to be the site's administrator and just make them that
333 : // to bootstrap the authentication database.
334 : //
335 15 : Permission admin =
336 : projectCache
337 15 : .getAllProjects()
338 15 : .getConfig()
339 15 : .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
340 15 : .orElseThrow(() -> new IllegalStateException("access section does not exist"))
341 15 : .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
342 :
343 15 : AccountGroup.UUID adminGroupUuid = admin.getRules().get(0).getGroup().getUUID();
344 15 : addGroupMember(adminGroupUuid, user);
345 : }
346 :
347 15 : realm.onCreateAccount(who, accountState.account());
348 15 : return new AuthResult(newId, extId.key(), true);
349 : }
350 :
351 : private ExternalId createUsername(Account.Id accountId, String username)
352 : throws AccountUserNameException {
353 15 : checkArgument(!Strings.isNullOrEmpty(username));
354 :
355 15 : if (!ExternalId.isValidUsername(username)) {
356 0 : throw new AccountUserNameException(
357 0 : String.format(
358 : "Cannot assign user name \"%s\" to account %s; name does not conform.",
359 : username, accountId));
360 : }
361 15 : return externalIdFactory.create(SCHEME_USERNAME, username, accountId);
362 : }
363 :
364 : private void checkEmailNotUsed(Account.Id accountId, ExternalId extIdToBeCreated)
365 : throws IOException, AccountException {
366 18 : String email = extIdToBeCreated.email();
367 18 : if (email == null) {
368 15 : return;
369 : }
370 :
371 14 : Set<ExternalId> existingExtIdsWithEmail = externalIds.byEmail(email);
372 14 : if (existingExtIdsWithEmail.isEmpty()) {
373 14 : return;
374 : }
375 :
376 1 : for (ExternalId externalId : existingExtIdsWithEmail) {
377 1 : if (externalId.accountId().get() != accountId.get()) {
378 1 : logger.atWarning().log(
379 : "Email %s is already assigned to account %s;"
380 : + " cannot create external ID %s with the same email for account %s.",
381 : email,
382 1 : externalId.accountId().get(),
383 1 : extIdToBeCreated.key().get(),
384 1 : extIdToBeCreated.accountId().get());
385 1 : throw new AccountException("Email '" + email + "' in use by another account");
386 : }
387 1 : }
388 1 : }
389 :
390 : private void addGroupMember(AccountGroup.UUID groupUuid, IdentifiedUser user)
391 : throws IOException, ConfigInvalidException, AccountException {
392 : // The user initiated this request by logging in. -> Attribute all modifications to that user.
393 15 : GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
394 : GroupDelta groupDelta =
395 15 : GroupDelta.builder()
396 15 : .setMemberModification(
397 15 : memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
398 15 : .build();
399 : try {
400 15 : groupsUpdate.updateGroup(groupUuid, groupDelta);
401 0 : } catch (NoSuchGroupException e) {
402 0 : throw new AccountException(String.format("Group %s not found", groupUuid), e);
403 15 : }
404 15 : }
405 :
406 : /**
407 : * Link another authentication identity to an existing account.
408 : *
409 : * @param to account to link the identity onto.
410 : * @param who the additional identity.
411 : * @return the result of linking the identity to the user.
412 : * @throws AccountException the identity belongs to a different account, or it cannot be linked at
413 : * this time.
414 : */
415 : public AuthResult link(Account.Id to, AuthRequest who)
416 : throws AccountException, IOException, ConfigInvalidException {
417 14 : Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
418 14 : if (optionalExtId.isPresent()) {
419 2 : ExternalId extId = optionalExtId.get();
420 2 : if (!extId.accountId().equals(to)) {
421 2 : throw new AccountException(
422 2 : "Identity '" + extId.key().get() + "' in use by another account");
423 : }
424 1 : update(who, extId);
425 1 : } else {
426 14 : ExternalId newExtId =
427 14 : externalIdFactory.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
428 14 : checkEmailNotUsed(to, newExtId);
429 14 : accountsUpdateProvider
430 14 : .get()
431 14 : .update(
432 : "Link External ID",
433 : to,
434 : (a, u) -> {
435 14 : u.addExternalId(newExtId);
436 14 : if (who.getEmailAddress() != null && a.account().preferredEmail() == null) {
437 11 : u.setPreferredEmail(who.getEmailAddress());
438 : }
439 14 : });
440 : }
441 14 : return new AuthResult(to, who.getExternalIdKey(), false);
442 : }
443 :
444 : /**
445 : * Update the link to another unique authentication identity to an existing account.
446 : *
447 : * <p>Existing external identities with the same scheme will be removed and replaced with the new
448 : * one.
449 : *
450 : * @param to account to link the identity onto.
451 : * @param who the additional identity.
452 : * @return the result of linking the identity to the user.
453 : * @throws AccountException the identity belongs to a different account, or it cannot be linked at
454 : * this time.
455 : */
456 : public AuthResult updateLink(Account.Id to, AuthRequest who)
457 : throws AccountException, IOException, ConfigInvalidException {
458 0 : accountsUpdateProvider
459 0 : .get()
460 0 : .update(
461 : "Delete External IDs on Update Link",
462 : to,
463 : (a, u) -> {
464 0 : Set<ExternalId> filteredExtIdsByScheme =
465 0 : a.externalIds().stream()
466 0 : .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
467 0 : .collect(toImmutableSet());
468 0 : if (filteredExtIdsByScheme.isEmpty()) {
469 0 : return;
470 : }
471 :
472 0 : if (filteredExtIdsByScheme.size() > 1
473 0 : || filteredExtIdsByScheme.stream()
474 0 : .noneMatch(e -> e.key().equals(who.getExternalIdKey()))) {
475 0 : u.deleteExternalIds(filteredExtIdsByScheme);
476 : }
477 0 : });
478 :
479 0 : return link(to, who);
480 : }
481 :
482 : /**
483 : * Unlink an external identity from an existing account.
484 : *
485 : * @param from account to unlink the external identity from
486 : * @param extIdKey the key of the external ID that should be deleted
487 : * @throws AccountException the identity belongs to a different account, or the identity was not
488 : * found
489 : */
490 : public void unlink(Account.Id from, ExternalId.Key extIdKey)
491 : throws AccountException, IOException, ConfigInvalidException {
492 0 : unlink(from, ImmutableList.of(extIdKey));
493 0 : }
494 :
495 : /**
496 : * Unlink an external identities from an existing account.
497 : *
498 : * @param from account to unlink the external identity from
499 : * @param extIdKeys the keys of the external IDs that should be deleted
500 : * @throws AccountException any of the identity belongs to a different account, or any of the
501 : * identity was not found
502 : */
503 : public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
504 : throws AccountException, IOException, ConfigInvalidException {
505 4 : if (extIdKeys.isEmpty()) {
506 0 : return;
507 : }
508 :
509 4 : List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
510 4 : for (ExternalId.Key extIdKey : extIdKeys) {
511 4 : Optional<ExternalId> extId = externalIds.get(extIdKey);
512 4 : if (extId.isPresent()) {
513 4 : if (!extId.get().accountId().equals(from)) {
514 0 : throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
515 : }
516 4 : extIds.add(extId.get());
517 : } else {
518 0 : throw new AccountException("Identity '" + extIdKey.get() + "' not found");
519 : }
520 4 : }
521 :
522 4 : accountsUpdateProvider
523 4 : .get()
524 4 : .update(
525 4 : "Unlink External ID" + (extIds.size() > 1 ? "s" : ""),
526 : from,
527 : (a, u) -> {
528 4 : u.deleteExternalIds(extIds);
529 4 : if (a.account().preferredEmail() != null
530 4 : && extIds.stream()
531 4 : .anyMatch(e -> a.account().preferredEmail().equals(e.email()))) {
532 4 : u.setPreferredEmail(null);
533 : }
534 4 : });
535 4 : }
536 : }
|