IdAdminLoginService.java

/*
 * Copyright © 2023 Mark Raynsford <code@io7m.com> https://www.io7m.com
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
 * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

package com.io7m.idstore.server.controller.admin;

import com.io7m.idstore.database.api.IdDatabaseAdminsQueriesType;
import com.io7m.idstore.database.api.IdDatabaseException;
import com.io7m.idstore.database.api.IdDatabaseTransactionType;
import com.io7m.idstore.error_codes.IdStandardErrorCodes;
import com.io7m.idstore.model.IdAdmin;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdPasswordException;
import com.io7m.idstore.server.controller.command_exec.IdCommandExecutionFailure;
import com.io7m.idstore.server.service.clock.IdServerClock;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitAdminLoginServiceType;
import com.io7m.idstore.server.service.sessions.IdSessionAdminService;
import com.io7m.idstore.server.service.telemetry.api.IdEventAdminLoggedIn;
import com.io7m.idstore.server.service.telemetry.api.IdEventAdminLoginAuthenticationFailed;
import com.io7m.idstore.server.service.telemetry.api.IdEventAdminLoginRateLimitExceeded;
import com.io7m.idstore.server.service.telemetry.api.IdEventServiceType;
import com.io7m.idstore.strings.IdStringConstants;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.repetoir.core.RPServiceType;
import com.io7m.seltzer.api.SStructuredErrorType;

import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

import static com.io7m.idstore.error_codes.IdStandardErrorCodes.ADMIN_NONEXISTENT;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.AUTHENTICATION_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.RATE_LIMIT_EXCEEDED;
import static com.io7m.idstore.strings.IdStringConstants.BANNED_NO_EXPIRE;
import static com.io7m.idstore.strings.IdStringConstants.ERROR_INVALID_USERNAME_PASSWORD;
import static com.io7m.idstore.strings.IdStringConstants.LOGIN_RATE_LIMITED;

/**
 * A service that handles the logic for admin logins.
 */

public final class IdAdminLoginService implements RPServiceType
{
  private final IdServerClock clock;
  private final IdStrings strings;
  private final IdSessionAdminService sessions;
  private final IdEventServiceType events;
  private final IdRateLimitAdminLoginServiceType rateLimit;

  /**
   * A service that handles the logic for admin logins.
   *
   * @param inClock          The clock
   * @param inStrings        The string resources
   * @param inSessions       A session service
   * @param inRateLimit      The rate limit service
   * @param inEvents         The event service
   */

  public IdAdminLoginService(
    final IdServerClock inClock,
    final IdStrings inStrings,
    final IdSessionAdminService inSessions,
    final IdRateLimitAdminLoginServiceType inRateLimit,
    final IdEventServiceType inEvents)
  {
    this.clock =
      Objects.requireNonNull(inClock, "clock");
    this.strings =
      Objects.requireNonNull(inStrings, "strings");
    this.sessions =
      Objects.requireNonNull(inSessions, "inSessions");
    this.events =
      Objects.requireNonNull(inEvents, "inEvents");
    this.rateLimit =
      Objects.requireNonNull(inRateLimit, "inRateLimit");
  }

  /**
   * Try logging in. Create a new session if logging in succeeds, or raise an
   * exception if the login cannot proceed for any reason (invalid credentials,
   * banned user, etc).
   *
   * @param transaction A database transaction
   * @param requestId   The ID of the request
   * @param remoteHost  The remote remoteHost attempting to log in
   * @param username    The username
   * @param password    The password
   * @param metadata    The request metadata
   *
   * @return A login record
   *
   * @throws IdCommandExecutionFailure On errors
   */

