mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
Personal access token middleware (#2069)
* 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:
parent
7fbe227e0f
commit
d79ace57ec
@ -71,6 +71,7 @@ exports[`should create default config 1`] = `
|
||||
"batchMetrics": false,
|
||||
"embedProxy": false,
|
||||
"embedProxyFrontend": false,
|
||||
"personalAccessTokens": false,
|
||||
"publicSignup": false,
|
||||
},
|
||||
},
|
||||
@ -81,6 +82,7 @@ exports[`should create default config 1`] = `
|
||||
"batchMetrics": false,
|
||||
"embedProxy": false,
|
||||
"embedProxyFrontend": false,
|
||||
"personalAccessTokens": false,
|
||||
"publicSignup": false,
|
||||
},
|
||||
"externalResolver": {
|
||||
|
@ -23,6 +23,7 @@ import secureHeaders from './middleware/secure-headers';
|
||||
import { loadIndexHTML } from './util/load-index-html';
|
||||
import { findPublicFolder } from './util/findPublicFolder';
|
||||
import { conditionalMiddleware } from './middleware/conditional-middleware';
|
||||
import patMiddleware from './middleware/pat-middleware';
|
||||
|
||||
export default async function getApp(
|
||||
config: IUnleashConfig,
|
||||
@ -81,6 +82,8 @@ export default async function getApp(
|
||||
),
|
||||
);
|
||||
|
||||
app.use(baseUriPath, patMiddleware(config, services));
|
||||
|
||||
switch (config.authentication.type) {
|
||||
case IAuthType.OPEN_SOURCE: {
|
||||
app.use(baseUriPath, apiTokenMiddleware(config, services));
|
||||
|
@ -204,6 +204,20 @@ class UserStore implements IUserStore {
|
||||
const row = await this.db(TABLE).where({ id }).first();
|
||||
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;
|
||||
|
@ -159,8 +159,18 @@ test('should not add user if disabled', async () => {
|
||||
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(req.user).toBeFalsy();
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ApiTokenType } from '../types/models/api-token';
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { IAuthRequest } from '../routes/unleash-types';
|
||||
|
||||
const isClientApi = ({ path }) => {
|
||||
return path && path.startsWith('/api/client');
|
||||
@ -39,27 +40,31 @@ const apiAccessMiddleware = (
|
||||
return (req, res, next) => next();
|
||||
}
|
||||
|
||||
return (req, res, next) => {
|
||||
return (req: IAuthRequest, res, next) => {
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const apiToken = req.header('authorization');
|
||||
const apiUser = apiTokenService.getUserForToken(apiToken);
|
||||
const { CLIENT, FRONTEND } = ApiTokenType;
|
||||
if (!apiToken?.startsWith('user:')) {
|
||||
const apiUser = apiTokenService.getUserForToken(apiToken);
|
||||
const { CLIENT, FRONTEND } = ApiTokenType;
|
||||
|
||||
if (apiUser) {
|
||||
if (
|
||||
(apiUser.type === CLIENT && !isClientApi(req)) ||
|
||||
(apiUser.type === FRONTEND && !isProxyApi(req)) ||
|
||||
(apiUser.type === FRONTEND &&
|
||||
!flagResolver.isEnabled('embedProxy'))
|
||||
) {
|
||||
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
|
||||
return;
|
||||
if (apiUser) {
|
||||
if (
|
||||
(apiUser.type === CLIENT && !isClientApi(req)) ||
|
||||
(apiUser.type === FRONTEND && !isProxyApi(req)) ||
|
||||
(apiUser.type === FRONTEND &&
|
||||
!flagResolver.isEnabled('embedProxy'))
|
||||
) {
|
||||
res.status(403).send({
|
||||
message: TOKEN_TYPE_ERROR_MESSAGE,
|
||||
});
|
||||
return;
|
||||
}
|
||||
req.user = apiUser;
|
||||
}
|
||||
req.user = apiUser;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
140
src/lib/middleware/pat-middleware.test.ts
Normal file
140
src/lib/middleware/pat-middleware.test.ts
Normal 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);
|
||||
});
|
35
src/lib/middleware/pat-middleware.ts
Normal file
35
src/lib/middleware/pat-middleware.ts
Normal 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;
|
@ -422,6 +422,10 @@ class UserService {
|
||||
);
|
||||
return resetLink;
|
||||
}
|
||||
|
||||
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
|
||||
return this.store.getUserByPersonalAccessToken(secret);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UserService;
|
||||
|
@ -10,6 +10,10 @@ export const defaultExperimentalOptions = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
||||
false,
|
||||
),
|
||||
personalAccessTokens: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_PERSONAL_ACCESS_TOKENS,
|
||||
false,
|
||||
),
|
||||
embedProxyFrontend: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY_FRONTEND,
|
||||
false,
|
||||
|
@ -32,4 +32,5 @@ export interface IUserStore extends Store<IUser, number> {
|
||||
incLoginAttempts(user: IUser): Promise<void>;
|
||||
successfullyLogin(user: IUser): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
getUserByPersonalAccessToken(secret: string): Promise<IUser>;
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
||||
embedProxy: true,
|
||||
embedProxyFrontend: true,
|
||||
batchMetrics: true,
|
||||
personalAccessTokens: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,17 +1,20 @@
|
||||
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
|
||||
import dbInit, { ITestDb } from '../../helpers/database-init';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
import { IPat } from '../../../../lib/types/models/pat';
|
||||
import { IUnleashTest, setupAppWithAuth } from '../../../helpers/test-helper';
|
||||
import dbInit, { ITestDb } from '../../../helpers/database-init';
|
||||
import getLogger from '../../../../fixtures/no-logger';
|
||||
import { IPat } from '../../../../../lib/types/models/pat';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
|
||||
let tomorrow = new Date();
|
||||
let firstSecret;
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('user_pat', getLogger);
|
||||
app = await setupAppWithAuth(db.stores);
|
||||
app = await setupAppWithAuth(db.stores, {
|
||||
experimental: { flags: { personalAccessTokens: true } },
|
||||
});
|
||||
|
||||
await app.request
|
||||
.post(`/auth/demo/login`)
|
||||
@ -40,12 +43,20 @@ test('should create a PAT', async () => {
|
||||
|
||||
expect(new Date(body.expiresAt)).toEqual(tomorrow);
|
||||
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 () => {
|
||||
const { request } = app;
|
||||
|
||||
const response = await request
|
||||
const { body } = await request
|
||||
.post('/api/admin/user/tokens')
|
||||
.send({
|
||||
expiresAt: tomorrow,
|
||||
@ -53,7 +64,7 @@ test('should delete the PAT', async () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201);
|
||||
|
||||
const createdSecret = response.body.secret;
|
||||
const createdSecret = body.secret;
|
||||
|
||||
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')
|
||||
.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);
|
||||
});
|
@ -1,6 +1,6 @@
|
||||
import dbInit from '../../helpers/database-init';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
import { setupAppWithAuth } from '../../helpers/test-helper';
|
||||
import dbInit from '../../../helpers/database-init';
|
||||
import getLogger from '../../../../fixtures/no-logger';
|
||||
import { setupAppWithAuth } from '../../../helpers/test-helper';
|
||||
|
||||
let app;
|
||||
let db;
|
5
src/test/fixtures/fake-user-store.ts
vendored
5
src/test/fixtures/fake-user-store.ts
vendored
@ -137,6 +137,11 @@ class UserStoreMock implements IUserStore {
|
||||
});
|
||||
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;
|
||||
|
Loading…
Reference in New Issue
Block a user