diff --git a/api/.env.dist b/api/.env.dist index 37e8e8f59..9f119ce1c 100644 --- a/api/.env.dist +++ b/api/.env.dist @@ -9,6 +9,7 @@ PROFILE_NAME=local-dev # Public name and short description for this instance CONDUCTOR_INSTANCE_NAME="Development FAIMS Server" CONDUCTOR_DESCRIPTION="Development server on localhost" +CONDUCTOR_SHORT_CODE_PREFIX="DEV" # couchdb configuration COUCHDB_USER=admin diff --git a/api/public/swagger.json b/api/public/swagger.json index 4fc46635e..559d715b3 100644 --- a/api/public/swagger.json +++ b/api/public/swagger.json @@ -41,11 +41,15 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } } } } @@ -56,6 +60,26 @@ } } }, + "/info": { + "get": { + "summary": "Get information", + "description": "Provides details of the server such as name and description", + "produces": ["application/json"], + "security": {"Auth": []}, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/definitions/ListingsObject" + } + } + } + } + } + } + }, "/notebooks/": { "get": { "summary": "Get a list of notebooks", @@ -66,10 +90,14 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/NotebookMeta", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/NotebookMeta", + } + } } } }, @@ -116,8 +144,12 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "$ref": "#/definitions/Notebook" + "content": { + "application/json": { + "schema": { + "$ref": "#/definitions/Notebook" + } + } } }, "401": { @@ -154,8 +186,12 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "$ref": "#/definitions/Notebook" + "content": { + "application/json": { + "schema": { + "$ref": "#/definitions/Notebook" + } + } } }, "401": { @@ -183,10 +219,14 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/RecordList", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/RecordList", + } + } } } }, @@ -221,10 +261,7 @@ "security": {"Auth": []}, "responses": { "200": { - "description": "successful operation", - "schema": { - "type": "string" - } + "description": "successful operation" }, "401": { "$ref": "#/components/responses/UnauthorizedError" @@ -288,10 +325,14 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/UserList", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/UserList", + } + } } } }, @@ -379,8 +420,12 @@ "responses": { "200": { "description": "successful operation", - "schema": { - "$ref": "#/definitions/UserList" + "content": { + "application/json": { + "schema": { + "$ref": "#/definitions/UserList" + } + } } }, "401": { @@ -391,6 +436,26 @@ } }, "definitions": { + "ListingsObject": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "conductor_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "prefix": { + "type": "string" + } + } + }, "NotebookMeta": { "type": "object", "properties": { diff --git a/api/src/api/routes.ts b/api/src/api/routes.ts index e4bb880f2..fa8d4fd4b 100644 --- a/api/src/api/routes.ts +++ b/api/src/api/routes.ts @@ -53,12 +53,13 @@ import { CONDUCTOR_DESCRIPTION, CONDUCTOR_INSTANCE_NAME, CONDUCTOR_PUBLIC_URL, + CONDUCTOR_SHORT_CODE_PREFIX, DEVELOPER_MODE, NOTEBOOK_CREATOR_GROUP_NAME, } from '../buildconfig'; import {createManyRandomRecords} from '../couchdb/devtools'; import {restoreFromBackup} from '../couchdb/backupRestore'; -import {ListingInformation} from '@faims3/data-model'; +import {ListingsObject} from '@faims3/data-model'; // TODO: configure this directory const upload = multer({dest: '/tmp/'}); @@ -83,12 +84,13 @@ api.post('/initialise/', async (req, res) => { /** * Handle info requests, basic identifying information for this server */ -api.get('/info', async (req, res) => { - const info: ListingInformation = { +api.get<{}, ListingsObject>('/info', async (req, res) => { + const info: ListingsObject = { id: slugify(CONDUCTOR_INSTANCE_NAME), name: CONDUCTOR_INSTANCE_NAME, conductor_url: CONDUCTOR_PUBLIC_URL, description: CONDUCTOR_DESCRIPTION, + prefix: CONDUCTOR_SHORT_CODE_PREFIX, }; res.json(info); }); diff --git a/api/src/auth_providers/index.ts b/api/src/auth_providers/index.ts index d11f3f8a2..8e1931143 100644 --- a/api/src/auth_providers/index.ts +++ b/api/src/auth_providers/index.ts @@ -30,6 +30,12 @@ const AVAILABLE_AUTH_PROVIDERS: {[name: string]: any} = { google: google_get_strategy, }; +/** + * Register auth providers using `passport.use`, + * provider identifiers must appear in the AVAILABLE_AUTH_PROVIDERS above + * + * @param providers_to_use array of provider identifiers + */ export function add_auth_providers(providers_to_use: string[]) { for (const provider_name of providers_to_use) { const provider_gen = AVAILABLE_AUTH_PROVIDERS[provider_name]; diff --git a/api/src/auth_routes.ts b/api/src/auth_routes.ts index 76137d298..739a3bd52 100644 --- a/api/src/auth_routes.ts +++ b/api/src/auth_routes.ts @@ -28,6 +28,19 @@ import {registerLocalUser} from './auth_providers/local'; import {body, validationResult} from 'express-validator'; import {getInvite} from './couchdb/invites'; import {acceptInvite} from './registration'; +import {generateUserToken} from './authkeys/create'; +import {NextFunction, Request, Response, Router} from 'express'; + +interface RequestQueryRedirect { + redirect: string; +} +interface PostRegisterRequestBody { + username: string; + password: string; + email: string; + repeat: string; + name: string; +} const AVAILABLE_AUTH_PROVIDER_DISPLAY_INFO: {[name: string]: any} = { google: { @@ -63,9 +76,57 @@ export function determine_callback_urls(provider_name: string): { }; } -export function add_auth_routes(app: any, handlers: any) { - app.get('/auth/', (req: any, res: any) => { - // Allow the user to decide what auth mechanism to use +/** + * Check that a redirect URL is one that we allow + * Mainly want to avoid a non-url redirect. Without a whitlist of + * app URLs we can't really block any but check that it starts with 'http' + * For mobile we redirect to the custom URL scheme for the app + * currently hard-coded as `org.fedarch.faims3` but should be configurable + * + * @param redirect URL to redirect to + * @returns a valid URl to redirect to, default to '/' if + * the one passed in is bad + */ +function validateRedirect(redirect: string) { + if (redirect.startsWith('http')) { + // could match against a whitelist of allowed URLs but for now... + return redirect; + } else if ( + redirect.startsWith('/') || + redirect.startsWith('org.fedarch.faims3://') + ) { + return redirect; + } else { + return '/'; + } +} + +/** + * Add authentication routes for local and federated login + * The list of handlers are the ids of the configured federated handlers (eg. ['google']) + * routes will be set up for each of these for auth and registration + * See `auth_providers/index.ts` for registration of providers. + * + * @param app Express router + * @param handlers an array of login provider identifiers + */ +export function add_auth_routes(app: Router, handlers: string[]) { + // In the following generic type signatures for routes the + // order of types is: + // + // Params: Route parameters + // ResBody: Response body + // ReqBody: Request body + // ReqQuery: Query string parameters + // Locals: Response local variables + + app.get<{}, {}, {}, RequestQueryRedirect>('/auth/', async (req, res) => { + const redirect = validateRedirect(req.query?.redirect || '/'); + + if (req.user) { + await redirect_with_token(res, req.user, redirect); + } + const available_provider_info = []; for (const handler of handlers) { available_provider_info.push({ @@ -77,69 +138,164 @@ export function add_auth_routes(app: any, handlers: any) { providers: available_provider_info, localAuth: true, // maybe make this configurable? messages: req.flash(), + redirect: redirect, }); }); - // handle local login post request - app.post( - '/auth/local', - passport.authenticate('local', { - successRedirect: '/send-token', - failureRedirect: '/auth', - }) + /** + * Define the logout route. Optionally redirect to a given URL + * after logout to account for logout from the app + */ + app.get<{}, {}, {}, RequestQueryRedirect>( + '/logout/', + (req, res, next: any) => { + const redirect = validateRedirect(req.query?.redirect || '/'); + + if (req.user) { + req.logout((err: any) => { + if (err) { + return next(err); + } + }); + } + res.redirect(redirect); + } ); - // accept an invite, auth not required, we invite them to - // register if they aren't already - app.get('/register/:invite_id/', async (req: any, res: any) => { - const invite_id = req.params.invite_id; - req.session['invite'] = invite_id; - const invite = await getInvite(invite_id); - if (!invite) { - res.sendStatus(404); - return; + /** + * Generate a function to handle logging in a user and returning + * a redirect with token + * + * @param req Express request + * @param res Express response + * @param next Express next function + * @param redirect URL to redirect to + * @returns a handler function + */ + const authenticate_return = ( + req: Request>, + res: Response, + next: NextFunction, + redirect: string + ) => { + return (err: any, user: Express.User) => { + if (err) { + return next(err); + } + if (!user) { + req.flash('message', 'Invalid username or password'); + return res.redirect('/auth/?redirect=' + redirect); + } + + req.login(user, async (loginErr: any) => { + if (loginErr) { + return next(loginErr); + } + return redirect_with_token(res, user, redirect); + }); + }; + }; + + /** + * Generate a redirect response with a token for a logged in user + * @param res Express response + * @param user Express user + * @param redirect URL to redirect to + * @returns a redirect response with a suitable token + */ + const redirect_with_token = async ( + res: Response, + user: Express.User, + redirect: string + ) => { + // there is a case where the redirect url will already + // have a token (register >> login >> register) + if (redirect.indexOf('?token=') >= 0) { + return res.redirect(redirect); } - if (req.user) { - // user already registered, sign them up for this notebook - // should there be conditions on this? Eg. check the email. - await acceptInvite(req.user, invite); - req.flash( - 'message', - 'You will now have access to the ${invite.notebook} notebook.' - ); - res.redirect('/'); - } else { - // need to sign up the user, show the registration page - const available_provider_info = []; - for (const handler of CONDUCTOR_AUTH_PROVIDERS) { - available_provider_info.push({ - label: handler, - name: AVAILABLE_AUTH_PROVIDER_DISPLAY_INFO[handler].name, + + // Generate a token + const token = await generateUserToken(user); + + // Append the token to the redirect URL + const redirectUrlWithToken = `${redirect}?token=${token.token}`; + + // Redirect to the app with the token + return res.redirect(redirectUrlWithToken); + }; + + /** + * Handle local login request with username and password + */ + app.post<{}, {}, {}, RequestQueryRedirect>( + '/auth/local', + (req, res, next: NextFunction) => { + const redirect = validateRedirect(req.query?.redirect || '/'); + passport.authenticate( + 'local', + authenticate_return(req, res, next, redirect) + )(req, res, next); + } + ); + + /** + * Register for a notebook using an invite, if no existing account + * then ask them to register. User is authenticated in either case. + * Return a redirect response to the given URL + */ + app.get<{invite_id: string}, {}, {}, RequestQueryRedirect>( + '/register/:invite_id/', + async (req, res) => { + const redirect = validateRedirect(req.query?.redirect || '/'); + const invite_id = req.params.invite_id; + req.session['invite'] = invite_id; + const invite = await getInvite(invite_id); + if (!invite) { + res.render('invite-error', {redirect}); + } else if (req.user) { + // user already registered, sign them up for this notebook + // should there be conditions on this? Eg. check the email. + await acceptInvite(req.user, invite); + redirect_with_token(res, req.user, redirect); + } else { + // need to sign up the user, show the registration page + const available_provider_info = []; + for (const handler of CONDUCTOR_AUTH_PROVIDERS) { + available_provider_info.push({ + label: handler, + name: AVAILABLE_AUTH_PROVIDER_DISPLAY_INFO[handler].name, + }); + } + const encodedRedirect = encodeURIComponent( + `/register/${invite_id}?redirect=${redirect}` + ); + res.render('register', { + invite: invite_id, + loginURL: `/auth?redirect=${encodedRedirect}`, + providers: available_provider_info, + redirect: redirect, + localAuth: true, // maybe make this configurable? + messages: req.flash(), }); } - res.render('register', { - invite: invite_id, - providers: available_provider_info, - localAuth: true, // maybe make this configurable? - messages: req.flash(), - }); } - }); + ); - app.post( + app.post<{}, {}, PostRegisterRequestBody, RequestQueryRedirect>( '/register/local', body('username').trim(), body('password') .isLength({min: 10}) .withMessage('Must be at least 10 characters'), body('email').isEmail().withMessage('Must be a valid email address'), - async (req: any, res: any) => { + async (req: any, res: any, next: any) => { // create a new local account if we have a valid invite const username = req.body.username; const password = req.body.password; const repeat = req.body.repeat; const name = req.body.name; const email = req.body.email; + const redirect = validateRedirect(req.body.redirect || '/'); const errors = validationResult(req); @@ -149,7 +305,9 @@ export function add_auth_routes(app: any, handlers: any) { req.flash('email', email); req.flash('name', name); res.status(400); - res.redirect('/register/' + req.session.invite); + res.redirect( + '/register/' + req.session.invite + `?redirect=${redirect}` + ); return; } @@ -169,14 +327,21 @@ export function add_auth_routes(app: any, handlers: any) { if (user) { await acceptInvite(user, invite); req.flash('message', 'Registration successful. Please login below.'); - res.redirect('/'); + req.login(user, (err: any) => { + if (err) { + return next(err); + } + return redirect_with_token(res, user, redirect); + }); } else { req.flash('error', {registration: error}); req.flash('username', username); req.flash('email', email); req.flash('name', name); res.status(400); - res.redirect('/register/' + req.session.invite); + res.redirect( + '/register/' + req.session.invite + `?redirect=${redirect}` + ); } } else { req.flash('error', {repeat: {msg: "Password and repeat don't match."}}); @@ -184,29 +349,27 @@ export function add_auth_routes(app: any, handlers: any) { req.flash('email', email); req.flash('name', name); res.status(400); - res.redirect('/register/' + req.session.invite); + res.redirect( + '/register/' + req.session.invite + `?redirect=${redirect}` + ); } } ); // set up handlers for OAuth providers for (const handler of handlers) { - app.get(`/auth/${handler}/`, (req: any, res: any) => { + app.get(`/auth/${handler}/`, (req: any, res: any, next: any) => { + const redirect = validateRedirect(req.query?.redirect || '/'); if ( typeof req.query?.state === 'string' || typeof req.query?.state === 'undefined' ) { - passport.authenticate(handler + '-validate', HANDLER_OPTIONS[handler])( - req, - res, - (err?: {}) => { - // Hack to avoid users getting caught when they're not in the right - // groups. - console.error('Authentication Error', err); - // res.redirect('https://auth.datacentral.org.au/cas/logout'); - //throw err ?? Error('Authentication failed (next, no error)'); - } - ); + passport.authenticate( + handler + '-validate', + authenticate_return(req, res, next, redirect) + // HANDLER_OPTIONS[handler] + )(req, res, next); + console.log('TODO: may need to insert:', HANDLER_OPTIONS[handler]); } else { throw Error( `state must be a string, or not set, not ${typeof req.query?.state}` diff --git a/api/src/authkeys/create.ts b/api/src/authkeys/create.ts index 6dee0a87d..3d0d06ab7 100644 --- a/api/src/authkeys/create.ts +++ b/api/src/authkeys/create.ts @@ -21,6 +21,7 @@ import {SignJWT} from 'jose'; import type {SigningKey} from '../services/keyService'; +import {CONDUCTOR_PUBLIC_URL, KEY_SERVICE} from '../buildconfig'; export async function createAuthKey( user: Express.User, @@ -29,6 +30,7 @@ export async function createAuthKey( const jwt = await new SignJWT({ '_couchdb.roles': user.roles ?? [], name: user.name, + server: CONDUCTOR_PUBLIC_URL, }) .setProtectedHeader({ alg: signingKey.alg, @@ -42,3 +44,18 @@ export async function createAuthKey( .sign(signingKey.privateKey); return jwt; } + +export async function generateUserToken(user: Express.User) { + const signingKey = await KEY_SERVICE.getSigningKey(); + if (signingKey === null || signingKey === undefined) { + throw new Error('No signing key is available, check configuration'); + } else { + const token = await createAuthKey(user, signingKey); + + return { + token: token, + pubkey: signingKey.publicKeyString, + pubalg: signingKey.alg, + }; + } +} diff --git a/api/src/buildconfig.ts b/api/src/buildconfig.ts index 6397f024d..fdc16e23a 100644 --- a/api/src/buildconfig.ts +++ b/api/src/buildconfig.ts @@ -191,6 +191,18 @@ function instance_name(): string { } } +function short_code_prefix(): string { + const prefix = process.env.CONDUCTOR_SHORT_CODE_PREFIX; + if (prefix === '' || prefix === undefined) { + console.log( + 'CONDUCTOR_SHORT_CODE_PREFIX not set, using "FAIMS" as default' + ); + return 'FAIMS'; + } else { + return prefix; + } +} + function instance_description(): string { const name = process.env.CONDUCTOR_DESCRIPTION; if (name === '' || name === undefined) { @@ -288,6 +300,7 @@ export const CONDUCTOR_KEY_ID = signing_key_id(); export const CONDUCTOR_PRIVATE_KEY_PATH = private_key_path(); export const CONDUCTOR_PUBLIC_KEY_PATH = public_key_path(); export const CONDUCTOR_INSTANCE_NAME = instance_name(); +export const CONDUCTOR_SHORT_CODE_PREFIX = short_code_prefix(); export const CONDUCTOR_DESCRIPTION = instance_description(); export const COOKIE_SECRET = cookie_secret(); export const GOOGLE_CLIENT_ID = google_client_id(); diff --git a/api/src/core.ts b/api/src/core.ts index dde882959..4f1fbd673 100644 --- a/api/src/core.ts +++ b/api/src/core.ts @@ -78,13 +78,13 @@ app.use( // fix for bug in passport 0.7.0 and compatibility with cookie-session app.use((request, response, next) => { if (request.session && !request.session.regenerate) { - request.session.regenerate = cb => { + request.session.regenerate = (cb: any) => { if (cb) cb(''); return request.session; }; } if (request.session && !request.session.save) { - request.session.save = cb => { + request.session.save = (cb: any) => { if (cb) cb(''); return request.session; }; diff --git a/api/src/couchdb/invites.ts b/api/src/couchdb/invites.ts index 47e0b580e..0fe6712aa 100644 --- a/api/src/couchdb/invites.ts +++ b/api/src/couchdb/invites.ts @@ -21,30 +21,74 @@ import {NonUniqueProjectID, ProjectID} from '@faims3/data-model'; import {getInvitesDB} from '.'; import {ConductorRole, RoleInvite} from '../datamodel/users'; -import {v4 as uuidv4} from 'uuid'; +import {CONDUCTOR_SHORT_CODE_PREFIX} from '../buildconfig'; +/** + * Create an invite for this project and role if there isn't already + * one. If it already exists, return it. + * @param project_id Project identifier + * @param role Project role + * @returns A RoleInvite object + */ export async function createInvite( - user: Express.User, project_id: NonUniqueProjectID, - role: ConductorRole, - number: number + role: ConductorRole ) { - const invite: RoleInvite = { - _id: uuidv4(), - requesting_user: user.user_id, - project_id: project_id, - role: role, - number: number, - unlimited: number === 0, - }; - await saveInvite(invite); - return invite; + // if there is already an invite for this role, + // just return that + const allInvites = await getInvitesForNotebook(project_id); + const existing = allInvites.filter( + i => i.project_id === project_id && i.role === role + ); + + if (existing.length === 0) { + // make a new one + const invite: RoleInvite = { + _id: generateId(), + project_id: project_id, + role: role, + }; + return await saveInvite(invite); + } else { + return existing[0]; + } } +/** + * Generate a short code identifier suitable for an invite, may not + * be unique. + * @returns a six character identifier + */ +function generateId() { + const INVITE_LENGTH = 6; + const chars = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'; + + let ident = ''; + for (let i = 0; i < INVITE_LENGTH; i++) { + const char = chars[Math.floor(Math.random() * chars.length)]; + ident = ident + char; + } + return CONDUCTOR_SHORT_CODE_PREFIX + '-' + ident; +} + +/** + * Store an invite, ensure that the identifier is unique + * @param invite An invite object + * @returns The invite, possibly with a new identifier + */ export async function saveInvite(invite: RoleInvite) { const invite_db = getInvitesDB(); if (invite_db) { - await invite_db.put(invite); + let done = false; + while (!done) { + try { + await invite_db.put(invite); + done = true; + } catch { + invite._id = generateId(); + } + } + return invite; } else { throw Error('Unable to connect to invites database'); } diff --git a/api/src/datamodel/users.ts b/api/src/datamodel/users.ts index 6b86001b2..de69de48e 100644 --- a/api/src/datamodel/users.ts +++ b/api/src/datamodel/users.ts @@ -66,9 +66,6 @@ export interface RoleInvite { _id: string; _rev?: string; _deleted?: boolean; - requesting_user: string; - unlimited?: boolean; project_id: NonUniqueProjectID; role: ConductorRole; - number: number; } diff --git a/api/src/registration.ts b/api/src/registration.ts index 245e8133b..043cf75e7 100644 --- a/api/src/registration.ts +++ b/api/src/registration.ts @@ -20,7 +20,6 @@ import {RoleInvite, ConductorRole} from './datamodel/users'; import {addProjectRoleToUser, saveUser} from './couchdb/users'; -import {saveInvite, deleteInvite} from './couchdb/invites'; import {CLUSTER_ADMIN_GROUP_NAME} from './buildconfig'; export function userCanAddOtherRole(user: Express.User | undefined): boolean { @@ -52,15 +51,6 @@ export function userCanRemoveOtherRole( export async function acceptInvite(user: Express.User, invite: RoleInvite) { addProjectRoleToUser(user, invite.project_id, invite.role); await saveUser(user); - - if (!invite.unlimited) { - invite.number--; - if (invite.number === 0) { - await deleteInvite(invite); - } else { - await saveInvite(invite); - } - } } export async function rejectInvite(invite: RoleInvite) { diff --git a/api/src/routes.ts b/api/src/routes.ts index ed8f23fb8..9b06e8562 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -55,8 +55,7 @@ import { getNotebooks, getRolesForNotebook, } from './couchdb/notebooks'; -import {createAuthKey} from './authkeys/create'; -import {getPublicUserDbURL} from './couchdb'; +import {generateUserToken} from './authkeys/create'; import {add_auth_providers} from './auth_providers'; import {add_auth_routes} from './auth_routes'; @@ -84,7 +83,6 @@ app.get('/notebooks/:id/invite/', requireAuthentication, async (req, res) => { app.post( '/notebooks/:id/invite/', requireAuthentication, - body('number').not().isEmpty(), body('role').not().isEmpty(), async (req, res) => { const errors = validationResult(req); @@ -93,7 +91,6 @@ app.post( } const project_id: NonUniqueProjectID = req.params.id; const role: string = req.body.role; - const number: number = parseInt(req.body.number); if (!userHasPermission(req.user, project_id, 'modify')) { res.render('invite-error', { @@ -106,7 +103,7 @@ app.post( ], }); } else { - await createInvite(req.user as Express.User, project_id, role, number); + await createInvite(project_id, role); res.redirect('/notebooks/' + project_id); } } @@ -199,13 +196,7 @@ app.get('/', async (req, res) => { const provider = Object.keys(req.user.profiles)[0]; // BBS 20221101 Adding token to here so we can support copy from conductor const signingKey = await KEY_SERVICE.getSigningKey(); - const jwt_token = await createAuthKey(req.user, signingKey); - const token = { - jwt_token: jwt_token, - public_key: signingKey.publicKeyString, - alg: signingKey.alg, - userdb: getPublicUserDbURL(), // query: is this actually needed? - }; + const token = generateUserToken(req.user); if (signingKey === null || signingKey === undefined) { res.status(500).send('Signing key not set up'); } else { @@ -225,17 +216,6 @@ app.get('/', async (req, res) => { } }); -app.get('/logout/', (req, res, next) => { - if (req.user) { - req.logout(err => { - if (err) { - return next(err); - } - }); - } - res.redirect('/'); -}); - app.get('/send-token/', (req, res) => { if (req.user) { res.render('send-token', { @@ -251,17 +231,11 @@ app.get('/send-token/', (req, res) => { app.get('/get-token/', async (req, res) => { if (req.user) { - const signingKey = await KEY_SERVICE.getSigningKey(); - if (signingKey === null || signingKey === undefined) { + try { + const token = await generateUserToken(req.user); + res.send(token); + } catch { res.status(500).send('Signing key not set up'); - } else { - const token = await createAuthKey(req.user, signingKey); - - res.send({ - token: token, - pubkey: signingKey.publicKeyString, - pubalg: signingKey.alg, - }); } } else { res.status(403).end(); diff --git a/api/src/types/express.d.ts b/api/src/types/express.d.ts new file mode 100644 index 000000000..a95227d7a --- /dev/null +++ b/api/src/types/express.d.ts @@ -0,0 +1,13 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import {Request} from 'express'; + +declare global { + namespace Express { + interface Request { + flash(type: string, message?: any): any; + flash(type: string): any[]; + flash(): {[key: string]: any[]}; + session: any; + } + } +} diff --git a/api/test/api.test.ts b/api/test/api.test.ts index 4558db0ad..42697ef86 100644 --- a/api/test/api.test.ts +++ b/api/test/api.test.ts @@ -44,6 +44,7 @@ import { CONDUCTOR_DESCRIPTION, CONDUCTOR_INSTANCE_NAME, CONDUCTOR_PUBLIC_URL, + CONDUCTOR_SHORT_CODE_PREFIX, DEVELOPER_MODE, KEY_SERVICE, NOTEBOOK_CREATOR_GROUP_NAME, @@ -109,6 +110,7 @@ describe('API tests', () => { expect(response.body.name).to.equal(CONDUCTOR_INSTANCE_NAME); expect(response.body.description).to.equal(CONDUCTOR_DESCRIPTION); expect(response.body.conductor_url).to.equal(CONDUCTOR_PUBLIC_URL); + expect(response.body.prefix).to.equal(CONDUCTOR_SHORT_CODE_PREFIX); }); }); diff --git a/api/test/invites.test.ts b/api/test/invites.test.ts index ed7d58ba4..78367b981 100644 --- a/api/test/invites.test.ts +++ b/api/test/invites.test.ts @@ -21,7 +21,6 @@ import {ProjectUIModel} from '@faims3/data-model'; import PouchDB from 'pouchdb'; import {createNotebook} from '../src/couchdb/notebooks'; -import {getUserFromEmailOrUsername} from '../src/couchdb/users'; import { createInvite, deleteInvite, @@ -45,20 +44,17 @@ describe('Invites', () => { beforeEach(initialiseDatabases); it('create invite', async () => { - const adminUser = await getUserFromEmailOrUsername('admin'); const project_id = await createNotebook('Test Notebook', uispec, {}); const role = 'user'; - const number = 10; - if (adminUser && project_id) { - const invite = await createInvite(adminUser, project_id, role, number); + if (project_id) { + const invite = await createInvite(project_id, role); // check that it was saved - fetch from db const fetched = await getInvite(invite._id); if (fetched) { expect(fetched.project_id).to.equal(project_id); - expect(fetched.number).to.equal(number); // get invites for notebook const invites = await getInvitesForNotebook(project_id); @@ -71,30 +67,22 @@ describe('Invites', () => { assert.fail('could not retrieve newly created invite'); } } else { - assert.fail('could not get admin user'); + assert.fail('could not create notebook'); } }); - it('create unlimited invite', async () => { - const adminUser = await getUserFromEmailOrUsername('admin'); + it('will not duplicate an invite', async () => { const project_id = await createNotebook('Test Notebook', uispec, {}); const role = 'user'; - const number = 0; - if (adminUser && project_id) { - const invite = await createInvite(adminUser, project_id, role, number); + if (project_id) { + const invite1 = await createInvite(project_id, role); + const invite2 = await createInvite(project_id, role); // check that it was saved - fetch from db - const fetched = await getInvite(invite._id); - - if (fetched) { - expect(fetched.project_id).to.equal(project_id); - expect(fetched.unlimited).to.be.true; - } else { - assert.fail('could not retrieve newly created invite'); - } + expect(invite1._id).to.equal(invite2._id); } else { - assert.fail('could not get admin user'); + assert.fail('could not create notebook'); } }); }); diff --git a/api/tsconfig.json b/api/tsconfig.json index 09b441c1b..f053e53b7 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -16,6 +16,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": false, + "typeRoots": ["../node_modules/@types", "./node_modules/@types", "./src/types"], }, "ts-node": { "files": true diff --git a/api/views/auth.handlebars b/api/views/auth.handlebars index dd96a5acc..c819a3f68 100644 --- a/api/views/auth.handlebars +++ b/api/views/auth.handlebars @@ -9,11 +9,11 @@

Sign In

Please select an authentication provider

- +
{{#each providers}} - {{ this.name }} {{/each}} @@ -26,14 +26,14 @@ {{#if localAuth}}

Local Login

- +
- +
- +
diff --git a/api/views/invite-error.handlebars b/api/views/invite-error.handlebars index d3307daee..f7de266e1 100644 --- a/api/views/invite-error.handlebars +++ b/api/views/invite-error.handlebars @@ -1,6 +1,6 @@ -
The following errors were found with your invitation:
- +

Registration Error

+ +

The invite code or URL you used is not valid on this server

+ + +Return to the app. diff --git a/api/views/invite.handlebars b/api/views/invite.handlebars index 2294456af..8b955c982 100644 --- a/api/views/invite.handlebars +++ b/api/views/invite.handlebars @@ -23,20 +23,8 @@ Create an invitation for users to sign up to this notebook. This will generate an invite URL and QR code that can be used to register. -
-
- - -
How many users should be able to - use this invite. Set to zero (0) for an unlimited number. -
-
-