Login flow working

This commit is contained in:
simojenki
2021-02-24 20:54:05 +11:00
parent c26a325ee1
commit f295d3f015
13 changed files with 416 additions and 63 deletions

View File

@@ -1,20 +1,39 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
export type Association = {
authToken: string
userId: string
nickname: string
}
export interface LinkCodes { export interface LinkCodes {
mint(): string mint(): string
clear(): any clear(): any
count(): Number count(): Number
has(linkCode: string): boolean
associate(linkCode: string, association: Association): any
associationFor(linkCode: string): Association | undefined
} }
export class InMemoryLinkCodes implements LinkCodes { export class InMemoryLinkCodes implements LinkCodes {
linkCodes: Record<string, string> = {} linkCodes: Record<string, Association | undefined> = {}
mint() { mint() {
const linkCode = uuid(); const linkCode = uuid();
this.linkCodes[linkCode] = "" this.linkCodes[linkCode] = undefined
return linkCode return linkCode
} }
clear = () => { this.linkCodes = {} } clear = () => { this.linkCodes = {} }
count = () => Object.keys(this.linkCodes).length count = () => Object.keys(this.linkCodes).length
has = (linkCode: string) => Object.keys(this.linkCodes).includes(linkCode)
associate = (linkCode: string, association: Association) => {
if(this.has(linkCode))
this.linkCodes[linkCode] = association;
else
throw `Invalid linkCode ${linkCode}`
}
associationFor = (linkCode: string) => {
return this.linkCodes[linkCode]!;
}
} }

View File

