1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-27 00:19:39 +01:00

Personal access token middleware ()

* Middleware first version

* Middleware tests

* Add tests

* Finish middleware tests

* Add type for request

* Add flagresolver

* Fix snapshot

* Update flags and tests

* Put it back as default

* Update snapshot
This commit is contained in:
sjaanus 2022-09-28 15:53:56 +02:00 committed by GitHub
parent 7fbe227e0f
commit d79ace57ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 296 additions and 24 deletions

View File

@ -71,6 +71,7 @@ exports[`should create default config 1`] = `
"batchMetrics": false, "batchMetrics": false,
"embedProxy": false, "embedProxy": false,
"embedProxyFrontend": false, "embedProxyFrontend": false,
"personalAccessTokens": false,
"publicSignup": false, "publicSignup": false,
}, },
}, },
@ -81,6 +82,7 @@ exports[`should create default config 1`] = `
"batchMetrics": false, "batchMetrics": false,
"embedProxy": false, "embedProxy": false,
"embedProxyFrontend": false, "embedProxyFrontend": false,
"personalAccessTokens": false,
"publicSignup": false, "publicSignup": false,
}, },
"externalResolver": { "externalResolver": {

View File

@ -23,6 +23,7 @@ import secureHeaders from './middleware/secure-headers';
import { loadIndexHTML } from './util/load-index-html'; import { loadIndexHTML } from './util/load-index-html';
import { findPublicFolder } from './util/findPublicFolder'; import { findPublicFolder } from './util/findPublicFolder';
import { conditionalMiddleware } from './middleware/conditional-middleware'; import { conditionalMiddleware } from './middleware/conditional-middleware';
import patMiddleware from './middleware/pat-middleware';
export default async function getApp( export default async function getApp(
config: IUnleashConfig, config: IUnleashConfig,
@ -81,6 +82,8 @@ export default async function getApp(
), ),
); );
app.use(baseUriPath, patMiddleware(config, services));
switch (config.authentication.type) { switch (config.authentication.type) {
case IAuthType.OPEN_SOURCE: { case IAuthType.OPEN_SOURCE: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiTokenMiddleware(config, services));

View File

@ -204,6 +204,20 @@ class UserStore implements IUserStore {
const row = await this.db(TABLE).where({ id }).first(); const row = await this.db(TABLE).where({ id }).first();
return rowToUser(row); return rowToUser(row);
} }
async getUserByPersonalAccessToken(secret: string): Promise<User> {
const row = await this.db
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.from(TABLE)
.leftJoin(
'personal_access_tokens',
'personal_access_tokens.user_id',
`${TABLE}.id`,
)
.where('secret', secret)
.first();
return rowToUser(row);
}
} }
module.exports = UserStore; module.exports = UserStore;

View File

