BareGit
#include "authentication.h"

#include <chrono>
#include <cstdint>
#include <optional>
#include <string>
#include <utility>

#include <spdlog/spdlog.h>

#include "email_address.h"
#include "secret_token.h"

namespace
{

constexpr std::int64_t CHALLENGE_LIFETIME_SECONDS = 10 * 60;
constexpr std::int64_t SESSION_LIFETIME_SECONDS = 28 * 24 * 60 * 60;
constexpr int TOKEN_ATTEMPTS = 3;

} // namespace

AuthenticationService::AuthenticationService(
    DataSourceInterface& data_source,
    EmailSenderInterface& email_sender,
    ClockInterface& clock,
    mw::CryptoInterface& crypto,
    mw::URL base_url,
    std::uint32_t daily_attempt_limit)
    : data_source_(data_source),
      email_sender_(email_sender),
      clock_(clock),
      crypto_(crypto),
      base_url_(std::move(base_url)),
      daily_attempt_limit_(daily_attempt_limit)
{}

std::int64_t AuthenticationService::nowSeconds() const
{
    return std::chrono::duration_cast<std::chrono::seconds>(
        clock_.now().time_since_epoch()).count();
}

mw::URL AuthenticationService::confirmationUrl(
    const std::string& token) const
{
    mw::URL url = base_url_;
    url.appendPath("authentication/confirm");
    url.appendPath(token);
    return url;
}

mw::E<void> AuthenticationService::requestEmail(
    const std::string& submitted_email)
{
    auto address = normalizeEmail(submitted_email);
    if(!address)
    {
        return std::unexpected(mw::httpError(400, "Invalid email address"));
    }
    const std::int64_t now = nowSeconds();
    const std::int64_t day = utcDay(clock_.now());
    auto transaction = data_source_.beginTransaction();
    if(!transaction)
    {
        return std::unexpected(std::move(transaction.error()));
    }
    auto reservation = (*transaction)->reserveAuthenticationEmail(
        address->key,
        now,
        email_sender_.usesGlobalQuota(),
        day,
        daily_attempt_limit_);
    if(!reservation)
    {
        return std::unexpected(std::move(reservation.error()));
    }
    if(reservation->status != AuthenticationReservationStatus::RESERVED)
    {
        return std::unexpected(mw::Error(AuthenticationRateLimitError{
            "Authentication email rate limit exceeded",
            reservation->retry_after}));
    }

    std::optional<SecretToken> challenge_token;
    std::optional<std::int64_t> challenge_id;
    for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
    {
        auto generated = generateSecretToken(crypto_);
        if(!generated)
        {
            return std::unexpected(std::move(generated.error()));
        }
        auto inserted = (*transaction)->insertAuthenticationChallenge(
            address->email,
            address->key,
            generated->hash,
            now,
            now + CHALLENGE_LIFETIME_SECONDS);
        if(inserted)
        {
            challenge_token = std::move(*generated);
            challenge_id = *inserted;
            break;
        }
    }
    if(!challenge_token || !challenge_id)
    {
        return std::unexpected(mw::runtimeError(
            "Failed to allocate a unique authentication challenge"));
    }
    auto committed = (*transaction)->commit();
    if(!committed)
    {
        return std::unexpected(std::move(committed.error()));
    }

    AuthenticationEmail email = {
        address->email,
        confirmationUrl(challenge_token->value),
        std::chrono::system_clock::time_point(
            std::chrono::seconds(now + CHALLENGE_LIFETIME_SECONDS))};
    auto sent = email_sender_.send(email);
    if(sent)
    {
        spdlog::info(
            "Sent authentication email to {}", address->email);
    }
    transaction = data_source_.beginTransaction();
    if(!transaction)
    {
        return std::unexpected(std::move(transaction.error()));
    }
    if(!sent)
    {
        auto deleted = (*transaction)->deleteAuthenticationChallenge(
            *challenge_id);
        if(!deleted)
        {
            return std::unexpected(std::move(deleted.error()));
        }
        auto deletion_commit = (*transaction)->commit();
        if(!deletion_commit)
        {
            return std::unexpected(std::move(deletion_commit.error()));
        }
        return std::unexpected(mw::httpError(
            502, "Authentication email delivery failed"));
    }
    auto delivered = (*transaction)->markAuthenticationChallengeDelivered(
        *challenge_id, nowSeconds());
    if(!delivered)
    {
        return std::unexpected(std::move(delivered.error()));
    }
    if(!*delivered)
    {
        return std::unexpected(mw::runtimeError(
            "Authentication challenge disappeared after delivery"));
    }
    auto delivery_commit = (*transaction)->commit();
    if(!delivery_commit)
    {
        return std::unexpected(std::move(delivery_commit.error()));
    }
    return {};
}

