#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();
}