diff --git a/src/lib/db/public-signup-token-store.ts b/src/lib/db/public-signup-token-store.ts index ca4478d127..ea76a45d34 100644 --- a/src/lib/db/public-signup-token-store.ts +++ b/src/lib/db/public-signup-token-store.ts @@ -30,22 +30,32 @@ interface ITokenUserRow { user_id: number; created_at: Date; } + const tokenRowReducer = (acc, tokenRow) => { - const { userId, name, ...token } = tokenRow; + const { userId, userName, userUsername, roleId, roleName, ...token } = + tokenRow; if (!acc[tokenRow.secret]) { acc[tokenRow.secret] = { secret: token.secret, name: token.name, + url: token.url, expiresAt: token.expires_at, createdAt: token.created_at, createdBy: token.created_by, - roleId: token.role_id, + role: { + id: roleId, + name: roleName, + }, users: [], }; } const currentToken = acc[tokenRow.secret]; if (userId) { - currentToken.users.push({ userId, name }); + currentToken.users.push({ + id: userId, + name: userName, + username: userUsername, + }); } return acc; }; @@ -58,6 +68,7 @@ const toRow = (newToken: IPublicSignupTokenCreate) => { expires_at: newToken.expiresAt, created_by: newToken.createdBy || null, role_id: newToken.roleId, + url: newToken.url, }; }; @@ -97,15 +108,19 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore { 'token_project_users.secret', ) .leftJoin(`users`, 'token_project_users.user_id', 'users.id') + .leftJoin(`roles`, 'tokens.role_id', 'roles.id') .select( 'tokens.secret', 'tokens.name', 'tokens.expires_at', 'tokens.created_at', 'tokens.created_by', - 'tokens.role_id', - 'token_project_users.user_id', - 'users.name', + 'tokens.url', + 'token_project_users.user_id as userId', + 'users.name as userName', + 'users.username as userUsername', + 'roles.id as roleId', + 'roles.name as roleName', ); } @@ -137,9 +152,9 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore { ): Promise { const response = await this.db(TABLE).insert( toRow(newToken), - ['created_at'], + ['*'], ); - return toTokens([response])[0]; + return toTokens(response)[0]; } async isValid(secret: string): Promise { @@ -163,14 +178,15 @@ export class PublicSignupTokenStore implements IPublicSignupTokenStore { } async get(key: string): Promise { - const row = await this.makeTokenUsersQuery() - .where('secret', key) - .first(); + const rows = await this.makeTokenUsersQuery().where( + 'tokens.secret', + key, + ); - if (!row) - throw new NotFoundError('Could not find a token with that key'); - - return toTokens([row])[0]; + if (rows.length > 0) { + return toTokens(rows)[0]; + } + throw new NotFoundError('Could not find public signup token.'); } async delete(secret: string): Promise { diff --git a/src/lib/openapi/spec/public-signup-schema.test.ts b/src/lib/openapi/spec/public-signup-schema.test.ts index f7544445b1..0cee5825c4 100644 --- a/src/lib/openapi/spec/public-signup-schema.test.ts +++ b/src/lib/openapi/spec/public-signup-schema.test.ts @@ -5,6 +5,7 @@ test('publicSignupTokenSchema', () => { const data: PublicSignupTokenSchema = { name: 'Default', secret: 'some-secret', + url: 'http://localhost:4242/invite-link/some-secret', expiresAt: new Date().toISOString(), users: [], role: { name: 'Viewer ', type: 'type', id: 1 }, diff --git a/src/lib/openapi/spec/public-signup-token-schema.ts b/src/lib/openapi/spec/public-signup-token-schema.ts index 2e2dc2fee1..e819658ce6 100644 --- a/src/lib/openapi/spec/public-signup-token-schema.ts +++ b/src/lib/openapi/spec/public-signup-token-schema.ts @@ -6,11 +6,22 @@ export const publicSignupTokenSchema = { $id: '#/components/schemas/publicSignupTokenSchema', type: 'object', additionalProperties: false, - required: ['secret', 'name', 'expiresAt', 'createdAt', 'createdBy', 'role'], + required: [ + 'secret', + 'url', + 'name', + 'expiresAt', + 'createdAt', + 'createdBy', + 'role', + ], properties: { secret: { type: 'string', }, + url: { + type: 'string', + }, name: { type: 'string', }, diff --git a/src/lib/routes/admin-api/public-signup.test.ts b/src/lib/routes/admin-api/public-signup.test.ts index eb70149fc9..4d88bb0d85 100644 --- a/src/lib/routes/admin-api/public-signup.test.ts +++ b/src/lib/routes/admin-api/public-signup.test.ts @@ -92,13 +92,14 @@ describe('Public Signup API', () => { }); test('should get All', async () => { - expect.assertions(1); + expect.assertions(2); const appName = '123!23'; stores.clientApplicationsStore.upsert({ appName }); - stores.publicSignupTokenStore.create({ + stores.publicSignupTokenStore.insert({ name: 'some-name', expiresAt: expireAt(), + createdBy: 'johnDoe', }); return request @@ -107,6 +108,7 @@ describe('Public Signup API', () => { .expect((res) => { const { tokens } = res.body; expect(tokens[0].name).toBe('some-name'); + expect(tokens[0].createdBy).toBe('johnDoe'); }); }); diff --git a/src/lib/routes/admin-api/public-signup.ts b/src/lib/routes/admin-api/public-signup.ts index 2e2e36a315..e230cc9fdc 100644 --- a/src/lib/routes/admin-api/public-signup.ts +++ b/src/lib/routes/admin-api/public-signup.ts @@ -13,7 +13,10 @@ import { resourceCreatedResponseSchema, } from '../../openapi/util/create-response-schema'; import { serializeDates } from '../../types/serialize-dates'; -import { emptyResponse } from '../../openapi/util/standard-responses'; +import { + emptyResponse, + getStandardResponses, +} from '../../openapi/util/standard-responses'; import { PublicSignupTokenService } from '../../services/public-signup-token-service'; import UserService from '../../services/user-service'; import { @@ -28,6 +31,7 @@ import { PublicSignupTokenCreateSchema } from '../../openapi/spec/public-signup- import { PublicSignupTokenUpdateSchema } from '../../openapi/spec/public-signup-token-update-schema'; import { CreateUserSchema } from '../../openapi/spec/create-user-schema'; import { UserSchema, userSchema } from '../../openapi/spec/user-schema'; +import { extractUsername } from '../../util/extract-user'; interface TokenParam { token: string; @@ -76,7 +80,7 @@ export class PublicSignupController extends Controller { tags: ['Public signup tokens'], operationId: 'getAllPublicSignupTokens', responses: { - 200: createResponseSchema('publicSignupTokenSchema'), + 200: createResponseSchema('publicSignupTokensSchema'), }, }), ], @@ -115,6 +119,7 @@ export class PublicSignupController extends Controller { requestBody: createRequestSchema('createUserSchema'), responses: { 200: createResponseSchema('userSchema'), + ...getStandardResponses(409), }, }), ], @@ -195,7 +200,7 @@ export class PublicSignupController extends Controller { req: IAuthRequest, res: Response, ): Promise { - const tokens = await this.publicSignupTokenService.getAllActiveTokens(); + const tokens = await this.publicSignupTokenService.getAllTokens(); this.openApiService.respondWithValidation( 200, res, @@ -237,7 +242,6 @@ export class PublicSignupController extends Controller { token, req.body, ); - this.openApiService.respondWithValidation( 201, res, @@ -250,15 +254,16 @@ export class PublicSignupController extends Controller { req: IAuthRequest, res: Response, ): Promise { + const username = extractUsername(req); const token = await this.publicSignupTokenService.createNewPublicSignupToken( req.body, - req.user.name, + username, ); this.openApiService.respondWithValidation( 201, res, - publicSignupTokensSchema.$id, + publicSignupTokenSchema.$id, serializeDates(token), { location: `tokens/${token.secret}` }, ); @@ -288,8 +293,9 @@ export class PublicSignupController extends Controller { res: Response, ): Promise { const { token } = req.params; + const username = extractUsername(req); - await this.publicSignupTokenService.delete(token, req.user.name); + await this.publicSignupTokenService.delete(token, username); res.status(200).end(); } } diff --git a/src/lib/services/public-signup-token-service.ts b/src/lib/services/public-signup-token-service.ts index ba289fadfc..c1fab5d1a5 100644 --- a/src/lib/services/public-signup-token-service.ts +++ b/src/lib/services/public-signup-token-service.ts @@ -15,6 +15,7 @@ import { } from '../types/events'; import UserService, { ICreateUser } from './user-service'; import { IUser } from '../types/user'; +import { URL } from 'url'; export class PublicSignupTokenService { private store: IPublicSignupTokenStore; @@ -29,6 +30,8 @@ export class PublicSignupTokenService { private timer: NodeJS.Timeout; + private readonly unleashBase: string; + constructor( { publicSignupTokenStore, @@ -38,7 +41,7 @@ export class PublicSignupTokenService { IUnleashStores, 'publicSignupTokenStore' | 'roleStore' | 'eventStore' >, - config: Pick, + config: Pick, userService: UserService, ) { this.store = publicSignupTokenStore; @@ -48,6 +51,13 @@ export class PublicSignupTokenService { this.logger = config.getLogger( '/services/public-signup-token-service.ts', ); + this.unleashBase = config.server.unleashUrl; + } + + private getUrl(secret: string): string { + return new URL( + `${this.unleashBase}/invite-link/${secret}/signup`, + ).toString(); } public async get(secret: string): Promise { @@ -77,11 +87,13 @@ export class PublicSignupTokenService { secret: string, createUser: ICreateUser, ): Promise { + const token = await this.get(secret); + createUser.rootRole = token.role.id; const user = await this.userService.createUser(createUser); await this.store.addTokenUser(secret, user.id); await this.eventStore.store( new PublicSignupTokenUserAddedEvent({ - createdBy: 'userId', + createdBy: 'System', data: { secret, userId: user.id }, }), ); @@ -109,26 +121,29 @@ export class PublicSignupTokenService { createdBy: string, ): Promise { const viewerRole = await this.roleStore.getRoleByName(RoleName.VIEWER); + const secret = this.generateSecretKey(); + const url = this.getUrl(secret); const newToken: IPublicSignupTokenCreate = { name: tokenCreate.name, expiresAt: new Date(tokenCreate.expiresAt), - secret: this.generateSecretKey(), + secret: secret, roleId: viewerRole ? viewerRole.id : -1, createdBy: createdBy, + url: url, }; const token = await this.store.insert(newToken); await this.eventStore.store( new PublicSignupTokenCreatedEvent({ createdBy: createdBy, - data: newToken, + data: token, }), ); return token; } private generateSecretKey(): string { - return crypto.randomBytes(32).toString('hex'); + return crypto.randomBytes(16).toString('hex'); } destroy(): void { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 3f9e0738b4..037aa9fdd9 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -18,6 +18,10 @@ export const defaultExperimentalOptions = { process.env.UNLEASH_EXPERIMENTAL_BATCH_METRICS, false, ), + publicSignup: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_PUBLIC_SIGNUP, + false, + ), }, externalResolver: { isEnabled: (): boolean => false }, }; diff --git a/src/lib/types/models/public-signup-token.ts b/src/lib/types/models/public-signup-token.ts index 4e32cff1aa..755af9a80a 100644 --- a/src/lib/types/models/public-signup-token.ts +++ b/src/lib/types/models/public-signup-token.ts @@ -6,6 +6,7 @@ export interface IPublicSignupTokenCreate { roleId: number; secret: string; createdBy: string; + url: string; } export interface IPublicSignupToken extends IPublicSignupTokenCreate { diff --git a/src/migrations/20220916093515-add-url-to-public-signup-tokens.js b/src/migrations/20220916093515-add-url-to-public-signup-tokens.js new file mode 100644 index 0000000000..439cc650a3 --- /dev/null +++ b/src/migrations/20220916093515-add-url-to-public-signup-tokens.js @@ -0,0 +1,21 @@ +'use strict'; + +exports.up = function (db, callback) { + db.runSql( + ` + ALTER table public_signup_tokens + ADD COLUMN IF NOT EXISTS url text + `, + callback, + ); +}; + +exports.down = function (db, callback) { + db.runSql( + ` + ALTER table public_signup_tokens + DROP COLUMN url + `, + callback, + ); +}; diff --git a/src/test/e2e/api/admin/public-signup-token.e2e.test.ts b/src/test/e2e/api/admin/public-signup-token.e2e.test.ts new file mode 100644 index 0000000000..edddf713a2 --- /dev/null +++ b/src/test/e2e/api/admin/public-signup-token.e2e.test.ts @@ -0,0 +1,223 @@ +import { setupAppWithCustomAuth } from '../../helpers/test-helper'; +import dbInit from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import { RoleName } from '../../../../lib/types/model'; +import { PublicSignupTokenCreateSchema } from '../../../../lib/openapi/spec/public-signup-token-create-schema'; +import { CreateUserSchema } from '../../../../lib/openapi/spec/create-user-schema'; + +let stores; +let db; + +jest.mock('../../../../lib/util/flag-resolver', () => { + return jest.fn().mockImplementation(() => { + return { + getAll: jest.fn(), + isEnabled: jest.fn().mockResolvedValue(true), + }; + }); +}); + +beforeEach(async () => { + db = await dbInit('test', getLogger); + stores = db.stores; +}); + +afterEach(async () => { + await stores.publicSignupTokenStore.deleteAll(); + await stores.eventStore.deleteAll(); + await stores.userStore.deleteAll(); +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +const expireAt = (addDays: number = 7): Date => { + let now = new Date(); + now.setDate(now.getDate() + addDays); + return now; +}; + +test('admin users should be able to create a token', async () => { + expect.assertions(3); + + const preHook = (app, config, { userService, accessService }) => { + app.use('/api/admin/', async (req, res, next) => { + const role = await accessService.getRootRole(RoleName.ADMIN); + const user = await userService.createUser({ + email: 'admin@example.com', + rootRole: role.id, + }); + req.user = user; + next(); + }); + }; + + const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); + + const tokenCreate: PublicSignupTokenCreateSchema = { + name: 'some-name', + expiresAt: expireAt().toISOString(), + }; + + await request + .post('/api/admin/invite-link/tokens') + .send(tokenCreate) + .expect('Content-Type', /json/) + .expect(201) + .expect((res) => { + expect(res.body.name).toBe('some-name'); + expect(res.body.secret).not.toBeNull(); + expect(res.body.url).not.toBeNull(); + }); + + await destroy(); +}); + +test('no permission to validate a token', async () => { + const preHook = (app, config, { userService, accessService }) => { + app.use('/api/admin/', async (req, res, next) => { + const admin = await accessService.getRootRole(RoleName.ADMIN); + await userService.createUser({ + email: 'admin@example.com', + username: 'admin@example.com', + rootRole: admin.id, + }); + next(); + }); + }; + + const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); + + await stores.publicSignupTokenStore.insert({ + name: 'some-name', + expiresAt: expireAt(), + secret: 'some-secret', + createAt: new Date(), + createdBy: 'admin@example.com', + roleId: 3, + }); + await request + .post('/api/admin/invite-link/tokens/some-secret/validate') + .expect(200); + + await destroy(); +}); + +test('should return 401 if token can not be validate', async () => { + const preHook = (app, config, { userService, accessService }) => { + app.use('/api/admin/', async (req, res, next) => { + const admin = await accessService.getRootRole(RoleName.ADMIN); + await userService.createUser({ + email: 'admin@example.com', + username: 'admin@example.com', + rootRole: admin.id, + }); + next(); + }); + }; + + const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); + + await request + .post('/api/admin/invite-link/tokens/some-invalid-secret/validate') + .expect(401); + + await destroy(); +}); + +test('users can signup with invite-link', async () => { + expect.assertions(1); + + const preHook = (app, config, { userService, accessService }) => { + app.use('/api/admin/', async (req, res, next) => { + const admin = await accessService.getRootRole(RoleName.ADMIN); + await userService.createUser({ + email: 'admin@example.com', + username: 'admin@example.com', + rootRole: admin.id, + }); + next(); + }); + }; + + const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); + + await stores.publicSignupTokenStore.insert({ + name: 'some-name', + expiresAt: expireAt(), + secret: 'some-secret', + url: 'http://localhost:4242/invite-lint/some-secret/signup', + createAt: new Date(), + createdBy: 'admin@example.com', + roleId: 3, + }); + + const createUser: CreateUserSchema = { + username: 'some-username', + email: 'some@example.com', + password: 'eweggwEG', + sendEmail: false, + rootRole: 1, + }; + + await request + .post('/api/admin/invite-link/tokens/some-secret/signup') + .send(createUser) + .expect(201) + .expect((res) => { + const user = res.body; + expect(user.username).toBe('some-username'); + }); + + await destroy(); +}); + +test('can get a token with users', async () => { + expect.assertions(1); + + const preHook = (app, config, { userService, accessService }) => { + app.use('/api/admin/', async (req, res, next) => { + const role = await accessService.getRootRole(RoleName.ADMIN); + const user = await userService.createUser({ + email: 'admin@example.com', + rootRole: role.id, + }); + req.user = user; + next(); + }); + }; + + const { request, destroy } = await setupAppWithCustomAuth(stores, preHook); + + await stores.publicSignupTokenStore.insert({ + name: 'some-name', + expiresAt: expireAt(), + secret: 'some-secret', + createAt: new Date(), + createdBy: 'admin@example.com', + roleId: 3, + }); + + const user = await stores.userStore.insert({ + username: 'some-username', + email: 'some@example.com', + password: 'eweggwEG', + sendEmail: false, + rootRole: 3, + }); + + await stores.publicSignupTokenStore.addTokenUser('some-secret', user.id); + + await request + .get('/api/admin/invite-link/tokens/some-secret') + .expect(200) + .expect((res) => { + const token = res.body; + expect(token.users.length).toEqual(1); + }); + + await destroy(); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 4f9a8b7e37..570e740479 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2432,6 +2432,9 @@ exports[`should serve the OpenAPI spec 1`] = ` "secret": { "type": "string", }, + "url": { + "type": "string", + }, "users": { "items": { "$ref": "#/components/schemas/userSchema", @@ -2442,6 +2445,7 @@ exports[`should serve the OpenAPI spec 1`] = ` }, "required": [ "secret", + "url", "name", "expiresAt", "createdAt", @@ -4512,11 +4516,11 @@ If the provided project does not exist, the list of events will be empty.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/publicSignupTokenSchema", + "$ref": "#/components/schemas/publicSignupTokensSchema", }, }, }, - "description": "publicSignupTokenSchema", + "description": "publicSignupTokensSchema", }, }, "tags": [ @@ -4680,6 +4684,9 @@ If the provided project does not exist, the list of events will be empty.", }, "description": "userSchema", }, + "409": { + "description": "The provided resource can not be created or updated because it would conflict with the current state of the resource or with an already existing resource, respectively.", + }, }, "tags": [ "Public signup tokens", diff --git a/src/test/fixtures/fake-public-signup-store.ts b/src/test/fixtures/fake-public-signup-store.ts index 24d61722df..84645d19eb 100644 --- a/src/test/fixtures/fake-public-signup-store.ts +++ b/src/test/fixtures/fake-public-signup-store.ts @@ -47,13 +47,14 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore { expiresAt: newToken.expiresAt.toISOString(), createdAt: new Date().toISOString(), users: [], + url: 'some=url', name: newToken.name, role: { name: 'Viewer', type: '', id: 1, }, - createdBy: null, + createdBy: newToken.createdBy, }; this.tokens.push(token); return Promise.resolve(token); @@ -80,6 +81,6 @@ export default class FakePublicSignupStore implements IPublicSignupTokenStore { // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars async getAll(query?: Object): Promise { - return Promise.resolve([]); + return Promise.resolve(this.tokens); } }