@ -159,8 +159,18 @@ test('should not add user if disabled', async () => {
user: undefined, user: undefined,
}; };
await func(req, undefined, cb); const send = jest.fn();
const res = {
status: () => {
return {
send: send,
};
},
};
await func(req, res, cb);
expect(send).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalled(); expect(cb).toHaveBeenCalled();
expect(req.user).toBeFalsy(); expect(req.user).toBeFalsy();
}); });

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ApiTokenType } from '../types/models/api-token'; import { ApiTokenType } from '../types/models/api-token';
import { IUnleashConfig } from '../types/option'; import { IUnleashConfig } from '../types/option';
import { IAuthRequest } from '../routes/unleash-types';
const isClientApi = ({ path }) => { const isClientApi = ({ path }) => {
return path && path.startsWith('/api/client'); return path && path.startsWith('/api/client');
@ -39,27 +40,31 @@ const apiAccessMiddleware = (
return (req, res, next) => next(); return (req, res, next) => next();
} }
return (req, res, next) => { return (req: IAuthRequest, res, next) => {
if (req.user) { if (req.user) {
return next(); return next();
} }
try { try {
const apiToken = req.header('authorization'); const apiToken = req.header('authorization');
const apiUser = apiTokenService.getUserForToken(apiToken); if (!apiToken?.startsWith('user:')) {
const { CLIENT, FRONTEND } = ApiTokenType; const apiUser = apiTokenService.getUserForToken(apiToken);
const { CLIENT, FRONTEND } = ApiTokenType;
if (apiUser) { if (apiUser) {
if ( if (
(apiUser.type === CLIENT && !isClientApi(req)) || (apiUser.type === CLIENT && !isClientApi(req)) ||
(apiUser.type === FRONTEND && !isProxyApi(req)) || (apiUser.type === FRONTEND && !isProxyApi(req)) ||
(apiUser.type === FRONTEND && (apiUser.type === FRONTEND &&
!flagResolver.isEnabled('embedProxy')) !flagResolver.isEnabled('embedProxy'))
) { ) {
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE }); res.status(403).send({
return; message: TOKEN_TYPE_ERROR_MESSAGE,
});
return;
}
req.user = apiUser;
} }
req.user = apiUser;
} }
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View File

@ -0,0 +1,140 @@
import getLogger from '../../test/fixtures/no-logger';
import { createTestConfig } from '../../test/config/test-config';
import patMiddleware from './pat-middleware';
import User from '../types/user';
let config: any;
beforeEach(() => {
config = {
getLogger,
flagResolver: {
isEnabled: jest.fn().mockReturnValue(true),
},
};
});
test('should not set user if unknown token', async () => {
const userService = {
getUserByPersonalAccessToken: jest.fn(),
};
const func = patMiddleware(config, { userService });
const cb = jest.fn();
const req = {
header: jest.fn().mockReturnValue('user:some-token'),
user: undefined,
};
await func(req, undefined, cb);
expect(cb).toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBeFalsy();
});
test('should not set user if token wrong format', async () => {
const userService = {
getUserByPersonalAccessToken: jest.fn(),
};
const func = patMiddleware(config, { userService });
const cb = jest.fn();
const req = {
header: jest.fn().mockReturnValue('token-not-starting-with-user'),
user: undefined,
};
await func(req, undefined, cb);
expect(userService.getUserByPersonalAccessToken).not.toHaveBeenCalled();
expect(cb).toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBeFalsy();
});
test('should add user if known token', async () => {
const apiUser = new User({
id: 44,
username: 'my-user',
});
const userService = {
getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser),
};
const func = patMiddleware(config, { userService });
const cb = jest.fn();
const req = {
header: jest.fn().mockReturnValue('user:some-known-token'),
user: undefined,
path: '/api/client',
};
await func(req, undefined, cb);
expect(cb).toHaveBeenCalled();
expect(req.header).toHaveBeenCalled();
expect(req.user).toBe(apiUser);
});
test('should not add user if disabled', async () => {
const apiUser = new User({
id: 44,
username: 'my-user',
});
const userService = {
getUserByPersonalAccessToken: jest.fn().mockReturnValue(apiUser),
};
const disabledConfig = createTestConfig({
getLogger,
experimental: {
flags: {
personalAccessTokens: false,
},
},
});
const func = patMiddleware(disabledConfig, { userService });
const cb = jest.fn();
const req = {
header: jest.fn().mockReturnValue('user:some-known-token'),
user: undefined,
};
await func(req, undefined, cb);
expect(cb).toHaveBeenCalled();
expect(req.user).toBeFalsy();
});
test('should call next if userService throws exception', async () => {
getLogger.setMuteError(true);
const userService = {
getUserByPersonalAccessToken: () => {
throw new Error('Error occurred');
},
};
const func = patMiddleware(config, { userService });
const cb = jest.fn();
const req = {
header: jest.fn().mockReturnValue('user:some-token'),
user: undefined,
};
await func(req, undefined, cb);
expect(cb).toHaveBeenCalled();
getLogger.setMuteError(false);
});

View File

@ -0,0 +1,35 @@
import { IUnleashConfig } from '../types';
import { IAuthRequest } from '../routes/unleash-types';
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const patMiddleware = (
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
{ userService }: any,
): any => {
const logger = getLogger('/middleware/pat-middleware.ts');
logger.debug('Enabling PAT middleware');
if (!flagResolver.isEnabled('personalAccessTokens')) {
return (req, res, next) => next();
}
return async (req: IAuthRequest, res, next) => {
try {
const apiToken = req.header('authorization');
if (apiToken?.startsWith('user:')) {
const user = await userService.getUserByPersonalAccessToken(
apiToken,
);
req.user = user;
}
} catch (error) {
logger.error(error);
}
next();
};
};
export default patMiddleware;

View File

@ -422,6 +422,10 @@ class UserService {
); );
return resetLink; return resetLink;
} }
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getUserByPersonalAccessToken(secret);
}
} }
module.exports = UserService; module.exports = UserService;

View File

