1
0
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:
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,
"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": {

View File

@ -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));

View File

@ -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;

View File

@ -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();
});

View File

@ -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);

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;
}
async getUserByPersonalAccessToken(secret: string): Promise<IUser> {
return this.store.getUserByPersonalAccessToken(secret);
}
}
module.exports = UserService;

View File

@ -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,

View File

@ -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>;
}

View File

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

View File

@ -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);
});

View File

@ -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;

View File

@ -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;