a b/src/ohcredentials.cxx
1
/* Copyright (C) 2018 J.F.Dockes
2
 *
3
 * This program is free software; you can redistribute it and/or modify
4
 * it under the terms of the GNU General Public License as published by
5
 * the Free Software Foundation; either version 2 of the License, or
6
 * (at your option) any later version.
7
 *
8
 * This program is distributed in the hope that it will be useful,
9
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
 * GNU General Public License for more details.
12
 *
13
 * You should have received a copy of the GNU General Public License
14
 * along with this program; if not, write to the
15
 * Free Software Foundation, Inc.,
16
 * 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
17
 */
18
19
#define LOGGER_LOCAL_LOGINC 6
20
21
#include "ohcredentials.hxx"
22
23
#include <upnp/upnp.h>
24
#include <sys/stat.h>
25
26
#include <functional>
27
#include <iostream>
28
#include <map>
29
#include <utility>
30
#include <vector>
31
#include <regex>
32
33
#include "conftree.h"
34
#include "main.hxx"
35
#include "pathut.h"
36
#include "execmd.h"
37
#include "mediaserver/cdplugins/cmdtalk.h"
38
39
#include "libupnpp/log.hxx"
40
#include "libupnpp/base64.hxx"
41
#include "libupnpp/soaphelp.hxx"
42
#include "libupnpp/device/device.hxx"
43
#include "mediaserver/cdplugins/cdplugin.hxx"
44
45
using namespace std;
46
using namespace std::placeholders;
47
48
static const string sTpCredentials("urn:av-openhome-org:service:Credentials:1");
49
static const string sIdCredentials("urn:av-openhome-org:serviceId:Credentials");
50
51
static const string idstring{"tidalhifi.com qobuz.com"};
52
static const map<string, string> idmap {
53
    {"tidalhifi.com", "tidal"},
54
    {"qobuz.com", "qobuz"}
55
};
56
57
// This is used for translating urls for the special use of
58
// Kazoo/Lumin. The media server, which is used to run the http server
59
// and for getting the real media URLs, must run on this host (for one
60
// thing the creds are passed through a local file).
61
// *** Note that this needs xxxautostart to work, else the HTTP server
62
//     won't be listening (as long as nobody accesses the app section
63
//     of the media server) ***
64
static string upnphost;
65
66
// Called from OHPlaylist. The CP (Kazoo/Lumin mostly) will send URIs
67
// like qobuz:// tidal:// and expect the renderer to know what to do
68
// with them. We transform them so that they point to our media server
69
// gateway (which should be running of course for this to work).
70
bool OHCredsMaybeMorphSpecialUri(string& uri)
71
{
72
    if (uri.find("http://") == 0 || uri.find("https://") == 0) {
73
        return true;
74
    }
75
76
    static string sport;
77
    if (sport.empty()) {
78
        std::unique_lock<std::mutex>(g_configlock);
79
        int port = CDPluginServices::default_microhttpport();
80
        if (!g_config->get("plgmicrohttpport", sport)) {
81
            sport = SoapHelp::i2s(port);
82
        }
83
    }
84
85
    // http://wiki.openhome.org/wiki/Av:Developer:Eriskay:StreamingServices
86
    // Tidal and qobuz tracks added by Kazoo / Lumin: 
87
    //   tidal://track?version=1&trackId=[tidal_track_id]
88
    //   qobuz://track?version=2&trackId=[qobuz_track_id]
89
    
90
    string se =
91
        "(tidal|qobuz)://track\\?version=([[:digit:]]+)&trackId=([[:digit:]]+)";
92
    std::regex e(se);
93
    std::smatch mr;
94
    bool found = std::regex_match(uri, mr, e);
95
    if (found) {
96
        string pathprefix = CDPluginServices::getpathprefix(mr[1]);
97
98
        // The microhttpd code actually only cares about getting a
99
        // trackId parameter. Make it look what the plugins normally
100
        // generate anyway:
101
        string path = path_cat(pathprefix,
102
                               "track?version=1&trackId=" + mr[3].str());
103
        uri = string("http://") + upnphost + ":" + sport + path;
104
    }
105
    return found;
106
}
107
108
// We might want to derive this into ServiceCredsQobuz,
109
// ServiceCredsTidal, there is a lot in common and a few diffs.
110
struct ServiceCreds {
111
    ServiceCreds() {}
112
    ServiceCreds(const string& inm, const string& u, const string& p,
113
                 const string& ep)
114
        : servicename(inm), user(u), password(p), encryptedpass(ep) {
115
116
        if (servicename == "qobuz") {
117
            // The appid used by the Qobuz python module. Has to be
118
            // consistent with the token obtained by the same, so we
119
            // return it (by convention, as seen in wiresharking
120
            // kazoo) in the data field. We could and do obtain the
121
            // appid from the module, but kazoo apparently wants it
122
            // before we login, so just hard-code it for now.  The
123
            // Python code uses the value from XBMC,285473059,
124
            // ohplayer uses 854233864
125
            data = "285473059";
126
        } else if (servicename == "tidal") {
127
            // data contains the country code
128
            data = "FR";
129
        }
130
    }
131
132
    ~ServiceCreds() {
133
        delete cmd;
134
    }
135
136
    // We need a Python helper to perform the login. That's the media
137
    // server gateway module, from which we only use a separate method
138
    // which logs-in and returns the auth data (token, etc.)
139
    bool maybeStartCmd() {
140
        LOGDEB("ServiceCreds: " << servicename << " maybeStartCmd()\n");
141
        if (nullptr == cmd) {
142
            cmd = new CmdTalk(30);
143
        }
144
        if (cmd->running()) {
145
            return true;
146
        }
147
        LOGDEB("ServiceCreds: " << servicename << " starting cmd\n");
148
        string exepath = path_cat(g_datadir, "cdplugins");
149
        exepath = path_cat(exepath, servicename);
150
        exepath = path_cat(exepath, servicename + "-app" + ".py");
151
152
        string pythonpath = string("PYTHONPATH=") +
153
            path_cat(g_datadir, "cdplugins") + ":" +
154
            path_cat(g_datadir, "cdplugins/pycommon") + ":" +
155
            path_cat(g_datadir, "cdplugins/" + servicename);
156
        string configname = string("UPMPD_CONFIG=") + g_configfilename;
157
        // hostport is not needed by this login-only instance.
158
        string hostport = string("UPMPD_HTTPHOSTPORT=bogus:0");
159
        string pp = string("UPMPD_PATHPREFIX=") +
160
            CDPluginServices::getpathprefix(servicename);
161
        if (!cmd->startCmd(exepath, {/*args*/},
162
                           /* env */ {pythonpath, configname, hostport, pp})) {
163
            LOGERR("ServiceCreds::maybeStartCmd: startCmd failed\n");
164
            return false;
165
        }
166
        LOGDEB("ServiceCreds: " << servicename << " cmd started\n");
167
        return true;
168
    }
169
170
    string login() {
171
        LOGDEB("ServiceCreds: " << servicename << " login\n");
172
173
        // Check if already logged-in
174
        if (servicename == "qobuz" || servicename == "tidal") {
175
            if (!servicedata["token"].empty()) {
176
                return servicedata["token"];
177
            }
178
        } else {
179
            LOGERR("Unsupported service: " << servicename << endl);
180
            return string();
181
        }
182
183
        if (!maybeStartCmd()) {
184
            return string();
185
        }
186
        unordered_map<string,string> res;
187
        if (!cmd->callproc("login", {{"user", user},
188
                    {"password", password}}, res)) {
189
            LOGERR("ServiceCreds::login: slave failure. Service " <<
190
                   servicename << " user " << user << endl);
191
            return string();
192
        }
193
194
        vector<string> toknames;
195
        if (servicename == "qobuz") {
196
            toknames = vector<string>{"token", "appid"};
197
        } else if (servicename == "tidal") {
198
            toknames = vector<string>{"token", "country"};
199
        }
200
        for (const auto& toknm : toknames) {
201
            auto it = res.find(toknm);
202
            if (it == res.end()) {
203
                LOGERR("ServiceCreds::login: no " << toknm << ". Service " <<
204
                       servicename << " user " << user << endl);
205
                return string();
206
            }
207
            servicedata[toknm] = it->second;
208
        }
209
        if (servicename == "qobuz") {
210
            data = servicedata["appid"];
211
        } else if (servicename == "tidal") {
212
            data = servicedata["country"];
213
        }
214
        return servicedata["token"];
215
    }
216
217
    void logout() {
218
        servicedata.clear();
219
    }
220
221
    string str() {
222
        string s;
223
        string sdata;
224
        for (const auto& entry:servicedata) {
225
            sdata += entry.first + " : " + entry.second + ", ";
226
        }
227
        s += "Service: " + servicename + " User: " + user +
228
            /*" Pass: "+password*/ + " Servicedata: " + sdata +
229
            /*" EPass: "+encryptedpass*/ + " Enabled: " +
230
            SoapHelp::i2s(enabled) + " Status: " + status + " Data: " + data;
231
        return s;
232
    }
233
234
    // Internal name, like "qobuz"
235
    string servicename;
236
    string user;
237
    string password;
238
    string encryptedpass;
239
    bool enabled{true};
240
    CmdTalk *cmd{0};
241
    // Things we obtain from the module and send to the CP
242
    unordered_map<string,string> servicedata;
243
244
    string status;
245
    // See comments about 'data' use above.
246
    string data;
247
};
248
249
class OHCredentials::Internal {
250
public:
251
    
252
    Internal(const string& cd) {
253
        cachedir = path_cat(cd, "ohcreds");
254
        if (!path_makepath(cachedir, 0700)) {
255
            LOGERR("OHCredentials: can't create cache dir " << cachedir <<endl);
256
            return;
257
        }
258
        keyfile = path_cat(cachedir, "credkey.pem");
259
        cmd.putenv("RANDFILE", path_cat(cachedir, "randfile"));
260
261
        if (!path_exists(keyfile)) {
262
            vector<string> acmd{"openssl", "genrsa", "-out", keyfile, "4096"};
263
            int status = cmd.doexec1(acmd);
264
            chmod(keyfile.c_str(), 0600);
265
            if (status != 0) {
266
                LOGERR("OHCredentials: could not create key\n");
267
                return;
268
            }
269
        }
270
271
        vector<string> acmd{"openssl", "pkey", "-in", keyfile, "-pubout"};
272
        if (!cmd.backtick(acmd, pubkey)) {
273
            LOGERR("OHCredentials: could not read public key\n");
274
            return;
275
        }
276
277
        LOGDEB1("OHCredentials: my public key:\n" << pubkey << endl);
278
    }
279
280
    bool decrypt(const string& in, string& out) {
281
        vector<string> acmd{"openssl", "pkeyutl", "-inkey",
282
                keyfile, "-pkeyopt", "rsa_padding_mode:oaep", "-decrypt"};
283
        int status = cmd.doexec1(acmd, &in, &out);
284
        if (status) {
285
            LOGERR("OHCredentials: decrypt failed\n");
286
            return false;
287
        }
288
        //LOGDEB1("decrypt: [" << out << "]\n");
289
        return true;
290
    }
291
292
    bool setEnabled(const string& id, bool enabled) {
293
        auto it = creds.find(id);
294
        if (it == creds.end()) {
295
            return false;
296
        }
297
        it->second.enabled = enabled;
298
        return true;
299
    }
300
301
    bool save() {
302
        string credsfile = path_cat(cachedir, "screds");
303
        ConfSimple credsconf(credsfile.c_str());
304
        if (!credsconf.ok()) {
305
            LOGERR("OHCredentials: error opening " << credsfile <<
306
                   " errno " << errno << endl);
307
            return false;
308
        }
309
        for (const auto& cred : creds) {
310
            credsconf.set("u", cred.second.user, cred.second.servicename);
311
            credsconf.set("p", cred.second.password, cred.second.servicename);
312
        }
313
        chmod(credsfile.c_str(), 0600);
314
        return true;
315
    }
316
    
317
    ExecCmd cmd;
318
    string cachedir;
319
    string keyfile;
320
    string pubkey;
321
    int seq{1};
322
    map<string, ServiceCreds> creds;
323
};
324
325
326
OHCredentials::OHCredentials(UpMpd *dev, const string& cachedir)
327
    : OHService(sTpCredentials, sIdCredentials, dev), m(new Internal(cachedir))
