#include "services/AuthService.h" #include "common/Crypto.h" #include "common/Email.h" #include "common/Validation.h" #include namespace visione::services { namespace { Json::Value failure(const std::string &code) { Json::Value body; body["ok"] = false; body["error"] = code; return body; } Json::Value success() { Json::Value body; body["ok"] = true; return body; } int otpTtlMinutes() { return drogon::app() .getCustomConfig()["auth"] .get("otp_ttl_minutes", 10) .asInt(); } int tokenTtlHours() { return drogon::app() .getCustomConfig()["auth"] .get("token_ttl_hours", 24) .asInt(); } Json::Value issueToken(long long userId) { auto token = crypto::randomToken(); drogon::app().getDbClient()->execSqlSync( "INSERT INTO api_tokens (user_id, token_hash, expires_at) " "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? HOUR))", userId, crypto::hashSecret(token), tokenTtlHours()); auto body = success(); body["token"] = token; body["token_type"] = "Bearer"; body["expires_in_hours"] = tokenTtlHours(); return body; } } // namespace Json::Value AuthService::registerCitizen(const Json::Value &body) { const auto missing = validation::missingStrings( body, {"first_name", "last_name", "email", "password", "fiscal_code"}); if (!missing.empty()) { auto response = failure("missing_fields"); for (const auto &field : missing) { response["fields"].append(field); } return response; } const auto email = body["email"].asString(); const auto fiscalCode = body["fiscal_code"].asString(); if (!validation::isValidEmail(email)) { return failure("invalid_email"); } if (!validation::isValidFiscalCode(fiscalCode)) { return failure("invalid_fiscal_code"); } auto db = drogon::app().getDbClient(); const auto duplicate = db->execSqlSync( "SELECT id FROM users WHERE email = ? OR fiscal_code = ? LIMIT 1", email, fiscalCode); if (!duplicate.empty()) { return failure("user_already_exists"); } db->execSqlSync( "INSERT INTO users " "(first_name, last_name, email, password_hash, fiscal_code, birth_date, " "birth_place, phone) VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)", body["first_name"].asString(), body["last_name"].asString(), email, crypto::hashPassword(body["password"].asString()), fiscalCode, body.get("birth_date", "").asString(), body.get("birth_place", "").asString(), body.get("phone", "").asString()); const auto inserted = db->execSqlSync("SELECT LAST_INSERT_ID() AS id"); const auto userId = inserted[0]["id"].as(); db->execSqlSync( "INSERT INTO user_roles (user_id, role_id) " "SELECT ?, id FROM roles WHERE name = 'citizen'", userId); auto response = success(); response["user_id"] = Json::Int64(userId); return response; } Json::Value AuthService::requestOtp(const Json::Value &body) { if (!validation::hasString(body, "email")) { return failure("missing_email"); } auto db = drogon::app().getDbClient(); const auto user = db->execSqlSync( "SELECT id, email FROM users WHERE email = ? AND banned = FALSE LIMIT 1", body["email"].asString()); if (user.empty()) { return failure("user_not_found"); } const auto code = crypto::randomNumericCode(6); const auto userId = user[0]["id"].as(); db->execSqlSync( "INSERT INTO otp_challenges (user_id, code_hash, expires_at) " "VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))", userId, crypto::hashSecret(code), otpTtlMinutes()); email::EmailService mail; if (!mail.sendOtp(user[0]["email"].as(), code)) { LOG_WARN << "Could not send OTP email through Mailpit"; return failure("otp_email_failed"); } auto response = success(); response["delivery"] = "mailpit"; response["expires_in_minutes"] = otpTtlMinutes(); return response; } Json::Value AuthService::verifyOtp(const Json::Value &body) { const auto missing = validation::missingStrings(body, {"email", "code"}); if (!missing.empty()) { return failure("missing_fields"); } auto db = drogon::app().getDbClient(); const auto challenge = db->execSqlSync( "SELECT c.id, c.user_id FROM otp_challenges c " "JOIN users u ON u.id = c.user_id " "WHERE u.email = ? AND c.code_hash = ? AND c.consumed_at IS NULL " "AND c.expires_at > NOW() ORDER BY c.id DESC LIMIT 1", body["email"].asString(), crypto::hashSecret(body["code"].asString())); if (challenge.empty()) { return failure("invalid_otp"); } db->execSqlSync("UPDATE otp_challenges SET consumed_at = NOW() WHERE id = ?", challenge[0]["id"].as()); return issueToken(challenge[0]["user_id"].as()); } Json::Value AuthService::loginWithPassword(const Json::Value &body) { const auto missing = validation::missingStrings(body, {"email", "password"}); if (!missing.empty()) { return failure("missing_fields"); } auto db = drogon::app().getDbClient(); const auto user = db->execSqlSync( "SELECT id, password_hash FROM users WHERE email = ? AND banned = FALSE " "LIMIT 1", body["email"].asString()); if (user.empty() || !crypto::verifyPassword(body["password"].asString(), user[0]["password_hash"].as())) { return failure("invalid_credentials"); } return issueToken(user[0]["id"].as()); } Json::Value AuthService::changePassword(const auth::UserContext &user, const Json::Value &body) { const auto missing = validation::missingStrings(body, {"current_password", "new_password"}); if (!missing.empty()) { return failure("missing_fields"); } if (body["new_password"].asString().size() < 10) { return failure("weak_password"); } auto db = drogon::app().getDbClient(); const auto current = db->execSqlSync( "SELECT password_hash FROM users WHERE id = ? AND banned = FALSE", user.id); if (current.empty() || !crypto::verifyPassword( body["current_password"].asString(), current[0]["password_hash"].as())) { return failure("invalid_credentials"); } db->execSqlSync( "UPDATE users SET password_hash = ?, must_change_password = FALSE " "WHERE id = ?", crypto::hashPassword(body["new_password"].asString()), user.id); return success(); } Json::Value AuthService::currentUser(const auth::UserContext &user) { auto db = drogon::app().getDbClient(); const auto result = db->execSqlSync( "SELECT id, first_name, last_name, email, fiscal_code, phone, points, " "must_change_password FROM users WHERE id = ?", user.id); auto response = success(); if (!result.empty()) { response["user"]["id"] = Json::Int64(result[0]["id"].as()); response["user"]["first_name"] = result[0]["first_name"].as(); response["user"]["last_name"] = result[0]["last_name"].as(); response["user"]["email"] = result[0]["email"].as(); response["user"]["fiscal_code"] = result[0]["fiscal_code"].as(); response["user"]["phone"] = result[0]["phone"].as(); response["user"]["points"] = result[0]["points"].as(); response["user"]["must_change_password"] = result[0]["must_change_password"].as(); response["user"]["role"] = user.role; for (const auto &role : user.roles) { response["user"]["roles"].append(role); } for (const auto &permission : user.permissions) { response["user"]["permissions"].append(permission); } } return response; } Json::Value AuthService::editUser(const auth::UserContext &user, Json::Value::const_iterator::reference body) { const auto allowedFields = {"first_name", "last_name", "phone"}; auto db = drogon::app().getDbClient(); bool hasUpdates = false; for (const auto& field : allowedFields) { if (body.isMember(field)) { if (!body[field].isString()) { return failure("invalid_field_type"); } db->execSqlSync( "UPDATE users SET " + std::string(field) + " = ? WHERE id = ?", body[field].asString(), user.id ); hasUpdates = true; } } if (!hasUpdates) { return failure("missing_fields"); } return success(); } } // namespace visione::services