@ -10,6 +10,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY, process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
false, false,
), ),
personalAccessTokens: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PERSONAL_ACCESS_TOKENS,
false,
),
embedProxyFrontend: parseEnvVarBoolean( embedProxyFrontend: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND, process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND,
false, false,

View File

@ -32,4 +32,5 @@ export interface IUserStore extends Store<IUser, number> {
incLoginAttempts(user: IUser): Promise<void>; incLoginAttempts(user: IUser): Promise<void>;
successfullyLogin(user: IUser): Promise<void>; successfullyLogin(user: IUser): Promise<void>;
count(): Promise<number>; count(): Promise<number>;
getUserByPersonalAccessToken(secret: string): Promise<IUser>;
} }

View File

@ -27,6 +27,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
embedProxy: true, embedProxy: true,
embedProxyFrontend: true, embedProxyFrontend: true,
batchMetrics: true, batchMetrics: true,
personalAccessTokens: true,
}, },
}, },
}; };

View File

@ -1,17 +1,20 @@
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper'; import { IUnleashTest, setupAppWithAuth } from '../../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init'; import dbInit, { ITestDb } from '../../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../../fixtures/no-logger';
import { IPat } from '../../../../lib/types/models/pat'; import { IPat } from '../../../../../lib/types/models/pat';
let app: IUnleashTest; let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
let tomorrow = new Date(); let tomorrow = new Date();
let firstSecret;
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('user_pat', getLogger); db = await dbInit('user_pat', getLogger);
app = await setupAppWithAuth(db.stores); app = await setupAppWithAuth(db.stores, {
experimental: { flags: { personalAccessTokens: true } },
});
await app.request await app.request
.post(`/auth/demo/login`) .post(`/auth/demo/login`)
@ -40,12 +43,20 @@ test('should create a PAT', async () => {
expect(new Date(body.expiresAt)).toEqual(tomorrow); expect(new Date(body.expiresAt)).toEqual(tomorrow);
expect(body.description).toEqual(description); expect(body.description).toEqual(description);
firstSecret = body.secret;
const response = await request
.get('/api/admin/user/tokens')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.pats).toHaveLength(1);
}); });
test('should delete the PAT', async () => { test('should delete the PAT', async () => {
const { request } = app; const { request } = app;
const response = await request const { body } = await request
.post('/api/admin/user/tokens') .post('/api/admin/user/tokens')
.send({ .send({
expiresAt: tomorrow, expiresAt: tomorrow,
@ -53,7 +64,7 @@ test('should delete the PAT', async () => {
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(201); .expect(201);
const createdSecret = response.body.secret; const createdSecret = body.secret;
await request.delete(`/api/admin/user/tokens/${createdSecret}`).expect(200); await request.delete(`/api/admin/user/tokens/${createdSecret}`).expect(200);
}); });
@ -108,3 +119,40 @@ test('should fail creation of PAT with passed expiry', async () => {
.set('Content-Type', 'application/json') .set('Content-Type', 'application/json')
.expect(500); .expect(500);
}); });
test('should get user id 1', async () => {
await app.request.get('/logout').expect(302);
await app.request
.get('/api/admin/user')
.set('Authorization', firstSecret)
.expect(200)
.expect((res) => {
expect(res.body.user.email).toBe('user@getunleash.io');
expect(res.body.user.id).toBe(1);
});
});
test('should be able to get projects', async () => {
await app.request
.get('/api/admin/projects')
.set('Authorization', firstSecret)
.expect(200);
});
test('should be able to create a toggle', async () => {
await app.request
.post('/api/admin/projects/default/features')
.set('Authorization', firstSecret)
.send({
name: 'test-toggle',
type: 'release',
})
.expect(201);
});
test('should not get user with invalid token', async () => {
await app.request
.get('/api/admin/user')
.set('Authorization', 'randomtoken')
.expect(401);
});

View File

@ -1,6 +1,6 @@
import dbInit from '../../helpers/database-init'; import dbInit from '../../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger'; import getLogger from '../../../../fixtures/no-logger';
import { setupAppWithAuth } from '../../helpers/test-helper'; import { setupAppWithAuth } from '../../../helpers/test-helper';
let app; let app;
let db; let db;

View File

@ -137,6 +137,11 @@ class UserStoreMock implements IUserStore {
}); });
return Promise.resolve(undefined); return Promise.resolve(undefined);
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return Promise.resolve(undefined);
}
} }
module.exports = UserStoreMock; module.exports = UserStoreMock;