blob: da83a036a49e9cc124de614c1f3a1dd5ec898760 [file] [log] [blame]
// Copyright (C) 2025 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.restapi.account;
import static com.google.gerrit.server.mail.EmailFactories.AUTH_TOKEN_UPDATED;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.extensions.auth.AuthTokenInfo;
import com.google.gerrit.extensions.auth.AuthTokenInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.account.AuthToken;
import com.google.gerrit.server.account.AuthTokenAccessor;
import com.google.gerrit.server.account.InvalidAuthTokenException;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.mail.EmailFactories;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
* REST endpoint to set an authentication token of an account.
*
* <p>This REST endpoint handles {@code PUT
* /accounts/<account-identifier>/tokens/<token-identifier>} requests.
*
* <p>Gerrit only stores the hash of the token, hence if a token was set it's not possible to get it
* back from Gerrit.
*/
@Singleton
public class CreateToken
implements RestCollectionCreateView<AccountResource, AccountResource.Token, AuthTokenInput> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final int LEN = 31;
private static final SecureRandom rng;
static {
try {
rng = SecureRandom.getInstance("SHA1PRNG");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Cannot create RNG for password generator", e);
}
}
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
private final EmailFactories emailFactories;
private final AuthTokenAccessor tokensAccessor;
private final Optional<Duration> maxAuthTokenLifetime;
@Inject
CreateToken(
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
EmailFactories emailFactories,
AuthTokenAccessor tokensAccessor,
AuthConfig authConfig) {
this.self = self;
this.permissionBackend = permissionBackend;
this.emailFactories = emailFactories;
this.tokensAccessor = tokensAccessor;
this.maxAuthTokenLifetime = authConfig.getMaxAuthTokenLifetime();
}
@Override
@CanIgnoreReturnValue
public Response<AuthTokenInfo> apply(AccountResource rsrc, IdString id, AuthTokenInput input)
throws IOException,
ConfigInvalidException,
PermissionBackendException,
BadRequestException,
InvalidAuthTokenException,
RestApiException {
if (!self.get().hasSameAccountId(rsrc.getUser())) {
permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
}
if (rsrc.getUser().getUserName().isEmpty()) {
throw new ResourceConflictException("A username is required to use basic authentication.");
}
if (input == null) {
input = new AuthTokenInput();
}
if (input.id != null && !input.id.equals(id.get())) {
throw new ResourceConflictException("Token ID must match in URL and input");
}
return apply(rsrc.getUser(), id.get(), input);
}
@UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
public Response<AuthTokenInfo> apply(IdentifiedUser user, String id, AuthTokenInput input)
throws IOException, ConfigInvalidException, RestApiException, PermissionBackendException {
String newToken;
if (Strings.isNullOrEmpty(input.token)) {
newToken = generate();
} else {
// Only administrators can explicitly set a token.
permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
newToken = input.token;
}
Optional<Instant> defaultExpiration = Optional.empty();
if (maxAuthTokenLifetime.isPresent()) {
defaultExpiration = Optional.of(Instant.now().plus(maxAuthTokenLifetime.get()));
}
Optional<Instant> expiration = getExpirationInstant(input, defaultExpiration);
AuthToken token;
try {
token = tokensAccessor.addPlainToken(user.getAccountId(), id, newToken, expiration);
} catch (InvalidAuthTokenException e) {
throw new BadRequestException(e.getMessage(), e);
}
try {
emailFactories
.createOutgoingEmail(
AUTH_TOKEN_UPDATED, emailFactories.createAuthTokenUpdateEmail(user, "added", id))
.send();
} catch (EmailException e) {
logger.atSevere().withCause(e).log(
"Cannot send HttpPassword update message to %s", user.getAccount().preferredEmail());
}
AuthTokenInfo info = new AuthTokenInfo();
info.id = token.id();
info.token = newToken;
if (token.expirationDate().isPresent()) {
info.expiration = Timestamp.from(token.expirationDate().get());
}
return Response.created(info);
}
public static Optional<Instant> getExpirationInstant(
AuthTokenInput input, Optional<Instant> defaultExpiration) throws BadRequestException {
return getExpirationInstant(input.lifetime, defaultExpiration);
}
public static Optional<Instant> getExpirationInstant(
String lifetime, Optional<Instant> defaultExpiration) throws BadRequestException {
if (Strings.isNullOrEmpty(lifetime)) {
return defaultExpiration;
}
long lifetimeMinutes;
try {
lifetimeMinutes = ConfigUtil.getTimeUnit(lifetime, 0, TimeUnit.MINUTES);
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid lifetime: " + lifetime, e);
}
if (lifetimeMinutes <= 0) {
throw new BadRequestException("Lifetime must be larger than 0");
}
return Optional.of(Instant.now().plus(lifetimeMinutes, ChronoUnit.MINUTES));
}
@UsedAt(UsedAt.Project.PLUGIN_SERVICEUSER)
public static String generate() {
byte[] rand = new byte[LEN];
rng.nextBytes(rand);
byte[] enc = BaseEncoding.base64().encode(rand).getBytes(UTF_8);
StringBuilder r = new StringBuilder(enc.length);
for (int i = 0; i < enc.length; i++) {
if (enc[i] == '=') {
break;
}
r.append((char) enc[i]);
}
return r.toString();
}
}