Skip to content

Commit

Permalink
refactor(service): users/auth: user operations types
Browse files Browse the repository at this point in the history
  • Loading branch information
restjohn committed Dec 1, 2024
1 parent 12e9776 commit 484b3ec
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 26 deletions.
132 changes: 121 additions & 11 deletions service/src/adapters/users/adapters.users.controllers.web.ts
Original file line number Diff line number Diff line change
@@ -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): express.Router {
export function UsersRoutes(app: UsersAppLayer, createAppRequest: WebAppRequestFactory<AppRequest<UserExpanded>>): 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<string, Express.Multer.File[]> || {}
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'] = {
Expand All @@ -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<UserIcon> =
typeof formIconAttrs === 'string' ?
JSON.parse(formIconAttrs) :
formIconAttrs
const files = req.files as Record<string, Express.Multer.File[]> || { 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 }
}
13 changes: 11 additions & 2 deletions service/src/app.api/users/app.api.users.ts
Original file line number Diff line number Diff line change
@@ -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<AppResponse<UserExpanded, PermissionDeniedError | InvalidInputError>>
}

export interface UserSearchRequest extends AppRequest {
userSearch: PagingParameters & {
nameOrContactTerm?: string | undefined,
Expand All @@ -22,6 +30,7 @@ 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 {
Expand Down
87 changes: 74 additions & 13 deletions service/src/ingress/ingress.adapters.controllers.web.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import express from 'express'
import svgCaptcha from 'svg-captcha'
import { Authenticator } from 'passport'
import { Strategy as BearerStrategy } from 'passport-http-bearer'
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 {
Expand Down Expand Up @@ -50,7 +51,30 @@ export type IngressRoutes = {
idpAdmission: express.Router
}

export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: IngressProtocolWebBindingCache, tokenService: JWTService, passport: Authenticator): IngressRoutes {
/**
* 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('/',
Expand Down Expand Up @@ -81,28 +105,29 @@ export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: Ingre
if (admission.error) {
return next(admission.error)
}
const { admissionToken, mageAccount } = admission.success
const { idpAuthenticationToken, mageAccount } = admission.success
if (idpBinding.ingressResponseType === IngressResponseType.Direct) {
return res.json({ user: mageAccount, token: admissionToken })
return res.json({ user: mageAccount, token: idpAuthenticationToken })
}
if (idpAdmission.flowState === UserAgentType.MobileApp) {
if (mageAccount.active && mageAccount.enabled) {
return res.redirect(`mage://app/authentication?token=${admissionToken}`)
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: admissionToken, user: mageAccount } })
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 idpAdmission = express.Router()
idpAdmission.use('/:identityProviderName',
const admission = express.Router()

admission.use('/:identityProviderName',
async (req, res, next) => {
const idpName = req.params.identityProviderName
const idpBindingEntry = await idpCache.idpWebBindingForIdpName(idpName)
Expand All @@ -117,7 +142,43 @@ export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: Ingre
routeToIdp
)

// TODO: mount to /api/users/signups
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) => {
Expand Down Expand Up @@ -146,7 +207,7 @@ export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: Ingre
}
})

const captchaBearer = new BearerStrategy((token, done) => {
const captchaBearer = new bearer.Strategy((token, done) => {
const expectation = {
subject: null,
expiration: null,
Expand All @@ -157,7 +218,7 @@ export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: Ingre
.catch(err => done(err))
})

// TODO: mount to /api/users/signups/verifications
// TODO: users-next: mount to /api/users/signups/verifications
localEnrollment.route('/signups/verifications')
.post(
async (req, res, next) => {
Expand Down Expand Up @@ -208,7 +269,7 @@ export function CreateIngressRoutes(ingressApp: IngressUseCases, idpCache: Ingre
}
)

return { localEnrollment, idpAdmission }
return { localEnrollment, idpAdmission: admission }
}

function validateEnrollment(input: any): Omit<EnrollMyselfRequest, 'username'> | InvalidInputError {
Expand Down

0 comments on commit 484b3ec

Please sign in to comment.