328
{
329
    dev->addActionMapping(
330
        this, "Set",
331
        bind(&OHCredentials::actSet, this, _1, _2));
332
    dev->addActionMapping(
333
        this, "Clear",
334
        bind(&OHCredentials::actClear, this, _1, _2));
335
    dev->addActionMapping(
336
        this, "SetEnabled",
337
        bind(&OHCredentials::actSetEnabled, this, _1, _2));
338
    dev->addActionMapping(
339
        this, "Get",
340
        bind(&OHCredentials::actGet, this, _1, _2));
341
    dev->addActionMapping(
342
        this, "Login",
343
        bind(&OHCredentials::actLogin, this, _1, _2));
344
    dev->addActionMapping(
345
        this, "ReLogin",
346
        bind(&OHCredentials::actReLogin, this, _1, _2));
347
    dev->addActionMapping(
348
        this, "GetIds",
349
        bind(&OHCredentials::actGetIds, this, _1, _2));
350
    dev->addActionMapping(
351
        this, "GetPublicKey",
352
        bind(&OHCredentials::actGetPublicKey, this, _1, _2));
353
    dev->addActionMapping(
354
        this, "GetSequenceNumber",
355
        bind(&OHCredentials::actGetSequenceNumber, this, _1, _2));
356
357
    unsigned short usport;
358
    dev->ipv4(&upnphost, &usport);
359
}
360
361
OHCredentials::~OHCredentials()
362
{
363
    delete m;
364
}
365
366
bool OHCredentials::makestate(unordered_map<string, string> &st)
367
{
368
    st.clear();
369
    if (nullptr == m) {
370
        return false;
371
    }
372
    st["Ids"] = idstring;
373
    st["PublicKey"] = m->pubkey;
374
    st["SequenceNumber"] = SoapHelp::i2s(m->seq);
375
    return true;
376
}
377
378
int OHCredentials::actSet(const SoapIncoming& sc, SoapOutgoing& data)
379
{
380
    bool ok = false;
381
    std::string in_Id;
382
    ok = sc.get("Id", &in_Id);
383
    if (!ok) {
384
        LOGERR("OHCredentials::actSet: no Id in params\n");
385
        return UPNP_E_INVALID_PARAM;
386
    }
387
    std::string in_UserName;
388
    ok = sc.get("UserName", &in_UserName);
389
    if (!ok) {
390
        LOGERR("OHCredentials::actSet: no UserName in params\n");
391
        return UPNP_E_INVALID_PARAM;
392
    }
393
    string in_Password;
394
    ok = sc.get("Password", &in_Password);
395
    if (!ok) {
396
        LOGERR("OHCredentials::actSet: no Password in params\n");
397
        return UPNP_E_INVALID_PARAM;
398
    }
399
400
    LOGDEB("OHCredentials::actSet: " << " Id " << in_Id << " UserName " <<
401
           in_UserName << " Password " << in_Password << endl);
402
403
    const auto it1 = idmap.find(in_Id);
404
    if (it1 == idmap.end()) {
405
        LOGERR("OHCredentials::actSet: bad service id [" << in_Id <<"]\n");
406
        return UPNP_E_INVALID_PARAM;
407
    }
408
    string servicename = it1->second;
409
    string cpass = base64_decode(in_Password);
410
    string plainpass;
411
    if (!m->decrypt(cpass, plainpass)) {
412
        LOGERR("OHCredentials::actSet: could not decrypt\n");
413
        return UPNP_E_INVALID_PARAM;
414
    }
415
    auto it = m->creds.find(in_Id);
416
    if (it == m->creds.end() || it->second.user != in_UserName ||
417
        it->second.password != plainpass ||
418
        it->second.encryptedpass != in_Password) {
419
        m->creds[in_Id] = ServiceCreds(servicename, in_UserName, plainpass,
420
                                       in_Password);
421
    }
422
    m->seq++;
423
    m->save();
424
    if (m->setEnabled(in_Id, true)) {
425
        return UPNP_E_SUCCESS;
426
    } else {
427
        return UPNP_E_INVALID_PARAM;
428
    }
429
}
430
431
int OHCredentials::actLogin(const SoapIncoming& sc, SoapOutgoing& data)
432
{
433
    bool ok = false;
434
    std::string in_Id;
435
    ok = sc.get("Id", &in_Id);
436
    if (!ok) {
437
        LOGERR("OHCredentials::actLogin: no Id in params\n");
438
        return UPNP_E_INVALID_PARAM;
439
    }
440
441
    LOGDEB("OHCredentials::actLogin: " << " Id " << in_Id << endl);
442
    auto it = m->creds.find(in_Id);
443
    if (it == m->creds.end()) {
444
        LOGERR("OHCredentials::Login: Id " << in_Id << " not found\n");
445
        return UPNP_E_INVALID_PARAM;
446
    }
447
    string token = it->second.login();
448
    LOGDEB("OHCredentials::Login: got token " << token << endl);
449
    data.addarg("Token", token);
450
    m->seq++;
451
    return UPNP_E_SUCCESS;
452
}
453
454
int OHCredentials::actReLogin(const SoapIncoming& sc, SoapOutgoing& data)
455
{
456
    bool ok = false;
457
    std::string in_Id;
458
    ok = sc.get("Id", &in_Id);
459
    if (!ok) {
460
        LOGERR("OHCredentials::actReLogin: no Id in params\n");
461
        return UPNP_E_INVALID_PARAM;
462
    }
463
    std::string in_CurrentToken;
464
    ok = sc.get("CurrentToken", &in_CurrentToken);
465
    if (!ok) {
466
        LOGERR("OHCredentials::actReLogin: no CurrentToken in params\n");
467
        return UPNP_E_INVALID_PARAM;
468
    }
469
470
    LOGDEB("OHCredentials::actReLogin: " << " Id " << in_Id << " CurrentToken "
471
           << in_CurrentToken << endl);
472
473
    auto it = m->creds.find(in_Id);
474
    if (it == m->creds.end()) {
475
        LOGERR("OHCredentials::Login: Id " << in_Id << " not found\n");
476
        return UPNP_E_INVALID_PARAM;
477
    }
478
    it->second.logout();
479
    string token = it->second.login();
480
    data.addarg("NewToken", token);
481
    m->seq++;
482
    return UPNP_E_SUCCESS;
483
}
484
485
int OHCredentials::actClear(const SoapIncoming& sc, SoapOutgoing& data)
486
{
487
    bool ok = false;
488
    std::string in_Id;
489
    ok = sc.get("Id", &in_Id);
490
    if (!ok) {
491
        LOGERR("OHCredentials::actClear: no Id in params\n");
492
        return UPNP_E_INVALID_PARAM;
493
    }
494
495
    LOGDEB("OHCredentials::actClear: " << " Id " << in_Id << endl);
496
    if (idmap.find(in_Id) == idmap.end()) {
497
        LOGERR("OHCredentials::actClear: bad service id [" << in_Id <<"]\n");
498
        return UPNP_E_INVALID_PARAM;
499
    }
500
    auto it = m->creds.find(in_Id);
501
    if (it != m->creds.end()) {
502
        m->creds.erase(it);
503
        m->seq++;
504
    }
505
    return UPNP_E_SUCCESS;
506
}
507
508
int OHCredentials::actSetEnabled(const SoapIncoming& sc, SoapOutgoing& data)
509
{
510
    bool ok = false;
511
    std::string in_Id;
512
    ok = sc.get("Id", &in_Id);
513
    if (!ok) {
514
        LOGERR("OHCredentials::actSetEnabled: no Id in params\n");
515
        return UPNP_E_INVALID_PARAM;
516
    }
517
    bool in_Enabled;
518
    ok = sc.get("Enabled", &in_Enabled);
519
    if (!ok) {
520
        LOGERR("OHCredentials::actSetEnabled: no Enabled in params\n");
521
        return UPNP_E_INVALID_PARAM;
522
    }
523
524
    LOGDEB("OHCredentials::actSetEnabled: " << " Id " << in_Id << " Enabled " <<
525
           in_Enabled << endl);
526
    if (m->setEnabled(in_Id, in_Enabled)) {
527
        m->seq++;
528
        return UPNP_E_SUCCESS;
529
    } else {
530
        return UPNP_E_INVALID_PARAM;
531
    }
532
}
533
534
int OHCredentials::actGet(const SoapIncoming& sc, SoapOutgoing& data)
535
{
536
    bool ok = false;
537
    std::string in_Id;
538
    ok = sc.get("Id", &in_Id);
539
    if (!ok) {
540
        LOGERR("OHCredentials::actGet: no Id in params\n");
541
        return UPNP_E_INVALID_PARAM;
542
    }
543
544
    LOGDEB("OHCredentials::actGet: " << " Id " << in_Id << endl);
545
546
    auto it = m->creds.find(in_Id);
547
    ServiceCreds emptycreds;
548
    ServiceCreds *credsp(&emptycreds);
549
    if (it != m->creds.end()) {
550
        credsp = &(it->second);
551
    } else {
552
        LOGDEB("OHCredentials::actGet: nothing found for " << in_Id << endl);
553
    }
554
    LOGDEB("OHCredentials::actGet: data for " << in_Id << " " <<
555
           credsp->str() << endl);
556
    data.addarg("UserName", credsp->user);
557
    // Encrypted password !
558
    data.addarg("Password", credsp->encryptedpass);
559
    // In theory enabled is set in response to setEnabled() or
560
    // set(). In practise, if it is not set, we don't get to the qobuz
561
    // settings screen in kazoo.
562
    data.addarg("Enabled", credsp->enabled ? "1" : "1");
563
    data.addarg("Status", credsp->status);
564
    data.addarg("Data", credsp->data);
565
    return UPNP_E_SUCCESS;
566
}
567
568
int OHCredentials::actGetIds(const SoapIncoming& sc, SoapOutgoing& data)
569
{
570
    LOGDEB("OHCredentials::actGetIds: " << endl);
571
    data.addarg("Ids", idstring);
572
    return UPNP_E_SUCCESS;
573
}
574
575
int OHCredentials::actGetPublicKey(const SoapIncoming& sc, SoapOutgoing& data)
576
{
577
    LOGDEB("OHCredentials::actGetPublicKey: " << endl);
578
    data.addarg("PublicKey", m->pubkey);
579
    return m->pubkey.empty() ? UPNP_E_INTERNAL_ERROR : UPNP_E_SUCCESS;
580
}
581
582
int OHCredentials::actGetSequenceNumber(const SoapIncoming& sc,
583
                                        SoapOutgoing& data)
584
{
585
    LOGDEB("OHCredentials::actGetSequenceNumber: " << endl);
586
    data.addarg("SequenceNumber", SoapHelp::i2s(m->seq));
587
    return UPNP_E_SUCCESS;
588
}