Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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: 2 additions & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const createUser = async (
gitAccount: string,
admin: boolean = false,
oidcId: string = '',
mustChangePassword: boolean = false,
) => {
console.log(
`creating user
Expand All @@ -76,6 +77,7 @@ export const createUser = async (
gitAccount: gitAccount,
email: email,
admin: admin,
mustChangePassword,
};

if (isBlank(username)) {
Expand Down
2 changes: 2 additions & 0 deletions src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class User {
gitAccount: string;
email: string;
admin: boolean;
mustChangePassword?: boolean;
oidcId?: string | null;
displayName?: string | null;
title?: string | null;
Expand Down Expand Up @@ -105,6 +106,7 @@ export interface PublicUser {
title: string;
gitAccount: string;
admin: boolean;
mustChangePassword?: boolean;
}

export interface Sink {
Expand Down
47 changes: 43 additions & 4 deletions src/service/passport/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,34 @@ import bcrypt from 'bcryptjs';
import { IVerifyOptions, Strategy as LocalStrategy } from 'passport-local';
import type { PassportStatic } from 'passport';
import * as db from '../../db';

import type { DefaultLocalUser } from './types';
export const type = 'local';

const DEFAULT_LOCAL_USERS: DefaultLocalUser[] = [
{
username: 'admin',
password: 'admin',
email: 'admin@place.com',
gitAccount: 'none',
admin: true,
},
{
username: 'user',
password: 'user',
email: 'user@place.com',
gitAccount: 'none',
admin: false,
},
];

const isProduction = (): boolean => process.env.NODE_ENV === 'production';
const isKnownDefaultCredentialAttempt = (username: string, password: string): boolean =>
DEFAULT_LOCAL_USERS.some(
(defaultUser) =>
defaultUser.username.toLowerCase() === username.toLowerCase() &&
defaultUser.password === password,
);

// Dynamic import to always get the current db module instance
// This is necessary for test environments where modules may be reset
const getDb = () => import('../../db');
Expand All @@ -45,6 +70,19 @@ export const configure = async (passport: PassportStatic): Promise<PassportStati
return done(null, undefined, { message: 'Incorrect password.' });
}

// Force password reset when using default accounts in production
if (
isProduction() &&
isKnownDefaultCredentialAttempt(username, password) &&
!user.mustChangePassword
) {
user.mustChangePassword = true;
await dbModule.updateUser({
username: user.username,
mustChangePassword: true,
});
}

return done(null, user);
} catch (error: unknown) {
return done(error);
Expand Down Expand Up @@ -83,10 +121,11 @@ export const createDefaultAdmin = async () => {
) => {
const user = await db.findUser(username);
if (!user) {
await db.createUser(username, password, email, type, isAdmin);
await db.createUser(username, password, email, type, isAdmin, '', isProduction());
}
};

await createIfNotExists('admin', 'admin', 'admin@place.com', 'none', true);
await createIfNotExists('user', 'user', 'user@place.com', 'none', false);
for (const u of DEFAULT_LOCAL_USERS) {
await createIfNotExists(u.username, u.password, u.email, u.gitAccount, u.admin);
}
};
27 changes: 27 additions & 0 deletions src/service/passport/passwordChangeHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Copyright 2026 GitProxy Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { RequestHandler } from 'express';
import { mustChangePassword } from '../routes/utils';

export const passwordChangeHandler: RequestHandler = (req, res, next) => {
if (mustChangePassword(req.user)) {
return res.status(428).send({
message: 'Password change required before accessing this endpoint',
});
}
return next();
};
8 changes: 8 additions & 0 deletions src/service/passport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,11 @@ export type ADProfileJson = {
};

export type ADVerifyCallback = (err: Error | null, user: ADProfile | null) => void;

export type DefaultLocalUser = {
username: string;
password: string;
email: string;
gitAccount: string;
admin: boolean;
};
113 changes: 112 additions & 1 deletion src/service/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import express, { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import { getPassport, authStrategies } from '../passport';
import { getAuthMethods } from '../../config';

Expand All @@ -25,7 +26,7 @@ import * as passportAD from '../passport/activeDirectory';
import { User } from '../../db/types';
import { AuthenticationElement } from '../../config/generated/config';

import { isAdminUser, toPublicUser } from './utils';
import { isAdminUser, mustChangePassword, toPublicUser } from './utils';
import { handleErrorAndLog } from '../../utils/errors';

const router = express.Router();
Expand All @@ -34,6 +35,32 @@ const passport = getPassport();
const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } =
process.env;

const PASSWORD_MIN_LENGTH = 8;
const PASSWORD_CHANGE_ALLOWED_PATHS = new Set([
'/',
'/config',
'/login',
'/logout',
'/profile',
'/change-password',
'/openidconnect',
'/openidconnect/callback',
]);

router.use((req: Request, res: Response, next: NextFunction) => {
if (!mustChangePassword(req.user)) {
return next();
}

if (PASSWORD_CHANGE_ALLOWED_PATHS.has(req.path)) {
return next();
}

return res.status(428).send({
message: 'Password change required before accessing this endpoint',
});
});

router.get('/', (_req: Request, res: Response) => {
res.status(200).json({
login: {
Expand Down Expand Up @@ -151,6 +178,90 @@ router.post('/logout', (req: Request, res: Response, next: NextFunction) => {
res.send({ isAuth: req.isAuthenticated(), user: req.user });
});

router.post('/change-password', async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
res
.status(401)
.send({
message: 'Not logged in',
})
.end();
return;
}

const { currentPassword, newPassword } = req.body ?? {};
if (
typeof currentPassword !== 'string' ||
typeof newPassword !== 'string' ||
currentPassword.trim().length === 0 ||
newPassword.trim().length < PASSWORD_MIN_LENGTH
) {
res
.status(400)
.send({
message: `currentPassword and newPassword are required, and newPassword must be at least ${PASSWORD_MIN_LENGTH} characters`,
})
.end();
return;
}

if (currentPassword === newPassword) {
res
.status(400)
.send({
message: 'newPassword must be different from currentPassword',
})
.end();
return;
}

try {
const user = await db.findUser((req.user as User).username);
if (!user) {
res.status(404).send({ message: 'User not found' }).end();
return;
}

if (!user.password) {
res
.status(400)
.send({ message: 'Password changes are not supported for this account' })
.end();
return;
}

const currentPasswordCorrect = await bcrypt.compare(currentPassword, user.password ?? '');
if (!currentPasswordCorrect) {
res.status(401).send({ message: 'Current password is incorrect' }).end();
return;
}

const hashedPassword = await bcrypt.hash(newPassword, 10);
await db.updateUser({
username: user.username,
password: hashedPassword,
mustChangePassword: false,
});

// Logout is added by passport and not present in test environment
req.logout?.((err: unknown) => {
if (err) return next(err);
});
res.clearCookie('connect.sid');
(req.user as User).mustChangePassword = false;

res.status(200).send({ message: 'Password updated successfully' }).end();
} catch (error: unknown) {
const msg = handleErrorAndLog(error, 'Failed to update password');
res
.status(500)
.send({
message: msg,
})
.end();
}
});

router.get('/profile', async (req: Request, res: Response) => {
if (!req.user) {
res
Expand Down
8 changes: 5 additions & 3 deletions src/service/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,18 @@ import users from './users';
import healthcheck from './healthcheck';
import config from './config';
import { jwtAuthHandler } from '../passport/jwtAuthHandler';
import { passwordChangeHandler } from '../passport/passwordChangeHandler';
import { Proxy } from '../../proxy';

const routes = (proxy: Proxy) => {
const router = express.Router();

router.use('/api', home);
router.use('/api/auth', auth.router);
router.use('/api/v1/healthcheck', healthcheck);
router.use('/api/v1/push', jwtAuthHandler(), push);
router.use('/api/v1/repo', jwtAuthHandler(), repo(proxy));
router.use('/api/v1/user', jwtAuthHandler(), users);
router.use('/api/v1/push', jwtAuthHandler(), passwordChangeHandler, push);
router.use('/api/v1/repo', jwtAuthHandler(), passwordChangeHandler, repo(proxy));
router.use('/api/v1/user', jwtAuthHandler(), passwordChangeHandler, users);
router.use('/api/v1/config', config);
return router;
};
Expand Down
11 changes: 10 additions & 1 deletion src/service/routes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,28 @@ import { PublicUser, User as DbUser } from '../../db/types';
interface User extends Express.User {
username: string;
admin?: boolean;
mustChangePassword?: boolean;
}

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

export const toPublicUser = (user: DbUser): PublicUser => {
return {
const publicUser: PublicUser = {
username: user.username || '',
displayName: user.displayName || '',
email: user.email || '',
title: user.title || '',
gitAccount: user.gitAccount || '',
admin: user.admin || false,
};
if (user.mustChangePassword) {
publicUser.mustChangePassword = true;
}
return publicUser;
};

export const mustChangePassword = (user?: Express.User): boolean => {
return user !== null && user !== undefined && (user as User).mustChangePassword === true;
};
4 changes: 4 additions & 0 deletions src/ui/components/RouteGuard/RouteGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const RouteGuard = ({ component: Component, fullRoutePath }: RouteGuardProps) =>
return <Navigate to='/login' />;
}

if (user?.mustChangePassword) {
return <Navigate to='/login' />;
}

if (adminOnly && !user?.admin) {
return <Navigate to='/not-authorized' />;
}
Expand Down
Loading
Loading