Switch to side-by-side view

--- a
+++ b/src/ohcredentials.cxx
@@ -0,0 +1,588 @@
+/* Copyright (C) 2018 J.F.Dockes
+ *
+ * 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 2 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, write to the
+ * Free Software Foundation, Inc.,
+ * 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
+ */
+
+#define LOGGER_LOCAL_LOGINC 6
+
+#include "ohcredentials.hxx"
+
+#include <upnp/upnp.h>
+#include <sys/stat.h>
+
+#include <functional>
+#include <iostream>
+#include <map>
+#include <utility>
+#include <vector>
+#include <regex>
+
+#include "conftree.h"
+#include "main.hxx"
+#include "pathut.h"
+#include "execmd.h"
+#include "mediaserver/cdplugins/cmdtalk.h"
+
+#include "libupnpp/log.hxx"
+#include "libupnpp/base64.hxx"
+#include "libupnpp/soaphelp.hxx"
+#include "libupnpp/device/device.hxx"
+#include "mediaserver/cdplugins/cdplugin.hxx"
+
+using namespace std;
+using namespace std::placeholders;
+
+static const string sTpCredentials("urn:av-openhome-org:service:Credentials:1");
+static const string sIdCredentials("urn:av-openhome-org:serviceId:Credentials");
+
+static const string idstring{"tidalhifi.com qobuz.com"};
+static const map<string, string> idmap {
+    {"tidalhifi.com", "tidal"},
+    {"qobuz.com", "qobuz"}
+};
+
+// This is used for translating urls for the special use of
+// Kazoo/Lumin. The media server, which is used to run the http server
+// and for getting the real media URLs, must run on this host (for one
+// thing the creds are passed through a local file).
+// *** Note that this needs xxxautostart to work, else the HTTP server
+//     won't be listening (as long as nobody accesses the app section
+//     of the media server) ***
+static string upnphost;
+
+// Called from OHPlaylist. The CP (Kazoo/Lumin mostly) will send URIs
+// like qobuz:// tidal:// and expect the renderer to know what to do
+// with them. We transform them so that they point to our media server
+// gateway (which should be running of course for this to work).
+bool OHCredsMaybeMorphSpecialUri(string& uri)
+{
+    if (uri.find("http://") == 0 || uri.find("https://") == 0) {
+        return true;
+    }
+
+    static string sport;
+    if (sport.empty()) {
+        std::unique_lock<std::mutex>(g_configlock);
+        int port = CDPluginServices::default_microhttpport();
+        if (!g_config->get("plgmicrohttpport", sport)) {
+            sport = SoapHelp::i2s(port);
+        }
+    }
+
+    // http://wiki.openhome.org/wiki/Av:Developer:Eriskay:StreamingServices
+    // Tidal and qobuz tracks added by Kazoo / Lumin: 
+    //   tidal://track?version=1&trackId=[tidal_track_id]
+    //   qobuz://track?version=2&trackId=[qobuz_track_id]
+    
+    string se =
+        "(tidal|qobuz)://track\\?version=([[:digit:]]+)&trackId=([[:digit:]]+)";
+    std::regex e(se);
+    std::smatch mr;
+    bool found = std::regex_match(uri, mr, e);
+    if (found) {
+        string pathprefix = CDPluginServices::getpathprefix(mr[1]);
+
+        // The microhttpd code actually only cares about getting a
+        // trackId parameter. Make it look what the plugins normally
+        // generate anyway:
+        string path = path_cat(pathprefix,
+                               "track?version=1&trackId=" + mr[3].str());
+        uri = string("http://") + upnphost + ":" + sport + path;
+    }
+    return found;
+}
+
+// We might want to derive this into ServiceCredsQobuz,
+// ServiceCredsTidal, there is a lot in common and a few diffs.
+struct ServiceCreds {
+    ServiceCreds() {}
+    ServiceCreds(const string& inm, const string& u, const string& p,
+                 const string& ep)
+        : servicename(inm), user(u), password(p), encryptedpass(ep) {
+
+        if (servicename == "qobuz") {
+            // The appid used by the Qobuz python module. Has to be
+            // consistent with the token obtained by the same, so we
+            // return it (by convention, as seen in wiresharking
+            // kazoo) in the data field. We could and do obtain the
+            // appid from the module, but kazoo apparently wants it
+            // before we login, so just hard-code it for now.  The
+            // Python code uses the value from XBMC,285473059,
+            // ohplayer uses 854233864
+            data = "285473059";
+        } else if (servicename == "tidal") {
+            // data contains the country code
+            data = "FR";
+        }
+    }
+
+    ~ServiceCreds() {
+        delete cmd;
+    }
+
+    // We need a Python helper to perform the login. That's the media
+    // server gateway module, from which we only use a separate method
+    // which logs-in and returns the auth data (token, etc.)
+    bool maybeStartCmd() {
+        LOGDEB("ServiceCreds: " << servicename << " maybeStartCmd()\n");
+        if (nullptr == cmd) {
+            cmd = new CmdTalk(30);
+        }
+        if (cmd->running()) {
+            return true;
+        }
+        LOGDEB("ServiceCreds: " << servicename << " starting cmd\n");
+        string exepath = path_cat(g_datadir, "cdplugins");
+        exepath = path_cat(exepath, servicename);
+        exepath = path_cat(exepath, servicename + "-app" + ".py");
+
+        string pythonpath = string("PYTHONPATH=") +
+            path_cat(g_datadir, "cdplugins") + ":" +
+            path_cat(g_datadir, "cdplugins/pycommon") + ":" +
+            path_cat(g_datadir, "cdplugins/" + servicename);
+        string configname = string("UPMPD_CONFIG=") + g_configfilename;
+        // hostport is not needed by this login-only instance.
+        string hostport = string("UPMPD_HTTPHOSTPORT=bogus:0");
+        string pp = string("UPMPD_PATHPREFIX=") +
+            CDPluginServices::getpathprefix(servicename);
+        if (!cmd->startCmd(exepath, {/*args*/},
+                           /* env */ {pythonpath, configname, hostport, pp})) {
+            LOGERR("ServiceCreds::maybeStartCmd: startCmd failed\n");
+            return false;
+        }
+        LOGDEB("ServiceCreds: " << servicename << " cmd started\n");
+        return true;
+    }
+
+    string login() {
+        LOGDEB("ServiceCreds: " << servicename << " login\n");
+
+        // Check if already logged-in
+        if (servicename == "qobuz" || servicename == "tidal") {
+            if (!servicedata["token"].empty()) {
+                return servicedata["token"];
+            }
+        } else {
+            LOGERR("Unsupported service: " << servicename << endl);
+            return string();
+        }
+
+        if (!maybeStartCmd()) {
+            return string();
+        }
+        unordered_map<string,string> res;
+        if (!cmd->callproc("login", {{"user", user},
+                    {"password", password}}, res)) {
+            LOGERR("ServiceCreds::login: slave failure. Service " <<
+                   servicename << " user " << user << endl);
+            return string();
+        }
+
+        vector<string> toknames;
+        if (servicename == "qobuz") {
+            toknames = vector<string>{"token", "appid"};
+        } else if (servicename == "tidal") {
+            toknames = vector<string>{"token", "country"};
+        }
+        for (const auto& toknm : toknames) {
+            auto it = res.find(toknm);
+            if (it == res.end()) {
+                LOGERR("ServiceCreds::login: no " << toknm << ". Service " <<
+                       servicename << " user " << user << endl);
+                return string();
+            }
+            servicedata[toknm] = it->second;
+        }
+        if (servicename == "qobuz") {
+            data = servicedata["appid"];
+        } else if (servicename == "tidal") {
+            data = servicedata["country"];
+        }
+        return servicedata["token"];
+    }
+
+    void logout() {
+        servicedata.clear();
+    }
+
+    string str() {
+        string s;
+        string sdata;
+        for (const auto& entry:servicedata) {
+            sdata += entry.first + " : " + entry.second + ", ";
+        }
+        s += "Service: " + servicename + " User: " + user +
+            /*" Pass: "+password*/ + " Servicedata: " + sdata +
+            /*" EPass: "+encryptedpass*/ + " Enabled: " +
+            SoapHelp::i2s(enabled) + " Status: " + status + " Data: " + data;
+        return s;
+    }
+
+    // Internal name, like "qobuz"
+    string servicename;
+    string user;
+    string password;
+    string encryptedpass;
+    bool enabled{true};
+    CmdTalk *cmd{0};
+    // Things we obtain from the module and send to the CP
+    unordered_map<string,string> servicedata;
+
+    string status;
+    // See comments about 'data' use above.
+    string data;
+};
+
+class OHCredentials::Internal {
+public:
+    
+    Internal(const string& cd) {
+        cachedir = path_cat(cd, "ohcreds");
+        if (!path_makepath(cachedir, 0700)) {
+            LOGERR("OHCredentials: can't create cache dir " << cachedir <<endl);
+            return;
+        }
+        keyfile = path_cat(cachedir, "credkey.pem");
+        cmd.putenv("RANDFILE", path_cat(cachedir, "randfile"));
+
+        if (!path_exists(keyfile)) {
+            vector<string> acmd{"openssl", "genrsa", "-out", keyfile, "4096"};
+            int status = cmd.doexec1(acmd);
+            chmod(keyfile.c_str(), 0600);
+            if (status != 0) {
+                LOGERR("OHCredentials: could not create key\n");
+                return;
+            }
+        }
+
+        vector<string> acmd{"openssl", "pkey", "-in", keyfile, "-pubout"};
+        if (!cmd.backtick(acmd, pubkey)) {
+            LOGERR("OHCredentials: could not read public key\n");
+            return;
+        }
+
+        LOGDEB1("OHCredentials: my public key:\n" << pubkey << endl);
+    }
+
+    bool decrypt(const string& in, string& out) {
+        vector<string> acmd{"openssl", "pkeyutl", "-inkey",
+                keyfile, "-pkeyopt", "rsa_padding_mode:oaep", "-decrypt"};
+        int status = cmd.doexec1(acmd, &in, &out);
+        if (status) {
+            LOGERR("OHCredentials: decrypt failed\n");
+            return false;
+        }
+        //LOGDEB1("decrypt: [" << out << "]\n");
+        return true;
+    }
+
+    bool setEnabled(const string& id, bool enabled) {
+        auto it = creds.find(id);
+        if (it == creds.end()) {
+            return false;
+        }
+        it->second.enabled = enabled;
+        return true;
+    }
+
+    bool save() {
+        string credsfile = path_cat(cachedir, "screds");
+        ConfSimple credsconf(credsfile.c_str());
+        if (!credsconf.ok()) {
+            LOGERR("OHCredentials: error opening " << credsfile <<
+                   " errno " << errno << endl);
+            return false;
+        }
+        for (const auto& cred : creds) {
+            credsconf.set("u", cred.second.user, cred.second.servicename);
+            credsconf.set("p", cred.second.password, cred.second.servicename);
+        }
+        chmod(credsfile.c_str(), 0600);
+        return true;
+    }
+    
+    ExecCmd cmd;
+    string cachedir;
+    string keyfile;
+    string pubkey;
+    int seq{1};
+    map<string, ServiceCreds> creds;
+};
+
+
+OHCredentials::OHCredentials(UpMpd *dev, const string& cachedir)
+    : OHService(sTpCredentials, sIdCredentials, dev), m(new Internal(cachedir))
+{
+    dev->addActionMapping(
+        this, "Set",
+        bind(&OHCredentials::actSet, this, _1, _2));
+    dev->addActionMapping(
+        this, "Clear",
+        bind(&OHCredentials::actClear, this, _1, _2));
+    dev->addActionMapping(
+        this, "SetEnabled",
+        bind(&OHCredentials::actSetEnabled, this, _1, _2));
+    dev->addActionMapping(
+        this, "Get",
+        bind(&OHCredentials::actGet, this, _1, _2));
+    dev->addActionMapping(
+        this, "Login",
+        bind(&OHCredentials::actLogin, this, _1, _2));
+    dev->addActionMapping(
+        this, "ReLogin",
+        bind(&OHCredentials::actReLogin, this, _1, _2));
+    dev->addActionMapping(
+        this, "GetIds",
+        bind(&OHCredentials::actGetIds, this, _1, _2));
+    dev->addActionMapping(
+        this, "GetPublicKey",
+        bind(&OHCredentials::actGetPublicKey, this, _1, _2));
+    dev->addActionMapping(
+        this, "GetSequenceNumber",
+        bind(&OHCredentials::actGetSequenceNumber, this, _1, _2));
+
+    unsigned short usport;
+    dev->ipv4(&upnphost, &usport);
+}
+
+OHCredentials::~OHCredentials()
+{
+    delete m;
+}
+
+bool OHCredentials::makestate(unordered_map<string, string> &st)
+{
+    st.clear();
+    if (nullptr == m) {
+        return false;
+    }
+    st["Ids"] = idstring;
+    st["PublicKey"] = m->pubkey;
+    st["SequenceNumber"] = SoapHelp::i2s(m->seq);
+    return true;
+}
+
+int OHCredentials::actSet(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actSet: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    std::string in_UserName;
+    ok = sc.get("UserName", &in_UserName);
+    if (!ok) {
+        LOGERR("OHCredentials::actSet: no UserName in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    string in_Password;
+    ok = sc.get("Password", &in_Password);
+    if (!ok) {
+        LOGERR("OHCredentials::actSet: no Password in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actSet: " << " Id " << in_Id << " UserName " <<
+           in_UserName << " Password " << in_Password << endl);
+
+    const auto it1 = idmap.find(in_Id);
+    if (it1 == idmap.end()) {
+        LOGERR("OHCredentials::actSet: bad service id [" << in_Id <<"]\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    string servicename = it1->second;
+    string cpass = base64_decode(in_Password);
+    string plainpass;
+    if (!m->decrypt(cpass, plainpass)) {
+        LOGERR("OHCredentials::actSet: could not decrypt\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    auto it = m->creds.find(in_Id);
+    if (it == m->creds.end() || it->second.user != in_UserName ||
+        it->second.password != plainpass ||
+        it->second.encryptedpass != in_Password) {
+        m->creds[in_Id] = ServiceCreds(servicename, in_UserName, plainpass,
+                                       in_Password);
+    }
+    m->seq++;
+    m->save();
+    if (m->setEnabled(in_Id, true)) {
+        return UPNP_E_SUCCESS;
+    } else {
+        return UPNP_E_INVALID_PARAM;
+    }
+}
+
+int OHCredentials::actLogin(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actLogin: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actLogin: " << " Id " << in_Id << endl);
+    auto it = m->creds.find(in_Id);
+    if (it == m->creds.end()) {
+        LOGERR("OHCredentials::Login: Id " << in_Id << " not found\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    string token = it->second.login();
+    LOGDEB("OHCredentials::Login: got token " << token << endl);
+    data.addarg("Token", token);
+    m->seq++;
+    return UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actReLogin(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actReLogin: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    std::string in_CurrentToken;
+    ok = sc.get("CurrentToken", &in_CurrentToken);
+    if (!ok) {
+        LOGERR("OHCredentials::actReLogin: no CurrentToken in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actReLogin: " << " Id " << in_Id << " CurrentToken "
+           << in_CurrentToken << endl);
+
+    auto it = m->creds.find(in_Id);
+    if (it == m->creds.end()) {
+        LOGERR("OHCredentials::Login: Id " << in_Id << " not found\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    it->second.logout();
+    string token = it->second.login();
+    data.addarg("NewToken", token);
+    m->seq++;
+    return UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actClear(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actClear: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actClear: " << " Id " << in_Id << endl);
+    if (idmap.find(in_Id) == idmap.end()) {
+        LOGERR("OHCredentials::actClear: bad service id [" << in_Id <<"]\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    auto it = m->creds.find(in_Id);
+    if (it != m->creds.end()) {
+        m->creds.erase(it);
+        m->seq++;
+    }
+    return UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actSetEnabled(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actSetEnabled: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+    bool in_Enabled;
+    ok = sc.get("Enabled", &in_Enabled);
+    if (!ok) {
+        LOGERR("OHCredentials::actSetEnabled: no Enabled in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actSetEnabled: " << " Id " << in_Id << " Enabled " <<
+           in_Enabled << endl);
+    if (m->setEnabled(in_Id, in_Enabled)) {
+        m->seq++;
+        return UPNP_E_SUCCESS;
+    } else {
+        return UPNP_E_INVALID_PARAM;
+    }
+}
+
+int OHCredentials::actGet(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    bool ok = false;
+    std::string in_Id;
+    ok = sc.get("Id", &in_Id);
+    if (!ok) {
+        LOGERR("OHCredentials::actGet: no Id in params\n");
+        return UPNP_E_INVALID_PARAM;
+    }
+
+    LOGDEB("OHCredentials::actGet: " << " Id " << in_Id << endl);
+
+    auto it = m->creds.find(in_Id);
+    ServiceCreds emptycreds;
+    ServiceCreds *credsp(&emptycreds);
+    if (it != m->creds.end()) {
+        credsp = &(it->second);
+    } else {
+        LOGDEB("OHCredentials::actGet: nothing found for " << in_Id << endl);
+    }
+    LOGDEB("OHCredentials::actGet: data for " << in_Id << " " <<
+           credsp->str() << endl);
+    data.addarg("UserName", credsp->user);
+    // Encrypted password !
+    data.addarg("Password", credsp->encryptedpass);
+    // In theory enabled is set in response to setEnabled() or
+    // set(). In practise, if it is not set, we don't get to the qobuz
+    // settings screen in kazoo.
+    data.addarg("Enabled", credsp->enabled ? "1" : "1");
+    data.addarg("Status", credsp->status);
+    data.addarg("Data", credsp->data);
+    return UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actGetIds(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    LOGDEB("OHCredentials::actGetIds: " << endl);
+    data.addarg("Ids", idstring);
+    return UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actGetPublicKey(const SoapIncoming& sc, SoapOutgoing& data)
+{
+    LOGDEB("OHCredentials::actGetPublicKey: " << endl);
+    data.addarg("PublicKey", m->pubkey);
+    return m->pubkey.empty() ? UPNP_E_INTERNAL_ERROR : UPNP_E_SUCCESS;
+}
+
+int OHCredentials::actGetSequenceNumber(const SoapIncoming& sc,
+                                        SoapOutgoing& data)
+{
+    LOGDEB("OHCredentials::actGetSequenceNumber: " << endl);
+    data.addarg("SequenceNumber", SoapHelp::i2s(m->seq));
+    return UPNP_E_SUCCESS;
+}