diff --git a/package.json b/package.json index 0dd27fd..d91e15d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "underscore":"^1.12.0", "uuid": "^8.3.2", "winston": "^3.3.3", + "x2js": "^3.4.0", "xmldom-ts": "^0.3.1" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index 21f7adc..1959c39 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,19 +1,25 @@ import sonos, { bonobService } from "./sonos"; import server from "./server"; import logger from "./logger"; -import { Navidrome } from './music_service'; - +import { Navidrome } from "./navidrome"; +import encryption from "./encryption"; const PORT = +(process.env["BONOB_PORT"] || 4534); -const WEB_ADDRESS = process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`; +const WEB_ADDRESS = + process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`; const bonob = bonobService( process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob", Number(process.env["BONOS_SONOS_SERVICE_ID"] || "246"), WEB_ADDRESS, - 'AppLink' + "AppLink" +); +const app = server( + sonos(process.env["BONOB_SONOS_SEED_HOST"]), + bonob, + WEB_ADDRESS, + new Navidrome(process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533", encryption()) ); -const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"]), bonob, WEB_ADDRESS, new Navidrome()); app.listen(PORT, () => { logger.info(`Listening on ${PORT} available @ ${WEB_ADDRESS}`); diff --git a/src/encryption.ts b/src/encryption.ts new file mode 100644 index 0000000..c922ca6 --- /dev/null +++ b/src/encryption.ts @@ -0,0 +1,33 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + +const ALGORITHM = "aes-256-cbc" +const IV = randomBytes(16); +const KEY = randomBytes(32); + +export type Hash = { + iv: string, + encryptedData: string +} + +export type Encryption = { + encrypt: (value:string) => Hash + decrypt: (hash: Hash) => string +} + +const encryption = (): Encryption => { + return { + encrypt: (value: string) => { + const cipher = createCipheriv(ALGORITHM, KEY, IV); + return { + iv: IV.toString("hex"), + encryptedData: Buffer.concat([cipher.update(value), cipher.final()]).toString("hex") + }; + }, + decrypt: (hash: Hash) => { + const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(hash.iv, 'hex')); + return Buffer.concat([decipher.update(Buffer.from(hash.encryptedData, 'hex')), decipher.final()]).toString(); + } + } +} + +export default encryption; diff --git a/src/music_service.ts b/src/music_service.ts index 5501960..e57db4d 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -1,10 +1,3 @@ -import axios from "axios"; -import { Md5 } from "ts-md5/dist/md5"; - -const s = "foobar100"; -const navidrome = process.env["BONOB_NAVIDROME_URL"]; -const u = process.env["BONOB_USER"]; -const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`); export type Credentials = { username: string; password: string }; @@ -31,8 +24,8 @@ export type AuthFailure = { }; export interface MusicService { - generateToken(credentials: Credentials): AuthSuccess | AuthFailure; - login(authToken: string): MusicLibrary | AuthFailure; + generateToken(credentials: Credentials): Promise; + login(authToken: string): Promise; } export type Artist = { @@ -50,38 +43,3 @@ export interface MusicLibrary { artist(id: string): Artist; albums({ artistId, _index, _count }: { artistId?: string, _index?: number, _count?: number }): Album[]; } - -export class Navidrome implements MusicService { - generateToken({ username }: Credentials) { - return { - authToken: `v1:${username}`, - userId: username, - nickname: username, - }; - } - - login(_: string) { - return { - artists: () => [], - artist: (id: string) => ({ - id, - name: id, - }), - albums: ({ artistId }: { artistId?: string }) => { - console.log(artistId) - return [] - }, - }; - } - - ping = (): Promise => - axios - .get( - `${navidrome}/rest/ping.view?u=${u}&t=${t}&s=${s}&v=1.16.1.0&c=myapp` - ) - .then((_) => true) - .catch((e) => { - console.log(e); - return false; - }); -} diff --git a/src/navidrome.ts b/src/navidrome.ts new file mode 100644 index 0000000..eff4832 --- /dev/null +++ b/src/navidrome.ts @@ -0,0 +1,91 @@ +import { Md5 } from "ts-md5/dist/md5"; +import { Credentials, MusicService } from "./music_service"; +import X2JS from "x2js"; + +import axios from "axios"; +import { Encryption } from "./encryption"; +import randomString from "./random_string"; + +export const t = (password: string, s: string) => + Md5.hashStr(`${password}${s}`); + +export type SubconicEnvelope = { + "subsonic-response": SubsonicResponse; +}; + +export type SubsonicResponse = { + _status: string; +}; + +export type SubsonicError = SubsonicResponse & { + error: { + _code: string; + _message: string; + }; +}; + +export function isError( + subsonicResponse: SubsonicResponse +): subsonicResponse is SubsonicError { + return (subsonicResponse as SubsonicError).error !== undefined; +} + +export class Navidrome implements MusicService { + url: string; + encryption: Encryption; + + constructor(url: string, encryption: Encryption) { + this.url = url; + this.encryption = encryption; + } + + generateToken = async ({ username, password }: Credentials) => { + const s = randomString(); + return axios + .get( + `${this.url}/rest/ping.view?u=${username}&t=${t( + password, + s + )}&s=${s}&v=1.16.1.0&c=bonob` + ) + .then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope) + .then((json) => json["subsonic-response"]) + .then((json) => { + if (isError(json)) throw json.error._message; + else return json; + }) + .then((_) => { + return { + authToken: Buffer.from( + JSON.stringify( + this.encryption.encrypt(JSON.stringify({ username, password })) + ) + ).toString("base64"), + userId: username, + nickname: username, + }; + }); + }; + + parseToken = (token: string): Credentials => + JSON.parse( + this.encryption.decrypt( + JSON.parse(Buffer.from(token, "base64").toString("ascii")) + ) + ); + + async login(_: string) { + // const credentials: Credentials = this.parseToken(token); + return Promise.resolve({ + artists: () => [], + artist: (id: string) => ({ + id, + name: id, + }), + albums: ({ artistId }: { artistId?: string }) => { + console.log(artistId); + return []; + }, + }); + } +} diff --git a/src/random_string.ts b/src/random_string.ts new file mode 100644 index 0000000..8386609 --- /dev/null +++ b/src/random_string.ts @@ -0,0 +1,6 @@ +import { randomBytes } from "crypto"; + +const randomString = () => randomBytes(32).toString('hex') + +export default randomString + diff --git a/src/server.ts b/src/server.ts index 754336e..eb77ad1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -69,27 +69,27 @@ function server( }); }); - app.post(LOGIN_ROUTE, (req, res) => { + app.post(LOGIN_ROUTE, async (req, res) => { const { username, password, linkCode } = req.body; - const authResult = musicService.generateToken({ - username, - password, - }); - if (isSuccess(authResult)) { - if (linkCodes.has(linkCode)) { + if (!linkCodes.has(linkCode)) { + res.status(400).render("failure", { + message: `Invalid linkCode!`, + }); + } else { + const authResult = await musicService.generateToken({ + username, + password, + }); + if (isSuccess(authResult)) { linkCodes.associate(linkCode, authResult); res.render("success", { message: `Login successful`, }); - } else { - res.status(400).render("failure", { - message: `Invalid linkCode!`, + } else { + res.status(403).render("failure", { + message: `Login failed, ${authResult.message}!`, }); } - } else { - res.status(403).render("failure", { - message: `Login failed, ${authResult.message}!`, - }); } }); diff --git a/src/smapi.ts b/src/smapi.ts index 701d44d..aed9027 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -6,7 +6,7 @@ import path from "path"; import logger from "./logger"; import { LinkCodes } from "./link_codes"; -import { isFailure, MusicLibrary, MusicService } from "./music_service"; +import { MusicLibrary, MusicService } from "./music_service"; export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; @@ -176,7 +176,7 @@ function bindSmapiSoapServiceToExpress( getAppLink: () => sonosSoap.getAppLink(), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), - getMetadata: ( + getMetadata: async ( { id, index, @@ -194,17 +194,16 @@ function bindSmapiSoapServiceToExpress( }, }; } - const login = musicService.login( + const login = await musicService.login( headers.credentials.loginToken.token - ); - if (isFailure(login)) { + ).catch(_ => { throw { Fault: { faultcode: "Client.LoginUnauthorized", faultstring: "Credentials not found...", }, }; - } + }); const musicLibrary = login as MusicLibrary; diff --git a/src/sonos.ts b/src/sonos.ts index 7ff44d8..6a8594b 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -129,7 +129,6 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { } }) .catch((e) => { - console.log("booom"); logger.error(`Failed looking for sonos devices ${e}`); return []; }); diff --git a/tests/builders.ts b/tests/builders.ts index 3787143..5dba7b3 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,9 +1,9 @@ import { SonosDevice } from "@svrooij/sonos/lib"; -import { ArtistWithAlbums } from "in_memory_music_service"; import { v4 as uuid } from "uuid"; import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; +import { Album, Artist } from "../src/music_service"; const randomInt = (max: number) => Math.floor(Math.random() * max); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; @@ -68,6 +68,10 @@ export function someCredentials(token: string): Credentials { } } +export type ArtistWithAlbums = Artist & { + albums: Album[]; +}; + export const BOB_MARLEY: ArtistWithAlbums = { id: uuid(), name: "Bob Marley", diff --git a/tests/encryption.test.ts b/tests/encryption.test.ts new file mode 100644 index 0000000..46b3be8 --- /dev/null +++ b/tests/encryption.test.ts @@ -0,0 +1,12 @@ +import encryption from '../src/encryption'; + +describe("encrypt", () => { + const e = encryption(); + + it("can encrypt and decrypt", () => { + const value = "bobs your uncle" + const hash = e.encrypt(value) + expect(hash.encryptedData).not.toEqual(value); + expect(e.decrypt(hash)).toEqual(value); + }); +}) \ No newline at end of file diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index da4a921..732891b 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -1,20 +1,18 @@ -import { - InMemoryMusicService, -} from "./in_memory_music_service"; +import { InMemoryMusicService } from "./in_memory_music_service"; import { AuthSuccess, MusicLibrary } from "../src/music_service"; import { v4 as uuid } from "uuid"; -import { BOB_MARLEY, MADONNA, BLONDIE } from './builders' +import { BOB_MARLEY, MADONNA, BLONDIE } from "./builders"; describe("InMemoryMusicService", () => { const service = new InMemoryMusicService(); describe("generateToken", () => { - it("should be able to generate a token and then use it to log in", () => { + it("should be able to generate a token and then use it to log in", async () => { const credentials = { username: "bob", password: "smith" }; service.hasUser(credentials); - const token = service.generateToken(credentials) as AuthSuccess; + const token = (await service.generateToken(credentials)) as AuthSuccess; expect(token.userId).toEqual(credentials.username); expect(token.nickname).toEqual(credentials.username); @@ -24,35 +22,31 @@ describe("InMemoryMusicService", () => { expect(musicLibrary).toBeDefined(); }); - it("should fail with an exception if an invalid token is used", () => { + it.only("should fail with an exception if an invalid token is used", async () => { const credentials = { username: "bob", password: "smith" }; service.hasUser(credentials); - const token = service.generateToken(credentials) as AuthSuccess; + const token = (await service.generateToken(credentials)) as AuthSuccess; service.clear(); - expect(service.login(token.authToken)).toEqual({ - message: "Invalid auth token", - }); + return expect(service.login(token.authToken)).rejects.toEqual("Invalid auth token"); }); }); describe("Music Library", () => { - - const user = { username: "user100", password: "password100" }; let musicLibrary: MusicLibrary; - beforeEach(() => { + beforeEach(async () => { service.clear(); service.hasArtists(BOB_MARLEY, MADONNA, BLONDIE); service.hasUser(user); - const token = service.generateToken(user) as AuthSuccess; - musicLibrary = service.login(token.authToken) as MusicLibrary; + const token = (await service.generateToken(user)) as AuthSuccess; + musicLibrary = (await service.login(token.authToken)) as MusicLibrary; }); describe("artists", () => { diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 0e28736..7cf961f 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -1,18 +1,16 @@ import { option as O } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; + +import { ArtistWithAlbums } from "./builders"; import { MusicService, Credentials, AuthSuccess, AuthFailure, Artist, - Album, MusicLibrary, } from "../src/music_service"; -export type ArtistWithAlbums = Artist & { - albums: Album[]; -}; const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ id: it.id, @@ -36,30 +34,26 @@ export class InMemoryMusicService implements MusicService { generateToken({ username, password, - }: Credentials): AuthSuccess | AuthFailure { + }: Credentials): Promise { if ( username != undefined && password != undefined && this.users[username] == password ) { - return { + return Promise.resolve({ authToken: JSON.stringify({ username, password }), userId: username, nickname: username, - }; + }); } else { - return { message: `Invalid user:${username}` }; + return Promise.resolve({ message: `Invalid user:${username}` }); } } - login(token: string): MusicLibrary | AuthFailure { + login(token: string): Promise { const credentials = JSON.parse(token) as Credentials; - if (this.users[credentials.username] != credentials.password) { - return { - message: "Invalid auth token", - }; - } - return { + if (this.users[credentials.username] != credentials.password) return Promise.reject("Invalid auth token") + return Promise.resolve({ artists: () => this.artists.map(artistWithAlbumsToArtist), artist: (id: string) => pipe( @@ -90,7 +84,7 @@ export class InMemoryMusicService implements MusicService { .flatMap((it) => it.albums) .slice(i0, i1); }, - }; + }); } hasUser(credentials: Credentials) { diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts new file mode 100644 index 0000000..acc056c --- /dev/null +++ b/tests/navidrome.test.ts @@ -0,0 +1,75 @@ +import { Md5 } from "ts-md5/dist/md5"; + +import { Navidrome, t } from "../src/navidrome"; +import encryption from "../src/encryption"; + +import axios from "axios"; +jest.mock("axios"); + +import randomString from "../src/random_string"; +jest.mock("../src/random_string"); + +describe("t", () => { + it("should be an md5 of the password and the salt", () => { + const p = "password123"; + const s = "saltydog"; + expect(t(p, s)).toEqual(Md5.hashStr(`${p}${s}`)); + }); +}); + +describe("navidrome", () => { + const url = "http://127.0.0.22:4567"; + const username = "user1"; + const password = "pass1"; + const salt = "saltysalty"; + + const navidrome = new Navidrome(url, encryption()); + + const mockedRandomString = (randomString as unknown) as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + axios.get = jest.fn(); + + mockedRandomString.mockReturnValue(salt); + }); + + describe("generateToken", () => { + describe("when the credentials are valid", () => { + it("should be able to generate a token and then login using it", async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: ` + `, + }); + + const token = await navidrome.generateToken({ username, password }); + + expect(token.authToken).toBeDefined(); + expect(token.nickname).toEqual(username); + expect(token.userId).toEqual(username); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/ping.view?u=${username}&t=${t( + password, + salt + )}&s=${salt}&v=1.16.1.0&c=bonob` + ); + }); + }); + + describe("when the credentials are not valid", () => { + it("should be able to generate a token and then login using it", async () => { + (axios.get as jest.Mock).mockResolvedValue({ + status: 200, + data: ` + + `, + }); + + return expect(navidrome.generateToken({ username, password })).rejects.toMatch("Wrong username or password"); + }); + }); + }); +}); diff --git a/tests/random_string.test.ts b/tests/random_string.test.ts new file mode 100644 index 0000000..d9646ba --- /dev/null +++ b/tests/random_string.test.ts @@ -0,0 +1,16 @@ +import randomString from "../src/random_string"; + +describe('randomString', () => { + it('should produce different strings...', () => { + const s1 = randomString() + const s2 = randomString() + const s3 = randomString() + const s4 = randomString() + + expect(s1.length).toEqual(64) + + expect(s1).not.toEqual(s2); + expect(s1).not.toEqual(s3); + expect(s1).not.toEqual(s4); + }); +}); \ No newline at end of file diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index a93b9e9..7e49f7d 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -289,7 +289,7 @@ describe("api", () => { const username = "userThatGetsDeleted"; const password = "password1"; musicService.hasUser({ username, password }); - const token = musicService.generateToken({ + const token = await musicService.generateToken({ username, password, }) as AuthSuccess; @@ -321,7 +321,7 @@ describe("api", () => { beforeEach(async () => { musicService.hasUser({ username, password }); - token = musicService.generateToken({ + token = await musicService.generateToken({ username, password, }) as AuthSuccess; diff --git a/yarn.lock b/yarn.lock index a1b130f..6acef6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4988,6 +4988,13 @@ ws@^7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.3.tgz#1f9643de34a543b8edb124bdcbc457ae55a6e5cd" integrity sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA== +x2js@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/x2js/-/x2js-3.4.0.tgz#1414ad99062705086a4838e8dde4ecd06e8bd3a9" + integrity sha512-1tozn7D51ghz2DAiy5U6R55qn9x2F3lHUxusOD0QtYlLSDGxyXjHfn0c508eXG1D7s8qqj54SiU5HsPEfhDIpg== + dependencies: + xmldom "^0.1.19" + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" @@ -5021,6 +5028,11 @@ xmldom@0.1.27: resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk= +xmldom@^0.1.19: + version "0.1.31" + resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" + integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== + xpath-ts@^1.3.13: version "1.3.13" resolved "https://registry.yarnpkg.com/xpath-ts/-/xpath-ts-1.3.13.tgz#abca4f15dd7010161acf5b9cd01566f7b8d9541f"