From 57d585d68a4d1186fa7374f5060be970ededed09 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 27 Jun 2019 21:46:00 -0600 Subject: [PATCH] Implement MSC1961 See https://github.com/matrix-org/matrix-doc/pull/1961 --- package-lock.json | 9 ++ package.json | 1 + src/api/Webserver.ts | 7 +- src/api/admin/AdminService.ts | 5 +- src/api/controllers/AccountController.ts | 123 ++++++++++++++++++ src/api/dimension/DimensionGitterService.ts | 13 +- .../dimension/DimensionIntegrationsService.ts | 13 +- src/api/dimension/DimensionIrcService.ts | 13 +- src/api/dimension/DimensionSlackService.ts | 19 ++- src/api/dimension/DimensionStickerService.ts | 15 ++- src/api/dimension/DimensionTelegramService.ts | 13 +- src/api/dimension/DimensionWebhooksService.ts | 13 +- src/api/msc/MSCAccountService.ts | 41 ++++++ src/api/scalar/ScalarService.ts | 92 ++----------- src/api/scalar/ScalarWidgetService.ts | 9 +- src/api/security/MSCSecurity.ts | 56 ++++++++ webpack.config.js | 6 +- 17 files changed, 327 insertions(+), 121 deletions(-) create mode 100644 src/api/controllers/AccountController.ts create mode 100644 src/api/msc/MSCAccountService.ts create mode 100644 src/api/security/MSCSecurity.ts diff --git a/package-lock.json b/package-lock.json index 618e8d5..77bed35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11705,6 +11705,15 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.3.tgz", "integrity": "sha512-FFgHdPt4T/duxx6Ndf7hwgMZZjZpB+U0nMNGVCYPq0rEzWKjEDobm4J6yb3CS7naZ0yURFqdw9Gwc7UOh/P9oQ==" }, + "typescript-ioc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/typescript-ioc/-/typescript-ioc-1.2.5.tgz", + "integrity": "sha512-HErBOZfOmrJ9N8QZDOHvP56FqvqZJMkaFW9Qm4ExQa93ilUnhQ+S7n80rVfUQPceZWIImsEBU/Kt19W5KXBDEw==", + "requires": { + "reflect-metadata": "^0.1.10", + "require-glob": "^3.2.0" + } + }, "typescript-rest": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/typescript-rest/-/typescript-rest-2.0.0.tgz", diff --git a/package.json b/package.json index 56bae72..8b280b7 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "sqlite3": "^4.0.6", "telegraf": "^3.28.0", "typescript": "^3.4.3", + "typescript-ioc": "^1.2.5", "typescript-rest": "^2.0.0", "umzug": "^2.2.0", "url": "^0.11.0" diff --git a/src/api/Webserver.ts b/src/api/Webserver.ts index 3099153..0d7cb4d 100644 --- a/src/api/Webserver.ts +++ b/src/api/Webserver.ts @@ -7,6 +7,7 @@ import { Server } from "typescript-rest"; import * as _ from "lodash"; import config from "../config"; import { ApiError } from "./ApiError"; +import MSCSecurity from "./security/MSCSecurity"; /** * Web server for Dimension. Handles the API routes for the admin, scalar, dimension, and matrix APIs. @@ -23,8 +24,12 @@ export default class Webserver { } private loadRoutes() { - const apis = ["scalar", "dimension", "admin", "matrix"].map(a => path.join(__dirname, a, "*.js")); + // TODO: Rename services to controllers, and controllers to services. They're backwards. + + const apis = ["scalar", "dimension", "admin", "matrix", "msc"].map(a => path.join(__dirname, a, "*.js")); const router = express.Router(); + Server.useIoC(); + Server.registerAuthenticator(new MSCSecurity()); apis.forEach(a => Server.loadServices(router, [a])); const routes = _.uniq(router.stack.map(r => r.route.path)); for (const route of routes) { diff --git a/src/api/admin/AdminService.ts b/src/api/admin/AdminService.ts index 0a4004f..6dddb9b 100644 --- a/src/api/admin/AdminService.ts +++ b/src/api/admin/AdminService.ts @@ -1,5 +1,4 @@ import { GET, Path, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import config from "../../config"; import { ApiError } from "../ApiError"; import { MatrixLiteClient } from "../../matrix/MatrixLiteClient"; @@ -7,6 +6,7 @@ import { CURRENT_VERSION } from "../../version"; import { getFederationConnInfo } from "../../matrix/helpers"; import UserScalarToken from "../../db/models/UserScalarToken"; import { Cache, CACHE_SCALAR_ACCOUNTS } from "../../MemoryCache"; +import AccountController from "../controllers/AccountController"; interface DimensionVersionResponse { version: string; @@ -50,7 +50,8 @@ export class AdminService { * @throws {ApiError} Thrown with a status code of 401 if the owner is not an administrator */ public static async validateAndGetAdminTokenOwner(scalarToken: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken, true); + const accountController = new AccountController(); + const userId = await accountController.getTokenOwner(scalarToken, true); if (!AdminService.isAdmin(userId)) throw new ApiError(401, "You must be an administrator to use this API"); return userId; diff --git a/src/api/controllers/AccountController.ts b/src/api/controllers/AccountController.ts new file mode 100644 index 0000000..695af07 --- /dev/null +++ b/src/api/controllers/AccountController.ts @@ -0,0 +1,123 @@ +import { OpenId } from "../../models/OpenId"; +import { MatrixOpenIdClient } from "../../matrix/MatrixOpenIdClient"; +import { LogService } from "matrix-js-snippets"; +import { ApiError } from "../ApiError"; +import User from "../../db/models/User"; +import Upstream from "../../db/models/Upstream"; +import { ScalarStore } from "../../db/ScalarStore"; +import UserScalarToken from "../../db/models/UserScalarToken"; +import { ScalarClient } from "../../scalar/ScalarClient"; +import * as randomString from "random-string"; +import { AutoWired } from "typescript-ioc/es6"; +import { Cache, CACHE_SCALAR_ACCOUNTS } from "../../MemoryCache"; +import { IMSCUser } from "../security/MSCSecurity"; + +export interface IAccountRegisteredResponse { + token: string; +} + +export interface IAccountInfoResponse { + user_id: string; +} + +/** + * API controller for account management + */ +@AutoWired +export default class AccountController { + constructor() { + } + + /** + * Gets the owner of a given scalar token, throwing an ApiError if the token is invalid. + * @param {string} scalarToken The scalar token to validate + * @param {boolean} ignoreUpstreams True to consider the token valid if it is missing links to other upstreams + * @returns {Promise} Resolves to the owner's user ID if the token is valid. + * @throws {ApiError} Thrown with a status code of 401 if the token is invalid. + */ + public async getTokenOwner(scalarToken: string, ignoreUpstreams = false): Promise { + const cachedUserId = Cache.for(CACHE_SCALAR_ACCOUNTS).get(scalarToken); + if (cachedUserId) return cachedUserId; + + try { + const user = await ScalarStore.getTokenOwner(scalarToken, ignoreUpstreams); + Cache.for(CACHE_SCALAR_ACCOUNTS).put(scalarToken, user.userId, 30 * 60 * 1000); // 30 minutes + return user.userId; + } catch (err) { + LogService.error("ScalarService", err); + throw new ApiError(401, "Invalid token"); + } + } + + /** + * Registers an account to use the Integration Manager + * @param {OpenId} openId The OpenID request information. + * @returns {Promise} Resolves when registered. + */ + public async registerAccount(openId: OpenId): Promise { + if (!openId || !openId.matrix_server_name || !openId.access_token) { + throw new ApiError(400, "Missing OpenID information"); + } + + const mxClient = new MatrixOpenIdClient(openId); + const mxUserId = await mxClient.getUserId(); + + if (!mxUserId.endsWith(":" + openId.matrix_server_name)) { + LogService.warn("AccountController", `OpenID subject '${mxUserId}' does not belong to the homeserver '${openId.matrix_server_name}'`); + throw new ApiError(401, "Invalid token"); + } + + const user = await User.findByPrimary(mxUserId); + if (!user) { + // There's a small chance we'll get a validation error because of: + // https://github.com/vector-im/riot-web/issues/5846 + LogService.verbose("AccountController", "User " + mxUserId + " never seen before - creating"); + await User.create({userId: mxUserId}); + } + + const upstreams = await Upstream.findAll(); + await Promise.all(upstreams.map(async upstream => { + if (!await ScalarStore.isUpstreamOnline(upstream)) { + LogService.warn("AccountController", `Skipping registration for ${mxUserId} on upstream ${upstream.id} (${upstream.name}) because it is offline`); + return null; + } + const tokens = await UserScalarToken.findAll({where: {userId: mxUserId, upstreamId: upstream.id}}); + if (!tokens || tokens.length === 0) { + LogService.info("AccountController", "Registering " + mxUserId + " for a token at upstream " + upstream.id + " (" + upstream.name + ")"); + const client = new ScalarClient(upstream); + const response = await client.register(openId); + return UserScalarToken.create({ + userId: mxUserId, + scalarToken: response.scalar_token, + isDimensionToken: false, + upstreamId: upstream.id, + }); + } + }).filter(token => !!token)); + + const dimensionToken = randomString({length: 25}); + const dimensionScalarToken = await UserScalarToken.create({ + userId: mxUserId, + scalarToken: dimensionToken, + isDimensionToken: true, + }); + + LogService.info("AccountController", mxUserId + " has registered for a scalar token successfully"); + return {token: dimensionScalarToken.scalarToken}; + } + + /** + * Logs a user out + * @param {IMSCUser} user The user to log out + * @returns {Promise<*>} Resolves when complete. + */ + public async logout(user: IMSCUser): Promise { + // TODO: Create a link to upstream tokens to log them out too + const tokens = await UserScalarToken.findAll({where: {scalarToken: user.token}}); + for (const token of tokens) { + await token.destroy(); + } + Cache.for(CACHE_SCALAR_ACCOUNTS).clear(); + return {}; + } +} \ No newline at end of file diff --git a/src/api/dimension/DimensionGitterService.ts b/src/api/dimension/DimensionGitterService.ts index 68fd3f7..3a49a9d 100644 --- a/src/api/dimension/DimensionGitterService.ts +++ b/src/api/dimension/DimensionGitterService.ts @@ -1,8 +1,9 @@ import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import { ApiError } from "../ApiError"; import { BridgedRoom, GitterBridge } from "../../bridges/GitterBridge"; import { LogService } from "matrix-js-snippets"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; interface BridgeRoomRequest { gitterRoomName: string; @@ -12,12 +13,16 @@ interface BridgeRoomRequest { * API for interacting with the Gitter bridge */ @Path("/api/v1/dimension/gitter") +@AutoWired export class DimensionGitterService { + @Inject + private accountController: AccountController; + @GET @Path("room/:roomId/link") public async getLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const gitter = new GitterBridge(userId); @@ -31,7 +36,7 @@ export class DimensionGitterService { @POST @Path("room/:roomId/link") public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const gitter = new GitterBridge(userId); @@ -46,7 +51,7 @@ export class DimensionGitterService { @DELETE @Path("room/:roomId/link") public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const gitter = new GitterBridge(userId); diff --git a/src/api/dimension/DimensionIntegrationsService.ts b/src/api/dimension/DimensionIntegrationsService.ts index 1e2431d..13f5078 100644 --- a/src/api/dimension/DimensionIntegrationsService.ts +++ b/src/api/dimension/DimensionIntegrationsService.ts @@ -1,5 +1,4 @@ import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import { Widget } from "../../integrations/Widget"; import { Cache, CACHE_INTEGRATIONS } from "../../MemoryCache"; import { Integration } from "../../integrations/Integration"; @@ -11,6 +10,8 @@ import { ComplexBot } from "../../integrations/ComplexBot"; import { Bridge } from "../../integrations/Bridge"; import { BridgeStore } from "../../db/BridgeStore"; import { BotStore } from "../../db/BotStore"; +import AccountController from "../controllers/AccountController"; +import { AutoWired, Inject } from "typescript-ioc/es6"; export interface IntegrationsResponse { widgets: Widget[], @@ -23,8 +24,12 @@ export interface IntegrationsResponse { * API for managing integrations, primarily for a given room */ @Path("/api/v1/dimension/integrations") +@AutoWired export class DimensionIntegrationsService { + @Inject + private accountController: AccountController; + /** * Gets a list of widgets * @param {boolean} enabledOnly True to only return the enabled widgets @@ -86,7 +91,7 @@ export class DimensionIntegrationsService { @GET @Path("room/:roomId") public async getIntegrationsInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); return { widgets: await DimensionIntegrationsService.getWidgets(true), bots: await DimensionIntegrationsService.getSimpleBots(userId), @@ -110,7 +115,7 @@ export class DimensionIntegrationsService { @POST @Path("room/:roomId/integrations/:category/:type/config") public async setIntegrationConfigurationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string, newConfig: any): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); if (category === "complex-bot") await NebStore.setComplexBotConfig(userId, integrationType, roomId, newConfig); else if (category === "bridge") await BridgeStore.setBridgeRoomConfig(userId, integrationType, roomId, newConfig); @@ -123,7 +128,7 @@ export class DimensionIntegrationsService { @DELETE @Path("room/:roomId/integrations/:category/:type") public async removeIntegrationInRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("category") category: string, @PathParam("type") integrationType: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); if (category === "widget") throw new ApiError(400, "Widgets should be removed client-side"); else if (category === "bot") { diff --git a/src/api/dimension/DimensionIrcService.ts b/src/api/dimension/DimensionIrcService.ts index 827d94b..ee8c09d 100644 --- a/src/api/dimension/DimensionIrcService.ts +++ b/src/api/dimension/DimensionIrcService.ts @@ -1,9 +1,10 @@ import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; import { LogService } from "matrix-js-snippets"; -import { ScalarService } from "../scalar/ScalarService"; import { IrcBridge } from "../../bridges/IrcBridge"; import IrcBridgeRecord from "../../db/models/IrcBridgeRecord"; import { ApiError } from "../ApiError"; +import AccountController from "../controllers/AccountController"; +import { AutoWired, Inject } from "typescript-ioc/es6"; interface RequestLinkRequest { op: string; @@ -13,12 +14,16 @@ interface RequestLinkRequest { * API for interacting with the IRC bridge */ @Path("/api/v1/dimension/irc") +@AutoWired export class DimensionIrcService { + @Inject + private accountController: AccountController; + @GET @Path(":networkId/channel/:channel/ops") public async getOps(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const parsed = IrcBridge.parseNetworkId(networkId); const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId); @@ -34,7 +39,7 @@ export class DimensionIrcService { @POST @Path(":networkId/channel/:channel/link/:roomId") public async requestLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string, @PathParam("roomId") roomId: string, request: RequestLinkRequest): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const parsed = IrcBridge.parseNetworkId(networkId); const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId); @@ -50,7 +55,7 @@ export class DimensionIrcService { @POST @Path(":networkId/channel/:channel/unlink/:roomId") public async unlink(@QueryParam("scalar_token") scalarToken: string, @PathParam("networkId") networkId: string, @PathParam("channel") channelNoHash: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const parsed = IrcBridge.parseNetworkId(networkId); const bridge = await IrcBridgeRecord.findByPrimary(parsed.bridgeId); diff --git a/src/api/dimension/DimensionSlackService.ts b/src/api/dimension/DimensionSlackService.ts index aa95771..7173984 100644 --- a/src/api/dimension/DimensionSlackService.ts +++ b/src/api/dimension/DimensionSlackService.ts @@ -1,9 +1,10 @@ import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import { ApiError } from "../ApiError"; import { LogService } from "matrix-js-snippets"; import { BridgedChannel, SlackBridge } from "../../bridges/SlackBridge"; import { SlackChannel, SlackTeam } from "../../bridges/models/slack"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; interface BridgeRoomRequest { teamId: string; @@ -14,12 +15,16 @@ interface BridgeRoomRequest { * API for interacting with the Slack bridge */ @Path("/api/v1/dimension/slack") +@AutoWired export class DimensionSlackService { + @Inject + private accountController: AccountController; + @GET @Path("room/:roomId/link") public async getLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const slack = new SlackBridge(userId); @@ -33,7 +38,7 @@ export class DimensionSlackService { @POST @Path("room/:roomId/link") public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const slack = new SlackBridge(userId); @@ -48,7 +53,7 @@ export class DimensionSlackService { @DELETE @Path("room/:roomId/link") public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const slack = new SlackBridge(userId); @@ -65,7 +70,7 @@ export class DimensionSlackService { @GET @Path("teams") public async getTeams(@QueryParam("scalar_token") scalarToken: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const slack = new SlackBridge(userId); const teams = await slack.getTeams(); @@ -76,7 +81,7 @@ export class DimensionSlackService { @GET @Path("teams/:teamId/channels") public async getChannels(@QueryParam("scalar_token") scalarToken: string, @PathParam("teamId") teamId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const slack = new SlackBridge(userId); @@ -90,7 +95,7 @@ export class DimensionSlackService { @GET @Path("auth") public async getAuthUrl(@QueryParam("scalar_token") scalarToken: string): Promise<{ authUrl: string }> { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const slack = new SlackBridge(userId); diff --git a/src/api/dimension/DimensionStickerService.ts b/src/api/dimension/DimensionStickerService.ts index bf0c15e..2bdaccc 100644 --- a/src/api/dimension/DimensionStickerService.ts +++ b/src/api/dimension/DimensionStickerService.ts @@ -2,12 +2,13 @@ import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; import { Cache, CACHE_STICKERS } from "../../MemoryCache"; import StickerPack from "../../db/models/StickerPack"; import Sticker from "../../db/models/Sticker"; -import { ScalarService } from "../scalar/ScalarService"; import UserStickerPack from "../../db/models/UserStickerPack"; import { ApiError } from "../ApiError"; import { StickerpackMetadataDownloader } from "../../utils/StickerpackMetadataDownloader"; import { MatrixStickerBot } from "../../matrix/MatrixStickerBot"; import config from "../../config"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; export interface MemoryStickerPack { id: number; @@ -63,8 +64,12 @@ interface StickerConfig { * API for stickers */ @Path("/api/v1/dimension/stickers") +@AutoWired export class DimensionStickerService { + @Inject + private accountController: AccountController; + public static async getStickerPacks(enabledOnly = false): Promise { const cachedPacks = Cache.for(CACHE_STICKERS).get("packs"); if (cachedPacks) { @@ -86,7 +91,7 @@ export class DimensionStickerService { @GET @Path("config") public async getConfig(@QueryParam("scalar_token") scalarToken: string): Promise { - await ScalarService.getTokenOwner(scalarToken); + await this.accountController.getTokenOwner(scalarToken); return { enabled: config.stickers.enabled, @@ -98,7 +103,7 @@ export class DimensionStickerService { @GET @Path("packs") public async getStickerPacks(@QueryParam("scalar_token") scalarToken: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const cachedPacks = Cache.for(CACHE_STICKERS).get("packs_" + userId); if (cachedPacks) return cachedPacks; @@ -125,7 +130,7 @@ export class DimensionStickerService { @POST @Path("packs/:packId/selected") public async setPackSelected(@QueryParam("scalar_token") scalarToken: string, @PathParam("packId") packId: number, request: SetSelectedRequest): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const pack = await StickerPack.findByPrimary(packId); if (!pack) throw new ApiError(404, "Sticker pack not found"); @@ -149,7 +154,7 @@ export class DimensionStickerService { @POST @Path("packs/import") public async importPack(@QueryParam("scalar_token") scalarToken: string, request: ImportPackRequest): Promise { - await ScalarService.getTokenOwner(scalarToken); + await this.accountController.getTokenOwner(scalarToken); if (!config.stickers.enabled) { throw new ApiError(400, "Custom stickerpacks are disabled on this homeserver"); diff --git a/src/api/dimension/DimensionTelegramService.ts b/src/api/dimension/DimensionTelegramService.ts index 7024e98..40f1510 100644 --- a/src/api/dimension/DimensionTelegramService.ts +++ b/src/api/dimension/DimensionTelegramService.ts @@ -1,7 +1,8 @@ import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import { TelegramBridge } from "../../bridges/TelegramBridge"; import { ApiError } from "../ApiError"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; interface PortalInfoResponse { bridged: boolean; @@ -19,12 +20,16 @@ interface BridgeRoomRequest { * API for interacting with the Telegram bridge */ @Path("/api/v1/dimension/telegram") +@AutoWired export class DimensionTelegramService { + @Inject + private accountController: AccountController; + @GET @Path("chat/:chatId") public async getPortalInfo(@QueryParam("scalar_token") scalarToken: string, @PathParam("chatId") chatId: number, @QueryParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const telegram = new TelegramBridge(userId); @@ -47,7 +52,7 @@ export class DimensionTelegramService { @POST @Path("chat/:chatId/room/:roomId") public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("chatId") chatId: number, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const telegram = new TelegramBridge(userId); @@ -69,7 +74,7 @@ export class DimensionTelegramService { @DELETE @Path("room/:roomId") public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); try { const telegram = new TelegramBridge(userId); diff --git a/src/api/dimension/DimensionWebhooksService.ts b/src/api/dimension/DimensionWebhooksService.ts index 4f5363f..d32f907 100644 --- a/src/api/dimension/DimensionWebhooksService.ts +++ b/src/api/dimension/DimensionWebhooksService.ts @@ -1,19 +1,24 @@ import { DELETE, FormParam, HeaderParam, Path, PathParam, POST, QueryParam } from "typescript-rest"; -import { ScalarService } from "../scalar/ScalarService"; import { SuccessResponse, WebhookConfiguration, WebhookOptions } from "../../bridges/models/webhooks"; import { WebhooksBridge } from "../../bridges/WebhooksBridge"; import Webhook from "../../db/models/Webhook"; import { ApiError } from "../ApiError"; import { LogService } from "matrix-js-snippets"; import * as request from "request"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; /** * API for interacting with the Webhooks bridge, and for setting up proxies to other * services. */ @Path("/api/v1/dimension/webhooks") +@AutoWired export class DimensionWebhooksService { + @Inject + private accountController: AccountController; + @POST @Path("/travisci/:webhookId") public async postTravisCiWebhook(@PathParam("webhookId") webhookId: string, @FormParam("payload") payload: string, @HeaderParam("Signature") signature: string): Promise { @@ -43,7 +48,7 @@ export class DimensionWebhooksService { @POST @Path("room/:roomId/webhooks/new") public async newWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, options: WebhookOptions): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const webhooks = new WebhooksBridge(userId); return webhooks.createWebhook(roomId, options); @@ -52,7 +57,7 @@ export class DimensionWebhooksService { @POST @Path("room/:roomId/webhooks/:hookId") public async updateWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("hookId") hookId: string, options: WebhookOptions): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const webhooks = new WebhooksBridge(userId); return webhooks.updateWebhook(roomId, hookId, options); @@ -61,7 +66,7 @@ export class DimensionWebhooksService { @DELETE @Path("room/:roomId/webhooks/:hookId") public async deleteWebhook(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, @PathParam("hookId") hookId: string): Promise { - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); const webhooks = new WebhooksBridge(userId); return webhooks.deleteWebhook(roomId, hookId); diff --git a/src/api/msc/MSCAccountService.ts b/src/api/msc/MSCAccountService.ts new file mode 100644 index 0000000..7a8abc3 --- /dev/null +++ b/src/api/msc/MSCAccountService.ts @@ -0,0 +1,41 @@ +import { Context, GET, Path, POST, Security, ServiceContext } from "typescript-rest"; +import { OpenId } from "../../models/OpenId"; +import AccountController, { IAccountInfoResponse, IAccountRegisteredResponse } from "../controllers/AccountController"; +import { AutoWired, Inject } from "typescript-ioc/es6"; +import { IMSCUser, ROLE_MSC_USER } from "../security/MSCSecurity"; + +/** + * API for account management + */ +@Path("/_matrix/integrations/v1/account") +@AutoWired +export class MSCAccountService { + + @Inject + private accountController: AccountController; + + @Context + private context: ServiceContext; + + @POST + @Path("register") + public async register(request: OpenId): Promise { + return this.accountController.registerAccount(request); + } + + @GET + @Path("") + @Security(ROLE_MSC_USER) + public async info(): Promise { + const user: IMSCUser = this.context.request.user; + return {user_id: user.userId}; + } + + @POST + @Path("logout") + @Security(ROLE_MSC_USER) + public async logout(): Promise { + await this.accountController.logout(this.context.request.user); + return {}; + } +} \ No newline at end of file diff --git a/src/api/scalar/ScalarService.ts b/src/api/scalar/ScalarService.ts index e01173b..ff24cf6 100644 --- a/src/api/scalar/ScalarService.ts +++ b/src/api/scalar/ScalarService.ts @@ -1,104 +1,30 @@ import { GET, Path, POST, QueryParam } from "typescript-rest"; -import { MatrixOpenIdClient } from "../../matrix/MatrixOpenIdClient"; -import Upstream from "../../db/models/Upstream"; -import { ScalarClient } from "../../scalar/ScalarClient"; -import User from "../../db/models/User"; -import UserScalarToken from "../../db/models/UserScalarToken"; -import { LogService } from "matrix-js-snippets"; import { ApiError } from "../ApiError"; -import * as randomString from "random-string"; import { OpenId } from "../../models/OpenId"; import { ScalarAccountResponse, ScalarRegisterResponse } from "../../models/ScalarResponses"; -import { Cache, CACHE_SCALAR_ACCOUNTS } from "../../MemoryCache"; -import { ScalarStore } from "../../db/ScalarStore"; - -interface RegisterRequest { - access_token: string; - token_type: string; - matrix_server_name: string; - expires_in: number; -} +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; /** * API for the minimum Scalar API we need to implement to be compatible with clients. Used for registration * and general account management. */ @Path("/api/v1/scalar") +@AutoWired export class ScalarService { - /** - * Gets the owner of a given scalar token, throwing an ApiError if the token is invalid. - * @param {string} scalarToken The scalar token to validate - * @param {boolean} ignoreUpstreams True to consider the token valid if it is missing links to other upstreams - * @returns {Promise} Resolves to the owner's user ID if the token is valid. - * @throws {ApiError} Thrown with a status code of 401 if the token is invalid. - */ - public static async getTokenOwner(scalarToken: string, ignoreUpstreams = false): Promise { - const cachedUserId = Cache.for(CACHE_SCALAR_ACCOUNTS).get(scalarToken); - if (cachedUserId) return cachedUserId; - - try { - const user = await ScalarStore.getTokenOwner(scalarToken, ignoreUpstreams); - Cache.for(CACHE_SCALAR_ACCOUNTS).put(scalarToken, user.userId, 30 * 60 * 1000); // 30 minutes - return user.userId; - } catch (err) { - LogService.error("ScalarService", err); - throw new ApiError(401, "Invalid token"); - } - } + @Inject + private accountController: AccountController; @POST @Path("register") - public async register(request: RegisterRequest, @QueryParam("v") apiVersion: string): Promise { + public async register(request: OpenId, @QueryParam("v") apiVersion: string): Promise { if (apiVersion !== "1.1") { throw new ApiError(401, "Invalid API version."); } - const mxClient = new MatrixOpenIdClient(request); - const mxUserId = await mxClient.getUserId(); - - if (!mxUserId.endsWith(":" + request.matrix_server_name)) { - LogService.warn("ScalarService", `OpenID subject '${mxUserId}' does not belong to the homeserver '${request.matrix_server_name}'`); - throw new ApiError(401, "Invalid token"); - } - - const user = await User.findByPrimary(mxUserId); - if (!user) { - // There's a small chance we'll get a validation error because of: - // https://github.com/vector-im/riot-web/issues/5846 - LogService.verbose("ScalarService", "User " + mxUserId + " never seen before - creating"); - await User.create({userId: mxUserId}); - } - - const upstreams = await Upstream.findAll(); - await Promise.all(upstreams.map(async upstream => { - if (!await ScalarStore.isUpstreamOnline(upstream)) { - LogService.warn("ScalarService", `Skipping registration for ${mxUserId} on upstream ${upstream.id} (${upstream.name}) because it is offline`); - return null; - } - const tokens = await UserScalarToken.findAll({where: {userId: mxUserId, upstreamId: upstream.id}}); - if (!tokens || tokens.length === 0) { - LogService.info("ScalarService", "Registering " + mxUserId + " for a token at upstream " + upstream.id + " (" + upstream.name + ")"); - const client = new ScalarClient(upstream); - const response = await client.register(request); - return UserScalarToken.create({ - userId: mxUserId, - scalarToken: response.scalar_token, - isDimensionToken: false, - upstreamId: upstream.id, - }); - } - }).filter(token => !!token)); - - const dimensionToken = randomString({length: 25}); - const dimensionScalarToken = await UserScalarToken.create({ - userId: mxUserId, - scalarToken: dimensionToken, - isDimensionToken: true, - }); - - LogService.info("ScalarService", mxUserId + " has registered for a scalar token successfully"); - return {scalar_token: dimensionScalarToken.scalarToken}; + const response = await this.accountController.registerAccount(request); + return {scalar_token: response.token}; } @GET @@ -108,7 +34,7 @@ export class ScalarService { throw new ApiError(401, "Invalid API version."); } - const userId = await ScalarService.getTokenOwner(scalarToken); + const userId = await this.accountController.getTokenOwner(scalarToken); return {user_id: userId}; } diff --git a/src/api/scalar/ScalarWidgetService.ts b/src/api/scalar/ScalarWidgetService.ts index 7de17e9..6634c7f 100644 --- a/src/api/scalar/ScalarWidgetService.ts +++ b/src/api/scalar/ScalarWidgetService.ts @@ -3,8 +3,9 @@ import { LogService } from "matrix-js-snippets"; import { Cache, CACHE_WIDGET_TITLES } from "../../MemoryCache"; import { MatrixLiteClient } from "../../matrix/MatrixLiteClient"; import config from "../../config"; -import { ScalarService } from "./ScalarService"; import moment = require("moment"); +import { AutoWired, Inject } from "typescript-ioc/es6"; +import AccountController from "../controllers/AccountController"; interface UrlPreviewResponse { cached_response: boolean; @@ -22,12 +23,16 @@ interface UrlPreviewResponse { * API for the minimum Scalar API for widget functionality in clients. */ @Path("/api/v1/scalar/widgets") +@AutoWired export class ScalarWidgetService { + @Inject + private accountController: AccountController; + @GET @Path("title_lookup") public async titleLookup(@QueryParam("scalar_token") scalarToken: string, @QueryParam("curl") url: string): Promise { - await ScalarService.getTokenOwner(scalarToken); + await this.accountController.getTokenOwner(scalarToken); const cachedResult = Cache.for(CACHE_WIDGET_TITLES).get(url); if (cachedResult) { diff --git a/src/api/security/MSCSecurity.ts b/src/api/security/MSCSecurity.ts new file mode 100644 index 0000000..69f1e85 --- /dev/null +++ b/src/api/security/MSCSecurity.ts @@ -0,0 +1,56 @@ +import { ServiceAuthenticator } from "typescript-rest"; +import { Request, RequestHandler, Response, Router } from "express"; +import { ApiError } from "../ApiError"; +import { LogService } from "matrix-js-snippets"; +import AccountController from "../controllers/AccountController"; + +export interface IMSCUser { + userId: string; + token: string; +} + +export const ROLE_MSC_USER = "ROLE_MSC_USER"; + +export default class MSCSecurity implements ServiceAuthenticator { + + private accountController = new AccountController(); + + public getRoles(req: Request): string[] { + if (req.user) return [ROLE_MSC_USER]; + return []; + } + + getMiddleware(): RequestHandler { + return (async (req: Request, res: Response, next: () => void) => { + try { + if (req.headers.authorization) { + const header = req.headers.authorization; + if (!header.startsWith("Bearer ")) { + return res.status(401).json({errcode: "M_INVALID_TOKEN", error: "Invalid token"}); + } + + const token = header.substring("Bearer ".length); + req.user = { + userId: await this.accountController.getTokenOwner(token), + token: token, + }; + return next(); + } + + console.log(req.query); + } catch (e) { + if (e instanceof ApiError) { + // TODO: Proper error message + res.status(e.statusCode).json({errcode: e.errorCode, error: "Error"}); + } else { + LogService.error("MSCSecurity", e); + res.status(500).json({errcode: "M_UNKNOWN", error: "Unknown server error"}); + } + } + }); + } + + initialize(_router: Router): void { + } + +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index caae7dc..60d07af 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -142,7 +142,11 @@ module.exports = function () { '/api': { target: 'http://localhost:8184', secure: false - } + }, + '/_matrix': { + target: 'http://localhost:8184', + secure: false + }, } };