commit ae6b53df909a3b3fb5a79158a096dbed6a10f89d Author: reverse Date: Wed Jul 1 09:44:07 2026 +0000 Custom modules for Anope 2.1 IRC Services diff --git a/m_apiauth.cpp b/m_apiauth.cpp new file mode 100644 index 0000000..08f41c9 --- /dev/null +++ b/m_apiauth.cpp @@ -0,0 +1,2021 @@ +/* +m_apiauth.cpp +2025 Jean "reverse" Chevronnet +Module for Anope IRC Services v2.1, lets users authenticate with +credentials stored in an external API endpoint instead of the internal +Anope database. + +This program is free software: you can redistribute it and/or modify it +under the terms of the GNU General Public License. + +/// $LinkerFlags: -lcrypto -lssl -lcurl -lnlohmann_json +/// BEGIN CMAKE +/// find_package(OpenSSL REQUIRED) +/// find_package(CURL REQUIRED) +/// target_link_libraries(${SO} PUBLIC OpenSSL::Crypto OpenSSL::SSL CURL::libcurl) +/// END CMAKE +*/ + +#include "module.h" +#include "serialize.h" +#include "modules/encryption.h" +#include "modules/nickserv/sasl.h" +#include "modules/sasl_scram.h" + +#include +#include +#include // Added for JWT token decoding and verification + +#include +#include +#include +#include + +#include +#include +#include + +// Global module reference +static Module *me; + +// For convenience +using json = nlohmann::json; + +namespace +{ + // SCRAM-SHA-512 helpers (RFC5802/RFC7677). Kept local to this module so + // SASL SCRAM-SHA-512 support can ship as an add-on to m_apiauth. + constexpr size_t SCRAM_SHA512_LEN = 64; + constexpr size_t SCRAM_SHA256_LEN = 32; + + Anope::string RandomBytes(size_t len); + + Anope::string B64EncodeNoPad(const Anope::string &raw) + { + auto b64 = Anope::B64Encode(raw); + // Some SCRAM client implementations appear to mishandle '=' padding + // inside the decoded SCRAM attribute values (s= / v=). Padding is not + // semantically significant for base64 decoding, so strip it. + while (b64.length() > 0 && b64[b64.length() - 1] == '=') + b64.erase(b64.length() - 1); + return b64; + } + + Anope::string ScramAttrB64Encode(const Anope::string &raw, bool no_padding) + { + return no_padding ? B64EncodeNoPad(raw) : Anope::B64Encode(raw); + } + + struct SaslAuthAssembleResult final + { + bool complete = false; + bool empty = false; + size_t appended = 0; + }; + + SaslAuthAssembleResult AssembleSaslAuthenticateData(const std::vector &parts, Anope::string &pending_b64) + { + // RFC 4616/IRCv3 SASL framing: + // - base64 is sent in 400-byte chunks. + // - if the last chunk is exactly 400 bytes, the sender MUST send an extra "AUTHENTICATE +" to terminate. + // - a bare "+" can also represent an empty response. + SaslAuthAssembleResult res; + if (parts.empty()) + return res; + + for (const auto &part : parts) + { + if (part == "+") + { + res.complete = true; + res.empty = pending_b64.empty(); + continue; + } + + pending_b64 += part; + res.appended += part.length(); + } + + // If no explicit terminator was present, a final chunk shorter than 400 completes the message. + if (!res.complete) + { + const auto &last = parts.back(); + if (last != "+" && last.length() < 400) + res.complete = true; + } + + return res; + } + + Anope::string MakeScramServerNonce(size_t bytes) + { + // Nonce is an opaque, printable string (must not contain commas). + // Some IRC clients appear to choke on '+' and '/' in SCRAM values; + // use hex to keep the nonce strictly [0-9a-f], which is always safe. + const auto raw = RandomBytes(bytes); + if (raw.empty()) + return {}; + return Anope::Hex(raw); + } + + bool ConstantTimeEquals(const Anope::string &a, const Anope::string &b) + { + if (a.length() != b.length()) + return false; + + unsigned char diff = 0; + for (size_t i = 0; i < a.length(); ++i) + diff |= static_cast(a[i] ^ b[i]); + return diff == 0; + } + + Anope::string RandomBytes(size_t len) + { + Anope::string out; + out.resize(len); + if (len && RAND_bytes(reinterpret_cast(&out[0]), static_cast(len)) != 1) + return {}; + return out; + } + + Anope::string Sha512(const Anope::string &data) + { + unsigned char md[SCRAM_SHA512_LEN]; + unsigned int md_len = 0; + if (!EVP_Digest(reinterpret_cast(data.data()), data.length(), md, &md_len, EVP_sha512(), nullptr) || md_len != SCRAM_SHA512_LEN) + return {}; + return Anope::string(reinterpret_cast(md), md_len); + } + + Anope::string Sha256(const Anope::string &data) + { + unsigned char md[SCRAM_SHA256_LEN]; + unsigned int md_len = 0; + if (!EVP_Digest(reinterpret_cast(data.data()), data.length(), md, &md_len, EVP_sha256(), nullptr) || md_len != SCRAM_SHA256_LEN) + return {}; + return Anope::string(reinterpret_cast(md), md_len); + } + + Anope::string HmacSha512(const Anope::string &key, const Anope::string &data) + { + unsigned char md[EVP_MAX_MD_SIZE]; + unsigned int md_len = 0; + if (!HMAC(EVP_sha512(), key.data(), static_cast(key.length()), reinterpret_cast(data.data()), data.length(), md, &md_len) || md_len != SCRAM_SHA512_LEN) + return {}; + return Anope::string(reinterpret_cast(md), md_len); + } + + Anope::string HmacSha256(const Anope::string &key, const Anope::string &data) + { + unsigned char md[EVP_MAX_MD_SIZE]; + unsigned int md_len = 0; + if (!HMAC(EVP_sha256(), key.data(), static_cast(key.length()), reinterpret_cast(data.data()), data.length(), md, &md_len) || md_len != SCRAM_SHA256_LEN) + return {}; + return Anope::string(reinterpret_cast(md), md_len); + } + + Anope::string Pbkdf2Sha512(const Anope::string &password, const Anope::string &salt, unsigned iterations) + { + if (!iterations) + return {}; + + unsigned char out[SCRAM_SHA512_LEN]; + if (PKCS5_PBKDF2_HMAC(password.c_str(), static_cast(password.length()), + reinterpret_cast(salt.data()), static_cast(salt.length()), + static_cast(iterations), EVP_sha512(), SCRAM_SHA512_LEN, out) != 1) + return {}; + + return Anope::string(reinterpret_cast(out), SCRAM_SHA512_LEN); + } + + Anope::string Pbkdf2Sha256(const Anope::string &password, const Anope::string &salt, unsigned iterations) + { + if (!iterations) + return {}; + + unsigned char out[SCRAM_SHA256_LEN]; + if (PKCS5_PBKDF2_HMAC(password.c_str(), static_cast(password.length()), + reinterpret_cast(salt.data()), static_cast(salt.length()), + static_cast(iterations), EVP_sha256(), SCRAM_SHA256_LEN, out) != 1) + return {}; + + return Anope::string(reinterpret_cast(out), SCRAM_SHA256_LEN); + } + + Anope::string XorSameLength(const Anope::string &a, const Anope::string &b) + { + if (a.length() != b.length()) + return {}; + + Anope::string out; + out.resize(a.length()); + for (size_t i = 0; i < a.length(); ++i) + out[i] = static_cast(static_cast(a[i]) ^ static_cast(b[i])); + return out; + } + + // RFC 5802 name escaping. + Anope::string ScramUnescape(const Anope::string &in) + { + Anope::string out; + for (size_t i = 0; i < in.length(); ++i) + { + if (in[i] != '=') + { + out.push_back(in[i]); + continue; + } + + if (i + 2 >= in.length()) + return {}; + + const auto a = in[i + 1]; + const auto b = in[i + 2]; + if (a == '2' && b == 'C') + out.push_back(','); + else if (a == '3' && b == 'D') + out.push_back('='); + else + return {}; + i += 2; + } + return out; + } + + struct ScramFields final + { + Anope::string username; + Anope::string nonce; + Anope::string channel_binding; + Anope::string proof_b64; + }; + + bool SplitCommaAttrs(const Anope::string &s, Anope::map &out) + { + out.clear(); + std::vector parts; + parts.clear(); + Anope::string cur; + for (char ch : s) + { + if (ch == ',') + { + parts.push_back(cur); + cur.clear(); + } + else + cur.push_back(ch); + } + parts.push_back(cur); + + for (const auto &p : parts) + { + if (p.empty()) + continue; + auto eq = p.find('='); + if (eq == Anope::string::npos) + return false; + Anope::string k = p.substr(0, eq); + Anope::string v = p.substr(eq + 1); + out[k] = v; + } + return true; + } + + bool ParseClientFirstMessage(const Anope::string &decoded, ScramFields &out, Anope::string &client_first_bare, Anope::string &gs2_header) + { + // Expected: gs2-header + client-first-message-bare + if (decoded.length() < 3) + return false; + + auto comma1 = decoded.find(','); + if (comma1 == Anope::string::npos) + return false; + auto comma2 = decoded.find(',', comma1 + 1); + if (comma2 == Anope::string::npos) + return false; + + const auto gs2_cbind_flag = decoded.substr(0, comma1); + if (!gs2_cbind_flag.equals_ci("n") && !gs2_cbind_flag.equals_ci("y")) + return false; + + // gs2-header is everything up to and including the second comma. + // Example: "n,," or "y,," or "n,a=authzid," + gs2_header = decoded.substr(0, comma2 + 1); + + // authzid field (between comma1 and comma2) must be empty or match authcid. + const auto authzid_field = decoded.substr(comma1 + 1, comma2 - comma1 - 1); + + client_first_bare = decoded.substr(comma2 + 1); + Anope::map attrs; + if (!SplitCommaAttrs(client_first_bare, attrs)) + return false; + + if (!attrs.count("n") || !attrs.count("r")) + return false; + + out.username = ScramUnescape(attrs["n"]); + out.nonce = attrs["r"]; + if (out.username.empty() || out.nonce.empty()) + return false; + + if (!authzid_field.empty()) + { + if (authzid_field.find_first_of("\r\n\0") != Anope::string::npos) + return false; + if (authzid_field.length() < 2 || authzid_field.substr(0, 2) != "a=") + return false; + const auto authzid = ScramUnescape(authzid_field.substr(2)); + if (authzid.empty() || authzid != out.username) + return false; + } + + if (out.username.find_first_of("\r\n\0") != Anope::string::npos) + return false; + if (out.nonce.find_first_of("\r\n\0") != Anope::string::npos) + return false; + + return true; + } + + bool ParseClientFinalMessage(const Anope::string &decoded, ScramFields &out, Anope::string &client_final_wo_proof) + { + // IMPORTANT: The SCRAM auth_message includes the *exact* + // client-final-message-without-proof (RFC5802), which may include + // extensions beyond c= and r=. Preserve ordering and any extra + // attributes exactly as sent (except p=), otherwise proof validation + // will fail for clients that include extensions. + + out.channel_binding.clear(); + out.nonce.clear(); + out.proof_b64.clear(); + client_final_wo_proof.clear(); + + std::vector parts; + parts.clear(); + Anope::string cur; + for (char ch : decoded) + { + if (ch == ',') + { + parts.push_back(cur); + cur.clear(); + } + else + cur.push_back(ch); + } + parts.push_back(cur); + + bool saw_p = false; + bool first_out = true; + + for (const auto &p : parts) + { + if (p.empty()) + continue; + + auto eq = p.find('='); + if (eq == Anope::string::npos) + return false; + + const auto k = p.substr(0, eq); + const auto v = p.substr(eq + 1); + if (k == "c") + out.channel_binding = v; + else if (k == "r") + out.nonce = v; + else if (k == "p") + { + if (saw_p) + return false; + out.proof_b64 = v; + saw_p = true; + continue; // omit p= from client-final-message-without-proof + } + + if (!first_out) + client_final_wo_proof += ","; + client_final_wo_proof += p; + first_out = false; + } + + if (!saw_p) + return false; + if (out.channel_binding.empty() || out.nonce.empty() || out.proof_b64.empty()) + return false; + if (client_final_wo_proof.empty()) + return false; + + return true; + } + + struct ScramVerifier final + { + unsigned iterations = 0; + Anope::string salt; // raw bytes + Anope::string stored_key; // raw bytes (SHA512) + Anope::string server_key; // raw bytes (SHA512) + }; + + bool ParseVerifier(const Anope::string &encoded, ScramVerifier &out) + { + Anope::map attrs; + if (!SplitCommaAttrs(encoded, attrs)) + return false; + + if (!attrs.count("v") || attrs["v"] != "1") + return false; + if (!attrs.count("i") || !attrs.count("s") || !attrs.count("sk") || !attrs.count("sv")) + return false; + + out.iterations = static_cast(Anope::Convert(attrs["i"], 0)); + if (!out.iterations) + return false; + + out.salt = Anope::B64Decode(attrs["s"]); + out.stored_key = Anope::B64Decode(attrs["sk"]); + out.server_key = Anope::B64Decode(attrs["sv"]); + + if (out.salt.empty() || out.stored_key.length() != SCRAM_SHA512_LEN || out.server_key.length() != SCRAM_SHA512_LEN) + return false; + + return true; + } + + Anope::string EncodeVerifier(const ScramVerifier &v) + { + return "v=1,i=" + Anope::ToString(v.iterations) + + ",s=" + Anope::B64Encode(v.salt) + + ",sk=" + Anope::B64Encode(v.stored_key) + + ",sv=" + Anope::B64Encode(v.server_key); + } + + bool MakeVerifier(const Anope::string &password, unsigned iterations, size_t salt_len, ScramVerifier &out) + { + out = {}; + out.iterations = iterations; + // Some clients incorrectly mishandle '+' or '/' in the base64 salt. + // Keep generating until the *standard* base64 encoding avoids those chars. + bool found_safe_salt = false; + for (unsigned attempt = 0; attempt < 64; ++attempt) + { + out.salt = RandomBytes(salt_len); + if (out.salt.empty()) + return false; + + const auto salt_b64 = Anope::B64Encode(out.salt); + if (salt_b64.find('+') == Anope::string::npos && salt_b64.find('/') == Anope::string::npos) + { + found_safe_salt = true; + break; + } + } + if (!found_safe_salt) + return false; + + const auto salted_password = Pbkdf2Sha512(password, out.salt, iterations); + if (salted_password.length() != SCRAM_SHA512_LEN) + return false; + + const auto client_key = HmacSha512(salted_password, "Client Key"); + if (client_key.length() != SCRAM_SHA512_LEN) + return false; + out.stored_key = Sha512(client_key); + if (out.stored_key.length() != SCRAM_SHA512_LEN) + return false; + + out.server_key = HmacSha512(salted_password, "Server Key"); + if (out.server_key.length() != SCRAM_SHA512_LEN) + return false; + + return true; + } + + struct ScramVerifier256 final + { + unsigned iterations = 0; + Anope::string salt; // raw bytes + Anope::string stored_key; // raw bytes (SHA256) + Anope::string server_key; // raw bytes (SHA256) + }; + + bool ParseVerifier256(const Anope::string &encoded, ScramVerifier256 &out) + { + Anope::map attrs; + if (!SplitCommaAttrs(encoded, attrs)) + return false; + + if (!attrs.count("v") || attrs["v"] != "1") + return false; + if (!attrs.count("i") || !attrs.count("s") || !attrs.count("sk") || !attrs.count("sv")) + return false; + + out.iterations = static_cast(Anope::Convert(attrs["i"], 0)); + if (!out.iterations) + return false; + + out.salt = Anope::B64Decode(attrs["s"]); + out.stored_key = Anope::B64Decode(attrs["sk"]); + out.server_key = Anope::B64Decode(attrs["sv"]); + + if (out.salt.empty() || out.stored_key.length() != SCRAM_SHA256_LEN || out.server_key.length() != SCRAM_SHA256_LEN) + return false; + + return true; + } + + Anope::string EncodeVerifier256(const ScramVerifier256 &v) + { + return "v=1,i=" + Anope::ToString(v.iterations) + + ",s=" + Anope::B64Encode(v.salt) + + ",sk=" + Anope::B64Encode(v.stored_key) + + ",sv=" + Anope::B64Encode(v.server_key); + } + + bool MakeVerifier256(const Anope::string &password, unsigned iterations, size_t salt_len, ScramVerifier256 &out) + { + out = {}; + out.iterations = iterations; + // Some clients incorrectly mishandle '+' or '/' in the base64 salt. + // Keep generating until the *standard* base64 encoding avoids those chars. + bool found_safe_salt = false; + for (unsigned attempt = 0; attempt < 64; ++attempt) + { + out.salt = RandomBytes(salt_len); + if (out.salt.empty()) + return false; + + const auto salt_b64 = Anope::B64Encode(out.salt); + if (salt_b64.find('+') == Anope::string::npos && salt_b64.find('/') == Anope::string::npos) + { + found_safe_salt = true; + break; + } + } + if (!found_safe_salt) + return false; + + const auto salted_password = Pbkdf2Sha256(password, out.salt, iterations); + if (salted_password.length() != SCRAM_SHA256_LEN) + return false; + + const auto client_key = HmacSha256(salted_password, "Client Key"); + if (client_key.length() != SCRAM_SHA256_LEN) + return false; + out.stored_key = Sha256(client_key); + if (out.stored_key.length() != SCRAM_SHA256_LEN) + return false; + + out.server_key = HmacSha256(salted_password, "Server Key"); + if (out.server_key.length() != SCRAM_SHA256_LEN) + return false; + + return true; + } + + // Redact SCRAM proofs from debug logs to avoid leaking secrets. + Anope::string RedactScramProof(const Anope::string &msg) + { + // Replace the value of p=... with p= + Anope::string out = msg; + auto ppos = out.find("p="); + if (ppos == Anope::string::npos) + return out; + auto start = ppos + 2; + auto end = out.find(',', start); + if (end == Anope::string::npos) + end = out.length(); + out = out.substr(0, start) + "" + out.substr(end); + return out; + } +} + +// ── Serializable database for SCRAM verifiers (persisted to m_apiauth.module.json) ── + +static constexpr const char *APIAUTH_VERIFIER_DATA_TYPE = "ApiAuthVerifier"; + +class ApiAuthVerifierEntry; +using apiauth_verifier_map = Anope::unordered_map; +static Serialize::Checker ApiAuthVerifierList(APIAUTH_VERIFIER_DATA_TYPE); + +class ApiAuthVerifierEntry final + : public Serializable +{ +public: + Anope::string account; + Anope::string scram_sha512_verifier; + Anope::string scram_sha256_verifier; + + ApiAuthVerifierEntry(const Anope::string &acct) + : Serializable(APIAUTH_VERIFIER_DATA_TYPE) + , account(acct) + { + ApiAuthVerifierList->insert_or_assign(acct, this); + } + + ~ApiAuthVerifierEntry() override + { + ApiAuthVerifierList->erase(this->account); + } + + static ApiAuthVerifierEntry *Find(const Anope::string &acct) + { + auto it = ApiAuthVerifierList->find(acct); + if (it != ApiAuthVerifierList->end()) + return it->second; + return nullptr; + } + + static ApiAuthVerifierEntry *FindOrCreate(const Anope::string &acct) + { + auto *entry = Find(acct); + if (!entry) + entry = new ApiAuthVerifierEntry(acct); + return entry; + } +}; + +class ApiAuthVerifierDataType final + : public Serialize::Type +{ +public: + ApiAuthVerifierDataType(Module *owner) + : Serialize::Type(APIAUTH_VERIFIER_DATA_TYPE, owner) + { + } + + void Serialize(Serializable *obj, Serialize::Data &data) const override + { + const auto *v = static_cast(obj); + data.Store("account", v->account); + data.Store("scram_sha512_verifier", v->scram_sha512_verifier); + data.Store("scram_sha256_verifier", v->scram_sha256_verifier); + } + + Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override + { + Anope::string account; + data.TryLoad("account", account); + if (account.empty()) + return nullptr; + + ApiAuthVerifierEntry *v = nullptr; + if (obj) + { + v = anope_dynamic_static_cast(obj); + } + else + { + v = ApiAuthVerifierEntry::Find(account); + if (!v) + v = new ApiAuthVerifierEntry(account); + } + + v->account = account; + data.TryLoad("scram_sha512_verifier", v->scram_sha512_verifier); + data.TryLoad("scram_sha256_verifier", v->scram_sha256_verifier); + return v; + } +}; + +class ApiAuthScramSHA512 final + : public SASL::Mechanism +{ +private: + struct Session final + : SASL::Session + { + bool saw_client_first = false; + bool sent_server_final = false; + Anope::string username; + Anope::string pending_b64; + Anope::string client_nonce; + Anope::string server_nonce; + Anope::string combined_nonce; + Anope::string client_first_bare; + Anope::string gs2_header_b64; + Anope::string server_first_message; + Anope::string client_final_wo_proof; + ScramVerifier verifier; + + Session(SASL::Mechanism *m, const Anope::string &u) + : SASL::Session(m, u) + { + } + }; + + unsigned iterations; + size_t saltlen; + + bool GetAccountVerifier(NickCore *nc, ScramVerifier &out) const + { + if (!nc) + return false; + auto *entry = ApiAuthVerifierEntry::Find(nc->display); + if (!entry || entry->scram_sha512_verifier.empty()) + return false; + return ParseVerifier(entry->scram_sha512_verifier, out); + } + + static bool SaltIsClientCompatSafe(const Anope::string &salt_raw) + { + const auto salt_b64 = Anope::B64Encode(salt_raw); + return salt_b64.find('+') == Anope::string::npos && salt_b64.find('/') == Anope::string::npos; + } + + NickCore *FindUsableAccount(const Anope::string &username) + { + if (username.empty() || !IRCD->IsNickValid(username)) + return nullptr; + + NickAlias *na = NickAlias::Find(username); + if (!na || !na->nc) + return nullptr; + + NickCore *nc = na->nc; + if (nc->HasExt("NS_SUSPENDED") || nc->HasExt("UNCONFIRMED")) + return nullptr; + + return nc; + } + +public: + ApiAuthScramSHA512(Module *o, unsigned iters, size_t slen) + : SASL::Mechanism(o, "SCRAM-SHA-512") + , iterations(iters) + , saltlen(slen) + { + if (iterations < 4096) + iterations = 4096; + if (saltlen < 16) + saltlen = 16; + } + + Session *CreateSession(const Anope::string &uid) override + { + return new Session(this, uid); + } + + bool HasVerifier(NickCore *nc) const + { + ScramVerifier tmp; + return GetAccountVerifier(nc, tmp); + } + + bool VerifierHasUnsafeSalt(NickCore *nc) const + { + ScramVerifier tmp; + if (!GetAccountVerifier(nc, tmp)) + return false; + return !SaltIsClientCompatSafe(tmp.salt); + } + + bool SetVerifierFromPassword(NickCore *nc, const Anope::string &password) + { + if (!nc || password.empty()) + return false; + + ScramVerifier v; + if (!MakeVerifier(password, iterations, saltlen, v)) + return false; + + auto *entry = ApiAuthVerifierEntry::FindOrCreate(nc->display); + entry->scram_sha512_verifier = EncodeVerifier(v); + entry->QueueUpdate(); + return true; + } + + bool SetVerifierFromEncoded(NickCore *nc, const Anope::string &encoded) + { + if (!nc || encoded.empty()) + return false; + ScramVerifier v; + if (!ParseVerifier(encoded, v)) + return false; + auto *entry = ApiAuthVerifierEntry::FindOrCreate(nc->display); + entry->scram_sha512_verifier = encoded; + entry->QueueUpdate(); + return true; + } + + bool ProcessMessage(SASL::Session *sess, const SASL::Message &m) override + { + auto *mysess = anope_dynamic_static_cast(sess); + if (!mysess) + return false; + + const auto &modconf = Config->GetModule(this->owner); + const bool scram_debug = modconf.Get("scram_debug", "no"); + const bool scram_attr_b64_nopad = modconf.Get("scram_attr_b64_nopad", "no"); + + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: message type='" << m.type << "' parts=" << m.data.size() << " from " << sess->GetUserInfo(); + + if (m.type == "S") + { + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: starting handshake"; + SASL::service->SendMessage(sess, "C", "+"); + return true; + } + + if (m.type != "C") + return true; + + const auto assembled = AssembleSaslAuthenticateData(m.data, mysess->pending_b64); + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: received data chunk (complete=" << (assembled.complete ? "yes" : "no") << ", empty=" << (assembled.empty ? "yes" : "no") << ")"; + + if (!assembled.complete) + return true; // wait for more chunks + + if (mysess->sent_server_final && !assembled.empty) + { + const auto b64 = mysess->pending_b64; + mysess->pending_b64.clear(); + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent unexpected data after server-final during SASL SCRAM-SHA-512 for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: protocol error (unexpected client data after server-final) decoded='" << RedactScramProof(Anope::B64Decode(b64)) << "'"; + SASL::service->SendMessage(sess, "C", Anope::B64Encode("e=protocol-error")); + return false; + } + + if (assembled.empty) + { + mysess->pending_b64.clear(); + + if (mysess->sent_server_final) + { + NickCore *nc = FindUsableAccount(mysess->username); + if (!nc) + return false; + + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: completed for account '" << nc->display << "'"; + + Log(this->owner, "sasl", Config->GetClient("NickServ")) << sess->GetUserInfo() << " identified to account " << nc->display << " using SASL SCRAM-SHA-512"; + SASL::service->Succeed(sess, nc); + delete sess; + return true; + } + + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an empty SASL SCRAM-SHA-512 message (client aborted after server-first; this usually means the client has no SASL password configured or does not support SCRAM)"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: client aborted (empty message)"; + + // SCRAM allows the server to return an error as the final message. + // This gives clients a hint that the handshake cannot continue. + SASL::service->SendMessage(sess, "C", Anope::B64Encode("e=other-error")); + return false; + } + + const auto b64 = mysess->pending_b64; + mysess->pending_b64.clear(); + const auto decoded = Anope::B64Decode(b64); + if (decoded.empty()) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid (non-base64) SASL SCRAM-SHA-512 message"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: invalid base64 (len=" << b64.length() << ")"; + return false; + } + + if (scram_debug) + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: decoded_len=" << decoded.length(); + + if (!mysess->saw_client_first) + { + ScramFields fields; + Anope::string gs2_header; + if (!ParseClientFirstMessage(decoded, fields, mysess->client_first_bare, gs2_header)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid SASL SCRAM-SHA-512 client-first message"; + if (scram_debug) + Log(LOG_NORMAL, "sasl") << "[api_auth/scram]: invalid client-first from " << sess->GetUserInfo() << " decoded='" << decoded << "'"; + return false; + } + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: client-first ok (user='" << fields.username << "')"; + + mysess->saw_client_first = true; + mysess->username = fields.username; + mysess->client_nonce = fields.nonce; + mysess->gs2_header_b64 = Anope::B64Encode(gs2_header); + + NickCore *nc = FindUsableAccount(mysess->username); + if (!nc) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " attempted SASL SCRAM-SHA-512 for nonexistent/suspended/unconfirmed account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: no usable account (user='" << mysess->username << "')"; + return false; + } + + if (!GetAccountVerifier(nc, mysess->verifier)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " attempted SASL SCRAM-SHA-512 for account " << nc->display << " but no SCRAM verifier is stored yet"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: missing verifier for account='" << nc->display << "'"; + return false; + } + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: using verifier (account='" << nc->display << "', i=" << mysess->verifier.iterations << ")"; + + if (!SaltIsClientCompatSafe(mysess->verifier.salt)) + { + // Some clients appear to mishandle '+' and '/' inside base64-encoded salt values. + // We can't change the encoding in server-first, so the verifier must be regenerated. + Log(LOG_DEBUG, "sasl") << "[api_auth/scram]: verifier salt base64 contains '+' or '/', some clients may abort; refresh verifier by IDENTIFY/PLAIN"; + } + + mysess->server_nonce = MakeScramServerNonce(18); + if (mysess->server_nonce.empty()) + { + Log(LOG_NORMAL, "sasl") << "Failed to generate server nonce for SASL SCRAM-SHA-512"; + return false; + } + + mysess->combined_nonce = mysess->client_nonce + mysess->server_nonce; + mysess->server_first_message = "r=" + mysess->combined_nonce + + ",s=" + ScramAttrB64Encode(mysess->verifier.salt, scram_attr_b64_nopad) + + ",i=" + Anope::ToString(mysess->verifier.iterations); + + SASL::service->SendMessage(sess, "C", Anope::B64Encode(mysess->server_first_message)); + return true; + } + + ScramFields fields; + if (!ParseClientFinalMessage(decoded, fields, mysess->client_final_wo_proof)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid SASL SCRAM-SHA-512 client-final message for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: invalid client-final decoded='" << RedactScramProof(decoded) << "'"; + return false; + } + if (scram_debug) + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: client-final ok (account='" << mysess->username << ")"; + + if (fields.channel_binding != mysess->gs2_header_b64) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " used unsupported SCRAM channel binding (c=) during SASL SCRAM-SHA-512 for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: channel binding mismatch"; + return false; + } + + if (fields.nonce != mysess->combined_nonce) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent a mismatched nonce during SASL SCRAM-SHA-512 for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: nonce mismatch"; + return false; + } + + const auto client_proof = Anope::B64Decode(fields.proof_b64); + if (client_proof.length() != SCRAM_SHA512_LEN) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid proof during SASL SCRAM-SHA-512 for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: invalid proof length=" << client_proof.length(); + return false; + } + + const auto auth_message = mysess->client_first_bare + "," + mysess->server_first_message + "," + mysess->client_final_wo_proof; + + const auto client_signature = HmacSha512(mysess->verifier.stored_key, auth_message); + if (client_signature.length() != SCRAM_SHA512_LEN) + return false; + + const auto client_key = XorSameLength(client_proof, client_signature); + if (client_key.length() != SCRAM_SHA512_LEN) + return false; + + const auto stored_key_check = Sha512(client_key); + if (stored_key_check.length() != SCRAM_SHA512_LEN) + return false; + + if (!ConstantTimeEquals(stored_key_check, mysess->verifier.stored_key)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " failed SASL SCRAM-SHA-512 authentication for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-512: password check failed (stored_key mismatch)"; + return false; + } + + const auto server_signature = HmacSha512(mysess->verifier.server_key, auth_message); + if (server_signature.length() != SCRAM_SHA512_LEN) + return false; + + SASL::service->SendMessage(sess, "C", Anope::B64Encode("v=" + ScramAttrB64Encode(server_signature, scram_attr_b64_nopad))); + mysess->sent_server_final = true; + return true; + } +}; + +class ApiAuthScramSHA256 final + : public SASL::Mechanism +{ +private: + struct Session final + : SASL::Session + { + bool saw_client_first = false; + bool sent_server_final = false; + Anope::string username; + Anope::string pending_b64; + Anope::string client_nonce; + Anope::string server_nonce; + Anope::string combined_nonce; + Anope::string client_first_bare; + Anope::string gs2_header_b64; + Anope::string server_first_message; + Anope::string client_final_wo_proof; + ScramVerifier256 verifier; + + Session(SASL::Mechanism *m, const Anope::string &u) + : SASL::Session(m, u) + { + } + }; + + unsigned iterations; + size_t saltlen; + + bool GetAccountVerifier(NickCore *nc, ScramVerifier256 &out) const + { + if (!nc) + return false; + auto *entry = ApiAuthVerifierEntry::Find(nc->display); + if (!entry || entry->scram_sha256_verifier.empty()) + return false; + return ParseVerifier256(entry->scram_sha256_verifier, out); + } + + static bool SaltIsClientCompatSafe(const Anope::string &salt_raw) + { + const auto salt_b64 = Anope::B64Encode(salt_raw); + return salt_b64.find('+') == Anope::string::npos && salt_b64.find('/') == Anope::string::npos; + } + + NickCore *FindUsableAccount(const Anope::string &username) + { + if (username.empty() || !IRCD->IsNickValid(username)) + return nullptr; + + NickAlias *na = NickAlias::Find(username); + if (!na || !na->nc) + return nullptr; + + NickCore *nc = na->nc; + if (nc->HasExt("NS_SUSPENDED") || nc->HasExt("UNCONFIRMED")) + return nullptr; + + return nc; + } + +public: + ApiAuthScramSHA256(Module *o, unsigned iters, size_t slen) + : SASL::Mechanism(o, "SCRAM-SHA-256") + , iterations(iters) + , saltlen(slen) + { + if (iterations < 4096) + iterations = 4096; + if (saltlen < 16) + saltlen = 16; + } + + Session *CreateSession(const Anope::string &uid) override + { + return new Session(this, uid); + } + + bool HasVerifier(NickCore *nc) const + { + ScramVerifier256 tmp; + return GetAccountVerifier(nc, tmp); + } + + bool VerifierHasUnsafeSalt(NickCore *nc) const + { + ScramVerifier256 tmp; + if (!GetAccountVerifier(nc, tmp)) + return false; + return !SaltIsClientCompatSafe(tmp.salt); + } + + bool SetVerifierFromPassword(NickCore *nc, const Anope::string &password) + { + if (!nc || password.empty()) + return false; + + ScramVerifier256 v; + if (!MakeVerifier256(password, iterations, saltlen, v)) + return false; + + auto *entry = ApiAuthVerifierEntry::FindOrCreate(nc->display); + entry->scram_sha256_verifier = EncodeVerifier256(v); + entry->QueueUpdate(); + return true; + } + + bool SetVerifierFromEncoded(NickCore *nc, const Anope::string &encoded) + { + if (!nc || encoded.empty()) + return false; + ScramVerifier256 v; + if (!ParseVerifier256(encoded, v)) + return false; + auto *entry = ApiAuthVerifierEntry::FindOrCreate(nc->display); + entry->scram_sha256_verifier = encoded; + entry->QueueUpdate(); + return true; + } + + bool ProcessMessage(SASL::Session *sess, const SASL::Message &m) override + { + auto *mysess = anope_dynamic_static_cast(sess); + if (!mysess) + return false; + + const auto &modconf = Config->GetModule(this->owner); + const bool scram_debug = modconf.Get("scram_debug", "no"); + const bool scram_attr_b64_nopad = modconf.Get("scram_attr_b64_nopad", "no"); + + if (scram_debug) + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: message type='" << m.type << "' parts=" << m.data.size() << " from " << sess->GetUserInfo(); + + if (m.type == "S") + { + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: starting handshake"; + SASL::service->SendMessage(sess, "C", "+"); + return true; + } + + if (m.type != "C") + return true; + + const auto assembled = AssembleSaslAuthenticateData(m.data, mysess->pending_b64); + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: received data chunk (complete=" << (assembled.complete ? "yes" : "no") << ", empty=" << (assembled.empty ? "yes" : "no") << ")"; + + if (!assembled.complete) + return true; // wait for more chunks + + if (mysess->sent_server_final && !assembled.empty) + { + mysess->pending_b64.clear(); + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent unexpected data after server-final during SASL SCRAM-SHA-256 for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: protocol error (unexpected client data after server-final)"; + SASL::service->SendMessage(sess, "C", Anope::B64Encode("e=protocol-error")); + return false; + } + + if (assembled.empty) + { + mysess->pending_b64.clear(); + + if (mysess->sent_server_final) + { + NickCore *nc = FindUsableAccount(mysess->username); + if (!nc) + return false; + + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: completed for account '" << nc->display << "'"; + + Log(this->owner, "sasl", Config->GetClient("NickServ")) << sess->GetUserInfo() << " identified to account " << nc->display << " using SASL SCRAM-SHA-256"; + SASL::service->Succeed(sess, nc); + delete sess; + return true; + } + + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an empty SASL SCRAM-SHA-256 message (client aborted after server-first; this usually means the client has no SASL password configured or does not support SCRAM)"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: client aborted (empty message)"; + + SASL::service->SendMessage(sess, "C", Anope::B64Encode("e=other-error")); + return false; + } + + const auto b64 = mysess->pending_b64; + mysess->pending_b64.clear(); + const auto decoded = Anope::B64Decode(b64); + if (decoded.empty()) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid (non-base64) SASL SCRAM-SHA-256 message"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: invalid base64 (len=" << b64.length() << ")"; + return false; + } + + if (scram_debug) + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: decoded_len=" << decoded.length(); + + if (!mysess->saw_client_first) + { + ScramFields fields; + Anope::string gs2_header; + if (!ParseClientFirstMessage(decoded, fields, mysess->client_first_bare, gs2_header)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid SASL SCRAM-SHA-256 client-first message"; + if (scram_debug) + Log(LOG_NORMAL, "sasl") << "[api_auth/scram256]: invalid client-first decoded='" << RedactScramProof(decoded) << "'"; + return false; + } + + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: client-first ok (user='" << fields.username << "')"; + + mysess->saw_client_first = true; + mysess->username = fields.username; + mysess->client_nonce = fields.nonce; + mysess->gs2_header_b64 = Anope::B64Encode(gs2_header); + + NickCore *nc = FindUsableAccount(mysess->username); + if (!nc) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " attempted SASL SCRAM-SHA-256 for nonexistent/suspended/unconfirmed account " << mysess->username; + return false; + } + + if (!GetAccountVerifier(nc, mysess->verifier)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " attempted SASL SCRAM-SHA-256 for account " << nc->display << " but no SCRAM verifier is stored yet"; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: missing verifier for account='" << nc->display << "'"; + return false; + } + + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: using verifier (account='" << nc->display << "', i=" << mysess->verifier.iterations << ")"; + + if (!SaltIsClientCompatSafe(mysess->verifier.salt)) + { + Log(LOG_DEBUG, "sasl") << "[api_auth/scram256]: verifier salt base64 contains '+' or '/', some clients may abort; refresh verifier by IDENTIFY/PLAIN"; + } + + mysess->server_nonce = MakeScramServerNonce(18); + if (mysess->server_nonce.empty()) + { + Log(LOG_NORMAL, "sasl") << "Failed to generate server nonce for SASL SCRAM-SHA-256"; + return false; + } + + mysess->combined_nonce = mysess->client_nonce + mysess->server_nonce; + mysess->server_first_message = "r=" + mysess->combined_nonce + + ",s=" + ScramAttrB64Encode(mysess->verifier.salt, scram_attr_b64_nopad) + + ",i=" + Anope::ToString(mysess->verifier.iterations); + + SASL::service->SendMessage(sess, "C", Anope::B64Encode(mysess->server_first_message)); + return true; + } + + ScramFields fields; + if (!ParseClientFinalMessage(decoded, fields, mysess->client_final_wo_proof)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid SASL SCRAM-SHA-256 client-final message for account " << mysess->username; + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: invalid client-final decoded='" << RedactScramProof(decoded) << "'"; + return false; + } + + if (scram_debug) + if (scram_debug) + Log(LOG_DEBUG, "sasl") << "SASL SCRAM-256: client-final ok (account='" << mysess->username << ")"; + + if (fields.channel_binding != mysess->gs2_header_b64) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " used unsupported SCRAM channel binding (c=) during SASL SCRAM-SHA-256 for account " << mysess->username; + return false; + } + + if (fields.nonce != mysess->combined_nonce) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent a mismatched nonce during SASL SCRAM-SHA-256 for account " << mysess->username; + return false; + } + + const auto client_proof = Anope::B64Decode(fields.proof_b64); + if (client_proof.length() != SCRAM_SHA256_LEN) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " sent an invalid proof during SASL SCRAM-SHA-256 for account " << mysess->username; + return false; + } + + const auto auth_message = mysess->client_first_bare + "," + mysess->server_first_message + "," + mysess->client_final_wo_proof; + + const auto client_signature = HmacSha256(mysess->verifier.stored_key, auth_message); + if (client_signature.length() != SCRAM_SHA256_LEN) + return false; + + const auto client_key = XorSameLength(client_proof, client_signature); + if (client_key.length() != SCRAM_SHA256_LEN) + return false; + + const auto stored_key_check = Sha256(client_key); + if (stored_key_check.length() != SCRAM_SHA256_LEN) + return false; + + if (!ConstantTimeEquals(stored_key_check, mysess->verifier.stored_key)) + { + Log(LOG_NORMAL, "sasl") << sess->GetUserInfo() << " failed SASL SCRAM-SHA-256 authentication for account " << mysess->username; + return false; + } + + const auto server_signature = HmacSha256(mysess->verifier.server_key, auth_message); + if (server_signature.length() != SCRAM_SHA256_LEN) + return false; + + SASL::service->SendMessage(sess, "C", Anope::B64Encode("v=" + ScramAttrB64Encode(server_signature, scram_attr_b64_nopad))); + mysess->sent_server_final = true; + return true; + } +}; + +// Callback for CURL to write response data. +static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) +{ + ((std::string *)userp)->append((char *)contents, size * nmemb); + return size * nmemb; +} + +// Synchronous form POST to the local Django bridge (localhost, sub-millisecond). +// Returns the HTTP status code, or 0 on transport failure. Used for grouped-nick +// validation/recording — same synchronous pattern as the auth request itself. +static long ApiFormPost(const Anope::string &url, const Anope::string &apikey, + const std::vector> &fields) +{ + CURL *curl = curl_easy_init(); + if (!curl) + return 0; + std::string post; + for (const auto &kv : fields) + { + char *ek = curl_easy_escape(curl, kv.first.c_str(), 0); + char *ev = curl_easy_escape(curl, kv.second.c_str(), 0); + if (!post.empty()) + post += "&"; + post += std::string(ek ? ek : "") + "=" + std::string(ev ? ev : ""); + if (ek) curl_free(ek); + if (ev) curl_free(ev); + } + std::string resp; + struct curl_slist *headers = nullptr; + if (!apikey.empty()) + { + std::string h = "X-API-Key: " + std::string(apikey.c_str()); + headers = curl_slist_append(headers, h.c_str()); + } + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, post.c_str()); + if (headers) + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 4L); + long code = 0; + if (curl_easy_perform(curl) == CURLE_OK) + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return code; +} + +// Structure to hold API response. +struct APIAuthResponse +{ + bool success; + Anope::string email; + Anope::string access_token; // JWT token returned by the API. + Anope::string scram_sha512_verifier; // Optional SCRAM verifier string. + Anope::string scram_sha256_verifier; // Optional SCRAM verifier string. + Anope::string error_message; +}; + +class APIAuthRequest +{ +private: + Reference user; + IdentifyRequest *req; + Anope::string api_url; + Anope::string api_username_param; + Anope::string api_password_param; + Anope::string api_method; + Anope::string api_success_field; // Unused in this version. + Anope::string api_email_field; + Anope::string api_key; // API key if needed. + Anope::string api_verify_ssl; + Anope::string api_capath; + Anope::string api_cainfo; + + Anope::string jwt_secret; + Anope::string jwt_issuer; + + ApiAuthScramSHA512 *scram = nullptr; + ApiAuthScramSHA256 *scram256 = nullptr; + +public: + // Constructor. + APIAuthRequest(User *u, IdentifyRequest *r, const Anope::string &url, + const Anope::string &username_param, const Anope::string &password_param, + const Anope::string &method, const Anope::string &success_field, + const Anope::string &email_field, const Anope::string &key, + const Anope::string &verify_ssl, const Anope::string &capath, const Anope::string &cainfo, + const Anope::string &jwt_secret_, const Anope::string &jwt_issuer_, ApiAuthScramSHA512 *scram_, ApiAuthScramSHA256 *scram256_) + : user(u), req(r), api_url(url), api_username_param(username_param), + api_password_param(password_param), api_method(method), + api_success_field(success_field), api_email_field(email_field), + api_key(key), api_verify_ssl(verify_ssl), api_capath(capath), api_cainfo(cainfo), + jwt_secret(jwt_secret_), jwt_issuer(jwt_issuer_), scram(scram_), scram256(scram256_) + { + req->Hold(me); + } + + ~APIAuthRequest() + { + req->Release(me); + } + + APIAuthResponse MakeRequest() + { + APIAuthResponse response; + response.success = false; + + CURL *curl = curl_easy_init(); + if (!curl) + { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: Could not initialize CURL"; + return response; + } + + std::string postData; + std::string userParam(api_username_param.c_str()); + std::string passParam(api_password_param.c_str()); + std::string url(api_url.c_str()); + + const auto &pw = req->GetPassword(); + Log(LOG_COMMAND) << "[api_auth]: Target URL: " << api_url; + Log(LOG_COMMAND) << "[api_auth]: Account='" << req->GetAccount() << "' password_present=" + << (!pw.empty() ? "yes" : "no") << " password_length=" << pw.length(); + Log(LOG_COMMAND) << "[api_auth]: API key configured: " << (!api_key.empty() ? "yes" : "no"); + + char *escaped_user = curl_easy_escape(curl, req->GetAccount().c_str(), 0); + char *escaped_pass = curl_easy_escape(curl, req->GetPassword().c_str(), 0); + + if (api_method.equals_ci("GET")) + { + std::string full_url = url; + full_url += (full_url.find('?') == std::string::npos) ? "?" : "&"; + full_url += userParam + "=" + (escaped_user ? escaped_user : ""); + full_url += "&" + passParam + "=" + (escaped_pass ? escaped_pass : ""); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_URL, full_url.c_str()); + } + else + { + postData = userParam + "=" + (escaped_user ? escaped_user : "") + "&" + + passParam + "=" + (escaped_pass ? escaped_pass : ""); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postData.c_str()); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + } + + if (escaped_user) + curl_free(escaped_user); + if (escaped_pass) + curl_free(escaped_pass); + + std::string key(api_key.c_str()); + struct curl_slist *headers = NULL; + if (!key.empty()) { + std::string header = "X-API-Key: " + key; + headers = curl_slist_append(headers, header.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + std::string readBuffer; + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &readBuffer); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Anope-API-Auth/1.0"); + + bool verify_ssl = (api_verify_ssl == "true" || api_verify_ssl == "1" || api_verify_ssl == "yes"); + if (!verify_ssl) + { + Log(LOG_DEBUG) << "[api_auth]: ⚠️ WARNING: SSL verification disabled"; + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + } + else if (!api_capath.empty()) + { + curl_easy_setopt(curl, CURLOPT_CAPATH, api_capath.c_str()); + } + else if (!api_cainfo.empty()) + { + curl_easy_setopt(curl, CURLOPT_CAINFO, api_cainfo.c_str()); + } + + Log(LOG_COMMAND) << "[api_auth]: 🔄 Making API request for user @" << req->GetAccount() << "@"; + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) + { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: CURL failed: " << curl_easy_strerror(res); + response.error_message = "Connection error: Could not reach authentication server"; + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return response; + } + + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + if (http_code != 200) + { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: API returned HTTP code " << http_code; + response.error_message = "API error: Unexpected HTTP response code"; + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return response; + } + + try { + json root = json::parse(readBuffer); + // Check for the presence of the access_token field. + if (root.contains("access_token")) + { + response.success = true; + response.access_token = root["access_token"].get().c_str(); + if (!api_email_field.empty() && root.contains(std::string(api_email_field.c_str()))) { + std::string email_field = std::string(api_email_field.c_str()); + response.email = root[email_field].get().c_str(); + } + if (root.contains("scram_sha512_verifier") && root["scram_sha512_verifier"].is_string()) + response.scram_sha512_verifier = root["scram_sha512_verifier"].get().c_str(); + if (root.contains("scram_sha256_verifier") && root["scram_sha256_verifier"].is_string()) + response.scram_sha256_verifier = root["scram_sha256_verifier"].get().c_str(); + } + else { + if (root.contains("error") && root["error"].is_string()) + response.error_message = root["error"].get().c_str(); + else if (root.contains("detail") && root["detail"].is_string()) + response.error_message = root["detail"].get().c_str(); + else + response.error_message = "API response missing access_token field"; + } + } + catch (const json::parse_error &e) { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: JSON parse error: " << e.what(); + Log(LOG_COMMAND) << "[api_auth]: Response was: " << readBuffer; + response.error_message = "JSON parse error"; + } + catch (const std::exception &e) { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: JSON exception: " << e.what(); + response.error_message = "JSON exception"; + } + + if (headers) + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return response; + } + + void ProcessAuth() + { + APIAuthResponse response = MakeRequest(); + if (response.success) { + Log(LOG_COMMAND) << "[api_auth]: ✅ SUCCESS: User @" << req->GetAccount() << "@ authenticated!"; + // Decode the JWT token to determine the canonical username. + Anope::string effective_account = req->GetAccount(); + if (!response.access_token.empty()) { + try { + std::string token_str(response.access_token.c_str()); + auto decoded = jwt::decode(token_str); + + if (!this->jwt_secret.empty() && !this->jwt_issuer.empty()) + { + std::string jwt_secret_str(this->jwt_secret.c_str()); + std::string expected_issuer(this->jwt_issuer.c_str()); + jwt::verify() + .allow_algorithm(jwt::algorithm::hs256{jwt_secret_str}) + .with_issuer(expected_issuer) + .verify(decoded); + } + else + { + Log(LOG_DEBUG) << "[api_auth]: JWT verification disabled (jwt_secret/jwt_issuer not configured)"; + } + + std::string subject = decoded.get_payload_claim("sub").as_string(); + if (!subject.empty()) + effective_account = subject.c_str(); + } catch (const std::exception &ex) { + Log(me, "api_auth") << "JWT decoding/verification failed: " << ex.what(); + // Fall back to req->GetAccount() if verification fails. + } + } + + Log(LOG_COMMAND) << "[api_auth]: Using effective account: " << effective_account; + + NickAlias *na = NickAlias::Find(effective_account); + BotInfo *NickServ = Config->GetClient("NickServ"); + if (na == NULL) { + na = new NickAlias(effective_account, new NickCore(effective_account)); + FOREACH_MOD(OnNickRegister, (user, na, "")); + if (user && NickServ) + user->SendMessage(NickServ, _("Your account \002%s\002 has been confirmed."), na->nick.c_str()); + } + + // Opportunistically store SCRAM verifiers so SASL SCRAM can work + // for this account on subsequent connections. + if (na && na->nc) + { + bool stored512 = false; + if (this->scram) + { + const bool needs_refresh = !this->scram->HasVerifier(na->nc) || this->scram->VerifierHasUnsafeSalt(na->nc); + if (needs_refresh) + { + // Prefer the API-provided verifier if present; otherwise derive from password. + if (!response.scram_sha512_verifier.empty()) + stored512 = this->scram->SetVerifierFromEncoded(na->nc, response.scram_sha512_verifier); + // If the API verifier is missing or client-incompatible, derive locally (generates a "safe" salt). + if (!stored512 || this->scram->VerifierHasUnsafeSalt(na->nc)) + stored512 = this->scram->SetVerifierFromPassword(na->nc, req->GetPassword()); + } + } + else + { + // If another module provides the mechanism, try to use its shared verifier service. + ServiceReference svc("SASLScram::VerifierService", "SCRAM-SHA-512"); + if (svc && !svc->HasVerifier(na->nc)) + { + svc->SetVerifierFromPassword(na->nc, req->GetPassword()); + stored512 = svc->HasVerifier(na->nc); + } + } + + if (stored512) + Log(LOG_COMMAND) << "[api_auth]: Stored SCRAM-SHA-512 verifier for " << na->nc->display; + + bool stored256 = false; + if (this->scram256) + { + const bool needs_refresh = !this->scram256->HasVerifier(na->nc) || this->scram256->VerifierHasUnsafeSalt(na->nc); + if (needs_refresh) + { + if (!response.scram_sha256_verifier.empty()) + stored256 = this->scram256->SetVerifierFromEncoded(na->nc, response.scram_sha256_verifier); + if (!stored256 || this->scram256->VerifierHasUnsafeSalt(na->nc)) + stored256 = this->scram256->SetVerifierFromPassword(na->nc, req->GetPassword()); + } + } + else + { + ServiceReference svc("SASLScram::VerifierService", "SCRAM-SHA-256"); + if (svc && !svc->HasVerifier(na->nc)) + { + svc->SetVerifierFromPassword(na->nc, req->GetPassword()); + stored256 = svc->HasVerifier(na->nc); + } + } + + if (stored256) + Log(LOG_COMMAND) << "[api_auth]: Stored SCRAM-SHA-256 verifier for " << na->nc->display; + } + + if (!response.email.empty() && response.email != na->nc->email) { + na->nc->email = response.email; + if (user && NickServ) + user->SendMessage(NickServ, _("E-mail set to \002%s\002."), response.email.c_str()); + } + // If the member logged in under a grouped (alias) nick, materialise it + // as a registered alias of the account so the nick is protected on IRC + // too. effective_account is the JWT sub (parent); req->GetAccount() is + // the name they authenticated as. login_token only resolves to the + // parent when that name is the account itself or a validated grouped + // nick, so a mismatch here means an already-vetted grouped nick. + if (na && na->nc && !req->GetAccount().equals_ci(effective_account) + && !NickAlias::Find(req->GetAccount())) { + NickAlias *ga = new NickAlias(req->GetAccount(), na->nc); + ga->registered = ga->last_seen = Anope::CurTime; + Log(LOG_COMMAND) << "[api_auth]: materialised grouped nick " << req->GetAccount() + << " -> " << na->nc->display; + } + // Optionally, store the JWT token (response.access_token) for further usage. + req->Success(me, na); + } else { + Log(LOG_COMMAND) << "[api_auth]: ❌ ERROR: Authentication failed for user " + << req->GetAccount() << " - " + << (!response.error_message.empty() ? response.error_message : "Invalid credentials"); + // Do not call Success(); IdentifyRequest will invoke OnFail() when released. + } + delete this; + } +}; + +class ModuleAPIAuth final : public Module { + Anope::string api_url; + Anope::string api_username_param; + Anope::string api_password_param; + Anope::string api_method; + // api_success_field is not used in this version. + Anope::string api_email_field; + Anope::string disable_reason; + Anope::string disable_email_reason; + Anope::string api_key; + Anope::string api_verify_ssl; + Anope::string api_capath; + Anope::string api_cainfo; + Anope::string jwt_secret; + Anope::string jwt_issuer; + Anope::string profile_url; + Anope::string register_url; + Anope::string group_api_url; + + bool enable_sasl_scram_sha512 = false; + bool enable_sasl_scram_sha256 = true; + unsigned scram_iterations = 4096; + size_t scram_saltlen = 16; + + void BroadcastSaslMechsIfSynced() + { + if (Me && Me->IsSynced() && SASL::protocol_interface) + { + auto mechs = ::Service::GetServiceKeys("SASL::Mechanism"); + + // Prioritize SCRAM-SHA-256 first, then other SCRAM variants, + // with PLAIN as the last fallback. + std::sort(mechs.begin(), mechs.end(), [](const Anope::string &a, const Anope::string &b) { + auto priority = [](const Anope::string &m) -> int { + if (m == "SCRAM-SHA-256") return 0; + if (m.find("SCRAM-") == 0) return 1; + if (m == "PLAIN") return 3; + return 2; + }; + return priority(a) < priority(b); + }); + + SASL::protocol_interface->SendSASLMechanisms(mechs); + } + } + + class ScramVerifierServiceImpl final + : public SASLScram::VerifierService + { + ApiAuthScramSHA512 &scram; + + public: + ScramVerifierServiceImpl(Module *creator, ApiAuthScramSHA512 &s) + : SASLScram::VerifierService(creator, "SCRAM-SHA-512") + , scram(s) + { + } + + bool HasVerifier(NickCore *nc) const override + { + return scram.HasVerifier(nc); + } + + void SetVerifierFromPassword(NickCore *nc, const Anope::string &password) override + { + scram.SetVerifierFromPassword(nc, password); + } + }; + + std::unique_ptr scram_mech; + std::unique_ptr scram_service; + + class Scram256VerifierServiceImpl final + : public SASLScram::VerifierService + { + ApiAuthScramSHA256 &scram; + + public: + Scram256VerifierServiceImpl(Module *creator, ApiAuthScramSHA256 &s) + : SASLScram::VerifierService(creator, "SCRAM-SHA-256") + , scram(s) + { + } + + bool HasVerifier(NickCore *nc) const override + { + return scram.HasVerifier(nc); + } + + void SetVerifierFromPassword(NickCore *nc, const Anope::string &password) override + { + scram.SetVerifierFromPassword(nc, password); + } + }; + + std::unique_ptr scram256_mech; + std::unique_ptr scram256_service; + + ApiAuthVerifierDataType verifier_type; +public: + ModuleAPIAuth(const Anope::string &modname, const Anope::string &creator) + : Module(modname, creator, EXTRA | VENDOR) + , verifier_type(this) + { + me = this; + curl_global_init(CURL_GLOBAL_ALL); + } + ~ModuleAPIAuth() { + // Delete every in-memory record on unload. ~ApiAuthVerifierEntry removes + // itself from ApiAuthVerifierList (and from Anope's global + // SerializableItems), so this must run while the module's code is still + // mapped -- otherwise the objects leak and leave dangling vtable pointers + // in the global serialization list. + while (!ApiAuthVerifierList->empty()) { + auto it = ApiAuthVerifierList->begin(); + ApiAuthVerifierEntry *e = it->second; + if (!e) { + ApiAuthVerifierList->erase(it); + continue; + } + delete e; + } + curl_global_cleanup(); + } + void OnReload(Configuration::Conf &conf) override { + Configuration::Block &config = conf.GetModule(this); + this->api_url = config.Get("api_url", "https://www.example/accounts/api/login_token/"); + this->api_username_param = config.Get("api_username_param", "username"); + this->api_password_param = config.Get("api_password_param", "password"); + this->api_method = config.Get("api_method", "POST"); + this->api_email_field = config.Get("api_email_field", "email"); + this->disable_reason = config.Get("disable_reason"); + this->disable_email_reason = config.Get("disable_email_reason"); + this->api_key = config.Get("api_key", ""); + this->api_verify_ssl = config.Get("verify_ssl", "true"); + this->api_capath = config.Get("capath", ""); + this->api_cainfo = config.Get("cainfo", ""); + this->jwt_secret = config.Get("jwt_secret", ""); + this->jwt_issuer = config.Get("jwt_issuer", ""); + this->profile_url = config.Get("profile_url", "https://www.example/accounts/profile/%s/"); // dynamic fetch + this->register_url = config.Get("register_url", "https://www.example/accounts/register/"); + this->group_api_url = config.Get("group_api_url", ""); + + this->enable_sasl_scram_sha512 = config.Get("enable_sasl_scram_sha512", "no"); + this->enable_sasl_scram_sha256 = config.Get("enable_sasl_scram_sha256", "yes"); + // Allow both the explicit scram_* names and the historical iterations/saltlen + // keys (used by ns_sasl_scram_sha512) to reduce config friction. + this->scram_iterations = config.Get("scram_iterations", config.Get("iterations", "4096")); + this->scram_saltlen = config.Get("scram_saltlen", config.Get("saltlen", "16")); + if (this->scram_iterations < 4096) + this->scram_iterations = 4096; + if (this->scram_saltlen < 16) + this->scram_saltlen = 16; + + if (this->enable_sasl_scram_sha512) + { + if (!SASL::protocol_interface) + { + Log(LOG_DEBUG) << "[api_auth]: SASL SCRAM-SHA-512 enabled but IRCd does not support SASL"; + } + else if (!this->scram_mech) + { + if (Service::FindService("SASL::Mechanism", "SCRAM-SHA-512")) + { + Log(LOG_DEBUG) << "[api_auth]: SASL::Mechanism SCRAM-SHA-512 already registered by another module; skipping"; + } + else + { + this->scram_mech = std::make_unique(this, this->scram_iterations, this->scram_saltlen); + if (!Service::FindService("SASLScram::VerifierService", "SCRAM-SHA-512")) + this->scram_service = std::make_unique(this, *this->scram_mech); + Log(LOG_COMMAND) << "[api_auth]: SASL SCRAM-SHA-512 mechanism registered"; + BroadcastSaslMechsIfSynced(); + } + } + } + else if (this->scram_mech) + { + // SCRAM was disabled; unregister the mechanism services. + this->scram_service.reset(); + this->scram_mech.reset(); + BroadcastSaslMechsIfSynced(); + } + + if (this->enable_sasl_scram_sha256) + { + if (!SASL::protocol_interface) + { + Log(LOG_DEBUG) << "[api_auth]: SASL SCRAM-SHA-256 enabled but IRCd does not support SASL"; + } + else if (!this->scram256_mech) + { + if (Service::FindService("SASL::Mechanism", "SCRAM-SHA-256")) + { + Log(LOG_DEBUG) << "[api_auth]: SASL::Mechanism SCRAM-SHA-256 already registered by another module; skipping"; + } + else + { + this->scram256_mech = std::make_unique(this, this->scram_iterations, this->scram_saltlen); + if (!Service::FindService("SASLScram::VerifierService", "SCRAM-SHA-256")) + this->scram256_service = std::make_unique(this, *this->scram256_mech); + Log(LOG_COMMAND) << "[api_auth]: SASL SCRAM-SHA-256 mechanism registered"; + BroadcastSaslMechsIfSynced(); + } + } + } + else if (this->scram256_mech) + { + this->scram256_service.reset(); + this->scram256_mech.reset(); + BroadcastSaslMechsIfSynced(); + } + } + + void OnPreUplinkSync(Server *) override + { + // Ensure SCRAM mechs are included in the mechlist at initial sync. + if ((this->scram_mech || this->scram256_mech) && SASL::protocol_interface) + BroadcastSaslMechsIfSynced(); + } + EventReturn OnPreCommand(CommandSource &source, Command *command, std::vector ¶ms) override { + // GROUP is allowed (unlike REGISTER), but the joining nick must be cleared + // with the website first: it can't be a name that is itself a registered + // account or already grouped onto someone else, which would shadow it. + if (command->name == "nickserv/group") { + if (!this->group_api_url.empty()) { + long code = ApiFormPost(this->group_api_url, this->api_key, + {{"action", "check"}, {"nick", source.GetNick()}}); + if (code != 200) { // fail closed (reserved name, or website unreachable) + source.Reply("Ce pseudo ne peut pas être groupé (il est réservé). Choisis-en un autre."); + return EVENT_STOP; + } + } + return EVENT_CONTINUE; + } + if (!this->disable_reason.empty() && command->name == "nickserv/register") { + Anope::string formatted_reason = this->disable_reason; + if (formatted_reason.find("%s") != Anope::string::npos) + formatted_reason = formatted_reason.replace_all_cs("%s", this->register_url); + else if (!this->register_url.empty()) + formatted_reason += " " + this->register_url; + source.Reply(formatted_reason); + return EVENT_STOP; + } + if (!this->disable_email_reason.empty() && command->name == "nickserv/set/email") { + Anope::string account = source.GetAccount() ? source.GetAccount()->display : ""; + Anope::string formatted_url = this->profile_url; + if (account.empty()) + account = ""; + if (formatted_url.find("%s") != Anope::string::npos) + formatted_url = formatted_url.replace_all_cs("%s", account); + Anope::string formatted_reason = this->disable_email_reason; + if (formatted_reason.find("%s") != Anope::string::npos) + formatted_reason = formatted_reason.replace_all_cs("%s", formatted_url); + else + formatted_reason += " " + formatted_url; + source.Reply(formatted_reason); + return EVENT_STOP; + } + return EVENT_CONTINUE; + } + // A nick was just grouped onto an account — record it on the website so logins + // as that nick resolve to the parent account and the name reads as taken. + void OnNickGroup(User *u, NickAlias *target) override { + if (this->group_api_url.empty() || !u || !target || !target->nc) + return; + long code = ApiFormPost(this->group_api_url, this->api_key, + {{"action", "add"}, {"nick", u->nick}, {"account", target->nc->display}}); + if (code != 200) + Log(LOG_COMMAND) << "[api_auth]: grouped_nick add failed for " << u->nick + << " -> " << target->nc->display << " (HTTP " << code << ")"; + } + void OnCheckAuthentication(User *u, IdentifyRequest *req) override { + Log(LOG_COMMAND) << "[api_auth]: 🔎 Checking authentication for " << req->GetAccount(); + APIAuthRequest *auth_req = new APIAuthRequest(u, req, this->api_url, + this->api_username_param, + this->api_password_param, + this->api_method, + "", // success field not used + this->api_email_field, + this->api_key, + this->api_verify_ssl, + this->api_capath, + this->api_cainfo, + this->jwt_secret, + this->jwt_issuer, + this->scram_mech.get(), this->scram256_mech.get()); + auth_req->ProcessAuth(); + } + void OnPreNickExpire(NickAlias *na, bool &expire) override { + if (na->nick == na->nc->display && na->nc->aliases->size() > 1) + expire = false; + } +}; + +MODULE_INIT(ModuleAPIAuth) \ No newline at end of file diff --git a/m_nickserv_oauth.cpp b/m_nickserv_oauth.cpp new file mode 100644 index 0000000..7fbe61b --- /dev/null +++ b/m_nickserv_oauth.cpp @@ -0,0 +1,258 @@ +/* + * m_nickserv_oauth.cpp + * 2026 Jean "reverse" Chevronnet + * + * Module for Anope IRC Services v2.1 + * Adds IDENTIFYOAUTH command to NickServ for JWT-based authentication + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License. + */ + +/// BEGIN CMAKE +/// find_package(OpenSSL REQUIRED) +/// target_link_libraries(${SO} PUBLIC OpenSSL::Crypto OpenSSL::SSL) +/// END CMAKE + +#include "module.h" +#include +#include + +static Module *me; + +class CommandNSIdentifyOAuth final : public Command +{ +public: + Anope::string jwt_secret; + Anope::string jwt_issuer; + + CommandNSIdentifyOAuth(Module *creator) + : Command(creator, "nickserv/identifyoauth", 2, 2) + { + this->SetDesc(_("Identify to services using an OAuth/JWT token")); + this->SetSyntax(_("\037nickname\037 \037token\037")); + this->AllowUnregistered(true); + } + + void Execute(CommandSource &source, const std::vector ¶ms) override + { + const Anope::string &username = params[0]; + const Anope::string &token = params[1]; + + User *u = source.GetUser(); + BotInfo *bi = Config->GetClient("NickServ"); + + if (!u || !bi) + return; + + Log(LOG_COMMAND, source, this) << "to authenticate as " << username << " using OAuth token"; + + // Find the account + NickAlias *na = NickAlias::Find(username); + if (!na) + { + source.Reply(_("Account \002%s\002 is not registered."), username.c_str()); + Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as \002" << username << "\002 but account does not exist"; + u->BadPassword(); + return; + } + + NickCore *nc = na->nc; + if (!nc) + { + source.Reply(_("Account \002%s\002 is not registered."), username.c_str()); + u->BadPassword(); + return; + } + + // Check if account is suspended + if (nc->HasExt("NS_SUSPENDED")) + { + source.Reply(_("Your account has been suspended.")); + Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as suspended account \002" << username << "\002"; + u->BadPassword(); + return; + } + + // Validate JWT token + try + { + std::string token_str(token.c_str()); + auto decoded = jwt::decode(token_str); + + // Debug logging + Log(me, "oauth") << "Token decoded - Algorithm: " << decoded.get_algorithm(); + if (decoded.has_issuer()) + Log(me, "oauth") << "Token issuer: " << decoded.get_issuer(); + if (decoded.has_payload_claim("sub")) + Log(me, "oauth") << "Token sub: " << decoded.get_payload_claim("sub").as_string(); + + // Verify token signature and issuer if configured + if (!this->jwt_secret.empty()) + { + std::string secret_str(this->jwt_secret.c_str()); + std::string expected_issuer(this->jwt_issuer.c_str()); + + Log(me, "oauth") << "Verifying with secret length: " << secret_str.length() + << " expected issuer: " << expected_issuer; + + auto verifier = jwt::verify() + .allow_algorithm(jwt::algorithm::hs256{secret_str}); + + // Only verify issuer if the token has one AND we have an expected issuer + if (!expected_issuer.empty() && decoded.has_issuer()) + verifier.with_issuer(expected_issuer); + + verifier.verify(decoded); + } + + // Extract username from token - check multiple possible claims + std::string token_username; + if (decoded.has_payload_claim("sub")) + token_username = decoded.get_payload_claim("sub").as_string(); + else if (decoded.has_payload_claim("name")) + token_username = decoded.get_payload_claim("name").as_string(); + else if (decoded.has_payload_claim("username")) + token_username = decoded.get_payload_claim("username").as_string(); + + // If no username in token, we need to look up by user_id + if (token_username.empty() && decoded.has_payload_claim("user_id")) + { + Log(me, "oauth") << "Token contains user_id but no username claim - cannot verify identity match"; + } + + // Verify token username matches requested username + if (!token_username.empty()) + { + Anope::string token_user_anope(token_username.c_str()); + if (!token_user_anope.equals_ci(username)) + { + source.Reply(_("Token username does not match requested account.")); + Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH as \002" << username + << "\002 but token is for \002" << token_username << "\002"; + u->BadPassword(); + return; + } + } + + // Check token expiration + if (decoded.has_expires_at()) + { + auto exp_time = decoded.get_expires_at(); + auto now = std::chrono::system_clock::now(); + if (exp_time < now) + { + source.Reply(_("OAuth token has expired.")); + Log(me, "oauth") << u->GetMask() << " tried to IDENTIFYOAUTH with expired token for \002" << username << "\002"; + u->BadPassword(); + return; + } + } + + // Token is valid - identify the user + u->Identify(na); + + source.Reply(_("Password accepted - you are now recognized.")); + Log(me, "oauth") << u->GetMask() << " successfully identified to account \002" << nc->display << "\002 using OAuth"; + + // If the user's current nickname matches the account, update display + if (u->nick.equals_ci(nc->display)) + { + Configuration::Block modconf = Config->GetModule("ns_identify"); + if (modconf.Get("modeonid", "yes")) + { + // Set correct modes in channels + for (const auto &[_, cc] : u->chans) + { + Channel *c = cc->chan; + if (c) + c->SetCorrectModes(u, true); + } + } + + // Apply configured user modes on identify + const Anope::string &modesonid = modconf.Get("modesonid"); + if (!modesonid.empty()) + u->SetModes(bi, modesonid); + } + + FOREACH_MOD(OnNickIdentify, (u)); + } + catch (const jwt::error::token_verification_exception &e) + { + source.Reply(_("Invalid or malformed OAuth token.")); + Log(me, "oauth") << u->GetMask() << " failed JWT verification for \002" << username + << "\002: " << e.what(); + u->BadPassword(); + } + catch (const std::exception &e) + { + source.Reply(_("OAuth authentication failed: Invalid token.")); + Log(me, "oauth") << u->GetMask() << " IDENTIFYOAUTH error for \002" << username + << "\002: " << e.what(); + u->BadPassword(); + } + } + + bool OnHelp(CommandSource &source, const Anope::string &subcommand) override + { + source.Reply(" "); + source.Reply(_("Authenticate to services using a JWT OAuth token obtained from\n" + "the web interface. This command is typically used automatically\n" + "by web chat clients and should not be used manually.")); + source.Reply(" "); + source.Reply(_("Syntax: \002IDENTIFYOAUTH \037nickname\037 \037token\037\002")); + source.Reply(" "); + source.Reply(_("Example:")); + source.Reply(_(" /msg NickServ IDENTIFYOAUTH YourNick eyJhbGc...")); + return true; + } + + void OnServHelp(CommandSource &source, HelpWrapper &help) override + { + source.Reply(_(" IDENTIFYOAUTH Identify using an OAuth token")); + } + + void OnSyntaxError(CommandSource &source, const Anope::string &subcommand) override + { + source.Reply(_("Syntax: \002IDENTIFYOAUTH \037nickname\037 \037token\037\002")); + source.Reply(_("Type \002/msg %s HELP IDENTIFYOAUTH\002 for more information."), source.service->nick.c_str()); + } +}; + +class ModuleNSIdentifyOAuth final : public Module +{ +private: + CommandNSIdentifyOAuth commandnsidentifyoauth; + +public: + ModuleNSIdentifyOAuth(const Anope::string &modname, const Anope::string &creator) + : Module(modname, creator, EXTRA | VENDOR) + , commandnsidentifyoauth(this) + { + me = this; + } + + void OnReload(Configuration::Conf &conf) override + { + Configuration::Block &config = conf.GetModule(this); + + // Read JWT configuration from module config + Anope::string jwt_secret = config.Get("jwt_secret", ""); + Anope::string jwt_issuer = config.Get("jwt_issuer", ""); + + // Update command with new config + commandnsidentifyoauth.jwt_secret = jwt_secret; + commandnsidentifyoauth.jwt_issuer = jwt_issuer; + + if (jwt_secret.empty()) + { + Log(this) << "Warning: jwt_secret not configured - JWT signature verification disabled"; + } + + Log(this) << "IDENTIFYOAUTH command loaded (JWT verification: " + << (jwt_secret.empty() ? "disabled" : "enabled") << ")"; + } +}; + +MODULE_INIT(ModuleNSIdentifyOAuth) diff --git a/m_youtube.cpp b/m_youtube.cpp new file mode 100644 index 0000000..b987ba9 --- /dev/null +++ b/m_youtube.cpp @@ -0,0 +1,978 @@ +/* +2024 Jean "reverse" Chevronnet +Module for Anope IRC Services v2.1. +Botserv read and answer youtube links. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +/// BEGIN CMAKE +/// target_link_libraries(${SO} PUBLIC curl ssl crypto yyjson) +/// END CMAKE + +#include "module.h" +#include +#include +#include +#include +#include +#include + +// ── Persistent video cache (written to m_youtube.module.json) ── + +static constexpr const char *YOUTUBE_CACHE_DATA_TYPE = "YouTubeCache"; + +class YouTubeCacheEntry; +using youtube_cache_map = Anope::unordered_map; +static Serialize::Checker YouTubeCacheList(YOUTUBE_CACHE_DATA_TYPE); + +class YouTubeCacheEntry final : public Serializable +{ +public: + Anope::string video_id; + Anope::string title; + Anope::string duration; + Anope::string view_count; + time_t expires_at = 0; + // Usage stats + uint64_t share_count = 0; + time_t first_seen = 0; + time_t last_seen = 0; + Anope::string last_channel; + Anope::string last_nick; + + explicit YouTubeCacheEntry(const Anope::string &vid) + : Serializable(YOUTUBE_CACHE_DATA_TYPE) + , video_id(vid) + { + YouTubeCacheList->insert_or_assign(vid, this); + } + + ~YouTubeCacheEntry() override + { + YouTubeCacheList->erase(this->video_id); + } + + bool IsExpired() const { return expires_at <= Anope::CurTime; } + + static YouTubeCacheEntry *Find(const Anope::string &vid) + { + auto it = YouTubeCacheList->find(vid); + return it != YouTubeCacheList->end() ? it->second : nullptr; + } + + static YouTubeCacheEntry *FindOrCreate(const Anope::string &vid) + { + auto *e = Find(vid); + return e ? e : new YouTubeCacheEntry(vid); + } +}; + +class YouTubeCacheDataType final : public Serialize::Type +{ +public: + YouTubeCacheDataType() + : Serialize::Type(YOUTUBE_CACHE_DATA_TYPE) + { + } + + void Serialize(Serializable *obj, Serialize::Data &data) const override + { + const auto *e = static_cast(obj); + data.Store("video_id", e->video_id); + data.Store("title", e->title); + data.Store("duration", e->duration); + data.Store("view_count", e->view_count); + data.Store("expires_at", e->expires_at); + data.Store("share_count", e->share_count); + data.Store("first_seen", e->first_seen); + data.Store("last_seen", e->last_seen); + data.Store("last_channel", e->last_channel); + data.Store("last_nick", e->last_nick); + } + + Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override + { + Anope::string video_id; + data.TryLoad("video_id", video_id); + if (video_id.empty()) + return nullptr; + + YouTubeCacheEntry *e = nullptr; + if (obj) + e = anope_dynamic_static_cast(obj); + else + { + e = YouTubeCacheEntry::Find(video_id); + if (!e) + e = new YouTubeCacheEntry(video_id); + } + + e->video_id = video_id; + data.TryLoad("title", e->title); + data.TryLoad("duration", e->duration); + data.TryLoad("view_count", e->view_count); + data.TryLoad("expires_at", e->expires_at); + data.TryLoad("share_count", e->share_count); + data.TryLoad("first_seen", e->first_seen); + data.TryLoad("last_seen", e->last_seen); + data.TryLoad("last_channel", e->last_channel); + data.TryLoad("last_nick", e->last_nick); + return e; + } +}; + +// ── Per-channel statistics ── + +static constexpr const char *YOUTUBE_CHAN_DATA_TYPE = "YouTubeChanStats"; + +class YouTubeChannelStats; +using youtube_chan_map = Anope::unordered_map; +static Serialize::Checker YouTubeChanStatsList(YOUTUBE_CHAN_DATA_TYPE); + +class YouTubeChannelStats final : public Serializable +{ +public: + Anope::string channel; + uint64_t link_count = 0; + uint64_t kick_count = 0; + + explicit YouTubeChannelStats(const Anope::string &chan) + : Serializable(YOUTUBE_CHAN_DATA_TYPE) + , channel(chan) + { + YouTubeChanStatsList->insert_or_assign(chan, this); + } + + ~YouTubeChannelStats() override + { + YouTubeChanStatsList->erase(this->channel); + } + + static YouTubeChannelStats *Find(const Anope::string &chan) + { + auto it = YouTubeChanStatsList->find(chan); + return it != YouTubeChanStatsList->end() ? it->second : nullptr; + } + + static YouTubeChannelStats *FindOrCreate(const Anope::string &chan) + { + auto *e = Find(chan); + return e ? e : new YouTubeChannelStats(chan); + } +}; + +class YouTubeChanStatsDataType final : public Serialize::Type +{ +public: + YouTubeChanStatsDataType() + : Serialize::Type(YOUTUBE_CHAN_DATA_TYPE) + { + } + + void Serialize(Serializable *obj, Serialize::Data &data) const override + { + const auto *e = static_cast(obj); + data.Store("channel", e->channel); + data.Store("link_count", e->link_count); + data.Store("kick_count", e->kick_count); + } + + Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override + { + Anope::string channel; + data.TryLoad("channel", channel); + if (channel.empty()) + return nullptr; + + YouTubeChannelStats *e = nullptr; + if (obj) + e = anope_dynamic_static_cast(obj); + else + { + e = YouTubeChannelStats::Find(channel); + if (!e) + e = new YouTubeChannelStats(channel); + } + + e->channel = channel; + data.TryLoad("link_count", e->link_count); + data.TryLoad("kick_count", e->kick_count); + return e; + } +}; + +// ── Per-user statistics ── + +static constexpr const char *YOUTUBE_USER_DATA_TYPE = "YouTubeUserStats"; + +class YouTubeUserStats; +using youtube_user_map = Anope::unordered_map; +static Serialize::Checker YouTubeUserStatsList(YOUTUBE_USER_DATA_TYPE); + +class YouTubeUserStats final : public Serializable +{ +public: + Anope::string nick; + uint64_t link_count = 0; + uint64_t kick_count = 0; + + explicit YouTubeUserStats(const Anope::string &n) + : Serializable(YOUTUBE_USER_DATA_TYPE) + , nick(n) + { + YouTubeUserStatsList->insert_or_assign(n, this); + } + + ~YouTubeUserStats() override + { + YouTubeUserStatsList->erase(this->nick); + } + + static YouTubeUserStats *Find(const Anope::string &n) + { + auto it = YouTubeUserStatsList->find(n); + return it != YouTubeUserStatsList->end() ? it->second : nullptr; + } + + static YouTubeUserStats *FindOrCreate(const Anope::string &n) + { + auto *e = Find(n); + return e ? e : new YouTubeUserStats(n); + } +}; + +class YouTubeUserStatsDataType final : public Serialize::Type +{ +public: + YouTubeUserStatsDataType() + : Serialize::Type(YOUTUBE_USER_DATA_TYPE) + { + } + + void Serialize(Serializable *obj, Serialize::Data &data) const override + { + const auto *e = static_cast(obj); + data.Store("nick", e->nick); + data.Store("link_count", e->link_count); + data.Store("kick_count", e->kick_count); + } + + Serializable *Unserialize(Serializable *obj, Serialize::Data &data) const override + { + Anope::string nick; + data.TryLoad("nick", nick); + if (nick.empty()) + return nullptr; + + YouTubeUserStats *e = nullptr; + if (obj) + e = anope_dynamic_static_cast(obj); + else + { + e = YouTubeUserStats::Find(nick); + if (!e) + e = new YouTubeUserStats(nick); + } + + e->nick = nick; + data.TryLoad("link_count", e->link_count); + data.TryLoad("kick_count", e->kick_count); + return e; + } +}; + +class CommandBSYouTube final : public Command +{ +private: + Anope::string api_key; + Anope::string prefix; + Anope::string duration_text; + Anope::string seen_text; + Anope::string times_text; + Anope::string response_format; + + // Spam tracking: key = "channel\nnick" -> list of link timestamps + std::map> spam_map; + // Spam config + int spam_limit = 3; + int spam_window = 60; + bool spam_kick_enabled = true; + Anope::string spam_kick_reason = "YouTube link spam"; + int cache_ttl = 300; + + static Anope::string UnescapeConfigString(const Anope::string &in) + { + // Anope's config parser only treats \" specially. We optionally support common + // escape sequences here so users can configure IRC formatting codes. + Anope::string out; + + for (size_t i = 0; i < in.length(); ++i) + { + const char ch = in[i]; + if (ch != '\\' || i + 1 >= in.length()) + { + out.push_back(ch); + continue; + } + + const char next = in[i + 1]; + if (next == 'n') + { + out.push_back('\n'); + ++i; + } + else if (next == 'r') + { + out.push_back('\r'); + ++i; + } + else if (next == 't') + { + out.push_back('\t'); + ++i; + } + else if (next == '\\' || next == '"') + { + out.push_back(next); + ++i; + } + else if (next == 'x' && i + 3 < in.length() && std::isxdigit(static_cast(in[i + 2])) && std::isxdigit(static_cast(in[i + 3]))) + { + auto hexval = [](char c) -> int + { + if (c >= '0' && c <= '9') + return c - '0'; + c = static_cast(std::tolower(static_cast(c))); + if (c >= 'a' && c <= 'f') + return 10 + (c - 'a'); + return 0; + }; + + const int value = (hexval(in[i + 2]) << 4) | hexval(in[i + 3]); + out.push_back(static_cast(value)); + i += 3; + } + else if (next >= '0' && next <= '7') + { + // Octal \NNN (up to 3 digits) + int value = 0; + size_t j = i + 1; + for (int digits = 0; digits < 3 && j < in.length(); ++digits, ++j) + { + char oc = in[j]; + if (oc < '0' || oc > '7') + break; + value = (value << 3) | (oc - '0'); + } + out.push_back(static_cast(value & 0xFF)); + i = j - 1; + } + else + { + // Unknown escape; keep the next char and drop the backslash. + out.push_back(next); + ++i; + } + } + + return out; + } + + Anope::string BuildResponse(const Anope::string &title, const Anope::string &duration, const Anope::string &viewCount) const + { + if (!this->response_format.empty()) + { + // Simple token replacement (no printf-style formatting). + Anope::string out = this->response_format; + out = out.replace_all_cs("{title}", title); + out = out.replace_all_cs("{duration}", duration); + out = out.replace_all_cs("{views}", viewCount); + out = out.replace_all_cs("{viewCount}", viewCount); + return out; + } + + // Backwards-compatible default. + return this->prefix + " " + title + " - " + this->duration_text + duration + " - " + this->seen_text + viewCount + this->times_text; + } + + static size_t WriteCallback(void* contents, size_t size, size_t nmemb, void* userp) + { + ((std::string*)userp)->append((char*)contents, size * nmemb); + return size * nmemb; + } + + std::string ParseISO8601Duration(const std::string &duration) + { + std::chrono::seconds seconds(0); + std::smatch match; + std::regex duration_regex("PT((\\d+)H)?((\\d+)M)?((\\d+)S)?"); + + if (std::regex_match(duration, match, duration_regex)) + { + if (match[2].matched) + seconds += std::chrono::hours(std::stoi(match[2].str())); + if (match[4].matched) + seconds += std::chrono::minutes(std::stoi(match[4].str())); + if (match[6].matched) + seconds += std::chrono::seconds(std::stoi(match[6].str())); + } + + auto h = std::chrono::duration_cast(seconds).count(); + auto m = std::chrono::duration_cast(seconds).count() % 60; + auto s = seconds.count() % 60; + + std::ostringstream oss; + if (h > 0) oss << h << "h"; + if (m > 0) oss << m << "m"; + if (s > 0) oss << s << "s"; + + return oss.str(); + } + + void FetchYouTubeDetails(const Anope::string &api_key, const Anope::string &video_id, Anope::string &title, Anope::string &duration, Anope::string &viewCount) + { + if (api_key.empty()) + { + Log(LOG_DEBUG) << "YouTube API key is not configured."; + return; + } + + // Check persistent cache + auto *cached = YouTubeCacheEntry::Find(video_id); + if (cached && !cached->IsExpired()) + { + title = cached->title; + duration = cached->duration; + viewCount = cached->view_count; + Log(LOG_DEBUG) << "m_youtube: cache hit for " << video_id; + return; + } + + // Keep payload small. + const Anope::string api_url = "https://www.googleapis.com/youtube/v3/videos?id=" + video_id + + "&part=snippet,contentDetails,statistics" + + "&fields=items(snippet(title),contentDetails(duration),statistics(viewCount))" + + "&key=" + api_key; + + CURL *curl = curl_easy_init(); + if (curl) + { + curl_easy_setopt(curl, CURLOPT_URL, api_url.c_str()); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Anope-m_youtube/1.0"); + + std::string response_string; + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_string); + + CURLcode res = curl_easy_perform(curl); + if (res == CURLE_OK) + { + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + if (http_code != 200) + { + Log(LOG_DEBUG) << "YouTube API HTTP status " << http_code << " for video ID: " << video_id; + curl_easy_cleanup(curl); + return; + } + + yyjson_doc *jsonDoc = yyjson_read(response_string.c_str(), response_string.size(), 0); + if (!jsonDoc) + { + Log() << "JSON Parse Error: failed to parse YouTube API response"; + } + else + { + yyjson_val *root = yyjson_doc_get_root(jsonDoc); + yyjson_val *items = yyjson_obj_get(root, "items"); + if (items && yyjson_is_arr(items) && yyjson_arr_size(items) > 0) + { + yyjson_val *item = yyjson_arr_get_first(items); + yyjson_val *snippet = yyjson_obj_get(item, "snippet"); + yyjson_val *contentDetails = yyjson_obj_get(item, "contentDetails"); + yyjson_val *statistics = yyjson_obj_get(item, "statistics"); + + if (snippet && contentDetails && statistics + && yyjson_is_obj(snippet) && yyjson_is_obj(contentDetails) && yyjson_is_obj(statistics)) + { + yyjson_val *titleVal = yyjson_obj_get(snippet, "title"); + yyjson_val *durationVal = yyjson_obj_get(contentDetails, "duration"); + yyjson_val *viewVal = yyjson_obj_get(statistics, "viewCount"); + + if (titleVal && yyjson_is_str(titleVal) + && durationVal && yyjson_is_str(durationVal) + && viewVal) + { + title = yyjson_get_str(titleVal); + duration = ParseISO8601Duration(yyjson_get_str(durationVal)); + + if (yyjson_is_str(viewVal)) + viewCount = yyjson_get_str(viewVal); + else if (yyjson_is_uint(viewVal)) + viewCount = Anope::ToString(yyjson_get_uint(viewVal)); + else if (yyjson_is_sint(viewVal)) + viewCount = Anope::ToString(yyjson_get_sint(viewVal)); + else + Log() << "Invalid JSON response: statistics.viewCount is not a string or integer"; + + // Store in persistent cache + if (!title.empty()) + { + auto *entry = YouTubeCacheEntry::FindOrCreate(video_id); + entry->title = title; + entry->duration = duration; + entry->view_count = viewCount; + entry->expires_at = Anope::CurTime + cache_ttl; + } + } + else + { + Log() << "Invalid JSON response: missing/invalid members in snippet, contentDetails, or statistics"; + } + } + else + { + Log() << "Invalid JSON response: missing/invalid snippet, contentDetails, or statistics"; + } + } + else + { + Log() << "Invalid JSON response: 'items' field missing, not an array, or empty"; + } + yyjson_doc_free(jsonDoc); + } + } + else + { + Log() << "CURL Error: " << curl_easy_strerror(res); + } + + curl_easy_cleanup(curl); + } + else + { + Log() << "CURL Initialization Error"; + } + } + + void HandleYouTubeLink(const Anope::string &api_key, ChannelInfo *ci, Channel *c, User *u, const Anope::string &message) + { + try + { + // Supports: + // - https://www.youtube.com/watch?v=VIDEOID + // - https://youtu.be/VIDEOID + // - https://www.youtube.com/shorts/VIDEOID + // - https://www.youtube.com/embed/VIDEOID + // Also accepts m.youtube.com and trailing query/fragment. + std::regex youtube_regex( + "https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?v=|shorts/|embed/)|youtu\\.be/)([A-Za-z0-9_-]{11})(?:[?&#/].*)?", + std::regex::icase); + std::smatch match; + if (std::regex_search(message.begin(), message.end(), match, youtube_regex) && match.size() > 1) + { + // Spam / rate-limit check (only for live channel messages) + if (u && c && spam_kick_enabled && ci->bi) + { + Anope::string spam_key = ci->name + "\n" + u->nick; + auto ×tamps = spam_map[spam_key]; + const time_t now = Anope::CurTime; + + // Drop timestamps outside the window + timestamps.erase( + std::remove_if(timestamps.begin(), timestamps.end(), + [&](time_t t) { return now - t > spam_window; }), + timestamps.end()); + + timestamps.push_back(now); + + if (static_cast(timestamps.size()) > spam_limit) + { + timestamps.clear(); + YouTubeChannelStats::FindOrCreate(ci->name)->kick_count++; + YouTubeUserStats::FindOrCreate(u->nick)->kick_count++; + Log(LOG_DEBUG) << "m_youtube: kicking " << u->nick << " from " << ci->name << " for URL spam"; + c->Kick(ci->bi, u, spam_kick_reason); + return; + } + } + + Anope::string video_id = match.str(1).c_str(); + Anope::string title, duration, viewCount; + FetchYouTubeDetails(api_key, video_id, title, duration, viewCount); + + if (!title.empty() && !duration.empty() && !viewCount.empty()) + { + IRCD->SendPrivmsg(*ci->bi, ci->name, this->BuildResponse(title, duration, viewCount)); + ci->bi->lastmsg = Anope::CurTime; + + if (u && c) + { + YouTubeChannelStats::FindOrCreate(ci->name)->link_count++; + YouTubeUserStats::FindOrCreate(u->nick)->link_count++; + auto *ce = YouTubeCacheEntry::Find(video_id); + if (ce) + { + ce->share_count++; + ce->last_seen = Anope::CurTime; + ce->last_channel = ci->name; + ce->last_nick = u->nick; + if (ce->first_seen == 0) + ce->first_seen = Anope::CurTime; + } + } + } + else + { + Log(LOG_DEBUG) << "Failed to fetch YouTube details for video ID: " << video_id; + } + } + } + catch (const std::regex_error &e) + { + Log() << "Regex error: " << e.what(); + } + } + +public: + CommandBSYouTube(Module *creator) : Command(creator, "botserv/youtube", 2, 2) + { + this->SetDesc(_("Handles YouTube links and fetches video details")); + this->SetSyntax(_("\037channel\037 \037message\037")); + + // Reasonable defaults (can be overridden in config). + this->prefix = "\x02\x03""01,00You\x03""00,04Tube\x0F\x02"; + this->duration_text = "Duration: "; + this->seen_text = "Seen: "; + this->times_text = " times."; + } + + void SetAPIKey(const Anope::string &key) + { + this->api_key = key; + } + + void SetResponseConfig(const Anope::string &prefix, + const Anope::string &duration_text, + const Anope::string &seen_text, + const Anope::string ×_text, + const Anope::string &response_format) + { + if (!prefix.empty()) + this->prefix = UnescapeConfigString(prefix); + if (!duration_text.empty()) + this->duration_text = UnescapeConfigString(duration_text); + if (!seen_text.empty()) + this->seen_text = UnescapeConfigString(seen_text); + if (!times_text.empty()) + this->times_text = UnescapeConfigString(times_text); + this->response_format = UnescapeConfigString(response_format); + } + + void SetSpamConfig(int limit, int window, bool kick_enabled, const Anope::string &kick_reason, int ttl) + { + this->spam_limit = limit; + this->spam_window = window; + this->spam_kick_enabled = kick_enabled; + this->spam_kick_reason = kick_reason.empty() ? "YouTube link spam" : kick_reason; + this->cache_ttl = ttl; + } + + void Execute(CommandSource &source, const std::vector ¶ms) override + { + const Anope::string &channel = params[0]; + const Anope::string &message = params[1]; + + ChannelInfo *ci = ChannelInfo::Find(channel); + if (ci == NULL) + { + source.Reply(CHAN_X_NOT_REGISTERED, channel.c_str()); + return; + } + + if (!source.AccessFor(ci).HasPriv("SAY") && !source.HasPriv("botserv/administration")) + { + source.Reply(ACCESS_DENIED); + return; + } + + if (!ci->bi) + { + source.Reply(BOT_NOT_ASSIGNED); + return; + } + + if (!ci->c || !ci->c->FindUser(ci->bi)) + { + source.Reply(BOT_NOT_ON_CHANNEL, ci->name.c_str()); + return; + } + + if (!message.empty() && message[0] == '\001') + { + this->OnSyntaxError(source, ""); + return; + } + + HandleYouTubeLink(this->api_key, ci, nullptr, nullptr, message); + + bool override = !source.AccessFor(ci).HasPriv("SAY"); + Log(override ? LOG_OVERRIDE : LOG_COMMAND, source, this, ci) << "to fetch YouTube info"; + } + + bool OnHelp(CommandSource &source, const Anope::string &subcommand) override + { + this->SendSyntax(source); + source.Reply(" "); + source.Reply(_("Handles YouTube links and fetches video details when a YouTube link is posted in the channel.")); + return true; + } + + friend class YouTubeModule; +}; + +class CommandBSYouTubeStats final : public Command +{ +private: + static Anope::string FormatTime(time_t t) + { + if (t == 0) + return "never"; + char buf[32]; + struct tm *tm_info = localtime(&t); + strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm_info); + return buf; + } + +public: + CommandBSYouTubeStats(Module *creator) : Command(creator, "botserv/ytstats", 2, 2) + { + this->SetDesc(_("Show YouTube module usage statistics")); + this->SetSyntax(_("VIDEO \037video_id\037")); + this->SetSyntax(_("CHANNEL \037#channel\037")); + this->SetSyntax(_("USER \037nick\037")); + } + + // Send a message either through the channel bot (fantasy) or via source.Reply (PM). + void Say(CommandSource &source, const char *fmt, ...) ATTR_FORMAT(3, 4) + { + char buf[1024]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + + if (source.c && source.c->ci && source.c->ci->bi) + { + IRCD->SendPrivmsg(*source.c->ci->bi, source.c->name, buf); + source.c->ci->bi->lastmsg = Anope::CurTime; + } + else + { + source.Reply("%s", buf); + } + } + + void Execute(CommandSource &source, const std::vector ¶ms) override + { + const Anope::string type = params[0].upper(); + const Anope::string &target = params[1]; + + if (type == "VIDEO") + { + const YouTubeCacheEntry *e = YouTubeCacheEntry::Find(target); + if (!e) + { + Say(source, "No data found for video \002%s\002.", target.c_str()); + return; + } + Say(source, "Video \002%s\002: \"%s\" | %s | %s views", + target.c_str(), e->title.c_str(), + e->duration.c_str(), e->view_count.c_str()); + Say(source, " Shared \002%s\002 time(s) | First seen: %s | Last seen: %s", + Anope::ToString(e->share_count).c_str(), + FormatTime(e->first_seen).c_str(), + FormatTime(e->last_seen).c_str()); + if (!e->last_nick.empty()) + Say(source, " Last posted by \002%s\002 in \002%s\002", + e->last_nick.c_str(), e->last_channel.c_str()); + } + else if (type == "CHANNEL") + { + const YouTubeChannelStats *e = YouTubeChannelStats::Find(target); + if (!e) + { + Say(source, "No data found for channel \002%s\002.", target.c_str()); + return; + } + Say(source, "Channel \002%s\002: \002%s\002 link(s) posted, \002%s\002 spam kick(s).", + target.c_str(), + Anope::ToString(e->link_count).c_str(), + Anope::ToString(e->kick_count).c_str()); + } + else if (type == "USER") + { + const YouTubeUserStats *e = YouTubeUserStats::Find(target); + if (!e) + { + Say(source, "No data found for user \002%s\002.", target.c_str()); + return; + } + Say(source, "User \002%s\002: \002%s\002 link(s) posted, \002%s\002 spam kick(s).", + target.c_str(), + Anope::ToString(e->link_count).c_str(), + Anope::ToString(e->kick_count).c_str()); + } + else + { + this->OnSyntaxError(source, type); + } + } + + bool OnHelp(CommandSource &source, const Anope::string &) override + { + this->SendSyntax(source); + source.Reply(" "); + source.Reply(_("Shows YouTube module usage statistics.")); + source.Reply(_("VIDEO Show share count, duration, and first/last seen info.")); + source.Reply(_("CHANNEL <#chan> Show total links posted and spam kicks for a channel.")); + source.Reply(_("USER Show total links posted and spam kicks for a user.")); + return true; + } +}; + +class YouTubeModule final : public Module +{ + CommandBSYouTube commandbsYouTube; + CommandBSYouTubeStats commandbsYouTubeStats; + YouTubeCacheDataType youtube_cache_type; + YouTubeChanStatsDataType youtube_chan_stats_type; + YouTubeUserStatsDataType youtube_user_stats_type; + Anope::string api_key; + Anope::string prefix; + Anope::string duration_text; + Anope::string seen_text; + Anope::string times_text; + Anope::string response_format; + + void LoadConfig(Configuration::Conf &conf) + { + const auto &modconf = conf.GetModule(this); + + // Prefer youtube_api_key but accept api_key for convenience. + this->api_key = modconf.Get("youtube_api_key"); + if (this->api_key.empty()) + this->api_key = modconf.Get("api_key"); + + if (this->api_key.empty()) + Log() << "m_youtube: youtube_api_key not set in modules.conf; video info lookups are disabled."; + + // Response text customization. + this->prefix = modconf.Get("prefix", "\x02\x03""01,00You\x03""00,04Tube\x0F\x02"); + + // Allow short key names for convenience. + this->duration_text = modconf.Get("duration_text"); + if (this->duration_text.empty()) + this->duration_text = modconf.Get("duration", "Duration: "); + + this->seen_text = modconf.Get("seen_text"); + if (this->seen_text.empty()) + this->seen_text = modconf.Get("seen", "Seen: "); + + this->times_text = modconf.Get("times_text"); + if (this->times_text.empty()) + this->times_text = modconf.Get("times", " times."); + + this->response_format = modconf.Get("response_format"); + if (this->response_format.empty()) + this->response_format = modconf.Get("format"); + + commandbsYouTube.SetAPIKey(this->api_key); + commandbsYouTube.SetResponseConfig(this->prefix, this->duration_text, this->seen_text, this->times_text, this->response_format); + + const int spam_limit = modconf.Get("spam_limit", "3"); + const int spam_window = modconf.Get("spam_window", "60"); + const bool spam_kick = modconf.Get("spam_kick", "yes"); + const Anope::string spam_reason = modconf.Get("spam_kick_reason", "YouTube link spam"); + const int cache_ttl = modconf.Get("cache_ttl", "300"); + commandbsYouTube.SetSpamConfig(spam_limit, spam_window, spam_kick, spam_reason, cache_ttl); + } + +public: + YouTubeModule(const Anope::string &modname, const Anope::string &creator) + : Module(modname, creator, VENDOR) + , commandbsYouTube(this) + , commandbsYouTubeStats(this) + { + // Configuration is loaded via OnReload. + } + + ~YouTubeModule() + { + // Delete every in-memory record on unload. Each ~Serializable removes + // itself from its list (and from Anope's global SerializableItems), so + // this must run while the module's code is still mapped -- otherwise the + // objects leak and leave dangling vtable pointers in the global list. + while (!YouTubeCacheList->empty()) + { + auto it = YouTubeCacheList->begin(); + YouTubeCacheEntry *e = it->second; + if (!e) { YouTubeCacheList->erase(it); continue; } + delete e; + } + while (!YouTubeChanStatsList->empty()) + { + auto it = YouTubeChanStatsList->begin(); + YouTubeChannelStats *e = it->second; + if (!e) { YouTubeChanStatsList->erase(it); continue; } + delete e; + } + while (!YouTubeUserStatsList->empty()) + { + auto it = YouTubeUserStatsList->begin(); + YouTubeUserStats *e = it->second; + if (!e) { YouTubeUserStatsList->erase(it); continue; } + delete e; + } + } + + void OnReload(Configuration::Conf &conf) override + { + this->LoadConfig(conf); + } + + void OnShutdown() override + { + Anope::SaveDatabases(); + } + + void OnRestart() override + { + Anope::SaveDatabases(); + } + + void OnPrivmsg(User *u, Channel *c, Anope::string &msg, const Anope::map &tags) override + { + if (u == nullptr || c == nullptr || c->ci == nullptr || c->ci->bi == nullptr || msg.empty() || msg[0] == '\1') + return; + + commandbsYouTube.HandleYouTubeLink(this->api_key, c->ci, c, u, msg); + } +}; + +MODULE_INIT(YouTubeModule) \ No newline at end of file