  public IdAdminLoggedIn adminLogin(
    final IdDatabaseTransactionType transaction,
    final UUID requestId,
    final String remoteHost,
    final String username,
    final String password,
    final Map<String, String> metadata)
    throws IdCommandExecutionFailure
  {
    Objects.requireNonNull(transaction, "transaction");
    Objects.requireNonNull(requestId, "requestId");
    Objects.requireNonNull(username, "username");
    Objects.requireNonNull(password, "password");
    Objects.requireNonNull(metadata, "metadata");

    try {
      this.checkRateLimit(requestId, remoteHost, username);

      final var admins =
        transaction.queries(IdDatabaseAdminsQueriesType.class);
      final var user =
        admins.adminGetForNameRequire(new IdName(username));

      this.checkBan(requestId, admins, user);
      this.checkPassword(requestId, remoteHost, password, user);

      admins.adminLogin(user.id(), metadata);
      this.events.emit(new IdEventAdminLoggedIn(user.id()));

      final var session = this.sessions.createSession(user.id());
      return new IdAdminLoggedIn(session, user.withRedactedPassword());
    } catch (final IdDatabaseException e) {
      if (Objects.equals(e.errorCode(), ADMIN_NONEXISTENT)) {
        throw this.authenticationFailed(requestId, e);
      }
      throw new IdCommandExecutionFailure(
        e.getMessage(),
        e,
        e.errorCode(),
        e.attributes(),
        e.remediatingAction(),
        requestId,
        500
      );
    } catch (final IdPasswordException e) {
      throw new IdCommandExecutionFailure(
        e.getMessage(),
        e,
        e.errorCode(),
        e.attributes(),
        e.remediatingAction(),
        requestId,
        500
      );
    }
  }

  private void checkRateLimit(
    final UUID requestId,
    final String remoteHost,
    final String username)
    throws IdCommandExecutionFailure
  {
    if (!this.rateLimit.isAllowedByRateLimit(remoteHost)) {
      this.events.emit(
        new IdEventAdminLoginRateLimitExceeded(remoteHost, username)
      );

      throw new IdCommandExecutionFailure(
        this.strings.format(LOGIN_RATE_LIMITED),
        RATE_LIMIT_EXCEEDED,
        Map.of(
          "Wait Duration", this.rateLimit.waitTime().toString()
        ),
        Optional.empty(),
        requestId,
        400
      );
    }
  }

  private void checkPassword(
    final UUID requestId,
    final String remoteHost,
    final String password,
    final IdAdmin user)
    throws IdPasswordException, IdCommandExecutionFailure
  {
    final var ok =
      user.password()
        .check(this.clock.clock(), password);

    if (!ok) {
      this.events.emit(
        new IdEventAdminLoginAuthenticationFailed(remoteHost, user.id())
      );

      throw new IdCommandExecutionFailure(
        this.strings.format(ERROR_INVALID_USERNAME_PASSWORD),
        AUTHENTICATION_ERROR,
        Map.of(),
        Optional.empty(),
        requestId,
        401
      );
    }
  }

  private void checkBan(
    final UUID requestId,
    final IdDatabaseAdminsQueriesType admins,
    final IdAdmin user)
    throws IdDatabaseException, IdCommandExecutionFailure
  {
    final var banOpt =
      admins.adminBanGet(user.id());

    /*
     * If there's no ban, allow the login.
     */

    if (banOpt.isEmpty()) {
      return;
    }

    final var ban = banOpt.get();
    final var expiresOpt = ban.expires();

    /*
     * If there's no expiration on the ban, deny the login.
     */

    if (expiresOpt.isEmpty()) {
      throw new IdCommandExecutionFailure(
        this.strings.format(BANNED_NO_EXPIRE, ban.reason()),
        IdStandardErrorCodes.BANNED,
        Map.of(),
        Optional.empty(),
        requestId,
        403
      );
    }

    /*
     * If the current time is before the expiration date, deny the login.
     */

    final var timeExpires = expiresOpt.get();
    final var timeNow = this.clock.now();

    if (timeNow.compareTo(timeExpires) < 0) {
      throw new IdCommandExecutionFailure(
        this.strings.format(IdStringConstants.BANNED, ban.reason(), timeExpires),
        IdStandardErrorCodes.BANNED,
        Map.of(),
        Optional.empty(),
        requestId,
        403
      );
    }
  }

  private IdCommandExecutionFailure authenticationFailed(
    final UUID requestId,
    final Exception cause)
  {
    if (cause instanceof final SStructuredErrorType<?> struct) {
      return new IdCommandExecutionFailure(
        this.strings.format(ERROR_INVALID_USERNAME_PASSWORD),
        cause,
        AUTHENTICATION_ERROR,
        struct.attributes(),
        struct.remediatingAction(),
        requestId,
        401
      );
    }
    return new IdCommandExecutionFailure(
      this.strings.format(ERROR_INVALID_USERNAME_PASSWORD),
      cause,
      AUTHENTICATION_ERROR,
      Map.of(),
      Optional.empty(),
      requestId,
      401
    );
  }

  @Override
  public String description()
  {
    return "Admin login service.";
  }

  @Override
  public String toString()
  {
    return "[IdAdminLoginService 0x%s]"
      .formatted(Integer.toUnsignedString(this.hashCode(), 16));
  }
}