@@ -8,13 +8,27 @@ const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`);
export type Credentials = { username: string, password: string } export type Credentials = { username: string, password: string }
export function isSuccess(authResult: AuthSuccess | AuthFailure): authResult is AuthSuccess {
return (authResult as AuthSuccess).authToken !== undefined;
}
export type AuthSuccess = {
authToken: string
userId: string
nickname: string
}
export type AuthFailure = {
message: string
}
export interface MusicService { export interface MusicService {
login(credentials: Credentials): boolean login(credentials: Credentials): AuthSuccess | AuthFailure
} }
export class Navidrome implements MusicService { export class Navidrome implements MusicService {
login(_: Credentials) { login({ username }: Credentials) {
return false return { authToken: `${username}`, userId: username, nickname: username }
} }
ping = (): Promise<boolean> => ping = (): Promise<boolean> =>

View File

@@ -4,6 +4,7 @@ import { listen } from "soap";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import path from "path"; import path from "path";
import morgan from "morgan"; import morgan from "morgan";
import crypto from "crypto";
import { import {
Sonos, Sonos,
@@ -12,8 +13,8 @@ import {
STRINGS_PATH, STRINGS_PATH,
PRESENTATION_MAP_PATH, PRESENTATION_MAP_PATH,
} from "./sonos"; } from "./sonos";
import { LinkCodes, InMemoryLinkCodes } from './link_codes' import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService } from './music_service' import { MusicService, isSuccess } from "./music_service";
import logger from "./logger"; import logger from "./logger";
const WSDL_FILE = path.resolve( const WSDL_FILE = path.resolve(
@@ -65,16 +66,30 @@ function server(
app.get("/login", (req, res) => { app.get("/login", (req, res) => {
res.render("login", { res.render("login", {
bonobService, bonobService,
linkCode: req.query.linkCode linkCode: req.query.linkCode,
}); });
}); });
app.post("/login", (req, res) => { app.post("/login", (req, res) => {
const canLogIn = musicService.login({ username: req.body.username, password: req.body.password}) const { username, password, linkCode } = req.body;
if(canLogIn) const authResult = musicService.login({
res.send("ok") username,
else password,
res.send("boo") });
if (isSuccess(authResult)) {
if (linkCodes.has(linkCode)) {
linkCodes.associate(linkCode, authResult);
res.render("loginOK");
} else {
res.status(400).render("failure", {
message: `Invalid linkCode!`,
});
}
} else {
res.status(403).render("failure", {
message: `Login failed, ${authResult.message}!`,
});
}
}); });
app.get(STRINGS_PATH, (_, res) => { app.get(STRINGS_PATH, (_, res) => {
@@ -99,15 +114,44 @@ function server(
return { return {
getAppLinkResult: { getAppLinkResult: {
authorizeAccount: { authorizeAccount: {
appUrlStringId: 'AppLinkMessage', appUrlStringId: "AppLinkMessage",
deviceLink: { deviceLink: {
regUrl: `${webAddress}/login?linkCode=${linkCode}`, regUrl: `${webAddress}/login?linkCode=${linkCode}`,
linkCode: linkCode, linkCode: linkCode,
showLinkCode: false, showLinkCode: false,
}, },
}, },
},
};
},
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => {
const association = linkCodes.associationFor(linkCode);
if (association) {
return {
getDeviceAuthTokenResult: {
authToken: association.authToken,
privateKey: "v1",
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
} }
}; };
} else {
throw {
Fault: {
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: {
ExceptionInfo: "NOT_LINKED_RETRY",
SonosError: "5"
}
}
}
}
}, },
getSessionId: ({ getSessionId: ({
username, username,
@@ -134,16 +178,16 @@ function server(
x.log = (type, data) => { x.log = (type, data) => {
switch (type) { switch (type) {
case "info": case "info":
logger.info({ level:"info", data }); logger.info({ level: "info", data });
break; break;
case "warn": case "warn":
logger.warn({ level:"warn", data }); logger.warn({ level: "warn", data });
break; break;
case "error": case "error":
logger.error({ level:"error", data }); logger.error({ level: "error", data });
break; break;
default: default:
logger.debug({ level:"debug", data }); logger.debug({ level: "debug", data });
} }
}; };

View File

@@ -5,7 +5,7 @@ import sonos, { bonobService } from "../src/sonos";
import server from "../src/server"; import server from "../src/server";
import logger from "../src/logger"; import logger from "../src/logger";
import { InMemoryMusicService } from "builders"; import { InMemoryMusicService } from "./in_memory_music_service";
const WEB_ADDRESS = "http://localhost:1234" const WEB_ADDRESS = "http://localhost:1234"

View File

@@ -1,6 +1,5 @@
import { SonosDevice } from "@svrooij/sonos/lib"; import { SonosDevice } from "@svrooij/sonos/lib";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { MusicService, Credentials } from "../src/music_service";
import { Service, Device } from "../src/sonos"; import { Service, Device } from "../src/sonos";
@@ -55,23 +54,3 @@ export function getAppLinkMessage() {
callbackPath: "", callbackPath: "",
}; };
} }
export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {};
login({ username, password }: Credentials) {
return username != undefined && password != undefined && this.users[username] == password;
}
hasUser(credentials: Credentials) {
this.users[credentials.username] = credentials.password;
}
hasNoUsers() {
this.users = {};
}
clear() {
this.users = {};
}
}

View File

@@ -0,0 +1,35 @@
import {
MusicService,
Credentials,
AuthSuccess,
AuthFailure,
} from "../src/music_service";
export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {};
login({ username, password }: Credentials): AuthSuccess | AuthFailure {
if (
username != undefined &&
password != undefined &&
this.users[username] == password
) {
return { authToken: "token123", userId: username, nickname: username };
} else {
return { message: `Invalid user:${username}` };
}
}
hasUser(credentials: Credentials) {
this.users[credentials.username] = credentials.password;
}
hasNoUsers() {
this.users = {};
}
clear() {
this.users = {};
}
}

View File

@@ -2,7 +2,8 @@ import request from "supertest";
import makeServer from "../src/server"; import makeServer from "../src/server";
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
import { aDevice, aService, InMemoryMusicService } from './builders'; import { aDevice, aService } from './builders';
import { InMemoryMusicService } from "./in_memory_music_service";
describe("index", () => { describe("index", () => {
describe("when sonos integration is disabled", () => { describe("when sonos integration is disabled", () => {

47
tests/link_codes.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { InMemoryLinkCodes } from "../src/link_codes"
describe("InMemoryLinkCodes", () => {
const linkCodes = new InMemoryLinkCodes()
describe('minting', () => {
it('should be able to mint unique codes', () => {
const code1 = linkCodes.mint()
const code2 = linkCodes.mint()
const code3 = linkCodes.mint()
expect(code1).not.toEqual(code2);
expect(code1).not.toEqual(code3);
});
});
describe("associating a code with a user", () => {
describe('when token is valid', () => {
it('should associate the token', () => {
const linkCode = linkCodes.mint();
const association = { authToken: "token123", nickname: "bob", userId: "1" };
linkCodes.associate(linkCode, association);
expect(linkCodes.associationFor(linkCode)).toEqual(association);
});
});
describe('when token is valid', () => {
it('should throw an error', () => {
const invalidLinkCode = "invalidLinkCode";
const association = { authToken: "token123", nickname: "bob", userId: "1" };
expect(() => linkCodes.associate(invalidLinkCode, association)).toThrow(`Invalid linkCode ${invalidLinkCode}`)
});
});
});
describe('fetching an association for a linkCode', () => {
describe('when the token doesnt exist', () => {
it('should return undefined', () => {
const missingLinkCode = 'someLinkCodeThatDoesntExist';
expect(linkCodes.associationFor(missingLinkCode)).toBeUndefined()
});
})
});
})

View File

@@ -4,7 +4,8 @@ import { Express } from "express";
import request from "supertest"; import request from "supertest";
import { GetAppLinkResult } from "../src/smapi"; import { GetAppLinkResult } from "../src/smapi";
import { InMemoryMusicService, getAppLinkMessage } from "./builders"; import { getAppLinkMessage } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import { InMemoryLinkCodes } from "../src/link_codes"; import { InMemoryLinkCodes } from "../src/link_codes";
import { Credentials } from "../src/music_service"; import { Credentials } from "../src/music_service";
import makeServer from "../src/server"; import makeServer from "../src/server";
@@ -53,13 +54,14 @@ class SonosDriver {
.post(this.stripServiceRoot(regUrl)) .post(this.stripServiceRoot(regUrl))
.type("form") .type("form")
.send({ username, password, linkCode }) .send({ username, password, linkCode })
.expect(200)
.then(response => ({ .then(response => ({
expectSuccess: () => { expectSuccess: () => {
expect(response.text).toContain("ok") expect(response.status).toEqual(200);
expect(response.text).toContain("Login Successful")
}, },
expectFailure: () => { expectFailure: () => {
expect(response.text).toContain("boo") expect(response.status).toEqual(403);
expect(response.text).toContain("Login failed")
}, },
})); }));
}, },

View File

@@ -1,18 +1,29 @@
import crypto from "crypto";
import request from "supertest"; import request from "supertest";
import { createClientAsync } from "soap";
import { DOMParserImpl } from 'xmldom-ts'; import { DOMParserImpl } from "xmldom-ts";
import * as xpath from 'xpath-ts'; import * as xpath from "xpath-ts";
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
import makeServer from "../src/server"; import makeServer from "../src/server";
import { SONOS_DISABLED, STRINGS_PATH } from "../src/sonos"; import { bonobService, SONOS_DISABLED, STRINGS_PATH } from "../src/sonos";
import { aService, InMemoryMusicService } from './builders'; import { aService, getAppLinkMessage } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
const select = xpath.useNamespaces({"sonos": "http://sonos.com/sonosapi"}) const select = xpath.useNamespaces({ sonos: "http://sonos.com/sonosapi" });
describe('strings.xml', () => { describe("service config", () => {
const server = makeServer(SONOS_DISABLED, aService(), 'http://localhost:1234', new InMemoryMusicService()); describe("strings.xml", () => {
const server = makeServer(
SONOS_DISABLED,
aService(),
"http://localhost:1234",
new InMemoryMusicService()
);
it("should return xml for the strings", async () => { it("should return xml for the strings", async () => {
const res = await request(server).get(STRINGS_PATH).send(); const res = await request(server).get(STRINGS_PATH).send();
@@ -20,8 +31,198 @@ describe('strings.xml', () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
const xml = parseXML(res.text); const xml = parseXML(res.text);
const x = select("//sonos:string[@stringId='AppLinkMessage']/text()", xml) as Node[] const x = select(
expect(x.length).toEqual(1) "//sonos:string[@stringId='AppLinkMessage']/text()",
expect(x[0]!.nodeValue).toEqual("Linking sonos with bonob") xml
) as Node[];
expect(x.length).toEqual(1);
expect(x[0]!.nodeValue).toEqual("Linking sonos with bonob");
});
});
});
describe("api", () => {
const rootUrl = "http://localhost:1234";
const service = bonobService("test-api", 133, rootUrl, "AppLink");
const musicService = new InMemoryMusicService();
const linkCodes = new InMemoryLinkCodes();
beforeEach(() => {
musicService.clear();
linkCodes.clear();
});
describe("pages", () => {
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
musicService,
linkCodes
);
describe("/login", () => {
describe("when the credentials are valid", () => {
it("should return 200 ok and have associated linkCode with user", async () => {
const username = "jane";
const password = "password100";
const linkCode = linkCodes.mint();
musicService.hasUser({ username, password });
const res = await request(server)
.post("/login")
.type("form")
.send({ username, password, linkCode })
.expect(200);
expect(res.text).toContain("Login successful");
const association = linkCodes.associationFor(linkCode);
expect(association.nickname).toEqual(username);
});
});
describe("when credentials are invalid", () => {
it("should return 403 with message", async () => {
const username = "userDoesntExist";
const password = "password";
const linkCode = linkCodes.mint();
musicService.hasNoUsers();
const res = await request(server)
.post("/login")
.type("form")
.send({ username, password, linkCode })
.expect(403);
expect(res.text).toContain(`Login failed, Invalid user:${username}`);
});
});
describe("when linkCode is invalid", () => {
it("should return 400 with message", async () => {
const username = "jane";
const password = "password100";
const linkCode = "someLinkCodeThatDoesntExist";
musicService.hasUser({ username, password });
const res = await request(server)
.post("/login")
.type("form")
.send({ username, password, linkCode })
.expect(400);
expect(res.text).toContain("Invalid linkCode!");
});
});
});
});
describe("soap api", () => {
describe("getAppLink", () => {
const mockLinkCodes = {
mint: jest.fn(),
};
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
musicService,
(mockLinkCodes as unknown) as LinkCodes
);
it("should do something", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const linkCode = "theLinkCode8899";
mockLinkCodes.mint.mockReturnValue(linkCode);
const result = await ws.getAppLinkAsync(getAppLinkMessage());
expect(result[0]).toEqual({
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: "AppLinkMessage",
deviceLink: {
regUrl: `${rootUrl}/login?linkCode=${linkCode}`,
linkCode: linkCode,
showLinkCode: false,
},
},
},
});
});
});
});
describe("getDeviceAuthToken", () => {
const linkCodes = new InMemoryLinkCodes();
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
musicService,
linkCodes
);
describe("when there is a linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = linkCodes.mint();
const association = { authToken: "at", userId: "uid", nickname: "nn" };
linkCodes.associate(linkCode, association);
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const result = await ws.getDeviceAuthTokenAsync({ linkCode });
expect(result[0]).toEqual({
getDeviceAuthTokenResult: {
authToken: association.authToken,
privateKey: "v1",
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
});
});
});
describe("when there is no linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = "invalidLinkCode";
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getDeviceAuthTokenAsync({ linkCode })
.then(() => {
throw "Shouldnt get here";
})
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5" },
});
});
});
});
}); });
}); });

View File

@@ -1,7 +1,8 @@
import makeServer from "../src/server"; import makeServer from "../src/server";
import { SONOS_DISABLED, SOAP_PATH } from "../src/sonos"; import { SONOS_DISABLED, SOAP_PATH } from "../src/sonos";
import { aService, InMemoryMusicService } from "./builders"; import { aService } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from './supersoap'; import supersoap from './supersoap';
import { createClientAsync } from "soap"; import { createClientAsync } from "soap";

5
web/views/failure.eta Normal file
View File

@@ -0,0 +1,5 @@
<% layout('./layout', { title: "Failure" }) %>
<div id="content">
<h1><%= it.message %></h1>
</div>

5
web/views/loginOK.eta Normal file
View File

@@ -0,0 +1,5 @@
<% layout('./layout', { title: "Login Successful" }) %>
<div id="content">
<h1>Login successful</h1>
</div>