mw::E<std::optional<AuthenticationChallenge>>
AuthenticationService::validate(const std::string& token) const
{
    auto hash = hashSecretToken(token);
    if(!hash)
    {
        return std::unexpected(mw::httpError(400, "Invalid link"));
    }
    return data_source_.getAuthenticationChallenge(*hash, nowSeconds());
}

mw::E<EstablishedSession> AuthenticationService::confirm(
    const std::string& token,
    const std::optional<std::string>& current_session_token)
{
    auto challenge_hash = hashSecretToken(token);
    if(!challenge_hash)
    {
        return std::unexpected(mw::httpError(400, "Invalid link"));
    }
    std::optional<TokenHash> current_session_hash;
    if(current_session_token)
    {
        auto hash = hashSecretToken(*current_session_token);
        if(hash)
        {
            current_session_hash = std::move(*hash);
        }
    }
    const std::int64_t now = nowSeconds();
    auto transaction = data_source_.beginTransaction();
    if(!transaction)
    {
        return std::unexpected(std::move(transaction.error()));
    }
    auto challenge = (*transaction)->consumeAuthenticationChallenge(
        *challenge_hash, now);
    if(!challenge)
    {
        return std::unexpected(std::move(challenge.error()));
    }
    if(!*challenge)
    {
        return std::unexpected(mw::httpError(400, "Invalid or expired link"));
    }
    auto user = (*transaction)->getUserByEmailKeyForUpdate(
        (**challenge).email_key);
    if(!user)
    {
        return std::unexpected(std::move(user.error()));
    }
    if(!*user)
    {
        User new_user = {
            0,
            (**challenge).email,
            (**challenge).email_key,
            std::nullopt,
            UserRole::PLAYER,
            1,
            utcDay(clock_.now()),
            now};
        auto user_id = (*transaction)->insertUser(new_user);
        if(!user_id)
        {
            return std::unexpected(std::move(user_id.error()));
        }
        new_user.id = *user_id;
        user = std::optional<User>(std::move(new_user));
    }
    auto invalidated = (*transaction)->invalidateAuthenticationChallenges(
        (**challenge).email_key, (**challenge).id, now);
    if(!invalidated)
    {
        return std::unexpected(std::move(invalidated.error()));
    }
    if(current_session_hash)
    {
        auto deleted = (*transaction)->deleteSession(*current_session_hash);
        if(!deleted)
        {
            return std::unexpected(std::move(deleted.error()));
        }
    }
    const std::int64_t expires_at = now + SESSION_LIFETIME_SECONDS;
    std::optional<SecretToken> session_token;
    std::optional<SecretToken> csrf_token;
    for(int attempt = 0; attempt < TOKEN_ATTEMPTS; ++attempt)
    {
        auto generated_session = generateSecretToken(crypto_);
        auto generated_csrf = generateSecretToken(crypto_);
        if(!generated_session || !generated_csrf)
        {
            return std::unexpected(mw::runtimeError(
                "Failed to generate session credentials"));
        }
        auto inserted = (*transaction)->insertSession(
            (**user).id,
            generated_session->hash,
            generated_csrf->value,
            now,
            expires_at);
        if(inserted)
        {
            session_token = std::move(*generated_session);
            csrf_token = std::move(*generated_csrf);
            break;
        }
    }
    if(!session_token || !csrf_token)
    {
        return std::unexpected(mw::runtimeError(
            "Failed to allocate unique session credentials"));
    }
    auto committed = (*transaction)->commit();
    if(!committed)
    {
        return std::unexpected(std::move(committed.error()));
    }
    return EstablishedSession{
        std::move(**user),
        std::move(session_token->value),
        std::move(csrf_token->value),
        expires_at};
}

mw::E<std::optional<SessionContext>> AuthenticationService::session(
    const std::string& token) const
{
    auto hash = hashSecretToken(token);
    if(!hash)
    {
        return std::optional<SessionContext>{};
    }
    return data_source_.getSession(*hash, nowSeconds());
}

mw::E<void> AuthenticationService::logout(const std::string& token)
{
    auto hash = hashSecretToken(token);
    if(!hash)
    {
        return {};
    }
    auto transaction = data_source_.beginTransaction();
    if(!transaction)
    {
        return std::unexpected(std::move(transaction.error()));
    }
    auto deleted = (*transaction)->deleteSession(*hash);
    if(!deleted)
    {
        return std::unexpected(std::move(deleted.error()));
    }
    return (*transaction)->commit();
}