Skip to content

Commit

Permalink
refactor(service): users/auth: progress commit: local signup web rout…
Browse files Browse the repository at this point in the history
…e and bunch of other related changes
  • Loading branch information
restjohn committed Sep 25, 2024
1 parent 422e6d5 commit 9ad2d68
Show file tree
Hide file tree
Showing 16 changed files with 652 additions and 243 deletions.
14 changes: 10 additions & 4 deletions service/functionalTests/security/devices.security.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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')
})

Expand All @@ -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() {
Expand Down
15 changes: 15 additions & 0 deletions service/functionalTests/security/users.security.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<Device, 'id' | 'userId'> & {
_id: mongoose.Types.ObjectId
Expand Down
27 changes: 27 additions & 0 deletions service/src/app.api/users/app.api.users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ export type UserSearchResult = Pick<User, 'id' | 'username' | 'displayName' | 'e
export interface SearchUsers {
(req: UserSearchRequest): Promise<AppResponse<PageOf<UserSearchResult>, PermissionDeniedError>>
}
export interface ReadMyAccountRequest extends AppRequest<User> {}

export interface ReadMyAccountOperation {
(req: ReadMyAccountRequest): Promise<AppResponse<User, PermissionDeniedError>>
}

export interface UpdateMyAccountRequest extends AppRequest<User> {}

export interface UpdateMyAccountOperation {
(req: UpdateMyAccountRequest): Promise<AppResponse<UserExpanded, PermissionDeniedError | InvalidInputError>>
}

export interface DisableUserRequest extends AppRequest {
userId: UserId
}

export interface DisableUserOperation {
(req: DisableUserOperation): Promise<AppResponse<void, PermissionDeniedError | EntityNotFoundError>>
}

export interface RemoveUserRequest extends AppRequest {
userId: UserId
}

export interface RemoveUserOperation {
(req: RemoveUserRequest): Promise<AppResponse<UserExpanded, PermissionDeniedError | EntityNotFoundError>>
}

export interface UsersPermissionService {
ensureReadUsersPermission(context: AppRequestContext): Promise<null | PermissionDeniedError>
Expand Down
1 change: 1 addition & 0 deletions service/src/app.impl/systemInfo/app.impl.systemInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
124 changes: 114 additions & 10 deletions service/src/app.impl/users/app.impl.users.ts
Original file line number Diff line number Diff line change
@@ -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<api.CreateUserOperation> {
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<api.UpdateUserOperation> {

// }
// }

export function SearchUsers(userRepo: UserRepository,permissions: api.UsersPermissionService): api.SearchUsers {
return async function searchUsers(req: api.UserSearchRequest): ReturnType<api.SearchUsers> {
return await withPermission<
PageOf<api.UserSearchResult>,
Expand All @@ -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
}
);
};
)
}
}
1 change: 1 addition & 0 deletions service/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
136 changes: 136 additions & 0 deletions service/src/ingress/ingress.adapters.controllers.web.ts
Original file line number Diff line number Diff line change
@@ -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<EnrollMyselfRequest, 'username'> | InvalidInputError {
const { displayName, email, password, phone } = input
if (!displayName) {
return invalidInput('displayName is required')
}
if (!password) {
return invalidInput('password is required')
}
const enrollment: Omit<EnrollMyselfRequest, 'username'> = { 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
}
Loading

0 comments on commit 9ad2d68

Please sign in to comment.