mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-27 00:19:39 +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
src
@ -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": {
|
||||||
|
@ -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));
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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);
|
||||||
|
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;
|
return resetLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
|
||||||
|
return this.store.getUserByPersonalAccessToken(secret);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = UserService;
|
module.exports = UserService;
|
||||||
|
@ -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,
|
||||||
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
|||||||
embedProxy: true,
|
embedProxy: true,
|
||||||
embedProxyFrontend: true,
|
embedProxyFrontend: true,
|
||||||
batchMetrics: true,
|
batchMetrics: true,
|
||||||
|
personalAccessTokens: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
});
|
@ -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;
|
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);
|
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;
|
||||||
|
Loading…
Reference in New Issue
Block a user