diff --git a/.eslintrc.js b/.eslintrc.js index fe21cf8..4868f62 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,7 +27,7 @@ module.exports = { "no-dupe-args": [ "error" ], "no-dupe-keys": [ "error" ], "no-duplicate-case": [ "error" ], - "no-empty": [ "error" ], + // "no-empty": [ "error" ], "no-empty-character-class": [ "error" ], "no-ex-assign": [ "error" ], "no-extra-semi": [ "error" ], diff --git a/migrations/20190428145801_init.js b/migrations/20190428145801_init.js new file mode 100644 index 0000000..6db46b8 --- /dev/null +++ b/migrations/20190428145801_init.js @@ -0,0 +1,30 @@ +'use strict'; + +module.exports.up = function(knex, Promise) { + return Promise.try(() => { + return knex.schema.createTable("users", (table) => { + table.bigIncrements("id").primary(); + table.text("username").notNull(); + table.text("password_hash"); + table.enum("type", ["user", "guest"]).notNull(); + }); + }).then(() => { + return knex.schema.createTable("devices", (table) => { + table.bigIncrements("id").primary(); + table.integer("user_id").notNull().references("users.id"); + table.text("name"); + table.text("device_id").notNull(); + table.text("token").notNull(); + + table.unique(["user_id", "device_id"]); + }); + }); +}; + +module.exports.down = function(knex, Promise) { + return Promise.try(() => { + return knex.schema.dropTable("devices"); + }).then(() => { + return knex.schema.dropTable("users"); + }); +}; \ No newline at end of file diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..7f6a298 --- /dev/null +++ b/notes.txt @@ -0,0 +1,62 @@ +QUESTIONS: +- What are allowable characters for a device ID? + https://github.com/matrix-org/matrix-doc/issues/1257 + +---- + +The txn_id in a token authentication request is meant to be tied permanently to the (single-use) token by the server, *after* the first request from the client that includes the txn_id. It functions as a nonce that other clients would not be able to guess, essentially locking the token to a specific client. Ref https://github.com/matrix-org/matrix-doc/pull/69 + +---- + +Error metadata: +TooManyRequests: { retry_after_ms: 2000 } + +---- + +Deprecated features to maybe support later: +- + +---- + +Skipped, todo later: + +GET /_matrix/static/client/login/ +https://matrix.org/docs/spec/client_server/r0.4.0.html#id216 +This returns an HTML and JavaScript page which can perform the entire login process. The page will attempt to call the JavaScript function window.onLogin when login has been successfully completed. + +----- + +GUEST ACCOUNTS + +https://matrix.org/docs/spec/client_server/r0.4.0.html#id520 + +The following API endpoints are allowed to be accessed by guest accounts for retrieving events: + + GET /rooms/:room_id/state + GET /rooms/:room_id/context/:event_id + GET /rooms/:room_id/event/:event_id + GET /rooms/:room_id/state/:event_type/:state_key + GET /rooms/:room_id/messages + GET /rooms/:room_id/initialSync + GET /sync + GET /events as used for room previews. + +The following API endpoints are allowed to be accessed by guest accounts for sending events: + + POST /rooms/:room_id/join + POST /rooms/:room_id/leave + PUT /rooms/:room_id/send/m.room.message/:txn_id + PUT /sendToDevice/{eventType}/{txnId} + +The following API endpoints are allowed to be accessed by guest accounts for their own account maintenance: + + PUT /profile/:user_id/displayname + GET /devices + GET /devices/{deviceId} + PUT /devices/{deviceId} + +The following API endpoints are allowed to be accessed by guest accounts for end-to-end encryption: + + POST /keys/upload + POST /keys/query + POST /keys/claim diff --git a/package.json b/package.json index 8fe5075..60b2594 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,20 @@ "author": "Sven Slootweg ", "license": "WTFPL OR CC0-1.0", "dependencies": { + "awesome-phonenumber": "^2.9.0", "bluebird": "^3.5.4", "body-parser": "^1.19.0", "chalk": "^2.4.2", "cors": "^2.8.5", + "database-error": "^2.0.1", "default-value": "^1.0.0", "express": "^4.16.4", "express-promise-router": "^3.0.3", "knex": "^0.16.5", - "pg": "^7.10.0" + "nanoid": "^2.0.1", + "nanoid-dictionary": "^2.0.0", + "pg": "^7.10.0", + "scrypt-for-humans": "^2.0.5" }, "devDependencies": { "eslint": "^5.16.0", diff --git a/src/access-token/decode.js b/src/access-token/decode.js new file mode 100644 index 0000000..0b8cbd7 --- /dev/null +++ b/src/access-token/decode.js @@ -0,0 +1,16 @@ +"use strict"; + +const errors = require("../errors"); + +module.exports = function decodeAccessToken(encodedAccessToken) { + if (encodedAccessToken.includes(":")) { + let [encodedDeviceId, token] = encodedAccessToken.split(":"); + + return { + deviceId: Buffer.from(encodedDeviceId, "base64").toString(), + token: token + }; + } else { + throw new errors.InvalidAccessToken("Invalid access token provided"); + } +}; \ No newline at end of file diff --git a/src/access-token/encode.js b/src/access-token/encode.js new file mode 100644 index 0000000..05c709b --- /dev/null +++ b/src/access-token/encode.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = function encodeAccessToken({ deviceId, token }) { + if (deviceId == null || token == null) { + throw new Error("Device ID and token are required"); + } else { + return `${Buffer.from(deviceId).toString("base64")}:${token}`; + } +}; \ No newline at end of file diff --git a/src/authenticators/password.js b/src/authenticators/password.js new file mode 100644 index 0000000..c3ed5d9 --- /dev/null +++ b/src/authenticators/password.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = function ({ db }) { + return function passwordAuthenticator(data) { + + } +}; \ No newline at end of file diff --git a/src/canonicalize-phone-number.js b/src/canonicalize-phone-number.js new file mode 100644 index 0000000..e588bf5 --- /dev/null +++ b/src/canonicalize-phone-number.js @@ -0,0 +1,7 @@ +"use strict"; + +const PhoneNumber = require("awesome-phonenumber"); + +module.exports = function canonicalizePhoneNumber(isoRegionCode, phoneNumber) { + return PhoneNumber(phoneNumber, isoRegionCode).getNumber("e164").replace(/^\+/, ""); +}; \ No newline at end of file diff --git a/src/db-module/at-least-one.js b/src/db-module/at-least-one.js new file mode 100644 index 0000000..83384c2 --- /dev/null +++ b/src/db-module/at-least-one.js @@ -0,0 +1,17 @@ +"use strict"; + +/* FIXME: Complete, maybe */ + +const Promise = require("bluebird"); + +module.exports = function atLeastOne(method) { + return function methodWrapper(...args) { + return Promise.try(() => { + return method(...args); + }).then((result) => { + if (Array.isArray(result)) { + + } + }) + } +}; \ No newline at end of file diff --git a/src/db-module/index.js b/src/db-module/index.js new file mode 100644 index 0000000..6a6dd59 --- /dev/null +++ b/src/db-module/index.js @@ -0,0 +1,23 @@ +"use strict"; + +const Promise = require("bluebird"); +const mapObj = require("map-obj"); +const databaseError = require("database-error"); + +function wrapMethod(value) { + if (typeof value === "function") { + return function wrappedDatabaseMethod(...args) { + return Promise.try(() => { + return value(...args); + }).catch(databaseError.rethrow); + }; + } else { + return value; + } +} + +module.exports = function createDatabaseModule(methods) { + return mapObj(methods, (key, value) => { + return [key, wrapMethod(value)]; + }); +}; diff --git a/src/db/accounts.js b/src/db/accounts.js new file mode 100644 index 0000000..cfc50bc --- /dev/null +++ b/src/db/accounts.js @@ -0,0 +1,39 @@ +"use strict"; + +const Promise = require("bluebird"); +const scryptForHumans = require("scrypt-for-humans"); + +const errors = require("../errors"); +const extractLocalPart = require("../extract-local-part"); +const createDatabaseModule = require("../db-module"); + +module.exports = function ({ knex }) { + return createDatabaseModule({ + create: function ({ username, password, type }, tx = knex) { + return Promise.try(() => { + if (password != null) { + return scryptForHumans.hash(password); + } + }).then((hash) => { + return tx("users").insert({ + username: username, + password_hash: hash, + type: type + }).returning("*"); + }); + }, + byUsername: function ({ username }, tx = knex) { + return Promise.try(() => { + /* TODO: Validate that, if a fully-qualified ID is passed in, it's for the correct homeserver? */ + return tx("users").first().where({ username: extractLocalPart(username) }); + }).then((user) => { + if (user != null) { + return user; + } else { + throw new errors.InvalidUsername("No such user exists"); + /* FIXME: Map to 403 error in login route */ + } + }); + } + }); +}; \ No newline at end of file diff --git a/src/db/devices.js b/src/db/devices.js index 0a4b9f8..c07c988 100644 --- a/src/db/devices.js +++ b/src/db/devices.js @@ -1,7 +1,91 @@ "use strict"; -module.exports = function ({db}) { - return { +const Promise = require("bluebird"); +const crypto = require("crypto"); +const nanoid = require("nanoid"); +const defaultValue = require("default-value"); +const databaseError = require("database-error"); - }; +const errors = require("../errors"); +const createDatabaseModule = require("../db-module"); + +function tokensMatch(a, b) { + return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +module.exports = function ({ knex }) { + return createDatabaseModule({ + create: function ({ name, deviceId, userId, token }, tx = knex) { + return Promise.try(() => { + let token_ = defaultValue(token, nanoid()); + let deviceId_ = defaultValue(deviceId, nanoid()); + + return tx("devices").insert({ + device_id: deviceId_, + user_id: userId, + name: name, + token: token_, + }).returning("*"); + }).then((results) => { + return results[0]; + }); + }, + byDeviceId: function ({ deviceId }, tx = knex) { + return Promise.try(() => { + return tx("devices").first().where({ device_id: deviceId }); + }).then((result) => { + if (result != null) { + return result; + } else { + throw new errors.NotFound("The specified device ID does not exist"); + } + }); + }, + byDeviceIdAndToken: function ({ deviceId, token }, _tx = knex) { + /* NOTE: This is intentionally a separate method as opposed to conditional logic in byDeviceId, to prevent cases where a bug results in a user-specified token not being passed in - which would fail unsafely by allowing any token, if conditional logic were used. */ + return Promise.try(() => { + if (token != null) { + return this.byDeviceId({ deviceId }); + } else { + throw new errors.MissingAccessToken("No access token was specified"); + } + }).then((device) => { + if (tokensMatch(token, device.token)) { + return device; + } else { + throw new errors.InvalidAccessToken("Specified token was invalid"); + } + }); + }, + updateToken: function ({ userId, deviceId, token }, tx = knex) { + return Promise.try(() => { + return tx("devices") + .update({ token: token }) + .where({ + device_id: deviceId, + user_id: userId + }) + .returning("*"); + }).then((results) => { + if (results.length > 0) { + return results[0]; + } else { + throw new errors.NotFound("Specified Device ID does not exist"); + } + }); + }, + createDeviceSession: function ({ name, deviceId, userId }, _tx = knex) { + return Promise.try(() => { + let token = nanoid(); + + return Promise.try(() => { + return this.create({ name, deviceId, userId, token }); + }).catch({ name: "UniqueConstraintViolationError", table: "devices", column: "device_id" }, (_error) => { + return this.updateToken({ userId, deviceId, token }); + }).then((device) => { + /* FIXME */ + }); + }); + } + }); }; \ No newline at end of file diff --git a/src/errors.js b/src/errors.js index fba3899..40a8fa1 100644 --- a/src/errors.js +++ b/src/errors.js @@ -42,8 +42,9 @@ let ExternalIdentifierExists = ResourceExists.extend("ExternalIdentifierExists", let RequestTooLarge = HttpError.extend("RequestTooLarge", { statusCode: 413, errorCode: "M_TOO_LARGE" }); /*** HTTP 422: Unprocessable Entity (Invalid Payload) ***/ +/* NOTE: Currently set to 400 instead, because that seems to be what the specification requires, even though 422 would be more appropriate. */ -let InvalidData = HttpError.extend("InvalidData", { statusCode: 422 }); +let InvalidData = HttpError.extend("InvalidData", { statusCode: /*422*/ 400 }); let InvalidUsername = InvalidData.extend("InvalidUsername", { errorCode: "M_INVALID_USERNAME" }); let InvalidRoomVersion = InvalidData.extend("InvalidRoomVersion", { errorCode: "M_UNSUPPORTED_ROOM_VERSION" }); let InvalidRoomState = InvalidData.extend("InvalidRoomState", { errorCode: "M_INVALID_ROOM_STATE" }); @@ -52,6 +53,7 @@ let InvalidExternalIdentifier = InvalidData.extend("InvalidExternalIdentifier", let InvalidParameter = InvalidData.extend("InvalidParameter", { errorCode: "M_INVALID_PARAM" }); let MissingParameter = InvalidData.extend("MissingParameter", { errorCode: "M_MISSING_PARAM" }); let IncompatibleRoomVersion = InvalidData.extend("IncompatibleRoomVersion", { errorCode: "M_INCOMPATIBLE_ROOM_VERSION" }); +let WeakPassword = InvalidData.extend("WeakPassword", { errorCode: "M_WEAK_PASSWORD" }); /*** HTTP 429: Too Many Requests ***/ @@ -61,11 +63,12 @@ let TooManyRequests = HttpError.extend("TooManyRequests", { statusCode: 429, err let InternalServerError = HttpError.extend("InternalServerError", { statusCode: 500 }); let UnknownError = InternalServerError.extend("UnknownError", { errorCode: "M_UNKNOWN" }); +let Unreachable = InternalServerError.extend("Unreachable"); /* FIXME: How to represent this with an error code? This should crash the process anyway. */ /*** HTTP 503: Service Unavailable ***/ - +// This describes reserved namespaces and such (eg. for registration), so probably should be a client error instead let ExclusiveResource = HttpError.extend("ExclusiveResource", { statusCode: 503, errorCode: "M_EXCLUSIVE" }); module.exports = { - HttpError, Unauthorized, MissingAccessToken, InvalidAccessToken, NotJson, BadJson, NotFound, TooManyRequests, MissingCaptcha, InvalidCaptcha, ServerNotTrusted, NoGuestAccess, UserExists, RoomExists, RequestTooLarge, InvalidUsername, InvalidRoomVersion, InvalidRoomState, InvalidStateChange, InvalidParameter, MissingParameter, IncompatibleRoomVersion, InternalServerError, UnknownError, ExclusiveResource, ResourceExists, Forbidden, BadRequest, ExternalIdentifierNotFound, ExternalIdentifierExists, ExternalIdentifierAuthenticationFailed, InvalidExternalIdentifier + HttpError, Unauthorized, MissingAccessToken, InvalidAccessToken, NotJson, BadJson, NotFound, TooManyRequests, MissingCaptcha, InvalidCaptcha, ServerNotTrusted, NoGuestAccess, UserExists, RoomExists, RequestTooLarge, InvalidUsername, InvalidRoomVersion, InvalidRoomState, InvalidStateChange, InvalidParameter, MissingParameter, IncompatibleRoomVersion, InternalServerError, UnknownError, ExclusiveResource, ResourceExists, Forbidden, BadRequest, ExternalIdentifierNotFound, ExternalIdentifierExists, ExternalIdentifierAuthenticationFailed, InvalidExternalIdentifier, WeakPassword, Unreachable }; \ No newline at end of file diff --git a/src/extract-local-part.js b/src/extract-local-part.js new file mode 100644 index 0000000..cdb4f37 --- /dev/null +++ b/src/extract-local-part.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = function extractLocalPart(id) { + if (id.includes(":")) { + return id.split(":")[0]; + } else { + return id; + } +}; \ No newline at end of file diff --git a/index.js b/src/index.js similarity index 59% rename from index.js rename to src/index.js index 6e2165a..7c1c2e7 100644 --- a/index.js +++ b/src/index.js @@ -6,22 +6,41 @@ const bodyParser = require("body-parser"); const cors = require("cors"); const url = require("url"); -const debugMiddleware = require("./src/debug-middleware"); -const accessTokenMiddleware = require("./src/middlewares/access-token"); +const debugMiddleware = require("./debug-middleware"); +const accessTokenMiddleware = require("./middlewares/access-token"); -const clientApiRouter = require("./src/routers/client-api"); +const clientApiRouter = require("./routers/client-api"); -const configuration = require("./config.json"); +const knex = require("knex")(require("../knexfile")); +const configuration = require("../config.json"); + +function fullyQualify(prefix, value) { + if (value.includes(":")) { + return value; + } else { + return `${prefix}${value}:${configuration.hostname}`; + } +} + +let state = { + configuration, knex, + fullyQualifyUser: fullyQualify.bind(null, "@"), + fullyQualifyRoom: fullyQualify.bind(null, "!"), + fullyQualifyRoomAlias: fullyQualify.bind(null, "#"), + fullyQualifyEvent: fullyQualify.bind(null, "$"), + fullyQualifyGroup: fullyQualify.bind(null, "+"), +}; + +state.db = { + devices: require("./db/devices")(state), + accounts: require("./db/accounts")(state) +}; let baseUrl = url.format({ protocol: (configuration.tls) ? "https" : "http", host: configuration.hostname }); -let state = { - configuration -}; - let app = express(); let router = expressPromiseRouter(); diff --git a/src/match-value.js b/src/match-value.js new file mode 100644 index 0000000..1827666 --- /dev/null +++ b/src/match-value.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = function matchValue(value, arms) { + for (let [candidateValue, arm] of Object.entries(arms)) { + if (candidateValue !== "_" && value === candidateValue) { + return arm(); + } + } + + if (arms._ != null) { + return arms._(); + } else { + throw new Error("No matching arm found"); + } +}; \ No newline at end of file diff --git a/src/middlewares/normalize-payload.js b/src/middlewares/normalize-payload.js new file mode 100644 index 0000000..194b76a --- /dev/null +++ b/src/middlewares/normalize-payload.js @@ -0,0 +1,20 @@ +"use strict"; + +function applyNormalizations(payload, normalizations) { + return normalizations.reduce((current, normalization) => { + let changes = normalization(current); + + if (changes == null) { + return current; + } else { + return Object.assign(current, changes); + } + }, Object.assign({}, payload)); +} + +module.exports = function createNormalizationMiddleware(normalizations) { + return function normalizePayload(req, _res, next) { + req.body = applyNormalizations(req.body, normalizations); + next(); + } +}; \ No newline at end of file diff --git a/src/middlewares/require-access-token.js b/src/middlewares/require-access-token.js index d4dada7..f81d0ea 100644 --- a/src/middlewares/require-access-token.js +++ b/src/middlewares/require-access-token.js @@ -1,11 +1,14 @@ "use strict"; -const errors = require("../errors"); +const Promise = require("bluebird"); -module.exports = function requireAccessToken(req, res, next) { - if (req.matrixAccessToken == null) { - throw new errors.UnauthorizedError("An access token is required, but none was provided", { - errorCode: "M_MISSING_TOKEN" +module.exports = function ({ db }) { + return function requireAccessToken(req, _res, next) { + return Promise.try(() => { + return db.devices.byToken(req.matrixAccessToken); + }).then((device) => { + req.device = device; + next(); }); - } -}; \ No newline at end of file + }; +}; diff --git a/src/middlewares/validate-payload.js b/src/middlewares/validate-payload.js new file mode 100644 index 0000000..aadb578 --- /dev/null +++ b/src/middlewares/validate-payload.js @@ -0,0 +1,36 @@ +"use strict"; + +// const Joi = require("@hapi/joi"); + +const errors = require("../errors"); + +const {validate, ValidationError} = require("../validate"); + +module.exports = function createPayloadValidator(validator) { + return function validatePayload(req, _res, next) { + try { + validate(validator, req.body); + } catch (error) { + if (error instanceof ValidationError) { + console.log(require("util").inspect(error, {colors: true, depth: null})); + throw new errors.MissingParameter("Something in the validation was wrong"); + } else { + throw error; + } + } + + next(); + + + // let result = Joi.validate(req.body, schema); + + // if (result.error != null) { + // /* FIXME: Actually produce meaningful output based on the Joi output */ + // console.log(require("util").inspect(result.error, {colors: true, depth: null})); + + // throw new errors.MissingParameter("Something in the validation was wrong"); + // } else { + // next(); + // } + } +}; \ No newline at end of file diff --git a/src/process-device-id.js b/src/process-device-id.js new file mode 100644 index 0000000..caa8a82 --- /dev/null +++ b/src/process-device-id.js @@ -0,0 +1,14 @@ +"use strict"; + +const nanoidGenerate = require("nanoid/generate"); +const uppercaseAlphabet = require("nanoid-dictionary/uppercase"); + +module.exports = function processDeviceId(deviceId) { + /* TODO: Add validation here, once it becomes clear what the formatting requirements for a Device ID are */ + + if (deviceId != null && deviceId.length > 0) { + return deviceId; + } else { + return nanoidGenerate(uppercaseAlphabet, 16); + } +}; \ No newline at end of file diff --git a/src/routers/client-api/account.js b/src/routers/client-api/account.js new file mode 100644 index 0000000..72ad3e7 --- /dev/null +++ b/src/routers/client-api/account.js @@ -0,0 +1,27 @@ +"use strict"; + +const expressPromiseRouter = require("express-promise-router"); + +module.exports = function(state) { + let {db} = state; + let router = expressPromiseRouter(); + + router.get("/r0/account/3pid", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-account-3pid + // Gets a list of the third party identifiers that the homeserver has associated with the user's account. This is not the same as the list of third party identifiers bound to the user's Matrix ID in identity servers. + }); + + router.post("/r0/account/3pid", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-3pid + // Adds contact information to the user's account. + }); + + router.post("/r0/account/3pid/delete", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-3pid-delete + // Removes a third party identifier from the user's account. This might not cause an unbind of the identifier from the identity server. + }); + + // MARKER: https://matrix.org/docs/spec/client_server/r0.4.0.html#id231 + + return router; +} \ No newline at end of file diff --git a/src/routers/client-api/authentication.js b/src/routers/client-api/authentication.js new file mode 100644 index 0000000..4c59d87 --- /dev/null +++ b/src/routers/client-api/authentication.js @@ -0,0 +1,221 @@ +"use strict"; + +const Promise = require("bluebird"); +const expressPromiseRouter = require("express-promise-router"); +const scryptForHumans = require("scrypt-for-humans"); + +const errors = require("../../errors"); +const normalize = require("../../middlewares/normalize-payload"); +const validate = require("../../middlewares/validate-payload"); +const processDeviceId = require("../../process-device-id"); +const matchValue = require("../../match-value"); +const canonicalizePhoneNumber = require("../../canonicalize-phone-number"); + +function normalizeIdentifier(payload) { + if (payload.identifier != null) { + /* Never overwrite the `identifier` key if it already exists */ + return null; + } else if (payload.user != null) { + return { + identifier: { + type: "m.id.user", + user: payload.user + } + }; + } else if (payload.address != null) { + return { + identifier: { + type: "m.id.thirdparty", + medium: payload.medium, + address: payload.address + } + }; + } +} + +function normalizePhoneNumber(payload) { + if (payload.identifier != null && payload.identifier.type === "m.id.phone") { + return { + identifier: { + type: "m.id.thirdparty", + medium: "msisdn", + address: canonicalizePhoneNumber(payload.identifier.country, payload.identifier.phone) + } + }; + } +} + +function validateUserIdentifier(userIdentifier) { + let {assertProperties, isPresent, isString, isOneOf} = require("../../validate.js"); + + assertProperties(userIdentifier, { + type: [ isPresent, isOneOf("m.id.user", "m.id.thirdparty", "m.id.phone") ] + }); + + matchValue(userIdentifier.type, { + "m.id.user": () => { + assertProperties(userIdentifier, { + user: [ isPresent, isString ] + }); + }, + "m.id.thirdparty": () => { + assertProperties(userIdentifier, { + medium: [ isPresent, isString ], + address: [ isPresent, isString ] + }); + }, + "m.id.phone": () => { + assertProperties(userIdentifier, { + country: [ isPresent, isString ], + phone: [ isPresent, isString ] + }); + } + }); +} + +module.exports = function(state) { + const requireAccessToken = require("../../middlewares/require-access-token")(state); + + let {db, configuration, fullyQualifyUser} = state; + let router = expressPromiseRouter(); + + router.get("/r0/login", (req, res) => { + res.json({ + flows: [{ type: "m.login.password" }] + }); + }); + + function validateLoginPayload(payload) { + let {assertProperties, isPresent, isString, isOneOf} = require("../../validate.js"); + + assertProperties(payload, { + type: [ isPresent, isOneOf("m.login.password", "m.login.token") ], + device_id: [ isString ], + initial_device_display_name: [ isString ], + }); + + matchValue(payload.type, { + "m.login.password": () => { + assertProperties(payload, { + identifier: [ isPresent, validateUserIdentifier ], + password: [ isPresent, isString ] + }); + }, + "m.login.token": () => { + assertProperties(payload, { + token: [ isPresent, isString ] + }); + } + }); + } + + router.post("/r0/login", normalize([normalizeIdentifier, normalizePhoneNumber]), validate(validateLoginPayload), (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-login + // Authenticates the user, and issues an access token they can use to authorize themself in subsequent requests. + + /* FIXME: Rate-limit */ + + return Promise.try(() => { + if (req.body.identifier.type === "m.id.user") { + return db.accounts.byUsername({ + username: req.body.identifier.user + }); + } else if (req.body.identifier.type === "m.id.thirdparty") { + // FIXME + } else { + /* NOTE: We're not handling m.id.phone here, as that's normalized into m.id.thirdparty in normalizePhoneNumber */ + throw new errors.Unreachable("Invalid identifier type"); + } + }).then((user) => { + let deviceId = processDeviceId(req.body.device_id); + + return Promise.try(() => { + if (req.body.type === "m.login.password") { + return scryptForHumans.verifyHash(req.body.password, user.password_hash); + } else if (req.body.type === "m.login.token") { + // FIXME + } else { + throw new errors.Unreachable("Invalid login type"); + } + }).then(() => { + return db.devices.createDeviceSession({ + name: req.body.initial_device_display_name, + deviceId: deviceId, + userId: user.id + }).then((device) => { + res.send({ + access_token: device.token, + user_id: fullyQualifyUser(user.username), + home_server: configuration.hostname, + device_id: device.deviceId + }); + }); + }); + }).catch(errors.InvalidUsername, errors.Forbidden.chain("Invalid username specified")) + .catch(scryptForHumans.PasswordError, errors.Forbidden.chain("Invalid password specified")); + /* FIXME: Formatting above */ + }); + + router.post("/r0/logout", requireAccessToken, (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-logout + // Invalidates an existing access token, so that it can no longer be used for authorization. + }); + + router.post("/r0/logout/all", requireAccessToken, (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-logout-all + // Invalidates all access tokens for a user, so that they can no longer be used for authorization. This includes the access token that made this request. + }); + + router.post("/r0/register/available", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#get-matrix-client-r0-register-available + // Checks to see if a username is available, and valid, for the server. + }); + + router.post("/r0/register", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-register + // Register for an account on this homeserver. This API endpoint uses the User-Interactive Authentication API. + // Optionally allows user-interactive auth + }); + + router.post("/r0/register/:source/requestToken", (req, res) => { + // source === "email" + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-register-email-requesttoken + // Proxies the Identity Service API validate/email/requestToken, but first checks that the given email address is not already associated with an account on this homeserver. See the Identity Service API for further information. + + // source === "msisdn" + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-register-msisdn-requesttoken + // Proxies the Identity Service API validate/msisdn/requestToken, but first checks that the given phone number is not already associated with an account on this homeserver. See the Identity Service API for further information. + + // NOTE: Unclear whether it requires authentication or not. + }); + + router.post("/r0/account/password", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-password + // Changes the password for an account on this homeserver. + + // NOTE: Requires access token *or* interactive auth. + }); + + router.post("/r0/account/password/:source/requestToken", (req, res) => { + // source === "email" + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-password-email-requesttoken + // Proxies the Identity Service API validate/email/requestToken, but first checks that the given email address is associated with an account on this homeserver. This API should be used to request validation tokens when authenticating for the account/password endpoint. + + // source === "msisdn" + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-password-msisdn-requesttoken + // Proxies the Identity Service API validate/msisdn/requestToken, but first checks that the given phone number is associated with an account on this homeserver. This API should be used to request validation tokens when authenticating for the account/password endpoint. + + // NOTE: Unclear whether it requires authentication or not. + }); + + router.post("/r0/account/deactivate", (req, res) => { + // https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-account-deactivate + // Deactivate the user's account, removing all ability for the user to login again. This API endpoint uses the User-Interactive Authentication API. + + // NOTE: Requires access token *or* interactive auth. + }); + + // FIXME: Modularize out 'create session for device' logic for both login and register endpoints + + return router; +} \ No newline at end of file diff --git a/src/routers/client-api/index.js b/src/routers/client-api/index.js index 54f5963..6f74e39 100644 --- a/src/routers/client-api/index.js +++ b/src/routers/client-api/index.js @@ -1,10 +1,16 @@ "use strict"; +const expressPromiseRouter = require("express-promise-router"); + module.exports = function(state) { - let router = require("express-promise-router")(); + const requireAccessToken = require("../../middlewares/require-access-token")(state); + const authenticationRouter = require("./authentication")(state); + const accountRouter = require("./account")(state); + + let router = expressPromiseRouter(); router.get("/versions", (req, res) => { - /* TODO: Support more specification versions? */ + /* FIXME: Support more specification versions? */ res.json({ versions: [ "r0.4.0" @@ -12,5 +18,13 @@ module.exports = function(state) { }); }); + /* Various routes in the authentication router are freely accessible, so access token requirements are implemented there on a per-route basis. */ + router.use(authenticationRouter); + + /* From this point onwards, every route requires an access token. */ + router.use(requireAccessToken); + + router.use(accountRouter); + return router; } \ No newline at end of file diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..352d7d5 --- /dev/null +++ b/src/validate.js @@ -0,0 +1,119 @@ +"use strict"; + +const errorChain = require("error-chain"); + +let ValidationError = errorChain("ValidationError"); + +function isPresent(value) { + if (value == null) { + throw new ValidationError("Required value is missing", { errorType: "isPresent" }); + } +} + +function isString(value) { + if (value != null && typeof value !== "string") { + throw new ValidationError("Value is not a string", { errorType: "isString" }); + } +} + +function isOneOf(...acceptableValues) { + let valueSet = new Set(acceptableValues); + + return function (value) { + if (value != null && !valueSet.has(value)) { + throw new ValidationError("Value is not one of the specified acceptable values", { + errorType: "isOneOf", + acceptableValues: acceptableValues + }); + } + }; +} + +/* NOTE: Extremely sketchy way of tracking validation state via a module-global object, but we can get away with this because validation is completely synchronous */ +let context; + +function assertProperties(object, properties) { + for (let [property, assertions] of Object.entries(properties)) { + context.path.push(property); + + let value = object[property]; + + try { + for (let assertion of assertions) { + assertion(value); + } + } catch (error) { + if (error.input == null) { + error.input = value; + } + + throw error; + } + + context.path.pop(); + } +} + +function arrayOf(assertions) { + return function (value) { + if (value != null) { + if (!Array.isArray(value)) { + throw new ValidationError("Value is not an array", { errorType: "arrayOf" }); + } else { + for (let [i, item] of value.entries()) { + context.path.push(i); + + try { + for (let assertion of assertions) { + assertion(item); + } + } catch (error) { + if (error.input == null) { + error.input = value; + } + + throw error; + } + + context.path.pop(); + } + } + } + }; +} + +function validate(validator, value) { + context = { + path: [] + }; + + try { + validator(value); + } catch (error) { + if (error.path == null) { + error.path = context.path; + } + + throw error; + } + + context = undefined; +} + +module.exports = { + validate, assertProperties, ValidationError, + isPresent, isString, isOneOf, arrayOf +}; + +// try { +// validate(validateLoginPayload, { +// type: 'm.login.password', +// password: 'test', +// identifier: { type: 'm.id.user', user: 'joepie91' }, +// initial_device_display_name: 'http://localhost:3001/index.html via Firefox on Linux', +// user: 'joepie91', +// arrayStuff: [ 42 ] +// }); +// } catch (error) { +// console.log(require("util").inspect(error, {colors: true, depth: null})); +// } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 36eb12d..1da9b7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -146,6 +146,11 @@ atob@^2.1.1: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +awesome-phonenumber@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/awesome-phonenumber/-/awesome-phonenumber-2.9.0.tgz#8b3a0eea4ab5f508876c0061c45ab655559068cb" + integrity sha512-iKaiK6BXxpd9mpFgu1XkcnT3TffaDYT+E76iAaKGsgsI/zLObi0c3FFbmcl7jgmRAb7ldPTo0rWYROUkIBzP1Q== + balanced-match@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" @@ -164,6 +169,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +bluebird@^2.6.4: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + integrity sha1-U0uQM8AiyVecVro7Plpcqvu2UOE= + bluebird@^3.5.4: version "3.5.4" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.4.tgz#d6cc661595de30d5b3af5fcedd3c0b3ef6ec5714" @@ -374,6 +384,11 @@ cors@^2.8.5: object-assign "^4" vary "^1" +create-error@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/create-error/-/create-error-0.3.1.tgz#69810245a629e654432bf04377360003a5351a23" + integrity sha1-aYECRaYp5lRDK/BDdzYAA6U1GiM= + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -385,6 +400,14 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +database-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/database-error/-/database-error-2.0.1.tgz#d91745b01281caa73a5603f7178ddf694b8d18ba" + integrity sha1-2RdFsBKByqc6VgP3F43faUuNGLo= + dependencies: + create-error "^0.3.1" + pg-error-codes "^1.0.0" + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -489,6 +512,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +errors@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/errors/-/errors-0.2.0.tgz#0f51e889daa3e11b19e7186d11f104aa66eb2403" + integrity sha1-D1Hoidqj4RsZ5xhtEfEEqmbrJAM= + es-abstract@^1.11.0, es-abstract@^1.7.0: version "1.13.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" @@ -1492,6 +1520,21 @@ mute-stream@0.0.7: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= +nan@^2.0.8: + version "2.13.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" + integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== + +nanoid-dictionary@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nanoid-dictionary/-/nanoid-dictionary-2.0.0.tgz#4e04562622db78171dda7aed0dd808071c7868ac" + integrity sha512-mjMNB0yg2lH6stvxeZs2sBFhwhMwoYdbevPT5DYMxk8iDTI3vAZ6YAvHm+uBFT4VvGITpPpBX1U0sWVRTlPzOA== + +nanoid@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.0.1.tgz#deb55cac196e3f138071911dabbc3726eb048864" + integrity sha512-k1u2uemjIGsn25zmujKnotgniC/gxQ9sdegdezeDiKdkDW56THUMqlz3urndKCXJxA6yPzSZbXx/QCMe/pxqsA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -1711,6 +1754,11 @@ pg-connection-string@2.0.0: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.0.0.tgz#3eefe5997e06d94821e4d502e42b6a1c73f8df82" integrity sha1-Pu/lmX4G2Ugh5NUC5CtqHHP434I= +pg-error-codes@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pg-error-codes/-/pg-error-codes-1.1.0.tgz#6d331d8797954ad55b4c3b631eccc5ca9c8ed563" + integrity sha512-WUN3AlYo7927nyvMEtCXevLRE23E2N3yx8nTb5WWABneG59FEDCzOms+JGki/UvQ6iMsbOZTm32WVp/4ef19eQ== + pg-int8@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" @@ -1962,6 +2010,22 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +scrypt-for-humans@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/scrypt-for-humans/-/scrypt-for-humans-2.0.5.tgz#a5c5dceffe20d62cbb4e972054f63cac65a9926e" + integrity sha1-pcXc7/4g1iy7TpcgVPY8rGWpkm4= + dependencies: + bluebird "^2.6.4" + errors "^0.2.0" + scrypt "^6.0.1" + +scrypt@^6.0.1: + version "6.0.3" + resolved "https://registry.yarnpkg.com/scrypt/-/scrypt-6.0.3.tgz#04e014a5682b53fa50c2d5cce167d719c06d870d" + integrity sha1-BOAUpWgrU/pQwtXM4WfXGcBthw0= + dependencies: + nan "^2.0.8" + semver@4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7"