From 9ad2d6883dc1f5aca6c4647621f8d99db62a214b Mon Sep 17 00:00:00 2001 From: "Robert St. John" Date: Wed, 25 Sep 2024 11:55:24 -0600 Subject: [PATCH] refactor(service): users/auth: progress commit: local signup web route and bunch of other related changes --- .../security/devices.security.test.ts | 14 +- .../security/users.security.test.ts | 15 + .../devices/adapters.devices.db.mongoose.ts | 2 +- service/src/app.api/users/app.api.users.ts | 27 ++ .../systemInfo/app.impl.systemInfo.ts | 1 + service/src/app.impl/users/app.impl.users.ts | 124 +++++- service/src/app.ts | 1 + .../ingress.adapters.controllers.web.ts | 136 ++++++ .../ingress/ingress.adapters.db.mongoose.ts | 32 ++ service/src/ingress/ingress.app.api.ts | 34 ++ service/src/ingress/ingress.entities.ts | 62 ++- .../ingress/local-idp.adapters.db.mongoose.ts | 388 +++++++++--------- service/src/ingress/local-idp.app.api.ts | 13 + service/src/ingress/local-idp.entities.ts | 41 +- service/src/ingress/verification.ts | 2 +- .../authentication-configuration.service.js | 3 - 16 files changed, 652 insertions(+), 243 deletions(-) create mode 100644 service/functionalTests/security/users.security.test.ts create mode 100644 service/src/ingress/ingress.adapters.controllers.web.ts create mode 100644 service/src/ingress/ingress.adapters.db.mongoose.ts create mode 100644 service/src/ingress/ingress.app.api.ts create mode 100644 service/src/ingress/local-idp.app.api.ts diff --git a/service/functionalTests/security/devices.security.test.ts b/service/functionalTests/security/devices.security.test.ts index c6f705a6a..dc3684d2b 100644 --- a/service/functionalTests/security/devices.security.test.ts +++ b/service/functionalTests/security/devices.security.test.ts @@ -1,8 +1,8 @@ import { expect } from 'chai' -describe('device operations', function() { +describe('device management security', function() { - describe('removing', function() { + describe('removing a device', function() { it('invalidates associated sessions', async function() { expect.fail('todo') @@ -13,9 +13,12 @@ describe('device operations', function() { }) }) - describe('disabling', function() { + /** + * AKA, set `registered` to `false`. + */ + describe('disabling a device', function() { - it('invalidates existing associated sessions', async function() { + it('invalidates associated sessions', async function() { expect.fail('todo') }) @@ -24,6 +27,9 @@ describe('device operations', function() { }) }) + /** + * AKA, approving; set `registered` to `true`. + */ describe('enabling', function() { it('allows the owning user to authenticate with the device', async function() { 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/src/adapters/devices/adapters.devices.db.mongoose.ts b/service/src/adapters/devices/adapters.devices.db.mongoose.ts index 8f4761dc1..1f22b01ed 100644 --- a/service/src/adapters/devices/adapters.devices.db.mongoose.ts +++ b/service/src/adapters/devices/adapters.devices.db.mongoose.ts @@ -5,7 +5,7 @@ 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; +const Schema = mongoose.Schema export type DeviceDocument = Omit & { _id: mongoose.Types.ObjectId diff --git a/service/src/app.api/users/app.api.users.ts b/service/src/app.api/users/app.api.users.ts index 1f462fd69..4b6fe9fd0 100644 --- a/service/src/app.api/users/app.api.users.ts +++ b/service/src/app.api/users/app.api.users.ts @@ -22,6 +22,33 @@ export type UserSearchResult = Pick, 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 diff --git a/service/src/app.impl/systemInfo/app.impl.systemInfo.ts b/service/src/app.impl/systemInfo/app.impl.systemInfo.ts index 4054701c8..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'; 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 ee6c09ce9..01de1b53c 100644 --- a/service/src/app.ts +++ b/service/src/app.ts @@ -61,6 +61,7 @@ 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"; +// TODO: users-next import AuthenticationConfiguration from "./models/authenticationconfiguration"; import AuthenticationConfigurationTransformer from "./transformers/authenticationconfiguration"; import { SystemInfoRoutes } from './adapters/systemInfo/adapters.systemInfo.controllers.web' 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..f1998c3c6 --- /dev/null +++ b/service/src/ingress/ingress.adapters.controllers.web.ts @@ -0,0 +1,136 @@ +import express from 'express' +import svgCaptcha from 'svg-captcha' +import { Authenticator } from 'passport' +import { Strategy as BearerStrategy } from 'passport-http-bearer' +import { defaultHashUtil } from '../utilities/password-hashing' +import { JWTService, Payload, TokenVerificationError, VerificationErrorReason } from './verification' +import { LocalIdpEnrollment } from './local-idp.entities' +import { invalidInput, InvalidInputError, MageError } from '../app.api/app.api.errors' +import { IdentityProviderHooks } from './ingress.entities' +import { EnrollMyselfOperation, EnrollMyselfRequest } from './ingress.app.api' + + +export type LocalIdpOperations = { + enrollMyself: EnrollMyselfOperation +} + +export function CreateLocalIdpRoutes(localIdpApp: LocalIdpOperations, tokenService: JWTService, passport: Authenticator): express.Router { + + const captchaBearer = new BearerStrategy((token, done) => { + const expectation = { + subject: null, + expiration: null, + assertion: TokenAssertion.Captcha + } + tokenService.verifyToken(token, expectation) + .then(payload => done(null, payload)) + .catch(err => done(err)) + }) + + const routes = express.Router() + + // TODO: signup + // TODO: signin + + // TODO: mount to /auth/local/signin + routes.route('/signin') + .post((req, res, next) => { + + }) + + // TODO: mount to /api/users/signups + routes.route('/signups') + .post(async (req, res, next) => { + try { + const username = typeof req.body.username === 'string' ? req.body.username.trime() : '' + if (!username) { + return res.status(400).send('Invalid signup; username is required.') + } + const background = req.body.background || '#FFFFFF' + const captcha = svgCaptcha.create({ + size: 6, + noise: 4, + color: false, + background: background.toLowerCase() !== '#ffffff' ? background : null + }) + const captchaHash = await defaultHashUtil.hashPassword(captcha.text) + const claims = { captcha: captchaHash } + const verificationToken = await tokenService.generateToken(username, TokenAssertion.Captcha, 60 * 3, claims) + res.json({ + token: verificationToken, + captcha: `data:image/svg+xml;base64,${Buffer.from(captcha.data).toString('base64')}` + }) + } + catch (err) { + next(err) + } + }) + + routes.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.user = captchaTokenPayload + next() + })(req, res, next) + }, + async (req, res, next) => { + try { + const isHuman = await defaultHashUtil.validPassword(req.body.captchaText, req.user.captcha) + if (!isHuman) { + return res.status(403).send('Invalid captcha. Please try again.') + } + const payload = req.user as Payload + const username = payload.subject! + const parsedEnrollment = validateEnrollment(req.body) + if (parsedEnrollment instanceof MageError) { + return next(parsedEnrollment) + } + const enrollment: EnrollMyselfRequest = { + ...parsedEnrollment, + username + } + const appRes = await localIdpApp.enrollMyself(enrollment) + if (appRes.success) { + return res.json(appRes.success) + } + next(appRes.error) + } + catch (err) { + next(err) + } + } + ) + + return routes +} + +function validateEnrollment(input: any): Omit | InvalidInputError { + const { displayName, email, password, phone } = input + if (!displayName) { + return invalidInput('displayName is required') + } + if (!password) { + 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..b5ca2860f --- /dev/null +++ b/service/src/ingress/ingress.adapters.db.mongoose.ts @@ -0,0 +1,32 @@ +import mongoose from 'mongoose' + +type ObjectId = mongoose.Types.ObjectId +const Schema = mongoose.Schema + +const UserIngressSchema = new Schema( + { + // TODO: type is really not necessary + type: { type: String, required: true }, + id: { type: String, required: false }, + authenticationConfigurationId: { type: Schema.Types.ObjectId, ref: 'AuthenticationConfiguration', required: false } + }, + { + timestamps: { + updatedAt: 'lastUpdated' + }, + toObject: { + transform: DbAuthenticationToObject + } + } +); + +export type UserIdpAccountDocument = { + _id: ObjectId + // TODO: migrate to this foreign key instead of on user records + // userId: ObjectId + createdAt: Date + lastUpdated: Date + // TODO: migrate to identityProviderId + authenticationConfigurationId: ObjectId + idpAccount: Record +} \ No newline at end of file diff --git a/service/src/ingress/ingress.app.api.ts b/service/src/ingress/ingress.app.api.ts new file mode 100644 index 000000000..4fc69ee11 --- /dev/null +++ b/service/src/ingress/ingress.app.api.ts @@ -0,0 +1,34 @@ +import { InvalidInputError, PermissionDeniedError } from '../app.api/app.api.errors' +import { AppRequest, AppResponse } from '../app.api/app.api.global' +import { Avatar, User, UserIcon } from '../entities/users/entities.users' + + +export interface EnrollMyselfRequest { + username: string + password: string + displayName: string + phone?: string | null + email?: string | null +} + +/** + * Create the given account 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> +} diff --git a/service/src/ingress/ingress.entities.ts b/service/src/ingress/ingress.entities.ts index d5e8103bf..1fe653016 100644 --- a/service/src/ingress/ingress.entities.ts +++ b/service/src/ingress/ingress.entities.ts @@ -1,3 +1,4 @@ +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' @@ -36,10 +37,11 @@ export interface AuthenticationProtocol { export type IdentityProviderId = string /** - * An identity provider (IDP) is a service maintains user profiles and that Mage trusts to authenticate user - * credentials via a specific authentication protocol. Mage delegates user authentication to identity providers. - * Within Mage, the identity provider implementation maps the provider's user profile/account attributes to a Mage - * user profile. + * 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 @@ -49,31 +51,61 @@ export interface IdentityProvider { protocolSettings: Record enabled: boolean lastUpdated: Date - enrollmentPolicy: EnrollmentPolicy + userEnrollmentPolicy: UserEnrollmentPolicy deviceEnrollmentPolicy: DeviceEnrollmentPolicy - description?: string | null - textColor?: string | null - buttonColor?: string | null - icon?: Buffer | null + textColor?: string + buttonColor?: string + icon?: Buffer } /** * Enrollment policy defines rules and effects to apply when a new user establishes a Mage account. */ -export interface EnrollmentPolicy { +export interface UserEnrollmentPolicy { + /** + * When true, an administrator must approve and activate new user accounts. + */ + accountApprovalRequired: boolean // TODO: configurable role assignment // assignRole: string assignToTeams: TeamId[] assignToEvents: MageEventId[] - requireAccountApproval: boolean - } export interface DeviceEnrollmentPolicy { - requireDeviceApproval: boolean + /** + * 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 + +export interface IdentityProviderHooks { + /** + * Indicate that a user has authenticated with the given identity provider and Mage can continue enrollment and/or + * establish a session for the user. + */ + admitUserFromIdentityProvider(account: IdentityProviderUser, idp: IdentityProvider): unknown + /** + * Indicate the given user has ended their session and logged out of the given identity provider, or the user has + * revoked access for Mage to use the IDP for authentication. + */ + terminateSessionsForUser(username: string, idp: IdentityProvider): unknown + accountDisabled(username: string, idp: IdentityProvider): unknown + accountEnabled(username: string, idp: IdentityProvider): unknown } export interface IdentityProviderRepository { - findById(id: IdentityProviderId): Promise - findByName(name: string): Promise + 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 } diff --git a/service/src/ingress/local-idp.adapters.db.mongoose.ts b/service/src/ingress/local-idp.adapters.db.mongoose.ts index 5fcd38f2b..0ec11ef42 100644 --- a/service/src/ingress/local-idp.adapters.db.mongoose.ts +++ b/service/src/ingress/local-idp.adapters.db.mongoose.ts @@ -1,47 +1,27 @@ "use strict"; import mongoose from 'mongoose' -import { LocalIdpAccount, LocalIdpRepository } from './local-idp.entities' - -const async = require('async') -const hasher = require('../utilities/pbkdf2')() -const User = require('./user') -const Token = require('./token') -// TODO: users-next -const PasswordValidator = require('../utilities/passwordValidator'); - -const Schema = mongoose.Schema; - -const AuthenticationSchema = new Schema( - { - // TODO: type is really not necessary - 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; +import { IdentityProviderModel } from './identity-providers.adapters.db.mongoose' +import { DuplicateUsernameError, 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[] } -const LocalSchema = new Schema( +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: { @@ -52,192 +32,206 @@ const LocalSchema = new Schema( } }, { - toObject: { - transform: DbLocalAuthenticationToObject + 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; -} +// 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({}); +// 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); -}); +// 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(); - }); -}); +// 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(); +// 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 } } +} - 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); - }); -}); - -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 }); +// 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 } -}; - -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 (hasKey('createdAt') && entity.createdAt) { + doc.createdAt = new Date(entity.createdAt) } - - if (authentication.type === 'local') { - document.password = authentication.password; - document.security ={ - lockedUntil: null - } + if (hasKey('lastUpdated') && entity.lastUpdated) { + doc.lastUpdated = new Date(entity.lastUpdated) } - - return Authentication.create(document); -}; - -exports.updateAuthentication = function (authentication) { - return authentication.save(); -}; - -exports.removeAuthenticationById = function (authenticationId, done) { - Authentication.findByIdAndRemove(authenticationId, done); -}; + 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 +} export class LocalIdpMongooseRepository implements LocalIdpRepository { - constructor(private UserModel: UserModel, private AuthenticationModel: AuthenticationModel) { + constructor(private LocalIdpAccountModel: LocalIdpAccountModel) {} + async createLocalAccount(account: LocalIdpAccount): Promise { + const doc = documentForEntity(account) + const created = await this.LocalIdpAccountModel.create(doc) + return entityForDocument(created) } - createLocalAccount(account: LocalIdpAccount): Promise { - throw new Error('Method not implemented.') - } - - async readLocalAccount(id: LocalIdpAccountId): Promise { - const dbId = new mongoose.Types.ObjectId(id) - const userModelInstance = await this.UserModel.findOne({ username }) - .populate({ path: 'authenticationId', populate: 'authenticationConfigurationId' }) - if (!userModelInstance) { - return null + async readLocalAccount(username: string): Promise { + const doc = await this.LocalIdpAccountModel.findById(username, null, { lean: true }) + if (doc) { + return entityForDocument(doc) } - return userModelInstance.authenticationId + return null } updateLocalAccount(update: Partial & Pick): Promise { throw new Error('Method not implemented.') } - deleteLocalAccount(id: string): Promise { + deleteLocalAccount(username: string): Promise { throw new Error('Method not implemented.') } } \ No newline at end of file diff --git a/service/src/ingress/local-idp.app.api.ts b/service/src/ingress/local-idp.app.api.ts new file mode 100644 index 000000000..8a0420ded --- /dev/null +++ b/service/src/ingress/local-idp.app.api.ts @@ -0,0 +1,13 @@ +import { EntityNotFoundError, InvalidInputError } from '../app.api/app.api.errors' +import { AppRequest, AppResponse } from '../app.api/app.api.global' +import { LocalIdpAccount } from './local-idp.entities' + + +export interface LocalIdpAuthenticateRequest extends AppRequest { + username: string + password: string +} + +export interface LocalIdpAuthenticateOperation { + (req: LocalIdpAuthenticateRequest): Promise> +} \ No newline at end of file diff --git a/service/src/ingress/local-idp.entities.ts b/service/src/ingress/local-idp.entities.ts index 4c134057b..801d9abda 100644 --- a/service/src/ingress/local-idp.entities.ts +++ b/service/src/ingress/local-idp.entities.ts @@ -1,9 +1,10 @@ import { PasswordRequirements } from '../utilities/password-policy' - -export type LocalIdpAccountId = string +import { IdentityProvider } from './ingress.entities' export interface LocalIdpAccount { - id: LocalIdpAccountId + username: string + createdAt: Date + lastUpdated: Date hashedPassword: string previousHashedPasswords: string[] security: { @@ -17,9 +18,6 @@ export interface LocalIdpAccount { export interface LocalIdpEnrollment { username: string password: string - displayName: string - email?: string - phone?: string } export interface AccountLockPolicy { @@ -44,10 +42,29 @@ export interface SecurityPolicy { } export interface LocalIdpRepository { - readSecurityPolicy(): Promise - updateSecurityPolicy(policy: SecurityPolicy): Promise - createLocalAccount(account: LocalIdpAccount): Promise - readLocalAccount(id: LocalIdpAccountId): Promise - updateLocalAccount(update: Partial & Pick): Promise - deleteLocalAccount(id: LocalIdpAccountId): Promise + // readSecurityPolicy(): Promise + // updateSecurityPolicy(policy: SecurityPolicy): Promise + createLocalAccount(account: LocalIdpAccount): Promise + readLocalAccount(username: string): Promise + updateLocalAccount(update: Partial & Pick): Promise + deleteLocalAccount(username: string): Promise +} + +export function localIdpSecurityPolicyFromIdenityProvider(localIdp: IdentityProvider): SecurityPolicy { + const settings = localIdp.protocolSettings + return { + accountLock: { ...settings.accountLock }, + passwordRequirements: { ...settings.passwordPolicy } + } +} + +export class LocalIdpError extends Error { + +} + +export class DuplicateUsernameError extends LocalIdpError { + + constructor(public username: string) { + super(`duplicate account username: ${username}`) + } } \ No newline at end of file diff --git a/service/src/ingress/verification.ts b/service/src/ingress/verification.ts index 63175a786..3010b269f 100644 --- a/service/src/ingress/verification.ts +++ b/service/src/ingress/verification.ts @@ -30,7 +30,7 @@ export class TokenGenerateError extends Error { } } -class TokenVerificationError extends Error { +export class TokenVerificationError extends Error { /** * @param reason why the verification failed 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: {