Skip to content

Commit 3186da2

Browse files
authored
Merge pull request finos#1457 from jescalada/1022-force-admin-password-reset
feat: add password reset for default admin/user accounts
2 parents 37d3db2 + d75a5e3 commit 3186da2

15 files changed

Lines changed: 1048 additions & 114 deletions

File tree

src/db/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const createUser = async (
6060
gitAccount: string,
6161
admin: boolean = false,
6262
oidcId: string = '',
63+
mustChangePassword: boolean = false,
6364
) => {
6465
console.log(
6566
`creating user
@@ -76,6 +77,7 @@ export const createUser = async (
7677
gitAccount: gitAccount,
7778
email: email,
7879
admin: admin,
80+
mustChangePassword,
7981
};
8082

8183
if (isBlank(username)) {

src/db/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ export class User {
8181
gitAccount: string;
8282
email: string;
8383
admin: boolean;
84+
mustChangePassword?: boolean;
8485
oidcId?: string | null;
8586
publicKeys?: PublicKeyRecord[];
8687
displayName?: string | null;
@@ -140,6 +141,7 @@ export interface PublicUser {
140141
title: string;
141142
gitAccount: string;
142143
admin: boolean;
144+
mustChangePassword?: boolean;
143145
}
144146

145147
export interface Sink {

src/service/passport/local.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,34 @@ import bcrypt from 'bcryptjs';
1818
import { IVerifyOptions, Strategy as LocalStrategy } from 'passport-local';
1919
import type { PassportStatic } from 'passport';
2020
import * as db from '../../db';
21-
21+
import type { DefaultLocalUser } from './types';
2222
export const type = 'local';
2323

24+
const DEFAULT_LOCAL_USERS: DefaultLocalUser[] = [
25+
{
26+
username: 'admin',
27+
password: 'admin',
28+
email: 'admin@place.com',
29+
gitAccount: 'none',
30+
admin: true,
31+
},
32+
{
33+
username: 'user',
34+
password: 'user',
35+
email: 'user@place.com',
36+
gitAccount: 'none',
37+
admin: false,
38+
},
39+
];
40+
41+
const isProduction = (): boolean => process.env.NODE_ENV === 'production';
42+
const isKnownDefaultCredentialAttempt = (username: string, password: string): boolean =>
43+
DEFAULT_LOCAL_USERS.some(
44+
(defaultUser) =>
45+
defaultUser.username.toLowerCase() === username.toLowerCase() &&
46+
defaultUser.password === password,
47+
);
48+
2449
// Dynamic import to always get the current db module instance
2550
// This is necessary for test environments where modules may be reset
2651
const getDb = () => import('../../db');
@@ -45,6 +70,19 @@ export const configure = async (passport: PassportStatic): Promise<PassportStati
4570
return done(null, undefined, { message: 'Incorrect password.' });
4671
}
4772

73+
// Force password reset when using default accounts in production
74+
if (
75+
isProduction() &&
76+
isKnownDefaultCredentialAttempt(username, password) &&
77+
!user.mustChangePassword
78+
) {
79+
user.mustChangePassword = true;
80+
await dbModule.updateUser({
81+
username: user.username,
82+
mustChangePassword: true,
83+
});
84+
}
85+
4886
return done(null, user);
4987
} catch (error: unknown) {
5088
return done(error);
@@ -83,10 +121,11 @@ export const createDefaultAdmin = async () => {
83121
) => {
84122
const user = await db.findUser(username);
85123
if (!user) {
86-
await db.createUser(username, password, email, type, isAdmin);
124+
await db.createUser(username, password, email, type, isAdmin, '', isProduction());
87125
}
88126
};
89127

90-
await createIfNotExists('admin', 'admin', 'admin@place.com', 'none', true);
91-
await createIfNotExists('user', 'user', 'user@place.com', 'none', false);
128+
for (const u of DEFAULT_LOCAL_USERS) {
129+
await createIfNotExists(u.username, u.password, u.email, u.gitAccount, u.admin);
130+
}
92131
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { RequestHandler } from 'express';
18+
import { mustChangePassword } from '../routes/utils';
19+
20+
export const passwordChangeHandler: RequestHandler = (req, res, next) => {
21+
if (mustChangePassword(req.user)) {
22+
return res.status(428).send({
23+
message: 'Password change required before accessing this endpoint',
24+
});
25+
}
26+
return next();
27+
};

src/service/passport/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,11 @@ export type ADProfileJson = {
5353
};
5454

5555
export type ADVerifyCallback = (err: Error | null, user: ADProfile | null) => void;
56+
57+
export type DefaultLocalUser = {
58+
username: string;
59+
password: string;
60+
email: string;
61+
gitAccount: string;
62+
admin: boolean;
63+
};

src/service/routes/auth.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import express, { Request, Response, NextFunction } from 'express';
18+
import bcrypt from 'bcryptjs';
1819
import { getPassport, authStrategies } from '../passport';
1920
import { getAuthMethods, getUIHost, getUIPort } from '../../config';
2021

@@ -24,13 +25,38 @@ import * as passportAD from '../passport/activeDirectory';
2425

2526
import { User } from '../../db/types';
2627
import { AuthenticationElement } from '../../config/generated/config';
27-
28-
import { isAdminUser, toPublicUser } from './utils';
28+
import { isAdminUser, mustChangePassword, toPublicUser } from './utils';
2929
import { handleErrorAndLog } from '../../utils/errors';
3030

3131
const router = express.Router();
3232
const passport = getPassport();
3333

34+
const PASSWORD_MIN_LENGTH = 8;
35+
const PASSWORD_CHANGE_ALLOWED_PATHS = new Set([
36+
'/',
37+
'/config',
38+
'/login',
39+
'/logout',
40+
'/profile',
41+
'/change-password',
42+
'/openidconnect',
43+
'/openidconnect/callback',
44+
]);
45+
46+
router.use((req: Request, res: Response, next: NextFunction) => {
47+
if (!mustChangePassword(req.user)) {
48+
return next();
49+
}
50+
51+
if (PASSWORD_CHANGE_ALLOWED_PATHS.has(req.path)) {
52+
return next();
53+
}
54+
55+
return res.status(428).send({
56+
message: 'Password change required before accessing this endpoint',
57+
});
58+
});
59+
3460
router.get('/', (_req: Request, res: Response) => {
3561
res.status(200).json({
3662
login: {
@@ -148,6 +174,85 @@ router.post('/logout', (req: Request, res: Response, next: NextFunction) => {
148174
res.send({ isAuth: req.isAuthenticated(), user: req.user });
149175
});
150176

177+
router.post('/change-password', async (req: Request, res: Response) => {
178+
if (!req.user) {
179+
res
180+
.status(401)
181+
.send({
182+
message: 'Not logged in',
183+
})
184+
.end();
185+
return;
186+
}
187+
188+
const { currentPassword, newPassword } = req.body ?? {};
189+
if (
190+
typeof currentPassword !== 'string' ||
191+
typeof newPassword !== 'string' ||
192+
currentPassword.trim().length === 0 ||
193+
newPassword.trim().length < PASSWORD_MIN_LENGTH
194+
) {
195+
res
196+
.status(400)
197+
.send({
198+
message: `currentPassword and newPassword are required, and newPassword must be at least ${PASSWORD_MIN_LENGTH} characters`,
199+
})
200+
.end();
201+
return;
202+
}
203+
204+
if (currentPassword === newPassword) {
205+
res
206+
.status(400)
207+
.send({
208+
message: 'newPassword must be different from currentPassword',
209+
})
210+
.end();
211+
return;
212+
}
213+
214+
try {
215+
const user = await db.findUser((req.user as User).username);
216+
if (!user) {
217+
res.status(404).send({ message: 'User not found' }).end();
218+
return;
219+
}
220+
221+
if (!user.password) {
222+
res
223+
.status(400)
224+
.send({ message: 'Password changes are not supported for this account' })
225+
.end();
226+
return;
227+
}
228+
229+
const currentPasswordCorrect = await bcrypt.compare(currentPassword, user.password ?? '');
230+
if (!currentPasswordCorrect) {
231+
res.status(401).send({ message: 'Current password is incorrect' }).end();
232+
return;
233+
}
234+
235+
const hashedPassword = await bcrypt.hash(newPassword, 10);
236+
await db.updateUser({
237+
username: user.username,
238+
password: hashedPassword,
239+
mustChangePassword: false,
240+
});
241+
242+
(req.user as User).mustChangePassword = false;
243+
244+
res.status(200).send({ message: 'Password updated successfully' }).end();
245+
} catch (error: unknown) {
246+
const msg = handleErrorAndLog(error, 'Failed to update password');
247+
res
248+
.status(500)
249+
.send({
250+
message: msg,
251+
})
252+
.end();
253+
}
254+
});
255+
151256
router.get('/profile', async (req: Request, res: Response) => {
152257
if (!req.user) {
153258
res
@@ -219,16 +324,11 @@ router.post('/gitAccount', async (req: Request, res: Response) => {
219324
}
220325

221326
user.gitAccount = req.body.gitAccount;
222-
db.updateUser(user);
223-
res.status(200).end();
327+
await db.updateUser(user);
328+
return res.status(200).send({ message: 'Git account updated successfully' }).end();
224329
} catch (error: unknown) {
225330
const msg = handleErrorAndLog(error, 'Failed to update git account');
226-
res
227-
.status(500)
228-
.send({
229-
message: msg,
230-
})
231-
.end();
331+
return res.status(500).send({ message: msg }).end();
232332
}
233333
});
234334

src/service/routes/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,18 @@ import users from './users';
2323
import healthcheck from './healthcheck';
2424
import config from './config';
2525
import { jwtAuthHandler } from '../passport/jwtAuthHandler';
26+
import { passwordChangeHandler } from '../passport/passwordChangeHandler';
2627
import { Proxy } from '../../proxy';
2728

2829
const routes = (proxy: Proxy) => {
2930
const router = express.Router();
31+
3032
router.use('/api', home);
3133
router.use('/api/auth', auth.router);
3234
router.use('/api/v1/healthcheck', healthcheck);
33-
router.use('/api/v1/push', jwtAuthHandler(), push);
34-
router.use('/api/v1/repo', jwtAuthHandler(), repo(proxy));
35-
router.use('/api/v1/user', jwtAuthHandler(), users);
35+
router.use('/api/v1/push', jwtAuthHandler(), passwordChangeHandler, push);
36+
router.use('/api/v1/repo', jwtAuthHandler(), passwordChangeHandler, repo(proxy));
37+
router.use('/api/v1/user', jwtAuthHandler(), passwordChangeHandler, users);
3638
router.use('/api/v1/config', config);
3739
return router;
3840
};

src/service/routes/utils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,28 @@ import { PublicUser, User as DbUser } from '../../db/types';
1919
interface User extends Express.User {
2020
username: string;
2121
admin?: boolean;
22+
mustChangePassword?: boolean;
2223
}
2324

2425
export function isAdminUser(user?: Express.User): user is User & { admin: true } {
2526
return user !== null && user !== undefined && (user as User).admin === true;
2627
}
2728

2829
export const toPublicUser = (user: DbUser): PublicUser => {
29-
return {
30+
const publicUser: PublicUser = {
3031
username: user.username || '',
3132
displayName: user.displayName || '',
3233
email: user.email || '',
3334
title: user.title || '',
3435
gitAccount: user.gitAccount || '',
3536
admin: user.admin || false,
3637
};
38+
if (user.mustChangePassword) {
39+
publicUser.mustChangePassword = true;
40+
}
41+
return publicUser;
42+
};
43+
44+
export const mustChangePassword = (user?: Express.User): boolean => {
45+
return user !== null && user !== undefined && (user as User).mustChangePassword === true;
3746
};

src/ui/components/RouteGuard/RouteGuard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ const RouteGuard = ({ component: Component, fullRoutePath }: RouteGuardProps) =>
5757
return <Navigate to='/login' />;
5858
}
5959

60+
if (user?.mustChangePassword) {
61+
return <Navigate to='/login' />;
62+
}
63+
6064
if (adminOnly && !user?.admin) {
6165
return <Navigate to='/not-authorized' />;
6266
}

0 commit comments

Comments
 (0)