Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/allow admin login using demo auth #6808

Merged
merged 5 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/component/user/DemoAuth/DemoAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const DemoAuth: VFC<IDemoAuthProps> = ({ authDetails, redirect }) => {
id='email'
data-testid={LOGIN_EMAIL_ID}
required
type='email'
type={email === 'admin' ? 'text' : 'email'}
/>

<Button
Expand Down
1 change: 1 addition & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ exports[`should create default config 1`] = `
"styleSrc": [],
},
"authentication": {
"authDemoAllowAdminLogin": false,
00Chaotic marked this conversation as resolved.
Show resolved Hide resolved
"createAdminUser": true,
"customAuthHandler": [Function],
"enableApiToken": true,
Expand Down
14 changes: 14 additions & 0 deletions src/lib/create-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,20 @@ test('should handle cases where no env var specified for tokens', async () => {
expect(config.authentication.initApiTokens).toHaveLength(1);
});

test('should load demo admin login flag from env var', async () => {
process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN = 'true';

const config = createConfig({});

expect(config.authentication.authDemoAllowAdminLogin).toBeTruthy();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is failing for some reason. Does it run correctly locally for you?

Copy link
Contributor Author

@00Chaotic 00Chaotic Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay this is weird, the process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN isn't being evaluated correctly. It evaluates to 'true' if I print it in this test case but if I add a console.log() in parseEnvVarBoolean() which is used here, it prints undefined.

Strange because a lot of the other tests in this file do the exact same thing and yet it correctly parses those env vars 🤔

Can't really be a timing/race issue either since those operations aren't async.

Is there any behind-the-scenes env var processing I'm not aware of that may be inadvertently affecting this variable?

Edit: It's also true in the parent createConfig() function so I have no idea why it magically changes in the parse function. However, I have noticed that there are no other boolean env vars being set in that config test file. What are the chances this is an existing or larger issue that has only been caught now that we actually have a boolean env var in the tests?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, so this is a little tricky, but it is actually kind of a timing issue.

The thing is, most of the other tests in that file have corresponding functions in createConfig, but defaultAuthentication is defined as a variable. So it does the parsing of the env variables when that file first gets loaded, which happens before you set the process variable.

You can try this out by setting any of the other auth variables to a different value (boolean or otherwise).

So this isn't really an issue for Unleash itself. It makes testing a little trickier, but only in this specific case.

We could make it so that the auth config is also loaded through a function, but I'm a little hesitant to do that. It might have further security repercussions if you can dynamically change the environment and thus the auth options of Unleash after it starts. It seems a little far-fetched, but I'd rather not change it.

So how do we deal with this, then? It feels a little weird, but I think I'd actually just remove that test.

But I'd actually like to hear @sighphyre's take here: is it safe to load auth options dynamically or does it pose potential risks? Unleash ever only does it on startup, so I don't see how it's really gonna be an issue, but I'm not a security expert. And if not, is this test low-stakes enough that leaving it out is fine?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to wait for their input.

I'd be hesitant to remove the test since this might be our only way of knowing if the true case ever stops working in future, whereas for the false case we still have the default config case that will include this flag.

I don't really have a better suggestion though so if you both agree that this is the best way forward then we can take this route.

Copy link
Contributor

@thomasheartman thomasheartman Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right: we won't have a test that checks whether you can set it via environment variables (which it also seems like we don't explicitly for a lot of the other config variables). But we do test that you can turn it on via config in the e2e test file, so it's not untested.

So the only thing that test is actually doing is checking that we parse the env var correctly. Which definitely isn't worthless, but feels like it's unlikely to break. If we break env var parsing, then the other tests that load env vars dynamically will yell at us. If we get the name wrong, well, we'd have to keep supporting the wrong name, then. But a test won't really protect us against that unless we go and update the exposed env var directly, which would be a breaking change and thus not likely to happen.

So in short: I think it'd be nice if it worked, and if loading it dynamically is cool, then that's great! However, I'm also not too bothered if it turns out to be better to leave this one case out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I'd actually like to hear @sighphyre's take here: is it safe to load auth options dynamically or does it pose potential risks? Unleash ever only does it on startup, so I don't see how it's really gonna be an issue, but I'm not a security expert. And if not, is this test low-stakes enough that leaving it out is fine?

I think if you've lost control of your environment and an attacker can inject something in there then you have much, much bigger problems on your hands. I don't see any harm in moving that to being loaded later in the startup sequence though, if only for testing purposes. It should still only be loaded once when this is invoked.

So weirdly enough I don't really have strong feelings about what we do here. Mostly because demo auth isn't a core feature and the most dangerous thing that can happen here is that is gets accidentally turned on. So I have strong feelings about adjacent things - that demo auth remains off by default and that that test is clear but that seems to be covered by the test below the one in question.

I think @thomasheartman is right here, this is covered enough by the e2e tests. So it largely depends on how strongly you feel about it, @00Chaotic. I'd lean in the direction of removing the test here and getting this merged because gosh this conversation has gone on for a long time and PRs that stay open for a long time get worse and worse to try merge. I think this is good enough to merge in it's current state.

I don't see harm in restructuring the way this gets loaded to later if you think the quality would be improved by a more isolated test though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your feedback, I'm happy to remove the tests. I agree about just trying to get it merged at this point, it has definitely been messy with long discussions in multiple PRs haha.

I'll go ahead and remove the test case, and we can merge it in once all the checks pass.

delete process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN;
});

test('should default demo admin login to false', async () => {
const config = createConfig({});
expect(config.authentication.authDemoAllowAdminLogin).toBeFalsy();
});

test('should load environment overrides from env var', async () => {
process.env.ENABLED_ENVIRONMENTS = 'default,production';

Expand Down
4 changes: 4 additions & 0 deletions src/lib/create-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ const defaultVersionOption: IVersionOption = {
};

const defaultAuthentication: IAuthOption = {
authDemoAllowAdminLogin: parseEnvVarBoolean(
process.env.AUTH_DEMO_ALLOW_ADMIN_LOGIN,
false,
),
enableApiToken: parseEnvVarBoolean(process.env.AUTH_ENABLE_API_TOKEN, true),
type: authTypeFromString(process.env.AUTH_TYPE),
customAuthHandler: defaultCustomAuthDenyAll,
Expand Down
73 changes: 73 additions & 0 deletions src/lib/middleware/demo-authentication.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import dbInit from '../../test/e2e/helpers/database-init';
import { IAuthType } from '../server-impl';
import { setupAppWithCustomAuth } from '../../test/e2e/helpers/test-helper';
import type { ITestDb } from '../../test/e2e/helpers/database-init';
import type { IUnleashStores } from '../types';

let db: ITestDb;
let stores: IUnleashStores;

beforeAll(async () => {
db = await dbInit('demo_auth_serial');
stores = db.stores;
});

afterAll(async () => {
await db?.destroy();
});

const getApp = (adminLoginEnabled: boolean) =>
setupAppWithCustomAuth(stores, () => {}, {
authentication: {
authDemoAllowAdminLogin: adminLoginEnabled,
type: IAuthType.DEMO,
createAdminUser: true,
},
});

test('the authDemoAllowAdminLogin flag should not affect regular user login/creation', async () => {
const app = await getApp(true);
return app.request
.post(`/auth/demo/login`)
.send({ email: '[email protected]' })
.expect(200)
.expect((res) => {
expect(res.body.email).toBe('[email protected]');
expect(res.body.id).not.toBe(1);
});
});

test('if the authDemoAllowAdminLogin flag is disabled, using `admin` should have the same result as any other invalid email', async () => {
const app = await getApp(false);

const nonAdminUsername = 'not-an-email';
const adminUsername = 'admin';

const nonAdminUser = await app.request
.post(`/auth/demo/login`)
.send({ email: nonAdminUsername });

const adminUser = await app.request
.post(`/auth/demo/login`)
.send({ email: adminUsername });

expect(nonAdminUser.status).toBe(adminUser.status);

for (const user of [nonAdminUser, adminUser]) {
expect(user.body).toMatchObject({
error: expect.stringMatching(/^Could not sign in with /),
});
}
});

test('should allow you to login as admin if the authDemoAllowAdminLogin flag enabled', async () => {
const app = await getApp(true);
return app.request
.post(`/auth/demo/login`)
.send({ email: 'admin' })
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(1);
expect(res.body.username).toBe('admin');
});
});
23 changes: 14 additions & 9 deletions src/lib/middleware/demo-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { IUnleashServices } from '../types/services';
import type { IUnleashConfig } from '../types/option';
import ApiUser from '../types/api-user';
import { ApiTokenType } from '../types/models/api-token';
import type { IAuthRequest } from '../server-impl';
import type { IAuthRequest, IUser } from '../server-impl';
import type { IApiRequest } from '../routes/unleash-types';
import { encrypt } from '../util';

Expand All @@ -19,14 +19,19 @@ function demoAuthentication(
): void {
app.post(`${basePath}/auth/demo/login`, async (req: IAuthRequest, res) => {
let { email } = req.body;
email = flagResolver.isEnabled('encryptEmails', { email })
? encrypt(email)
: email;
let user: IUser;

Comment on lines +22 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Complex Method
demoAuthentication has a cyclomatic complexity of 12, threshold = 9

Suppress

try {
const user = await userService.loginUserWithoutPassword(
email,
true,
);
if (authentication.authDemoAllowAdminLogin && email === 'admin') {
user = await userService.loginDemoAuthDefaultAdmin();
} else {
email = flagResolver.isEnabled('encryptEmails', { email })
? encrypt(email)
: email;

user = await userService.loginUserWithoutPassword(email, true);
}

req.session.user = user;
return res.status(200).json(user);
} catch (e) {
Expand All @@ -37,7 +42,7 @@ function demoAuthentication(
});

app.use(`${basePath}/api/admin/`, (req: IAuthRequest, res, next) => {
if (req.session.user?.email) {
if (req.session.user?.email || req.session.user?.username === 'admin') {
req.user = req.session.user;
}
next();
Expand Down
6 changes: 6 additions & 0 deletions src/lib/services/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@ class UserService {
return user;
}

async loginDemoAuthDefaultAdmin(): Promise<IUser> {
const user = await this.store.getByQuery({ id: 1 });
await this.store.successfullyLogin(user);
return user;
}

async changePassword(userId: number, password: string): Promise<void> {
this.validatePassword(password);
const passwordHash = await bcrypt.hash(password, saltRounds);
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type CustomAuthHandler = (
) => void;

export interface IAuthOption {
authDemoAllowAdminLogin?: boolean;
enableApiToken: boolean;
type: IAuthType;
customAuthHandler?: CustomAuthHandler;
Expand Down