diff --git a/CHANGELOG.md b/CHANGELOG.md index 823def7de..d2001a6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,23 @@ MAGE adheres to [Semantic Versioning](http://semver.org/). - The `MAGE_MONGO_TLS_INSECURE` env var avoids issues with [self-signed certs](https://github.com/Automattic/mongoose/issues/9147). - [GARS](https://github.com/ngageoint/gars-js) grid overlay - [MGRS](https://github.com/ngageoint/mgrs-js) grid overlay +- Add support for sorting observations by `timestamp`: `GET /api/observations?sort=timestamp+(asc|desc)` ##### Bug fixes - Single observation download bug - Protect against disabling all authentications. - Problem with OAuth web login +##### Benign API Changes +- `/api/events/{eventId}/observations` + - Remove support for `geometry` query parameter + - Remove support for `fields` query parameter +- `/api/events/{eventId}/observations/{observationId}` + - Remove support for unnecessary observation query parameters +- `/api/devices` + - Remove support for `sort` query parameter + - Remove support for `expand` query parameter + ## [6.2.12](https://github.com/ngageoint/mage-server/releases/tag/6.2.12) ### Service #### Security diff --git a/docs/development/mongoose.md b/docs/development/mongoose.md new file mode 100644 index 000000000..57dd5a28b --- /dev/null +++ b/docs/development/mongoose.md @@ -0,0 +1,38 @@ +# Mongoose Development Guidelines and Patterns + +## Types +* DocType vs. Entity +* Entity with JsonObject mapped to DocType causes TS error + _Type instantiation is excessively deep and possibly infinite.ts(2589)_ + because of JsonObject recursive type definition +``` +export type FeedServiceDocument = Omit & { + _id: mongoose.Types.ObjectId + serviceType: mongoose.Types.ObjectId + // config: any +} +export type FeedServiceModel = Model +export const FeedServiceSchema = new mongoose.Schema( + { + serviceType: { type: mongoose.SchemaTypes.ObjectId, required: true, ref: FeedsModels.FeedServiceTypeIdentity }, + title: { type: String, required: true }, + summary: { type: String, required: false }, + config: { type: Object, required: false }, + }, + { + toJSON: { + getters: true, + versionKey: false, + transform: (doc: FeedServiceDocument, json: any & FeedService): void => { + delete json._id + json.serviceType = doc.serviceType.toHexString() + } + } + }) +``` + +``` +var o: ObservationDocument = null +var l: mongoose.LeanDocument = null +o = l +``` \ No newline at end of file diff --git a/docs/domain.md b/docs/domain.md index 8b647c36c..13b3e0a3b 100644 --- a/docs/domain.md +++ b/docs/domain.md @@ -44,6 +44,9 @@ Many MAGE customers have enterprise data sources available to them that are rele ## Core Terms +### User +A user is a human that interacts with the Mage application to support or accomplish a business goal. + ### Feature A feature represents a physical object or occurrence that has spatial and/or temporal attributes. A spatial attribute is the geographic location and shape of a feature and includes single points, (e.g. latitude and longitude), and geographic geometry structures such as lines and polygon shapes. A temporal attribute could be a single instantaneous timestamp (e.g., 1 January 2020 at 10:35:40.555 AM) or a temporal duration (e.g., 2020-01-01 through 2020-01-31). See **feature** from https://www.ogc.org/ogc/glossary/f. @@ -97,13 +100,13 @@ An event is a scope to manage users, the data they collect, and the data they ar An event defines the observation data participants can submit. Events may define one or more forms into which participants enter observation data about a subject. Each form defines one or more form fields of varying types into which a participant enters a data value of the field's type, such as a date, text, number, email, etc. An event may impose validation rules on submitted observations, such as minimum and/or maximum number of entries for a given form. Form fields may impose validation rules on individual data values, such as required vs. optional, minimum and/or maximum numeric values, text input patterns, or allowed attachment media types. ### Participant -A participant is a user that has access to the data associated with a specific event, as well as to submit observations for the event. +A participant is a user that has access to the data associated with a specific event, as well as access to submit observations for the event. ### Field Participant A field participant is a participant of an event that is actively collecting observations for the event using a mobile device. ### Monitor -A monitor is a participant of an event that is not actively collecting data for the event in the field. +A monitor is a user that has access to view data associated with an event, but not to create or modify data for the event. ### Location A location is the reported geospatial position of a field participant. Locations, therefore, only exist within the scope of an event. diff --git a/package.json b/package.json index 4df4a9b00..bec258838 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "version": "6.3.0-beta.6", "files": [], "scripts": { - "postinstall": "npm-run-all service:ci web-app:ci image.service:ci nga-msi:ci", + "install:clean": "npm-run-all service:ci web-app:ci image.service:ci nga-msi:ci", "install:resolve": "npm-run-all service:install web-app:install image.service:install nga-msi:install", "build": "npm-run-all service:build web-app:build image.service:build nga-msi:build instance:build", "pack-all": "npm-run-all service:pack web-app:pack image.service:pack nga-msi:pack", diff --git a/service/functionalTests/security/devices.security.test.ts b/service/functionalTests/security/devices.security.test.ts new file mode 100644 index 000000000..dc3684d2b --- /dev/null +++ b/service/functionalTests/security/devices.security.test.ts @@ -0,0 +1,39 @@ +import { expect } from 'chai' + +describe('device management security', function() { + + describe('removing a device', function() { + + it('invalidates associated sessions', async function() { + expect.fail('todo') + }) + + it('prevents the owning user from authenticating with the device', async function() { + expect.fail('todo') + }) + }) + + /** + * AKA, set `registered` to `false`. + */ + describe('disabling a device', function() { + + it('invalidates associated sessions', async function() { + expect.fail('todo') + }) + + it('prevents the owning user from authenticating with the device', async function() { + expect.fail('todo') + }) + }) + + /** + * AKA, approving; set `registered` to `true`. + */ + describe('enabling', function() { + + it('allows the owning user to authenticate with the device', async function() { + expect.fail('todo') + }) + }) +}) \ No newline at end of file diff --git a/service/functionalTests/security/users.security.test.ts b/service/functionalTests/security/users.security.test.ts new file mode 100644 index 000000000..ab88b229d --- /dev/null +++ b/service/functionalTests/security/users.security.test.ts @@ -0,0 +1,15 @@ +import { expect } from 'chai' + +describe('user management security', function() { + + describe('disabling a user account', function() { + + it('prevents the user from authenticating', async function() { + expect.fail('todo') + }) + + it('invalidates associated sessions', async function() { + expect.fail('todo') + }) + }) +}) \ No newline at end of file diff --git a/service/npm-shrinkwrap.json b/service/npm-shrinkwrap.json index a82d630e5..85cd33e8a 100644 --- a/service/npm-shrinkwrap.json +++ b/service/npm-shrinkwrap.json @@ -73,6 +73,7 @@ "@fluffy-spoon/substitute": "^1.196.0", "@types/archiver": "^5.3.4", "@types/async": "^3.0.5", + "@types/base-64": "^1.0.2", "@types/bson": "^1.0.11", "@types/busboy": "^1.5.0", "@types/chai": "^4.2.19", @@ -82,12 +83,17 @@ "@types/express-serve-static-core": "~4.17.0", "@types/fs-extra": "^8.0.1", "@types/json2csv": "~4.5.0", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.6", "@types/mocha": "^7.0.2", "@types/multer": "^1.4.7", "@types/node": "^18.18.4", "@types/node-fetch": "^2.5.4", "@types/passport": "^1.0.3", + "@types/passport-http-bearer": "^1.0.41", + "@types/passport-local": "^1.0.38", + "@types/passport-oauth2": "^1.4.17", + "@types/passport-openidconnect": "^0.1.3", "@types/sinon": "^9.0.4", "@types/sinon-chai": "^3.2.4", "@types/superagent": "^8.1.3", @@ -2436,6 +2442,15 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/archiver": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.4.tgz", @@ -2451,6 +2466,12 @@ "integrity": "sha512-8iHVLHsCCOBKjCF2KwFe0p9Z3rfM9mL+sSP8btyR5vTjJRAqpBYD28/ZLgXPf0pjG1VxOvtCV/BgXkQbpSe8Hw==", "dev": true }, + "node_modules/@types/base-64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/base-64/-/base-64-1.0.2.tgz", + "integrity": "sha512-uPgKMmM9fmn7I+Zi6YBqctOye4SlJsHKcisjHIMWpb2YKZRc36GpKyNuQ03JcT+oNXg1m7Uv4wU94EVltn8/cw==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -2501,12 +2522,30 @@ "@types/node": "*" } }, + "node_modules/@types/content-disposition": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.8.tgz", + "integrity": "sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==", + "dev": true + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cookies": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.0.tgz", + "integrity": "sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2557,6 +2596,12 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" }, + "node_modules/@types/http-assert": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.5.tgz", + "integrity": "sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -2576,6 +2621,46 @@ "@types/node": "*" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true + }, + "node_modules/@types/koa": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.15.0.tgz", + "integrity": "sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==", + "dev": true, + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.8.tgz", + "integrity": "sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==", + "dev": true, + "dependencies": { + "@types/koa": "*" + } + }, "node_modules/@types/ldapjs": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-2.2.5.tgz", @@ -2644,6 +2729,15 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/oauth": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.5.tgz", + "integrity": "sha512-+oQ3C2Zx6ambINOcdIARF5Z3Tu3x//HipE889/fqo3sgpQZbe9c6ExdQFtN6qlhpR7p83lTZfPJt0tCAW29dog==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.16.tgz", @@ -2652,6 +2746,51 @@ "@types/express": "*" } }, + "node_modules/@types/passport-http-bearer": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/@types/passport-http-bearer/-/passport-http-bearer-1.0.41.tgz", + "integrity": "sha512-ecW+9e8C+0id5iz3YZ+uIarsk/vaRPkKSajt1i1Am66t0mC9gDfQDKXZz9fnPOW2xKUufbmCSou4005VM94Feg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/koa": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-oauth2": { + "version": "1.4.17", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz", + "integrity": "sha512-ODiAHvso6JcWJ6ZkHHroVp05EHGhqQN533PtFNBkg8Fy5mERDqsr030AX81M0D69ZcaMvhF92SRckEk2B0HYYg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/passport-openidconnect": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@types/passport-openidconnect/-/passport-openidconnect-0.1.3.tgz", + "integrity": "sha512-k1Ni7bG/9OZNo2Qpjg2W6GajL+pww6ZPaNWMXfpteCX4dXf4QgaZLt2hjR5IiPrqwBT9+W8KjCTJ/uhGIoBx/g==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", diff --git a/service/package.json b/service/package.json index b2c1794da..454509018 100644 --- a/service/package.json +++ b/service/package.json @@ -90,6 +90,7 @@ "@fluffy-spoon/substitute": "^1.196.0", "@types/archiver": "^5.3.4", "@types/async": "^3.0.5", + "@types/base-64": "^1.0.2", "@types/bson": "^1.0.11", "@types/busboy": "^1.5.0", "@types/chai": "^4.2.19", @@ -99,12 +100,17 @@ "@types/express-serve-static-core": "~4.17.0", "@types/fs-extra": "^8.0.1", "@types/json2csv": "~4.5.0", + "@types/jsonwebtoken": "^9.0.6", "@types/lodash": "^4.17.6", "@types/mocha": "^7.0.2", "@types/multer": "^1.4.7", "@types/node": "^18.18.4", "@types/node-fetch": "^2.5.4", "@types/passport": "^1.0.3", + "@types/passport-http-bearer": "^1.0.41", + "@types/passport-local": "^1.0.38", + "@types/passport-oauth2": "^1.4.17", + "@types/passport-openidconnect": "^0.1.3", "@types/sinon": "^9.0.4", "@types/sinon-chai": "^3.2.4", "@types/superagent": "^8.1.3", diff --git a/service/src/@types/express/index.d.ts b/service/src/@types/express/index.d.ts index 8cb01edd7..8c35753ce 100644 --- a/service/src/@types/express/index.d.ts +++ b/service/src/@types/express/index.d.ts @@ -1,9 +1,30 @@ -import { UserDocument } from '../../models/user' +import { UserExpanded } from '../../entities/users/entities.users' +import { Session } from '../../ingress/ingress.entities' + + +export type AdmittedWebUser = { + account: UserExpanded + session: Session +} + +declare global { + namespace Express { + interface User { + /** + * Mage populates the `admitted` user property when the user completes the ingress authentication flow through an + * identity provider and establishes a session. + */ + admitted?: AdmittedWebUser + } + } +} declare module 'express-serve-static-core' { export interface Request { - user: UserDocument & any - provisionedDeviceId: string + token?: string + // TODO: users-next: reconcile these two device properties and change to device entity + provisionedDevice?: any + provisionedDeviceId?: string /** * Return the root HTTP URL of the server, including the scheme, e.g., * `https://mage.io`. diff --git a/service/src/@types/mongodb-migrations/index.d.ts b/service/src/@types/mongodb-migrations/index.d.ts index 0610c1783..060e96c84 100644 --- a/service/src/@types/mongodb-migrations/index.d.ts +++ b/service/src/@types/mongodb-migrations/index.d.ts @@ -2,7 +2,7 @@ declare module '@ngageoint/mongodb-migrations' { - import { MongoClientOptions } from 'mongodb' + import { MongoClientOptions, Db } from 'mongodb' export { MongoClientOptions } from 'mongodb' export class Migrator { @@ -74,10 +74,15 @@ declare module '@ngageoint/mongodb-migrations' { options?: MongoClientOptions } + export type MigrationContext = { + db: Db + log: Console['log'] + } + export interface Migration { id: string - up?: (callback: (err: any) => any) => any - down?: (callback: (err: any) => any) => any + up?: (this: MigrationContext, callback: (err: any) => any) => any + down?: (this: MigrationContext, callback: (err: any) => any) => any } export interface MigrationResult { diff --git a/service/src/access/index.ts b/service/src/access/index.ts index 4b1934c33..8d0a3dd01 100644 --- a/service/src/access/index.ts +++ b/service/src/access/index.ts @@ -4,8 +4,7 @@ import express from 'express' import { AnyPermission } from '../entities/authorization/entities.permissions' -import { RoleDocument } from '../models/role' -import { UserDocument } from '../models/user' +import { UserExpanded } from '../entities/users/entities.users' export = Object.freeze({ @@ -20,27 +19,20 @@ export = Object.freeze({ */ authorize(permission: AnyPermission): express.RequestHandler { return function(req, res, next): any { - if (!req.user) { + if (!req.user?.admitted) { return next() } - const role = req.user.roleId as RoleDocument - if (!role) { - return res.sendStatus(403) - } - const userPermissions = role.permissions - const ok = userPermissions.indexOf(permission) !== -1 - if (!ok) { - return res.sendStatus(403) + const userPermissions = req.user.admitted.account.role.permissions + if (userPermissions.includes(permission)) { + return next() } - next() + return res.sendStatus(403) } }, - userHasPermission(user: UserDocument, permission: AnyPermission) { - if (!user || !user.roleId) { - return false - } - const role = user.roleId as RoleDocument - return role.permissions.indexOf(permission) !== -1 + // TODO: users-next + userHasPermission(user: UserExpanded | null | undefined, permission: AnyPermission): boolean { + const role = user?.role + return role ? role.permissions.indexOf(permission) !== -1 : false } }) diff --git a/service/src/adapters/adapters.controllers.web.ts b/service/src/adapters/adapters.controllers.web.ts index 3f95dd7ba..b9af0ea54 100644 --- a/service/src/adapters/adapters.controllers.web.ts +++ b/service/src/adapters/adapters.controllers.web.ts @@ -3,7 +3,7 @@ import { ErrEntityNotFound, ErrInfrastructure, ErrInvalidInput, ErrPermissionDen import { AppRequest } from '../app.api/app.api.global' export interface WebAppRequestFactory { - (webReq: express.Request, params?: RequestParams): Req & RequestParams + = Record>(webReq: express.Request, params?: RequestParams): Req & RequestParams } /** diff --git a/service/src/adapters/base/adapters.base.db.mongoose.ts b/service/src/adapters/base/adapters.base.db.mongoose.ts index 2df137d0d..f9ac63082 100644 --- a/service/src/adapters/base/adapters.base.db.mongoose.ts +++ b/service/src/adapters/base/adapters.base.db.mongoose.ts @@ -3,75 +3,87 @@ import { PagingParameters } from '../../entities/entities.global' type EntityReference = { id: string | number } +export type MongooseDefaultVersionKey = '__v' +export const MongooseDefaultVersionKey: MongooseDefaultVersionKey = '__v' +export type WithMongooseDefaultVersionKey = { [MongooseDefaultVersionKey]: number } + /** * Map Mongoose `Document` instances to plain entity objects. */ -export type DocumentMapping = (doc: D) => E +export type DocumentMapping = (doc: DocType | mongoose.HydratedDocument | mongoose.LeanDocument) => E /** - * Map entities to objects suitable to create Mongoose `Document` instances, as + * Map entities to objects suitable to create Mongoose `Model` `Document` instances, as * in `new mongoose.Model(stub)`. */ -export type EntityMapping = (entity: Partial) => any +export type EntityMapping = (entity: Partial) => Partial /** - * Return a document mapping that calls `toJSON()` on the given `Document` + * Return a document mapping that calls {@link mongoose.Document.toObject()} on the given `Document` * instance and returns the result. */ -export function createDefaultDocMapping(): DocumentMapping { - return (d): any => d.toJSON() +export function createDefaultDocMapping(): DocumentMapping { + return d => { + if (d instanceof mongoose.Document) { + return d.toObject() + } + return d + } } /** * Return an entity mapping that simply returns the given entity object as is. */ -export function createDefaultEntityMapping(): EntityMapping { +export function createDefaultEntityMapping(): EntityMapping { return e => e as any } /** - * * Type parameter `D` is a subtype of `mongoose.Document` - * * Type parameter `M` is a subtpye of `mongoose.Model` that creates - * instances of type `D`. - * * Type parameter `Attrs` is the entity attributes type, which is typically a - * plain object interface, and is the type that repository queries return - * using `entityForDocuent()`. - * * Type parameter `Entity` is an optional, typically more objected-oriented - * entity type that provides extra functionality beyond just the raw data - * of the `Attrs` type. + * * Type parameter `DocType` is the shape of the raw document the MongoDB driver stores and retrieves. + * * Type parameter `Model` is a subtpye of `mongoose.Model` that creates instances of type `D`. + * * Type parameter `Entity` is the entity attributes type, which is typically a plain object interface, + * or an instantiable class and is the type that repository queries return using `entityForDocument()`. */ -export class BaseMongooseRepository, Attrs extends object, Entity extends object = Attrs> { +export class BaseMongooseRepository = mongoose.Model, Entity extends object = DocType> { - readonly model: M - readonly entityForDocument: DocumentMapping - readonly documentStubForEntity: EntityMapping + readonly model: Model + readonly entityForDocument: DocumentMapping + readonly documentStubForEntity: EntityMapping - constructor(model: M, mapping?: { docToEntity?: DocumentMapping, entityToDocStub?: EntityMapping }) { + /** + * When the caller omits `docToEntity` and/or `entityToDocStub` + */ + constructor(model: Model, mapping?: { docToEntity?: DocumentMapping, entityToDocStub?: EntityMapping }) { this.model = model this.entityForDocument = mapping?.docToEntity || createDefaultDocMapping() this.documentStubForEntity = mapping?.entityToDocStub || createDefaultEntityMapping() } - async create(attrs: Partial): Promise { + async create(attrs: Partial): Promise { const stub = this.documentStubForEntity(attrs) const created = await this.model.create(stub) return this.entityForDocument(created) } - async findAll(): Promise { + async findAll(): Promise { const docs = await this.model.find().cursor() - const entities: Attrs[] = [] + const entities: Entity[] = [] for await (const doc of docs) { entities.push(this.entityForDocument(doc)) } return entities } - async findById(id: any): Promise { + async findById(id: any): Promise { const doc = await this.model.findById(id) return doc ? this.entityForDocument(doc) : null as any } - async findAllByIds(ids: ID[]): Promise { + /** + * _NOTE_ this currently only explicitly supports 'string' and 'number' IDs, though Mongoose will implicitly + * [cast](https://mongoosejs.com/docs/6.x/docs/tutorials/query_casting.html) a filter's `_id` entry to `ObjectID` + * before sending the query through the driver. + */ + async findAllByIds(ids: ID[]): Promise { if (!ids.length) { return {} as any } @@ -79,16 +91,23 @@ export class BaseMongooseRepository & EntityReference): Promise { + /** + * Use the {@link BaseMongooseRepository.documentStubForEntity entity mapping} to convert the given entity to a + * MongoDB document, and use that document to {@link mongoose.Document.set() set} the entries on the existing + * document. To remove an entry from the persisted document, set the key's value to `undefined` in the given input + * document. For example, `repo.update({ id: 'asdf0987', deleteThisKey: undefined })`. + */ + async update(attrs: Partial & EntityReference): Promise { let doc = (await this.model.findById(attrs.id)) if (!doc) { throw new Error(`document not found for id: ${attrs.id}`) @@ -99,7 +118,7 @@ export class BaseMongooseRepository { + async removeById(id: any): Promise { const doc = await this.model.findByIdAndRemove(id) if (doc) { return this.entityForDocument(doc) @@ -108,9 +127,14 @@ export class BaseMongooseRepository(query: mongoose.Query, paging: PagingParameters): Promise<{ totalCount: number | null, query: mongoose.Query }> => { - //TODO had to use any to construct - const BaseQuery: any = query.toConstructor() + const BaseQuery = query.toConstructor() const pageQuery = new BaseQuery().limit(paging.pageSize).skip(paging.pageIndex * paging.pageSize) as mongoose.Query const includeTotalCount = typeof paging.includeTotalCount === 'boolean' ? paging.includeTotalCount : paging.pageIndex === 0 if (includeTotalCount) { diff --git a/service/src/adapters/devices/adapters.devices.controllers.web.ts b/service/src/adapters/devices/adapters.devices.controllers.web.ts new file mode 100644 index 000000000..b14d6cba8 --- /dev/null +++ b/service/src/adapters/devices/adapters.devices.controllers.web.ts @@ -0,0 +1,225 @@ +import express from 'express' +import { entityNotFound, invalidInput } from '../../app.api/app.api.errors' +import { DevicePermissionService } from '../../app.api/devices/app.api.devices' +import { Device, DeviceRepository, FindDevicesSpec } from '../../entities/devices/entities.devices' +import { PageOf, PagingParameters } from '../../entities/entities.global' +import { User, UserFindParameters, UserRepository } from '../../entities/users/entities.users' +import { SessionRepository } from '../../ingress/ingress.entities' +import { compatibilityMageAppErrorHandler, WebAppRequestFactory } from '../adapters.controllers.web' + + +export function DeviceRoutes(deviceRepo: DeviceRepository, userRepo: UserRepository, sessionRepo: SessionRepository, permissions: DevicePermissionService, createAppContext: WebAppRequestFactory): express.Router { + + const deviceResource = express.Router() + const ensurePermission = PermissionMiddleware(permissions, createAppContext) + + deviceResource.get('/count', + ensurePermission.create, + async (req, res, next) => { + const findSpec = parseDeviceFindSpec(req) + try { + const count = await deviceRepo.countSome(findSpec) + return res.json({ count }) + } + catch (err) { + next(err) + } + } + ) + + // TODO: check for READ_USER also + // ... meh + deviceResource.route('/:id') + .get( + ensurePermission.read, + async (req, res, next) => { + const id = req.params.id + try { + const device = deviceRepo.findById(id) + if (device) { + return res.json(device) + } + } + catch (err) { + next(err) + } + } + ) + .put( + ensurePermission.update, + express.urlencoded(), + async (req, res, next) => { + const idInPath = req.params.id + const update = parseDeviceAttributes(req) + // TODO: if request is marking registered false, remove associated sessions like mongoose middleware + if (typeof update.id === 'string' && update.id !== idInPath) { + return next(invalidInput(`body id ${update.id} does not match id in path ${idInPath}`)) + } + try { + if (update.registered === false) { + console.info(`update device ${idInPath} to unregistered`) + const sessionsRemovedCount = await sessionRepo.deleteSessionsForDevice(idInPath) + console.info(`removed ${sessionsRemovedCount} session(s) for device ${idInPath}`) + } + const updated = await deviceRepo.update({ ...update, id: idInPath }) + if (updated) { + return res.json(updated) + } + return next(entityNotFound(idInPath, 'Device')) + } + catch (err) { + next(err) + } + } + ) + .delete( + ensurePermission.delete, + async (req, res, next) => { + try { + const idInPath = req.params.id + console.info(`delete device`, idInPath) + const deleted = await deviceRepo.removeById(idInPath) + const removedSessionsCount = sessionRepo.deleteSessionsForDevice(idInPath) + console.info(`removed ${removedSessionsCount} session(s) for device ${idInPath}`) + // TODO: the old observation model had a middleware that removed the device id from created observations, + // but do we really care that much + if (deleted) { + return res.json(deleted) + } + return next(entityNotFound(idInPath, 'Device')) + } + catch (err) { + next(err) + } + } + ) + + deviceResource.route('/') + .post( + ensurePermission.create, + express.urlencoded(), + async (req, res, next) => { + const attrs = parseDeviceAttributes(req) + if (typeof attrs.uid !== 'string') { + return res.status(400).send('missing uid') + } + if (typeof attrs.registered !== 'boolean') { + attrs.registered = false + } + try { + const device = await deviceRepo.create(attrs as Device) + return res.status(201).json(device) + } + catch (err) { + next(err) + } + } + ) + .get( + ensurePermission.read, + async (req, res, next) => { + const findDevices = { ...parseDeviceFindSpec(req), expandUser: true } as FindDevicesSpec & { expandUser: true } + const userSearch: UserFindParameters | null = typeof findDevices.where.containsSearchTerm === 'string' ? + { nameOrContactTerm: findDevices.where.containsSearchTerm, pageIndex: 0, pageSize: Number.MAX_SAFE_INTEGER } : + null + try { + // TODO: would this user query be necessary if the web app's user page just showed the user's devices? + const usersPage: PageOf = userSearch ? + await userRepo.find(userSearch) : + { pageIndex: 0, pageSize: 0, totalCount: null, items: [] } + const userIds = usersPage.items.map(x => x.id) + // TODO: hope user id list is not too big + if (userIds.length) { + findDevices.where.userIdIsAnyOf = userIds + } + const devicesPage = await deviceRepo.findSome(findDevices) + const { items, ...paging } = devicesPage + const compatibilityDevicesPage = { + count: paging.totalCount, + // web app really only uses the links entry + paginInfo: paging, + devices: items + } + return res.json(compatibilityDevicesPage) + } + catch (err) { + next(err) + } + } + ) + + return deviceResource.use(compatibilityMageAppErrorHandler) +} + + + +interface PermissionMiddleware { + create: express.RequestHandler + read: express.RequestHandler + update: express.RequestHandler + delete: express.RequestHandler +} + +function PermissionMiddleware(permissionService: DevicePermissionService, createAppContext: WebAppRequestFactory): PermissionMiddleware { + return { + async create(req, res, next): Promise { + next(await permissionService.ensureCreateDevicePermission(createAppContext(req))) + }, + async read(req, res, next): Promise { + next(await permissionService.ensureReadDevicePermission(createAppContext(req))) + }, + async update(req, res, next): Promise { + next(await permissionService.ensureUpdateDevicePermission(createAppContext(req))) + }, + async delete(req, res, next): Promise { + next(await permissionService.ensureDeleteDevicePermission(createAppContext(req))) + } + } +} + +function parseDeviceAttributes(req: express.Request): Partial { + const { uid, name, description, userId, registered } = req.body + const attrs: Record = {} + if (typeof uid === 'string') { + attrs.uid = uid + } + if (typeof name === 'string') { + attrs.name = name + } + if (typeof description === 'string') { + attrs.description = description + } + if (typeof userId === 'string') { + attrs.userId = userId + } + if (typeof registered === 'boolean') { + attrs.registered = registered + } + return attrs +} + +function parseDeviceFindSpec(req: express.Request): FindDevicesSpec { + const { registered, search, limit, start } = req.query + const filter: FindDevicesSpec['where'] = {} + if (typeof registered === 'string') { + const lowerRegistered = registered.toLowerCase() + if (lowerRegistered === 'true') { + filter.registered = true + } + else if (lowerRegistered === 'false') { + filter.registered = false + } + } + if (typeof search === 'string' && search.length > 0) { + filter.containsSearchTerm = search + } + const limitParsed = parseInt(String(limit)) || Number.MAX_SAFE_INTEGER + const startParsed = parseInt(String(start)) || 0 + const paging: PagingParameters = { + pageIndex: startParsed, + pageSize: limitParsed, + } + const spec: FindDevicesSpec = { where: filter, paging } + return spec +} + diff --git a/service/src/adapters/devices/adapters.devices.db.mongoose.ts b/service/src/adapters/devices/adapters.devices.db.mongoose.ts new file mode 100644 index 000000000..1f22b01ed --- /dev/null +++ b/service/src/adapters/devices/adapters.devices.db.mongoose.ts @@ -0,0 +1,111 @@ +import _ from 'lodash' +import mongoose from 'mongoose' +import { Device, DeviceExpanded, DeviceRepository, DeviceUid, FindDevicesSpec } from '../../entities/devices/entities.devices' +import { pageOf, PageOf } from '../../entities/entities.global' +import { BaseMongooseRepository, DocumentMapping, pageQuery } from '../base/adapters.base.db.mongoose' +import { UserDocument } from '../users/adapters.users.db.mongoose' + +const Schema = mongoose.Schema + +export type DeviceDocument = Omit & { + _id: mongoose.Types.ObjectId + userId: mongoose.Types.ObjectId | null +} +export type DeviceDocumentPopulated = Omit & { + userId: { _id: UserDocument['_id'], displayName?: UserDocument['displayName'] | null } +} +export type DeviceModel = mongoose.Model + +// TODO cascade delete from userId when user is deleted. +export const DeviceSchema = new Schema( + { + uid: { type: String, required: true, unique: true, lowercase: true }, + description: { type: String, required: false }, + registered: { type: Boolean, required: true, default: false }, + userId: { type: Schema.Types.ObjectId, ref: 'User' }, + userAgent: { type: String, required: false }, + appVersion: { type: String, required: false } + }, + { + versionKey: false + } +) + +export const docToEntity: DocumentMapping = function docToEntity(doc) { + const baseEntity: Partial = { + id: doc._id.toHexString(), + uid: doc.uid, + registered: doc.registered, + userAgent: doc.userAgent, + appVersion: doc.appVersion, + description: doc.description, + } + const userAttrs = doc.userId + if (userAttrs instanceof mongoose.Types.ObjectId) { + baseEntity.userId = userAttrs.toHexString() + } + else if (userAttrs) { + baseEntity.userId = userAttrs._id.toHexString() + baseEntity.user = userAttrs.displayName ? { + id: userAttrs._id.toHexString(), + displayName: userAttrs.displayName + } : null + } + return baseEntity as Device +} + +export class DeviceMongooseRepository extends BaseMongooseRepository implements DeviceRepository { + + constructor(model: DeviceModel) { + super(model, { docToEntity }) + } + + async findByUid(uid: DeviceUid): Promise { + return await this.model.findOne({ uid }).then(x => x ? docToEntity(x) : null) + } + + async findSome(findSpec: FindDevicesSpec): Promise> { + const filter = dbFilterForFindSpec(findSpec) + const baseQuery = this.model.find(filter, null, { sort: 'userAgent _id' }).lean() + const maybePopulateQuery = findSpec.expandUser ? + baseQuery.populate({ + path: 'userId', select: 'displayName' , + transform: (doc, _id) => (doc ? doc : { _id }) + }) : + baseQuery + const counted = await pageQuery(maybePopulateQuery, findSpec.paging) + const devices = Array.prototype.map.call(await counted.query, docToEntity) as DeviceExpanded[] + return pageOf(devices, findSpec.paging, counted.totalCount) + } + + async countSome(findSpec: FindDevicesSpec): Promise { + const filter = dbFilterForFindSpec(findSpec) + return await this.model.count(filter) + } +} + +function dbFilterForFindSpec(findSpec: FindDevicesSpec): mongoose.FilterQuery { + const filter: mongoose.FilterQuery = {} + const where = findSpec.where + if (typeof where.containsSearchTerm === 'string') { + const searchRegex = new RegExp(_.escapeRegExp(where.containsSearchTerm), 'i') + filter.$or = [ + { uid: searchRegex }, + { userAgent: searchRegex }, + { description: searchRegex }, + ] + } + if (Array.isArray(where.userIdIsAnyOf) && where.userIdIsAnyOf.length) { + const userIdFilter = { $in: where.userIdIsAnyOf } + if (Array.isArray(filter.$or)) { + filter.$or.push({ userId: userIdFilter }) + } + else { + filter.userId = userIdFilter + } + } + if (typeof where.registered === 'boolean') { + filter.registered = where.registered + } + return filter +} \ No newline at end of file diff --git a/service/src/routes/events.ts b/service/src/adapters/events/adapters.events.controllers.web.legacy.ts similarity index 90% rename from service/src/routes/events.ts rename to service/src/adapters/events/adapters.events.controllers.web.legacy.ts index 1b5b1e3fe..df85e3f66 100644 --- a/service/src/routes/events.ts +++ b/service/src/adapters/events/adapters.events.controllers.web.legacy.ts @@ -1,26 +1,26 @@ const api = require('../api') -const userTransformer = require('../transformers/user') import async from 'async' import util from 'util' import fileType from 'file-type' -import EventModel, { FormDocument, MageEventDocument } from '../models/event' +import mongoose from 'mongoose' +import EventModel, { FormDocument, MageEventDocument } from '../../models/event' import express from 'express' -import access from '../access' -import { AnyPermission, MageEventPermission } from '../entities/authorization/entities.permissions' -import { JsonObject } from '../entities/entities.json_types' -import authentication from '../authentication' +import access from '../../access' +import { AnyPermission, MageEventPermission } from '../../entities/authorization/entities.permissions' +import { JsonObject } from '../../entities/entities.json_types' import fs from 'fs-extra' -import { EventAccessType, MageEvent } from '../entities/events/entities.events' -import { defaultHandler as upload } from '../upload' -import { defaultEventPermissionsService } from '../permissions/permissions.events' -import { LineStyle, PagingParameters } from '../entities/entities.global' +import { EventAccessType, MageEvent } from '../../entities/events/entities.events' +import { defaultHandler as upload } from '../../upload' +import { defaultEventPermissionsService } from '../../permissions/permissions.events' +import { LineStyle, PagingParameters } from '../../entities/entities.global' +import { UserId } from '../../entities/users/entities.users' declare module 'express-serve-static-core' { export interface Request { - event?: EventModel.MageEventDocument + event?: EventModel.MageEventModelInstance eventEntity?: MageEvent - access?: { user: express.Request['user'], permission: EventAccessType } + access?: { userId: UserId, permission: EventAccessType } parameters?: EventQueryParams form?: FormJson team?: any @@ -28,10 +28,11 @@ declare module 'express-serve-static-core' { } function determineReadAccess(req: express.Request, res: express.Response, next: express.NextFunction): void { - if (!access.userHasPermission(req.user, MageEventPermission.READ_EVENT_ALL)) { - req.access = { user: req.user, permission: EventAccessType.Read }; + const requestingUser = req.user?.admitted ? req.user.admitted.account : null + if (requestingUser && !access.userHasPermission(requestingUser, MageEventPermission.READ_EVENT_ALL)) { + req.access = { userId: requestingUser.id, permission: EventAccessType.Read } } - next(); + next() } /** @@ -42,10 +43,10 @@ function determineReadAccess(req: express.Request, res: express.Response, next: function middlewareAuthorizeAccess(collectionPermission: AnyPermission, aclPermission: EventAccessType): express.RequestHandler { return async (req, res, next) => { const denied = await defaultEventPermissionsService.authorizeEventAccess(req.event!, req.user, collectionPermission, aclPermission) - if (!denied) { - return next() + if (denied) { + return res.sendStatus(403) } - return res.sendStatus(403) + next() } } @@ -167,10 +168,12 @@ function reduceStyle(style: any): LineStyle { } +/** + * TODO: users-next: replace old authentication reference + */ +function EventRoutes(): express.Router { -function EventRoutes(app: express.Application, security: { authentication: authentication.AuthLayer }): void { - - const passport = security.authentication.passport; + const app = express.Router() /* TODO: this just sends whatever is in the body straight through the API level @@ -180,7 +183,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe */ app.post( '/api/events', - passport.authenticate('bearer'), access.authorize(MageEventPermission.CREATE_EVENT), function(req, res, next) { new api.Event().createEvent(req.body, req.user, function(err: any, event: MageEventDocument) { @@ -194,7 +196,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events', - passport.authenticate('bearer'), determineReadAccess, parseEventQueryParams, function (req, res, next) { @@ -217,7 +218,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/count', - passport.authenticate('bearer'), determineReadAccess, function(req, res, next) { EventModel.count({access: req.access}, function(err, count) { @@ -230,7 +230,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), determineReadAccess, parseEventQueryParams, @@ -248,13 +247,13 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.put( '/api/events/:eventId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { new api.Event(req.event).updateEvent(req.body, {}, function(err: any, event: MageEventDocument) { if (err) { return next(err); } + // TODO: scale: should avoid this for large user sets new api.Form(event).populateUserFields(function(err: any) { if (err) { return next(err); @@ -267,7 +266,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.delete( '/api/events/:eventId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.DELETE_EVENT, EventAccessType.Delete), function(req, res, next) { new api.Event(req.event).deleteEvent(function(err: any) { @@ -279,7 +277,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.post( '/api/events/:eventId/forms', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), upload.single('form'), function(req, res, next) { @@ -321,12 +318,11 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.post( '/api/events/:eventId/forms', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), parseForm, function(req, res, next) { const form = req.form; - new api.Event(req.event).addForm(form, function(err: any, form: FormDocument) { + new api.Event(req.event).addForm(form, function(err: any, form: mongoose.HydratedDocument) { if (err) return next(err); async.parallel([ @@ -350,7 +346,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.put( '/api/events/:eventId/forms/:formId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), parseForm, function(req, res, next) { @@ -374,7 +369,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe // TODO: why not /api/events/:eventId/forms/:formId.zip? app.get( '/api/events/:eventId/:formId/form.zip', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function(req, res, next) { new api.Form(req.event).export(parseInt(req.params.formId, 10), function(err: any, form: any) { @@ -389,7 +383,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId/form/icons.zip', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function(req, res) { new api.Icon(req.event!._id).getZipPath(function(err: any, zipPath: string) { @@ -407,7 +400,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe // the eventId parameter is not even used. app.get( '/api/events/:eventId/form/icons*', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function(req, res) { res.sendFile(api.Icon.defaultIconPath); @@ -416,7 +408,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId/icons/:formId.json', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function(req, res, next) { new api.Icon(req.event!._id, req.params.formId).getIcons(function(err: any, icons: any) { @@ -457,7 +448,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe // TODO: should be PUT? app.post( '/api/events/:eventId/icons/:formId?/:primary?/:variant?', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), upload.single('icon'), function(req, res, next) { @@ -473,7 +463,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe // get icon app.get( '/api/events/:eventId/icons/:formId?/:primary?/:variant?', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function(req, res, next) { new api.Icon(req.event!._id, req.params.formId, req.params.primary, req.params.variant).getIcon(function(err: any, icon: any) { @@ -507,7 +496,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe // Delete an icon app.delete( '/api/events/:eventId/icons/:formId?/:primary?/:variant?', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { new api.Icon(req.event!._id, req.params.formId, req.params.primary, req.params.variant).delete(function(err: any) { @@ -520,7 +508,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.post( '/api/events/:eventId/layers', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.addLayer(req.event!, req.body, function(err: any, event?: MageEventDocument) { @@ -534,7 +521,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.delete( '/api/events/:eventId/layers/:id', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.removeLayer(req.event!, {id: req.params.id}, function(err: any, event?: MageEventDocument) { @@ -548,7 +534,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId/users', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), determineReadAccess, function (req, res, next) { @@ -564,7 +549,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.post( '/api/events/:eventId/teams', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.addTeam(req.event!, req.body, function(err, event) { @@ -578,7 +562,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.delete( '/api/events/:eventId/teams/:teamId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.removeTeam(req.event!, req.team, function(err, event) { @@ -592,7 +575,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.put( '/api/events/:eventId/acl/:targetUserId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.updateUserInAcl(req.event!._id, req.params.targetUserId, req.body.role, function(err, event) { @@ -606,7 +588,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.delete( '/api/events/:eventId/acl/:targetUserId', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.UPDATE_EVENT, EventAccessType.Update), function(req, res, next) { EventModel.removeUserFromAcl(req.event!._id, req.params.targetUserId, function(err, event) { @@ -620,7 +601,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:id/members', - passport.authenticate('bearer'), determineReadAccess, function (req, res, next) { const options = { @@ -641,7 +621,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:id/nonMembers', - passport.authenticate('bearer'), determineReadAccess, function (req, res, next) { const options = { @@ -674,7 +653,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId/teams', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function (req, res, next) { const options = teamQueryOptionsFromRequest(req) @@ -694,7 +672,6 @@ function EventRoutes(app: express.Application, security: { authentication: authe app.get( '/api/events/:eventId/nonTeams', - passport.authenticate('bearer'), middlewareAuthorizeAccess(MageEventPermission.READ_EVENT_ALL, EventAccessType.Read), function (req, res, next) { const options = teamQueryOptionsFromRequest(req) @@ -706,6 +683,8 @@ function EventRoutes(app: express.Application, security: { authentication: authe }).catch(err => next(err)); } ); + + return app } export = EventRoutes diff --git a/service/src/adapters/events/adapters.events.db.mongoose.ts b/service/src/adapters/events/adapters.events.db.mongoose.ts index ba37e545f..728e4a164 100644 --- a/service/src/adapters/events/adapters.events.db.mongoose.ts +++ b/service/src/adapters/events/adapters.events.db.mongoose.ts @@ -1,9 +1,8 @@ -import { BaseMongooseRepository } from '../base/adapters.base.db.mongoose' +import { BaseMongooseRepository, DocumentMapping } from '../base/adapters.base.db.mongoose' import { MageEventRepository, MageEventAttrs, MageEventId, MageEvent } from '../../entities/events/entities.events' import mongoose from 'mongoose' import { FeedId } from '../../entities/feeds/entities.feeds' import * as legacy from '../../models/event' -import { Team } from '../../entities/teams/entities.teams' export const MageEventModelName = 'Event' @@ -11,34 +10,38 @@ export type MageEventDocument = legacy.MageEventDocument export type MageEventModel = mongoose.Model export const MageEventSchema = legacy.Model.schema -export class MongooseMageEventRepository extends BaseMongooseRepository implements MageEventRepository { +const docToEntity: DocumentMapping = doc => { + if (doc instanceof mongoose.Document) { + return new MageEvent(doc.toJSON()) + } + // TODO: might need to implement this + throw new Error('event document mapping only support hydrated model instances') +} + +export class MongooseMageEventRepository extends BaseMongooseRepository implements MageEventRepository { constructor(model: MageEventModel) { - super(model) + super(model, { docToEntity }) } - async create(): Promise { + async create(): Promise { throw new Error('method not allowed') } - async update(attrs: Partial & { id: MageEventId }): Promise { + async update(attrs: Partial & { id: MageEventId }): Promise { throw new Error('method not allowed') } async findById(id: MageEventId): Promise { - const attrs = await super.findById(id) - if (attrs) { - return new MageEvent(attrs) - } - return null + return await super.findById(id) } - async findActiveEvents(): Promise { - const docs: legacy.MageEventDocument[] = await this.model.find({ complete: { $in: [ null, false ] }}).exec() + async findActiveEvents(): Promise { + const docs = await this.model.find({ complete: { $in: [ null, false ] }}) return docs.map(this.entityForDocument) } - async addFeedsToEvent(event: MageEventId, ...feeds: FeedId[]): Promise { + async addFeedsToEvent(event: MageEventId, ...feeds: FeedId[]): Promise { const updated = await this.model.findByIdAndUpdate( event, { @@ -50,7 +53,7 @@ export class MongooseMageEventRepository extends BaseMongooseRepository { + async removeFeedsFromEvent(event: MageEventId, ...feeds: FeedId[]): Promise { const updated = await this.model.findByIdAndUpdate( event, { diff --git a/service/src/adapters/feeds/adapters.feeds.db.mongoose.ts b/service/src/adapters/feeds/adapters.feeds.db.mongoose.ts index ee013448e..803044ea8 100644 --- a/service/src/adapters/feeds/adapters.feeds.db.mongoose.ts +++ b/service/src/adapters/feeds/adapters.feeds.db.mongoose.ts @@ -1,9 +1,8 @@ -import mongoose, { Model, SchemaOptions } from 'mongoose' +import mongoose, { Model } from 'mongoose' import { BaseMongooseRepository } from '../base/adapters.base.db.mongoose' import { FeedServiceType, FeedService, FeedServiceTypeId, RegisteredFeedServiceType, FeedRepository, Feed, FeedServiceId } from '../../entities/feeds/entities.feeds' import { FeedServiceTypeRepository, FeedServiceRepository } from '../../entities/feeds/entities.feeds' -import { FeedServiceDescriptor } from '../../app.api/feeds/app.api.feeds' import { EntityIdFactory } from '../../entities/entities.global' @@ -13,28 +12,26 @@ export const FeedsModels = { Feed: 'Feed', } -export type FeedServiceTypeIdentity = Pick & { - id: string +export type FeedServiceTypeIdentityDocument = Pick & { + _id: mongoose.Types.ObjectId moduleName: string } -export type FeedServiceTypeIdentityDocument = FeedServiceTypeIdentity & mongoose.Document export type FeedServiceTypeIdentityModel = Model -//TODO remove cast to any -export const FeedServiceTypeIdentitySchema = new mongoose.Schema({ +export const FeedServiceTypeIdentitySchema = new mongoose.Schema({ pluginServiceTypeId: { type: String, required: true }, moduleName: { type: String, required: true } }) export function FeedServiceTypeIdentityModel(conn: mongoose.Connection, collection?: string): FeedServiceTypeIdentityModel { - //TODO remove cast to any - return conn.model(FeedsModels.FeedServiceTypeIdentity, FeedServiceTypeIdentitySchema, collection || 'feed_service_types') as any + return conn.model(FeedsModels.FeedServiceTypeIdentity, FeedServiceTypeIdentitySchema, collection || 'feed_service_types') } -export type FeedServiceDocument = Omit & mongoose.Document & { +export type FeedServiceDocument = Omit & { + _id: mongoose.Types.ObjectId serviceType: mongoose.Types.ObjectId + config: object } export type FeedServiceModel = Model -//TODO remove cast to any -export const FeedServiceSchema = new mongoose.Schema( +export const FeedServiceSchema = new mongoose.Schema( { serviceType: { type: mongoose.SchemaTypes.ObjectId, required: true, ref: FeedsModels.FeedServiceTypeIdentity }, title: { type: String, required: true }, @@ -45,24 +42,30 @@ export const FeedServiceSchema = new mongoose.Schema( toJSON: { getters: true, versionKey: false, - transform: (doc: FeedServiceDocument, json: any & FeedService, options: SchemaOptions): void => { + transform: (doc: FeedServiceDocument, json: any & FeedService): void => { delete json._id json.serviceType = doc.serviceType.toHexString() } } }) export function FeedServiceModel(conn: mongoose.Connection, collection?: string): FeedServiceModel { - //TODO remove cast to any - return conn.model(FeedsModels.FeedService, FeedServiceSchema, collection || 'feed_services') as any + return conn.model(FeedsModels.FeedService, FeedServiceSchema, collection || 'feed_services') } -export type FeedDocument = Omit & mongoose.Document & { - service: mongoose.Types.ObjectId, +export type FeedDocument = Omit & { + _id: string + service: mongoose.Types.ObjectId icon?: string + /** + * This extra definition with `object` avoids the following TS error on the `FeedSchema` `constantParams` member + * below. + * + * _Type instantiation is excessively deep and possibly infinite.ts(2589)_ + */ + constantParams: object } export type FeedModel = Model -//TODO remove cast to any -export const FeedSchema = new mongoose.Schema( +export const FeedSchema = new mongoose.Schema( { _id: { type: String, required: true }, service: { type: mongoose.SchemaTypes.ObjectId, required: true, ref: FeedsModels.FeedService }, @@ -86,7 +89,7 @@ export const FeedSchema = new mongoose.Schema( toJSON: { getters: true, versionKey: false, - transform: (doc: FeedDocument, json: any & Feed, options: SchemaOptions): void => { + transform: (doc: FeedDocument, json: any & Feed): void => { delete json._id json.service = doc.service.toHexString() if (doc.icon) { @@ -96,8 +99,7 @@ export const FeedSchema = new mongoose.Schema( } }) export function FeedModel(conn: mongoose.Connection, collection?: string): FeedModel { - //TODO remove cast to any - return conn.model(FeedsModels.Feed, FeedSchema, collection || 'feeds') as any + return conn.model(FeedsModels.Feed, FeedSchema, collection || 'feeds') } export class MongooseFeedServiceTypeRepository implements FeedServiceTypeRepository { @@ -148,7 +150,16 @@ export class MongooseFeedServiceRepository extends BaseMongooseRepository implements FeedRepository { constructor(model: FeedModel, private readonly idFactory: EntityIdFactory) { - super(model, { entityToDocStub: e => ({ ...e, icon: e.icon?.id }) }) + super(model, { + entityToDocStub: e => { + return { + ...e, + _id: e.id, + service: new mongoose.Types.ObjectId(e.service!), + icon: e.icon?.id + } + } + }) } async create(attrs: Partial): Promise { diff --git a/service/src/adapters/icons/adapters.icons.db.mongoose.ts b/service/src/adapters/icons/adapters.icons.db.mongoose.ts index c28bd52a6..2af6daf8d 100644 --- a/service/src/adapters/icons/adapters.icons.db.mongoose.ts +++ b/service/src/adapters/icons/adapters.icons.db.mongoose.ts @@ -5,12 +5,13 @@ import { EntityIdFactory, pageOf, PageOf, PagingParameters, UrlResolutionError, import { StaticIcon, StaticIconStub, StaticIconId, StaticIconRepository, LocalStaticIconStub, StaticIconReference, StaticIconContentStore, StaticIconImportFetch } from '../../entities/icons/entities.icons' import { BaseMongooseRepository, pageQuery } from '../base/adapters.base.db.mongoose' -export type StaticIconDocument = Omit & mongoose.Document & { +export type StaticIconDocument = Omit & { + _id: string sourceUrl: string } export type StaticIconModel = mongoose.Model export const StaticIconModelName = 'StaticIcon' -export const StaticIconSchema = new mongoose.Schema( +export const StaticIconSchema = new mongoose.Schema( { _id: { type: String, required: true }, sourceUrl: { type: String, required: true, unique: true }, @@ -38,7 +39,7 @@ export const StaticIconSchema = new mongoose.Schema( toJSON: { getters: true, versionKey: false, - transform: (doc: StaticIconDocument, json: any & StaticIcon, options: mongoose.SchemaOptions): void => { + transform: (doc: StaticIconDocument, json: any & StaticIcon): void => { delete json._id json.sourceUrl = new URL(doc.sourceUrl) } @@ -85,24 +86,20 @@ export class MongooseStaticIconRepository extends BaseMongooseRepository { + private async fetchAndStore(iconDoc: mongoose.HydratedDocument): Promise | UrlResolutionError> { if (typeof iconDoc.resolvedTimestamp === 'number') { return iconDoc } @@ -193,12 +190,12 @@ export class MongooseStaticIconRepository extends BaseMongooseRepository { + private async findDocBySourceUrl(url: URL): Promise | null> { return await this.model.findOne({ sourceUrl: url.toString() }) } } -async function updateRegisteredIconIfChanged(this: MongooseStaticIconRepository, registered: StaticIconDocument, stub: StaticIconStub): Promise { +async function updateRegisteredIconIfChanged(this: MongooseStaticIconRepository, registered: mongoose.HydratedDocument, stub: StaticIconStub): Promise> { /* TODO: some of this logic could potentially be captured as an entity layer function, such as which properties a client is allowed to update when diff --git a/service/src/adapters/observations/adapters.observations.controllers.web.ts b/service/src/adapters/observations/adapters.observations.controllers.web.ts index 1bd89457c..2b0856164 100644 --- a/service/src/adapters/observations/adapters.observations.controllers.web.ts +++ b/service/src/adapters/observations/adapters.observations.controllers.web.ts @@ -1,11 +1,12 @@ import express from 'express' import { compatibilityMageAppErrorHandler } from '../adapters.controllers.web' -import { AllocateObservationId, ExoAttachment, ExoIncomingAttachmentContent, ExoObservation, ExoObservationMod, ObservationRequest, ReadAttachmentContent, ReadAttachmentContentRequest, SaveObservation, SaveObservationRequest, StoreAttachmentContent, StoreAttachmentContentRequest } from '../../app.api/observations/app.api.observations' -import { AttachmentStore, EventScopedObservationRepository, ObservationState } from '../../entities/observations/entities.observations' +import { AllocateObservationId, ExoAttachment, ExoIncomingAttachmentContent, ExoObservation, ObservationRequest, ReadAttachmentContent, ReadAttachmentContentRequest, ReadObservation, ReadObservations, ReadObservationsRequest, SaveObservation, SaveObservationRequest, StoreAttachmentContent, StoreAttachmentContentRequest } from '../../app.api/observations/app.api.observations' +import { AttachmentStore, EventScopedObservationRepository, ObservationState, ObservationStateName } from '../../entities/observations/entities.observations' import { MageEvent, MageEventId } from '../../entities/events/entities.events' -import busboy from 'busboy' import { invalidInput } from '../../app.api/app.api.errors' import { exoObservationModFromJson } from './adapters.observations.dto.ecma404-json' +import busboy from 'busboy' +import moment from 'moment' declare module 'express-serve-static-core' { interface Request { @@ -16,6 +17,8 @@ declare module 'express-serve-static-core' { export interface ObservationAppLayer { allocateObservationId: AllocateObservationId saveObservation: SaveObservation + readObservations: ReadObservations + readObservation: ReadObservation storeAttachmentContent: StoreAttachmentContent readAttachmentContent: ReadAttachmentContent } @@ -70,8 +73,8 @@ export function ObservationRoutes(app: ObservationAppLayer, attachmentStore: Att }, async (req, res, next) => { const afterUploadStreamEvent = 'afterUploadStream' - const sendInvalidRequestStructure = () => next(invalidInput(`request must contain only one file part named 'attachment'`)) - const afterUploadStream = (finishResponse: () => void) => { + const sendInvalidRequestStructure = (): void => next(invalidInput(`request must contain only one file part named 'attachment'`)) + const afterUploadStream = (finishResponse: () => void): void => { if (req.attachmentUpload?.listenerCount(afterUploadStreamEvent)) { return } @@ -190,10 +193,141 @@ export function ObservationRoutes(app: ObservationAppLayer, attachmentStore: Att } next(appRes.error) }) + .get(async (req, res, next) => { + const observationId = req.params.observationId + const appReq = createAppRequest(req, { observationId }) + const appRes = await app.readObservation(appReq) + if (appRes.success) { + return res.json(jsonForObservation(appRes.success, qualifiedBaseUrl(req))) + } + next(appRes.error) + }) + + routes.route('/') + .get(async (req, res, next) => { + const readSpec: Pick = parseObservationQueryParams(req) + const appReq = createAppRequest(req, readSpec) + const appRes = await app.readObservations(appReq) + if (appRes.success) { + return res.json(appRes.success.map(x => jsonForObservation(x, qualifiedBaseUrl(req)))) + } + next(appRes.error) + }) return routes.use(compatibilityMageAppErrorHandler) } +/** + * Attempt to parse the given string to an array of numbers that represents a + * bounding box of the form [ xMin, yMin, xMax, yMax ]. This does not validate + * lat/lon bounds, only array length and number type. The string can be a + * JSON string number array (deprecated), e.g., `'[ 1, 2, 3, 4 ]'`, or a comma- + * separated list, e.g., `'1,2,3,4'`. + */ +function parseBBox(maybeBBoxString: any): number[] | null { + if (typeof maybeBBoxString !== 'string') { + return null + } + let parsed: number[] = [] + try { + // TODO: move this geometryFormat.parse() call down to mongodb repository + // filter.geometries = geometryFormat.parse('bbox', bbox) + // TODO: would be better not to embed json strings in query parameters; use csv instead + parsed = JSON.parse(maybeBBoxString) + if (!Array.isArray(parsed)) { + return null + } + return null + } + catch(err) { + console.debug('invalid json string from query parameter `bbox`', maybeBBoxString, err) + } + // try csv instead of json + // TODO: this should be the only supported format + if (!parsed) { + parsed = maybeBBoxString.split(',').map(parseFloat) + } + if (parsed.length !== 4 && parsed.some(x => typeof x !== 'number' || isNaN(x))) { + return null + } + return parsed +} + +/** + * Parse {@link ObservationStateName} strings from the given input string. This expects the input string to be + * comma-separated values with no spaces. Only parse the first N state names, where N is number of valid state names. + * Return null if the input is not a string or contains no valid state names. + */ +function parseStatesParam(maybeStatesString: any): ObservationStateName[] | null { + if (typeof maybeStatesString !== 'string') { + return null + } + const allStateNames = Object.values(ObservationStateName) + const states = maybeStatesString.split(',', allStateNames.length).reduce((states: Set, stateName: any) => { + if (allStateNames.includes(stateName) && !states.has(stateName)) { + return states.add(stateName) + } + return states + }, new Set()) + return states.size > 0 ? Array.from(states.values()) : null +} + +const allowedSortFields = { + lastModified: true, + timestamp: true, +} as Record + +/** + * Parse a sort field specification of the form `field+order`, where `field` is the name of an observation field, + * and `order` is `desc`, `-`, or `-1`, to indicate a descending sort. The default sort order is ascending. Only the + * first valid sort field is used + */ +function parseSortParam(maybeSortString: any): ReadObservationsRequest['sort'] | null { + if (typeof maybeSortString !== 'string') { + return null + } + const sort = maybeSortString.split(',').reduce<{ field: string, order: 1 | -1 }[]>((sort, sortFieldSpec) => { + const [ name, orderString ] = sortFieldSpec.split('+') + const order = orderString?.toLowerCase() === 'desc' || orderString === '-' || orderString === '-1' ? -1 : 1 + if (allowedSortFields[name] === true) { + return [ ...sort, { field: name, order } ] + } + return sort + }, [] as { field: string, order: 1 | -1 }[])[0] + return sort || null +} + +function parseObservationQueryParams(req: express.Request): Pick { + const filter: ReadObservationsRequest['filter'] = {} + const startDate = req.query.startDate + if (startDate) { + filter.lastModifiedAfter = moment(String(startDate)).utc().toDate() + } + const endDate = req.query.endDate + if (endDate) { + filter.lastModifiedBefore = moment(String(endDate)).utc().toDate() + } + const observationStartDate = req.query.observationStartDate + if (observationStartDate) { + filter.timestampAfter = moment(String(observationStartDate)).utc().toDate() + } + const observationEndDate = req.query.observationEndDate + if (observationEndDate) { + filter.timestampBefore = moment(String(observationEndDate)).utc().toDate() + } + const bboxParam = parseBBox(req.query.bbox) + if (!bboxParam) { + console.warn('invalid bbox query parameter', req.query.bbox) + } + const states = parseStatesParam(req.query.states) + if (states) { + filter.states = states + } + const sort = parseSortParam(req.query.sort) || void(0) + const populate = req.query.populate === 'true' + return { filter, sort, populate } +} + export type WebObservation = Omit & { url: string state?: WebObservationState @@ -208,6 +342,13 @@ export type WebAttachment = ExoAttachment & { url?: string } +/** + * Map the given observation to a {@link WebObservation} JSON object which has extra URL + * entries based on the current base URL of the web app. + * @deprecated TODO: abs url: Stop using absolute URLs with FQDN. Clients should constuct + * requests based on the ReST API definition or using relative URLs appended to the base + * URL of the server. + */ export function jsonForObservation(o: ExoObservation, baseUrl: string): WebObservation { const obsUrl = `${baseUrl}/${o.id}` return { diff --git a/service/src/adapters/observations/adapters.observations.db.mongoose.ts b/service/src/adapters/observations/adapters.observations.db.mongoose.ts index 276611156..53c2ecd28 100644 --- a/service/src/adapters/observations/adapters.observations.db.mongoose.ts +++ b/service/src/adapters/observations/adapters.observations.db.mongoose.ts @@ -1,26 +1,265 @@ -import { MageEvent, MageEventId } from '../../entities/events/entities.events' -import { Attachment, AttachmentId, AttachmentNotFoundError, AttachmentPatchAttrs, copyObservationAttrs, EventScopedObservationRepository, FormEntry, FormEntryId, Observation, ObservationAttrs, ObservationId, ObservationImportantFlag, ObservationRepositoryError, ObservationRepositoryErrorCode, ObservationRepositoryForEvent, ObservationState, patchAttachment, Thumbnail } from '../../entities/observations/entities.observations' -import { BaseMongooseRepository, DocumentMapping, pageQuery } from '../base/adapters.base.db.mongoose' +import { MageEvent, MageEventAttrs, MageEventId } from '../../entities/events/entities.events' +import { Attachment, AttachmentId, AttachmentNotFoundError, AttachmentPatchAttrs, copyObservationAttrs, EventScopedObservationRepository, UsersExpandedObservationAttrs, FindObservationsSpec, FormEntry, FormEntryId, Observation, ObservationAttrs, ObservationFeatureProperties, ObservationId, ObservationImportantFlag, ObservationRepositoryError, ObservationRepositoryErrorCode, ObservationState, patchAttachment, Thumbnail, UserExpandedObservationImportantFlag } from '../../entities/observations/entities.observations' +import { BaseMongooseRepository, DocumentMapping, pageQuery, WithMongooseDefaultVersionKey } from '../base/adapters.base.db.mongoose' import mongoose from 'mongoose' -import * as legacy from '../../models/observation' import { MageEventDocument } from '../../models/event' import { pageOf, PageOf, PagingParameters } from '../../entities/entities.global' -import { MongooseMageEventRepository } from '../events/adapters.events.db.mongoose' import { EventEmitter } from 'events' -export type ObservationIdDocument = mongoose.Document +const Schema = mongoose.Schema + +export type ObservationIdDocument = { _id: mongoose.Types.ObjectId } export type ObservationIdModel = mongoose.Model +export const ObservationIdModelName: string = 'ObservationId' +export const ObservationIdSchema = new Schema() +export function createObservationIdModel(conn: mongoose.Connection): ObservationIdModel { + return conn.model(ObservationIdModelName, ObservationIdSchema) +} -export class MongooseObservationRepository extends BaseMongooseRepository implements EventScopedObservationRepository { +export type ObservationDocument = Omit + & { + _id: mongoose.Types.ObjectId + userId?: mongoose.Types.ObjectId + deviceId?: mongoose.Types.ObjectId + important?: ObservationDocumentImportantFlag + favoriteUserIds: mongoose.Types.ObjectId[] + states: ObservationStateDocument[] + attachments: AttachmentDocument[] + properties: ObservationDocumentFeatureProperties + } + & WithMongooseDefaultVersionKey +export type ObservationSubdocuments = { + states: mongoose.Types.DocumentArray + attachments: mongoose.Types.DocumentArray +} +export type ObservationModelOverrides = ObservationSubdocuments & { + toObject(options?: ObservationTransformOptions): ObservationAttrs + toJSON(options?: ObservationTransformOptions): ObservationDocumentJson +} +export type ObservationSchema = mongoose.Schema +export type ObservationModel = mongoose.Model +export type ObservationModelInstance = mongoose.HydratedDocument +export type ObservationDocumentJson = Omit & { + id: mongoose.Types.ObjectId + eventId?: number + /** + * @deprecated TODO: confine URLs to the web layer + */ + url: string + attachments: AttachmentDocumentJson[] + states: ObservationStateDocumentJson[] +} - readonly eventScope: MageEventId - readonly idModel: ObservationIdModel +/** + * This interface defines the options that one can supply to the `toJSON()` + * method of the Mongoose Document instances of the Observation model. + */ +export interface ObservationTransformOptions extends mongoose.ToObjectOptions { + /** + * The database schema does not include the event ID for observation + * documents. Use this option to add the `eventId` property to the + * observation JSON document. + */ + event?: MageEventDocument | MageEventAttrs | { id: MageEventId, [others: string]: any } + /** + * If the `path` option is present, the JSON transormation will prefix the + * `url` property of the observation JSON object with the value of `path`. + * @deprecated TODO: confine URLs to the web layer + */ + path?: string +} +export type ObservationDocumentFeatureProperties = Omit & { + forms: ObservationDocumentFormEntry[] +} - constructor(eventDoc: Pick, readonly eventLookup: (eventId: MageEventId) => Promise, readonly domainEvents: EventEmitter) { - // TODO: do not bind to the default mongoose instance and connection - super(legacy.observationModel(eventDoc), { docToEntity: createDocumentMapping(eventDoc.id) }) - this.eventScope = eventDoc.id - this.idModel = legacy.ObservationId +export type ObservationDocumentFormEntry = Omit & { + _id: mongoose.Types.ObjectId +} +export type ObservationDocumnetFormEntryJson = Omit & { + id: ObservationDocumentFormEntry['_id'] +} + +export type AttachmentDocument = Omit & { + _id: mongoose.Types.ObjectId + observationFormId: mongoose.Types.ObjectId + relativePath?: string + thumbnails: ThumbnailDocument[] +} +export type AttachmentDocumentJson = Omit & { + id: AttachmentDocument['_id'] + relativePath?: string + /** + * @deprecated TODO: confine URLs to the web layer + */ + url?: string +} + +export type ThumbnailDocument = Omit & { + _id: mongoose.Types.ObjectId + relativePath?: string +} + +export type ObservationDocumentImportantFlag = Omit & { + userId?: mongoose.Types.ObjectId +} + +export type ObservationStateDocument = Omit & { + _id: mongoose.Types.ObjectId + userId?: mongoose.Types.ObjectId +} +export type ObservationStateDocumentJson = Omit & { + id: string + // TODO: url should move to web layer + url: string +} + +function hasOwnProperty(wut: any, property: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(wut, property) +} + +/** + * @deprecated TODO: obs types: This exists for backward compatibility, but should be viable to remove when all + * observation routes that return observation json transition to DI architecture in the adapter layer, as the newer + * `WebObservation` type mapping handles adding the `url` key to observation documents in the observation + * [web controller](./adapters.observations.controllers.web.ts). + */ +function transformObservationModelInstance(modelInstance: mongoose.HydratedDocument | mongoose.HydratedDocument, result: any, options: ObservationTransformOptions): ObservationAttrs { + result.id = modelInstance._id.toHexString() + delete result._id + delete result.__v + const event = options.event || {} as any + if (hasOwnProperty(event, '_id')) { + result.eventId = event?._id + } + else if (hasOwnProperty(event, 'id')) { + result.eventId = event.id + } + const path = options.path || '' + result.url = `${path}/${result.id}` + if (result.states && result.states.length) { + const currentState = result.states[0] + const currentStateId = currentState._id.toHexString() + result.state = { + id: currentStateId, + name: currentState.name, + userId: currentState.userId?.toHexString(), + url: `${result.url}/states/${currentStateId}` + } + delete result.states + } + if (result.properties && result.properties.forms) { + result.properties.forms = result.properties.forms.map((formEntry: AttachmentDocument) => { + const mapped: any = formEntry + mapped.id = formEntry._id.toHexString() + delete mapped._id + return mapped + }) + } + if (result.attachments) { + result.attachments = result.attachments.map((doc: AttachmentDocument) => { + const entity = attachmentAttrsForDoc(doc) as Partial + delete entity.thumbnails + entity.url = `${result.url}/attachments/${entity.id}` + return entity + }) + } + const populatedUserId = modelInstance.populated('userId') + if (populatedUserId) { + result.user = result.userId + // TODO Update mobile clients to handle observation.userId or observation.user.id + // Leave userId as mobile clients depend on it for observation create/update, + result.userId = populatedUserId + } + const populatedImportantUserId = modelInstance.populated('important.userId') + if (populatedImportantUserId && result.important) { + result.important.user = result.important.userId + delete result.important.userId + } + return result +} + +ObservationIdSchema.set('toJSON', { transform: transformObservationModelInstance }) + +export const StateSchema = new Schema({ + name: { type: String, required: true }, + userId: { type: Schema.Types.ObjectId, ref: 'User' } +}) + +export const ThumbnailSchema = new Schema( + { + minDimension: { type: Number, required: true }, + contentType: { type: String, required: false }, + size: { type: Number, required: false }, + name: { type: String, required: false }, + width: { type: Number, required: false }, + height: { type: Number, required: false }, + relativePath: { type: String, required: false }, + }, + { strict: false } +) + +export const AttachmentSchema = new Schema( + { + observationFormId: { type: Schema.Types.ObjectId, required: true }, + fieldName: { type: String, required: true }, + lastModified: { type: Date, required: false }, + contentType: { type: String, required: false }, + size: { type: Number, required: false }, + name: { type: String, required: false }, + relativePath: { type: String, required: false }, + width: { type: Number, required: false }, + height: { type: Number, required: false }, + oriented: { type: Boolean, required: true, default: false }, + thumbnails: [ThumbnailSchema] + }, + { strict: false } +) + +export const ObservationSchema = new Schema( + { + type: { type: String, enum: ['Feature'], required: true }, + lastModified: { type: Date, required: false }, + userId: { type: Schema.Types.ObjectId, ref: 'User', required: false, sparse: true }, + deviceId: { type: Schema.Types.ObjectId, required: false, sparse: true }, + geometry: Schema.Types.Mixed, + properties: Schema.Types.Mixed, + attachments: [AttachmentSchema], + states: [StateSchema], + important: { + userId: { type: Schema.Types.ObjectId, ref: 'User', required: false }, + timestamp: { type: Date, required: false }, + description: { type: String, required: false } + }, + favoriteUserIds: [{ type: Schema.Types.ObjectId, ref: 'User' }] + }, + { + strict: false, + timestamps: { + createdAt: 'createdAt', + updatedAt: 'lastModified' + } + } +) + +ObservationSchema.index({ geometry: '2dsphere' }) +ObservationSchema.index({ lastModified: 1 }) +ObservationSchema.index({ userId: 1 }) +ObservationSchema.index({ deviceId: 1 }) +ObservationSchema.index({ 'properties.timestamp': 1 }) +ObservationSchema.index({ 'states.name': 1 }) +ObservationSchema.index({ 'attachments.lastModified': 1 }) +ObservationSchema.index({ 'attachments.oriented': 1 }) +ObservationSchema.index({ 'attachments.contentType': 1 }) +ObservationSchema.index({ 'attachments.thumbnails.minDimension': 1 }) + +/** + * TODO: add support for mongoose `lean()` queries + */ +export class MongooseObservationRepository extends BaseMongooseRepository implements EventScopedObservationRepository { + + constructor(model: ObservationModel, readonly idModel: ObservationIdModel, readonly eventScope: MageEventId, readonly eventLookup: (eventId: MageEventId) => Promise, readonly domainEvents: EventEmitter) { + super(model, { docToEntity: createDocumentMapping(eventScope) }) + this.idModel = mongoose.model('ObservationId') } async allocateObservationId(): Promise { @@ -54,8 +293,7 @@ export class MongooseObservationRepository extends BaseMongooseRepository(findSpec: FindSpec, mapping?: (x: ObservationAttrs | UsersExpandedObservationAttrs) => T): Promise> { + const { where, orderBy, paging: specPaging } = findSpec + const dbFilter = {} as any + if (where.lastModifiedAfter) { + dbFilter.lastModified = { $gte: where.lastModifiedAfter } + } + if (where.lastModifiedBefore) { + dbFilter.lastModified = { ...dbFilter.lastModified, $lt: where.lastModifiedBefore } + } + if (where.timestampAfter) { + dbFilter['properties.timestamp'] = { $gte: where.timestampAfter } + } + if (where.timestampBefore) { + dbFilter['properties.timestamp'] = { ...dbFilter['properties.timestamp'], $lt: where.timestampBefore } + } + if (where.stateIsAnyOf) { + dbFilter['states.0.name'] = { $in: where.stateIsAnyOf } + } + if (Array.isArray(where.geometryIntersects)) { + dbFilter.$geoIntersects = { $geometry: where.geometryIntersects } + } + if (typeof where.isFlaggedImportant === 'boolean') { + dbFilter.important = { $exists: where.isFlaggedImportant } + } + if (where.isFavoriteOfUser) { + dbFilter.favoriteUserIds = where.isFavoriteOfUser + } + const dbSort = {} as any + const order = typeof orderBy?.order === 'number' ? orderBy.order : 1 + if (orderBy?.field === 'lastModified') { + dbSort.lastModified = order + } + else if (orderBy?.field === 'timestamp') { + dbSort['properties.timestamp'] = order + } + // add _id to sort for consistent ordering + dbSort._id = orderBy?.order || -1 + const populatedUserNullSafetyTransform = (user: object | null, _id: mongoose.Types.ObjectId): object => user ? user : { _id } + const options: mongoose.QueryOptions = dbSort ? { sort: dbSort } : {} + const paging: PagingParameters = specPaging || { includeTotalCount: false, pageSize: Number.MAX_SAFE_INTEGER, pageIndex: 0 } + const counted = await pageQuery( + this.model.find(dbFilter, null, options) + .lean(true) + .populate([ + // TODO: something smarter than a mongoose join would be good here + // maybe just store the display name on the observation document + { path: 'userId', select: 'displayName', transform: populatedUserNullSafetyTransform }, + { path: 'important.userId', select: 'displayName', transform: populatedUserNullSafetyTransform } + ]), + paging + ) + const mapResult = typeof mapping === 'function' ? mapping : ((x: any): T => x as T) + const items = await counted.query + const mappedItems = items.map(doc => mapResult(this.entityForDocument(doc))) + return pageOf(mappedItems, paging, counted.totalCount) + } + async findLastModifiedAfter(timestamp: number, paging: PagingParameters): Promise> { const match = { lastModified: {$gte: new Date(timestamp)} } const counted = await pageQuery(this.model.find(match), paging) @@ -97,7 +395,6 @@ export class MongooseObservationRepository extends BaseMongooseRepository { - return Array.from({ length: count }).map(_ => (new mongoose.Types.ObjectId()).toHexString()) + return Array.from({ length: count }).map(() => (new mongoose.Types.ObjectId()).toHexString()) } async nextAttachmentIds(count: number = 1): Promise { - return Array.from({ length: count }).map(_ => (new mongoose.Types.ObjectId()).toHexString()) - } -} - -export const createObservationRepositoryFactory = (eventRepo: MongooseMageEventRepository, domainEvents: EventEmitter): ObservationRepositoryForEvent => { - return async (eventId: MageEventId): Promise => { - const event = await eventRepo.model.findById(eventId) - if (event) { - return new MongooseObservationRepository( - { id: eventId, collectionName: event.collectionName }, - async mageEventId => { - return await eventRepo.findById(mageEventId) - }, - domainEvents) - } - const err = new Error(`unexpected error: event not found for id ${event}`) - console.error(err) - throw err + return Array.from({ length: count }).map(() => (new mongoose.Types.ObjectId()).toHexString()) } } -export function docToEntity(doc: legacy.ObservationDocument, eventId: MageEventId): ObservationAttrs { - return createDocumentMapping(eventId)(doc) +type PopulatedUserDocument = { _id: mongoose.Types.ObjectId, displayName: string } +type ObservationDocumentPopulated = Omit & { + userId: PopulatedUserDocument + important: Omit & { userId: PopulatedUserDocument } } -function createDocumentMapping(eventId: MageEventId): DocumentMapping { +function createDocumentMapping(eventId: MageEventId): DocumentMapping { return doc => { - const attrs: ObservationAttrs = { - id: doc.id, + const attrs: UsersExpandedObservationAttrs = { + id: doc._id.toHexString(), eventId, createdAt: doc.createdAt, lastModified: doc.lastModified, type: doc.type, geometry: doc.geometry, - bbox: doc.bbox, + bbox: doc.bbox as GeoJSON.BBox, states: doc.states.map(stateAttrsForDoc), properties: { ...doc.properties, forms: doc.properties.forms.map(formEntryForDoc) }, + important: importantFlagAttrsForDoc(doc) as any, attachments: doc.attachments.map(attachmentAttrsForDoc), - userId: doc.userId?.toHexString(), deviceId: doc.deviceId?.toHexString(), - important: importantFlagAttrsForDoc(doc), favoriteUserIds: doc.favoriteUserIds?.map(x => x.toHexString()), } + if (doc.userId instanceof mongoose.Types.ObjectId) { + attrs.userId = doc.userId.toHexString() + } + else if (doc.userId?._id) { + attrs.userId = doc.userId._id.toHexString() + attrs.user = { + id: attrs.userId, + displayName: doc.userId.displayName + } + } return attrs } } -function importantFlagAttrsForDoc(doc: legacy.ObservationDocument): ObservationImportantFlag | undefined { +function importantFlagAttrsForDoc(doc: ObservationDocument | ObservationDocumentPopulated | mongoose.LeanDocument): ObservationImportantFlag | UserExpandedObservationImportantFlag | undefined { /* - because the observation schema defines `important` as a nested documnet + because the observation schema defines `important` as a nested document instead of a subdocument schema, a mongoose observation document instance always returns a value for `observation.important`, even if the `important` key is undefined in the database. so, if `important` is undefined in the @@ -183,18 +474,28 @@ function importantFlagAttrsForDoc(doc: legacy.ObservationDocument): ObservationI */ const docImportant = doc.important if (docImportant?.userId || docImportant?.timestamp || docImportant?.description) { - return { - userId: docImportant.userId?.toHexString(), + const important: UserExpandedObservationImportantFlag = { timestamp: docImportant.timestamp, description: docImportant.description } + if (docImportant.userId instanceof mongoose.Types.ObjectId) { + important.userId = docImportant.userId.toHexString() + } + else if (docImportant.userId?._id instanceof mongoose.Types.ObjectId) { + important.userId = docImportant.userId._id.toHexString() + important.user = { + id: docImportant.userId._id.toHexString(), + displayName: docImportant.userId.displayName + } + } + return important } - return void (0) + return void(0) } -function attachmentAttrsForDoc(doc: legacy.AttachmentDocument): Attachment { +function attachmentAttrsForDoc(doc: AttachmentDocument): Attachment { return { - id: doc.id, + id: doc._id.toHexString(), observationFormId: doc.observationFormId.toHexString(), fieldName: doc.fieldName, lastModified: doc.lastModified ? new Date(doc.lastModified) : undefined, @@ -209,7 +510,7 @@ function attachmentAttrsForDoc(doc: legacy.AttachmentDocument): Attachment { } } -function thumbnailAttrsForDoc(doc: legacy.ThumbnailDocument): Thumbnail { +function thumbnailAttrsForDoc(doc: ThumbnailDocument): Thumbnail { return { // TODO: is id necessary for thumnails? needs cleanup contentLocator: doc.relativePath, @@ -222,19 +523,19 @@ function thumbnailAttrsForDoc(doc: legacy.ThumbnailDocument): Thumbnail { } } -function stateAttrsForDoc(doc: legacy.ObservationStateDocument): ObservationState { +function stateAttrsForDoc(doc: ObservationStateDocument): ObservationState { return { - id: doc.id, + id: doc._id.toHexString(), name: doc.name, userId: doc.userId?.toHexString() } } -function formEntryForDoc(doc: legacy.ObservationDocumentFormEntry): FormEntry { +function formEntryForDoc(doc: ObservationDocumentFormEntry): FormEntry { const { _id, ...withoutDbId } = doc return { - ...withoutDbId, - id: _id.toHexString() + ...withoutDbId as any, + id: _id.toHexString(), } } @@ -247,15 +548,15 @@ function assignMongoIdToDocAttrs(attrs: { id?: any, [other: string]: any }): { _ return withoutId } -function attachmentDocSeedForEntity(attrs: Attachment): legacy.AttachmentDocAttrs { +function attachmentDocSeedForEntity(attrs: Attachment): AttachmentDocument { const seed = assignMongoIdToDocAttrs(attrs) seed.relativePath = attrs.contentLocator delete seed['contentLocator'] seed.thumbnails = attrs.thumbnails.map(thumbnailDocSeedForEntity) - return seed as legacy.AttachmentDocAttrs + return seed as AttachmentDocument } -function thumbnailDocSeedForEntity(attrs: Thumbnail): legacy.ThumbnailDocAttrs { +function thumbnailDocSeedForEntity(attrs: Thumbnail): ThumbnailDocument { return { _id: new mongoose.Types.ObjectId(), relativePath: attrs.contentLocator, diff --git a/service/src/adapters/observations/adapters.observations.dto.ecma404-json.ts b/service/src/adapters/observations/adapters.observations.dto.ecma404-json.ts index ac6650df4..8dfe5fbbd 100644 --- a/service/src/adapters/observations/adapters.observations.dto.ecma404-json.ts +++ b/service/src/adapters/observations/adapters.observations.dto.ecma404-json.ts @@ -3,7 +3,6 @@ import moment from 'moment' import { invalidInput, InvalidInputError } from '../../app.api/app.api.errors' import { ExoObservationMod } from '../../app.api/observations/app.api.observations' import { Json } from '../../entities/entities.json_types' -import { ObservationId } from '../../entities/observations/entities.observations' /* * NOTE: This file is named ecma404-json to avoid any potential problems with diff --git a/service/src/adapters/plugins/adapters.plugins.db.mongoose.ts b/service/src/adapters/plugins/adapters.plugins.db.mongoose.ts index d78141682..0130a4d74 100644 --- a/service/src/adapters/plugins/adapters.plugins.db.mongoose.ts +++ b/service/src/adapters/plugins/adapters.plugins.db.mongoose.ts @@ -2,9 +2,9 @@ import mongoose from 'mongoose' import { EnsureJson } from '../../entities/entities.json_types' import { PluginStateRepository } from '../../plugins.api' -export type PluginStateDocument = mongoose.Document & { +export type PluginStateDocument = { _id: 'string', - state: State + state: State | null } const SCHEMA_SPEC = { @@ -14,18 +14,19 @@ const SCHEMA_SPEC = { export class MongoosePluginStateRepository implements PluginStateRepository { - //TODO remove cast to any, was mongoose.Model> - readonly model: mongoose.Model + readonly model: mongoose.Model> constructor(public readonly pluginId: string, public readonly mongoose: mongoose.Mongoose) { const collectionName = `plugin_state_${pluginId}` const modelNames = mongoose.modelNames() - this.model = modelNames.includes(collectionName) ? mongoose.model(collectionName) : mongoose.model(collectionName, new mongoose.Schema(SCHEMA_SPEC), collectionName) + this.model = modelNames.includes(collectionName) ? + mongoose.model>(collectionName) : + mongoose.model(collectionName, new mongoose.Schema>(SCHEMA_SPEC), collectionName) } - async put(state: EnsureJson): Promise> { + async put(state: EnsureJson | null): Promise> { const updated = await this.model.findByIdAndUpdate(this.pluginId, { state }, { new: true, upsert: true, setDefaultsOnInsert: false }) - return updated.toJSON().state + return updated.toJSON().state as any } async patch(state: Partial>): Promise> { @@ -40,11 +41,11 @@ export class MongoosePluginStateRepository implements Plug return update }, {} as any) const patched = await this.model.findByIdAndUpdate(this.pluginId, update, { new: true, upsert: true, setDefaultsOnInsert: false }) - return patched.toJSON().state + return patched.toJSON().state as any } async get(): Promise | null> { const doc = await this.model.findById(this.pluginId) - return doc?.toJSON().state || null + return doc?.toJSON().state as any || null } } \ No newline at end of file diff --git a/service/src/adapters/users/adapters.users.controllers.web.ts b/service/src/adapters/users/adapters.users.controllers.web.ts index 6b65cd435..46715da7f 100644 --- a/service/src/adapters/users/adapters.users.controllers.web.ts +++ b/service/src/adapters/users/adapters.users.controllers.web.ts @@ -1,16 +1,62 @@ import express from 'express' -import { SearchUsers, UserSearchRequest } from '../../app.api/users/app.api.users' +import { SearchUsers, UserSearchRequest, CreateUserOperation } from '../../app.api/users/app.api.users' import { WebAppRequestFactory } from '../adapters.controllers.web' -import { calculateLinks } from '../../entities/entities.global' +import { calculatePagingLinks } from '../../entities/entities.global' +import { defaultHandler as upload } from '../../upload' +import { Phone, UserExpanded, UserIcon } from '../../entities/users/entities.users' +import { invalidInput, InvalidInputError } from '../../app.api/app.api.errors' +import { AppRequest } from '../../app.api/app.api.global' export interface UsersAppLayer { + createUser: CreateUserOperation searchUsers: SearchUsers } -export function UsersRoutes(app: UsersAppLayer, createAppRequest: WebAppRequestFactory) { +export function UsersRoutes(app: UsersAppLayer, createAppRequest: WebAppRequestFactory>): express.Router { const routes = express.Router() + routes.route('/') + .post( + access.authorize('CREATE_USER'), + upload.fields([ { name: 'avatar' }, { name: 'icon' } ]), + function (req, res, next) { + const accountForm = validateAccountForm(req) + if (accountForm instanceof Error) { + return next(accountForm) + } + const iconAttrs = parseIconUpload(req) + if (iconAttrs instanceof Error) { + return next(iconAttrs) + } + const user = { + username: accountForm.username, + roleId: accountForm.roleId, + active: true, // Authorized to update users, activate account by default + displayName: accountForm.displayName, + email: accountForm.email, + phones: accountForm.phones, + authentication: { + type: 'local', + password: accountForm.password, + authenticationConfiguration: { + name: 'local' + } + } + } + const files = req.files as Record || {} + const [ avatar ] = files.avatar || [] + const [ icon ] = files.icon || [] + + // TODO: users-next + app.createUser() + new api.User().create(user, { avatar, icon }).then(newUser => { + newUser = userTransformer.transform(newUser, { path: req.getRoot() }); + res.json(newUser); + }).catch(err => next(err)); + } + ); + routes.route('/search') .get(async (req, res, next) => { const userSearch: UserSearchRequest['userSearch'] = { @@ -26,24 +72,88 @@ export function UsersRoutes(app: UsersAppLayer, createAppRequest: WebAppRequestF 'enabled' in req.query ? /^true$/i.test(String(req.query.enabled)) : undefined - }; - + } const appReq = createAppRequest(req, { userSearch }) const appRes = await app.searchUsers(appReq) if (appRes.success) { - const links = calculateLinks( + const links = calculatePagingLinks( { pageSize: userSearch.pageSize, pageIndex: userSearch.pageIndex }, appRes.success.totalCount - ); - + ) const responseWithLinks = { ...appRes.success, links - }; - - return res.json(responseWithLinks); + } + return res.json(responseWithLinks) } next(appRes.error) }) + return routes +} + +interface AccountForm { + username: string + password: string + displayName: string + email?: string + phones?: Phone[] + roleId: string +} + +function validateAccountForm(req: express.Request): AccountForm | InvalidInputError { + const username = req.body.username + if (typeof username !== 'string' || username.length === 0) { + return invalidInput('username is required') + } + const displayName = req.body.displayName + if (typeof displayName !== 'string' || displayName.length === 0) { + return invalidInput('displayName is required') + } + const email = req.body.email + if (typeof email === 'string') { + const emailRegex = /^[^\s@]+@[^\s@]+\./ + if (!emailRegex.test(email)) { + return invalidInput('invalid email') + } + } + const formPhone = req.body.phone + const phones: Phone[] = typeof formPhone === 'string' ? + [ { type: 'Main', number: formPhone } ] : [] + const password = req.body.password + if (typeof password !== 'string') { + return invalidInput('password is required') + } + const roleId = req.body.roleId + if (typeof roleId !== 'string') { + return invalidInput('roleId is required') + } + return { + username: username.trim(), + password, + displayName, + email, + phones, + roleId, + } +} + +function parseIconUpload(req: express.Request): UserIcon | InvalidInputError { + const formIconAttrs = req.body.iconMetadata || {} as any + const iconAttrs: Partial = + typeof formIconAttrs === 'string' ? + JSON.parse(formIconAttrs) : + formIconAttrs + const files = req.files as Record || { icon: [] } + const [ iconFile ] = files.icon || [] + if (iconFile) { + if (!iconAttrs.type) { + iconAttrs.type = UserIconType.Upload + } + if (iconAttrs.type !== 'create' && iconAttrs.type !== 'upload') { + // TODO: does this really matter? just take the uploaded image + return invalidInput(`invalid icon type: ${iconAttrs.type}`) + } + } + return { type: UserIconType.None } } \ No newline at end of file diff --git a/service/src/adapters/users/adapters.users.db.mongoose.ts b/service/src/adapters/users/adapters.users.db.mongoose.ts index 1e1eacd9e..351c69253 100644 --- a/service/src/adapters/users/adapters.users.db.mongoose.ts +++ b/service/src/adapters/users/adapters.users.db.mongoose.ts @@ -1,53 +1,290 @@ -import { User, UserId, UserRepository, UserFindParameters } from '../../entities/users/entities.users' +import { User, UserId, UserRepository, UserFindParameters, UserIcon, Avatar, UserRepositoryError, UserRepositoryErrorCode, UserExpanded } from '../../entities/users/entities.users' import { BaseMongooseRepository, pageQuery } from '../base/adapters.base.db.mongoose' import { PageOf, pageOf } from '../../entities/entities.global' -import * as legacy from '../../models/user' import _ from 'lodash' import mongoose from 'mongoose' +import { RoleDocument, RoleJson } from '../../models/role' +import { ObjectId } from 'bson' +import { Role } from '../../entities/authorization/entities.authorization' export const UserModelName = 'User' -export type UserDocument = legacy.UserDocument -export type UserModel = mongoose.Model -export const UserSchema = legacy.Model.schema +/** + * This is the raw document structure that MongoDB stores, and that the + * Mongoose/MongoDB driver retrieves without preforming any join-populate + * operation with related models. + */ +export type UserDocument = Omit & { + _id: mongoose.Types.ObjectId, + roleId: mongoose.Types.ObjectId, +} + +export type UserExpandedDocument = Omit + & { roleId: RoleDocument } + +// TODO: users-next: this probably needs an update now with new authentication changes +export type UserJson = Omit + & { + id: mongoose.Types.ObjectId, + icon: Omit, + avatarUrl?: string, + } + & (RolePopulated | RoleReferenced) + +type RoleReferenced = { + roleId: string, + role: never +} + +type RolePopulated = { + roleId: never, + role: RoleJson +} + +export type UserModel = mongoose.Model> +export type UserModelInstance = mongoose.HydratedDocument +export type UserExpandedModelInstance = mongoose.HydratedDocument }> + +export const UserPhoneSchema = new mongoose.Schema( + { + type: { type: String, required: true }, + number: { type: String, required: true } + }, + { + versionKey: false, + _id: false + } +) + +export const UserSchema = new mongoose.Schema( + { + username: { type: String, required: true, unique: true }, + displayName: { type: String, required: true }, + email: { type: String, required: false }, + phones: [ UserPhoneSchema ], + avatar: { + contentType: { type: String, required: false }, + size: { type: Number, required: false }, + relativePath: { type: String, required: false } + }, + icon: { + text: { type: String }, + color: { type: String }, + contentType: { type: String, required: false }, + size: { type: Number, required: false }, + relativePath: { type: String, required: false } + }, + active: { type: Boolean, required: true }, + enabled: { type: Boolean, default: true, required: true }, + roleId: { type: mongoose.Schema.Types.ObjectId, ref: 'Role', required: true }, + recentEventIds: [ { type: Number, ref: 'Event' } ], + }, + { + versionKey: false, + timestamps: { + updatedAt: 'lastUpdated' + }, + toObject: { + transform: DbUserToObject + }, + toJSON: { + transform: DbUserToObject + } + } +) + +// TODO: users-next: find references and delete +// UserSchema.virtual('authentication').get(function () { +// return this.populated('authenticationId') ? this.authenticationId : null; +// }); + +// Lowercase the username we store, this will allow for case insensitive usernames +// Validate that username does not already exist +// TODO: users-next: properly set 409 statusj in web layer +// UserSchema.pre('save', function (next) { +// const user = this; +// user.username = user.username.toLowerCase(); +// this.model('User').findOne({ username: user.username }, function (err, possibleDuplicate) { +// if (err) return next(err); +// if (possibleDuplicate && !possibleDuplicate._id.equals(user._id)) { +// const error = new Error('username already exists'); +// error.status = 409; +// return next(error); +// } +// next(); +// }); +// }); + +// TODO: users-next: this is business logic that belongs elsewhere +// UserSchema.pre('save', function (next) { +// const user = this; +// if (user.active === false || user.enabled === false) { +// Token.removeTokensForUser(user, function (err) { +// next(err); +// }); +// } else { +// next(); +// } +// }); + +// TODO: users-next: move to app/service layer +// UserSchema.pre('remove', function (next) { +// const user = this; + +// async.parallel({ +// location: function (done) { +// Location.removeLocationsForUser(user, done); +// }, +// cappedlocation: function (done) { +// CappedLocation.removeLocationsForUser(user, done); +// }, +// token: function (done) { +// Token.removeTokensForUser(user, done); +// }, +// login: function (done) { +// Login.removeLoginsForUser(user, done); +// }, +// observation: function (done) { +// Observation.removeUser(user, done); +// }, +// eventAcl: function (done) { +// Event.removeUserFromAllAcls(user, function (err) { +// done(err); +// }); +// }, +// teamAcl: function (done) { +// Team.removeUserFromAllAcls(user, done); +// }, +// authentication: function (done) { +// Authentication.removeAuthenticationById(user.authenticationId, done); +// } +// }, +// function (err) { +// next(err); +// }); +// }); + +// eslint-disable-next-line complexity +function DbUserToObject(user, userOut, options) { + userOut.id = userOut._id; + delete userOut._id; + + delete userOut.avatar; + + if (userOut.icon) { // TODO remove if check, icon is always there + delete userOut.icon.relativePath; + } + + if (!!user.roleId && typeof user.roleId.toObject === 'function') { + userOut.role = user.roleId.toObject(); + delete userOut.roleId; + } + + /* + TODO: this used to use user.populated('authenticationId'), but when paging + and using the cursor(), mongoose was not setting the populated flag on the + cursor documents, so this condition was never met and paged user documents + erroneously retained the authenticationId key with the populated + authentication object. this occurs in mage server 6.2.x with mongoose 4.x. + this might be fixed in mongoose 5+, so revisit on mage server 6.3.x. + */ + if (!!user.authenticationId && typeof user.authenticationId.toObject === 'function') { + const authPlain = user.authenticationId.toObject({ virtuals: true }); + delete userOut.authenticationId; + userOut.authentication = authPlain; + } + + if (user.avatar && user.avatar.relativePath) { + // TODO: users-next: this belongs in the web layer only + userOut.avatarUrl = [(options.path ? options.path : ""), "api", "users", user._id, "avatar"].join("/"); + } + + if (user.icon && user.icon.relativePath) { + // TODO: users-next: this belongs in the web layer only + userOut.iconUrl = [(options.path ? options.path : ""), "api", "users", user._id, "icon"].join("/"); + } + return userOut; +} + +export function UserModel(conn: mongoose.Connection): UserModel { + return conn.model(UserModelName, UserSchema, 'users') +} + +/** + * Return the string value of the MongoDB ObjectID or Mongoose document's ID. + */ const idString = (x: mongoose.Document | mongoose.Types.ObjectId): string => { const id: mongoose.Types.ObjectId = x instanceof mongoose.Document ? x._id : x return id.toHexString() } -export class MongooseUserRepository extends BaseMongooseRepository implements UserRepository { +type EntityTypeForDocType = DocType extends UserExpandedDocument | UserExpandedModelInstance ? UserExpanded : User - constructor(model: mongoose.Model) { - super(model, { - docToEntity: doc => { - const json = doc.toJSON() - return { - ...json, - id: doc._id.toHexString(), - roleId: idString(doc.roleId), - authenticationId: idString(doc.authenticationId) - } - } - }) +function entityForDoc(from: DocType): EntityTypeForDocType { + const doc = from instanceof mongoose.Document ? from.toObject() : from + const entity: any = { + ...doc, + id: from._id.toHexString(), } + if (doc.roleId instanceof ObjectId) { + entity.roleId = idString(doc.roleId) + return entity + } + const { _id: docRoleId, ...partialRole } = doc.roleId + const roleId = docRoleId.toHexString() + entity.roleId = roleId + entity.role = { ...partialRole, id: roleId } + return entity +} - async create(): Promise { - throw new Error('method not allowed') +export class MongooseUserRepository implements UserRepository { + + private baseRepo: BaseMongooseRepository + + constructor(private model: UserModel) { + this.baseRepo = new BaseMongooseRepository(model, { docToEntity: entityForDoc }) + } + + findById(id: string): Promise { + return this.model.findById(id, null, { populate: 'roleId', lean: true }).then(x => x ? entityForDoc(x) : null) + } + + findAllByIds(ids: string[]): Promise<{ [id: string]: User | null }> { + return this.baseRepo.findAllByIds(ids) + } + + async create(attrs: Omit): Promise { + try { + const userOrm = await this.model.create(attrs) + const userExpandedDoc = await userOrm.populate>('roleId') as UserExpandedModelInstance + return entityForDoc(userExpandedDoc) + } + catch (err) { + // TODO: duplicate username error + return new UserRepositoryError(UserRepositoryErrorCode.StorageError, String(err)) + } } async update(attrs: Partial & { id: UserId }): Promise { throw new Error('method not allowed') } - async removeById(id: any): Promise { + async removeById(id: UserId): Promise { throw new Error('method not allowed') } + async findByUsername(username: string): Promise { + const doc = await this.model.findOne({ username }, null, { populate: 'roleId', lean: true }) + if (doc) { + return entityForDoc(doc) + } + return null + } + async find(which: UserFindParameters, mapping?: (x: User) => T): Promise> { - const { nameOrContactTerm, active, enabled } = which || {} + const { nameOrContactTerm, active, enabled } = (which || {}) const searchRegex = new RegExp(_.escapeRegExp(nameOrContactTerm), 'i') - const params = nameOrContactTerm ? { $or: [ { username: searchRegex }, @@ -56,27 +293,37 @@ export class MongooseUserRepository extends BaseMongooseRepository (x as any as T) + mapping = (x: User): T => (x as any as T) } for await (const userDoc of counted.query.cursor()) { - users.push(mapping(this.entityForDocument(userDoc))) + users.push(mapping(entityForDoc(userDoc))) } + return pageOf(users, which, counted.totalCount) + } + + saveMapIcon(userId: UserId, icon: UserIcon, content: NodeJS.ReadableStream | Buffer): Promise { + throw new Error('Method not implemented.') + } - const finalResult = pageOf(users, which, counted.totalCount); - return finalResult; + saveAvatar(userId: UserId, avatar: Avatar, content: NodeJS.ReadableStream | Buffer): Promise { + throw new Error('Method not implemented.') + } + deleteMapIcon(userId: UserId): Promise { + throw new Error('Method not implemented.') } -} \ No newline at end of file + + deleteAvatar(userId: UserId): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/service/src/api/attachment.js b/service/src/api/attachment.js index 010688d00..57bc95fd9 100644 --- a/service/src/api/attachment.js +++ b/service/src/api/attachment.js @@ -1,91 +1,15 @@ -const ObservationModel = require('../models/observation') - , log = require('winston') - , path = require('path') - , fs = require('fs-extra') - , environment = require('../environment/env'); +const log = require('winston') +const path = require('path') +const fs = require('fs-extra') +const environment = require('../environment/env') -const attachmentBase = environment.attachmentBaseDirectory; - -const createAttachmentPath = function(event) { - const now = new Date(); - return path.join( - event.collectionName, - now.getFullYear().toString(), - (now.getMonth() + 1).toString(), - now.getDate().toString() - ); -}; +const attachmentBase = environment.attachmentBaseDirectory function Attachment(event, observation) { this._event = event; this._observation = observation; } -Attachment.prototype.getById = function(attachmentId, options, callback) { - const size = options.size ? Number(options.size) : null; - - ObservationModel.getAttachment(this._event, this._observation._id, attachmentId, function(err, attachment) { - if (!attachment) return callback(err); - - if (size) { - attachment.thumbnails.forEach(function(thumbnail) { - if ((thumbnail.minDimension < attachment.height || !attachment.height) && - (thumbnail.minDimension < attachment.width || !attachment.width) && - (thumbnail.minDimension >= size)) { - attachment = thumbnail; - } - }); - } - - if (attachment && attachment.relativePath) attachment.path = path.join(attachmentBase, attachment.relativePath); - - callback(null, attachment); - }); -}; - -Attachment.prototype.update = function(attachmentId, attachment, callback) { - const relativePath = createAttachmentPath(this._event); - // move file upload to its new home - const dir = path.join(attachmentBase, relativePath); - fs.mkdirp(dir, err => { - if (err) return callback(err); - - const fileName = path.basename(attachment.path); - attachment.relativePath = path.join(relativePath, fileName); - const file = path.join(attachmentBase, attachment.relativePath); - - fs.move(attachment.path, file, err => { - if (err) return callback(err); - - ObservationModel.addAttachment(this._event, this._observation._id, attachmentId, attachment, (err, newAttachment) => { - // TODO: now defunct after removing legacy attachment events module - // if (!err && newAttachment) { - // EventEmitter.emit(AttachmentEvents.events.add, newAttachment.toObject(), this._observation, this._event); - // } - callback(err, newAttachment); - }); - }); - }); -}; - -Attachment.prototype.delete = function(attachmentId, callback) { - const attachment = this._observation.attachments.find(attachment => attachment._id.toString() === attachmentId); - ObservationModel.removeAttachment(this._event, this._observation._id, attachmentId, err => { - if (err) return callback(err); - - if (attachment && attachment.relativePath) { - const file = path.join(attachmentBase, attachment.relativePath); - fs.remove(file, err => { - if (err) { - log.error('Could not remove attachment file ' + file + '.', err); - } - }); - } - - callback(); - }); -}; - /** * TODO: this no longer works with the directory scheme `FileSystemAttachmentStore` uses. */ diff --git a/service/src/api/event.js b/service/src/api/event.js index 70a067878..87951a753 100644 --- a/service/src/api/event.js +++ b/service/src/api/event.js @@ -130,6 +130,7 @@ Event.prototype.deleteEvent = async function(callback) { }); }, recentEventIds: function(done) { + // TODO: users-next User.removeRecentEventForUsers(event, function(err) { done(err); }); diff --git a/service/src/api/observation.js b/service/src/api/observation.js index bc212611b..355db3e4b 100644 --- a/service/src/api/observation.js +++ b/service/src/api/observation.js @@ -44,6 +44,10 @@ Observation.prototype.getAll = function(options, callback) { } }; +/** + * TODO: this can be deleted when these refs go away + * * routes/index.js + */ Observation.prototype.getById = function(observationId, options, callback) { if (typeof options === 'function') { callback = options; @@ -53,191 +57,6 @@ Observation.prototype.getById = function(observationId, options, callback) { ObservationModel.getObservationById(this._event, observationId, options, callback); }; -Observation.prototype.validate = function(observation) { - const errors = {}; - let message = ''; - - if (observation.type !== 'Feature') { - errors.type = { error: 'required', message: observation.type ? 'type is required' : 'type must equal "Feature"' }; - message += observation.type ? '\u2022 type is required\n' : '\u2022 type must equal "Feature"\n' - } - - // validate timestamp - const properties = observation.properties || {}; - const timestampError = fieldFactory.createField({ - type: 'date', - required: true, - name: 'timestamp', - title: 'Date' - }, properties).validate(); - if (timestampError) { - errors.timestamp = timestampError; - message += `\u2022 ${timestampError.message}\n`; - } - - // validate geometry - const geometryError = fieldFactory.createField({ - type: 'geometry', - required: true, - name: 'geometry', - title: 'Location' - }, observation).validate(); - if (geometryError) { - errors.geometry = geometryError; - message += `\u2022 ${geometryError.message}\n`; - } - - const formEntries = properties.forms || []; - const formCount = formEntries.reduce((count, form) => { - count[form.formId] = (count[form.formId] || 0) + 1; - return count; - }, {}) - - const formDefinitions = {}; - - // Validate total number of forms - if (this._event.minObservationForms != null && formEntries.length < this._event.minObservationForms) { - errors.minObservationForms = new Error("Insufficient number of forms"); - message += `\u2022 Total number of forms in observation must be at least ${this._event.minObservationForms}\n`; - } - - if (this._event.maxObservationForms != null && formEntries.length > this._event.maxObservationForms) { - errors.maxObservationForms = new Error("Exceeded maximum number of forms"); - message += `\u2022 Total number of forms in observation cannot be more than ${this._event.maxObservationForms}\n`; - } - - // Validate forms min/max occurrences - const formError = {}; - this._event.forms - .filter(form => !form.archived) - .forEach(formDefinition => { - formDefinitions[formDefinition._id] = formDefinition; - - const count = formCount[formDefinition.id] || 0; - if (formDefinition.min && count < formDefinition.min) { - formError[formDefinition.id] = { - error: 'min', - message: `${formDefinition.name} form must be included in observation at least ${formDefinition.min} times` - } - - message += `\u2022 ${formDefinition.name} form must be included in observation at least ${formDefinition.min} times\n`; - } else if (formDefinition.max && (count > formDefinition.max)) { - formError[formDefinition.id] = { - error: 'max', - message: `${formDefinition.name} form cannot be included in observation more than ${formDefinition.max} times` - } - - message += `\u2022 ${formDefinition.name} form cannot be included in observation more than ${formDefinition.max} times\n`; - } - }); - - // TODO attachment-work, validate attachment restrictions and min/max - - if (Object.keys(formError).length) { - errors.form = formError; - } - - // Validate form fields - const formErrors = []; - - formEntries.forEach(formEntry => { - let fieldsMessage = ''; - const fieldsError = {}; - - formDefinitions[formEntry.formId].fields - .filter(fieldDefinition => !fieldDefinition.archived) - .forEach(fieldDefinition => { - const field = fieldFactory.createField(fieldDefinition, formEntry, observation); - const fieldError = field.validate(); - - if (fieldError) { - fieldsError[field.name] = fieldError; - fieldsMessage += ` \u2022 ${fieldError.message}\n`; - } - }); - - if (Object.keys(fieldsError).length) { - formErrors.push(fieldsError); - message += `${formDefinitions[formEntry.formId].name} form is invalid\n`; - message += fieldsMessage; - } - }); - - if (formErrors.length) { - errors.forms = formErrors - } - - if (Object.keys(errors).length) { - const err = new Error('Invalid Observation'); - err.name = 'ValidationError'; - err.status = 400; - err.message = message; - err.errors = errors; - return err; - } -}; - -Observation.prototype.createObservationId = function(callback) { - ObservationModel.createObservationId(callback); -}; - -Observation.prototype.validateObservationId = function(id, callback) { - ObservationModel.getObservationId(id, function(err, id) { - if (err) return callback(err); - - if (!id) { - err = new Error(); - err.status = 404; - } - - callback(err, id); - }); -}; - -// TODO create is gone, do I need to figure out if this is an observation create? -Observation.prototype.update = function(observationId, observation, callback) { - if (this._user) observation.userId = this._user._id; - if (this._deviceId) observation.deviceId = this._deviceId; - - const err = this.validate(observation); - if (err) return callback(err); - - ObservationModel.updateObservation(this._event, observationId, observation, (err, updatedObservation) => { - if (updatedObservation) { - EventEmitter.emit(ObservationEvents.events.update, updatedObservation.toObject({event: this._event}), this._event, this._user); - - // Remove any deleted attachments from file system - /* - TODO: this might not even work. observation form entries are not stored - with field entry keys for attachment fields, so finding attachments based - on matching form entry keys with form field names will not produce any - results. - */ - const {forms: formEntries = []} = observation.properties || {}; - formEntries.forEach(formEntry => { - const formDefinition = this._event.forms.find(form => form._id === formEntry.formId); - Object.keys(formEntry).forEach(fieldName => { - const fieldDefinition = formDefinition.fields.find(field => field.name === fieldName); - if (fieldDefinition && fieldDefinition.type === 'attachment') { - const attachmentsField = formEntry[fieldName] || []; - attachmentsField.filter(attachmentField => attachmentField.action === 'delete').forEach(attachmentField => { - const attachment = observation.attachments.find(attachment => attachment._id.toString() === attachmentField.id); - if (attachment) { - console.info(`deleting attachment ${attachment.id} for field ${fieldName} on observation ${observation.id}`) - new Attachment(this._event, observation).delete(attachment._id, err => { - log.warn('Error removing deleted attachment from file system', err); - }); - } - }); - } - }); - }); - } - - callback(err, updatedObservation); - }); -}; - Observation.prototype.addFavorite = function(observationId, user, callback) { ObservationModel.addFavorite(this._event, observationId, user, callback); }; diff --git a/service/src/api/user.js b/service/src/api/user.js index e58e8bb3c..e07b8b268 100644 --- a/service/src/api/user.js +++ b/service/src/api/user.js @@ -4,6 +4,7 @@ const UserModel = require('../models/user') , LoginModel = require('../models/login') , DeviceModel = require('../models/device') , TeamModel = require('../models/team') + // TODO: users-next , AuthenticationConfiguration = require('../models/authenticationconfiguration') , path = require('path') , fs = require('fs-extra') @@ -11,6 +12,8 @@ const UserModel = require('../models/user') , async = require('async') , environment = require('../environment/env'); +// TODO: users-next + const userBase = environment.userBaseDirectory; function contentPath(id, user, content, type) { @@ -50,6 +53,10 @@ User.prototype.login = function (user, device, options, callback) { if (device) { // set user-agent and mage version on device + /* + TODO: users-next: don't do this - set user agent on login record and leave user agent as registered on device + record maybe even require re-approval if configured device policy dictates + */ DeviceModel.updateDevice(device._id, { userAgent: options.userAgent, appVersion: options.appVersion }).then(() => { callback(null, token); }).catch(err => { @@ -139,18 +146,15 @@ User.prototype.create = async function (user, options = {}) { } catch { } } - if (options.icon && (options.icon.type === 'create' || options.icon.type === 'upload')) { + if (options.icon) { try { const icon = iconPath(newUser._id, newUser, options.icon); await fs.move(options.icon.path, icon.absolutePath); - - newUser.icon.type = options.icon.type; newUser.icon.relativePath = icon.relativePath; newUser.icon.contentType = options.icon.mimetype; newUser.icon.size = options.icon.size; newUser.icon.text = options.icon.text; newUser.icon.color = options.icon.color; - await newUser.save(); } catch { } } diff --git a/service/src/app.api/app.api.global.ts b/service/src/app.api/app.api.global.ts index 3073a901b..2ea5ec2f3 100644 --- a/service/src/app.api/app.api.global.ts +++ b/service/src/app.api/app.api.global.ts @@ -9,6 +9,7 @@ export interface AppRequestContext { * variable that a Java application would use. Node does not use the model * of one thread per request, so some unique value is necessary to track a * request context across multiple asynchronous operations. + * TODO: Node introduced async context tracking: https://nodejs.org/docs/latest-v18.x/api/async_context.html */ readonly requestToken: unknown requestingPrincipal(): Principal @@ -50,17 +51,17 @@ export interface Descriptor extends JsonObject { */ export type AnyMageError = KnownErrors extends MageError ? MageError : never -export class AppResponse { +export const AppResponse = Object.freeze({ - static success(result: Success, contentLanguage?: LanguageTag[] | null | undefined): AppResponse> { - return new AppResponse>(result, null, contentLanguage) - } + success(result: Success, contentLanguage?: LanguageTag[] | null | undefined): AppResponse> { + return Object.freeze({ success: result, error: null, contentLanguage }) + }, - static error(result: KnownErrors, contentLanguage?: LanguageTag[] | null | undefined): AppResponse { - return new AppResponse(null, result, contentLanguage) - } + error(result: KnownErrors, contentLanguage?: LanguageTag[] | null | undefined): AppResponse { + return Object.freeze({ success: null, error: result, contentLanguage }) + }, - static resultOf(promise: Promise>): Promise>> { + resultOf(promise: Promise>): Promise>> { return promise.then( successOrKnownError => { if (successOrKnownError instanceof MageError) { @@ -69,9 +70,11 @@ export class AppResponse { return AppResponse.success(successOrKnownError) }) } +}) - private constructor(readonly success: Success | null, readonly error: KnownErrors | null, readonly contentLanguage?: LanguageTag[] | null | undefined) {} -} +export type AppResponse = + & { contentLanguage?: LanguageTag[] | null | undefined } + & ({ success: Success, error: null } | { success: null, error: KnownErrors }) /** * This type provides a shorthand to map the given operation type argument to diff --git a/service/src/app.api/devices/app.api.devices.ts b/service/src/app.api/devices/app.api.devices.ts new file mode 100644 index 000000000..7162675b9 --- /dev/null +++ b/service/src/app.api/devices/app.api.devices.ts @@ -0,0 +1,9 @@ +import { PermissionDeniedError } from '../app.api.errors' +import { AppRequestContext } from '../app.api.global' + +export interface DevicePermissionService { + ensureCreateDevicePermission(context: AppRequestContext): Promise + ensureReadDevicePermission(context: AppRequestContext): Promise + ensureUpdateDevicePermission(context: AppRequestContext): Promise + ensureDeleteDevicePermission(context: AppRequestContext): Promise +} \ No newline at end of file diff --git a/service/src/app.api/observations/app.api.observations.ts b/service/src/app.api/observations/app.api.observations.ts index 1207b9054..cd61c6cdb 100644 --- a/service/src/app.api/observations/app.api.observations.ts +++ b/service/src/app.api/observations/app.api.observations.ts @@ -1,9 +1,9 @@ import { EntityNotFoundError, InfrastructureError, InvalidInputError, PermissionDeniedError } from '../app.api.errors' import { AppRequest, AppRequestContext, AppResponse } from '../app.api.global' -import { Attachment, AttachmentId, copyObservationAttrs, EventScopedObservationRepository, FormEntry, FormFieldEntry, Observation, ObservationAttrs, ObservationFeatureProperties, ObservationId, ObservationImportantFlag, ObservationState, StagedAttachmentContentRef, Thumbnail, thumbnailIndexForTargetDimension } from '../../entities/observations/entities.observations' +import { Attachment, AttachmentId, copyObservationAttrs, EventScopedObservationRepository, FindObservationsSpec, FormEntry, FormFieldEntry, Observation, ObservationAttrs, ObservationFeatureProperties, ObservationId, ObservationImportantFlag, ObservationState, StagedAttachmentContentRef, Thumbnail, thumbnailIndexForTargetDimension } from '../../entities/observations/entities.observations' import { MageEvent } from '../../entities/events/entities.events' -import _ from 'lodash' import { User, UserId } from '../../entities/users/entities.users' +import { PageOf } from '../../entities/entities.global' @@ -16,7 +16,11 @@ export interface ObservationRequestContext extends AppReque * probably be a user-device pair, eventually. */ userId: UserId - deviceId: string + /** + * TODO: device id: The `None` device provisioning strategy returns a dummy "device" + * object that has no ID. + */ + deviceId?: string observationRepository: EventScopedObservationRepository } export interface ObservationRequest extends AppRequest> {} @@ -33,6 +37,21 @@ export interface SaveObservationRequest extends ObservationRequest { observation: ExoObservationMod } +export interface ReadObservationRequest extends ObservationRequest { + observationId: ObservationId +} +export interface ReadObservation { + (req: ReadObservationRequest): Promise> +} + +export interface ReadObservationsRequest extends ObservationRequest { + findSpec: FindObservationsSpec, + mapping?: (x: ExoObservation) => T +} +export interface ReadObservations { + (req: ReadObservationsRequest): Promise, PermissionDeniedError | InvalidInputError | InfrastructureError>> +} + export interface StoreAttachmentContent { (req: StoreAttachmentContentRequest): Promise> } diff --git a/service/src/app.api/systemInfo/app.api.systemInfo.ts b/service/src/app.api/systemInfo/app.api.systemInfo.ts index 372874fde..51c5ac1fb 100644 --- a/service/src/app.api/systemInfo/app.api.systemInfo.ts +++ b/service/src/app.api/systemInfo/app.api.systemInfo.ts @@ -9,21 +9,19 @@ export type ExoRedactedSystemInfo = Omit export type ExoSystemInfo = ExoPrivilegedSystemInfo | ExoRedactedSystemInfo export interface ReadSystemInfoRequest extends AppRequest { - context: AppRequestContext; + context: AppRequestContext } export interface ReadSystemInfoResponse extends AppResponse {} export interface ReadSystemInfo { - (req: ReadSystemInfoRequest): Promise< - ReadSystemInfoResponse - >; + (req: ReadSystemInfoRequest): Promise } export interface SystemInfoAppLayer { - readSystemInfo: ReadSystemInfo; - permissionsService: SystemInfoPermissionService; + readSystemInfo: ReadSystemInfo + permissionsService: SystemInfoPermissionService } export interface SystemInfoPermissionService { - ensureReadSystemInfoPermission(context: AppRequestContext): Promise; + ensureReadSystemInfoPermission(context: AppRequestContext): Promise } \ No newline at end of file diff --git a/service/src/app.api/users/app.api.users.ts b/service/src/app.api/users/app.api.users.ts index 1f462fd69..925f23510 100644 --- a/service/src/app.api/users/app.api.users.ts +++ b/service/src/app.api/users/app.api.users.ts @@ -1,9 +1,17 @@ import { AppResponse, AppRequest, AppRequestContext } from '../app.api.global' -import { PermissionDeniedError, InvalidInputError } from '../app.api.errors' +import { EntityNotFoundError, InvalidInputError, PermissionDeniedError } from '../app.api.errors' import { PageOf, PagingParameters } from '../../entities/entities.global' -import { User } from '../../entities/users/entities.users' +import { User, UserExpanded, UserId } from '../../entities/users/entities.users' +export interface CreateUserRequest extends AppRequest { + +} + +export interface CreateUserOperation { + (req: CreateUserRequest): Promise> +} + export interface UserSearchRequest extends AppRequest { userSearch: PagingParameters & { nameOrContactTerm?: string | undefined, @@ -23,6 +31,34 @@ export interface SearchUsers { (req: UserSearchRequest): Promise, PermissionDeniedError>> } +export interface ReadMyAccountRequest extends AppRequest {} + +export interface ReadMyAccountOperation { + (req: ReadMyAccountRequest): Promise> +} + +export interface UpdateMyAccountRequest extends AppRequest {} + +export interface UpdateMyAccountOperation { + (req: UpdateMyAccountRequest): Promise> +} + +export interface DisableUserRequest extends AppRequest { + userId: UserId +} + +export interface DisableUserOperation { + (req: DisableUserOperation): Promise> +} + +export interface RemoveUserRequest extends AppRequest { + userId: UserId +} + +export interface RemoveUserOperation { + (req: RemoveUserRequest): Promise> +} + export interface UsersPermissionService { ensureReadUsersPermission(context: AppRequestContext): Promise } \ No newline at end of file diff --git a/service/src/app.impl/observations/app.impl.observations.ts b/service/src/app.impl/observations/app.impl.observations.ts index d2a184f36..1f44eeb3f 100644 --- a/service/src/app.impl/observations/app.impl.observations.ts +++ b/service/src/app.impl/observations/app.impl.observations.ts @@ -52,6 +52,38 @@ export function SaveObservation(permissionService: api.ObservationPermissionServ } } +export function ReadObservations(permissionService: api.ObservationPermissionService): api.ReadObservations { + return async function readObservations(req: api.ReadObservationsRequest): ReturnType { + const denied = await permissionService.ensureReadObservationPermission(req.context) + if (denied) { + return AppResponse.error(denied) + } + const mapping = (x: ObservationAttrs): any => (typeof req.mapping === 'function' ? req.mapping(api.exoObservationFor(x)) : api.exoObservationFor(x)) + try { + const results = await req.context.observationRepository.findSome(req.findSpec, mapping) + return AppResponse.success(results) + } + catch (err) { + return AppResponse.error(infrastructureError(err instanceof Error ? err : String(err))) + } + } +} + +export function ReadObservation(permissionService: api.ObservationPermissionService): api.ReadObservation { + return async function readObservation(req: api.ReadObservationRequest): ReturnType { + const denied = await permissionService.ensureReadObservationPermission(req.context) + if (denied) { + return AppResponse.error(denied) + } + const obs = await req.context.observationRepository.findById(req.observationId) + if (obs instanceof Observation) { + const exoObs = api.exoObservationFor(obs) + return AppResponse.success(exoObs) + } + return AppResponse.error(entityNotFound(req.observationId, 'Observation')) + } +} + export function StoreAttachmentContent(permissionService: api.ObservationPermissionService, attachmentStore: AttachmentStore): api.StoreAttachmentContent { return async function storeAttachmentContent(req: api.StoreAttachmentContentRequest): ReturnType { const obsRepo = req.context.observationRepository @@ -163,7 +195,7 @@ export function registerDeleteRemovedAttachmentsHandler(domainEvents: EventEmitt * attachments should move to {@link Observation.assignTo()} so that method can * generate appropriate domain events, but that will require some API changes * in the entity layer, i.e., some alternative to the pre-generated ID - * requirements for new form entries and attachments. That could be soemthing + * requirements for new form entries and attachments. That could be something * like generating pending identifiers that are easily distinguished from * persistence layer identifiers; maybe a `PendingId` class or `Id` class with * an `isPending` property. That should be reasonable to implement, but no diff --git a/service/src/app.impl/systemInfo/app.impl.systemInfo.ts b/service/src/app.impl/systemInfo/app.impl.systemInfo.ts index 23bf80d4d..968e96b5e 100644 --- a/service/src/app.impl/systemInfo/app.impl.systemInfo.ts +++ b/service/src/app.impl/systemInfo/app.impl.systemInfo.ts @@ -3,6 +3,7 @@ import * as api from '../../app.api/systemInfo/app.api.systemInfo'; import { EnvironmentService } from '../../entities/systemInfo/entities.systemInfo'; import * as Settings from '../../models/setting'; import * as Users from '../../models/user'; +// TODO: users-next import * as AuthenticationConfiguration from '../../models/authenticationconfiguration'; import AuthenticationConfigurationTransformer from '../../transformers/authenticationconfiguration'; import { ExoPrivilegedSystemInfo, ExoRedactedSystemInfo, ExoSystemInfo, SystemInfoPermissionService } from '../../app.api/systemInfo/app.api.systemInfo'; @@ -54,19 +55,19 @@ export function CreateReadSystemInfo( req: api.ReadSystemInfoRequest ): Promise { const isAuthenticated = req.context.requestingPrincipal() != null; - - // FIXME: Replace this with Robert's first-run secret implementation when available - const legacyUsers = Users as any; + // FIXME: Replace this with Robert's first-run secret implementation when available + // TODO: users-next + const legacyUsers = Users as any; const userCount = await new Promise(resolve => { legacyUsers.count({}, (err:any, count:any) => { - resolve(count) - }); + resolve(count) + }); }); - + // Initialize with base system info - let systemInfoResponse: ExoRedactedSystemInfo = { - version: versionInfo, - initial: userCount == 0, + let systemInfoResponse: ExoRedactedSystemInfo = { + version: versionInfo, + initial: userCount == 0, disclaimer: (await settingsModule.getSetting('disclaimer')) || {}, contactInfo: (await settingsModule.getSetting('contactInfo')) || {} }; diff --git a/service/src/app.impl/users/app.impl.users.ts b/service/src/app.impl/users/app.impl.users.ts index 8c8b15b9c..9ca2574bd 100644 --- a/service/src/app.impl/users/app.impl.users.ts +++ b/service/src/app.impl/users/app.impl.users.ts @@ -1,10 +1,114 @@ -import * as api from '../../app.api/users/app.api.users'; -import { UserRepository } from '../../entities/users/entities.users'; -import { withPermission, KnownErrorsOf } from '../../app.api/app.api.global'; -import { PageOf } from '../../entities/entities.global'; +import * as api from '../../app.api/users/app.api.users' +import { UserRepository } from '../../entities/users/entities.users' +import { withPermission, KnownErrorsOf } from '../../app.api/app.api.global' +import { PageOf } from '../../entities/entities.global' +import { IdentityProviderRepository } from '../../ingress/ingress.entities' -export function SearchUsers(userRepo: UserRepository,permissions: api.UsersPermissionService -): api.SearchUsers { + +export function CreateUserOperation(userRepo: UserRepository, idpRepo: IdentityProviderRepository): api.CreateUserOperation { + return async function createUser(req: api.CreateUserRequest): ReturnType { + const reqUser = req.user + const baseUser = { + ...reqUser, + active: false, + enabled: true, + } + const localIdp = await idpRepo.findIdpByName('local') + if (!localIdp) { + throw new Error('local identity provider does not exist') + } + const created = await userRepo.create(baseUser) + const enrollmentPolicy = localIdp.userEnrollmentPolicy + if (Array.isArray(enrollmentPolicy.assignToEvents) && enrollmentPolicy.assignToEvents.length > 0) { + } + if (Array.isArray(enrollmentPolicy.assignToTeams) && enrollmentPolicy.assignToTeams.length > 0) { + } + + let defaultTeams; + let defaultEvents + if (authenticationConfig) { + baseUser.authentication.authenticationConfigurationId = authenticationConfig._id; + const requireAdminActivation = authenticationConfig.settings.usersReqAdmin || { enabled: true }; + if (requireAdminActivation) { + baseUser.active = baseUser.active || !requireAdminActivation.enabled; + } + + defaultTeams = authenticationConfig.settings.newUserTeams; + defaultEvents = authenticationConfig.settings.newUserEvents; + } else { + throw new Error('No configuration defined for ' + baseUser.authentication.type); + } + + const created = await userRepo.create(baseUser) + + if (options.avatar) { + try { + const avatar = avatarPath(newUser._id, newUser, options.avatar); + await fs.move(options.avatar.path, avatar.absolutePath); + + newUser.avatar = { + relativePath: avatar.relativePath, + contentType: options.avatar.mimetype, + size: options.avatar.size + }; + + await newUser.save(); + } catch { } + } + + if (options.icon && (options.icon.type === 'create' || options.icon.type === 'upload')) { + try { + const icon = iconPath(newUser._id, newUser, options.icon); + await fs.move(options.icon.path, icon.absolutePath); + + newUser.icon.type = options.icon.type; + newUser.icon.relativePath = icon.relativePath; + newUser.icon.contentType = options.icon.mimetype; + newUser.icon.size = options.icon.size; + newUser.icon.text = options.icon.text; + newUser.icon.color = options.icon.color; + + await newUser.save(); + } catch { } + } + + if (defaultTeams && Array.isArray(defaultTeams)) { + const addUserToTeam = util.promisify(TeamModel.addUser); + for (let i = 0; i < defaultTeams.length; i++) { + try { + await addUserToTeam({ _id: defaultTeams[i] }, newUser); + } catch { } + } + } + + if (defaultEvents && Array.isArray(defaultEvents)) { + const addUserToTeam = util.promisify(TeamModel.addUser); + + for (let i = 0; i < defaultEvents.length; i++) { + const team = await TeamModel.getTeamForEvent({ _id: defaultEvents[i] }); + if (team) { + try { + await addUserToTeam(team, newUser); + } catch { } + } + } + } + + return newUser; + } +} + +export function AdmitUserFromIdentityProvider(): api.AdminUserFromIdentityProvider { + +} + +// export function UpdateUserOperation(): api.UpdateUserOperation { +// return async function updateUser(req: api.UpdateUserRequest): ReturnType { + +// } +// } + +export function SearchUsers(userRepo: UserRepository,permissions: api.UsersPermissionService): api.SearchUsers { return async function searchUsers(req: api.UserSearchRequest): ReturnType { return await withPermission< PageOf, @@ -25,13 +129,13 @@ export function SearchUsers(userRepo: UserRepository,permissions: api.UsersPermi allPhones: x.phones.reduce((allPhones, phone, index) => { return index === 0 ? `${phone.number}` - : `${allPhones}; ${phone.number}`; + : `${allPhones}; ${phone.number}` }, '') }; } ); - return page; + return page } - ); - }; + ) + } } \ No newline at end of file diff --git a/service/src/app.ts b/service/src/app.ts index c7faab2aa..b6d507054 100644 --- a/service/src/app.ts +++ b/service/src/app.ts @@ -20,7 +20,6 @@ import { PreFetchedUserRoleFeedsPermissionService } from './permissions/permissi import { FeedsRoutes } from './adapters/feeds/adapters.feeds.controllers.web' import { WebAppRequestFactory } from './adapters/adapters.controllers.web' import { AppRequest, AppRequestContext } from './app.api/app.api.global' -import { UserDocument } from './models/user' import SimpleIdFactory from './adapters/adapters.simple_id_factory' import { JsonSchemaService, JsonValidator, JSONSchema4 } from './entities/entities.json_types' import { MageEventModel, MongooseMageEventRepository } from './adapters/events/adapters.events.db.mongoose' @@ -49,11 +48,11 @@ import { RoleBasedUsersPermissionService } from './permissions/permissions.users import { MongoosePluginStateRepository } from './adapters/plugins/adapters.plugins.db.mongoose' import path from 'path' import { MageEventDocument } from './models/event' -import { parseAcceptLanguageHeader } from './entities/entities.i18n' +import { Locale, parseAcceptLanguageHeader } from './entities/entities.i18n' import { ObservationRoutes, ObservationWebAppRequestFactory } from './adapters/observations/adapters.observations.controllers.web' import { UserWithRole } from './permissions/permissions.role-based.base' import { AttachmentStore, EventScopedObservationRepository, ObservationRepositoryForEvent } from './entities/observations/entities.observations' -import { createObservationRepositoryFactory } from './adapters/observations/adapters.observations.db.mongoose' +import { createObservationIdModel, MongooseObservationRepository, ObservationIdModel, ObservationSchema } from './adapters/observations/adapters.observations.db.mongoose' import { FileSystemAttachmentStoreInitError, intializeAttachmentStore } from './adapters/observations/adapters.observations.attachment_store.file_system' import { AttachmentStoreToken, ObservationRepositoryToken } from './plugins.api/plugins.api.observations' import { GetDbConnection, MongooseDbConnectionToken } from './plugins.api/plugins.api.db' @@ -62,8 +61,6 @@ import { EnvironmentServiceImpl } from './adapters/systemInfo/adapters.systemInf import { SystemInfoAppLayer } from './app.api/systemInfo/app.api.systemInfo' import { CreateReadSystemInfo } from './app.impl/systemInfo/app.impl.systemInfo' import Settings from "./models/setting"; -import AuthenticationConfiguration from "./models/authenticationconfiguration"; -import AuthenticationConfigurationTransformer from "./transformers/authenticationconfiguration"; import { SystemInfoRoutes } from './adapters/systemInfo/adapters.systemInfo.controllers.web' import { RoleBasedSystemInfoPermissionService } from './permissions/permissions.systemInfo' import { SettingsAppLayer, SettingsRoutes } from './adapters/settings/adapters.settings.controllers.web' @@ -141,7 +138,7 @@ export const boot = async function(config: BootConfig): Promise { const appLayer = await initAppLayer(repos) const { webController, addAuthenticatedPluginRoutes } = await initWebLayer(repos, appLayer, config.plugins?.webUIPlugins || []) const routesForPluginId: { [pluginId: string]: WebRoutesHooks['webRoutes'] } = {} - const collectPluginRoutesToSort = (pluginId: string, initPluginRoutes: WebRoutesHooks['webRoutes']) => { + const collectPluginRoutesToSort = (pluginId: string, initPluginRoutes: WebRoutesHooks['webRoutes']): void => { routesForPluginId[pluginId] = initPluginRoutes } const globalScopeServices = new Map, any>([ @@ -227,6 +224,9 @@ type DatabaseLayer = { events: { event: MageEventModel } + observations: { + observationId: ObservationIdModel + } icons: { staticIcon: StaticIconModel }, @@ -248,6 +248,8 @@ type AppLayer = { observations: { allocateObservationId: observationsApi.AllocateObservationId saveObservation: observationsApi.SaveObservation + readObservation: observationsApi.ReadObservation + readObservations: observationsApi.ReadObservations storeAttachmentContent: observationsApi.StoreAttachmentContent readAttachmentContent: observationsApi.ReadAttachmentContent }, @@ -311,11 +313,14 @@ async function initDatabase(): Promise { events: { event: require('./models/event').Model }, + observations: { + observationId: createObservationIdModel(conn) + }, icons: { staticIcon: StaticIconModel(conn) }, users: { - user: require('./models/user').Model + user: UserModel(conn) }, settings: { setting: require('./models/setting').Model @@ -387,7 +392,15 @@ async function initRepositories(models: DatabaseLayer, config: BootConfig): Prom eventRepo }, observations: { - obsRepoFactory: createObservationRepositoryFactory(eventRepo, DomainEvents), + obsRepoFactory: async (eventId): Promise => { + const eventModelInstance = await models.events.event.findById(eventId) + if (!eventModelInstance) { + throw new Error(`unexpected error: event not found for id ${eventId}`) + } + const modelName = eventModelInstance.collectionName + const obsModel = models.conn.models[eventModelInstance.collectionName] || models.conn.model(modelName, ObservationSchema) + return new MongooseObservationRepository(obsModel, models.observations.observationId, eventId, eventRepo.findById.bind(eventRepo), DomainEvents) + }, attachmentStore }, icons: { @@ -449,6 +462,8 @@ async function initObservationsAppLayer(repos: Repositories): Promise { +interface MageEventRequestContext extends AppRequestContext { event: MageEventDocument | MageEvent | undefined } @@ -538,7 +554,7 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st const webLayer = await import('./express') const webController = webLayer.app const webAuth = webLayer.auth - const appRequestFactory: WebAppRequestFactory = (req: express.Request, params: Params): AppRequest & Params => { + const appRequestFactory: WebAppRequestFactory = (req: express.Request, params: Params): AppRequest & Params => { return { ...params, context: { @@ -547,6 +563,7 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st } } } + // TODO: users-next: initialize new ingress components const bearerAuth = webAuth.passport.authenticate('bearer') const settingsRoutes = SettingsRoutes(app.settings, appRequestFactory) @@ -583,7 +600,7 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st const context: observationsApi.ObservationRequestContext = { ...baseAppRequestContext(req), mageEvent: req[observationEventScopeKey]!.mageEvent, - userId: req.user.id, + userId: req.user!.admitted!.account.id, deviceId: req.provisionedDeviceId, observationRepository: req[observationEventScopeKey]!.observationRepository } @@ -622,15 +639,10 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st const pluginAppRequestContext: GetAppRequestContext = (req: express.Request) => { return { requestToken: Symbol(), - requestingPrincipal() { - /* - TODO: this should ideally change so that the existing passport login - middleware applies the entity form of a user on the request rather than - the mongoose document instance - */ - return { ...req.user.toJSON(), id: req.user._id.toHexString() } as UserExpanded + requestingPrincipal(): UserExpanded { + return { ...req.user?.admitted?.account as UserExpanded } }, - locale() { + locale(): Locale | null { return Object.freeze({ languagePreferences: parseAcceptLanguageHeader(req.headers['accept-language']) }) @@ -647,7 +659,7 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st } return { webController, - addAuthenticatedPluginRoutes: (pluginId: string, initPluginRoutes: WebRoutesHooks['webRoutes']) => { + addAuthenticatedPluginRoutes: (pluginId: string, initPluginRoutes: WebRoutesHooks['webRoutes']): void => { const routes = initPluginRoutes(pluginAppRequestContext) webController.use(`/plugins/${pluginId}`, [ bearerAuth, routes ]) } @@ -657,10 +669,10 @@ async function initWebLayer(repos: Repositories, app: AppLayer, webUIPlugins: st function baseAppRequestContext(req: express.Request): AppRequestContext { return { requestToken: Symbol(), - requestingPrincipal() { + requestingPrincipal(): UserWithRole { return req.user as UserWithRole }, - locale() { + locale(): Locale | null { return Object.freeze({ languagePreferences: parseAcceptLanguageHeader(req.headers['accept-language']) }) @@ -669,7 +681,7 @@ function baseAppRequestContext(req: express.Request): AppRequestContext { + return async (req: express.Request, res: express.Response, next: express.NextFunction): Promise => { const eventIdFromPath = req.params[observationEventScopeKey] const eventId: MageEventId = parseInt(eventIdFromPath) const mageEvent = Number.isInteger(eventId) ? await eventRepo.findById(eventId) : null diff --git a/service/src/authentication/anonymous.js b/service/src/authentication/anonymous.js deleted file mode 100644 index 83ae04a04..000000000 --- a/service/src/authentication/anonymous.js +++ /dev/null @@ -1,18 +0,0 @@ -const AuthenticationInitializer = require('./index'); - -function initialize() { - const passport = AuthenticationInitializer.passport; - - const AnonymousStrategy = require('passport-anonymous').Strategy; - passport.use(new AnonymousStrategy()); - - return { - loginStrategy: 'anonymous', - authenticationStrategy: 'anonymous', - passport: passport - }; -}; - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/ldap.js b/service/src/authentication/ldap.js deleted file mode 100644 index 719808363..000000000 --- a/service/src/authentication/ldap.js +++ /dev/null @@ -1,148 +0,0 @@ -const LdapStrategy = require('passport-ldapauth') - , log = require('winston') - , User = require('../models/user') - , Role = require('../models/role') - , TokenAssertion = require('./verification').TokenAssertion - , api = require('../api') - , userTransformer = require('../transformers/user') - , { app, passport, tokenService } = require('./index'); - -function configure(strategy) { - log.info('Configuring ' + strategy.title + ' authentication'); - - passport.use(strategy.name, new LdapStrategy({ - server: { - url: strategy.settings.url, - bindDN: strategy.settings.bindDN, - bindCredentials: strategy.settings.bindCredentials, - searchBase: strategy.settings.searchBase, - searchFilter: strategy.settings.searchFilter, - searchScope: strategy.settings.searchScope, - groupSearchBase: strategy.settings.groupSearchBase, - groupSearchFilter: strategy.settings.groupSearchFilter, - groupSearchScope: strategy.settings.groupSearchScope, - bindProperty: strategy.settings.bindProperty, - groupDnProperty: strategy.settings.groupDnProperty - } - }, - function (profile, done) { - const username = profile[strategy.settings.profile.id ]; - User.getUserByAuthenticationStrategy(strategy.type, username, function (err, user) { - if (err) return done(err); - - if (!user) { - // Create an account for the user - Role.getRole('USER_ROLE', function (err, role) { - if (err) return done(err); - - const user = { - username: username, - displayName: profile[strategy.settings.profile.displayName], - email: profile[strategy.settings.profile.email], - active: false, - roleId: role._id, - authentication: { - type: strategy.name, - id: username, - authenticationConfiguration: { - name: strategy.name - } - } - }; - - new api.User().create(user).then(newUser => { - if (!newUser.authentication.authenticationConfiguration.enabled) { - log.warn(newUser.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, newUser, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } - if (newUser.active) { - done(null, newUser); - } else { - done(null, newUser, { status: 403 }); - } - }).catch(err => done(err)); - }); - } else if (!user.authentication.authenticationConfiguration.enabled) { - log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, user, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } else { - return done(null, user); - } - }); - }) - ); -} - -function setDefaults(strategy) { - if (!strategy.settings.profile) { - strategy.settings.profile = {}; - } - if (!strategy.settings.profile.displayName) { - strategy.settings.profile.displayName = 'givenname'; - } - if (!strategy.settings.profile.email) { - strategy.settings.profile.email = 'mail'; - } - if (!strategy.settings.profile.id) { - strategy.settings.profile.id = 'cn'; - } -} - -function initialize(strategy) { - setDefaults(strategy); - configure(strategy); - - const authenticationOptions = { - invalidLogonHours: `Not Permitted to login to ${strategy.title} account at this time.`, - invalidWorkstation: `Not permited to logon to ${strategy.title} account at this workstation.`, - passwordExpired: `${strategy.title} password expired.`, - accountDisabled: `${strategy.title} account disabled.`, - accountExpired: `${strategy.title} account expired.`, - passwordMustChange: `User must reset ${strategy.title} password.`, - accountLockedOut: `${strategy.title} user account locked.`, - invalidCredentials: `Invalid ${strategy.title} username/password.` - }; - - app.post(`/auth/${strategy.name}/signin`, - function authenticate(req, res, next) { - passport.authenticate(strategy.name, authenticationOptions, function (err, user, info = {}) { - if (err) return next(err); - - if (!user) { - return res.status(401).send(info.message); - } - - if (!user.active) { - return res.status(info.status || 401).send('User account is not approved, please contact your MAGE administrator to approve your account.'); - } - - if (!user.enabled) { - log.warn('Failed user login attempt: User ' + user.username + ' account is disabled.'); - return res.status(401).send('Your account has been disabled, please contact a MAGE administrator for assistance.') - } - - if (!user.authentication.authenticationConfigurationId) { - log.warn('Failed user login attempt: ' + user.authentication.type + ' is not configured'); - return res.status(401).send(user.authentication.type + ' authentication is not configured, please contact a MAGE administrator for assistance.') - } - - if (!user.authentication.authenticationConfiguration.enabled) { - log.warn('Failed user login attempt: Authentication ' + user.authentication.authenticationConfiguration.title + ' is disabled.'); - return res.status(401).send(user.authentication.authenticationConfiguration.title + ' authentication is disabled, please contact a MAGE administrator for assistance.') - } - - tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5) - .then(token => { - res.json({ - user: userTransformer.transform(req.user, { path: req.getRoot() }), - token: token - }); - }).catch(err => next(err)); - })(req, res, next); - } - ); -}; - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/local.js b/service/src/authentication/local.js deleted file mode 100644 index f438419ad..000000000 --- a/service/src/authentication/local.js +++ /dev/null @@ -1,93 +0,0 @@ -const log = require('winston') - , moment = require('moment') - , LocalStrategy = require('passport-local').Strategy - , TokenAssertion = require('./verification').TokenAssertion - , User = require('../models/user') - , userTransformer = require('../transformers/user') - , { app, passport, tokenService } = require('./index') - , Authentication = require('../models/authentication'); - -function configure() { - log.info('Configuring local authentication'); - passport.use(new LocalStrategy( - function (username, password, done) { - User.getUserByUsername(username, function (err, user) { - if (err) { return done(err); } - - if (!user) { - log.warn('Failed login attempt: User with username ' + username + ' not found'); - return done(null, false, { message: 'Please check your username and password and try again.' }); - } - - if (!user.active) { - log.warn('Failed user login attempt: User ' + user.username + ' is not active'); - return done(null, false, { message: 'User account is not approved, please contact your MAGE administrator to approve your account.' }); - } - - if (!user.enabled) { - log.warn('Failed user login attempt: User ' + user.username + ' account is disabled.'); - return done(null, false, { message: 'Your account has been disabled, please contact a MAGE administrator for assistance.' }); - } - - const settings = user.authentication.security; - if (settings && settings.locked && moment().isBefore(moment(settings.lockedUntil))) { - log.warn('Failed user login attempt: User ' + user.username + ' account is locked until ' + settings.lockedUntil); - return done(null, false, { message: 'Your account has been temporarily locked, please try again later or contact a MAGE administrator for assistance.' }); - } - - if (!(user.authentication instanceof Authentication.Local)) { - log.warn(user.username + " is not a local account"); - return done(null, false, { message: 'You do not have a local account, please contact a MAGE administrator for assistance.' }); - } - - if (!user.authentication.authenticationConfiguration.enabled) { - log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, false, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } - - user.authentication.validatePassword(password, function (err, isValid) { - if (err) return done(err); - - if (isValid) { - User.validLogin(user) - .then(() => done(null, user)) - .catch(err => done(err)); - } else { - log.warn('Failed login attempt: User with username ' + username + ' provided an invalid password'); - User.invalidLogin(user) - .then(() => done(null, false, { message: 'Please check your username and password and try again.' })) - .catch(err => done(err)); - } - }); - }); - } - )); -} - -function initialize() { - configure(); - - app.post('/auth/local/signin', - function authenticate(req, res, next) { - passport.authenticate('local', function (err, user, info = {}) { - if (err) return next(err); - - if (!user) return res.status(401).send(info.message); - - tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5) - .then(token => { - res.json({ - token: token, - user: userTransformer.transform(user, { path: req.getRoot() }) - }); - }).catch(err => { - next(err); - }); - })(req, res, next); - } - ); -}; - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/oauth.js b/service/src/authentication/oauth.js deleted file mode 100644 index a531c69ed..000000000 --- a/service/src/authentication/oauth.js +++ /dev/null @@ -1,223 +0,0 @@ -'use strict'; - -const OAuth2Strategy = require('passport-oauth2').Strategy - , TokenAssertion = require('./verification').TokenAssertion - , base64 = require('base-64') - , api = require('../api') - , log = require('../logger') - , User = require('../models/user') - , Role = require('../models/role') - , { app, passport, tokenService } = require('./index'); - -class OAuth2ProfileStrategy extends OAuth2Strategy { - constructor(options, verify) { - super(options, verify); - - if (!options.profileURL) { throw new TypeError('OAuth2Strategy requires a profileURL option'); } - this._profileURL = options.profileURL; - - this._oauth2.useAuthorizationHeaderforGET(true); - } - - userProfile(accessToken, done) { - this._oauth2.get(this._profileURL, accessToken, function (err, body) { - if (err) { return done(new InternalOAuthError('Failed to fetch user profile', err)); } - - try { - const json = JSON.parse(body); - - const profile = {}; - profile.provider = 'oauth2'; - profile.raw = body; - profile.json = json; - - done(null, profile); - } catch (e) { - log.warn('Error parsing oauth profile', e); - done(e); - } - }); - } -} - -function configure(strategy) { - log.info('Configuring ' + strategy.title + ' authentication'); - - let customHeaders = null; - - if (strategy.settings.headers) { - customHeaders = {}; - if (strategy.settings.headers.basic) { - customHeaders['Authorization'] = `Basic ${base64.encode(`${strategy.settings.clientID}:${strategy.settings.clientSecret}`)}`; - } - } - - passport.use(strategy.name, new OAuth2ProfileStrategy({ - clientID: strategy.settings.clientID, - clientSecret: strategy.settings.clientSecret, - callbackURL: `/auth/${strategy.name}/callback`, - authorizationURL: strategy.settings.authorizationURL, - tokenURL: strategy.settings.tokenURL, - profileURL: strategy.settings.profileURL, - customHeaders: customHeaders, - scope: strategy.settings.scope, - pkce: strategy.settings.pkce, - store: true - }, function (accessToken, refreshToken, profileResponse, done) { - const profile = profileResponse.json; - - if (!profile[strategy.settings.profile.id]) { - log.warn("JSON: " + JSON.stringify(profile) + " RAW: " + profileResponse.raw); - return done(`OAuth2 user profile does not contain id property named ${strategy.settings.profile.id}`); - } - - const profileId = profile[strategy.settings.profile.id]; - - User.getUserByAuthenticationStrategy(strategy.type, profileId, function (err, user) { - if (err) return done(err); - - if (!user) { - // Create an account for the user - Role.getRole('USER_ROLE', function (err, role) { - if (err) return done(err); - - let email = null; - if (profile[strategy.settings.profile.email]) { - if (Array.isArray(profile[strategy.settings.profile.email])) { - email = profile[strategy.settings.profile.email].find(email => { - email.verified === true - }); - } else { - email = profile[strategy.settings.profile.email]; - } - } else { - log.warn(`OAuth2 user profile does not contain email property named ${strategy.settings.profile.email}`); - log.debug(JSON.stringify(profile)); - } - - const user = { - username: profileId, - displayName: profile[strategy.settings.profile.displayName] || profileId, - email: email, - active: false, - roleId: role._id, - authentication: { - type: strategy.type, - id: profileId, - authenticationConfiguration: { - name: strategy.name - } - } - }; - - new api.User().create(user).then(newUser => { - if (!newUser.authentication.authenticationConfiguration.enabled) { - log.warn(newUser.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, false, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } - return done(null, newUser); - }).catch(err => done(err)); - }); - } else if (!user.active) { - return done(null, user, { message: "User is not approved, please contact your MAGE administrator to approve your account." }); - } else if (!user.authentication.authenticationConfiguration.enabled) { - log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, user, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } else { - return done(null, user); - } - }); - })); -} - -function setDefaults(strategy) { - if (!strategy.settings.profile) { - strategy.settings.profile = {}; - } - if (!strategy.settings.profile.displayName) { - strategy.settings.profile.displayName = 'displayName'; - } - if (!strategy.settings.profile.email) { - strategy.settings.profile.email = 'email'; - } - if (!strategy.settings.profile.id) { - strategy.settings.profile.id = 'id'; - } -} - -function initialize(strategy) { - setDefaults(strategy); - - // TODO lets test with newer geoaxis server to see if this is still needed - // If it is, this should be a admin client side option, would also need to modify the - // renderer to provide a more generic message - strategy.redirect = false; - configure(strategy); - - function authenticate(req, res, next) { - passport.authenticate(strategy.name, function (err, user, info = {}) { - if (err) return next(err); - - req.user = user; - - // For inactive or disabled accounts don't generate an authorization token - if (!user.active || !user.enabled) { - log.warn('Failed user login attempt: User ' + user.username + ' account is inactive or disabled.'); - return next(); - } - - if (!user.authentication.authenticationConfigurationId) { - log.warn('Failed user login attempt: ' + user.authentication.type + ' is not configured'); - return next(); - } - - if (!user.authentication.authenticationConfiguration.enabled) { - log.warn('Failed user login attempt: Authentication ' + user.authentication.authenticationConfiguration.title + ' is disabled.'); - return next(); - } - - tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5) - .then(token => { - req.token = token; - req.user = user; - req.info = info; - next(); - }).catch(err => next(err)); - })(req, res, next); - } - - app.get(`/auth/${strategy.name}/signin`, - function (req, res, next) { - passport.authenticate(strategy.name, { - scope: strategy.settings.scope, - state: req.query.state - })(req, res, next); - } - ); - - app.get(`/auth/${strategy.name}/callback`, - authenticate, - function (req, res) { - if (req.query.state === 'mobile') { - let uri; - if (!req.user.active || !req.user.enabled) { - uri = `mage://app/invalid_account?active=${req.user.active}&enabled=${req.user.enabled}`; - } else { - uri = `mage://app/authentication?token=${req.token}` - } - - if (strategy.redirect) { - res.redirect(uri); - } else { - res.render('oauth', { uri: uri }); - } - } else { - res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } }); - } - } - ); -}; - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/openidconnect.js b/service/src/authentication/openidconnect.js deleted file mode 100644 index 88cfc9e1b..000000000 --- a/service/src/authentication/openidconnect.js +++ /dev/null @@ -1,167 +0,0 @@ -const OpenIdConnectStrategy = require('passport-openidconnect').Strategy - , log = require('winston') - , User = require('../models/user') - , Role = require('../models/role') - , TokenAssertion = require('./verification').TokenAssertion - , api = require('../api') - , { app, passport, tokenService } = require('./index'); - -function configure(strategy) { - log.info(`Configuring ${strategy.title} authentication`); - - passport.use(strategy.name, new OpenIdConnectStrategy({ - clientID: strategy.settings.clientID, - clientSecret: strategy.settings.clientSecret, - issuer: strategy.settings.issuer, - authorizationURL: strategy.settings.authorizationURL, - tokenURL: strategy.settings.tokenURL, - userInfoURL: strategy.settings.profileURL, - callbackURL: `/auth/${strategy.name}/callback`, - scope: strategy.settings.scope - }, function (issuer, uiProfile, profile, context, idToken, accessToken, refreshToken, params, done) { - const jsonProfile = uiProfile._json - const profileId = jsonProfile[strategy.settings.profile.id]; - if (!profileId) { - log.warn(JSON.stringify(jsonProfile)); - return done(`OIDC user profile does not contain id property ${strategy.settings.profile.id}`); - } - - User.getUserByAuthenticationStrategy(strategy.type, profileId, function (err, user) { - if (err) return done(err); - - if (!user) { - // Create an account for the user - Role.getRole('USER_ROLE', function (err, role) { - if (err) return done(err); - - const user = { - username: profileId, - displayName: jsonProfile[strategy.settings.profile.displayName] || profileId, - email: jsonProfile[strategy.settings.profile.email], - active: false, - roleId: role._id, - authentication: { - type: strategy.name, - id: profileId, - authenticationConfiguration: { - name: strategy.name - } - } - }; - - new api.User().create(user).then(newUser => { - if (!newUser.authentication.authenticationConfiguration.enabled) { - log.warn(newUser.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, false, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } - return done(null, newUser); - }).catch(err => done(err)); - }); - } else if (!user.active) { - return done(null, user, { message: "User is not approved, please contact your MAGE administrator to approve your account." }); - } else if (!user.authentication.authenticationConfiguration.enabled) { - log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, user, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } else { - return done(null, user); - } - }); - })); - - function authenticate(req, res, next) { - passport.authenticate(strategy.name, function (err, user, info = {}) { - if (err) return next(err); - - // TODO, this is a workaround for openidconnect library killing the app state - req.query.state = info.state - - req.user = user; - - // For inactive or disabled accounts don't generate an authorization token - if (!user.active || !user.enabled) { - log.warn('Failed user login attempt: User ' + user.username + ' account is inactive or disabled.'); - return next(); - } - - if (!user.authentication.authenticationConfigurationId) { - log.warn('Failed user login attempt: ' + user.authentication.type + ' is not configured'); - return next(); - } - - if (!user.authentication.authenticationConfiguration.enabled) { - log.warn('Failed user login attempt: Authentication ' + user.authentication.authenticationConfiguration.title + ' is disabled.'); - return next(); - } - - tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5) - .then(token => { - req.token = token; - req.user = user; - req.info = info - next(); - }).catch(err => { - next(err); - }); - })(req, res, next); - } - - app.get(`/auth/${strategy.name}/callback`, - authenticate, - function (req, res) { - if (req.query.state === 'mobile') { - let uri; - if (!req.user.active || !req.user.enabled) { - uri = `mage://app/invalid_account?active=${req.user.active}&enabled=${req.user.enabled}`; - } else { - uri = `mage://app/authentication?token=${req.token}` - } - - res.redirect(uri); - } else { - res.render('authentication', { host: req.getRoot(), success: true, login: { token: req.token, user: req.user } }); - } - } - ); -} - -function setDefaults(strategy) { - //openid must be included in scope - if (!strategy.settings.scope) { - strategy.settings.scope = ['openid']; - } else { - if (!strategy.settings.scope.includes('openid')) { - strategy.settings.scope.push('openid'); - } - } - - if (!strategy.settings.profile) { - strategy.settings.profile = {}; - } - if (!strategy.settings.profile.displayName) { - strategy.settings.profile.displayName = 'name'; - } - if (!strategy.settings.profile.email) { - strategy.settings.profile.email = 'email'; - } - if (!strategy.settings.profile.id) { - strategy.settings.profile.id = 'sub'; - } -} - -function initialize(strategy) { - configure(strategy); - setDefaults(strategy); - - app.get(`/auth/${strategy.name}/signin`, - function (req, res, next) { - passport.authenticate(strategy.name, { - scope: strategy.settings.scope, - state: req.query.state - })(req, res, next); - } - ); -}; - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/saml.js b/service/src/authentication/saml.js deleted file mode 100644 index 0c1a5ffaa..000000000 --- a/service/src/authentication/saml.js +++ /dev/null @@ -1,330 +0,0 @@ -const SamlStrategy = require('@node-saml/passport-saml').Strategy - , log = require('winston') - , User = require('../models/user') - , Role = require('../models/role') - , Device = require('../models/device') - , TokenAssertion = require('./verification').TokenAssertion - , api = require('../api') - , userTransformer = require('../transformers/user') - , AuthenticationInitializer = require('./index') - , authenticationApiAppender = require('../utilities/authenticationApiAppender'); - -function configure(strategy) { - log.info('Configuring ' + strategy.title + ' authentication'); - - const options = { - path: `/auth/${strategy.name}/callback`, - entryPoint: strategy.settings.entryPoint, - cert: strategy.settings.cert, - issuer: strategy.settings.issuer - } - if (strategy.settings.privateKey) { - options.privateKey = strategy.settings.privateKey; - } - if (strategy.settings.decryptionPvk) { - options.decryptionPvk = strategy.settings.decryptionPvk; - } - if (strategy.settings.signatureAlgorithm) { - options.signatureAlgorithm = strategy.settings.signatureAlgorithm; - } - if(strategy.settings.audience) { - options.audience = strategy.settings.audience; - } - if(strategy.settings.identifierFormat) { - options.identifierFormat = strategy.settings.identifierFormat; - } - if(strategy.settings.acceptedClockSkewMs) { - options.acceptedClockSkewMs = strategy.settings.acceptedClockSkewMs; - } - if(strategy.settings.attributeConsumingServiceIndex) { - options.attributeConsumingServiceIndex = strategy.settings.attributeConsumingServiceIndex; - } - if(strategy.settings.disableRequestedAuthnContext) { - options.disableRequestedAuthnContext = strategy.settings.disableRequestedAuthnContext; - } - if(strategy.settings.authnContext) { - options.authnContext = strategy.settings.authnContext; - } - if(strategy.settings.forceAuthn) { - options.forceAuthn = strategy.settings.forceAuthn; - } - if(strategy.settings.skipRequestCompression) { - options.skipRequestCompression = strategy.settings.skipRequestCompression; - } - if(strategy.settings.authnRequestBinding) { - options.authnRequestBinding = strategy.settings.authnRequestBinding; - } - if(strategy.settings.RACComparison) { - options.RACComparison = strategy.settings.RACComparison; - } - if(strategy.settings.providerName) { - options.providerName = strategy.settings.providerName; - } - if(strategy.settings.idpIssuer) { - options.idpIssuer = strategy.settings.idpIssuer; - } - if(strategy.settings.validateInResponseTo) { - options.validateInResponseTo = strategy.settings.validateInResponseTo; - } - if(strategy.settings.requestIdExpirationPeriodMs) { - options.requestIdExpirationPeriodMs = strategy.settings.requestIdExpirationPeriodMs; - } - if(strategy.settings.logoutUrl) { - options.logoutUrl = strategy.settings.logoutUrl; - } - - AuthenticationInitializer.passport.use(new SamlStrategy(options, function (profile, done) { - const uid = profile[strategy.settings.profile.id]; - - if (!uid) { - log.warn('Failed to find property uid. SAML profile keys ' + Object.keys(profile)); - return done('Failed to load user id from SAML profile'); - } - - User.getUserByAuthenticationStrategy(strategy.type, uid, function (err, user) { - if (err) return done(err); - - if (!user) { - // Create an account for the user - Role.getRole('USER_ROLE', function (err, role) { - if (err) return done(err); - - const user = { - username: uid, - displayName: profile[strategy.settings.profile.displayName], - email: profile[strategy.settings.profile.email], - active: false, - roleId: role._id, - authentication: { - type: strategy.name, - id: uid, - authenticationConfiguration: { - name: strategy.name - } - } - }; - - new api.User().create(user).then(newUser => { - if (!newUser.authentication.authenticationConfiguration.enabled) { - log.warn(newUser.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, false, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } - return done(null, newUser); - }).catch(err => done(err)); - }); - } else if (!user.active) { - return done(null, user, { message: "User is not approved, please contact your MAGE administrator to approve your account." }); - } else if (!user.authentication.authenticationConfiguration.enabled) { - log.warn(user.authentication.authenticationConfiguration.title + " authentication is not enabled"); - return done(null, user, { message: 'Authentication method is not enabled, please contact a MAGE administrator for assistance.' }); - } else { - return done(null, user); - } - }); - })); - - function authenticate(req, res, next) { - AuthenticationInitializer.passport.authenticate(strategy.name, function (err, user, info = {}) { - if (err) { - console.error('saml: authentication error', err); - return next(err); - } - - req.user = user; - - // For inactive or disabled accounts don't generate an authorization token - if (!user.active || !user.enabled) { - log.warn('Failed user login attempt: User ' + user.username + ' account is inactive or disabled.'); - return next(); - } - - if (!user.authentication.authenticationConfigurationId) { - log.warn('Failed user login attempt: ' + user.authentication.type + ' is not configured'); - return next(); - } - - if (!user.authentication.authenticationConfiguration.enabled) { - log.warn('Failed user login attempt: Authentication ' + user.authentication.authenticationConfiguration.title + ' is disabled.'); - return next(); - } - - // DEPRECATED session authorization, remove req.login which creates session in next version - req.login(user, function (err) { - if (err) { - return next(err); - } - AuthenticationInitializer.tokenService.generateToken(user._id.toString(), TokenAssertion.Authorized, 60 * 5) - .then(token => { - req.token = token; - req.user = user; - req.info = info - next(); - }).catch(err => { - next(err); - }); - }); - })(req, res, next); - } - - AuthenticationInitializer.app.post( - `/auth/${strategy.name}/callback`, - authenticate, - function (req, res) { - let state = {}; - try { - state = JSON.parse(req.body.RelayState) - } catch (ignore) { - console.warn('saml: error parsing RelayState', ignore) - } - - if (state.initiator === 'mage') { - if (state.client === 'mobile') { - let uri; - if (!req.user.active || !req.user.enabled) { - uri = `mage://app/invalid_account?active=${req.user.active}&enabled=${req.user.enabled}`; - } else { - uri = `mage://app/authentication?token=${req.token}` - } - - res.redirect(uri); - } else { - res.render('authentication', { host: req.getRoot(), login: { token: req.token, user: req.user } }); - } - } else { - if (req.user.active && req.user.enabled) { - res.redirect(`/#/signin?strategy=${strategy.name}&action=authorize-device&token=${req.token}`); - } else { - const action = !req.user.active ? 'inactive-account' : 'disabled-account'; - res.redirect(`/#/signin?strategy=${strategy.name}&action=${action}`); - } - } - } - ); -} - -function setDefaults(strategy) { - if (!strategy.settings.profile) { - strategy.settings.profile = {}; - } - if (!strategy.settings.profile.displayName) { - strategy.settings.profile.displayName = 'email'; - } - if (!strategy.settings.profile.email) { - strategy.settings.profile.email = 'email'; - } - if (!strategy.settings.profile.id) { - strategy.settings.profile.id = 'uid'; - } -} - -function initialize(strategy) { - const app = AuthenticationInitializer.app; - const passport = AuthenticationInitializer.passport; - const provision = AuthenticationInitializer.provision; - - setDefaults(strategy); - configure(strategy); - - function parseLoginMetadata(req, res, next) { - req.loginOptions = { - userAgent: req.headers['user-agent'], - appVersion: req.param('appVersion') - }; - - next(); - } - app.get( - '/auth/' + strategy.name + '/signin', - function (req, res, next) { - const state = { - initiator: 'mage', - client: req.query.state - }; - - passport.authenticate(strategy.name, { - additionalParams: { RelayState: JSON.stringify(state) } - })(req, res, next); - } - ); - - // DEPRECATED retain old routes as deprecated until next major version. - // Create a new device - // Any authenticated user can create a new device, the registered field - // will be set to false. - app.post('/auth/' + strategy.name + '/devices', - function (req, res, next) { - if (req.user) { - next(); - } else { - res.sendStatus(401); - } - }, - function (req, res, next) { - const newDevice = { - uid: req.param('uid'), - name: req.param('name'), - registered: false, - description: req.param('description'), - userAgent: req.headers['user-agent'], - appVersion: req.param('appVersion'), - userId: req.user.id - }; - - Device.getDeviceByUid(newDevice.uid) - .then(device => { - if (device) { - // already exists, do not register - return res.json(device); - } - - Device.createDevice(newDevice) - .then(device => res.json(device)) - .catch(err => next(err)); - }) - .catch(err => next(err)); - } - ); - - // DEPRECATED session authorization, remove in next version. - app.post( - '/auth/' + strategy.name + '/authorize', - function (req, res, next) { - if (req.user) { - log.warn('session authorization is deprecated, please use jwt'); - return next(); - } - - passport.authenticate('authorization', function (err, user, info = {}) { - if (!user) return res.status(401).send(info.message); - - req.user = user; - next(); - })(req, res, next); - }, - provision.check(strategy.name), - parseLoginMetadata, - function (req, res, next) { - new api.User().login(req.user, req.provisionedDevice, req.loginOptions, function (err, token) { - if (err) return next(err); - - authenticationApiAppender.append(strategy.api).then(api => { - res.json({ - token: token.token, - expirationDate: token.expirationDate, - user: userTransformer.transform(req.user, { path: req.getRoot() }), - device: req.provisionedDevice, - api: api - }); - }).catch(err => { - next(err); - }); - }); - - req.session = null; - } - ); -} - -module.exports = { - initialize -} \ No newline at end of file diff --git a/service/src/authentication/verification.js b/service/src/authentication/verification.js deleted file mode 100644 index e361929de..000000000 --- a/service/src/authentication/verification.js +++ /dev/null @@ -1,152 +0,0 @@ -const JWT = require('jsonwebtoken'); - -function payload(subject, assertion) { - return { subject: `${subject}`, assertion: assertion }; -} - -const VerificationErrorReason = { - NoToken: 'no_token', - Subject: 'subject', - Assertion: 'assertion', - Expired: 'expired', - Decode: 'decode', - Implementation: 'implementation' -} - -const TokenAssertion = { - Authorized: 'urn:mage:auth:authorized', - Captcha: 'urn:mage:signup:captcha' -} - -class TokenGenerateError extends Error { - - constructor(subject, assertion, secondsToLive, cause) { - super(); - this.name = 'TokenVerificationError'; - this.subject = subject; - this.assertion = assertion; - this.secondsToLive = secondsToLive; - this.cause = cause; - let causeMessage = ''; - if (cause) { - causeMessage = 'cause=' + (cause instanceof Error ? `${"\n" + cause.stack || `${cause.name}: ${cause.message}`}` : `${cause}`) - } - this.message = `error generating token: subject=${subject}; assertion=${assertion}; secondsToLive=${secondsToLive}; ${causeMessage}` - } -} - -class TokenVerificationError extends Error { - /** - * @param reason why the verification failed - * @param subject the expected subject - * @param assertion the expected assertion - * @param token the encoded token string - * @param decoded the decoded payload of the token - * @param cause cause of the verification error from the underlying token implementation - */ - constructor(reason, expected, token, decoded, cause) { - super(); - this.reason = reason; - this.expected = expected || { subject: null, assertion: null }; - this.token = token; - this.decoded = decoded; - let reasonMessage = `${reason}`; - let causeMessage = ''; - if (cause) { - causeMessage = 'cause=' + (cause instanceof Error ? `${"\n" + cause.stack || `${cause.name}: ${cause.message}`}` : `${cause}`) - } - - decoded = decoded || { subject: null, assertion: null, expiration: null }; - if (reason === VerificationErrorReason.Subject) { - reasonMessage = `subject was ${decoded.subject}` - } else if (reason === VerificationErrorReason.Assertion) { - reasonMessage = `assertion was ${decoded.assertion}` - } else if (reason === VerificationErrorReason.Expired) { - reasonMessage = `token expired ${new Date(decoded.expiration || 0)} (${decoded.expiration})` - } else if (reason === VerificationErrorReason.Decode) { - reasonMessage = `failed to decode token`; - } - - this.message = `TokenVerificationError (${reasonMessage}): expected subject=${this.expected.subject}, assertion=${this.expected.assertion}; token=${token}; ${causeMessage}` - } -} - -const JWTService = class { - - constructor(secret, issuer) { - this._secret = secret; - this._issuer = issuer; - this._algorithm = 'HS256'; - } - - generateToken(subject, assertion, secondsToLive, claims = {}) { - return new Promise((resolve, reject) => { - const payload = Object.assign({ assertion: assertion }, claims); - - JWT.sign(payload, this._secret, { - algorithm: this._algorithm, - issuer: this._issuer, - expiresIn: secondsToLive, - subject: subject, - },(err, token) => { - if (err) { - reject(new TokenGenerateError(subject, assertion, secondsToLive, err)); - } else { - resolve(token); - } - }); - }); - } - - verifyToken(token, expected) { - return new Promise((resolve, reject) => { - JWT.verify(token, this._secret, { algorithms: [this._algorithm], issuer: this._issuer }, (err, payload) => { - if (!expected) { - expected = { subject: null, assertion: null }; - } - - if (err) { - let reason = VerificationErrorReason.Implementation; - let decoded = null; - try { - const jwt = JWT.decode(token, { json: true }); - if (jwt) { - decoded = Object.assign({}, jwt, { - subject: jwt.sub || null, - assertion: jwt.assertion || null, - expiration: jwt.exp || null - }); - } - } catch (err) { - reason = VerificationErrorReason.Decode; - } - - if (!decoded) { - reason = VerificationErrorReason.Decode; - decoded = { subject: null, assertion: null, expiration: null }; - } else if (err instanceof JWT.TokenExpiredError) { - reason = VerificationErrorReason.Expired; - } - return reject(new TokenVerificationError(reason, expected, token, payload, err)); - } else if (expected.subject && payload.sub !== expected.subject) { - return reject(new TokenVerificationError(VerificationErrorReason.Subject, expected, token, payload)) - } else if (expected.assertion && payload.assertion !== expected.assertion) { - return reject(new TokenVerificationError(VerificationErrorReason.Assertion, expected, token, payload)); - } - - resolve(Object.assign({}, payload, { - subject: payload.sub || null, - assertion: payload.assertion || null, - expiration: payload.exp || null - })); - - }); - }); - } -} - -exports.payload = payload; -exports.TokenAssertion = TokenAssertion; -exports.TokenGenerateError = TokenGenerateError; -exports.VerificationErrorReason = VerificationErrorReason; -exports.JWTService = JWTService; diff --git a/service/src/docs/openapi.yaml b/service/src/docs/openapi.yaml index 8da5c9b1c..80d6271df 100644 --- a/service/src/docs/openapi.yaml +++ b/service/src/docs/openapi.yaml @@ -461,36 +461,6 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/User' } - /api/users/myself/status: - put: - tags: [ User ] - description: Update the status of the requesting user. - operationId: updateMyStatus - requestBody: - content: - application/json: - schema: - type: object - properties: - status: - type: string - required: [ status ] - responses: - 200: - description: Successful status update - content: - application/json: - schema: { $ref: '#/components/schemas/User' } - delete: - tags: [ User ] - description: Delete the status of the requesting user. - operationId: deleteMyStatus - responses: - 200: - description: Successfully deleted status - content: - application/json: - schema: { $ref: '#/components/schemas/User' } /api/users/{userId}: parameters: - { $ref: '#/components/parameters/userIdInPath' } @@ -1677,16 +1647,6 @@ paths: user must have `READ_OBSERVATION_ALL` permission, or have `READ_OBSERVATION_EVENT` permission and an ACL entry on the event with `read` permission. - parameters: &obsQueryParams - - $ref: '#/components/parameters/observationQuery.fields' - - $ref: '#/components/parameters/observationQuery.startDate' - - $ref: '#/components/parameters/observationQuery.endDate' - - $ref: '#/components/parameters/observationQuery.observationStartDate' - - $ref: '#/components/parameters/observationQuery.observationEndDate' - - $ref: '#/components/parameters/observationQuery.bbox' - - $ref: '#/components/parameters/observationQuery.geometry' - - $ref: '#/components/parameters/observationQuery.states' - - $ref: '#/components/parameters/observationQuery.sort' responses: 200: description: observation response @@ -1733,13 +1693,11 @@ paths: # the update to js-yaml 4.1.x through openapi-enforcer, yaml anchors seem # to be broken parameters: - - $ref: '#/components/parameters/observationQuery.fields' - $ref: '#/components/parameters/observationQuery.startDate' - $ref: '#/components/parameters/observationQuery.endDate' - $ref: '#/components/parameters/observationQuery.observationStartDate' - $ref: '#/components/parameters/observationQuery.observationEndDate' - $ref: '#/components/parameters/observationQuery.bbox' - - $ref: '#/components/parameters/observationQuery.geometry' - $ref: '#/components/parameters/observationQuery.states' - $ref: '#/components/parameters/observationQuery.sort' responses: @@ -3064,8 +3022,6 @@ components: type: string displayName: type: string - status: - type: string email: type: string format: email @@ -3125,7 +3081,6 @@ components: iconMetadata: type: object properties: - type: { $ref: '#/components/schemas/UserIcon/properties/type' } color: { $ref: '#/components/schemas/UserIcon/properties/color' } text: { $ref: '#/components/schemas/UserIcon/properties/text' } icon: @@ -3159,13 +3114,6 @@ components: location. type: object properties: - type: - description: The origin of the icon - type: string - enum: - - create - - upload - - none color: description: Color is only applicable for `create` type icons. $ref: '#/components/schemas/ColorHex' @@ -4404,16 +4352,6 @@ components: description: The ID of the target attachment document required: true schema: { $ref: '#/components/schemas/Attachment/properties/id' } - observationQuery.fields: - in: query - name: fields - description: > - The form fields to project in the result observation documents (JSON) - explode: false - schema: - type: array - items: - type: string observationQuery.startDate: in: query name: startDate @@ -4421,8 +4359,7 @@ components: type: string format: date-time description: > - The low end of the range for the observations' `lastModified` - property + The low end of the range for the observations' `lastModified` property observationQuery.endDate: in: query name: endDate @@ -4430,8 +4367,7 @@ components: type: string format: date-time description: > - The high end of the range for the observations' `lastModified` - property + The high end of the range for the observations' `lastModified` property observationQuery.observationStartDate: in: query name: observationStartDate @@ -4439,8 +4375,7 @@ components: type: string format: date-time description: > - The low end of the range for the observations' `timestamp` - property + The low end of the range for the observations' `timestamp` property observationQuery.observationEndDate: in: query name: observationEndDate @@ -4448,8 +4383,7 @@ components: type: string format: date-time description: > - The low end of the range for the observations' `lastModified` - property + The high end of the range for the observations' `timestamp` property observationQuery.bbox: in: query name: bbox @@ -4459,15 +4393,6 @@ components: explode: false schema: $ref: 'geojson.yaml#/definitions/boundingBox' - observationQuery.geometry: - in: query - name: geometry - description: > - A URL-encoded, stringified JSON object that is a GeoJSON geometry - as defined in geojson.yaml#/definitions/geometryObject - schema: - type: string - format: json observationQuery.states: in: query name: states diff --git a/service/src/entities/authentication/entities.authentication.ts b/service/src/entities/authentication/entities.authentication.ts deleted file mode 100644 index aae1fd6ac..000000000 --- a/service/src/entities/authentication/entities.authentication.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Authentication { - id: string - type: string -} - -/** - * TODO: move somewhere else - */ -export interface SecurityStatus { - locked: boolean - lockedUntil: Date - invalidLoginAttempts: number - numberOfTimesLocked: number -} diff --git a/service/src/entities/authorization/entities.authorization.ts b/service/src/entities/authorization/entities.authorization.ts index 8c9502ea5..974b717a2 100644 --- a/service/src/entities/authorization/entities.authorization.ts +++ b/service/src/entities/authorization/entities.authorization.ts @@ -1,7 +1,10 @@ +import { AnyPermission } from './entities.permissions' + +export type RoleId = string export interface Role { - id: string + id: RoleId name: string description?: string - permissions: string[] + permissions: AnyPermission[] } diff --git a/service/src/entities/devices/entities.devices.ts b/service/src/entities/devices/entities.devices.ts new file mode 100644 index 000000000..036475264 --- /dev/null +++ b/service/src/entities/devices/entities.devices.ts @@ -0,0 +1,65 @@ +import { PageOf, PagingParameters } from '../entities.global' +import { User, UserId } from '../users/entities.users' + +export type DeviceId = string +export type DeviceUid = string + +export interface Device { + id: DeviceId + /** + * The UID originates from the client, whereas the server generates ID. + */ + uid: DeviceUid + description?: string | undefined + /** + * A registered device is one that has received approval from an administrator to access the Mage server. + */ + registered: boolean + /** + * The device's `userId` is the user that owns the device. If the user ID is null, multiple users may access Mage + * with the device. + */ + userId?: UserId | null + userAgent?: string | undefined + appVersion?: string | undefined +} + +/** + * The related user entry will have only the user's ID and display name, but no other attributes. + */ +export type DeviceExpanded = Device & { user: Pick | null } + +export interface FindDevicesSpec { + where: { + /** + * If present and `true` or `false`, match only devices with that value for the `registered` property. Otherwise, + * match any device regardless of registered status. + */ + registered?: boolean | null | undefined + /** + * Match only devices whose `userAgent`, `description`, or `uid` contain the given search term. + */ + containsSearchTerm?: string | null | undefined + /** + * Match devices whose `userId` is in the given array. This constraint is independent of `containsSearchTerm`, but + * subject to the `registered` constraint. + */ + userIdIsAnyOf?: UserId[] + } + /** + * If `true`, return {@link DeviceExpanded results} + */ + expandUser?: boolean | undefined + paging: PagingParameters +} + +export interface DeviceRepository { + create(deviceAttrs: Omit): Promise + update(deviceAttrs: Partial & Pick): Promise + removeById(id: DeviceId): Promise + findById(id: DeviceId): Promise + findByUid(uid: DeviceUid): Promise + findSome(findSpec: FindDevicesSpec & { expandUser: false | undefined | never }): Promise> + findSome(findSpec: FindDevicesSpec & { expandUser: true }): Promise> + countSome(findSpec: FindDevicesSpec): Promise +} \ No newline at end of file diff --git a/service/src/entities/entities.global.ts b/service/src/entities/entities.global.ts index 8b637191e..e728b6238 100644 --- a/service/src/entities/entities.global.ts +++ b/service/src/entities/entities.global.ts @@ -76,53 +76,39 @@ export interface PagingParameters { } export interface PageOf { - totalCount: number | null; - pageSize: number; - pageIndex: number; - items: T[]; + totalCount: number | null + pageSize: number + pageIndex: number + items: T[] links?: { - next: number | null; - prev: number | null; - }; + next: number | null + prev: number | null + } } export interface Links { - next: number | null; - prev: number | null; + next: number | null + prev: number | null } -export function calculateLinks( paging: PagingParameters, totalCount: number | null): Links { - const links: Links = { - next: null, - prev: null - }; - - const limit = paging.pageSize; - const start = paging.pageIndex * limit; - - if (start + limit < (totalCount || 0)) { - links.next = paging.pageIndex + 1; - } - - if (start > 0) { - links.prev = Math.max(0, paging.pageIndex - 1); - } - - return links; +export function calculatePagingLinks(paging: PagingParameters, totalCount: number | null): Links { + const limit = paging.pageSize + const start = paging.pageIndex * limit + const next = start + paging.pageSize < (totalCount || 0) ? paging.pageIndex + 1 : null + const prev = paging.pageIndex > 0 ? paging.pageIndex - 1 : null + return { next, prev } } export const pageOf = (items: T[], paging: PagingParameters, totalCount?: number | null): PageOf => { - // Provide a default value for totalCount if it's undefined const resolvedTotalCount = totalCount || 0; - const links = calculateLinks(paging, resolvedTotalCount); - + const links = calculatePagingLinks(paging, resolvedTotalCount) return { - totalCount: typeof totalCount === 'number' ? totalCount : null, - pageSize: paging.pageSize, + totalCount: totalCount || null, + pageSize: typeof paging.pageSize === 'number' ? paging.pageSize : -1, pageIndex: paging.pageIndex, items, links - }; + } } /** diff --git a/service/src/entities/events/entities.events.ts b/service/src/entities/events/entities.events.ts index c5d238815..0b57aa0a3 100644 --- a/service/src/entities/events/entities.events.ts +++ b/service/src/entities/events/entities.events.ts @@ -175,25 +175,22 @@ export interface Acl { export type MageEventCreateAttrs = Pick export interface MageEventRepository { - findAll(): Promise + findAll(): Promise findById(id: MageEventId): Promise - findAllByIds(ids: MageEventId[]): Promise<{ [id: number]: MageEventAttrs | null }> + findAllByIds(ids: MageEventId[]): Promise<{ [id: number]: MageEvent | null }> /** * Return all the MAGE events that are not {@link MageEventAttrs.complete | complete}. */ - findActiveEvents(): Promise + findActiveEvents(): Promise /** * Add a reference to the given feed ID on the given event. - * @param event an Event ID - * @param feed a Feed ID */ - addFeedsToEvent(event: MageEventId, ...feeds: FeedId[]): Promise + addFeedsToEvent(event: MageEventId, ...feeds: FeedId[]): Promise findTeamsInEvent(event: MageEventId): Promise - removeFeedsFromEvent(event: MageEventId, ...feeds: FeedId[]): Promise + removeFeedsFromEvent(event: MageEventId, ...feeds: FeedId[]): Promise /** * Remove the given feeds from any events that reference the feed. Return the * count of events the operation modified. - * @param feed the ID of the feed to remove from events */ removeFeedsFromEvents(...feed: FeedId[]): Promise } diff --git a/service/src/entities/feeds/entities.feeds.ts b/service/src/entities/feeds/entities.feeds.ts index 412b215b6..6ec6ce72e 100644 --- a/service/src/entities/feeds/entities.feeds.ts +++ b/service/src/entities/feeds/entities.feeds.ts @@ -422,7 +422,7 @@ export function validateSchemaPropertyReferencesForFeed key ? schemaProps.hasOwnProperty(key) : true)) { + if (referencedProps.every(key => key ? Object.prototype.hasOwnProperty.call(schemaProps, key) : true)) { return feed } return new FeedsError(ErrInvalidFeedAttrs, { invalidKeys: [ 'itemPropertiesSchema' ] }, diff --git a/service/src/entities/observations/entities.observations.ts b/service/src/entities/observations/entities.observations.ts index 72955f2c3..835bbac76 100644 --- a/service/src/entities/observations/entities.observations.ts +++ b/service/src/entities/observations/entities.observations.ts @@ -1,4 +1,4 @@ -import { UserId } from '../users/entities.users' +import { User, UserId } from '../users/entities.users' import { BBox, Feature, Geometry } from 'geojson' import { MageEvent, MageEventAttrs, MageEventId } from '../events/entities.events' import { PageOf, PagingParameters, PendingEntityId } from '../entities.global' @@ -16,6 +16,9 @@ export interface ObservationAttrs extends Feature | undefined @@ -31,6 +34,10 @@ export interface ObservationAttrs extends Feature +export type AttachmentCreateAttrs = Omit export type AttachmentPatchAttrs = Partial export type AttachmentContentPatchAttrs = Required> export type ThumbnailContentPatchAttrs = Required> & Thumbnail @@ -828,7 +857,13 @@ export interface EventScopedObservationRepository { * @returns an `Observation` object or `null` */ findLatest(): Promise + /** + * @deprecated TODO: `findSome()` makes this obsolete. One of the plugins might use this one. + */ findLastModifiedAfter(timestamp: number, paging: PagingParameters): Promise> + findSome(findSpec: FindObservationsSpecUnpopulated, mapping?: (o: ObservationAttrs) => T): Promise> + findSome(findSpec: FindObservationsSpecPopulated, mapping?: (o: UsersExpandedObservationAttrs) => T): Promise> + findSome(findSpec: FindObservationsSpec, mapping?: (o: ObservationAttrs | UsersExpandedObservationAttrs) => T): Promise> /** * Update the specified attachment with the given attributes. This * persistence function exists alongside the {@link save} method to prevent @@ -862,8 +897,59 @@ export interface EventScopedObservationRepository { nextAttachmentIds(count?: number): Promise } -export class ObservationRepositoryError extends Error { +export type FindObservationsSortField = 'lastModified' | 'timestamp' + +export interface FindObservationsSort { + /** + * The default sort field is `lastModified`. + */ + field: FindObservationsSortField + /** + * `1` indicates ascending, `-1` indicates descending. Ascending is the default order. + */ + order?: 1 | -1 +} +export interface FindObservationsSpec { + where: { + lastModifiedAfter?: Date + lastModifiedBefore?: Date + timestampAfter?: Date + timestampBefore?: Date + /** + * A series of lon/lat coordinates in the order [ west, south, east, north ] as in + * https://datatracker.ietf.org/doc/html/rfc7946#section-5. + */ + geometryIntersects?: [ number, number, number, number ] + stateIsAnyOf?: ObservationStateName[] + /** + * Specifying a boolean value finds only observations conforming to the value, whereas omitting or specifiying + * `null` causes the query to disregard the important flag. + */ + isFlaggedImportant?: boolean | null, + isFavoriteOfUser?: UserId, + } + /** + * If `true`, populate the display names from related user documents on the observation creator and important flag. + * This results in adding `user: { id: string, displayName: string }` entries on the observation document and + * important sub-document. + */ + populateUserNames?: boolean + orderBy?: FindObservationsSort + paging?: PagingParameters +} +type FindObservationsSpecPopulated = FindObservationsSpec & { populateUserNames: true } +type FindObservationsSpecUnpopulated = Omit | (FindObservationsSpec & { populateUserNames: false }) + +export type ObservationUserExpanded = Pick +export type UsersExpandedObservationAttrs = ObservationAttrs & { + user?: ObservationUserExpanded + important?: UserExpandedObservationImportantFlag +} + +export type UserExpandedObservationImportantFlag = ObservationImportantFlag & { user?: ObservationUserExpanded } + +export class ObservationRepositoryError extends Error { constructor(readonly code: ObservationRepositoryErrorCode, message?: string) { super(message) } @@ -1157,7 +1243,7 @@ const FieldTypeValidationRules: { [type in FormFieldType]: FormFieldValidationRu [FormFieldType.Email]: validateRequiredThen(context => fields.email.EmailFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))), [FormFieldType.Geometry]: validateRequiredThen(context => fields.geometry.GeometryFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))), // TODO: no validation at all? legacy validation code did nothing for hidden fields - [FormFieldType.Hidden]: context => null, + [FormFieldType.Hidden]: () => null, [FormFieldType.MultiSelectDropdown]: validateRequiredThen(context => fields.multiselect.MultiSelectFormFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))), [FormFieldType.Numeric]: validateRequiredThen(context => fields.numeric.NumericFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))), [FormFieldType.Password]: validateRequiredThen(context => fields.text.TextFieldValidation(context.field, context.fieldEntry, FormFieldValidationResult(context))), diff --git a/service/src/entities/teams/entities.teams.ts b/service/src/entities/teams/entities.teams.ts index 08cabdf5f..df874cdf3 100644 --- a/service/src/entities/teams/entities.teams.ts +++ b/service/src/entities/teams/entities.teams.ts @@ -14,10 +14,17 @@ export interface Team { * that MAGE creates for each event. When an event manager or administrator * adds participant users to an event individually, as opposed to an entire * team, MAGE places the users in the event's _event team_. + * + * TODO: this should perhaps be an encapsulated detail of the data layer, + * i.e., the notion that an event has an implicit team to manage direct/adhoc + * user membership. */ teamEventId?: MageEventId } +/** + * TODO: move to permission/authorization module + */ export interface TeamAcl { [userId: string]: { role: TeamMemberRole, diff --git a/service/src/entities/users/entities.users.ts b/service/src/entities/users/entities.users.ts index 93cd0b25b..1da61041b 100644 --- a/service/src/entities/users/entities.users.ts +++ b/service/src/entities/users/entities.users.ts @@ -1,6 +1,6 @@ import { PagingParameters, PageOf } from '../entities.global' import { Role } from '../authorization/entities.authorization' -import { Authentication } from '../authentication/entities.authentication' +import { MageEventId } from '../events/entities.events' export type UserId = string @@ -8,22 +8,35 @@ export interface User { id: UserId username: string displayName: string + /** + * Active indicates whether an admin approved the user account. This flag only ever changes value one time. + */ active: boolean + /** + * The enabled flag indicates whether a user can access Mage and perform any operations. An administrator can + * disable a user account at any time to block the user's access. + */ enabled: boolean createdAt: Date lastUpdated: Date + roleId: string email?: string phones: Phone[] - roleId: string - authenticationId: string - // TODO: the rest of the properties + /** + * A user's avatar is the profile picture that represents the user in list views and such. + * TODO: make this nullable rather than an empty object. that is a symptom of the mongoose schema. make sure a null value does not break clients + */ + avatar: Avatar + /** + * The purpose of the user icon is to represent the user's location on a map display. + */ + icon: UserIcon + recentEventIds: MageEventId[] } -export type UserExpanded = Omit - & { - role: Role - authentication: Authentication - } +export interface UserExpanded extends User { + role: Role +} export interface Phone { type: string, @@ -31,27 +44,17 @@ export interface Phone { } /** - * A user's icon is what appears on the map to mark the user's location. + * A user's icon is the image that appears on the map to indicate the user's location. If the icon has text and color, + * a client can choose to render that rather than the raster image the server stores. */ export interface UserIcon { - type: UserIconType - text: string - color: string + text?: string + color?: string contentType?: string size?: number relativePath?: string } -export enum UserIconType { - None = 'none', - Upload = 'upload', - Create = 'create', -} - -/** - * A user's avatar is the profile picture that represents the user in list - * views and such. - */ export interface Avatar { contentType?: string, size?: number, @@ -59,9 +62,19 @@ export interface Avatar { } export interface UserRepository { - findById(id: UserId): Promise + create(userAttrs: Omit): Promise + /** + * Return `null` if the specified user ID does not exist. + */ + update(userAttrs: Partial & Pick): Promise + findById(id: UserId): Promise + findByUsername(username: string): Promise findAllByIds(ids: UserId[]): Promise<{ [id: string]: User | null }> find(which?: UserFindParameters, mapping?: (user: User) => MappedResult): Promise> + saveMapIcon(userId: UserId, icon: UserIcon, content: NodeJS.ReadableStream | Buffer): Promise + saveAvatar(userId: UserId, avatar: Avatar, content: NodeJS.ReadableStream | Buffer): Promise + deleteMapIcon(userId: UserId): Promise + deleteAvatar(userId: UserId): Promise } export interface UserFindParameters extends PagingParameters { @@ -72,3 +85,14 @@ export interface UserFindParameters extends PagingParameters { active?: boolean | undefined enabled?: boolean | undefined } + +export class UserRepositoryError extends Error { + constructor(public errorCode: UserRepositoryErrorCode, message?: string) { + super(message) + } +} + +export enum UserRepositoryErrorCode { + DuplicateUserName = 'DuplicateUserName', + StorageError = 'StorageError', +} \ No newline at end of file diff --git a/service/src/export/csv.ts b/service/src/export/csv.ts index 8df82aebd..d255f539c 100644 --- a/service/src/export/csv.ts +++ b/service/src/export/csv.ts @@ -143,8 +143,10 @@ export class Csv extends Exporter { ...observation.properties } as any + // TODO: users-next if (!cache.user || cache.user._id.toString() !== observation.userId?.toString()) { if (observation.userId) { + // TODO: users-next cache.user = await User.getUserById(observation.userId!) } } @@ -243,6 +245,7 @@ export class Csv extends Exporter { mgrs: mgrs.forward(location.geometry.coordinates), } as UserLocationDocumentProperties & { user?: string, device?: string } if (!cache.user || cache.user._id.toString() !== location.userId.toString()) { + // TODO: users-next cache.user = await User.getUserById(location.userId) } if (!cache.device || cache.device._id.toString() !== flat.deviceId?.toString()) { diff --git a/service/src/export/exporter.ts b/service/src/export/exporter.ts index 3c6b9ab91..e657f304d 100755 --- a/service/src/export/exporter.ts +++ b/service/src/export/exporter.ts @@ -1,12 +1,15 @@ import mongoose from 'mongoose' import * as ObservationModelModule from '../models/observation' import * as UserLocationModelModule from '../models/location' -import { MageEventDocument } from '../models/event' +import { MageEventModelInstance } from '../models/event' import { MageEvent } from '../entities/events/entities.events' +import { UserId } from '../entities/users/entities.users' +import { ObservationStateName } from '../entities/observations/entities.observations' +import { ObservationModelInstance } from '../adapters/observations/adapters.observations.db.mongoose' export interface ExportOptions { - event: MageEventDocument + event: MageEventModelInstance filter: ExportFilter } @@ -15,7 +18,7 @@ export interface ExportFilter { exportLocations?: boolean startDate?: Date endDate?: Date - favorites?: false | { userId: mongoose.Types.ObjectId } + favorites?: false | { userId: UserId } important?: boolean /** * Unintuitively, `attachments: true` will EXCLUDE attachments from the @@ -29,7 +32,7 @@ export type LocationFetchOptions = Pick export class Exporter { - protected eventDoc: MageEventDocument + protected eventDoc: MageEventModelInstance protected _event: MageEvent protected _filter: ExportFilter @@ -39,10 +42,10 @@ export class Exporter { this._filter = options.filter; } - requestObservations(filter: ExportFilter): mongoose.Cursor { + requestObservations(filter: ExportFilter): mongoose.Cursor { const options: ObservationModelModule.ObservationReadStreamOptions = { filter: { - states: [ 'active' ] as [ 'active' ], + states: [ ObservationStateName.Active ], observationStartDate: filter.startDate, observationEndDate: filter.endDate, favorites: filter.favorites, diff --git a/service/src/export/geojson.ts b/service/src/export/geojson.ts index 2dd4c5a84..47626c2c2 100755 --- a/service/src/export/geojson.ts +++ b/service/src/export/geojson.ts @@ -127,6 +127,7 @@ export class GeoJson extends Exporter { this.mapObservationProperties(observation, archive); if (observation.userId) { + // TODO: users-next if (!user || user._id.toString() !== String(observation.userId)) { user = await User.getUserById(observation.userId) } diff --git a/service/src/export/geopackage.ts b/service/src/export/geopackage.ts index 0a20eef67..cae3bffb6 100644 --- a/service/src/export/geopackage.ts +++ b/service/src/export/geopackage.ts @@ -205,6 +205,7 @@ export class GeoPackage extends Exporter { const userIconRows: Map = new Map() let zoomToEnvelope: Envelope | null = null return cursor.eachAsync(async location => { + // TODO: users-next if (user?._id.toString() !== location.userId.toString()) { user = await User.getUserById(location.userId); } diff --git a/service/src/export/kml.ts b/service/src/export/kml.ts index ec04198db..ac1329e7d 100755 --- a/service/src/export/kml.ts +++ b/service/src/export/kml.ts @@ -107,6 +107,7 @@ export class Kml extends Exporter { } locationString = ''; lastUserId = location.userId.toString(); + // TODO: users-next lastUser = await User.getUserById(location.userId); if (lastUser) { userStyles += writer.generateUserStyle(lastUser); diff --git a/service/src/export/kmlWriter.ts b/service/src/export/kmlWriter.ts index f1e6559b4..5ad8383e1 100755 --- a/service/src/export/kmlWriter.ts +++ b/service/src/export/kmlWriter.ts @@ -100,6 +100,7 @@ export function generateKMLFolderStart(name: string): string { return `${name}`; } +// TODO: users-next export function generateUserStyle(user: UserDocument): string { if (user.icon && user.icon.relativePath) { return fragment({ diff --git a/service/src/express.js b/service/src/express.js index 5a49fe017..d7d91fd0a 100644 --- a/service/src/express.js +++ b/service/src/express.js @@ -29,9 +29,6 @@ app.use(function(req, res, next) { return next(); }); -const secret = crypto.randomBytes(64).toString('hex'); -app.use(session({ secret })); - app.set('config', config); app.enable('trust proxy'); @@ -43,8 +40,6 @@ app.use( express.json(jsonOptions), express.urlencoded( { ...jsonOptions, extended: true })); -app.use(passport.initialize()); -app.use(passport.session()); app.get('/api/docs/openapi.yaml', async function(req, res) { const docPath = path.resolve(__dirname, 'docs', 'openapi.yaml'); fs.readFile(docPath, (err, contents) => { @@ -59,10 +54,11 @@ app.use('/private', express.static(path.join(__dirname, 'private'))); // Configure authentication +// TODO: users-next: remove this and initialize in main app module const authentication = AuthenticationInitializer.initialize(app, passport, provision); // Configure routes -// TODO: don't pass authentication to other routes, but enforce authentication ahead of adding route modules +// TODO: users-next: don't pass authentication to other routes, but enforce authentication ahead of adding route modules require('./routes')(app, { authentication }); // Express requires a 4 parameter function callback, do not remove unused next parameter diff --git a/service/src/format/geoJsonFormat.js b/service/src/format/geoJsonFormat.js index dc63e5b54..ea45cbc81 100644 --- a/service/src/format/geoJsonFormat.js +++ b/service/src/format/geoJsonFormat.js @@ -1,50 +1,51 @@ -var parseEnvelope = function(text) { - var bbox = JSON.parse(text); - if (bbox.length !== 4) { +function parseEnvelope(text) { + const coords = JSON.parse(text); + if (coords.length !== 4) { throw new Error("Invalid geometry: " + text); } - var xmin = parseFloat(bbox[0]); - var ymin = parseFloat(bbox[1]); - var xmax = parseFloat(bbox[2]); - var ymax = parseFloat(bbox[3]); + let xmin = parseFloat(coords[0]); + let ymin = parseFloat(coords[1]); + let xmax = parseFloat(coords[2]); + let ymax = parseFloat(coords[3]); + // TODO: is this the right thing to do? xmin = xmin < -180 ? -180 : xmin; ymin = ymin < -90 ? -90 : ymin; xmax = xmax > 180 ? 180 : xmax; ymax = ymax > 90 ? 90 : ymax; - bbox = {xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax }; + const bbox = { xmin, ymin, xmax, ymax }; - var geometries = []; - - // TODO hack until mongo fixes queries for more than + // TODO: hack until mongo fixes queries for more than // 180 degrees longitude. Create 2 geometries if we cross // the prime meridian if (bbox.xmax > 0 && bbox.xmin < 0) { - geometries.push({ - type: 'Polygon', - coordinates: [ [ - [bbox.xmin, bbox.ymin], - [0, bbox.ymin], - [0, bbox.ymax], - [bbox.xmin, bbox.ymax], - [bbox.xmin, bbox.ymin] - ] ] - }); - - geometries.push({ - type: 'Polygon', - coordinates: [ [ - [0, bbox.ymin], - [bbox.xmax, bbox.ymin], - [bbox.xmax, bbox.ymax], - [0, bbox.ymax], - [0, bbox.ymin] - ] ] - }); - } else { - geometries.push({ + return [ + { + type: 'Polygon', + coordinates: [ [ + [bbox.xmin, bbox.ymin], + [0, bbox.ymin], + [0, bbox.ymax], + [bbox.xmin, bbox.ymax], + [bbox.xmin, bbox.ymin] + ] ] + }, + { + type: 'Polygon', + coordinates: [ [ + [0, bbox.ymin], + [bbox.xmax, bbox.ymin], + [bbox.xmax, bbox.ymax], + [0, bbox.ymax], + [0, bbox.ymin] + ] ] + } + ] + } + return [ + { type: 'Polygon', coordinates: [ [ [bbox.xmin, bbox.ymin], @@ -53,19 +54,17 @@ var parseEnvelope = function(text) { [bbox.xmin, bbox.ymax], [bbox.xmin, bbox.ymin] ] ] - }); - } - - return geometries; -}; + } + ] +} -var parseGeometry = function(type, text) { +function parseGeometry(type, text) { switch (type) { case 'bbox': return parseEnvelope(text); default: - return [JSON.parse(text)]; + return [ JSON.parse(text) ]; } -}; +} exports.parse = parseGeometry; diff --git a/service/src/ingress/devices.ts b/service/src/ingress/devices.ts new file mode 100644 index 000000000..b1a5a3aa3 --- /dev/null +++ b/service/src/ingress/devices.ts @@ -0,0 +1,4 @@ + +export interface DeviceAuthenticationPolicyService { + +} \ No newline at end of file diff --git a/service/src/authentication/index.d.ts b/service/src/ingress/index.d.ts similarity index 100% rename from service/src/authentication/index.d.ts rename to service/src/ingress/index.d.ts diff --git a/service/src/authentication/index.js b/service/src/ingress/index.ts similarity index 54% rename from service/src/authentication/index.js rename to service/src/ingress/index.ts index d7bd03611..33c3bf9ba 100644 --- a/service/src/authentication/index.js +++ b/service/src/ingress/index.ts @@ -1,69 +1,54 @@ -const crypto = require('crypto') - , verification = require('./verification') - , api = require('../api/') - , config = require('../config.js') - , log = require('../logger') - , userTransformer = require('../transformers/user') - , authenticationApiAppender = require('../utilities/authenticationApiAppender') - , AuthenticationConfiguration = require('../models/authenticationconfiguration') - , SecurePropertyAppender = require('../security/utilities/secure-property-appender'); - -const JWTService = verification.JWTService; -const TokenAssertion = verification.TokenAssertion; - -class AuthenticationInitializer { +import crypto from 'crypto' +import { JWTService, Payload, TokenAssertion } from './verification' +import express from 'express' +import passport from 'passport' +import provision, { ProvisionStatic } from '../provision' +import { User, UserRepository } from '../entities/users/entities.users' +import bearer from 'passport-http-bearer' +import { UserDocument } from '../adapters/users/adapters.users.db.mongoose' +import { SessionRepository } from './ingress.entities' +const api = require('../api/') +const config = require('../config.js') +const log = require('../logger') +const userTransformer = require('../transformers/user') +const authenticationApiAppender = require('../utilities/authenticationApiAppender') +const AuthenticationConfiguration = require('../models/authenticationconfiguration') +const SecurePropertyAppender = require('../security/utilities/secure-property-appender'); + +/** + * TODO: users-next: this module should go away. this remains for now as a reference to migrate legacy logic to new architecture + */ + +export class AuthenticationInitializer { static tokenService = new JWTService(crypto.randomBytes(64).toString('hex'), 'urn:mage'); - static app; - static passport; - static provision; + static app: express.Application + static passport: passport.Authenticator; + static provision: provision.ProvisionStatic; - static initialize(app, passport, provision) { + static initialize(app: express.Application, passport: passport.Authenticator, provision: provision.ProvisionStatic): { passport: passport.Authenticator } { AuthenticationInitializer.app = app; AuthenticationInitializer.passport = passport; AuthenticationInitializer.provision = provision; - const BearerStrategy = require('passport-http-bearer').Strategy - , User = require('../models/user') - , Token = require('../models/token'); - passport.serializeUser(function (user, done) { done(null, user._id); }); passport.deserializeUser(function (id, done) { + // TODO: users-next User.getUserById(id, function (err, user) { done(err, user); }); }); - passport.use(new BearerStrategy({ - passReqToCallback: true - }, - function (req, token, done) { - Token.getToken(token, function (err, credentials) { - if (err) { return done(err); } - - if (!credentials || !credentials.user) { - return done(null, false); - } - - req.token = credentials.token; - - if (credentials.token.deviceId) { - req.provisionedDeviceId = credentials.token.deviceId; - } - - return done(null, credentials.user, { scope: 'all' }); - }); - })); - passport.use('authorization', new BearerStrategy(function (token, done) { const expectation = { - assertion: TokenAssertion.Authorized + assertion: TokenAssertion.Authenticated }; AuthenticationInitializer.tokenService.verifyToken(token, expectation) .then(payload => { + // TODO: users-next User.getUserById(payload.subject) .then(user => done(null, user)) .catch(err => done(err)); @@ -71,16 +56,17 @@ class AuthenticationInitializer { .catch(err => done(err)); })); - function authorize(req, res, next) { - passport.authenticate('authorization', function (err, user, info = {}) { - if (!user) return res.status(401).send(info.message); - - req.user = user; - next(); - })(req, res, next); + function authorize(req: express.Request, res: express.Response, next: express.NextFunction): any { + passport.authenticate('authorization', function (err: Error, user: User, info: any = {}) { + if (!user) { + return res.status(401).send(info.message) + } + req.user = user + next() + })(req, res, next) } - function provisionDevice(req, res, next) { + function provisionDevice(req: express.Request, res: express.Response, next: express.NextFunction): any { provision.check(req.user.authentication.authenticationConfiguration.type, req.user.authentication.authenticationConfiguration.name)(req, res, next); } @@ -92,14 +78,14 @@ class AuthenticationInitializer { userAgent: req.headers['user-agent'], appVersion: req.param('appVersion') }; - - new api.User().login(req.user, req.provisionedDevice, options, function (err, token) { + // TODO: users-next + new api.User().login(req.user, req.provisionedDevice, options, function (err, session) { if (err) return next(err); authenticationApiAppender.append(config.api).then(api => { res.json({ - token: token.token, - expirationDate: token.expirationDate, + token: session.token, + expirationDate: session.expirationDate, user: userTransformer.transform(req.user, { path: req.getRoot() }), device: req.provisionedDevice, api: api @@ -131,10 +117,6 @@ class AuthenticationInitializer { require('./local').initialize(); require('./anonymous').initialize(); - return { - passport: passport - }; + return { passport } } } - -module.exports = AuthenticationInitializer; diff --git a/service/src/ingress/ingress.adapters.controllers.web.ts b/service/src/ingress/ingress.adapters.controllers.web.ts new file mode 100644 index 000000000..1eaf13723 --- /dev/null +++ b/service/src/ingress/ingress.adapters.controllers.web.ts @@ -0,0 +1,294 @@ +import express from 'express' +import svgCaptcha from 'svg-captcha' +import passport from 'passport' +import bearer from 'passport-http-bearer' +import { defaultHashUtil } from '../utilities/password-hashing' +import { JWTService, Payload, TokenVerificationError, VerificationErrorReason, TokenAssertion } from './verification' +import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors' +import { IdentityProvider } from './ingress.entities' +import { AdmitFromIdentityProviderOperation, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' +import { User, UserRepository } from '../entities/users/entities.users' + +declare module 'express-serve-static-core' { + interface Request { + ingress?: IngressRequestContext + localEnrollment?: LocalEnrollmentContext + } +} + +enum UserAgentType { + MobileApp = 'MobileApp', + WebApp = 'WebApp' +} + +type IngressRequestContext = { + idp: IdentityProvider + idpBinding: IngressProtocolWebBinding +} + +type LocalEnrollmentContext = + | { + state: 'humanTokenVerified' + captchaTokenPayload: Payload + } + | { + state: 'humanVerified' + subject: string + } + +export type IngressUseCases = { + enrollMyself: EnrollMyselfOperation + admitFromIdentityProvider: AdmitFromIdentityProviderOperation +} + +export type IngressProtocolWebBindingCache = { + idpWebBindingForIdpName(idpName: string): Promise<{ idp: IdentityProvider, idpBinding: IngressProtocolWebBinding } | null> +} + +export type IngressRoutes = { + localEnrollment: express.Router + idpAdmission: express.Router +} + +/** + * Register a `BearerStrategy` that expects a JWT in the `Authorization` header that contains the + * {@link TokenAssertion.Authenticated} claim. The claim indicates the subject has authenticated with an IDP and can + * continue the ingress process. Decode and verify the JWT signature, retrieve the `User` for the JWT subject, and set + * `Request.user`. + */ +function createIdpAuthenticationTokenVerificationStrategy(passport: passport.Authenticator, verificationService: JWTService, userRepo: UserRepository): bearer.Strategy { + return new bearer.Strategy(async function(token, done: (error: any, user?: User) => any) { + try { + const expectation: Payload = { assertion: TokenAssertion.Authenticated, subject: null, expiration: null } + const payload = await verificationService.verifyToken(token, expectation) + const user = payload.subject ? await userRepo.findById(payload.subject) : null + if (user) { + return done(null, user) + } + done(new Error(`user id ${payload.subject} not found for transient token ${String(payload)}`)) + } + catch (err) { + done(err) + } + }) +} + +export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: IngressProtocolWebBindingCache, tokenService: JWTService, passport: passport.Authenticator): IngressRoutes { + + const routeToIdp = express.Router() + .all('/', + ((req, res, next) => { + const idpBinding = req.ingress?.idpBinding + if (!idpBinding) { + return next(new Error(`no identity provider for ingress request: ${req.method} ${req.originalUrl}`)) + } + if (req.path.endsWith('/signin')) { + const userAgentType: UserAgentType = req.params.state === 'mobile' ? UserAgentType.MobileApp : UserAgentType.WebApp + return idpBinding.beginIngressFlow(req, res, next, userAgentType) + } + idpBinding.handleIngressFlowRequest(req, res, next) + }) as express.RequestHandler, + (async (err, req, res, next) => { + if (err) { + console.error('identity provider authentication error:', err) + return res.status(500).send('unexpected authentication result') + } + if (!req.user?.admittingFromIdentityProvider) { + console.error('unexpected ingress user type:', req.user) + return res.status(500).send('unexpected authentication result') + } + const idpAdmission = req.user.admittingFromIdentityProvider + const { idpBinding, idp } = req.ingress! + const identityProviderUser = idpAdmission.account + const admission = await ingressApp.admitFromIdentityProvider({ identityProviderName: idp.name, identityProviderUser }) + if (admission.error) { + return next(admission.error) + } + const { idpAuthenticationToken, mageAccount } = admission.success + if (idpBinding.ingressResponseType === IngressResponseType.Direct) { + return res.json({ user: mageAccount, token: idpAuthenticationToken }) + } + if (idpAdmission.flowState === UserAgentType.MobileApp) { + if (mageAccount.active && mageAccount.enabled) { + return res.redirect(`mage://app/authentication?token=${idpAuthenticationToken}`) + } + else { + return res.redirect(`mage://app/invalid_account?active=${mageAccount.active}&enabled=${mageAccount.enabled}`) + } + } + else if (idpAdmission.flowState === UserAgentType.WebApp) { + return res.render('authentication', { host: req.getRoot(), success: true, login: { token: idpAuthenticationToken, user: mageAccount } }) + } + return res.status(500).send('invalid authentication state') + }) as express.ErrorRequestHandler + ) + + // TODO: mount to /auth + const admission = express.Router() + + admission.use('/:identityProviderName', + async (req, res, next) => { + const idpName = req.params.identityProviderName + const idpBindingEntry = await idpCache.idpWebBindingForIdpName(idpName) + if (idpBindingEntry) { + const { idp, idpBinding } = idpBindingEntry + req.ingress = { idp, idpBinding } + return next() + } + res.status(404).send(`${idpName} not found`) + }, + // use a sub-router so express implicitly strips the base url /auth/:identityProviderName before routing to idp handler + routeToIdp + ) + + const verifyIdpAuthenticationTokenStrategy = createIdpAuthenticationTokenVerificationStrategy(passport, tokenService, userRepo) + admission.post('/token', + passport.authenticate(verifyIdpAuthenticationTokenStrategy), + async (req, res, next) => { + deviceProvisioning.check() + const options = { + userAgent: req.headers['user-agent'], + appVersion: req.body.appVersion + } + /* + TODO: users-next + insert a new login record for the user and start a new session + retrieve the server api descriptor + add the available identity providers to the api descriptor + return a json object shaped as below + */ + // new api.User().login(req.user, req.provisionedDevice, options, function (err, session) { + // if (err) return next(err); + + // authenticationApiAppender.append(config.api).then(api => { + // res.json({ + // token: session.token, + // expirationDate: session.expirationDate, + // user: userTransformer.transform(req.user, { path: req.getRoot() }), + // device: req.provisionedDevice, + // api: api + // }); + // }).catch(err => { + // next(err); + // }); + // }); + + // req.session = null; + } + ) + + // TODO: users-next: mount to /api/users/signups + const localEnrollment = express.Router() + localEnrollment.route('/signups') + .post(async (req, res, next) => { + try { + const username = typeof req.body.username === 'string' ? req.body.username.trim() : null + if (!username) { + return res.status(400).send('Invalid signup - username is required.') + } + const background = typeof req.body.background === 'string' ? req.body.background.toLowerCase() : '#ffffff' + const captcha = svgCaptcha.create({ + size: 6, + noise: 4, + color: false, + background: background !== '#ffffff' ? background : null + }) + const captchaHash = await defaultHashUtil.hashPassword(captcha.text) + const claims = { captcha: captchaHash } + const verificationToken = await tokenService.generateToken(username, TokenAssertion.IsHuman, 60 * 3, claims) + res.json({ + token: verificationToken, + captcha: `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}` + }) + } + catch (err) { + next(err) + } + }) + + const captchaBearer = new bearer.Strategy((token, done) => { + const expectation = { + subject: null, + expiration: null, + assertion: TokenAssertion.IsHuman + } + tokenService.verifyToken(token, expectation) + .then(payload => done(null, payload)) + .catch(err => done(err)) + }) + + // TODO: users-next: mount to /api/users/signups/verifications + localEnrollment.route('/signups/verifications') + .post( + async (req, res, next) => { + passport.authenticate(captchaBearer, (err: TokenVerificationError, captchaTokenPayload: Payload) => { + if (err) { + if (err.reason === VerificationErrorReason.Expired) { + return res.status(401).send('Captcha timeout') + } + return res.status(400).send('Invalid captcha. Please try again.') + } + if (!captchaTokenPayload) { + return res.status(400).send('Missing captcha token') + } + req.localEnrollment = { state: 'humanTokenVerified', captchaTokenPayload } + next() + })(req, res, next) + }, + async (req, res, next) => { + try { + if (req.localEnrollment?.state !== 'humanTokenVerified') { + return res.status(500).send('invalid ingress state') + } + const tokenPayload = req.localEnrollment.captchaTokenPayload + const hashedCaptchaText = tokenPayload.captcha as string + const userCaptchaText = req.body.captchaText + const isHuman = await defaultHashUtil.validPassword(userCaptchaText, hashedCaptchaText) + if (!isHuman) { + return res.status(403).send('Invalid captcha. Please try again.') + } + const username = tokenPayload.subject! + const parsedEnrollment = validateEnrollment(req.body) + if (parsedEnrollment instanceof MageError) { + return next(parsedEnrollment) + } + const enrollment: EnrollMyselfRequest = { + ...parsedEnrollment, + username + } + const appRes = await ingressApp.enrollMyself(enrollment) + if (appRes.success) { + return res.json(appRes.success) + } + next(appRes.error) + } + catch (err) { + next(err) + } + } + ) + + return { localEnrollment, idpAdmission: admission } +} + +function validateEnrollment(input: any): Omit | InvalidInputError { + const { displayName, email, password, phone } = input + if (typeof displayName !== 'string') { + return invalidInput('displayName is required') + } + if (typeof password !== 'string') { + return invalidInput('password is required') + } + const enrollment: Omit = { displayName, password } + if (email && typeof email === 'string') { + if (!/^[^\s@]+@[^\s@]+\./.test(email)) { + return invalidInput('email is invalid') + } + enrollment.email = email + } + if (phone && typeof phone === 'string') { + enrollment.phone = phone + } + return enrollment +} \ No newline at end of file diff --git a/service/src/ingress/ingress.adapters.db.mongoose.ts b/service/src/ingress/ingress.adapters.db.mongoose.ts new file mode 100644 index 000000000..4cc7b9968 --- /dev/null +++ b/service/src/ingress/ingress.adapters.db.mongoose.ts @@ -0,0 +1,263 @@ +import mongoose from 'mongoose' +import { BaseMongooseRepository } from '../adapters/base/adapters.base.db.mongoose' +import { PagingParameters, PageOf } from '../entities/entities.global' +import { UserId } from '../entities/users/entities.users' +import { DeviceEnrollmentPolicy, IdentityProvider, IdentityProviderId, IdentityProviderMutableAttrs, IdentityProviderRepository, UserEnrollmentPolicy, UserIngressBinding, UserIngressBindings, UserIngressBindingsRepository } from './ingress.entities' + +type ObjectId = mongoose.Types.ObjectId +const ObjectId = mongoose.Types.ObjectId +const Schema = mongoose.Schema + +export type IdentityProviderDocument = Omit & { + _id: ObjectId +} + +export type IdentityProviderModel = mongoose.Model + +export const IdentityProviderSchema = new Schema( + { + name: { type: String, required: true }, + protocol: { type: String, required: true }, + protocolSettings: Schema.Types.Mixed, + userEnrollmentPolicy: { + accountApprovalRequired: { type: Boolean, required: true, default: true }, + assignRole: { type: String, required: true }, + assignToEvents: { type: [Number], default: [] }, + assignToTeams: { type: [String], default: [] }, + }, + deviceEnrollmentPolicy: { + deviceApprovalRequired: { type: Boolean, required: true, default: true }, + }, + title: { type: String, required: false }, + textColor: { type: String, required: false }, + buttonColor: { type: String, required: false }, + icon: { type: Buffer, required: false }, + enabled: { type: Boolean, default: true }, + }, + { + timestamps: { + updatedAt: 'lastUpdated' + }, + versionKey: false, + } +) + +IdentityProviderSchema.index({ name: 1, type: 1 }, { unique: true }) + +export function idpEntityForDocument(doc: IdentityProviderDocument): IdentityProvider { + const userEnrollmentPolicy: UserEnrollmentPolicy = { ...doc.userEnrollmentPolicy } + const deviceEnrollmentPolicy: DeviceEnrollmentPolicy = { ...doc.deviceEnrollmentPolicy } + return { + id: doc._id.toHexString(), + name: doc.name, + enabled: doc.enabled, + lastUpdated: doc.lastUpdated, + protocol: doc.protocol, + title: doc.title || doc.name, + protocolSettings: { ...doc.protocolSettings }, + textColor: doc.textColor, + buttonColor: doc.buttonColor, + icon: doc.icon, + userEnrollmentPolicy, + deviceEnrollmentPolicy, + } +} + +export function idpDocumentForEntity(entity: Partial): Partial { + const doc = {} as Partial + const entityHasKey = (key: keyof IdentityProvider): boolean => Object.prototype.hasOwnProperty.call(entity, key) + if (entityHasKey('id')) { + doc._id = new mongoose.Types.ObjectId(entity.id) + } + if (entityHasKey('protocol')) { + doc.protocol = entity.protocol + } + if (entityHasKey('protocolSettings')) { + // TODO: maybe delegate to protocol to copy settings + doc.protocolSettings = { ...entity.protocolSettings } + } + if (entityHasKey('userEnrollmentPolicy')) { + doc.userEnrollmentPolicy = { ...entity.userEnrollmentPolicy! } + } + if (entityHasKey('deviceEnrollmentPolicy')) { + doc.deviceEnrollmentPolicy = { ...entity.deviceEnrollmentPolicy! } + } + if (entityHasKey('buttonColor')) { + doc.buttonColor = entity.buttonColor + } + if (entityHasKey('enabled')) { + doc.enabled = entity.enabled + } + if (entityHasKey('icon')) { + doc.icon = entity.icon + } + if (entityHasKey('lastUpdated')) { + doc.lastUpdated = entity.lastUpdated ? new Date(entity.lastUpdated) : undefined + } + if (entityHasKey('name')) { + doc.name = entity.name + } + if (entityHasKey('protocol')) { + doc.protocol = entity.protocol + } + if (entityHasKey('textColor')) { + doc.textColor = entity.textColor + } + if (entityHasKey('title')) { + doc.title = entity.title + } + return doc +} + +export class IdentityProviderMongooseRepository extends BaseMongooseRepository implements IdentityProviderRepository { + + constructor(model: IdentityProviderModel) { + super(model, { docToEntity: idpEntityForDocument, entityToDocStub: idpDocumentForEntity }) + } + + findIdpById(id: string): Promise { + return super.findById(id) + } + + async findIdpByName(name: string): Promise { + const doc = await this.model.findOne({ name }, null, { lean: true }) + if (doc) { + return this.entityForDocument(doc) + } + return null + } + + updateIdp(update: Partial & Pick): Promise { + return super.update(update) + } + + deleteIdp(id: IdentityProviderId): Promise { + return super.removeById(id) + } +} + + +export type UserIngressBindingsDocument = { + /** + * The ingress bindings `_id` is actually the `_id` of the related user document. + */ + _id: ObjectId + bindings: { [idpId: IdentityProviderId]: UserIngressBinding } +} + +export type UserIngressBindingsModel = mongoose.Model + +export const UserIngressBindingsSchema = new Schema( + { + bindings: { type: Schema.Types.Mixed, required: true } + } +) + +export class UserIngressBindingsMongooseRepository implements UserIngressBindingsRepository { + + constructor(readonly model: UserIngressBindingsModel) {} + + async readBindingsForUser(userId: UserId): Promise { + const doc = await this.model.findById(userId, null, { lean: true }) + return { userId, bindingsByIdpId: new Map(Object.entries(doc?.bindings || {})) } + } + + async readAllBindingsForIdp(idpId: IdentityProviderId, paging?: PagingParameters | undefined): Promise> { + throw new Error('Method not implemented.') + } + + async saveUserIngressBinding(userId: UserId, binding: UserIngressBinding): Promise { + const _id = new ObjectId(userId) + const bindingsUpdate = { $set: { [`bindings.${binding.idpId}`]: binding } } + const doc = await this.model.findOneAndUpdate({ _id }, bindingsUpdate, { upsert: true, new: true }) + return { userId, bindingsByIdpId: new Map(Object.entries(doc.bindings)) } + } + + async deleteBinding(userId: UserId, idpId: IdentityProviderId): Promise { + const _id = new ObjectId(userId) + const bindingsUpdate = { $unset: [`bindings.${idpId}`] } + const doc = await this.model.findOneAndUpdate({ _id }, bindingsUpdate) + return doc?.bindings[idpId] || null + } + + async deleteBindingsForUser(userId: UserId): Promise { + throw new Error('Method not implemented.') + } + + async deleteAllBindingsForIdp(idpId: IdentityProviderId): Promise { + throw new Error('Method not implemented.') + } +} + +// TODO: should be per protocol and identity provider and in entity layer +const whitelist = ['name', 'type', 'title', 'textColor', 'buttonColor', 'icon']; +const blacklist = ['clientsecret', 'bindcredentials', 'privatecert', 'decryptionpvk']; +const secureMask = '*****'; + +function DbAuthenticationConfigurationToObject(config, ret, options) { + delete ret.__v; + + if (options.whitelist) { + if (config.type === 'local') { + return; + } + + Object.keys(ret).forEach(key => { + if (!whitelist.includes(key)) { + delete ret[key]; + } + }); + } + + if (options.blacklist) { + Object.keys(ret.settings).forEach(key => { + if (blacklist.includes(key.toLowerCase())) { + ret.settings[key] = secureMask; + } + }); + } + + ret.icon = ret.icon ? ret.icon.toString('base64') : null; +} + +// TODO: move to api/web layer +function manageIcon(config) { + if (config.icon) { + if (config.icon.startsWith('data')) { + config.icon = Buffer.from(config.icon.split(",")[1], "base64"); + } else { + config.icon = Buffer.from(config.icon, 'base64'); + } + } else { + config.icon = null; + } +} + +// TODO: move to protocol +function manageSettings(config) { + if (config.settings.scope) { + if (!Array.isArray(config.settings.scope)) { + config.settings.scope = config.settings.scope.split(','); + } + + for (let i = 0; i < config.settings.scope.length; i++) { + config.settings.scope[i] = config.settings.scope[i].trim(); + } + } +} + +//TODO move the 'manage' methods to a pre save method + +// exports.create = function (config) { +// manageIcon(config); +// manageSettings(config); + +// return AuthenticationConfiguration.create(config); +// }; + +// exports.update = function (id, config) { +// manageIcon(config); +// manageSettings(config); + +// return AuthenticationConfiguration.findByIdAndUpdate(id, config, { new: true }).exec(); +// }; diff --git a/service/src/ingress/ingress.app.api.ts b/service/src/ingress/ingress.app.api.ts new file mode 100644 index 000000000..b4f3c71f2 --- /dev/null +++ b/service/src/ingress/ingress.app.api.ts @@ -0,0 +1,86 @@ +import { EntityNotFoundError, InfrastructureError, InvalidInputError, MageError, PermissionDeniedError } from '../app.api/app.api.errors' +import { AppRequest, AppResponse } from '../app.api/app.api.global' +import { Avatar, User, UserExpanded, UserIcon } from '../entities/users/entities.users' +import { IdentityProviderUser } from './ingress.entities' + + + +export interface EnrollMyselfRequest { + username: string + password: string + displayName: string + phone?: string | null + email?: string | null +} + +/** + * Create the given account for the requesting user in the local identity provider. + */ +export interface EnrollMyselfOperation { + (req: EnrollMyselfRequest): Promise> +} + +export interface CreateUserRequest extends AppRequest { + user: Omit + password: string + icon?: UserIcon & { content: NodeJS.ReadableStream | Buffer } + avatar?: Avatar & { content: NodeJS.ReadableStream | Buffer } +} + +/** + * Manually create an account on behalf of another user using the Mage local IDP. This is the use case of an admin + * creating an account for another user. + */ +export interface CreateUserOperation { + (req: CreateUserRequest): Promise> +} + +export interface AdmitFromIdentityProviderRequest { + identityProviderName: string + identityProviderUser: IdentityProviderUser +} + +export interface AdmitFromIdentityProviderResult { + mageAccount: UserExpanded + idpAuthenticationToken: string +} + +/** + * Admit a user that has authenticated with a configured identity provider. User admission includes implicit user + * enrollment, i.e., when the Mage account does not exist, create a new Mage account for the user bound to the + * authenticating identity provider, and apply the enrollment policy configured for the identity provider. + */ +export interface AdmitFromIdentityProviderOperation { + (req: AdmitFromIdentityProviderRequest): Promise> +} + +export interface UpdateIdentityProviderRequest { + +} + +export interface UpdateIdentityProviderResult { + +} + +export interface UpdateIdentityProviderOperation { + (req: UpdateIdentityProviderRequest): Promise> +} + +export interface DeleteIdentityProviderRequest { + +} + +export interface DeleteIdentityProviderResult { + +} + +export interface DeleteIdentityProviderOperation { + (req: DeleteIdentityProviderRequest): Promise> +} + +export const ErrAuthenticationFailed = Symbol.for('MageError.Ingress.AuthenticationFailed') +export type AuthenticationFailedErrorData = { username: string, identityProviderName: string } +export type AuthenticationFailedError = MageError +export function authenticationFailedError(username: string, identityProviderName: string, message?: string): AuthenticationFailedError { + return new MageError(ErrAuthenticationFailed, { username, identityProviderName }, message || `Authentication failed: ${username} @(${identityProviderName})`) +} \ No newline at end of file diff --git a/service/src/ingress/ingress.app.impl.ts b/service/src/ingress/ingress.app.impl.ts new file mode 100644 index 000000000..7cf9af697 --- /dev/null +++ b/service/src/ingress/ingress.app.impl.ts @@ -0,0 +1,72 @@ +import { entityNotFound, infrastructureError, invalidInput } from '../app.api/app.api.errors' +import { AppResponse } from '../app.api/app.api.global' +import { AdmitFromIdentityProviderOperation, AdmitFromIdentityProviderRequest, authenticationFailedError, EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api' +import { IdentityProviderRepository, IdentityProviderUser } from './ingress.entities' +import { AdmissionDeniedReason, AdmitUserFromIdentityProviderAccount, EnrollNewUser } from './ingress.services.api' +import { LocalIdpError, LocalIdpInvalidPasswordError } from './local-idp.entities' +import { MageLocalIdentityProviderService } from './local-idp.services.api' +import { JWTService, TokenAssertion } from './verification' + + +export function CreateEnrollMyselfOperation(localIdp: MageLocalIdentityProviderService, idpRepo: IdentityProviderRepository, enrollNewUser: EnrollNewUser): EnrollMyselfOperation { + return async function enrollMyself(req: EnrollMyselfRequest): ReturnType { + const localIdpAccount = await localIdp.createAccount(req) + if (localIdpAccount instanceof LocalIdpError) { + if (localIdpAccount instanceof LocalIdpInvalidPasswordError) { + return AppResponse.error(invalidInput(localIdpAccount.message)) + } + console.error('error creating local idp account for self-enrollment', localIdpAccount) + return AppResponse.error(invalidInput('Error creating local Mage account')) + } + const candidateMageAccount: IdentityProviderUser = { + username: localIdpAccount.username, + displayName: req.displayName, + phones: [], + } + if (req.email) { + candidateMageAccount.email = req.email + } + if (req.phone) { + candidateMageAccount.phones = [ { number: req.phone, type: 'Main' } ] + } + const idp = await idpRepo.findIdpByName('local') + if (!idp) { + throw new Error('local idp not found') + } + const enrollmentResult = await enrollNewUser(candidateMageAccount, idp) + + // TODO: auto-activate account after enrollment policy + throw new Error('unimplemented') + } +} + +export function CreateAdmitFromIdentityProviderOperation(idpRepo: IdentityProviderRepository, admitFromIdpAccount: AdmitUserFromIdentityProviderAccount, tokenService: JWTService): AdmitFromIdentityProviderOperation { + return async function admitFromIdentityProvider(req: AdmitFromIdentityProviderRequest): ReturnType { + const idp = await idpRepo.findIdpByName(req.identityProviderName) + if (!idp) { + return AppResponse.error(entityNotFound(req.identityProviderName, 'IdentityProvider', `identity provider not found: ${req.identityProviderName}`)) + } + const idpAccount = req.identityProviderUser + console.info(`admitting user ${idpAccount.username} from identity provider ${idp.name}`) + const admission = await admitFromIdpAccount(idpAccount, idp) + if (admission.action === 'denied') { + if (admission.mageAccount) { + if (admission.reason === AdmissionDeniedReason.PendingApproval) { + return AppResponse.error(authenticationFailedError(admission.mageAccount.username, idp.name, 'Your account requires approval from a Mage administrator.')) + } + if (admission.reason === AdmissionDeniedReason.Disabled) { + return AppResponse.error(authenticationFailedError(admission.mageAccount.username, idp.name, 'Your account is disabled.')) + } + } + return AppResponse.error(authenticationFailedError(idpAccount.username, idp.name)) + } + try { + const admissionToken = await tokenService.generateToken(admission.mageAccount.id, TokenAssertion.Authenticated, 5 * 60) + return AppResponse.success({ mageAccount: admission.mageAccount, admissionToken }) + } + catch (err) { + console.error(`error generating admission token while authenticating user ${admission.mageAccount.username}`, err) + return AppResponse.error(infrastructureError('An unexpected error occurred while generating an authentication token.')) + } + } +} diff --git a/service/src/ingress/ingress.entities.ts b/service/src/ingress/ingress.entities.ts new file mode 100644 index 000000000..46c284646 --- /dev/null +++ b/service/src/ingress/ingress.entities.ts @@ -0,0 +1,194 @@ +import { User } from '../entities/users/entities.users' +import { Device, DeviceId } from '../entities/devices/entities.devices' +import { MageEventId } from '../entities/events/entities.events' +import { TeamId } from '../entities/teams/entities.teams' +import { UserExpanded, UserId } from '../entities/users/entities.users' +import { RoleId } from '../entities/authorization/entities.authorization' +import { PageOf, PagingParameters } from '../entities/entities.global' + +export interface Session { + token: string + expirationDate: Date + user: UserId + device?: DeviceId | null +} + +export type SessionExpanded = Omit & { + user: UserExpanded + device: Device +} + +export interface SessionRepository { + readSessionByToken(token: string): Promise + createOrRefreshSession(userId: UserId, deviceId?: string): Promise + deleteSession(token: string): Promise + deleteSessionsForUser(userId: UserId): Promise + deleteSessionsForDevice(deviceId: DeviceId): Promise +} + +/** + * An authentication protocol defines the sequence of messages between a user, a service provider (Mage), and an + * identity provider (Google, Meta, Okta, Auth0, Microsoft, GitHub) necessary to securely inform the service provider + * that the user is valid. Authentication protocols are OpenID Connect, OAuth, SAML, LDAP, and Mage's own local + * password authentication database. + */ +export type AuthenticationProtocolId = 'local' | 'ldap' | 'oauth' | 'oidc' | 'saml' + +export type IdentityProviderId = string + +/** + * An identity provider (IDP) is a service that maintains user profiles and that Mage trusts to authenticate user + * credentials using a specific authentication protocol. Mage delegates user authentication to identity providers. + * Within Mage, the identity provider's protocol implementation maps the provider's user profile/account attributes to + * a Mage user profile. This identity provider entity encapsulates the authentication protocol parameters to enable + * communication to a specific identity provider service. + */ +export interface IdentityProvider { + id: IdentityProviderId + name: string + title: string + protocol: AuthenticationProtocolId + protocolSettings: Record + enabled: boolean + lastUpdated: Date + userEnrollmentPolicy: UserEnrollmentPolicy + deviceEnrollmentPolicy: DeviceEnrollmentPolicy + textColor?: string + buttonColor?: string + icon?: Buffer +} + +/** + * Enrollment policy defines rules and effects to apply when a new user establishes a Mage account. + */ +export interface UserEnrollmentPolicy { + /** + * When true, an administrator must approve and activate new user accounts. + */ + accountApprovalRequired: boolean + assignRole: RoleId + assignToTeams: TeamId[] + assignToEvents: MageEventId[] +} + +export interface DeviceEnrollmentPolicy { + /** + * When true, an administrator must approve and activate new devices associated with user accounts. + */ + deviceApprovalRequired: boolean +} + +/** + * The identity provider user is the result of mapping a specific IDP account to a Mage user account. + */ +export type IdentityProviderUser = Pick + & { + idpAccountId?: string + idpAccountAttrs?: Record + } + +/** + * A user ingress binding is the bridge between a Mage user and an identity provider account. When a user attempts + * to authenticate to Mage through an identity provider, a binding must exist between the Mage user account and the + * identity provider for Mage to map the identity provider account to the Mage user account. + */ +export interface UserIngressBinding { + idpId: IdentityProviderId + created: Date + updated: Date + // TODO: evaluate for utility of disabling a single ingress/idp path for a user as opposed to the entire account + // verified: boolean + // enabled: boolean + /** + * The identity provider account ID is the identifier of the account native to the owning identity provider. This is + * only necessary if the identifier differs from the Mage account's username, especially if a Mage account has + * multiple ingress bindings for different identity providers with different account identifiers. + */ + idpAccountId?: string + // TODO: unused for now + // idpAccountAttrs?: Record +} + +export type UserIngressBindings = { + userId: UserId + bindingsByIdpId: Map +} + +export type IdentityProviderMutableAttrs = Omit + +export interface IdentityProviderRepository { + findIdpById(id: IdentityProviderId): Promise + findIdpByName(name: string): Promise + /** + * Update the IDP according to patch semantics. Remove keys in the given update with `undefined` values from the + * saved record. Keys not present in the given update will have no affect on the saved record. + */ + updateIdp(update: Partial & Pick): Promise + deleteIdp(id: IdentityProviderId): Promise +} + +export interface UserIngressBindingsRepository { + /** + * Return null if the user has no persisted bindings entry. + */ + readBindingsForUser(userId: UserId): Promise + readAllBindingsForIdp(idpId: IdentityProviderId, paging?: PagingParameters): Promise> + /** + * Save the given ingress binding to the bindings dictionary for the given user, creating or updating as necessary. + * Return the modified ingress bindings. + */ + saveUserIngressBinding(userId: UserId, binding: UserIngressBinding): Promise + /** + * Return the binding that was deleted, or null if the user did not have a binding to the given IDP. + */ + deleteBinding(userId: UserId, idpId: IdentityProviderId): Promise + /** + * Return the bindings that were deleted for the given user, or null if the user had no ingress bindings. + */ + deleteBindingsForUser(userId: UserId): Promise + /** + * Return the number of deleted bindings. + */ + deleteAllBindingsForIdp(idpId: IdentityProviderId): Promise +} + +export type AdmissionAction = + | { admitNew: UserIngressBinding, admitExisting: false, deny: false } + | { admitExisting: UserIngressBinding, admitNew: false, deny: false } + | { deny: true, admitNew: false, admitExisting: false } + +export function determinUserIngressBindingAdmission(idpAccount: IdentityProviderUser, idp: IdentityProvider, bindings: UserIngressBindings): AdmissionAction { + if (bindings.bindingsByIdpId.size === 0) { + // new user account + const now = new Date(Date.now()) + return { admitNew: { created: now, updated: now, idpId: idp.id, idpAccountId: idpAccount.idpAccountId }, admitExisting: false, deny: false } + } + const binding = bindings.bindingsByIdpId.get(idp.id) + if (binding) { + // existing account bound to idp + return { admitExisting: binding, admitNew: false, deny: false } + } + return { deny: true, admitNew: false, admitExisting: false } +} + +/** + * Return a new user object from the given identity provider account information suitable to persist as a newly + * enrolled user. The enrollment policy for the identity provider determines the `active` flag and assigned role for + * the new user. + */ +export function createEnrollmentCandidateUser(idpAccount: IdentityProviderUser, idp: IdentityProvider): Omit { + const policy = idp.userEnrollmentPolicy + const now = new Date() + const candidate: Omit = { + active: !policy.accountApprovalRequired, + roleId: policy.assignRole, + enabled: true, + createdAt: now, + lastUpdated: now, + avatar: {}, + icon: {}, + recentEventIds: [], + ...idpAccount + } + return candidate +} diff --git a/service/src/ingress/ingress.main.ts b/service/src/ingress/ingress.main.ts new file mode 100644 index 000000000..15fff8948 --- /dev/null +++ b/service/src/ingress/ingress.main.ts @@ -0,0 +1,116 @@ +import express from 'express' +import passport from 'passport' +import bearer from 'passport-http-bearer' +import { UserRepository } from '../entities/users/entities.users' +import { IngressProtocolWebBindingCache } from './ingress.adapters.controllers.web' +import { IdentityProvider, IdentityProviderRepository, SessionRepository } from './ingress.entities' +import { IngressProtocolWebBinding } from './ingress.protocol.bindings' +import { createLdapProtocolWebBinding } from './ingress.protocol.ldap' +import { createLocalProtocolWebBinding } from './ingress.protocol.local' +import { createOAuthProtocolWebBinding } from './ingress.protocol.oauth' +import { createOIDCProtocolWebBinding } from './ingress.protocol.oidc' +import { createSamlProtocolWebBinding } from './ingress.protocol.saml' +import { MageLocalIdentityProviderService } from './local-idp.services.api' +import { JWTService } from './verification' + +export function createIdpCache(idpRepo: IdentityProviderRepository, localIdp: MageLocalIdentityProviderService): IngressProtocolWebBindingCache { + const bindingsByIdpName = new Map() + const services: BindingServices = { + passport, + localIdp, + } + async function idpWebBindingForIdpName(idpName: string): Promise<{ idp: IdentityProvider, idpBinding: IngressProtocolWebBinding } | null> { + const cached = bindingsByIdpName.get(idpName) + if (cached) { + return cached + } + const idp = await idpRepo.findIdpByName(idpName) + if (!idp) { + return null + } + const idpBinding = createWebBinding(idp, services) + const cacheEntry = { idp, idpBinding } + bindingsByIdpName.set(idp.name, cacheEntry) + return cacheEntry + } + return { idpWebBindingForIdpName } +} + +type BindingServices = { + passport: passport.Authenticator + localIdp: MageLocalIdentityProviderService + baseUrl: string +} + +function createWebBinding(idp: IdentityProvider, services: BindingServices): IngressProtocolWebBinding { + if (idp.protocol === 'local') { + return createLocalProtocolWebBinding(services.passport, services.localIdp) + } + if (idp.protocol === 'ldap') { + return createLdapProtocolWebBinding(idp, services.passport) + } + if (idp.protocol === 'oauth') { + return createOAuthProtocolWebBinding(idp, services.passport, services.baseUrl) + } + if (idp.protocol === 'oidc') { + return createOIDCProtocolWebBinding(idp, services.passport, services.baseUrl) + } + if (idp.protocol === 'saml') { + return createSamlProtocolWebBinding(idp, services.passport, services.baseUrl) + } + throw new Error(`cannot create ingress web binding for idp:\n${JSON.stringify(idp, null, 2)}`) +} + +export async function initializeIngress( + userRepo: UserRepository, + sessionRepo: SessionRepository, + verificationService: JWTService, + provisioning: provision.ProvisionStatic, + passport: passport.Authenticator, +): Promise { +} + +/** + * This is the default bearer token authentication, registered to the passport instance under the default `bearer` + * name. Apply session token authentication to routes using Passport's middelware factory as follows. + * ``` + * expressRoutes.route('/protected') + * .use(passport.authenticate('bearer')) + * .get((req, res, next) => { ... }) + * ``` + */ +function registerAuthenticatedBearerTokenHandling(passport: passport.Authenticator, sessionRepo: SessionRepository, userRepo: UserRepository): passport.Authenticator { + return passport.use( + new bearer.Strategy( + { passReqToCallback: true }, + async function (req: express.Request, token: string, done: (err: Error | null, user?: Express.User, access?: bearer.IVerifyOptions) => any) { + try { + const session = await sessionRepo.readSessionByToken(token) + if (!session) { + console.warn('no session for token', token, req.method, req.url) + return done(null) + } + const user = await userRepo.findById(session.user) + if (!user) { + console.warn('no user for token', token, 'user id', session.user, req.method, req.url) + return done(null) + } + req.token = session.token + if (session.device) { + req.provisionedDeviceId = session.device + } + const webUser: Express.User = { + admitted: { + account: user, + session + } + } + return done(null, webUser, { scope: 'all' }); + } + catch (err) { + return done(err as Error) + } + } + ) + ) +} \ No newline at end of file diff --git a/service/src/ingress/ingress.protocol.bindings.ts b/service/src/ingress/ingress.protocol.bindings.ts new file mode 100644 index 000000000..96bfb021a --- /dev/null +++ b/service/src/ingress/ingress.protocol.bindings.ts @@ -0,0 +1,54 @@ +import express from 'express' +import { IdentityProviderUser } from './ingress.entities' + +export type IdentityProviderAdmissionWebUser = { + idpName: string + account: IdentityProviderUser + flowState?: string | undefined +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface User { + /** + * The ingress protocol must populate `req.user` with an object that has the `admittingFromIdentityProvider` + * property. When using a Passport strategy middleware to handle the authentication flow, the protocol would + * invoke Passport's callback like + * ``` + * done(null, { admittingFromIdentityProvider: { idpName: 'example', account: { ... } } }) + * ``` + */ + admittingFromIdentityProvider?: IdentityProviderAdmissionWebUser + } + } +} + +export enum IngressResponseType { + Direct = 'Direct', + Redirect = 'Redirect' +} + +/** + * `IngressProtocolWebBinding` is the binding of an authentication protocol's HTTP requests to an identity provider. + * The protocol uses the identity provider settings to determine the identity provider's endpoints and orchestrate the + * flow of HTTP messages between the Mage client, Mage server, and the identity provider's endpoints. + */ +export interface IngressProtocolWebBinding { + ingressResponseType: IngressResponseType + /** + * This function initiates the protocol's ingress process, which starts with a request to the `/signin` path of the + * IDP's context, e.g., `GET /auth/google-oidc/signin`. + * + * The `flowState` parameter is a URL-safe, percent-encoded string value which holds any state information the app + * needs to persist across multiple ingress protocol requests. + * This is primarily for saving information about how Mage delivers the final ingress result to the client, such as + * a direct response, or a redirect URL suitable for the modile or web apps. + * Different protocols have different ways of persisting state across requests, such as the OAuth/OpenID Connect + * `state` parameter and the SAML `RelayState` body attribute. The protocol must store this value and return the + * value in the {@link IdentityProviderAdmissionWebUser#flowState admission result}. + */ + beginIngressFlow(req: express.Request, res: express.Response, next: express.NextFunction, flowState: string | undefined): any + handleIngressFlowRequest: express.RequestHandler +} + diff --git a/service/src/ingress/ingress.protocol.ldap.ts b/service/src/ingress/ingress.protocol.ldap.ts new file mode 100644 index 000000000..24c65df0e --- /dev/null +++ b/service/src/ingress/ingress.protocol.ldap.ts @@ -0,0 +1,113 @@ +import passport from 'passport' +import LdapStrategy from 'passport-ldapauth' +import { IdentityProvider, IdentityProviderUser } from './ingress.entities' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' + + +type LdapProfileKeys = { + id?: string + email?: string + displayName?: string +} + +type LdapProtocolSettings = LdapStrategy.Options['server'] & { + profile?: LdapProfileKeys +} + +type ReadyLdapProtocolSettings = Omit & { profile: Required } + +function copyProtocolSettings(from: LdapProtocolSettings): LdapProtocolSettings { + const copy = { ...from } + if (copy.profile) { + copy.profile = { ...from.profile! } + } + return copy +} + +function applyDefaultProtocolSettings(idp: IdentityProvider): ReadyLdapProtocolSettings { + const settings = copyProtocolSettings(idp.protocolSettings as LdapProtocolSettings) + const profileKeys = settings.profile || {} + if (!profileKeys.displayName) { + profileKeys.displayName = 'givenname'; + } + if (!profileKeys.email) { + profileKeys.email = 'mail'; + } + if (!profileKeys.id) { + profileKeys.id = 'cn'; + } + settings.profile = profileKeys + return settings as ReadyLdapProtocolSettings +} + +function strategyOptionsFromProtocolSettings(settings: ReadyLdapProtocolSettings): LdapStrategy.Options { + return { + server: { + url: settings.url, + bindDN: settings.bindDN, + bindCredentials: settings.bindCredentials, + searchBase: settings.searchBase, + searchFilter: settings.searchFilter, + searchScope: settings.searchScope, + groupSearchBase: settings.groupSearchBase, + groupSearchFilter: settings.groupSearchFilter, + groupSearchScope: settings.groupSearchScope, + bindProperty: settings.bindProperty, + groupDnProperty: settings.groupDnProperty + } + } +} + +export function createLdapProtocolWebBinding(idp: IdentityProvider, passport: passport.Authenticator): IngressProtocolWebBinding { + const settings = applyDefaultProtocolSettings(idp) + const profileKeys = settings.profile + const strategyOptions = strategyOptionsFromProtocolSettings(settings) + const verify: LdapStrategy.VerifyCallback = (profile, done) => { + const idpAccount: IdentityProviderUser = { + username: profile[profileKeys.id], + displayName: profile[profileKeys.displayName], + email: profile[profileKeys.email], + phones: [], + idpAccountId: profile[profileKeys.id] + } + const webIngressUser: Express.User = { + admittingFromIdentityProvider: { + account: idpAccount, + idpName: idp.name, + } + } + return done(null, webIngressUser) + } + const title = idp.title + const authOptions: LdapStrategy.AuthenticateOptions = { + invalidLogonHours: `Access to ${title} account is prohibited at this time.`, + invalidWorkstation: `Access to ${title} account is prohibited from this workstation.`, + passwordExpired: `${title} password expired.`, + accountDisabled: `${title} account disabled.`, + accountExpired: `${title} account expired.`, + passwordMustChange: `${title} account requires password reset.`, + accountLockedOut: `${title} account locked.`, + invalidCredentials: `Invalid ${title} credentials.`, + } + const ldapIdp = new LdapStrategy(strategyOptions, verify) + return { + ingressResponseType: IngressResponseType.Direct, + beginIngressFlow(req, res, next, flowState): any { + const completeIngress: passport.AuthenticateCallback = (err, user) => { + if (err) { + return next(err) + } + if (user && user.admittingFromIdentityProvider) { + user.admittingFromIdentityProvider.flowState = flowState + req.user = user + return next() + } + return res.status(500).send('internal server error: invalid ldap ingress state') + } + passport.authenticate(ldapIdp, authOptions, completeIngress)(req, res, next) + }, + handleIngressFlowRequest(req, res): any { + return res.status(400).send('invalid ldap ingress request') + } + } +} \ No newline at end of file diff --git a/service/src/ingress/ingress.protocol.local.ts b/service/src/ingress/ingress.protocol.local.ts new file mode 100644 index 000000000..d30ad7a05 --- /dev/null +++ b/service/src/ingress/ingress.protocol.local.ts @@ -0,0 +1,61 @@ +import passport from 'passport' +import express from 'express' +import { Strategy as LocalStrategy, VerifyFunction as LocalStrategyVerifyFunction } from 'passport-local' +import { LocalIdpAccount } from './local-idp.entities' +import { IdentityProviderUser } from './ingress.entities' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' +import { MageLocalIdentityProviderService } from './local-idp.services.api' +import { permissionDenied } from '../app.api/app.api.errors' + + +function userForLocalIdpAccount(account: LocalIdpAccount): IdentityProviderUser { + return { + username: account.username, + displayName: account.username, + phones: [], + } +} + +function createLocalStrategy(localIdp: MageLocalIdentityProviderService, flowState: string | undefined): passport.Strategy { + const verify: LocalStrategyVerifyFunction = async function LocalIngressProtocolVerify(username, password, done) { + const authResult = await localIdp.authenticate({ username, password }) + if (!authResult || authResult.failed) { + return done(permissionDenied('local authentication failed', username)) + } + const localAccount = authResult.authenticated + const localIdpUser = userForLocalIdpAccount(localAccount) + return done(null, { admittingFromIdentityProvider: { idpName: 'local', account: localIdpUser, flowState } }) + } + return new LocalStrategy(verify) +} + +const validateSigninRequest: express.RequestHandler = function LocalProtocolIngressHandler(req, res, next) { + if (req.method !== 'POST' || !req.body) { + return res.status(400).send(`invalid request method ${req.method}`) + } + const username = req.body.username + const password = req.body.password + if (typeof username !== 'string' || typeof password !== 'string') { + return res.status(400).send(`username and password are required`) + } + next() +} + +export function createLocalProtocolWebBinding(passport: passport.Authenticator, localIdpAuthenticate: MageLocalIdentityProviderService): IngressProtocolWebBinding { + return { + ingressResponseType: IngressResponseType.Direct, + beginIngressFlow: (req, res, next, flowState): any => { + const authStrategy = createLocalStrategy(localIdpAuthenticate, flowState) + const applyLocalProtocol = express.Router() + .post('/*', + express.urlencoded(), + validateSigninRequest, + passport.authenticate(authStrategy) + ) + applyLocalProtocol(req, res, next) + }, + handleIngressFlowRequest(req, res): any { + return res.status(400).send('invalid local ingress request') + } + } +} diff --git a/service/src/ingress/ingress.protocol.oauth.ts b/service/src/ingress/ingress.protocol.oauth.ts new file mode 100644 index 000000000..effe790b1 --- /dev/null +++ b/service/src/ingress/ingress.protocol.oauth.ts @@ -0,0 +1,147 @@ +import express from 'express' +import { InternalOAuthError, Strategy as OAuth2Strategy, StrategyOptions as OAuth2Options, VerifyCallback, VerifyFunction } from 'passport-oauth2' +import base64 from 'base-64' +import { IdentityProvider, IdentityProviderUser } from './ingress.entities' +import passport from 'passport' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' + +export type OAuth2ProtocolSettings = + Pick & + { + profileURL: string, + headers?: { basic?: boolean | null | undefined }, + profile: OAuth2ProfileKeys + } + +export type OAuth2ProfileKeys = { + id: string + email: string + displayName: string +} + +function copyProtocolSettings(from: OAuth2ProtocolSettings): OAuth2ProtocolSettings { + const copy = { ...from } + copy.profile = { ...from.profile } + if (Array.isArray(from.scope)) { + copy.scope = [ ...from.scope ] + } + if (from.headers) { + copy.headers = { ...from.headers } + } + return copy +} + +class OAuth2ProfileStrategy extends OAuth2Strategy { + + constructor(options: OAuth2Options, readonly profileURL: string, verify: VerifyFunction) { + super(options as OAuth2Options, verify) + this._oauth2.useAuthorizationHeaderforGET(true) + } + + userProfile(accessToken: string, done: (err: unknown, profile?: any) => void): void { + this._oauth2.get(this.profileURL, accessToken, (err, body) => { + if (err) { + return done(new InternalOAuthError('error fetching oauth2 user profile', err)) + } + try { + const parsedBody = JSON.parse(body as string) + const profile = { + provider: 'oauth2', + json: parsedBody, + raw: body, + } + done(null, profile) + } + catch (err) { + console.error('error parsing oauth profile', err) + done(err) + } + }) + } +} + +function applyDefaultProtocolSettings(idp: IdentityProvider): OAuth2ProtocolSettings { + const settings = copyProtocolSettings(idp.protocolSettings as OAuth2ProtocolSettings) + const profile = settings.profile + if (!profile.displayName) { + profile.displayName = 'displayName' + } + if (!profile.email) { + profile.email = 'email' + } + if (!profile.id) { + profile.id = 'id'; + } + return settings +} + +type OAuth2Info = { state: string } + +/** + * The `baseUrl` parameter is the URL at which Mage will mount the returned `express.Router`, including any + * distinguishing component of the given `IdentityProvider`, without a trailing slash, e.g. `/auth/example-idp`. + */ +export function createOAuthProtocolWebBinding(idp: IdentityProvider, passport: passport.Authenticator, baseUrl: string): IngressProtocolWebBinding { + const settings = applyDefaultProtocolSettings(idp) + const profileURL = settings.profileURL + const customHeaders = settings.headers?.basic ? { + authorization: `Basic ${base64.encode(`${settings.clientID}:${settings.clientSecret}`)}` + } : undefined + const strategyOptions: OAuth2Options = { + clientID: settings.clientID, + clientSecret: settings.clientSecret, + callbackURL: `${baseUrl}/callback`, + authorizationURL: settings.authorizationURL, + tokenURL: settings.tokenURL, + customHeaders, + scope: settings.scope, + pkce: settings.pkce, + /** + * cast to `any` because `@types/passport-oauth2` incorrectly does not allow `boolean` for the `store` entry + * https://github.com/jaredhanson/passport-oauth2/blob/master/lib/strategy.js#L107 + */ + store: true as any + } + const verify: VerifyFunction = (accessToken: string, refreshToken: string, profileResponse: any, done: VerifyCallback) => { + const profile = profileResponse.json + const profileKeys = settings.profile + if (!profile[profileKeys.id]) { + console.error(`oauth2 profile missing id for key ${profileKeys.id}:\n`, JSON.stringify(profile, null, 2)) + return done(new Error(`oauth2 user profile does not contain id property ${profileKeys.id}`)) + } + const username = profile[profileKeys.id] + const displayName = profile[profileKeys.displayName] || username + const email = profile[profileKeys.email] + const idpUser: IdentityProviderUser = { username, displayName, email, phones: [] } + return done(null, { admittingFromIdentityProvider: { idpName: idp.name, account: idpUser, flowState: '' }}) + } + const oauth2Strategy = new OAuth2ProfileStrategy(strategyOptions, profileURL, verify) + const handleIngressFlowRequest = express.Router() + .get('/callback', (req, res, next) => { + const finishIngressFlow = passport.authenticate( + oauth2Strategy, + (err: Error | null | undefined, account: IdentityProviderUser, info: OAuth2Info) => { + if (err) { + return next(err) + } + req.user = { admittingFromIdentityProvider: { idpName: idp.name, account, flowState: info.state }} + next() + } + ) + finishIngressFlow(req, res, next) + }) + return { + ingressResponseType: IngressResponseType.Redirect, + beginIngressFlow(req, res, next, flowState): any { + passport.authenticate(oauth2Strategy, { state: flowState })(req, res, next) + }, + handleIngressFlowRequest + } +} \ No newline at end of file diff --git a/service/src/ingress/ingress.protocol.oidc.ts b/service/src/ingress/ingress.protocol.oidc.ts new file mode 100644 index 000000000..29a336b3c --- /dev/null +++ b/service/src/ingress/ingress.protocol.oidc.ts @@ -0,0 +1,122 @@ +import express from 'express' +import passport from 'passport' +import OpenIdConnectStrategy from 'passport-openidconnect' +import { IdentityProvider, IdentityProviderUser } from './ingress.entities' +import { IdentityProviderAdmissionWebUser, IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' + + +export type OpenIdConnectProtocolSettings = + Pick< + OpenIdConnectStrategy.StrategyOptions, + 'clientID' | 'clientSecret' | 'issuer' | 'authorizationURL' | 'tokenURL' | 'scope' + > & + { + profileURL: string, + profile: { + displayName?: string + email?: string + id?: string + } + } + +function copyProtocolSettings(from: OpenIdConnectProtocolSettings): OpenIdConnectProtocolSettings { + const copy = { ...from } + copy.profile = { ...from.profile } + if (Array.isArray(from.scope)) { + copy.scope = [ ...from.scope ] + } + return copy +} + +function applyDefaultProtocolSettings(idp: IdentityProvider): OpenIdConnectProtocolSettings { + const settings = copyProtocolSettings(idp.protocolSettings as OpenIdConnectProtocolSettings) + if (!settings.scope) { + settings.scope = [ 'openid' ] + } + else if (Array.isArray(settings.scope) && !settings.scope.includes('openid')) { + settings.scope = [ ...settings.scope, 'openid' ] + } + else if (typeof settings.scope === 'string' && settings.scope !== 'openid') { + settings.scope = [ settings.scope, 'openid' ] + } + const profile = settings.profile + if (!profile.displayName) { + profile.displayName = 'displayName' + } + if (!profile.email) { + profile.email = 'email' + } + if (!profile.id) { + profile.id = 'sub'; + } + return settings +} + +export function createOIDCProtocolWebBinding(idp: IdentityProvider, passport: passport.Authenticator, baseUrl: string): IngressProtocolWebBinding { + const settings = applyDefaultProtocolSettings(idp) + const verify: OpenIdConnectStrategy.VerifyFunction = ( + issuer: string, + uiProfile: any, + idProfile: object, + context: object, + idToken: string | object, + accessToken: string | object, + refreshToken: string, + params: any, + done: OpenIdConnectStrategy.VerifyCallback + ) => { + const jsonProfile = uiProfile._json + const idpAccountId = jsonProfile[settings.profile.id!] + if (!idpAccountId) { + const message = `user profile from oidc identity provider ${idp.name} does not contain id property ${settings.profile.id}` + console.error(message, JSON.stringify(jsonProfile, null, 2)) + return done(new Error(message)) + } + const idpUser: IdentityProviderUser = { + username: idpAccountId, + displayName: jsonProfile[settings.profile.displayName!] || idpAccountId, + email: jsonProfile[settings.profile.email!], + phones: [], + idpAccountId + } + done(null, { admittingFromIdentityProvider: { idpName: idp.name, account: idpUser } } ) + } + const oidcStrategy = new OpenIdConnectStrategy( + { + clientID: settings.clientID, + clientSecret: settings.clientSecret, + issuer: settings.issuer, + authorizationURL: settings.authorizationURL, + tokenURL: settings.tokenURL, + userInfoURL: settings.profileURL, + callbackURL: `${baseUrl}/callback`, + scope: settings.scope + }, + verify + ) + const handleIngressFlowRequest = express.Router() + .get('/callback', (req, res, next) => { + const finishIngressFlow = passport.authenticate( + oidcStrategy, + (err: Error | null, user: IdentityProviderAdmissionWebUser, info: { state: string | undefined }) => { + if (err) { + return next(err) + } + const idpUserWithState: IdentityProviderAdmissionWebUser = { + ...user, + flowState: info.state + } + req.user = { admittingFromIdentityProvider: idpUserWithState } + next() + } + ) + finishIngressFlow(req, res, next) + }) + return { + ingressResponseType: IngressResponseType.Redirect, + beginIngressFlow(req, res, next, flowState): any { + passport.authenticate(oidcStrategy, { state: flowState })(req, res, next) + }, + handleIngressFlowRequest + } +} diff --git a/service/src/ingress/ingress.protocol.saml.ts b/service/src/ingress/ingress.protocol.saml.ts new file mode 100644 index 000000000..b580ffe15 --- /dev/null +++ b/service/src/ingress/ingress.protocol.saml.ts @@ -0,0 +1,123 @@ +import express from 'express' +import passport from 'passport' +import { SamlConfig, Strategy as SamlStrategy, VerifyWithRequest } from '@node-saml/passport-saml' +import { IdentityProvider, IdentityProviderUser } from './ingress.entities' +import { IngressProtocolWebBinding, IngressResponseType } from './ingress.protocol.bindings' + + +type SamlProfileKeys = { + id?: string + email?: string + displayName?: string +} + +type SamlProtocolSettings = + Pick< + SamlConfig, + | 'path' + | 'entryPoint' + | 'cert' + | 'issuer' + | 'privateKey' + | 'decryptionPvk' + | 'signatureAlgorithm' + | 'audience' + | 'identifierFormat' + | 'acceptedClockSkewMs' + | 'attributeConsumingServiceIndex' + | 'disableRequestedAuthnContext' + | 'authnContext' + | 'forceAuthn' + | 'skipRequestCompression' + | 'authnRequestBinding' + | 'racComparison' + | 'providerName' + | 'idpIssuer' + | 'validateInResponseTo' + | 'requestIdExpirationPeriodMs' + | 'logoutUrl' + > + & { + profile: SamlProfileKeys + } + +function copyProtocolSettings(from: SamlProtocolSettings): SamlProtocolSettings { + const copy = { ...from } + copy.profile = { ...from.profile } + return copy +} + +function applyDefaultProtocolSettings(idp: IdentityProvider): SamlProtocolSettings { + const settings = copyProtocolSettings(idp.protocolSettings as SamlProtocolSettings) + if (!settings.profile) { + settings.profile = {} + } + if (!settings.profile.displayName) { + settings.profile.displayName = 'email' + } + if (!settings.profile.email) { + settings.profile.email = 'email' + } + if (!settings.profile.id) { + settings.profile.id = 'uid' + } + return settings +} + +export function createSamlProtocolWebBinding(idp: IdentityProvider, passport: passport.Authenticator, baseUrlPath: string): IngressProtocolWebBinding { + const { profile: profileKeys, ...settings } = applyDefaultProtocolSettings(idp) + // TODO: this will need the the saml callback override change + settings.path = `${baseUrlPath}/callback` + const samlStrategy = new SamlStrategy(settings, + (function samlSignIn(req, profile, done) { + if (!profile) { + return done(new Error('missing saml profile')) + } + const uid = profile[profileKeys.id!] + if (!uid || typeof uid !== 'string') { + return done(new Error(`saml profile missing id for key ${profileKeys.id}`)) + } + const idpAccount: IdentityProviderUser = { + username: uid, + displayName: profile[profileKeys.displayName!] as string, + email: profile[profileKeys.email!] as string | undefined, + phones: [], + } + const webUser: Pick = { + admittingFromIdentityProvider: { + idpName: idp.name, + account: idpAccount, + } + } + try { + const relayState = JSON.parse(req.body.RelayState) || {} + if (!relayState) { + return done(new Error('missing saml relay state')) + } + if (relayState.initiator !== 'mage') { + return done(new Error(`invalid saml relay state initiator: ${relayState.initiator}`)) + } + webUser.admittingFromIdentityProvider!.flowState = relayState.flowState + } + catch (err) { + return done(err as Error) + } + done(null, webUser) + }) as VerifyWithRequest, + (function samlSignOut() { + console.warn('saml sign out unimplemented') + }) as VerifyWithRequest + ) + const handleIngressFlowRequest = express.Router() + .post('/callback', + passport.authenticate(samlStrategy), + ) + return { + ingressResponseType: IngressResponseType.Redirect, + beginIngressFlow(req, res, next, flowState): any { + const RelayState = JSON.stringify({ initiator: 'mage', flowState }) + passport.authenticate(samlStrategy, { additionalParams: { RelayState } } as any)(req, res, next) + }, + handleIngressFlowRequest + } +} \ No newline at end of file diff --git a/service/src/ingress/ingress.services.api.ts b/service/src/ingress/ingress.services.api.ts new file mode 100644 index 000000000..a3ad3c8a6 --- /dev/null +++ b/service/src/ingress/ingress.services.api.ts @@ -0,0 +1,39 @@ +import { UserExpanded } from '../entities/users/entities.users' +import { IdentityProvider, IdentityProviderUser, UserIngressBindings } from './ingress.entities' + +export type AdmissionResult = + | { + /** + * `'admitted'` if the user account is valid for admission and access to Mage, `'denied'` otherwise + */ + action: 'admitted', + /** + * The existing or newly enrolled Mage account + */ + mageAccount: UserExpanded, + /** + * Whether the admission resulted in a new Mage account enrollment + */ + enrolled: boolean, + } + | { + action: 'denied', + reason: AdmissionDeniedReason, + mageAccount: UserExpanded | null, + enrolled: boolean, + } + +export enum AdmissionDeniedReason { + PendingApproval = 'PendingApproval', + Disabled = 'Disabled', + NameConflict = 'NameConflict', + InternalError = 'InternalError', +} + +export interface AdmitUserFromIdentityProviderAccount { + (idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise +} + +export interface EnrollNewUser { + (idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: UserExpanded, ingressBindings: UserIngressBindings }> +} \ No newline at end of file diff --git a/service/src/ingress/ingress.services.impl.ts b/service/src/ingress/ingress.services.impl.ts new file mode 100644 index 000000000..0c0edadcb --- /dev/null +++ b/service/src/ingress/ingress.services.impl.ts @@ -0,0 +1,114 @@ +import { MageEventId } from '../entities/events/entities.events' +import { Team, TeamId } from '../entities/teams/entities.teams' +import { UserExpanded, UserId, UserRepository, UserRepositoryError } from '../entities/users/entities.users' +import { createEnrollmentCandidateUser, IdentityProvider, IdentityProviderUser, UserIngressBindingsRepository, UserIngressBindings, determinUserIngressBindingAdmission } from './ingress.entities' +import { AdmissionDeniedReason, AdmissionResult, AdmitUserFromIdentityProviderAccount, EnrollNewUser } from './ingress.services.api' + +export interface AssignTeamMember { + (member: UserId, team: TeamId): Promise +} + +export interface FindEventTeam { + (mageEventId: MageEventId): Promise +} + +export function CreateUserAdmissionService(userRepo: UserRepository, ingressBindingRepo: UserIngressBindingsRepository, enrollNewUser: EnrollNewUser): AdmitUserFromIdentityProviderAccount { + return async function(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise { + return userRepo.findByUsername(idpAccount.username) + .then(existingAccount => { + if (existingAccount) { + return ingressBindingRepo.readBindingsForUser(existingAccount.id).then(ingressBindings => { + return { enrolled: false, mageAccount: existingAccount, ingressBindings } + }) + } + console.info(`enrolling new user account ${idpAccount.username} from identity provider ${idp.name}`) + return enrollNewUser(idpAccount, idp).then(enrollment => ({ enrolled: true, ...enrollment })) + }) + .then(userIngress => { + const { enrolled, mageAccount, ingressBindings } = userIngress + const idpAdmission = determinUserIngressBindingAdmission(idpAccount, idp, ingressBindings) + if (idpAdmission.deny) { + console.error(`user ${mageAccount.username} has no ingress binding to identity provider ${idp.name}`) + return { action: 'denied', reason: AdmissionDeniedReason.NameConflict, enrolled, mageAccount } + } + if (idpAdmission.admitNew) { + return ingressBindingRepo.saveUserIngressBinding(mageAccount.id, idpAdmission.admitNew) + .then(() => ({ action: 'admitted', mageAccount, enrolled })) + .catch(err => { + console.error(`error saving ingress binding for user ${mageAccount.username} to idp ${idp.name}`, err) + return { action: 'denied', reason: AdmissionDeniedReason.InternalError, mageAccount, enrolled } + }) + } + return { action: 'admitted', mageAccount, enrolled } + }) + .then(userIngress => { + const { action, mageAccount, enrolled } = userIngress + if (!mageAccount) { + return { action: 'denied', reason: AdmissionDeniedReason.InternalError, mageAccount, enrolled } + } + if (action === 'denied') { + return userIngress + } + if (!mageAccount.active) { + return { action: 'denied', reason: AdmissionDeniedReason.PendingApproval, mageAccount, enrolled } + } + if (!mageAccount.enabled) { + return { action: 'denied', reason: AdmissionDeniedReason.Disabled, mageAccount, enrolled } + } + return userIngress + }) + .catch(err => { + console.error(`error admitting user account ${idpAccount.username} from identity provider ${idp.name}`, err) + return { action: 'denied', reason: AdmissionDeniedReason.InternalError, enrolled: false, mageAccount: null } + }) + } +} + +export function CreateNewUserEnrollmentService(userRepo: UserRepository, ingressBindingRepo: UserIngressBindingsRepository, findEventTeam: FindEventTeam, assignTeamMember: AssignTeamMember): EnrollNewUser { + return async function processNewUserEnrollment(idpAccount: IdentityProviderUser, idp: IdentityProvider): Promise<{ mageAccount: UserExpanded, ingressBindings: UserIngressBindings }> { + console.info(`enrolling new user account ${idpAccount.username} from identity provider ${idp.name}`) + const candidate = createEnrollmentCandidateUser(idpAccount, idp) + const mageAccount = await userRepo.create(candidate) + if (mageAccount instanceof UserRepositoryError) { + throw mageAccount + } + const now = new Date() + const ingressBindings = await ingressBindingRepo.saveUserIngressBinding( + mageAccount.id, + { + idpId: idp.id, + idpAccountId: idpAccount.username, + created: now, + updated: now, + } + ) + if (ingressBindings instanceof Error) { + throw ingressBindings + } + const { assignToTeams, assignToEvents } = idp.userEnrollmentPolicy + const assignEnrolledToTeam = (teamId: TeamId): Promise<{ teamId: TeamId, assigned: boolean }> => { + return assignTeamMember(mageAccount.id, teamId) + .then(assigned => ({ teamId, assigned })) + .catch(err => { + console.error(`error assigning enrolled user ${mageAccount.username} to team ${teamId}`, err) + return { teamId, assigned: false } + }) + } + const assignEnrolledToEventTeam = (eventId: MageEventId): Promise<{ eventId: MageEventId, teamId: TeamId | null, assigned: boolean }> => { + return findEventTeam(eventId) + .then<{ eventId: MageEventId, teamId: TeamId | null, assigned: boolean }>(eventTeam => { + if (eventTeam) { + return assignEnrolledToTeam(eventTeam.id).then(teamAssignment => ({ eventId, ...teamAssignment })) + } + console.error(`failed to find implicit team for event ${eventId} while enrolling user ${mageAccount.username}`) + return { eventId, teamId: null, assigned: false } + }) + .catch(err => { + console.error(`error looking up implicit team for event ${eventId} while enrolling user ${mageAccount.username}`, err) + return { eventId, teamId: null, assigned: false } + }) + } + await Promise.all([ ...assignToTeams.map(assignEnrolledToTeam), ...assignToEvents.map(assignEnrolledToEventTeam) ]) + return { mageAccount, ingressBindings } + } +} \ No newline at end of file diff --git a/service/src/ingress/local-idp.adapters.db.mongoose.ts b/service/src/ingress/local-idp.adapters.db.mongoose.ts new file mode 100644 index 000000000..db8359cfc --- /dev/null +++ b/service/src/ingress/local-idp.adapters.db.mongoose.ts @@ -0,0 +1,254 @@ +"use strict"; + +import mongoose from 'mongoose' +import { IdentityProviderDocument, IdentityProviderModel } from './ingress.adapters.db.mongoose' +import { LocalIdpDuplicateUsernameError, LocalIdpAccount, LocalIdpRepository, SecurityPolicy } from './local-idp.entities' + +const Schema = mongoose.Schema + +export type LocalIdpAccountDocument = Omit & { + /** + * The _id is the username on the acccount. + */ + _id: string + password: string + previousPasswords: string[] +} + +export type LocalIdpAccountModel = mongoose.Model + +// TODO: migrate from old authentication schema +export const LocalIdpAccountSchema = new Schema( + { + // TODO: users-next: migration to set username as _id + _id: { type: String, required: true }, + password: { type: String, required: true }, + previousPasswords: { type: [String], default: [] }, + security: { + locked: { type: Boolean, default: false }, + lockedUntil: { type: Date }, + invalidLoginAttempts: { type: Number, default: 0 }, + numberOfTimesLocked: { type: Number, default: 0 } + } + }, + { + id: false, + timestamps: { + createdAt: true, + updatedAt: 'lastUpdated' + } + } +) + +// function DbLocalAuthenticationToObject(authIn, authOut, options) { +// authOut = DbAuthenticationToObject(authIn, authOut, options) +// delete authOut.password; +// delete authOut.previousPasswords; +// return authOut; +// } + +// const SamlSchema = new Schema({}); +// const LdapSchema = new Schema({}); +// const OauthSchema = new Schema({}); +// const OpenIdConnectSchema = new Schema({}); + +// AuthenticationSchema.method('validatePassword', function (password, callback) { +// hasher.validPassword(password, this.password, callback); +// }); + +// Encrypt password before save +// LocalSchema.pre('save', function (next) { +// const authentication = this; + +// // only hash the password if it has been modified (or is new) +// if (!authentication.isModified('password')) { +// return next(); +// } + +// async.waterfall([ +// function (done) { +// AuthenticationConfiguration.getById(authentication.authenticationConfigurationId).then(localConfiguration => { +// done(null, localConfiguration.settings.passwordPolicy); +// }).catch(err => done(err)); +// }, +// function (policy, done) { +// const { password, previousPasswords } = authentication; +// PasswordValidator.validate(policy, { password, previousPasswords }).then(validationStatus => { +// if (!validationStatus.valid) { +// const err = new Error(validationStatus.errorMsg); +// err.status = 400; +// return done(err); +// } + +// done(null, policy); +// }); +// }, +// function (policy, done) { +// hasher.hashPassword(authentication.password, function (err, password) { +// done(err, policy, password); +// }); +// } +// ], function (err, policy, password) { +// if (err) return next(err); + +// authentication.password = password; +// authentication.previousPasswords.unshift(password); +// authentication.previousPasswords = authentication.previousPasswords.slice(0, policy.passwordHistoryCount); +// next(); +// }); +// }); + +// Remove Token if password changed +// LocalSchema.pre('save', function (next) { +// const authentication = this; + +// // only remove token if password has been modified (or is new) +// if (!authentication.isModified('password')) { +// return next(); +// } + +// async.waterfall([ +// function (done) { +// // TODO: users-next +// User.getUserByAuthenticationId(authentication._id, function (err, user) { +// done(err, user); +// }); +// }, +// function (user, done) { +// if (user) { +// Token.removeTokensForUser(user, function (err) { +// done(err); +// }); +// } else { +// done(); +// } +// } +// ], function (err) { +// return next(err); +// }); +// }); + +// exports.getAuthenticationByStrategy = function (strategy, uid, callback) { +// if (callback) { +// Authentication.findOne({ id: uid, type: strategy }, callback); +// } else { +// return Authentication.findOne({ id: uid, type: strategy }); +// } +// }; + +// exports.getAuthenticationsByType = function (type) { +// return Authentication.find({ type: type }).exec(); +// }; + +// exports.getAuthenticationsByAuthConfigId = function (authConfigId) { +// return Authentication.find({ authenticationConfigurationId: authConfigId }).exec(); +// }; + +// exports.countAuthenticationsByAuthConfigId = function (authConfigId) { +// return Authentication.count({ authenticationConfigurationId: authConfigId }).exec(); +// }; + +// exports.createAuthentication = function (authentication) { +// const document = { +// id: authentication.id, +// type: authentication.type, +// authenticationConfigurationId: authentication.authenticationConfigurationId, +// } + +// if (authentication.type === 'local') { +// document.password = authentication.password; +// document.security ={ +// lockedUntil: null +// } +// } + +// return Authentication.create(document); +// }; + +// exports.updateAuthentication = function (authentication) { +// return authentication.save(); +// }; + +// exports.removeAuthenticationById = function (authenticationId, done) { +// Authentication.findByIdAndRemove(authenticationId, done); +// }; + +function entityForDocument(doc: LocalIdpAccountDocument): LocalIdpAccount { + return { + username: doc._id, + createdAt: doc.createdAt, + lastUpdated: doc.lastUpdated, + hashedPassword: doc.password, + previousHashedPasswords: [ ...doc.previousPasswords ], + security: { ...doc.security } + } +} + +// TODO: verify desired behavior for this mapping +function documentForEntity(entity: Partial): Partial { + const hasKey = (key: keyof LocalIdpAccount): boolean => Object.prototype.hasOwnProperty.call(entity, key) + const doc: Partial = {} + if (hasKey('username')) { + doc._id = entity.username + } + if (hasKey('createdAt') && entity.createdAt) { + doc.createdAt = new Date(entity.createdAt) + } + if (hasKey('lastUpdated') && entity.lastUpdated) { + doc.lastUpdated = new Date(entity.lastUpdated) + } + if (hasKey('hashedPassword')) { + doc.password = entity.hashedPassword + } + if (hasKey('previousHashedPasswords') && entity.previousHashedPasswords) { + doc.previousPasswords = entity.previousHashedPasswords + } + if (hasKey('security') && entity.security) { + doc.security = { ...entity.security } + } + return doc +} + +function localIdpSecurityPolicyFromIdenityProvider(localIdp: IdentityProviderDocument): SecurityPolicy { + const settings = localIdp.protocolSettings + return { + accountLock: { ...settings.accountLock }, + passwordRequirements: { ...settings.passwordPolicy } + } +} + +export class LocalIdpMongooseRepository implements LocalIdpRepository { + + constructor(private LocalIdpAccountModel: LocalIdpAccountModel, private IdentityProviderModel: IdentityProviderModel) {} + + async readSecurityPolicy(): Promise { + const idpDoc = await this.IdentityProviderModel.findOne({ name: 'local' }) + if (idpDoc) { + return localIdpSecurityPolicyFromIdenityProvider(idpDoc) + } + throw new Error('local identity provider not found') + } + + async createLocalAccount(account: LocalIdpAccount): Promise { + const doc = documentForEntity(account) + const created = await this.LocalIdpAccountModel.create(doc) + // TODO: handle duplicate username error + return entityForDocument(created) + } + + async readLocalAccount(username: string): Promise { + const doc = await this.LocalIdpAccountModel.findById(username, null, { lean: true }) + if (doc) { + return entityForDocument(doc) + } + return null + } + + updateLocalAccount(update: Partial & Pick): Promise { + throw new Error('Method not implemented.') + } + + deleteLocalAccount(username: string): Promise { + throw new Error('Method not implemented.') + } +} \ No newline at end of file diff --git a/service/src/ingress/local-idp.entities.ts b/service/src/ingress/local-idp.entities.ts new file mode 100644 index 000000000..ba9a59790 --- /dev/null +++ b/service/src/ingress/local-idp.entities.ts @@ -0,0 +1,175 @@ +import moment from 'moment' +import { PasswordRequirements, validatePasswordRequirements } from '../utilities/password-policy' +import { defaultHashUtil } from '../utilities/password-hashing' + +export interface LocalIdpAccount { + username: string + createdAt: Date + lastUpdated: Date + hashedPassword: string + previousHashedPasswords: string[] + security: { + locked: boolean + /** + * When `lockedUntil` is `null`, the account is locked indefinitely. + */ + lockedUntil: Date | null + invalidLoginAttempts: number + numberOfTimesLocked: number + } +} + +export interface LocalIdpCredentials { + username: string + password: string +} + +export interface AccountLockPolicy { + enabled: boolean + /** + * The number of failed login attempts allowed before locking the account + */ + lockAfterInvalidLoginCount: number + /** + * The duration in seconds to lock an account after reaching the failed login threshold + */ + lockDurationSeconds: number + /** + * The number of account locks allowed before disabling the account + */ + disableAfterLockCount: number +} + +export interface SecurityPolicy { + passwordRequirements: PasswordRequirements + accountLock: AccountLockPolicy +} + +export interface LocalIdpRepository { + readSecurityPolicy(): Promise + // updateSecurityPolicy(policy: SecurityPolicy): Promise + createLocalAccount(account: LocalIdpAccount): Promise + readLocalAccount(username: string): Promise + updateLocalAccount(update: Partial & Pick): Promise + deleteLocalAccount(username: string): Promise +} + +export async function prepareNewAccount(username: string, password: string, policy: SecurityPolicy): Promise { + const passwordRequirements = policy.passwordRequirements + const passwordValidation = await validatePasswordRequirements(password, passwordRequirements, []) + if (!passwordValidation.valid) { + return invalidPasswordError(passwordValidation.errorMessage || 'Password does not meet requirements.') + } + const hashedPassword = await defaultHashUtil.hashPassword(password) + const now = new Date() + const account: LocalIdpAccount = { + username, + hashedPassword: defaultHashUtil.serializePassword(hashedPassword), + previousHashedPasswords: [], + createdAt: now, + lastUpdated: now, + security: { + invalidLoginAttempts: 0, + locked: false, + lockedUntil: null, + numberOfTimesLocked: 0 + } + } + return account +} + +export function verifyPasswordForAccount(account: LocalIdpAccount, password: string): Promise { + return defaultHashUtil.validPassword(password, account.hashedPassword) +} + +export function applyPolicyForFailedAuthenticationAttempt(account: LocalIdpAccount, policy: AccountLockPolicy): LocalIdpAccount { + if (!policy.enabled) { + return account + } + const accountStatus = { ...account.security } + accountStatus.invalidLoginAttempts += 1 + if (accountStatus.invalidLoginAttempts >= policy.lockAfterInvalidLoginCount) { + accountStatus.locked = true + accountStatus.numberOfTimesLocked += 1 + if (accountStatus.numberOfTimesLocked >= policy.disableAfterLockCount) { + accountStatus.lockedUntil = null + } + else { + accountStatus.lockedUntil = moment().add(policy.lockDurationSeconds, 'seconds').toDate() + } + } + else { + accountStatus.locked = false + accountStatus.lockedUntil = null + } + return { + ...account, + security: accountStatus + } +} + +export type LocalIdpAuthenticationResult = { authenticated: LocalIdpAccount, failed: false } | { authenticated: false, failed: LocalIdpFailedAuthenticationError } + +/** + * Check whether the given password matches the given account's password. If the password does not match, or the + * account is locked, return an error whose account object reflects the given account lock policy. If the password + * matches, return the given account with {@link unlockAndResetSecurityStatus() good security status}. + */ +export async function attemptAuthentication(account: LocalIdpAccount, password: string, policy: AccountLockPolicy): Promise { + if (account.security.locked) { + if (account.security.lockedUntil === null || account.security.lockedUntil.getTime() > Date.now()) { + return { authenticated: false, failed: accountLockedError(account) } + } + } + const passwordMatches = await verifyPasswordForAccount(account, password) + if (passwordMatches) { + return { authenticated: unlockAndResetSecurityStatusOfAccount(account), failed: false } + } + return { authenticated: false, failed: passwordMismatchError(applyPolicyForFailedAuthenticationAttempt(account, policy)) } +} + +export function unlockAndResetSecurityStatusOfAccount(account: LocalIdpAccount): LocalIdpAccount { + const accountStatus = { ...account.security } + accountStatus.locked = false + accountStatus.lockedUntil = null + accountStatus.invalidLoginAttempts = 0 + accountStatus.numberOfTimesLocked = 0 + return { + ...account, + security: accountStatus + } +} + +export class LocalIdpError extends Error { +} + +export class LocalIdpInvalidPasswordError extends LocalIdpError { +} + +export class LocalIdpDuplicateUsernameError extends LocalIdpError { + constructor(public username: string) { + super(`duplicate account username: ${username}`) + } +} + +export class LocalIdpFailedAuthenticationError extends LocalIdpError { + constructor(public account: LocalIdpAccount, message: string = `failed to authenticate user ${account.username}`) { + super(message) + } +} + +export class LocalIdpAccountNotFoundError extends LocalIdpError { + +} + +function invalidPasswordError(reason: string): LocalIdpError { + return new LocalIdpError(reason) +} + +function passwordMismatchError(account: LocalIdpAccount): LocalIdpFailedAuthenticationError { + return new LocalIdpFailedAuthenticationError(account, `invalid password for user ${account.username}`) +} + +function accountLockedError(account: LocalIdpAccount): LocalIdpFailedAuthenticationError { + return new LocalIdpFailedAuthenticationError(account, `account for user ${account.username} is locked${account.security.lockedUntil ? account.security.lockedUntil.toUTCString() : ''}`) +} \ No newline at end of file diff --git a/service/src/ingress/local-idp.services.api.ts b/service/src/ingress/local-idp.services.api.ts new file mode 100644 index 000000000..0a342d4df --- /dev/null +++ b/service/src/ingress/local-idp.services.api.ts @@ -0,0 +1,15 @@ +import { LocalIdpAccount, LocalIdpAuthenticationResult, LocalIdpCredentials, LocalIdpError } from './local-idp.entities' + + +export interface MageLocalIdentityProviderService { + createAccount(credentials: LocalIdpCredentials): Promise + /** + * Return `null` if no account for the given username exists. + */ + deleteAccount(username: string): Promise + /** + * Return `null` if no account for the given username exists. If authentication fails, update the corresponding + * account according to the service's account lock policy. + */ + authenticate(credentials: LocalIdpCredentials): Promise +} \ No newline at end of file diff --git a/service/src/ingress/local-idp.services.impl.ts b/service/src/ingress/local-idp.services.impl.ts new file mode 100644 index 000000000..8a94e5ff9 --- /dev/null +++ b/service/src/ingress/local-idp.services.impl.ts @@ -0,0 +1,55 @@ +import { attemptAuthentication, LocalIdpAccount, LocalIdpAuthenticationResult, LocalIdpCredentials, LocalIdpDuplicateUsernameError, LocalIdpError, LocalIdpInvalidPasswordError, LocalIdpRepository, prepareNewAccount } from './local-idp.entities' +import { MageLocalIdentityProviderService } from './local-idp.services.api' + + +export function createLocalIdentityProviderService(repo: LocalIdpRepository): MageLocalIdentityProviderService { + + async function createAccount(credentials: LocalIdpCredentials): Promise { + const securityPolicy = await repo.readSecurityPolicy() + const { username, password } = credentials + const candidateAccount = await prepareNewAccount(username, password, securityPolicy) + if (candidateAccount instanceof LocalIdpInvalidPasswordError) { + return candidateAccount + } + const createdAccount = await repo.createLocalAccount(candidateAccount) + if (createdAccount instanceof LocalIdpError) { + if (createdAccount instanceof LocalIdpDuplicateUsernameError) { + console.error(`attempted to create local account with duplicate username ${username}`, createdAccount) + } + return createdAccount + } + return createdAccount + } + + function deleteAccount(username: string): Promise { + return repo.deleteLocalAccount(username) + } + + async function authenticate(credentials: LocalIdpCredentials): Promise { + const { username, password } = credentials + const account = await repo.readLocalAccount(username) + if (!account) { + console.info('local account does not exist:', username) + return null + } + const securityPolicy = await repo.readSecurityPolicy() + const attempt = await attemptAuthentication(account, password, securityPolicy.accountLock) + if (attempt.failed) { + console.info('local authentication failed', attempt.failed) + return attempt + } + const accountSaved = await repo.updateLocalAccount(attempt.authenticated) + if (accountSaved) { + attempt.authenticated = accountSaved + return attempt + } + console.error(`account for username ${username} did not exist for update after authentication attempt`) + return null + } + + return { + createAccount, + deleteAccount, + authenticate, + } +} diff --git a/service/src/ingress/readme.md b/service/src/ingress/readme.md new file mode 100644 index 000000000..66ce239f6 --- /dev/null +++ b/service/src/ingress/readme.md @@ -0,0 +1,34 @@ +# Mage Ingress + +Ingress is the process Mage users to enroll, authenticate, and admit users to establish sessions. + +## Concepts + +**User:** The person that requires access to the Mage service and resources + +**Account:** The persistent record in Mage that represents and tracks a user + +**Enrollment:** Creating a new Mage account a user will use to access Mage resources + +**Authentication:** Authentication is the verification of a user's credentials to prove + +**Identity Provider:** An identity provider is a service with the sole responsibility to authenticate users using a +specific protocol, from the perspective of Mage. Examples include Google OAuth, Google OIDC, Meta, a corporate +enterprise LDAP server, etc. + +**Ingress Protocol:** An ingress protocol defines the sequence of interactions between a user, an identity provider, +and the Mage service that delegates user authentication to the identity provider in order to allow access +to Mage resources. A typical example is a service which allows a user to authenticate with a Google account through +a series of HTTP requests and redirects. + +## Flow + +1. User chooses the identity provider. +1. Web app requests sign-in to identity provider. +1. Server directs sign-in request to IDP. +1. IDP returns result to server. +1. Server maps IDP account to Mage user account, creating account if necessary, to enroll the user. + 1. If account inactive or disabled, skip JWT, return { user, token: null } + 1. If account is active and enabled, generate authenticated JWT, return { user, token } +1. Client sends JWT and device information to server to request API session token. +1. Server verifies JWT, applies device enrollment policy, creates a session, and returns the session token to the client. \ No newline at end of file diff --git a/service/src/ingress/sessions.adapters.db.mongoose.ts b/service/src/ingress/sessions.adapters.db.mongoose.ts new file mode 100644 index 000000000..652ca352d --- /dev/null +++ b/service/src/ingress/sessions.adapters.db.mongoose.ts @@ -0,0 +1,84 @@ +import crypto from 'crypto' +import mongoose, { Schema } from 'mongoose' +import { UserExpandedDocument } from '../adapters/users/adapters.users.db.mongoose' +import { UserId } from '../entities/users/entities.users' +import { Session, SessionRepository } from './ingress.entities' + +export interface SessionDocument { + token: string + expirationDate: Date + userId: mongoose.Types.ObjectId + deviceId?: mongoose.Types.ObjectId | undefined +} +export type SessionExpandedDocument = SessionDocument & { + userId: UserExpandedDocument +} +export type SessionModel = mongoose.Model + +const SessionSchema = new Schema( + { + token: { type: String, required: true }, + expirationDate: { type: Date, required: true }, + userId: { type: Schema.Types.ObjectId, ref: 'User' }, + deviceId: { type: Schema.Types.ObjectId, ref: 'Device' }, + }, + { versionKey: false } +) + +SessionSchema.index({ token: 1, unique: 1 }) +SessionSchema.index({ expirationDate: 1 }, { expireAfterSeconds: 0 }) + +const populateSessionUserRole: mongoose.PopulateOptions = { + path: 'userId', + populate: 'roleId' +} + +export function SessionsMongooseRepository(conn: mongoose.Connection, collectionName: string, sessionTimeoutSeconds: number): SessionRepository { + const model = conn.model('Token', SessionSchema, collectionName) + return Object.freeze({ + model, + async readSessionByToken(token: string): Promise { + const doc = await model.findOne({ token }).lean() + if (!doc) { + return null + } + return { + token: doc.token, + expirationDate: doc.expirationDate, + user: doc.userId.toHexString(), + device: doc.deviceId?.toHexString(), + } + }, + async createOrRefreshSession(userId: UserId, deviceId?: string): Promise> { + const seed = crypto.randomBytes(20) + const token = crypto.createHash('sha256').update(seed).digest('hex') + const query: any = { userId: new mongoose.Types.ObjectId(userId) } + if (deviceId) { + query.deviceId = new mongoose.Types.ObjectId(deviceId) + } + const now = Date.now() + const update = { + token, + expirationDate: new Date(now + sessionTimeoutSeconds * 1000) + } + return await model.findOneAndUpdate(query, update, + { upsert: true, new: true, populate: populateSessionUserRole }) + }, + async deleteSession(token: string): Promise { + const session = await this.readSessionByToken(token) + if (!session) { + return null + } + const removed = await this.model.deleteOne({ token }) + return removed.deletedCount === 1 ? session : null + }, + async deleteSessionsForUser(userId: UserId): Promise { + const { deletedCount } = await model.deleteMany({ userId: new mongoose.Types.ObjectId(userId) }) + return deletedCount + }, + async deleteSessionsForDevice(deviceId: string): Promise { + const { deletedCount } = await model.deleteMany({ deviceId: new mongoose.Types.ObjectId(deviceId) }) + return deletedCount + } + }) +} diff --git a/service/src/ingress/verification.ts b/service/src/ingress/verification.ts new file mode 100644 index 000000000..6ea75b787 --- /dev/null +++ b/service/src/ingress/verification.ts @@ -0,0 +1,139 @@ +import JWT from 'jsonwebtoken' +import { Json } from '../entities/entities.json_types' + +export type Payload = { + subject: string | null, + assertion: TokenAssertion | null, + expiration: number | null, + [key: string]: Json +} + +export enum VerificationErrorReason { + NoToken = 'no_token', + Subject = 'subject', + Assertion = 'assertion', + Expired = 'expired', + Decode = 'decode', + Implementation = 'implementation' +} + +export enum TokenAssertion { + Authenticated = 'urn:mage:ingress:authenticated', + IsHuman = 'urn:mage:ingress:is_human' +} + +export class TokenGenerateError extends Error { + + constructor(readonly subject: string, readonly assertion: TokenAssertion, readonly secondsToLive: number, readonly cause?: Error | null) { + const causeMessage = cause ? `cause=\n${cause.stack || `${cause.name}: ${cause.message}`}` : '' + super(`error generating token: subject=${subject}; assertion=${assertion}; secondsToLive=${secondsToLive}; ${causeMessage}`) + } +} + +export class TokenVerificationError extends Error { + + /** + * @param reason why the verification failed + * @param expected the expected `Payload` + * @param token the encoded token string + * @param decoded the decoded `Payload` of the token + * @param cause cause of the verification error from the underlying token implementation + */ + constructor(readonly reason: VerificationErrorReason, readonly expected: Payload | null, readonly token: string, readonly decoded: any, readonly cause?: Error | null) { + let reasonMessage = `${reason}`; + let causeMessage = ''; + if (cause) { + causeMessage = 'cause=' + (cause instanceof Error ? `${"\n" + cause.stack || `${cause.name}: ${cause.message}`}` : `${cause}`) + } + if (reason === VerificationErrorReason.Subject) { + reasonMessage = `subject was ${decoded?.subject}` + } + else if (reason === VerificationErrorReason.Assertion) { + reasonMessage = `assertion was ${decoded?.assertion}` + } + else if (reason === VerificationErrorReason.Expired) { + reasonMessage = `token expired ${new Date(decoded?.expiration || 0)} (${decoded?.expiration})` + } + else if (reason === VerificationErrorReason.Decode) { + reasonMessage = `failed to decode token` + } + super(`TokenVerificationError (${reasonMessage}): expected subject=${expected?.subject}, assertion=${expected?.assertion}; token=${token}; ${causeMessage}`) + } +} + +export class JWTService { + + private readonly algorithm = 'HS256' + + constructor(private readonly secret: string, private readonly issuer: string) {} + + generateToken(subject: string, assertion: TokenAssertion, secondsToLive: number, claims = {}): Promise { + return new Promise((resolve, reject) => { + const payload = Object.assign({ assertion }, claims); + JWT.sign(payload, this.secret, + { + algorithm: this.algorithm, + issuer: this.issuer, + expiresIn: secondsToLive, + subject: subject, + }, + (err, token) => { + if (err) { + reject(new TokenGenerateError(subject, assertion, secondsToLive, err)) + } + else { + resolve(String(token)) + } + } + ) + }) + } + + verifyToken(token: string, expected: Payload): Promise { + return new Promise((resolve, reject) => { + JWT.verify(token, this.secret, { algorithms: [this.algorithm], issuer: this.issuer }, (err: JWT.VerifyErrors | null, anyPayload?: any) => { + const jwtPayload = anyPayload as JWT.JwtPayload + if (!expected) { + expected = { subject: null, assertion: null, expiration: null }; + } + if (err) { + let reason = VerificationErrorReason.Implementation; + let decoded = null; + try { + const jwt = JWT.decode(token, { json: true }); + if (jwt) { + decoded = { + ...jwt, + subject: jwt.sub || null, + assertion: jwt.assertion || null, + expiration: jwt.exp || null + }; + } + } catch (err) { + reason = VerificationErrorReason.Decode; + } + if (!decoded) { + reason = VerificationErrorReason.Decode; + decoded = { subject: null, assertion: null, expiration: null }; + } + else if (err instanceof JWT.TokenExpiredError) { + reason = VerificationErrorReason.Expired; + } + return reject(new TokenVerificationError(reason, expected, token, jwtPayload, err)); + } + else if (expected.subject && jwtPayload.sub !== expected.subject) { + return reject(new TokenVerificationError(VerificationErrorReason.Subject, expected, token, jwtPayload)) + } + else if (expected.assertion && jwtPayload.assertion !== expected.assertion) { + return reject(new TokenVerificationError(VerificationErrorReason.Assertion, expected, token, jwtPayload)); + } + resolve({ + ...jwtPayload, + subject: jwtPayload.sub || null, + assertion: jwtPayload.assertion || null, + expiration: jwtPayload.exp || null + }) + }) + }) + } +} diff --git a/service/src/migrations/007-user-icon.js b/service/src/migrations/007-user-icon.js index 65933da3e..0c01114ac 100644 --- a/service/src/migrations/007-user-icon.js +++ b/service/src/migrations/007-user-icon.js @@ -1,22 +1,27 @@ -const async = require('async') - , User = require('../models/user'); +// const async = require('async') +// , User = require('../models/user'); exports.id = '007-user-icon'; +/** + * This migration became obsolete after removing the `type` property from the user icon document. + */ exports.up = function(done) { - this.log('updating user icons'); + done(); - User.getUsers(function(err, users) { - if (err) return done(err); + // this.log('updating user icons'); + // // TODO: users-next + // User.getUsers(function(err, users) { + // if (err) return done(err); - async.each(users, function(user, done) { - user.icon = user.icon || {}; - user.icon.type = user.icon.relativePath ? 'upload' : 'none'; - user.save(done); - }, function(err) { - done(err); - }); - }); + // async.each(users, function(user, done) { + // user.icon = user.icon || {}; + // user.icon.type = user.icon.relativePath ? 'upload' : 'none'; + // user.save(done); + // }, function(err) { + // done(err); + // }); + // }); }; exports.down = function(done) { diff --git a/service/src/migrations/018-set-default-password-policy.js b/service/src/migrations/018-set-default-password-policy.js index 27e63d1da..62b4f0e05 100644 --- a/service/src/migrations/018-set-default-password-policy.js +++ b/service/src/migrations/018-set-default-password-policy.js @@ -1,7 +1,7 @@ "use strict"; -const config = require('../config.js'), - log = require('winston'); +const config = require('../config.js'); +const log = require('winston'); exports.id = 'set-default-password-policy'; diff --git a/service/src/migrations/032-rename-authconfigs.ts b/service/src/migrations/032-rename-authconfigs.ts new file mode 100644 index 000000000..1aea3a2f1 --- /dev/null +++ b/service/src/migrations/032-rename-authconfigs.ts @@ -0,0 +1,31 @@ +// import { Migration } from '@ngageoint/mongodb-migrations' + +// const migration: Migration = { + +// id: 'rename-authconfigs', + +// up: async function(done) { +// this.log('rename authenticationconfigurations to identity_providers') +// try { +// await this.db.renameCollection('authenticationconfigurations', 'identity_providers') +// done(null) +// } +// catch (err) { +// done(err) +// } +// done(null) +// }, + +// down: async function(done) { +// this.log('rename identity_providers to authenticationconfigurations') +// try { +// await this.db.renameCollection('identity_providers', 'authenticationconfigurations') +// done(null) +// } +// catch (err) { +// done(err) +// } +// } +// } + +// export = migration \ No newline at end of file diff --git a/service/src/migrations/033-user-idp-relationship.ts b/service/src/migrations/033-user-idp-relationship.ts new file mode 100644 index 000000000..6f31944e9 --- /dev/null +++ b/service/src/migrations/033-user-idp-relationship.ts @@ -0,0 +1,20 @@ +import { Migration } from '@ngageoint/mongodb-migrations' + +/** + * Invert the relationship between users and identity providers by migrating `users.authenticationId` to + * `user_ingress_bindings.userId`. + */ +const migration: Migration = { + + id: 'user-idp-relationship', + + up: async function(done) { + done(new Error('unimplemented')) + }, + + down: async function(done) { + done(new Error('unimplemented')) + } +} + +export = migration \ No newline at end of file diff --git a/service/src/migrations/034-ingress-refactor.ts b/service/src/migrations/034-ingress-refactor.ts new file mode 100644 index 000000000..b3cf8b018 --- /dev/null +++ b/service/src/migrations/034-ingress-refactor.ts @@ -0,0 +1,33 @@ +// import { Migration } from '@ngageoint/mongodb-migrations' + +// /** +// * **TODO** +// * * rename `type` to `protocol` +// * * rename `settings` to `protocolSettings` +// * * migrate common policy from `authenticationconfigurations.settings` to `identity_providers` `userEnrollmentPolicy` and `deviceEnrollmentPolicy` +// * * usersReqAdmin, devicesReqAdmin, newUserTeams, newUserEvents +// * * add `assignRole` with default USER_ROLE ID to `userEnrollmentPolicy` +// * * move `authentications` to `User.ingressAccounts` array `{ identityProviderId: ObjectId, enabled: boolean, accountSettings?: Mixed }` +// * * move `authentications` with local idp to `local_idp_accounts` +// */ +// const migration: Migration = { + +// id: 'ingress-refactor', + +// async up(done) { +// const { db, log } = this +// const idps = db.collection('identity_providers') +// const localIdps = await idps.find({ type: 'local' }).toArray() +// if (localIdps.length !== 1) { +// return done(new Error(`unexpected `)) +// } +// const localIdp = localIdps[0] +// const localIdpId = localIdp._id +// }, + +// async down(done) { + +// } +// } + +// export = migration \ No newline at end of file diff --git a/service/src/models/authentication.js b/service/src/models/authentication.js deleted file mode 100644 index 86505e2f9..000000000 --- a/service/src/models/authentication.js +++ /dev/null @@ -1,210 +0,0 @@ -"use strict"; - -const mongoose = require('mongoose') - , async = require('async') - , hasher = require('../utilities/pbkdf2')() - , User = require('./user') - , Token = require('./token') - , AuthenticationConfiguration = require('./authenticationconfiguration') - , PasswordValidator = require('../utilities/passwordValidator'); - -const Schema = mongoose.Schema; - -const AuthenticationSchema = new Schema( - { - type: { type: String, required: true }, - id: { type: String, required: false }, - authenticationConfigurationId: { type: Schema.Types.ObjectId, ref: 'AuthenticationConfiguration', required: false } - }, - { - discriminatorKey: 'type', - timestamps: { - updatedAt: 'lastUpdated' - }, - toObject: { - transform: DbAuthenticationToObject - } - } -); - -function DbAuthenticationToObject(authIn, authOut, options) { - delete authOut._id - authOut.id = authIn._id - if (authIn.populated('authenticationConfigurationId') && authIn.authenticationConfigurationId) { - delete authOut.authenticationConfigurationId; - authOut.authenticationConfiguration = authIn.authenticationConfigurationId.toObject(options); - } - return authOut; -} - -const LocalSchema = new Schema( - { - password: { type: String, required: true }, - previousPasswords: { type: [String], default: [] }, - security: { - locked: { type: Boolean, default: false }, - lockedUntil: { type: Date }, - invalidLoginAttempts: { type: Number, default: 0 }, - numberOfTimesLocked: { type: Number, default: 0 } - } - }, - { - toObject: { - transform: DbLocalAuthenticationToObject - } - } -); - -function DbLocalAuthenticationToObject(authIn, authOut, options) { - authOut = DbAuthenticationToObject(authIn, authOut, options) - delete authOut.password; - delete authOut.previousPasswords; - return authOut; -} - -const SamlSchema = new Schema({}); -const LdapSchema = new Schema({}); -const OauthSchema = new Schema({}); -const OpenIdConnectSchema = new Schema({}); - -AuthenticationSchema.method('validatePassword', function (password, callback) { - hasher.validPassword(password, this.password, callback); -}); - -// Encrypt password before save -LocalSchema.pre('save', function (next) { - const authentication = this; - - // only hash the password if it has been modified (or is new) - if (!authentication.isModified('password')) { - return next(); - } - - async.waterfall([ - function (done) { - AuthenticationConfiguration.getById(authentication.authenticationConfigurationId).then(localConfiguration => { - done(null, localConfiguration.settings.passwordPolicy); - }).catch(err => done(err)); - }, - function (policy, done) { - const { password, previousPasswords } = authentication; - PasswordValidator.validate(policy, { password, previousPasswords }).then(validationStatus => { - if (!validationStatus.valid) { - const err = new Error(validationStatus.errorMsg); - err.status = 400; - return done(err); - } - - done(null, policy); - }); - }, - function (policy, done) { - hasher.hashPassword(authentication.password, function (err, password) { - done(err, policy, password); - }); - } - ], function (err, policy, password) { - if (err) return next(err); - - authentication.password = password; - authentication.previousPasswords.unshift(password); - authentication.previousPasswords = authentication.previousPasswords.slice(0, policy.passwordHistoryCount); - next(); - }); -}); - -// Remove Token if password changed -LocalSchema.pre('save', function (next) { - const authentication = this; - - // only remove token if password has been modified (or is new) - if (!authentication.isModified('password')) { - return next(); - } - - async.waterfall([ - function (done) { - User.getUserByAuthenticationId(authentication._id, function (err, user) { - done(err, user); - }); - }, - function (user, done) { - if (user) { - Token.removeTokensForUser(user, function (err) { - done(err); - }); - } else { - done(); - } - } - ], function (err) { - return next(err); - }); -}); - -AuthenticationSchema.virtual('authenticationConfiguration').get(function () { - return this.populated('authenticationConfigurationId') ? this.authenticationConfigurationId : null; -}); - -const Authentication = mongoose.model('Authentication', AuthenticationSchema); -exports.Model = Authentication; - -const LocalAuthentication = Authentication.discriminator('local', LocalSchema); -exports.Local = LocalAuthentication; - -const SamlAuthentication = Authentication.discriminator('saml', SamlSchema); -exports.SAML = SamlAuthentication; - -const LdapAuthentication = Authentication.discriminator('ldap', LdapSchema); -exports.LDAP = LdapAuthentication; - -const OauthAuthentication = Authentication.discriminator('oauth', OauthSchema); -exports.Oauth = OauthAuthentication; - -const OpenIdConnectAuthentication = Authentication.discriminator('openidconnect', OpenIdConnectSchema); -exports.OpenIdConnect = OpenIdConnectAuthentication; - -exports.getAuthenticationByStrategy = function (strategy, uid, callback) { - if (callback) { - Authentication.findOne({ id: uid, type: strategy }, callback); - } else { - return Authentication.findOne({ id: uid, type: strategy }); - } -}; - -exports.getAuthenticationsByType = function (type) { - return Authentication.find({ type: type }).exec(); -}; - -exports.getAuthenticationsByAuthConfigId = function (authConfigId) { - return Authentication.find({ authenticationConfigurationId: authConfigId }).exec(); -}; - -exports.countAuthenticationsByAuthConfigId = function (authConfigId) { - return Authentication.count({ authenticationConfigurationId: authConfigId }).exec(); -}; - -exports.createAuthentication = function (authentication) { - const document = { - id: authentication.id, - type: authentication.type, - authenticationConfigurationId: authentication.authenticationConfigurationId, - } - - if (authentication.type === 'local') { - document.password = authentication.password; - document.security ={ - lockedUntil: null - } - } - - return Authentication.create(document); -}; - -exports.updateAuthentication = function (authentication) { - return authentication.save(); -}; - -exports.removeAuthenticationById = function (authenticationId, done) { - Authentication.findByIdAndRemove(authenticationId, done); -}; \ No newline at end of file diff --git a/service/src/models/authenticationconfiguration.js b/service/src/models/authenticationconfiguration.js deleted file mode 100644 index 7b4a0322d..000000000 --- a/service/src/models/authenticationconfiguration.js +++ /dev/null @@ -1,127 +0,0 @@ -"use strict"; - -const mongoose = require('mongoose'); - -// Creates a new Mongoose Schema object -const Schema = mongoose.Schema; - -const AuthenticationConfigurationSchema = new Schema( - { - name: { type: String, required: true }, - type: { type: String, required: true }, - title: { type: String, required: false }, - textColor: { type: String, required: false }, - buttonColor: { type: String, required: false }, - icon: { type: Buffer, required: false }, - enabled: { type: Boolean, default: true }, - settings: Schema.Types.Mixed - }, - { - timestamps: { - updatedAt: 'lastUpdated' - }, - versionKey: false, - toObject: { - transform: DbAuthenticationConfigurationToObject - } - } -); - -AuthenticationConfigurationSchema.index({ name: 1, type: 1 }, { unique: true }); - -const whitelist = ['name', 'type', 'title', 'textColor', 'buttonColor', 'icon']; -const blacklist = ['clientsecret', 'bindcredentials', 'privatecert', 'decryptionpvk']; -const secureMask = '*****'; - -function DbAuthenticationConfigurationToObject(config, ret, options) { - delete ret.__v; - - if (options.whitelist) { - if (config.type === 'local') { - return; - } - - Object.keys(ret).forEach(key => { - if (!whitelist.includes(key)) { - delete ret[key]; - } - }); - } - - if (options.blacklist) { - Object.keys(ret.settings).forEach(key => { - if (blacklist.includes(key.toLowerCase())) { - ret.settings[key] = secureMask; - } - }); - } - - ret.icon = ret.icon ? ret.icon.toString('base64') : null; -} - -exports.transform = DbAuthenticationConfigurationToObject; -exports.secureMask = secureMask; -exports.blacklist = blacklist; - -const AuthenticationConfiguration = mongoose.model('AuthenticationConfiguration', AuthenticationConfigurationSchema); -exports.Model = AuthenticationConfiguration; - -exports.getById = function (id) { - return AuthenticationConfiguration.findById(id).exec(); -}; - -exports.getConfiguration = function (type, name) { - return AuthenticationConfiguration.findOne({ type: type, name: name }).exec(); -}; - -exports.getConfigurationsByType = function (type) { - return AuthenticationConfiguration.find({ type: type }).exec(); -}; - -exports.getAllConfigurations = function () { - return AuthenticationConfiguration.find({}).exec(); -}; - -function manageIcon(config) { - if (config.icon) { - if (config.icon.startsWith('data')) { - config.icon = Buffer.from(config.icon.split(",")[1], "base64"); - } else { - config.icon = Buffer.from(config.icon, 'base64'); - } - } else { - config.icon = null; - } -} - -function manageSettings(config) { - if (config.settings.scope) { - if (!Array.isArray(config.settings.scope)) { - config.settings.scope = config.settings.scope.split(','); - } - - for (let i = 0; i < config.settings.scope.length; i++) { - config.settings.scope[i] = config.settings.scope[i].trim(); - } - } -} - -//TODO move the 'manage' methods to a pre save method - -exports.create = function (config) { - manageIcon(config); - manageSettings(config); - - return AuthenticationConfiguration.create(config); -}; - -exports.update = function (id, config) { - manageIcon(config); - manageSettings(config); - - return AuthenticationConfiguration.findByIdAndUpdate(id, config, { new: true }).exec(); -}; - -exports.remove = function (id) { - return AuthenticationConfiguration.findByIdAndRemove(id).exec(); -}; \ No newline at end of file diff --git a/service/src/models/device.js b/service/src/models/device.js index 34891b4bf..965abfa45 100644 --- a/service/src/models/device.js +++ b/service/src/models/device.js @@ -29,6 +29,7 @@ DeviceSchema.path('userId').validate(async function (userId) { let isValid = true; try { + // TODO: users-next const user = await User.getUserById(userId); if (!user) { isValid = false; @@ -42,6 +43,7 @@ DeviceSchema.path('userId').validate(async function (userId) { DeviceSchema.pre('findOneAndUpdate', function (next) { if (this.getUpdate() && this.getUpdate().registered === false) { + // TODO: users-next: new token methods Token.removeTokenForDevice({ _id: this.getQuery()._id }, function (err) { next(err); }); @@ -56,6 +58,7 @@ DeviceSchema.pre('findOneAndDelete', function (next) { async.parallel({ token: function (done) { + // TODO: users-next: new token methods Token.removeTokenForDevice({ _id: query.getQuery()._id }, function (err) { done(err); }); @@ -181,6 +184,7 @@ async function queryUsersAndDevicesThenPage(options, conditions) { registered = conditions.registered; delete conditions.registered; } + // TODO: users-next const count = await User.Model.count(conditions); return User.Model.find(conditions, "_id").populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }).exec().then(data => { const ids = []; diff --git a/service/src/models/event.d.ts b/service/src/models/event.d.ts index bc1947599..de117218a 100644 --- a/service/src/models/event.d.ts +++ b/service/src/models/event.d.ts @@ -1,12 +1,13 @@ import mongoose, { ToObjectOptions } from 'mongoose' -import { UserDocument } from './user' +import { UserDocument } from '../adapters/users/adapters.users.db.mongoose' import { MageEventId, MageEventAttrs, MageEventCreateAttrs, EventAccessType, EventRole } from '../entities/events/entities.events' import { Team, TeamMemberRole } from '../entities/teams/entities.teams' import { Form, FormField, FormFieldChoice } from '../entities/events/entities.events.forms' import { PageInfo } from '../utilities/paging'; +import { UserId } from '../entities/users/entities.users' export interface MageEventDocumentToObjectOptions extends ToObjectOptions { - access: { user: UserDocument, permission: EventAccessType } + access: { userId: UserId, permission: EventAccessType } projection: any } @@ -21,9 +22,8 @@ export type TeamDocument = Omit & mongoose.Document & { } } -export type MageEventDocument = Omit & Omit & { +export type MageEventDocument = Omit & { _id: number - id: number /** * The event's collection name is the name of the MongoDB collection that * stores observations for the event. @@ -33,22 +33,32 @@ export type MageEventDocument = Omit & Omit + +export type MageEventModelInstance = mongoose.HydratedDocument & { + toObject(options?: MageEventDocumentToObjectOptions): MageEventAttrs + toJSON(options: MageEventDocumentToObjectOptions): MageEventAttrs } export interface MageEventDocumentAcl { [userId: string]: EventRole } -export type FormDocument = Omit & mongoose.Document & { +export type FormDocument = Omit & { _id: number fields: FormFieldDocument[] } -export type FormFieldDocument = FormField & mongoose.Document & { +export type FormFieldDocument = FormField & { _id: never } -export type FormFieldChoiceDocument = FormFieldChoice & mongoose.Document & { +export type FormFieldChoiceDocument = FormFieldChoice & { _id: never } @@ -56,19 +66,18 @@ export type TODO = any export type Callback = (err: Error | null, result?: Result) => void export declare function count(options: TODO, callback: Callback): void -export declare function getEvents(options: TODO, callback: Callback): void -export declare function getById(id: MageEventId, options: TODO, callback: Callback): void -export declare function filterEventsByUserId(events: MageEventDocument[], userId: string, callback: Callback): void -export declare function create(event: MageEventCreateAttrs, user: Partial & Pick, callback: Callback): void -export declare function addForm(eventId: MageEventId, form: any, callback: Callback): void -export declare function addLayer(event: MageEventDocument, layer: any, callback: Callback): void -export declare function removeLayer(event: MageEventDocument, layer: { id: any }, callback: Callback): void +export declare function getEvents(options: TODO, callback: Callback): void +export declare function getById(id: MageEventId, options: TODO, callback: Callback): void +export declare function create(event: MageEventCreateAttrs, user: Partial & Pick, callback: Callback): void +export declare function addForm(eventId: MageEventId, form: any, callback: Callback): void +export declare function addLayer(event: MageEventDocument, layer: any, callback: Callback): void +export declare function removeLayer(event: MageEventDocument, layer: { id: any }, callback: Callback): void export declare function getUsers(eventId: MageEventId, callback: Callback): void -export declare function addTeam(event: MageEventDocument, team: any, callback: Callback): void +export declare function addTeam(event: MageEventDocument, team: any, callback: Callback): void export declare function getTeams(eventId: MageEventId, options: { populate: string[] | null }, callback: Callback): void -export declare function removeTeam(event: MageEventDocument, team: any, callback: Callback): void -export declare function updateUserInAcl(eventId: MageEventId, userId: string, role: string, callback: Callback): void -export declare function removeUserFromAcl(eventId: MageEventId, userId: string, callback: Callback): void +export declare function removeTeam(event: MageEventDocument, team: any, callback: Callback): void +export declare function updateUserInAcl(eventId: MageEventId, userId: string, role: string, callback: Callback): void +export declare function removeUserFromAcl(eventId: MageEventId, userId: string, callback: Callback): void export declare function getMembers(eventId: MageEventId, options: TODO): Promise export declare function getNonMembers(eventId: MageEventId, options: TODO): Promise export declare function getTeamsInEvent(eventId: MageEventId, options: TODO): Promise diff --git a/service/src/models/event.js b/service/src/models/event.js index 08e5a8d19..b1fcb1e87 100644 --- a/service/src/models/event.js +++ b/service/src/models/event.js @@ -10,7 +10,6 @@ const mongoose = require('mongoose') , log = require('winston') const { rolesWithPermission, EventRolePermissions } = require('../entities/events/entities.events'); -// Creates a new Mongoose Schema object const Schema = mongoose.Schema; const OptionSchema = new Schema({ @@ -158,8 +157,7 @@ EventSchema.pre('init', function (event) { instance during the pre-init hook's execution. */ if (event.forms) { - populateUserFields(event, function () { - }); + populateUserFields(event, function () {}); } }); @@ -208,12 +206,13 @@ function transform(event, ret, options) { } // if read only permissions in event acl, only return users acl + // TODO: move this business logic if (options.access) { - const roleOfUserOnEvent = ret.acl[options.access.user._id]; + const roleOfUserOnEvent = ret.acl[options.access.userId]; const rolesThatCanModify = rolesWithPermission('update').concat(rolesWithPermission('delete')); if (!roleOfUserOnEvent || rolesThatCanModify.indexOf(roleOfUserOnEvent) === -1) { const acl = {}; - acl[options.access.user._id] = ret.acl[options.access.user._id]; + acl[options.access.userId] = ret.acl[options.access.userId]; ret.acl = acl; } } @@ -234,21 +233,11 @@ function transform(event, ret, options) { } } -FormSchema.set("toJSON", { - transform: transformForm -}); - -FormSchema.set("toObject", { - transform: transformForm -}); +FormSchema.set("toJSON", { transform: transformForm }); +FormSchema.set("toObject", { transform: transformForm }); -EventSchema.set("toJSON", { - transform: transform -}); - -EventSchema.set("toObject", { - transform: transform -}); +EventSchema.set("toJSON", { transform }); +EventSchema.set("toObject", { transform }); // Creates the Model for the Layer Schema const Event = mongoose.model('Event', EventSchema); @@ -279,18 +268,14 @@ function filterEventsByUserId(events, userId, callback) { if (err) return callback(err); const filteredEvents = events.filter(function (event) { - // Check if user has read access to the event based on - // being on a team that is in the event + // Check if user has read access to the event based on being on a team that is in the event if (event.teamIds.some(function (team) { return team.userIds.indexOf(userId) !== -1; })) { return true; } - - // Check if user has read access to the event based on - // being in the events access control list + // Check if user has read access to the event based on being in the event access control list if (event.acl[userId] && rolesWithPermission('read').some(function (role) { return role === event.acl[userId]; })) { return true; } - return false; }); @@ -308,9 +293,7 @@ exports.count = function (options, callback) { if (options.access) { const accesses = []; rolesWithPermission(options.access.permission).forEach(function (role) { - const access = {}; - access['acl.' + options.access.user._id.toString()] = role; - accesses.push(access); + accesses.push({ [`acl.${options.access.userId}`]: role }); }); conditions['$or'] = accesses; } @@ -346,9 +329,9 @@ exports.getEvents = function (options, callback) { const filters = []; // First filter out events user cannot access - if (options.access && options.access.user) { + if (options.access && options.access.userId) { filters.push(function (done) { - filterEventsByUserId(events, options.access.user._id, function (err, filteredEvents) { + filterEventsByUserId(events, options.access.userId, function (err, filteredEvents) { if (err) return done(err); events = filteredEvents; @@ -422,9 +405,6 @@ exports.getById = function (id, options, callback) { }); }; -// TODO probably should live in event api -exports.filterEventsByUserId = filterEventsByUserId; - function createObservationCollection(event) { log.info("Creating observation collection: " + event.collectionName + ' for event ' + event.name); mongoose.connection.db.createCollection(event.collectionName).then(() => { @@ -560,22 +540,16 @@ exports.updateForm = function (event, form, callback) { exports.getMembers = async function (eventId, options) { const query = { _id: eventId }; if (options.access) { - const accesses = [{ - userIds: { - '$in': [options.access.user._id] - } - }]; - + const accesses = [ + { userIds: { '$in': [ new mongoose.Types.ObjectId(options.access.userId) ] } } + ]; rolesWithPermission(options.access.permission).forEach(role => { - const access = {}; - access['acl.' + options.access.user._id.toString()] = role; - accesses.push(access); + accesses.push({ [`acl.${options.access.userId}`]: role }); }); - query['$or'] = accesses; } - const event = await Event.findOne(query) + const event = await Event.findOne(query) if (event) { const { searchTerm } = options || {} const searchRegex = new RegExp(searchTerm, 'i') @@ -593,6 +567,7 @@ exports.getMembers = async function (eventId, options) { // per https://docs.mongodb.com/v5.0/reference/method/cursor.sort/#sort-consistency, // add _id to sort to ensure consistent ordering + // TODO: users-next const members = await User.Model.find(params) .sort('displayName _id') .limit(options.pageSize) @@ -606,6 +581,7 @@ exports.getMembers = async function (eventId, options) { const includeTotalCount = typeof options.includeTotalCount === 'boolean' ? options.includeTotalCount : options.pageIndex === 0 if (includeTotalCount) { + // TODO: users-next page.totalCount = await User.Model.count(params); } @@ -618,22 +594,16 @@ exports.getMembers = async function (eventId, options) { exports.getNonMembers = async function (eventId, options) { const query = { _id: eventId }; if (options.access) { - const accesses = [{ - userIds: { - '$in': [options.access.user._id] - } - }]; - + const accesses = [ + { userIds: { '$in': [ new mongoose.Types.ObjectId(options.access.userId) ] } } + ]; rolesWithPermission(options.access.permission).forEach(role => { - const access = {}; - access['acl.' + options.access.user._id.toString()] = role; - accesses.push(access); + accesses.push({ [`acl.${options.access.userId}`]: role }); }); - query['$or'] = accesses; } - const event = await Event.findOne(query) + const event = await Event.findOne(query) if (event) { const { searchTerm } = options || {} const searchRegex = new RegExp(searchTerm, 'i') diff --git a/service/src/models/export.d.ts b/service/src/models/export.d.ts index ff0fe9c28..9ba770729 100644 --- a/service/src/models/export.d.ts +++ b/service/src/models/export.d.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose' import { MageEventId } from '../entities/events/entities.events' +import { UserId } from '../entities/users/entities.users' import { ExportFormat } from '../export' import { ExportOptions } from '../export/exporter' import { UserDocument } from './user' @@ -18,24 +19,33 @@ export type ExportErrorAttrs = { updatedAt: Date, } +export type ExportId = string + export type ExportAttrs = { - userId: mongoose.Types.ObjectId, - relativePath?: string, - filename?: string, - exportType: ExportFormat, - status?: ExportStatus, + id: ExportId + userId: UserId + relativePath?: string + filename?: string + exportType: ExportFormat + status?: ExportStatus options: { - eventId: MageEventId, + eventId: MageEventId filter: any }, - processingErrors?: ExportErrorAttrs[], - expirationDate: Date, - lastUpdated: Date, + processingErrors?: ExportErrorAttrs[] + expirationDate: Date + lastUpdated: Date +} + +export type ExportDocument = Omit & { + _id: mongoose.Types.ObjectId + userId: mongoose.Types.ObjectId } -export type ExportDocument = ExportAttrs & mongoose.Document +export type ExportModelInstance = mongoose.HydratedDocument -export type ExportDocumentPopulated = Omit & { +export type ExportModelInstancePopulated = Omit & { + // TODO: users-next userId: UserDocument | null, options: Omit & { event: { _id: number, name: string } @@ -44,14 +54,14 @@ export type ExportDocumentPopulated = Omit export type PopulateQueryOption = { populate: true } -export function createExport(spec: Pick): Promise -export function getExportById(id: mongoose.Types.ObjectId | string): Promise -export function getExportById(id: mongoose.Types.ObjectId | string, options: PopulateQueryOption): Promise -export function getExportsByUserId(userId: mongoose.Types.ObjectId): Promise -export function getExportsByUserId(userId: mongoose.Types.ObjectId, options: PopulateQueryOption): Promise -export function getExports(): Promise -export function getExports(options: PopulateQueryOption): Promise +export function createExport(spec: Pick): Promise +export function getExportById(id: mongoose.Types.ObjectId | ExportId): Promise +export function getExportById(id: mongoose.Types.ObjectId | ExportId, options: PopulateQueryOption): Promise +export function getExportsByUserId(userId: UserId): Promise +export function getExportsByUserId(userId: UserId, options: PopulateQueryOption): Promise +export function getExports(): Promise +export function getExports(options: PopulateQueryOption): Promise export function count(options?: { filter: any }): Promise -export function updateExport(id: mongoose.Types.ObjectId, spec: Partial): Promise -export function removeExport(id: mongoose.Types.ObjectId | string): Promise +export function updateExport(id: ExportId, spec: Partial): Promise +export function removeExport(id: ExportId): Promise diff --git a/service/src/models/export.js b/service/src/models/export.js index 1778907c6..e368d0664 100644 --- a/service/src/models/export.js +++ b/service/src/models/export.js @@ -61,9 +61,7 @@ function transform(exportDocument, ret, options) { } } -ExportSchema.set('toJSON', { - transform: transform -}); +ExportSchema.set('toJSON', { transform }); const Export = mongoose.model('Export', ExportSchema); exports.ExportModel = Export; @@ -80,7 +78,6 @@ exports.createExport = function (data) { }, expirationDate: new Date(Date.now() + exportExpiration) }); - return Export.create(document); }; @@ -89,16 +86,14 @@ exports.getExportById = function (id, options = {}) { if (options.populate) { query = query.populate('userId').populate({ path: 'options.eventId', select: 'name' }); } - return query.exec() }; exports.getExportsByUserId = function (userId, options = {}) { - let query = Export.find({ userId: userId }); + let query = Export.find({ userId }); if (options.populate) { query = query.populate('userId').populate({ path: 'options.eventId', select: 'name' }); } - return query.exec(); }; @@ -107,22 +102,18 @@ exports.getExports = function (options = {}) { if (options.populate) { query = query.populate('userId').populate({ path: 'options.eventId', select: 'name' }); } - return query.exec(); }; exports.count = function (options) { options = options || {}; const filter = options.filter || {}; - const conditions = FilterParser.parse(filter); - return Export.count(conditions).exec(); }; exports.updateExport = function (id, exp) { return Export.findByIdAndUpdate(id, exp, { new: true }).exec(); - }; exports.removeExport = function (id) { diff --git a/service/src/models/observation.d.ts b/service/src/models/observation.d.ts index 0297c2ef7..a4244c9a5 100644 --- a/service/src/models/observation.d.ts +++ b/service/src/models/observation.d.ts @@ -1,97 +1,8 @@ import { Geometry } from 'geojson' import mongoose from 'mongoose' -import { MageEventAttrs, MageEventId } from '../entities/events/entities.events' -import { Attachment, FormEntry, ObservationAttrs, ObservationFeatureProperties, ObservationId, ObservationImportantFlag, ObservationState, Thumbnail } from '../entities/observations/entities.observations' +import { ObservationId, ObservationState } from '../entities/observations/entities.observations' import { MageEventDocument } from './event' -export type ObservationDocument = Omit & Omit & { - userId?: mongoose.Types.ObjectId - deviceId?: mongoose.Types.ObjectId - important?: ObservationDocumentImportantFlag - favoriteUserIds: mongoose.Types.ObjectId[] - states: ObservationStateDocument[] - attachments: AttachmentDocument[] - properties: ObservationDocumentProperties - toJSON(options?: ObservationJsonOptions): ObservationDocumentJson -} -export interface ObservationModel extends mongoose.Model {} -export type ObservationDocumentJson = Omit & { - id: mongoose.Types.ObjectId - eventId?: number - url: string - attachments: AttachmentDocumentJson[] - states: ObservationStateDocumentJson[] -} - -/** - * This interface defines the options that one can supply to the `toJSON()` - * method of the Mongoose Document instances of the Observation model. - */ -export interface ObservationJsonOptions extends mongoose.ToObjectOptions { - /** - * The database schema does not include the event ID for observation - * documents. Use this option to add the `eventId` property to the - * observation JSON document. - */ - event?: MageEventDocument | MageEventAttrs | { id: MageEventId, [others: string]: any } - /** - * If the `path` option is prenent, the JSON transormation will prefix the - * `url` property of the observation JSON object with the value of `path`. - */ - path?: string -} - -export type ObservationDocumentProperties = Omit & { - forms: ObservationDocumentFormEntry[] -} - -export type ObservationDocumentFormEntry = FormEntry & { - _id: mongoose.Types.ObjectId -} -export type ObservationDocumnetFormEntryJson = Omit & { - id: ObservationDocumentFormEntry['_id'] -} - -export type AttachmentDocAttrs = Omit & { - _id: mongoose.Types.ObjectId - observationFormId: mongoose.Types.ObjectId - relativePath?: string - thumbnails: ThumbnailDocAttrs[] -} -export type AttachmentDocument = mongoose.Document & Omit & { - thumbnails: ThumbnailDocument[] -} -export type AttachmentDocumentJson = Omit & { - id: AttachmentDocument['_id'] - relativePath?: string - url?: string -} - -export type ThumbnailDocAttrs = Omit & { - _id: mongoose.Types.ObjectId - relativePath?: string -} -export type ThumbnailDocument = mongoose.Document & ThumbnailDocAttrs - -export type ObservationDocumentImportantFlag = Omit & { - userId?: mongoose.Types.ObjectId -} - -export type ObservationStateDocument = Omit & { - id: ObservationState['id'] - userId?: mongoose.Types.ObjectId -} -export type ObservationStateDocumentJson = Omit & { - id: ObservationStateDocument['_id'] - url: string -} - -export const ObservationIdSchema: mongoose.Schema -export type ObservationIdDocument = mongoose.Document -export const ObservationId: mongoose.Model - -export function observationModel(event: Partial & Pick): ObservationModel - export interface ObservationReadOptions { filter?: { geometry?: Geometry @@ -100,7 +11,7 @@ export interface ObservationReadOptions { observationStartDate?: Date observationEndDate?: Date states?: ObservationState['name'][] - favorites?: false | { userId?: mongoose.Types.ObjectId } + favorites?: false | { userId?: UserId } important?: boolean attachments?: boolean } @@ -116,5 +27,4 @@ export type ObservationReadStreamOptions = Omit any): void export function getObservations(event: MageEventDocument, options: ObservationReadStreamOptions): mongoose.QueryCursor - export function updateObservation(event: MageEventDocument, observationId: ObservationId, update: any, callback: (err: any | null, obseration: ObservationDocument) => any): void \ No newline at end of file diff --git a/service/src/models/observation.js b/service/src/models/observation.js index 45c53839a..9c74545dc 100644 --- a/service/src/models/observation.js +++ b/service/src/models/observation.js @@ -3,160 +3,8 @@ const mongoose = require('mongoose') , Event = require('./event') , log = require('winston'); -const Schema = mongoose.Schema; - -// Collection to hold unique observation ids -const ObservationIdSchema = new Schema(); -ObservationIdSchema.set("toJSON", { transform }); -const ObservationId = mongoose.model('ObservationId', ObservationIdSchema); - -exports.ObservationIdSchema = ObservationIdSchema; -exports.ObservationId = ObservationId; - -const StateSchema = new Schema({ - name: { type: String, required: true }, - userId: { type: Schema.Types.ObjectId, ref: 'User' } -}); - -const ThumbnailSchema = new Schema({ - minDimension: { type: Number, required: true }, - contentType: { type: String, required: false }, - size: { type: Number, required: false }, - name: { type: String, required: false }, - width: { type: Number, required: false }, - height: { type: Number, required: false }, - relativePath: { type: String, required: false }, -}, { - strict: false -}); - -const AttachmentSchema = new Schema({ - observationFormId: { type: Schema.Types.ObjectId, required: true }, - fieldName: { type: String, required: true }, - lastModified: { type: Date, required: false }, - contentType: { type: String, required: false }, - size: { type: Number, required: false }, - name: { type: String, required: false }, - relativePath: { type: String, required: false }, - width: { type: Number, required: false }, - height: { type: Number, required: false }, - oriented: { type: Boolean, required: true, default: false }, - thumbnails: [ThumbnailSchema] -}, { - strict: false -}); - -// Creates the Schema for the Attachments object -const ObservationSchema = new Schema({ - type: { type: String, enum: ['Feature'], required: true }, - lastModified: { type: Date, required: false }, - userId: { type: Schema.Types.ObjectId, ref: 'User', required: false, sparse: true }, - deviceId: { type: Schema.Types.ObjectId, required: false, sparse: true }, - geometry: Schema.Types.Mixed, - properties: Schema.Types.Mixed, - attachments: [AttachmentSchema], - states: [StateSchema], - important: { - userId: { type: Schema.Types.ObjectId, ref: 'User', required: false }, - timestamp: { type: Date, required: false }, - description: { type: String, required: false } - }, - favoriteUserIds: [{ type: Schema.Types.ObjectId, ref: 'User' }] -}, { - strict: false, - timestamps: { - createdAt: 'createdAt', - updatedAt: 'lastModified' - } -}); - -ObservationSchema.index({ geometry: '2dsphere' }); -ObservationSchema.index({ lastModified: 1 }); -ObservationSchema.index({ userId: 1 }); -ObservationSchema.index({ deviceId: 1 }); -ObservationSchema.index({ 'properties.timestamp': 1 }); -ObservationSchema.index({ 'states.name': 1 }); -ObservationSchema.index({ 'attachments.lastModified': 1 }); -ObservationSchema.index({ 'attachments.oriented': 1 }); -ObservationSchema.index({ 'attachments.contentType': 1 }); -ObservationSchema.index({ 'attachments.thumbnails.minDimension': 1 }); - -function transformAttachment(attachment, observation) { - attachment.id = attachment._id; - delete attachment._id; - delete attachment.thumbnails; - - /* - TODO: is this actually checking if the attachment is stored? - ANSWER: yes it is. the clients depend on it. the web client will not upload - if the url property is present on an attachment. - stop sending absolute urls to the clients. - */ - if (attachment.relativePath) { - attachment.url = [observation.url, "attachments", attachment.id].join("/"); - } - - return attachment; -} - -function transformState(state, observation) { - state.id = state._id; - delete state._id; - - state.url = [observation.url, "states", state.id].join("/"); - return state; -} - -function transform(observation, ret, options) { - ret.id = ret._id; - delete ret._id; - delete ret.__v; - - if (options.event) { - ret.eventId = options.event._id; - } - - const path = options.path ? options.path : ""; - ret.url = [path, observation.id].join("/"); - - if (observation.states && observation.states.length) { - ret.state = transformState(ret.states[0], ret); - delete ret.states; - } - - if (observation.properties && observation.properties.forms) { - ret.properties.forms = ret.properties.forms.map(observationForm => { - observationForm.id = observationForm._id; - delete observationForm._id; - return observationForm; - }); - } - - if (observation.attachments) { - ret.attachments = ret.attachments.map(function (attachment) { - return transformAttachment(attachment, ret); - }); - } - - const populatedUserId = observation.populated('userId'); - if (populatedUserId) { - ret.user = ret.userId; - // TODO Update mobile clients to handle observation.userId or observation.user.id - // Leave userId as mobile clients depend on it for observation create/update, - ret.userId = populatedUserId; - } - - const populatedImportantUserId = observation.populated('important.userId') - if (populatedImportantUserId && ret.important) { - ret.important.user = ret.important.userId - delete ret.important.userId; - } -} - -ObservationSchema.set('toJSON', { transform }); -ObservationSchema.set('toObject', { transform }); -const models = {}; +// TODO: are these necessary? mongoose.model('Attachment', AttachmentSchema); mongoose.model('Thumbnail', ThumbnailSchema); mongoose.model('State', StateSchema); @@ -201,21 +49,6 @@ function parseFields(fields) { } } -function observationModel(event) { - const name = event.collectionName; - let model = models[name]; - if (!model) { - // Creates the Model for the Observation Schema - model = mongoose.model(name, ObservationSchema, name); - // TODO: mongoose should be caching these models so this seems unnecessary - models[name] = model; - } - - return model; -} - -exports.observationModel = observationModel; - exports.getObservations = function (event, o, callback) { const conditions = {}; @@ -298,95 +131,12 @@ exports.getObservations = function (event, o, callback) { } }; -exports.createObservationId = function (callback) { - ObservationId.create({}, callback); -}; - -exports.getObservationId = function (id, callback) { - ObservationId.findById(id, function (err, doc) { - callback(err, doc ? doc._id : null); - }); -}; - -exports.getLatestObservation = function (event, callback) { - observationModel(event).findOne({}, { lastModified: true }, { sort: { "lastModified": -1 }, limit: 1 }, callback); -}; - exports.getObservationById = function (event, observationId, options, callback) { const fields = parseFields(options.fields); observationModel(event).findById(observationId, fields, callback); }; -exports.updateObservation = function (event, observationId, update, callback) { - let addAttachments = []; - let removeAttachments = []; - const { forms = [] } = update.properties || {}; - forms.map(observationForm => { - observationForm._id = observationForm._id || new mongoose.Types.ObjectId(); - delete observationForm.id; - return observationForm; - }) - .forEach(formEntry => { - // TODO: move to app or web layer - const formDefinition = event.forms.find(form => form._id === formEntry.formId); - Object.keys(formEntry).forEach(fieldName => { - const fieldDefinition = formDefinition.fields.find(field => field.name === fieldName); - if (fieldDefinition && fieldDefinition.type === 'attachment') { - const attachments = formEntry[fieldName] || []; - addAttachments = addAttachments.concat(attachments - .filter(attachment => attachment.action === 'add') - .map(attachment => { - return { - observationFormId: formEntry._id, - fieldName: fieldName, - name: attachment.name, - contentType: attachment.contentType - } - })); - - removeAttachments = removeAttachments.concat(attachments - .filter(attachment => attachment.action === 'delete') - .map(attachment => attachment.id) - ); - - delete formEntry[fieldName] - } - }); - }); - - const ObservationModel = observationModel(event); - ObservationModel.findById(observationId) - .then(observation => { - if (!observation) { - observation = new ObservationModel({ - _id: observationId, - userId: update.userId, - deviceId: update.deviceId, - states: [{ name: 'active', userId: update.userId }] - }); - } - - observation.type = update.type; - observation.geometry = update.geometry; - observation.properties = update.properties; - observation.attachments = observation.attachments.concat(addAttachments); - observation.attachments = observation.attachments.filter(attachment => { - return !removeAttachments.includes(attachment._id.toString()) - }); - - return observation.save(); - }) - .then(observation => { - return observation - .populate([{ path: 'userId', select: 'displayName' }, { path: 'important.userId', select: 'displayName' }]); - }) - .then(observation => { - callback(null, observation); - }) - .catch(err => callback(err)); -}; - exports.removeObservation = function (event, observationId, callback) { observationModel(event).findByIdAndRemove(observationId, callback); }; diff --git a/service/src/models/role.d.ts b/service/src/models/role.d.ts index 138099520..b5f6a4bf7 100644 --- a/service/src/models/role.d.ts +++ b/service/src/models/role.d.ts @@ -1,21 +1,22 @@ - import mongoose from 'mongoose' import { AnyPermission } from '../entities/authorization/entities.permissions' type Callback = (err: any, result?: R) => any -export declare interface RoleDocument extends mongoose.Document { - id: string +export declare interface RoleDocument { + _id: mongoose.Types.ObjectId name: string description?: string permissions: AnyPermission[] } +export type RoleModelInstance = mongoose.HydratedDocument + export declare type RoleJson = Omit -export declare function getRoleById(id: string, callback: Callback): void -export declare function getRole(name: string, callback: Callback): void -export declare function getRoles(callback: Callback): void -export declare function createRole(role: RoleDocument, callback: Callback): void -export declare function updateRole(id: string, update: Partial, callback: Callback): void -export declare function deleteRole(role: RoleDocument, callback: Callback): void +export declare function getRoleById(id: string, callback: Callback): void +export declare function getRole(name: string, callback: Callback): void +export declare function getRoles(callback: Callback): void +export declare function createRole(role: Omit, callback: Callback): void +export declare function updateRole(id: string, update: Partial, callback: Callback): void +export declare function deleteRole(role: RoleModelInstance, callback: Callback): void diff --git a/service/src/models/role.js b/service/src/models/role.js index 7d1a5df10..406ceb080 100644 --- a/service/src/models/role.js +++ b/service/src/models/role.js @@ -21,7 +21,7 @@ const RoleSchema = new Schema( RoleSchema.pre('remove', function (next) { const role = this; - + // TODO: users-next User.removeRoleFromUsers(role, function (err) { next(err); }); diff --git a/service/src/models/team.d.ts b/service/src/models/team.d.ts index 71a055d01..d8c2f3c77 100644 --- a/service/src/models/team.d.ts +++ b/service/src/models/team.d.ts @@ -28,4 +28,5 @@ export function removeUserFromAclForEventTeam(eventId: any, userId: any, callbac export function removeUserFromAllAcls(user: any, callback: any): void; declare var Team: mongoose.Model; import mongoose = require("mongoose"); +// TODO: users-next import User from './user' \ No newline at end of file diff --git a/service/src/models/team.js b/service/src/models/team.js index 72cfedcef..d1ba70e7f 100644 --- a/service/src/models/team.js +++ b/service/src/models/team.js @@ -1,4 +1,3 @@ -/// var mongoose = require('mongoose') , async = require('async') , Event = require('./event') @@ -186,6 +185,7 @@ exports.getMembers = async function (teamId, options) { // per https://docs.mongodb.com/v5.0/reference/method/cursor.sort/#sort-consistency, // add _id to sort to ensure consistent ordering + // TODO: users-next const members = await User.Model.find(params) .sort('displayName _id') .limit(options.pageSize) @@ -199,6 +199,7 @@ exports.getMembers = async function (teamId, options) { const includeTotalCount = typeof options.includeTotalCount === 'boolean' ? options.includeTotalCount : options.pageIndex === 0 if (includeTotalCount) { + // TODO: users-next page.totalCount = await User.Model.count(params); } diff --git a/service/src/models/token.js b/service/src/models/token.js deleted file mode 100644 index 6c8112967..000000000 --- a/service/src/models/token.js +++ /dev/null @@ -1,80 +0,0 @@ -var crypto = require('crypto') - , mongoose = require('mongoose') - , environment = require('../environment/env'); - -// Token expiration in msecs -var tokenExpiration = environment.tokenExpiration * 1000; - -// Creates a new Mongoose Schema object -var Schema = mongoose.Schema; - -// Collection to hold users -var TokenSchema = new Schema({ - userId: { type: Schema.Types.ObjectId, ref: 'User' }, - deviceId: { type: Schema.Types.ObjectId, ref: 'Device' }, - expirationDate: { type: Date, required: true }, - token: { type: String, required: true } -},{ - versionKey: false -}); - -// TODO: index token -TokenSchema.index({'expirationDate': 1}, {expireAfterSeconds: 0}); - -// Creates the Model for the User Schema -var Token = mongoose.model('Token', TokenSchema); - -exports.getToken = function(token, callback) { - Token.findOne({token: token}).populate({ - path: 'userId', - populate: { - path: 'authenticationId', - model: 'Authentication' - } - }).exec(function(err, token) { - if (err) return callback(err); - - if (!token || !token.userId) { - return callback(null, null); - } - - token.userId.populate('roleId', function(err, user) { - return callback(err, {user: user, deviceId: token.deviceId, token: token}); - }); - }); -}; - -exports.createToken = function(options, callback) { - const seed = crypto.randomBytes(20); - const token = crypto.createHash('sha256').update(seed).digest('hex'); - const query = {userId: options.userId}; - if (options.device) { - query.deviceId = options.device._id; - } - const now = Date.now(); - const update = { - token: token, - expirationDate: new Date(now + tokenExpiration) - }; - Token.findOneAndUpdate(query, update, {upsert: true, new: true}, function(err, newToken) { - callback(err, newToken); - }); -}; - -exports.removeToken = function(token, callback) { - Token.findByIdAndRemove(token._id, function(err) { - callback(err); - }); -}; - -exports.removeTokensForUser = function(user, callback) { - Token.remove({userId: user._id}, function(err, numberRemoved) { - callback(err, numberRemoved); - }); -}; - -exports.removeTokenForDevice = function(device, callback) { - Token.remove({deviceId: device._id}, function(err, numberRemoved) { - callback(err, numberRemoved); - }); -}; diff --git a/service/src/models/user.d.ts b/service/src/models/user.d.ts index 0ae581ae9..36c65f1d5 100644 --- a/service/src/models/user.d.ts +++ b/service/src/models/user.d.ts @@ -1,63 +1,5 @@ import mongoose from 'mongoose' -import { RoleJson, RoleDocument } from './role' -import { UserIcon, Avatar, Phone } from '../entities/users/entities.users' -import { Authentication } from '../entities/authentication/entities.authentication' - - -export interface UserDocument extends mongoose.Document { - _id: mongoose.Types.ObjectId - id: string - username: string - displayName: string - email?: string - phones: Phone[] - avatar: Avatar - icon: UserIcon - active: boolean - enabled: boolean - roleId: mongoose.Types.ObjectId | RoleDocument - authenticationId: mongoose.Types.ObjectId | mongoose.Document - status?: string - recentEventIds: number[] - createdAt: Date - lastUpdated: Date - toJSON(): UserJson -} - - -// TODO: this probably needs an update now with new authentication changes -export type UserJson = Omit - & { - id: mongoose.Types.ObjectId, - icon: Omit, - avatarUrl?: string, - } - & (RolePopulated | RoleReferenced) - & (AuthenticationPopulated | AuthenticationReferenced) export declare const Model: mongoose.Model - export function getUserById(id: mongoose.Types.ObjectId): Promise export function getUserById(id: mongoose.Types.ObjectId, callback: (err: null | any, result: UserDocument | null) => any): void - -type RoleReferenced = { - roleId: string, - role: never -} - -type RolePopulated = { - roleId: never, - role: RoleJson -} - -type AuthenticationPopulated = { - authenticationId: never, - authentication: Authentication -} - -type AuthenticationReferenced = { - authenticationId: string, - authentication: never -} - - diff --git a/service/src/models/user.js b/service/src/models/user.js index f31225dff..7290f75c5 100644 --- a/service/src/models/user.js +++ b/service/src/models/user.js @@ -1,184 +1,12 @@ "use strict"; const mongoose = require('mongoose') - , async = require('async') , moment = require('moment') - , Token = require('./token') - , Login = require('./login') - , Event = require('./event') - , Team = require('./team') - , Observation = require('./observation') - , Location = require('./location') - , CappedLocation = require('./cappedLocation') , Authentication = require('./authentication') + // TODO: users-next , AuthenticationConfiguration = require('./authenticationconfiguration') - , { pageQuery } = require('../adapters/base/adapters.base.db.mongoose') - , { pageOf } = require('../entities/entities.global') , FilterParser = require('../utilities/filterParser'); -// Creates a new Mongoose Schema object -const Schema = mongoose.Schema; - -const PhoneSchema = new Schema({ - type: { type: String, required: true }, - number: { type: String, required: true } -}, { - versionKey: false, - _id: false -}); - -// Collection to hold users -const UserSchema = new Schema( - { - username: { type: String, required: true, unique: true }, - displayName: { type: String, required: true }, - email: { type: String, required: false }, - phones: [PhoneSchema], - avatar: { - contentType: { type: String, required: false }, - size: { type: Number, required: false }, - relativePath: { type: String, required: false } - }, - icon: { - type: { type: String, enum: ['none', 'upload', 'create'], default: 'none' }, - text: { type: String }, - color: { type: String }, - contentType: { type: String, required: false }, - size: { type: Number, required: false }, - relativePath: { type: String, required: false } - }, - active: { type: Boolean, required: true }, - enabled: { type: Boolean, default: true, required: true }, - roleId: { type: Schema.Types.ObjectId, ref: 'Role', required: true }, - status: { type: String, required: false, index: 'sparse' }, - recentEventIds: [{ type: Number, ref: 'Event' }], - authenticationId: { type: Schema.Types.ObjectId, ref: 'Authentication', required: true } - }, - { - versionKey: false, - timestamps: { - updatedAt: 'lastUpdated' - }, - toObject: { - transform: DbUserToObject - }, - toJSON: { - transform: DbUserToObject - } - } -); - -UserSchema.virtual('authentication').get(function () { - return this.populated('authenticationId') ? this.authenticationId : null; -}); - -// Lowercase the username we store, this will allow for case insensitive usernames -// Validate that username does not already exist -UserSchema.pre('save', function (next) { - const user = this; - user.username = user.username.toLowerCase(); - this.model('User').findOne({ username: user.username }, function (err, possibleDuplicate) { - if (err) return next(err); - - if (possibleDuplicate && !possibleDuplicate._id.equals(user._id)) { - const error = new Error('username already exists'); - error.status = 409; - return next(error); - } - - next(); - }); -}); - -UserSchema.pre('save', function (next) { - const user = this; - if (user.active === false || user.enabled === false) { - Token.removeTokensForUser(user, function (err) { - next(err); - }); - } else { - next(); - } -}); - -UserSchema.pre('remove', function (next) { - const user = this; - - async.parallel({ - location: function (done) { - Location.removeLocationsForUser(user, done); - }, - cappedlocation: function (done) { - CappedLocation.removeLocationsForUser(user, done); - }, - token: function (done) { - Token.removeTokensForUser(user, done); - }, - login: function (done) { - Login.removeLoginsForUser(user, done); - }, - observation: function (done) { - Observation.removeUser(user, done); - }, - eventAcl: function (done) { - Event.removeUserFromAllAcls(user, function (err) { - done(err); - }); - }, - teamAcl: function (done) { - Team.removeUserFromAllAcls(user, done); - }, - authentication: function (done) { - Authentication.removeAuthenticationById(user.authenticationId, done); - } - }, - function (err) { - next(err); - }); -}); - -// eslint-disable-next-line complexity -function DbUserToObject(user, userOut, options) { - userOut.id = userOut._id; - delete userOut._id; - - delete userOut.avatar; - - if (userOut.icon) { // TODO remove if check, icon is always there - delete userOut.icon.relativePath; - } - - if (!!user.roleId && typeof user.roleId.toObject === 'function') { - userOut.role = user.roleId.toObject(); - delete userOut.roleId; - } - - /* - TODO: this used to use user.populated('authenticationId'), but when paging - and using the cursor(), mongoose was not setting the populated flag on the - cursor documents, so this condition was never met and paged user documents - erroneously retained the authenticationId key with the populated - authentication object. this occurs in mage server 6.2.x with mongoose 4.x. - this might be fixed in mongoose 5+, so revisit on mage server 6.3.x. - */ - if (!!user.authenticationId && typeof user.authenticationId.toObject === 'function') { - const authPlain = user.authenticationId.toObject({ virtuals: true }); - delete userOut.authenticationId; - userOut.authentication = authPlain; - } - - if (user.avatar && user.avatar.relativePath) { - // TODO, don't really like this, need a better way to set user resource, route - userOut.avatarUrl = [(options.path ? options.path : ""), "api", "users", user._id, "avatar"].join("/"); - } - - if (user.icon && user.icon.relativePath) { - // TODO, don't really like this, need a better way to set user resource, route - userOut.iconUrl = [(options.path ? options.path : ""), "api", "users", user._id, "icon"].join("/"); - } - - return userOut; -}; exports.transform = DbUserToObject; @@ -186,36 +14,6 @@ exports.transform = DbUserToObject; const User = mongoose.model('User', UserSchema); exports.Model = User; -exports.getUserById = function (id, callback) { - let result = User.findById(id).populate('roleId').populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }); - if (typeof callback === 'function') { - result = result.then( - user => { - callback(null, user); - }, - err => { - callback(err); - }); - } - return result; -}; - -exports.getUserByUsername = function (username, callback) { - User.findOne({ username: username.toLowerCase() }).populate('roleId').populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }).exec(callback); -}; - -exports.getUserByAuthenticationId = function (id, callback) { - User.findOne({ authenticationId: id }).populate('roleId').populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }).exec(callback); -} - -exports.getUserByAuthenticationStrategy = function (strategy, uid, callback) { - Authentication.getAuthenticationByStrategy(strategy, uid, function (err, authentication) { - if (err || !authentication) return callback(err); - - User.findOne({ authenticationId: authentication._id }).populate('roleId').populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }).exec(callback); - }); -} - function createQueryConditions(filter) { const conditions = FilterParser.parse(filter); @@ -245,66 +43,6 @@ exports.count = function (options, callback) { }); }; -exports.getUsers = async function (options, callback) { - if (typeof options === 'function') { - callback = options; - options = {}; - } - - options = options || {}; - const filter = options.filter || {}; - - const conditions = createQueryConditions(filter); - - let baseQuery = User.find(conditions).populate({ path: 'authenticationId', populate: { path: 'authenticationConfigurationId' } }); - - if (options.lean) { - baseQuery = baseQuery.lean(); - } - - if (options.populate && (options.populate.indexOf('roleId') !== -1)) { - baseQuery = baseQuery.populate('roleId'); - } - - const isPaging = options.limit != null && options.limit > 0; - if (isPaging) { - const limit = Math.abs(options.limit) || 10; - const start = (Math.abs(options.start) || 0); - const page = Math.ceil(start / limit); - - const which = { - pageSize: limit, - pageIndex: page, - includeTotalCount: true - }; - try { - const counted = await pageQuery(baseQuery, which); - const users = []; - for await (const userDoc of counted.query.cursor()) { - users.push(entityForDocument(userDoc)); - } - const pageof = pageOf(users, which, counted.totalCount); - callback(null, users, pageof); - } catch (err) { - callback(err); - } - } else { - baseQuery.exec(function (err, users) { - callback(err, users, null); - }); - } -}; - -function entityForDocument(doc) { - const json = doc.toJSON(); - const entity = { - ...json, - id: doc._id.toHexString() - } - - return entity; -} - exports.createUser = function (user, callback) { Authentication.createAuthentication(user.authentication).then(authentication => { const newUser = { @@ -381,13 +119,6 @@ exports.validLogin = async function (user) { await user.authentication.save(); }; -exports.setStatusForUser = function (user, status, callback) { - const update = { status: status }; - User.findByIdAndUpdate(user._id, update, { new: true }, function (err, user) { - callback(err, user); - }); -}; - exports.setRoleForUser = function (user, role, callback) { const update = { role: role }; User.findByIdAndUpdate(user._id, update, { new: true }, function (err, user) { diff --git a/service/src/permissions/permissions.events.ts b/service/src/permissions/permissions.events.ts index 899d4c952..f03e95cc6 100644 --- a/service/src/permissions/permissions.events.ts +++ b/service/src/permissions/permissions.events.ts @@ -12,6 +12,7 @@ import { UserId } from '../entities/users/entities.users' import { MongooseMageEventRepository } from '../adapters/events/adapters.events.db.mongoose' import { TeamId } from '../entities/teams/entities.teams' +// TODO: users-next export interface EventRequestContext extends AppRequestContext { readonly event: MageEventAttrs | MageEventDocument } diff --git a/service/src/permissions/permissions.role-based.base.ts b/service/src/permissions/permissions.role-based.base.ts index 57c545ef1..a51ccfe08 100644 --- a/service/src/permissions/permissions.role-based.base.ts +++ b/service/src/permissions/permissions.role-based.base.ts @@ -8,6 +8,7 @@ import { AnyPermission } from '../entities/authorization/entities.permissions' * TODO: This should not be statically linked to the Mongoose Document type but * for now this is the quick and dirty way because the legacy web adapter layer * puts the user Mongoose document on the request. + * TODO: users-next */ export type UserWithRole = UserDocument & { roleId: RoleDocument diff --git a/service/src/plugins.api/index.ts b/service/src/plugins.api/index.ts index c8dfeb873..096a2e68f 100644 --- a/service/src/plugins.api/index.ts +++ b/service/src/plugins.api/index.ts @@ -54,7 +54,7 @@ import { EnsureJson } from '../entities/entities.json_types' * state the plugin requires. */ export interface PluginStateRepository { - put(state: EnsureJson): Promise> + put(state: EnsureJson | null): Promise | null> patch(state: Partial>): Promise> get(): Promise | null> } diff --git a/service/src/provision/index.js b/service/src/provision/index.js index 060cdef16..96e3057cb 100644 --- a/service/src/provision/index.js +++ b/service/src/provision/index.js @@ -1,6 +1,6 @@ -const Setting = require('../models/setting'); const { modulesPathsInDir } = require('../utilities/loader'); const log = require('../logger'); +// TODO: users-next const AuthenticationConfiguration = require('../models/authenticationconfiguration'); /** @@ -46,7 +46,7 @@ Provision.prototype.check = function (type, name, options) { next(); }); }).catch(err => { - next(err);; + next(err); }); }; }; diff --git a/service/src/routes/authenticationconfigurations.js b/service/src/routes/authenticationconfigurations.js index cdf239035..1439cf27a 100644 --- a/service/src/routes/authenticationconfigurations.js +++ b/service/src/routes/authenticationconfigurations.js @@ -4,6 +4,7 @@ const log = require('winston') , AsyncLock = require('async-lock') , access = require('../access') , Authentication = require('../models/authentication') + // TODO: users-next , AuthenticationConfiguration = require('../models/authenticationconfiguration') , AuthenticationConfigurationTransformer = require('../transformers/authenticationconfiguration') , SecretStoreService = require('../security/secret-store-service') diff --git a/service/src/routes/devices.js b/service/src/routes/devices.js deleted file mode 100644 index da9e7cc47..000000000 --- a/service/src/routes/devices.js +++ /dev/null @@ -1,237 +0,0 @@ -const log = require('winston'); -const Device = require('../models/device'); -const access = require('../access'); -const pageInfoTransformer = require('../transformers/pageinfo.js'); - -function DeviceResource() {} - -module.exports = function(app, security) { - - var passport = security.authentication.passport; - var resource = new DeviceResource(passport); - - // DEPRECATED retain old routes as deprecated until next major version. - /** - * @deprecated - */ - app.post('/api/devices', - function authenticate(req, res, next) { - log.warn('DEPRECATED - The /api/devices route will be removed in the next major version, please use /auth/{auth_strategy}/devices'); - passport.authenticate('local', function(err, user) { - if (err) { - return next(err); - } - if (!user) { - return next('route'); - } - req.login(user, function(err) { - next(err); - }); - })(req, res, next); - }, - async function(req, res, next) { - const newDevice = { - uid: req.param('uid'), - name: req.param('name'), - registered: false, - description: req.param('description'), - userAgent: req.headers['user-agent'], - appVersion: req.param('appVersion'), - userId: req.user.id - }; - try { - let device = await Device.getDeviceByUid(newDevice.uid); - if (!device) { - device = await Device.createDevice(newDevice); - } - return res.json(device) - } - catch(err) { - next(err); - } - next(new Error(`unknown error registering device ${newDevice.uid} for user ${newDevice.userId}`)); - } - ); - - /** - * Create a new device, requires CREATE_DEVICE role - * - * @deprecated Use /auth/{strategy}/authorize instead. - */ - app.post('/api/devices', - passport.authenticate('bearer'), - resource.ensurePermission('CREATE_DEVICE'), - resource.parseDeviceParams, - resource.validateDeviceParams, - resource.create - ); - - app.get('/api/devices/count', - passport.authenticate('bearer'), - access.authorize('READ_DEVICE'), - resource.count - ); - - // get all devices - app.get('/api/devices', - passport.authenticate('bearer'), - access.authorize('READ_DEVICE'), - resource.getDevices - ); - - // get device - // TODO: check for READ_USER also - app.get('/api/devices/:id', - passport.authenticate('bearer'), - access.authorize('READ_DEVICE'), - resource.getDevice - ); - - // Update a device - app.put('/api/devices/:id', - passport.authenticate('bearer'), - access.authorize('UPDATE_DEVICE'), - resource.parseDeviceParams, - resource.updateDevice - ); - - // Delete a device - app.delete('/api/devices/:id', - passport.authenticate('bearer'), - access.authorize('DELETE_DEVICE'), - resource.deleteDevice - ); -}; - -DeviceResource.prototype.ensurePermission = function(permission) { - return function(req, res, next) { - access.userHasPermission(req.user, permission) ? next() : res.sendStatus(403); - }; -}; - -/** - * TODO: this should return a 201 and a location header - * - * @deprecated Use /auth/{strategy}/authorize instead. - */ -DeviceResource.prototype.create = function(req, res, next) { - console.warn("Calling deprecated function to create device. Call authorize instead."); - - // Automatically register any device created by an ADMIN - req.newDevice.registered = true; - - Device.createDevice(req.newDevice) - .then(device => { - res.json(device); - }) - .catch(err => { - next(err) - }); -}; - -DeviceResource.prototype.count = function (req, res, next) { - var filter = {}; - - if(req.query) { - for (let [key, value] of Object.entries(req.query)) { - if(key == 'populate' || key == 'limit' || key == 'start' || key == 'sort' || key == 'forceRefresh'){ - continue; - } - filter[key] = value; - } - } - - Device.count({ filter: filter }) - .then(count => res.json({count: count})) - .catch(err => next(err)); -}; - -/** - * TODO: - * * the /users route uses the `populate` query param while this uses - * `expand`; should be consistent - * * openapi supports array query parameters using the pipe `|` delimiter; - * use that instead of comma for the `expand` query param. on the other hand, - * this only actually supports a singular `expand` key, so why bother with - * the split anyway? - */ -DeviceResource.prototype.getDevices = function (req, res, next) { - - const { populate, expand, limit, start, sort, forceRefresh, ...filter } = req.query; - const expandFlags = { user: /\buser\b/i.test(expand) }; - - Device.getDevices({ filter, expand: expandFlags, limit, start, sort }) - .then(result => { - if (!Array.isArray(result)) { - result = pageInfoTransformer.transform(result, req); - } - return res.json(result); - }) - .catch(err => next(err)); -}; - -DeviceResource.prototype.getDevice = function(req, res, next) { - var expand = {}; - if (req.query.expand) { - var expandList = req.query.expand.split(","); - if (expandList.some(function(e) { return e === 'user';})) { - expand.user = true; - } - } - - Device.getDeviceById(req.params.id, {expand: expand}) - .then(device => res.json(device)) - .catch(err => next(err)); -}; - -DeviceResource.prototype.updateDevice = function(req, res, next) { - const update = {}; - if (req.newDevice.uid) update.uid = req.newDevice.uid; - if (req.newDevice.name) update.name = req.newDevice.name; - if (req.newDevice.description) update.description = req.newDevice.description; - if (req.newDevice.registered !== undefined) update.registered = req.newDevice.registered; - if (req.newDevice.userId) update.userId = req.newDevice.userId; - - Device.updateDevice(req.param('id'), update) - .then(device => { - if (!device) return res.sendStatus(404); - - res.json(device); - }) - .catch(err => { - next(err) - }); -}; - -DeviceResource.prototype.deleteDevice = function(req, res, next) { - Device.deleteDevice(req.param('id')) - .then(device => { - if (!device) return res.sendStatus(404); - - res.json(device); - }) - .catch(err => next(err)); -}; - -DeviceResource.prototype.parseDeviceParams = function(req, res, next) { - req.newDevice = { - uid: req.param('uid'), - name: req.param('name'), - description: req.param('description'), - userId: req.param('userId') - }; - - if (req.param('registered') !== undefined) { - req.newDevice.registered = req.param('registered') === 'true'; - } - - next(); -}; - -DeviceResource.prototype.validateDeviceParams = function(req, res, next) { - if (!req.newDevice.uid) { - return res.status(400).send("missing required param 'uid'"); - } - - next(); -}; diff --git a/service/src/routes/exports.ts b/service/src/routes/exports.ts index d4c348839..47f850ee1 100644 --- a/service/src/routes/exports.ts +++ b/service/src/routes/exports.ts @@ -4,7 +4,7 @@ import express from 'express' import fs from 'fs' import log from '../logger' import { exportDirectory } from '../environment/env' -import Event, { MageEventDocument } from '../models/event' +import Event, { MageEventModelInstance } from '../models/event' import access from '../access' import exportXform from '../transformers/export' import { exportFactory, ExportFormat } from '../export' @@ -12,14 +12,14 @@ import { defaultEventPermissionsService as eventPermissions } from '../permissio import { MageRouteDefinitions } from './routes.types' import { ExportPermission, ObservationPermission } from '../entities/authorization/entities.permissions' import { EventAccessType } from '../entities/events/entities.events' -import Export, { ExportDocument } from '../models/export' +import Export, { ExportId, ExportModelInstance } from '../models/export' type ExportRequest = express.Request & { - export?: ExportDocument | null - parameters?: { - exportId?: ExportDocument['_id'] - filter: any - }, + export?: ExportModelInstance | null + parameters?: { + exportId?: ExportModelInstance['_id'] + filter: any + }, } const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { @@ -27,12 +27,13 @@ const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { const passport = security.authentication.passport; async function authorizeEventAccess(req: express.Request, res: express.Response, next: express.NextFunction): Promise { - if (access.userHasPermission(req.user, ObservationPermission.READ_OBSERVATION_ALL)) { + const account = req.user?.admitted?.account + if (access.userHasPermission(account, ObservationPermission.READ_OBSERVATION_ALL)) { return next(); } - else if (access.userHasPermission(req.user, ObservationPermission.READ_OBSERVATION_EVENT)) { + else if (account && access.userHasPermission(account, ObservationPermission.READ_OBSERVATION_EVENT)) { // Make sure I am part of this event - const allowed = await eventPermissions.userHasEventPermission(req.event!, req.user.id, EventAccessType.Read) + const allowed = await eventPermissions.userHasEventPermission(req.event!, account.id, EventAccessType.Read) if (allowed) { return next(); } @@ -43,13 +44,15 @@ const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { function authorizeExportAccess(permission: ExportPermission): express.RequestHandler { return async function authorizeExportAccess(req, res, next) { const exportReq = req as ExportRequest + const account = exportReq.user?.admitted?.account exportReq.export = await Export.getExportById(req.params.exportId) - if (access.userHasPermission(exportReq.user, permission)) { - next() + if (access.userHasPermission(account, permission)) { + return next() } - else { - exportReq.user._id.toString() === exportReq.export?.userId.toString() ? next() : res.sendStatus(403); + else if (account && account.id === exportReq.export?.userId.toString()) { + return next() } + res.sendStatus(403) } } @@ -60,8 +63,9 @@ const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { authorizeEventAccess, function (req, res, next) { const exportReq = req as ExportRequest + const userId = exportReq.user!.admitted!.account.id const document = { - userId: exportReq.user._id, + userId, exportType: exportReq.body.exportType, options: { eventId: req.body.eventId, @@ -71,7 +75,7 @@ const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { Export.createExport(document).then(result => { const response = exportXform.transform(result, { path: req.getPath() }); res.location(`${req.route.path}/${result._id.toString()}`).status(201).json(response); - exportData(result._id, exportReq.event!); + exportData(result.id, exportReq.event!); }) .catch(err => next(err)) } @@ -97,7 +101,8 @@ const DefineExportsRoutes: MageRouteDefinitions = function(app, security) { app.get('/api/exports/myself', passport.authenticate('bearer'), function (req, res, next) { - Export.getExportsByUserId(req.user._id, { populate: true }).then(exports => { + const userId = req.user!.admitted!.account.id + Export.getExportsByUserId(userId, { populate: true }).then(exports => { const response = exportXform.transform(exports, { path: `${req.getRoot()}/api/exports` }); res.json(response); }).catch(err => next(err)); @@ -209,7 +214,7 @@ function parseQueryParams(req: express.Request, res: express.Response, next: exp parameters.filter.favorites = String(body.favorites).toLowerCase() === 'true'; if (parameters.filter.favorites) { parameters.filter.favorites = { - userId: req.user._id + userId: req.user?.admitted?.account.id }; } parameters.filter.important = String(body.important).toLowerCase() === 'true'; @@ -235,7 +240,7 @@ function getEvent(req: express.Request, res: express.Response, next: express.Nex }) } -async function exportData(exportId: ExportDocument['_id'], event: MageEventDocument): Promise { +async function exportData(exportId: ExportId, event: MageEventModelInstance): Promise { let exportDocument = await Export.updateExport(exportId, { status: Export.ExportStatus.Running }) if (!exportDocument) { return diff --git a/service/src/routes/index.js b/service/src/routes/index.js index 12ad25196..439b5a80f 100644 --- a/service/src/routes/index.js +++ b/service/src/routes/index.js @@ -33,6 +33,7 @@ module.exports = function(app, security) { if (!/^[0-9a-f]{24}$/.test(userId)) { return res.status(400).send('Invalid user ID in request path'); } + // TODO: users-next new api.User().getById(userId, function(err, user) { if (!user) return res.status(404).send('User not found'); req.userParam = user; @@ -100,6 +101,7 @@ module.exports = function(app, security) { // Grab the feature for any endpoint that uses observationId app.param('observationId', function(req, res, next, observationId) { req.observationId = observationId; + // TODO: obs types: use repo new api.Observation(req.event).getById(observationId, function( err, observation diff --git a/service/src/routes/observations.js b/service/src/routes/observations.js index a3496f64a..9091d772a 100644 --- a/service/src/routes/observations.js +++ b/service/src/routes/observations.js @@ -1,26 +1,16 @@ -const { attachmentTypeIsValidForField } = require('../entities/events/entities.events.forms'); - module.exports = function (app, security) { - const async = require('async') - , api = require('../api') + const api = require('../api') , log = require('winston') , archiver = require('archiver') , path = require('path') , environment = require('../environment/env') - , fs = require('fs-extra') - , moment = require('moment') - , Team = require('../models/team') , access = require('../access') , { default: turfCentroid } = require('@turf/centroid') - , geometryFormat = require('../format/geoJsonFormat') , observationXform = require('../transformers/observation') - , FileType = require('file-type') , passport = security.authentication.passport , { defaultEventPermissionsService: eventPermissions } = require('../permissions/permissions.events'); - const sortColumnWhitelist = ["lastModified"]; - function transformOptions(req) { return { event: req.event, @@ -42,74 +32,17 @@ module.exports = function (app, security) { res.sendStatus(403); } - function validateObservationCreateAccess(validateObservationId) { - return function (req, res, next) { - + function validateObservationCreateAccess() { + return async (req, res, next) => { if (!access.userHasPermission(req.user, 'CREATE_OBSERVATION')) { return res.sendStatus(403); } - - var tasks = []; - if (validateObservationId) { - tasks.push(function (done) { - /* - TODO: this is validating the id from body document, but should it - validate the id from the url path instead? the path id is the one - that is passed down to the update operation - */ - new api.Observation().validateObservationId(req.param('id'), done); - }); - } - - tasks.push(function (done) { - Team.teamsForUserInEvent(req.user, req.event, function (err, teams) { - if (err) return next(err); - - if (teams.length === 0) { - return res.status(403).send('Cannot submit an observation for an event that you are not part of.'); - } - - done(); - }); - }); - - async.series(tasks, next); - }; - } - - async function validateObservationUpdateAccess(req, res, next) { - if (access.userHasPermission(req.user, 'UPDATE_OBSERVATION_ALL')) { - return next(); - } - if (access.userHasPermission(req.user, 'UPDATE_OBSERVATION_EVENT')) { - // Make sure I am part of this event - const hasPermission = await eventPermissions.userHasEventPermission(req.event, req.user.id, 'read') - if (hasPermission) { - return next(); + const isParticipant = await eventPermissions.userIsParticipantInEvent(req.event, req.user.id) + if (isParticipant) { + return next() } + res.status(403).send('You must be an event participant to perform this operation.') } - res.sendStatus(403); - } - - function validateAttachmentFile(req, res, next) { - FileType.fromFile(req.file.path).then(fileType => { - const attachment = req.observation.attachments.find(attachment => attachment._id.toString() === req.params.attachmentId); - const observationForm = req.observation.properties.forms.find(observationForm => { - return observationForm._id.toString() === attachment.observationFormId.toString() - }); - if (!observationForm) { - return res.status(400).send('Attachment form not found'); - } - const formDefinition = req.event.forms.find(form => form._id === observationForm.formId); - const fieldDefinition = formDefinition.fields.find(field => field.name === attachment.fieldName); - if (!fieldDefinition) { - return res.status(400).send('Attachment field not found'); - } - if (!attachmentTypeIsValidForField(fieldDefinition, fileType.mime)) { - return res.status(400).send(`Invalid attachment '${attachment.name}', type must be one of ${fieldDefinition.allowedAttachmentTypes.join(' or ')}`); - } - next(); - }); } function authorizeEventAccess(collectionPermission, aclPermission) { @@ -142,7 +75,7 @@ module.exports = function (app, security) { function getUserForObservation(req, res, next) { var userId = req.observation.userId; if (!userId) return next(); - + // TODO: users-next new api.User().getById(userId, function (err, user) { if (err) return next(err); @@ -176,84 +109,6 @@ module.exports = function (app, security) { }); } - function parseQueryParams(req, res, next) { - // setup defaults - const parameters = { - filter: { - } - }; - - const fields = req.query.fields; - if (fields) { - parameters.fields = JSON.parse(fields); - } - - const startDate = req.query.startDate; - if (startDate) { - parameters.filter.startDate = moment(startDate).utc().toDate(); - } - - const endDate = req.query.endDate; - if (endDate) { - parameters.filter.endDate = moment(endDate).utc().toDate(); - } - - const observationStartDate = req.query.observationStartDate; - if (observationStartDate) { - parameters.filter.observationStartDate = moment(observationStartDate).utc().toDate(); - } - - const observationEndDate = req.query.observationEndDate; - if (observationEndDate) { - parameters.filter.observationEndDate = moment(observationEndDate).utc().toDate(); - } - - const bbox = req.query.bbox; - if (bbox) { - parameters.filter.geometries = geometryFormat.parse('bbox', bbox); - } - - const geometry = req.query.geometry; - if (geometry) { - parameters.filter.geometries = geometryFormat.parse('geometry', geometry); - } - - const states = req.query.states; - if (states) { - parameters.filter.states = states.split(','); - } - - const sort = req.query.sort; - if (sort) { - const columns = {}; - let err = null; - sort.split(',').every(function (column) { - const sortParams = column.split('+'); - // Check sort column is in whitelist - if (sortColumnWhitelist.indexOf(sortParams[0]) === -1) { - err = `Cannot sort on column '${sortParams[0]}'`; - return false; // break - } - // Order can be nothing (ASC by default) or ASC, DESC - let direction = 1; // ASC - if (sortParams.length > 1 && sortParams[1] === 'DESC') { - direction = -1; // DESC - } - columns[sortParams[0]] = direction; - }); - if (err) { - return res.status(400).send(err); - } - parameters.sort = columns; - } - - parameters.populate = req.query.populate === 'true'; - - req.parameters = parameters; - - next(); - } - app.get( '/api/events/:eventId/observations/(:observationId).zip', passport.authenticate('bearer'), @@ -302,46 +157,6 @@ module.exports = function (app, security) { } ); - app.get( - '/api/events/:eventId/observations/:observationIdInPath', - passport.authenticate('bearer'), - validateObservationReadAccess, - parseQueryParams, - function (req, res, next) { - const options = { fields: req.parameters.fields }; - new api.Observation(req.event).getById(req.params.observationIdInPath, options, function (err, observation) { - if (err) { - return next(err); - } - if (!observation) { - return res.sendStatus(404); - } - const response = observationXform.transform(observation, transformOptions(req)); - res.json(response); - }); - } - ); - - app.get( - '/api/events/:eventId/observations', - passport.authenticate('bearer'), - validateObservationReadAccess, - parseQueryParams, - function (req, res, next) { - const options = { - filter: req.parameters.filter, - fields: req.parameters.fields, - sort: req.parameters.sort, - populate: req.parameters.populate - }; - - new api.Observation(req.event).getAll(options, function (err, observations) { - if (err) return next(err); - res.json(observationXform.transform(observations, transformOptions(req))); - }); - } - ); - app.put( '/api/events/:eventId/observations/:observationIdInPath/favorite', passport.authenticate('bearer'), diff --git a/service/src/routes/setup.js b/service/src/routes/setup.js index 5dc5ad2ae..21c1182e0 100644 --- a/service/src/routes/setup.js +++ b/service/src/routes/setup.js @@ -4,9 +4,11 @@ module.exports = function (app, security) { , User = require('../models/user') , Device = require('../models/device') , userTransformer = require('../transformers/user') + // TODO: users-next , AuthenticationConfiguration = require('../models/authenticationconfiguration'); function authorizeSetup(req, res, next) { + // TODO: users-next User.count(function (err, count) { if (err) next(err); @@ -87,6 +89,7 @@ module.exports = function (app, security) { }); }, function (done) { + // TODO: users-next User.createUser(req.user, function (err, user) { done(err, user); }); diff --git a/service/src/routes/users.js b/service/src/routes/users.js index 4c7cf4b0d..9383f1132 100644 --- a/service/src/routes/users.js +++ b/service/src/routes/users.js @@ -45,30 +45,15 @@ module.exports = function (app, security) { function parseIconUpload(req, res, next) { let iconMetadata = req.param('iconMetadata') || {}; - if (typeof iconMetadata === 'string' || iconMetadata instanceof String) { + if (typeof iconMetadata === 'string') { iconMetadata = JSON.parse(iconMetadata); } - const files = req.files || {}; - let [icon] = files.icon || []; + let [ icon ] = files.icon || []; if (icon) { - // default type to upload - if (!iconMetadata.type) iconMetadata.type = 'upload'; - - if (iconMetadata.type !== 'create' && iconMetadata.type !== 'upload') { - return res.status(400).send('invalid icon metadata'); - } - - icon.type = iconMetadata.type; icon.text = iconMetadata.text; icon.color = iconMetadata.color; - } else if (iconMetadata.type === 'none') { - icon = { - type: 'none' - }; - files.icon = [icon]; } - next(); } @@ -419,35 +404,6 @@ module.exports = function (app, security) { } ); - // update status for myself - app.put( - '/api/users/myself/status', - passport.authenticate('bearer'), - function (req, res) { - var status = req.param('status'); - if (!status) return res.status(400).send("Missing required parameter 'status'"); - req.user.status = status; - - new api.User().update(req.user, function (err, updatedUser) { - updatedUser = userTransformer.transform(updatedUser, { path: req.getRoot() }); - res.json(updatedUser); - }); - } - ); - - // remove status for myself - app.delete( - '/api/users/myself/status', - passport.authenticate('bearer'), - function (req, res) { - req.user.status = undefined; - new api.User().update(req.user, function (err, updatedUser) { - updatedUser = userTransformer.transform(updatedUser, { path: req.getRoot() }); - res.json(updatedUser); - }); - } - ); - // get user by id app.get( '/api/users/:userId', @@ -507,9 +463,8 @@ module.exports = function (app, security) { } ); - // Update a specific user's password - // Need UPDATE_USER_PASSWORD to change a users password - // TODO this needs to be update to use the UPDATE_USER_PASSWORD permission when Android is updated to handle that permission + // TODO: this needs to be update to use the UPDATE_USER_PASSWORD permission when Android is updated to handle that permission + // TODO: updated: android is fine now. create a migration to add UPDATE_USER_PASSWORD to the ADMIN_ROLE app.put( '/api/users/:userId/password', passport.authenticate('bearer'), diff --git a/service/src/security/utilities/secure-property-appender.js b/service/src/security/utilities/secure-property-appender.js index 3a05c1115..b4208c370 100644 --- a/service/src/security/utilities/secure-property-appender.js +++ b/service/src/security/utilities/secure-property-appender.js @@ -1,11 +1,12 @@ 'use strict'; const SecretStoreService = require('../secret-store-service') +// TODO: users-next , AuthenticationConfiguration = require('../../models/authenticationconfiguration') /** * Helper function to append secure properties to a configuration under the settings property. - * + * * @param {*} config Must contain the _id property * @returns A copy of the config with secure properties appended (if any exist) */ diff --git a/service/src/transformers/authenticationconfiguration.js b/service/src/transformers/authenticationconfiguration.js index ed596dfd6..cc068c954 100644 --- a/service/src/transformers/authenticationconfiguration.js +++ b/service/src/transformers/authenticationconfiguration.js @@ -1,5 +1,6 @@ "use strict"; +// TODO: users-next const AuthenticationConfiguration = require('../models/authenticationconfiguration'); function transformAuthenticationConfiguration(authenticationConfiguration, options) { diff --git a/service/src/transformers/user.js b/service/src/transformers/user.js deleted file mode 100644 index 499ba88d6..000000000 --- a/service/src/transformers/user.js +++ /dev/null @@ -1,21 +0,0 @@ -function transformUser(user, options) { - if (!user) { - return null; - } - return user.toObject ? - user.toObject({ path: options.path }) : - user; -} - -function transformUsers(users, options) { - return users.map(function(user) { - return transformUser(user, options); - }); -} - -exports.transform = function(users, options) { - options = options || {}; - return Array.isArray(users) ? - transformUsers(users, options) : - transformUser(users, options); -}; diff --git a/service/src/upload.ts b/service/src/upload.ts index 2e6f9e22b..85ee97831 100644 --- a/service/src/upload.ts +++ b/service/src/upload.ts @@ -6,26 +6,21 @@ import env from './environment/env' const storage = multer.diskStorage({ destination: env.tempDirectory, - filename: function(req, file, cb: any) { + filename: function(req, file, cb: (error: Error | null, filename: string) => void): void { crypto.pseudoRandomBytes(16, function(err, raw) { if (err) { - return cb(err); + return cb(err, '') } - cb(null, raw.toString('hex') + path.extname(file.originalname)); - }); + cb(null, raw.toString('hex') + path.extname(file.originalname)) + }) } -}); +}) -function Upload(limits: multer.Options['limits'] = {}) { - return multer({ - storage, limits - }); +function UploadMiddleware(limits: multer.Options['limits'] = {}): multer.Multer { + return multer({ storage, limits }) } -const defaultHandler = Upload() - -const upload = { - Upload, defaultHandler -} +const defaultHandler = UploadMiddleware() +const upload = Object.freeze({ defaultHandler }) export = upload diff --git a/service/src/utilities/authenticationApiAppender.js b/service/src/utilities/authenticationApiAppender.js index 715af906f..b437ce7e7 100644 --- a/service/src/utilities/authenticationApiAppender.js +++ b/service/src/utilities/authenticationApiAppender.js @@ -1,15 +1,16 @@ "use strict"; const extend = require('util')._extend + // TODO: users-next , AuthenticationConfiguration = require('../models/authenticationconfiguration') , AuthenticationConfigurationTransformer = require('../transformers/authenticationconfiguration'); /** - * Appends authenticationStrategies to the config.api object (the original config.api is not modified; this method returns a copy). + * Appends authenticationStrategies to the config.api object (the original config.api is not modified; this method returns a copy). * These strategies are read from the db. - * - * @param {*} api - * @param {*} options + * + * @param {*} api + * @param {*} options * @returns Promise containing the modified copy of the api parameter */ async function append(api, options) { diff --git a/service/src/utilities/password-hashing.ts b/service/src/utilities/password-hashing.ts new file mode 100644 index 000000000..215a57069 --- /dev/null +++ b/service/src/utilities/password-hashing.ts @@ -0,0 +1,96 @@ +import crypto from 'crypto' +import util from 'util' + +const digest = 'sha512' +const pbkdf2_promise = util.promisify(crypto.pbkdf2) + +export type HashedPassword = { + salt: string + derivedKey: string + derivedKeyLength: number + iterations: number +} + +export function formatHashedPassword(hashed: HashedPassword): string { + return `${hashed.salt}::${hashed.derivedKey}::${hashed.derivedKeyLength}::${hashed.iterations}` +} + +export type PasswordHashOptions = { + iterations?: number + saltLength?: number + derivedKeyLength?: number +} + +export const defaultPasswordHashOptions = { + iterations: 12000, + saltLength: 128, + derivedKeyLength: 256, +} as const + +export class PasswordHashUtil { + + private iterations: number + private saltLength: number + private derivedKeyLength: number + + constructor(readonly options = defaultPasswordHashOptions) { + this.iterations = options.iterations || defaultPasswordHashOptions.iterations + this.saltLength = options.saltLength || defaultPasswordHashOptions.saltLength + this.derivedKeyLength = options.derivedKeyLength || defaultPasswordHashOptions.derivedKeyLength + } + + /** + * Serialize a password object containing all the information needed to check a password into a string + * The info is salt, derivedKey, derivedKey length and number of iterations + */ + serializePassword(hashed: HashedPassword): string { + return formatHashedPassword(hashed) + } + + /** + * Deserialize a string into a password object. + * The info is salt, derivedKey, derivedKey length and number of iterations + */ + deserializePassword(password: string): HashedPassword { + const items = password.split('::') + return { + salt: items[0], + derivedKey: items[1], + derivedKeyLength: parseInt(items[2], 10), + iterations: parseInt(items[3], 10) + } + } + /** + * Hash a password using Node crypto's PBKDF2 + * Description here: http://en.wikipedia.org/wiki/PBKDF2 + * Number of iterations are saved in case we change the setting in the future + */ + async hashPassword(password: string): Promise { + const salt = crypto.randomBytes(this.saltLength).toString('base64') + // TODO: upgrade hash algorithm + const derivedKey = await pbkdf2_promise(password, salt, this.iterations, this.derivedKeyLength, digest) + return { + salt, + iterations: this.iterations, + derivedKeyLength: this.derivedKeyLength, + derivedKey: derivedKey.toString('base64') + } + } + + /** + * Compare a password to a password hash + */ + async validPassword(password: string, serializedHash: string): Promise { + if (!serializedHash) { + return false + } + const hash = this.deserializePassword(serializedHash) + if (!hash.salt || !hash.derivedKey || !hash.iterations || !hash.derivedKeyLength) { + throw new Error('invalid password hash') + } + const testHash = await pbkdf2_promise(password, hash.salt, hash.iterations, hash.derivedKeyLength, digest) + return testHash.toString('base64') === hash.derivedKey + } +} + +export const defaultHashUtil = new PasswordHashUtil() \ No newline at end of file diff --git a/service/src/utilities/password-policy.ts b/service/src/utilities/password-policy.ts new file mode 100644 index 000000000..421db79be --- /dev/null +++ b/service/src/utilities/password-policy.ts @@ -0,0 +1,166 @@ +import { defaultHashUtil } from './password-hashing' + +const SPECIAL_CHARS = '~!@#$%^&*(),.?":{}|<>_=;-' + +export type PasswordRequirements = { + passwordMinLengthEnabled: boolean + passwordMinLength: number + /** Minimum number of alpha characters, A-Z, case-insensitive */ + minCharsEnabled: boolean + minChars: number + maxConCharsEnabled: boolean + maxConChars: number + lowLettersEnabled: boolean + lowLetters: number + highLettersEnabled: boolean + highLetters: number + numbersEnabled: boolean + numbers: number + specialCharsEnabled: boolean + specialChars: number + restrictSpecialCharsEnabled: boolean + restrictSpecialChars: string + passwordHistoryCountEnabled: boolean + passwordHistoryCount: number + helpText: string +} + +export type PasswordValidationResult = { + valid: boolean + errorMessage: string | null +} + +export async function validatePasswordRequirements(password: string, policy: PasswordRequirements, previousPasswords: string[]): Promise { + if (!password) { + return { + valid: false, + errorMessage: 'Password is missing' + } + } + const valid = + validatePasswordLength(policy, password) && + validateMinimumCharacters(policy, password) && + validateMaximumConsecutiveCharacters(policy, password) && + validateMinimumLowercaseCharacters(policy, password) && + validateMinimumUppercaseCharacters(policy, password) && + validateMinimumNumbers(policy, password) && + validateMinimumSpecialCharacters(policy, password) && + (await validatePasswordHistory(policy, password, previousPasswords)) + return { valid, errorMessage: valid ? null : policy.helpText } +} + +function validatePasswordLength(policy: PasswordRequirements, password: string): boolean { + return policy.passwordMinLengthEnabled && + password.length >= policy.passwordMinLength +} + +function validateMinimumCharacters(policy: PasswordRequirements, password: string): boolean { + if (!policy.minCharsEnabled) { + return true + } + let letterCount = 0 + for (let i = 0; i < password.length; i++) { + if (password[i].match(/[a-z]/i)) { + letterCount++ + } + } + return letterCount >= policy.minChars +} + +function validateMaximumConsecutiveCharacters(policy: PasswordRequirements, password: string): boolean { + if (!policy.maxConCharsEnabled) { + return true + } + const tooManyConsecutiveLetters = new RegExp(`[a-z]{${policy.maxConChars + 1}}`, 'i') + return !tooManyConsecutiveLetters.test(password) +} + +function validateMinimumLowercaseCharacters(policy: PasswordRequirements, password: string): boolean { + if (!policy.lowLettersEnabled) { + return true + } + let letterCount = 0 + for (let i = 0; i < password.length; i++) { + if (/[a-z]/.test(password[i])) { + letterCount++ + } + } + return letterCount >= policy.lowLetters +} + +function validateMinimumUppercaseCharacters(policy: PasswordRequirements, password: string): boolean { + if (!policy.highLettersEnabled) { + return true + } + let letterCount = 0 + for (let i = 0; i < password.length; i++) { + if (/[A-Z]/.test(password[i])) { + letterCount++ + } + } + return letterCount >= policy.highLetters +} + +function validateMinimumNumbers(policy: PasswordRequirements, password: string): boolean { + if (!policy.numbersEnabled) { + return true + } + let numberCount = 0 + for (let i = 0; i < password.length; i++) { + if (/[0-9]/.test(password[i])) { + numberCount++ + } + } + return numberCount >= policy.numbers +} + +function validateMinimumSpecialCharacters(policy: PasswordRequirements, password: string): boolean { + if (!policy.specialCharsEnabled) { + return true + } + let allowedChars = null + let forbiddenChars = null + if (policy.restrictSpecialCharsEnabled) { + forbiddenChars = new RegExp('[' + createRestrictedRegex(policy.restrictSpecialChars) + ']') + allowedChars = new RegExp('[' + policy.restrictSpecialChars + ']') + } + else { + allowedChars = new RegExp('[' + SPECIAL_CHARS + ']') + } + let specialCharCount = 0 + for (let i = 0; i < password.length && specialCharCount < policy.specialChars && specialCharCount > -1; i++) { + const char = password[i] + if (forbiddenChars && forbiddenChars.test(char)) { + specialCharCount = -1 + } + else if (allowedChars.test(char)) { + specialCharCount++ + } + } + return specialCharCount >= policy.specialChars +} + +function createRestrictedRegex(restrictedChars: string): string { + let forbiddenRegex = '' + for (let i = 0; i < SPECIAL_CHARS.length; i++) { + const specialChar = SPECIAL_CHARS[i] + if (!restrictedChars.includes(specialChar)) { + forbiddenRegex += specialChar + } + } + return forbiddenRegex +} + +async function validatePasswordHistory(policy: PasswordRequirements, password: string, previousPasswordHashes: string[]): Promise { + if (!policy.passwordHistoryCountEnabled || !previousPasswordHashes) { + return true + } + const truncatedHistory = previousPasswordHashes.slice(0, policy.passwordHistoryCount) + for (const previousPasswordHash of truncatedHistory) { + const used = await defaultHashUtil.validPassword(password, previousPasswordHash) + if (used) { + return false + } + } + return true +} diff --git a/service/src/utilities/passwordValidator.js b/service/src/utilities/passwordValidator.js deleted file mode 100644 index 7b0b82087..000000000 --- a/service/src/utilities/passwordValidator.js +++ /dev/null @@ -1,190 +0,0 @@ -const util = require('util') - , log = require('winston') - , hasher = require('./pbkdf2')(); - -const SPECIAL_CHARS = '~!@#$%^&*(),.?":{}|<>_=;-'; -const validPassword = util.promisify(hasher.validPassword); - -async function validate(passwordPolicy, { password, previousPasswords: previousHashedPasswords }) { - if (!password) { - return { - valid: false, - errorMsg: 'Password is missing' - }; - } - - const invalid = - !validatePasswordLength(passwordPolicy, password) || - !validateMinimumCharacters(passwordPolicy, password) || - !validateMaximumConsecutiveCharacters(passwordPolicy, password) || - !validateMinimumLowercaseCharacters(passwordPolicy, password) || - !validateMinimumUppercaseCharacters(passwordPolicy, password) || - !validateMinimumNumbers(passwordPolicy, password) || - !validateMinimumSpecialCharacters(passwordPolicy, password) || - !(await validatePasswordHistory(passwordPolicy, password, previousHashedPasswords)); - - return { - valid: !invalid, - errorMsg: invalid ? passwordPolicy.helpText : null - }; -} - -function validatePasswordLength(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.passwordMinLengthEnabled) { - isValid = password.length >= passwordPolicy.passwordMinLength; - } - - log.debug('Password meets min length: ' + isValid); - - return isValid; -} - -function validateMinimumCharacters(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.minCharsEnabled) { - let passwordCount = 0; - for (let i = 0; i < password.length; i++) { - const a = password[i]; - - if (a.match(/[a-z]/i)) { - passwordCount++; - } - } - - isValid = passwordCount >= passwordPolicy.minChars; - log.debug('Password meets miniminum letters: ' + isValid); - } - return isValid; -} - -function validateMaximumConsecutiveCharacters(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.maxConCharsEnabled) { - let conCount = 0; - for (let i = 0; i < password.length; i++) { - const a = password[i]; - - if (a.match(/[a-z]/i)) { - conCount++; - } else { - conCount = 0; - } - - if (conCount > passwordPolicy.maxConChars) { - isValid = false; - break; - } - } - log.debug('Password meets max consecutive letters: ' + isValid); - } - return isValid; -} - -function validateMinimumLowercaseCharacters(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.lowLettersEnabled) { - let passwordCount = 0; - for (let i = 0; i < password.length; i++) { - const a = password[i]; - - if (a.match(/[a-z]/)) { - passwordCount++; - } - } - isValid = passwordCount >= passwordPolicy.lowLetters; - log.debug('Password meets minimum lowercase letters: ' + isValid); - } - return isValid; -} - -function validateMinimumUppercaseCharacters(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.highLettersEnabled) { - let passwordCount = 0; - for (let i = 0; i < password.length; i++) { - const a = password[i]; - - if (a.match(/[A-Z]/)) { - passwordCount++; - } - } - isValid = passwordCount >= passwordPolicy.highLetters; - log.debug('Password meets minimum uppercase letters: ' + isValid); - } - return isValid; -} - -function validateMinimumNumbers(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.numbersEnabled) { - let passwordCount = 0; - for (let i = 0; i < password.length; i++) { - let a = password[i]; - - if (a.match(/[0-9]/)) { - passwordCount++; - } - } - isValid = passwordCount >= passwordPolicy.numbers; - log.debug('Password meets minimum numbers: ' + isValid); - } - return isValid; -} - -function validateMinimumSpecialCharacters(passwordPolicy, password) { - let isValid = true; - if (passwordPolicy.specialCharsEnabled) { - let regex = null; - let nonAllowedRegex = null; - if (passwordPolicy.restrictSpecialCharsEnabled) { - nonAllowedRegex = new RegExp('[' + createRestrictedRegex(passwordPolicy.restrictSpecialChars) + ']'); - regex = new RegExp('[' + passwordPolicy.restrictSpecialChars + ']'); - } else { - regex = new RegExp('[' + SPECIAL_CHARS + ']'); - } - - let specialCharCount = 0; - for (let i = 0; i < password.length; i++) { - const a = password[i]; - - if (nonAllowedRegex && a.match(nonAllowedRegex)) { - specialCharCount = -1; - break; - } - - if (a.match(regex)) { - specialCharCount++; - } - } - isValid = specialCharCount >= passwordPolicy.specialChars; - log.debug('Password meets special characters policy: ' + isValid); - } - return isValid; -} - -function createRestrictedRegex(restrictedChars) { - let nonAllowedRegex = ''; - - for (let i = 0; i < SPECIAL_CHARS.length; i++) { - const specialChar = SPECIAL_CHARS[i]; - - if (!restrictedChars.includes(specialChar)) { - nonAllowedRegex += specialChar; - } - } - - return nonAllowedRegex; -} - -async function validatePasswordHistory(passwordPolicy, password, passwords) { - if (!passwordPolicy.passwordHistoryCountEnabled || !passwords) return Promise.resolve(true); - - const policyPasswords = passwords.slice(0, passwordPolicy.passwordHistoryCount); - const results = await Promise.all(policyPasswords.map(policyPassword => validPassword(password, policyPassword))); - return !results.includes(true); -} - -module.exports = { - validate -} \ No newline at end of file diff --git a/service/src/utilities/pbkdf2.js b/service/src/utilities/pbkdf2.js deleted file mode 100644 index 4f172bcbc..000000000 --- a/service/src/utilities/pbkdf2.js +++ /dev/null @@ -1,88 +0,0 @@ -module.exports = function(options) { - - const crypto = require('crypto'); - - options = options || {}; - const iterations = options.iterations || 12000; - const saltLength = options.saltLength || 128; - const derivedKeyLength = options.derivedKeyLength || 256; - - /** - * Serialize a password object containing all the information needed to check a password into a string - * The info is salt, derivedKey, derivedKey length and number of iterations - */ - function serializePassword(password) { - return password.salt + "::" + - password.derivedKey + "::" + - password.derivedKeyLength + "::" + - password.iterations; - } - - /** - * Deserialize a string into a password object - * The info is salt, derivedKey, derivedKey length and number of iterations - */ - function deserializePassword(password) { - const items = password.split('::'); - - return { - salt: items[0], - derivedKey: items[1], - derivedKeyLength: parseInt(items[2], 10), - iterations: parseInt(items[3], 10) - }; - } - - /** - * Hash a password using node.js' crypto's PBKDF2 - * Description here: http://en.wikipedia.org/wiki/PBKDF2 - * Number of iterations are saved in case we change the setting in the future - * @param {String} password - * @param {Function} callback Signature: err, hashedPassword - */ - function hashPassword(password, callback) { - const salt = crypto.randomBytes(saltLength).toString('base64'); - - crypto.pbkdf2(password, salt, iterations, derivedKeyLength, 'sha1', function (err, derivedKey) { - if (err) { return callback(err); } - - const hashedPassword = serializePassword({ - salt: salt, - iterations: iterations, - derivedKeyLength: derivedKeyLength, - derivedKey: derivedKey.toString('base64') - }); - - callback(null, hashedPassword); - }); - } - - /** - * Compare a password to a hashed password - * @param {String} password - * @param {String} hashedPassword - * @param {Function} callback Signature: err, true/false - */ - function validPassword(password, hashedPassword, callback) { - if (!hashedPassword) return callback(false); - - hashedPassword = deserializePassword(hashedPassword); - - if (!hashedPassword.salt || !hashedPassword.derivedKey || !hashedPassword.iterations || !hashedPassword.derivedKeyLength) { - return callback(new Error("hashedPassword doesn't have the right format")); - } - - // Use the hashedPassword password's parameters to hash the candidate password - crypto.pbkdf2(password, hashedPassword.salt, hashedPassword.iterations, hashedPassword.derivedKeyLength, 'sha1', function (err, derivedKey) { - if (err) { return callback(err); } - - callback(null, derivedKey.toString('base64') === hashedPassword.derivedKey); - }); - } - - return { - hashPassword: hashPassword, - validPassword: validPassword - }; - -}; diff --git a/service/test/adapters/base/adapters.base.db.mongoose.test.ts b/service/test/adapters/base/adapters.base.db.mongoose.test.ts index b6594a712..4a80ec707 100644 --- a/service/test/adapters/base/adapters.base.db.mongoose.test.ts +++ b/service/test/adapters/base/adapters.base.db.mongoose.test.ts @@ -17,7 +17,7 @@ describe('mongoose adapter layer base', function() { squee?: boolean noo?: number } - type BaseDocument = BaseEntity & mongoose.Document + type BaseDocument = BaseEntity type BaseModel = mongoose.Model const collection = 'base' diff --git a/service/test/adapters/devices/adapters.devices.mongoose.test.ts b/service/test/adapters/devices/adapters.devices.mongoose.test.ts new file mode 100644 index 000000000..4b5aabf33 --- /dev/null +++ b/service/test/adapters/devices/adapters.devices.mongoose.test.ts @@ -0,0 +1,8 @@ +import { expect } from 'chai' + +describe('device mongoose repository', function() { + + it('has tests', function() { + expect.fail('todo') + }) +}) \ No newline at end of file diff --git a/service/test/adapters/observations/adapters.observations.controllers.web.test.ts b/service/test/adapters/observations/adapters.observations.controllers.web.test.ts index 2b4a7a061..45685686e 100644 --- a/service/test/adapters/observations/adapters.observations.controllers.web.test.ts +++ b/service/test/adapters/observations/adapters.observations.controllers.web.test.ts @@ -166,6 +166,52 @@ describe('observations web controller', function () { }) }) + describe('GET /observations', function() { + + describe('query string parsing', function() { + + it('parses bbox as a json string', async function() { + // TODO: this format should be deprecated + expect.fail('todo') + }) + + it('parses bbox as csv', async function() { + expect.fail('todo') + }) + + it('parses min last modified date', async function() { + expect.fail('todo') + }) + + it('parses max last modified date', async function() { + expect.fail('todo') + }) + + it('parses min timestamp', async function() { + expect.fail('todo') + }) + + it('parses max timestamp', async function() { + expect.fail('todo') + }) + + it('parses populate flag', async function() { + expect.fail('todo') + }) + + it('parses sort field', async function() { + expect.fail('todo') + }) + }) + }) + + describe('GET /observations/{observationId}', function() { + + it('has tests', async function() { + expect.fail('todo') + }) + }) + describe('PUT /observations/{observationId}', function() { it('saves the observation for a mod request', async function() { diff --git a/service/test/adapters/observations/adapters.observations.db.mongoose.test.ts b/service/test/adapters/observations/adapters.observations.db.mongoose.test.ts index 6ccdd8558..1b5387e96 100644 --- a/service/test/adapters/observations/adapters.observations.db.mongoose.test.ts +++ b/service/test/adapters/observations/adapters.observations.db.mongoose.test.ts @@ -2,15 +2,14 @@ import { describe, it } from 'mocha' import { expect } from 'chai' import mongoose from 'mongoose' import _ from 'lodash' -import { MongooseMageEventRepository } from '../../../lib/adapters/events/adapters.events.db.mongoose' -import { MongooseObservationRepository } from '../../../lib/adapters/observations/adapters.observations.db.mongoose' +import { MageEventModel, MongooseMageEventRepository } from '../../../lib/adapters/events/adapters.events.db.mongoose' +import { MongooseObservationRepository, ObservationDocument, ObservationModel } from '../../../lib/adapters/observations/adapters.observations.db.mongoose' import * as legacy from '../../../lib/models/observation' import * as legacyEvent from '../../../lib/models/event' import { MageEventDocument } from '../../../src/models/event' import { MageEvent, MageEventAttrs, MageEventCreateAttrs, MageEventId } from '../../../lib/entities/events/entities.events' -import { ObservationDocument, ObservationModel } from '../../../src/models/observation' -import { ObservationAttrs, ObservationId, Observation, ObservationRepositoryError, ObservationRepositoryErrorCode, copyObservationAttrs, AttachmentContentPatchAttrs, copyAttachmentAttrs, AttachmentNotFoundError, AttachmentPatchAttrs, removeAttachment, validationResultMessage, ObservationDomainEventType, ObservationEmitted, PendingObservationDomainEvent, AttachmentsRemovedDomainEvent } from '../../../lib/entities/observations/entities.observations' +import { ObservationAttrs, ObservationId, Observation, ObservationRepositoryError, ObservationRepositoryErrorCode, copyObservationAttrs, AttachmentContentPatchAttrs, copyAttachmentAttrs, AttachmentNotFoundError, AttachmentPatchAttrs, removeAttachment, validationResultMessage, ObservationDomainEventType, ObservationEmitted, PendingObservationDomainEvent, AttachmentsRemovedDomainEvent, ObservationStateName, EventScopedObservationRepository } from '../../../lib/entities/observations/entities.observations' import { AttachmentPresentationType, FormFieldType, Form, AttachmentMediaTypes } from '../../../lib/entities/events/entities.events.forms' import util from 'util' import { PendingEntityId } from '../../../lib/entities/entities.global' @@ -57,7 +56,7 @@ describe('mongoose observation repository', function() { beforeEach('initialize model', async function() { //TODO remove cast to any, was mongoose.Model - const MageEventModel = legacyEvent.Model as any + const MageEventModel = legacyEvent.Model const eventRepo = new MongooseMageEventRepository(MageEventModel) createEvent = (attrs: Partial): Promise => { return new Promise((resolve, reject) => { @@ -123,7 +122,7 @@ describe('mongoose observation repository', function() { userFields: [] }) domainEvents = Substitute.for() - model = legacy.observationModel(eventDoc) + model = MageEventModel. repo = new MongooseObservationRepository(eventDoc, eventRepo.findById.bind(eventRepo), domainEvents) event = new MageEvent(eventRepo.entityForDocument(eventDoc)) @@ -153,11 +152,24 @@ describe('mongoose observation repository', function() { expect(id).to.be.a.string expect(id).to.not.be.empty - expect(parsed.equals(found?._id)).to.be.true + expect(parsed.equals(found?._id || '')).to.be.true expect(idCount).to.equal(1) }) }) + describe('reading observations', function() { + + describe('finding some', function() { + + it('has tests', async function() { + + const some = await repo.findSome({ where: {} }) + + expect.fail('todo') + }) + }) + }) + describe('saving observations', function() { describe('new observations', function() { @@ -242,12 +254,12 @@ describe('mongoose observation repository', function() { attrs.states = [ { id: (new mongoose.Types.ObjectId()).toHexString(), - name: 'active', + name: ObservationStateName.Active, userId: (new mongoose.Types.ObjectId()).toHexString() }, { id: (new mongoose.Types.ObjectId()).toHexString(), - name: 'archived', + name: ObservationStateName.Archived, userId: undefined } ] @@ -293,7 +305,7 @@ describe('mongoose observation repository', function() { } ] origAttrs.states = [ - { id: (new mongoose.Types.ObjectId()).toHexString(), name: 'active', userId: (new mongoose.Types.ObjectId()).toHexString() } + { id: (new mongoose.Types.ObjectId()).toHexString(), name: ObservationStateName.Active, userId: (new mongoose.Types.ObjectId()).toHexString() } ] origAttrs.properties.forms = [ { @@ -326,7 +338,7 @@ describe('mongoose observation repository', function() { coordinates: [ 12, 34 ] } putAttrs.states = [ - { name: 'archived', id: PendingEntityId } + { name: ObservationStateName.Archived, id: PendingEntityId } ] putAttrs.properties.forms = [ { @@ -412,7 +424,7 @@ describe('mongoose observation repository', function() { state1Stub.states = [ { id: PendingEntityId, - name: 'archived', + name: ObservationStateName.Archived, userId: (new mongoose.Types.ObjectId()).toHexString() } ] @@ -423,7 +435,7 @@ describe('mongoose observation repository', function() { state2Stub.states = [ { id: PendingEntityId, - name: 'active', + name: ObservationStateName.Active, userId: (new mongoose.Types.ObjectId()).toHexString() }, state1Saved.states[0], diff --git a/service/test/app/ingress/app.ingress.test.ts b/service/test/app/ingress/app.ingress.test.ts new file mode 100644 index 000000000..bee7572fc --- /dev/null +++ b/service/test/app/ingress/app.ingress.test.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai' + +describe('ingress use cases', function() { + + describe('admitting users', function() { + + describe('without an existing account', function() { + + it('creates a new account bound to the admitting idp', async function() { + expect.fail('todo') + }) + + it('applies enrollment policies to the new account', async function() { + expect.fail('todo') + }) + + it('generates an admission token', async function() { + expect.fail('todo') + }) + + it('fails if the enrollment policy requires account approval', async function() { + expect.fail('todo') + }) + + it('fails if the idp is disabled', async function() { + expect.fail('todo') + }) + }) + + describe('with an existing account', function() { + + it('generates an admission token', async function() { + expect.fail('todo') + }) + + it('fails without a matching idp binding', async function() { + expect.fail('todo') + }) + + it('fails if the idp is disabled', async function() { + expect.fail('todo') + }) + + it('fails if the account is disabled', async function() { + expect.fail('todo') + }) + }) + }) +}) \ No newline at end of file diff --git a/service/test/app/observations/app.observations.test.ts b/service/test/app/observations/app.observations.test.ts index 8572c64c1..3a638de78 100644 --- a/service/test/app/observations/app.observations.test.ts +++ b/service/test/app/observations/app.observations.test.ts @@ -4,11 +4,11 @@ import uniqid from 'uniqid' import * as api from '../../../lib/app.api/observations/app.api.observations' import { AllocateObservationId, registerDeleteRemovedAttachmentsHandler, ReadAttachmentContent, SaveObservation, StoreAttachmentContent } from '../../../lib/app.impl/observations/app.impl.observations' import { copyMageEventAttrs, MageEvent } from '../../../lib/entities/events/entities.events' -import { addAttachment, Attachment, AttachmentContentPatchAttrs, AttachmentCreateAttrs, AttachmentsRemovedDomainEvent, AttachmentStore, AttachmentStoreError, AttachmentStoreErrorCode, copyAttachmentAttrs, copyObservationAttrs, copyObservationStateAttrs, EventScopedObservationRepository, Observation, ObservationAttrs, ObservationDomainEventType, ObservationEmitted, ObservationRepositoryError, ObservationRepositoryErrorCode, ObservationState, patchAttachment, putAttachmentThumbnailForMinDimension, removeAttachment, removeFormEntry, StagedAttachmentContentRef } from '../../../lib/entities/observations/entities.observations' +import { addAttachment, Attachment, AttachmentContentPatchAttrs, AttachmentCreateAttrs, AttachmentsRemovedDomainEvent, AttachmentStore, AttachmentStoreError, AttachmentStoreErrorCode, copyAttachmentAttrs, copyObservationAttrs, copyObservationStateAttrs, EventScopedObservationRepository, Observation, ObservationAttrs, ObservationDomainEventType, ObservationEmitted, ObservationRepositoryError, ObservationRepositoryErrorCode, ObservationState, ObservationStateName, patchAttachment, putAttachmentThumbnailForMinDimension, removeAttachment, removeFormEntry, StagedAttachmentContentRef } from '../../../lib/entities/observations/entities.observations' import { permissionDenied, MageError, ErrPermissionDenied, ErrEntityNotFound, EntityNotFoundError, InvalidInputError, ErrInvalidInput, PermissionDeniedError, InfrastructureError, ErrInfrastructure } from '../../../lib/app.api/app.api.errors' import { FormFieldType } from '../../../lib/entities/events/entities.events.forms' import _ from 'lodash' -import { User, UserId, UserRepository } from '../../../lib/entities/users/entities.users' +import { User, UserIconType, UserId, UserRepository } from '../../../lib/entities/users/entities.users' import { pipeline, Readable } from 'stream' import util from 'util' import { BufferWriteable } from '../../utils' @@ -290,8 +290,8 @@ describe('observations use case interactions', function() { expect(exo.state).to.be.undefined const states: ObservationState[] = [ - { id: uniqid(), name: 'archived', userId: uniqid() }, - { id: uniqid(), name: 'active', userId: uniqid() } + { id: uniqid(), name: ObservationStateName.Archived, userId: uniqid() }, + { id: uniqid(), name: ObservationStateName.Active, userId: uniqid() } ] from.states = states.map(copyObservationStateAttrs) exo = api.exoObservationFor(from) @@ -343,7 +343,8 @@ describe('observations use case interactions', function() { { minDimension: 200, contentLocator: void(0), size: 2000, contentType: 'image/jpeg', width: 200, height: 300, name: 'rainbow@200.jpg' }, { minDimension: 300, contentLocator: void(0), size: 3000, contentType: 'image/jpeg', width: 300, height: 400, name: 'rainbow@300.jpg' }, { minDimension: 400, contentLocator: void(0), size: 4000, contentType: 'image/jpeg', width: 400, height: 500, name: 'rainbow@400.jpg' }, - ] + ], + url: '/remove/me' } expect(api.exoAttachmentForThumbnail(0, att)).to.deep.equal({ @@ -497,6 +498,9 @@ describe('observations use case interactions', function() { phones: [], roleId: uniqid(), username: 'populate.me', + avatar: {}, + icon: { type: UserIconType.None }, + recentEventIds: [] } const req: api.SaveObservationRequest = { context, @@ -530,6 +534,9 @@ describe('observations use case interactions', function() { phones: [], roleId: uniqid(), username: 'populate.me', + recentEventIds: [], + avatar: {}, + icon: { type: UserIconType.None } } const req: api.SaveObservationRequest = { context, @@ -567,6 +574,9 @@ describe('observations use case interactions', function() { phones: [], roleId: uniqid(), username: 'user1', + recentEventIds: [], + avatar: {}, + icon: { type: UserIconType.None } } const importantFlagger: User = { id: uniqid(), @@ -579,6 +589,9 @@ describe('observations use case interactions', function() { phones: [], roleId: uniqid(), username: 'user2', + recentEventIds: [], + avatar: {}, + icon: { type: UserIconType.None } } const req: api.SaveObservationRequest = { context, @@ -1404,8 +1417,8 @@ describe('observations use case interactions', function() { ...copyObservationAttrs(obsBefore), id: uniqid(), states: [ - { id: uniqid(), name: 'active', userId: uniqid() }, - { id: uniqid(), name: 'archived', userId: uniqid() } + { id: uniqid(), name: ObservationStateName.Active, userId: uniqid() }, + { id: uniqid(), name: ObservationStateName.Archived, userId: uniqid() } ] }, mageEvent) as Observation const obsAfter = Observation.assignTo(obsBefore, { @@ -1530,6 +1543,28 @@ describe('observations use case interactions', function() { }) }) + describe('reading observations', function() { + + describe('finding one by id', function() { + it('has tests', async function() { + expect.fail('todo') + }) + }) + + describe('reading many', function() { + it('has tests', async function() { + + const req: api.ReadObservationsRequest = { + context, + findSpec: { + where: {} + } + } + expect.fail('todo') + }) + }) + }) + describe('saving attachment content', function() { let storeAttachmentContent: api.StoreAttachmentContent diff --git a/service/test/pbkdf2.js b/service/test/pbkdf2.js deleted file mode 100644 index 23e3afcdb..000000000 --- a/service/test/pbkdf2.js +++ /dev/null @@ -1,32 +0,0 @@ -var crypto = require('crypto') - , pbkdf2 = require('../lib/utilities/pbkdf2.js'); - -describe("PBKDF2 tests", function() { - var hasher = new pbkdf2(); - - it("should hash password", function(done) { - hasher.hashPassword("password", function(err, hash) { - hash.should.be.a('string'); - var items = hash.split('::'); - items.should.have.length(4); - items[2].should.equal('256'); - items[3].should.equal('12000'); - - done(err); - }); - }); - - it("should validate password", function(done) { - var hash = [ - crypto.randomBytes(128).toString('base64').slice(0, 128), - crypto.randomBytes(256).toString('base64'), - 256, - 12000, - ].join("::"); - - hasher.validPassword("password", hash, function(err) { - done(err); - }); - }); - -}); diff --git a/service/test/utilities/password-hashing.test.ts b/service/test/utilities/password-hashing.test.ts new file mode 100644 index 000000000..56736aca8 --- /dev/null +++ b/service/test/utilities/password-hashing.test.ts @@ -0,0 +1,32 @@ +import crypto from 'crypto' +import { expect } from 'chai' +import * as PasswordHashing from '../lib/utilities/password-hashing' + +describe('password hashing', function() { + + const hasher = PasswordHashing.defaultHashUtil + + it('should hash password', async function() { + const hashed = await hasher.hashPassword('password') + hashed.should.be.a('string') + const items = hashed.split('::') + items.should.have.length(4) + items[2].should.equal('256') + items[3].should.equal('12000') + }) + + it('should validate password', async function() { + // TODO: what is this testing? + const hash = [ + crypto.randomBytes(128).toString('base64').slice(0, 128), + crypto.randomBytes(256).toString('base64'), + 256, + 12000, + ].join('::') + await hasher.validPassword('password', hash) + }) + + it('has meaningful tests', async function() { + expect.fail('todo') + }) +}) diff --git a/web-app/src/app/admin/admin-authentication/admin-authentication-saml/admin-authentication-saml.component.html b/web-app/src/app/admin/admin-authentication/admin-authentication-saml/admin-authentication-saml.component.html index af132dcfe..c486279f7 100644 --- a/web-app/src/app/admin/admin-authentication/admin-authentication-saml/admin-authentication-saml.component.html +++ b/web-app/src/app/admin/admin-authentication/admin-authentication-saml/admin-authentication-saml.component.html @@ -203,7 +203,7 @@ RAC Comparison - + {{rac.viewValue}} diff --git a/web-app/src/ng1/admin/users/user.edit.component.js b/web-app/src/ng1/admin/users/user.edit.component.js index df386c74c..eb6004e3d 100644 --- a/web-app/src/ng1/admin/users/user.edit.component.js +++ b/web-app/src/ng1/admin/users/user.edit.component.js @@ -56,9 +56,8 @@ class AdminUserEditController { if (this.$stateParams.userId) { this.UserService.getUser(this.$stateParams.userId).then(user => { this.user = angular.copy(user); - this.iconMetadata = { - type: this.user.icon.type, + type: this.user.icon.type || 'upload', text: this.user.icon.text, color: this.user.icon.color }; diff --git a/web-app/src/ng1/factories/authentication-configuration.service.js b/web-app/src/ng1/factories/authentication-configuration.service.js index b915ee65d..878798c86 100644 --- a/web-app/src/ng1/factories/authentication-configuration.service.js +++ b/web-app/src/ng1/factories/authentication-configuration.service.js @@ -6,9 +6,6 @@ function AuthenticationConfigurationService($http, $httpParamSerializer) { return $http.get('/api/authentication/configuration/', { params: options }); } - /** - * TODO: why is this using form encoding instead of straight json? - */ function updateConfiguration(config) { return $http.put('/api/authentication/configuration/' + config._id, config, { headers: { diff --git a/web-app/src/ng1/factories/device-paging.service.js b/web-app/src/ng1/factories/device-paging.service.js index eda141213..598dca99e 100644 --- a/web-app/src/ng1/factories/device-paging.service.js +++ b/web-app/src/ng1/factories/device-paging.service.js @@ -22,21 +22,21 @@ function DevicePagingService(DeviceService, $q) { return { all: { countFilter: {}, - deviceFilter: { limit: 10, sort: { userAgent: 1, _id: 1 } }, + deviceFilter: { limit: 10 }, searchFilter: '', deviceCount: 0, pageInfo: {} }, registered: { countFilter: { registered: true }, - deviceFilter: { limit: 10, sort: { userAgent: 1, _id: 1 }, registered: true }, + deviceFilter: { limit: 10, registered: true }, searchFilter: '', deviceCount: 0, pageInfo: {} }, unregistered: { countFilter: { registered: false }, - deviceFilter: { limit: 10, sort: { userAgent: 1, _id: 1 }, registered: false }, + deviceFilter: { limit: 10, registered: false }, searchFilter: '', deviceCount: 0, pageInfo: {} @@ -153,15 +153,12 @@ function DevicePagingService(DeviceService, $q) { promise = $q.resolve(data.pageInfo.devices); } else { //Perform the server side searching + // TODO: use /next-users/search data.searchFilter = deviceSearch; var filter = data.deviceFilter; if (userSearch == null) { - filter.or = { - userAgent: '.*' + deviceSearch + '.*', - description: '.*' + deviceSearch + '.*', - uid: '.*' + deviceSearch + '.*' - }; + filter.search = deviceSearch } else { filter.or = { displayName: '.*' + userSearch + '.*',