Compare commits
8 Commits
test-async
...
custom-dep
Author | SHA1 | Date | |
---|---|---|---|
![]() |
76296532cf | ||
![]() |
10290ce6e3 | ||
![]() |
a2df7c1e89 | ||
![]() |
b89ba1e623 | ||
![]() |
4d0481a3c6 | ||
![]() |
3da5e13ecd | ||
![]() |
40d0fe0db6 | ||
![]() |
029fd2d0b0 |
@@ -36,7 +36,6 @@ services:
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:21.1
|
||||
restart: always
|
||||
container_name: keycloak
|
||||
environment:
|
||||
- KEYCLOAK_ADMIN=admin
|
||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
|
@@ -21,6 +21,14 @@ export async function createUser(
|
||||
email = 'user@automatisch.io',
|
||||
password = 'sample'
|
||||
) {
|
||||
if (appConfig.disableSeedUser) {
|
||||
logger.info('Seed user is disabled.');
|
||||
|
||||
process.exit(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION_CODE = '23505';
|
||||
|
||||
const role = await fetchAdminRole();
|
||||
|
@@ -67,6 +67,7 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"raw-body": "^2.5.2",
|
||||
"showdown": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.7.1",
|
||||
"xmlrpc": "^1.3.2"
|
||||
},
|
||||
|
@@ -98,6 +98,7 @@ const appConfig = {
|
||||
disableFavicon: process.env.DISABLE_FAVICON === 'true',
|
||||
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
|
||||
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
|
||||
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
|
||||
};
|
||||
|
||||
if (!appConfig.encryptionKey) {
|
||||
|
@@ -0,0 +1,12 @@
|
||||
import User from '../../../../../models/user.js';
|
||||
import Config from '../../../../../models/config.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { email, password, fullName } = request.body;
|
||||
|
||||
await User.createAdminUser({ email, password, fullName });
|
||||
|
||||
await Config.markInstallationCompleted();
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import app from '../../../../../app.js';
|
||||
import Config from '../../../../../models/config.js';
|
||||
import User from '../../../../../models/user.js';
|
||||
import { createRole } from '../../../../../../test/factories/role';
|
||||
import { createInstallationCompletedConfig } from '../../../../../../test/factories/config';
|
||||
|
||||
describe('POST /api/v1/installation/users', () => {
|
||||
let adminRole;
|
||||
|
||||
beforeEach(async () => {
|
||||
adminRole = await createRole({
|
||||
name: 'Admin',
|
||||
key: 'admin',
|
||||
})
|
||||
});
|
||||
|
||||
describe('for incomplete installations', () => {
|
||||
it('should respond with HTTP 204 with correct payload', async () => {
|
||||
expect(await Config.isInstallationCompleted()).toBe(false);
|
||||
|
||||
await request(app)
|
||||
.post('/api/v1/installation/users')
|
||||
.send({
|
||||
email: 'user@automatisch.io',
|
||||
password: 'password',
|
||||
fullName: 'Initial admin'
|
||||
})
|
||||
.expect(204);
|
||||
|
||||
const user = await User.query().findOne({ email: 'user@automatisch.io' });
|
||||
|
||||
expect(user.roleId).toBe(adminRole.id);
|
||||
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for completed installations', () => {
|
||||
beforeEach(async () => {
|
||||
await createInstallationCompletedConfig();
|
||||
});
|
||||
|
||||
it('should respond with HTTP 403 when installation completed', async () => {
|
||||
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||
|
||||
await request(app)
|
||||
.post('/api/v1/installation/users')
|
||||
.send({
|
||||
email: 'user@automatisch.io',
|
||||
password: 'password',
|
||||
fullName: 'Initial admin'
|
||||
})
|
||||
.expect(403);
|
||||
|
||||
const user = await User.query().findOne({ email: 'user@automatisch.io' });
|
||||
|
||||
expect(user).toBeUndefined();
|
||||
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||
});
|
||||
})
|
||||
});
|
@@ -0,0 +1,11 @@
|
||||
export async function up(knex) {
|
||||
return knex.schema.table('access_tokens', (table) => {
|
||||
table.string('saml_session_id').nullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.table('access_tokens', (table) => {
|
||||
table.dropColumn('saml_session_id');
|
||||
});
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
export async function up(knex) {
|
||||
const users = await knex('users').limit(1);
|
||||
|
||||
// no user implies installation is not completed yet.
|
||||
if (users.length === 0) return;
|
||||
|
||||
await knex('config').insert({
|
||||
key: 'installation.completed',
|
||||
value: {
|
||||
data: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export async function down(knex) {
|
||||
await knex('config').where({ key: 'installation.completed' }).delete();
|
||||
};
|
9
packages/backend/src/helpers/authorize-installation.js
Normal file
9
packages/backend/src/helpers/authorize-installation.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import Config from '../models/config.js';
|
||||
|
||||
export async function authorizeInstallation(request, response, next) {
|
||||
if (await Config.isInstallationCompleted()) {
|
||||
return response.status(403).end();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
};
|
@@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js';
|
||||
|
||||
const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds
|
||||
|
||||
const createAuthTokenByUserId = async (userId) => {
|
||||
const createAuthTokenByUserId = async (userId, samlSessionId) => {
|
||||
const user = await User.query().findById(userId).throwIfNotFound();
|
||||
const token = await crypto.randomBytes(48).toString('hex');
|
||||
|
||||
await AccessToken.query().insert({
|
||||
token,
|
||||
samlSessionId,
|
||||
userId: user.id,
|
||||
expiresIn: TOKEN_EXPIRES_IN,
|
||||
});
|
||||
|
@@ -5,8 +5,11 @@ import passport from 'passport';
|
||||
import appConfig from '../config/app.js';
|
||||
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
|
||||
import SamlAuthProvider from '../models/saml-auth-provider.ee.js';
|
||||
import AccessToken from '../models/access-token.js';
|
||||
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js';
|
||||
|
||||
const asyncNoop = async () => { };
|
||||
|
||||
export default function configurePassport(app) {
|
||||
app.use(
|
||||
passport.initialize({
|
||||
@@ -19,6 +22,10 @@ export default function configurePassport(app) {
|
||||
{
|
||||
passReqToCallback: true,
|
||||
getSamlOptions: async function (request, done) {
|
||||
// This is a workaround to avoid session logout which passport-saml enforces
|
||||
request.logout = asyncNoop;
|
||||
request.logOut = asyncNoop;
|
||||
|
||||
const { issuer } = request.params;
|
||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||
|
||||
@@ -35,7 +42,7 @@ export default function configurePassport(app) {
|
||||
return done(null, authProvider.config);
|
||||
},
|
||||
},
|
||||
async function (request, user, done) {
|
||||
async function signonVerify(request, user, done) {
|
||||
const { issuer } = request.params;
|
||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||
|
||||
@@ -53,10 +60,38 @@ export default function configurePassport(app) {
|
||||
user,
|
||||
authProvider
|
||||
);
|
||||
|
||||
request.samlSessionId = user.sessionIndex;
|
||||
|
||||
return done(null, foundUserWithIdentity);
|
||||
},
|
||||
function (request, user, done) {
|
||||
return done(null, null);
|
||||
async function logoutVerify(request, user, done) {
|
||||
const { issuer } = request.params;
|
||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||
|
||||
if (!issuer) return done(notFoundIssuer);
|
||||
|
||||
const authProvider = await SamlAuthProvider.query().findOne({
|
||||
issuer: request.params.issuer,
|
||||
});
|
||||
|
||||
if (!authProvider) {
|
||||
return done(notFoundIssuer);
|
||||
}
|
||||
|
||||
const foundUserWithIdentity = await findOrCreateUserBySamlIdentity(
|
||||
user,
|
||||
authProvider
|
||||
);
|
||||
|
||||
const accessToken = await AccessToken.query().findOne({
|
||||
revoked_at: null,
|
||||
saml_session_id: user.sessionIndex,
|
||||
}).throwIfNotFound();
|
||||
|
||||
await accessToken.revoke();
|
||||
|
||||
return done(null, foundUserWithIdentity);
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -73,17 +108,22 @@ export default function configurePassport(app) {
|
||||
'/login/saml/:issuer/callback',
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
failureRedirect: '/',
|
||||
failureFlash: true,
|
||||
}),
|
||||
async (req, res) => {
|
||||
const token = await createAuthTokenByUserId(req.currentUser.id);
|
||||
async (request, response) => {
|
||||
const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId);
|
||||
|
||||
const redirectUrl = new URL(
|
||||
`/login/callback?token=${token}`,
|
||||
appConfig.webAppUrl
|
||||
).toString();
|
||||
res.redirect(redirectUrl);
|
||||
response.redirect(redirectUrl);
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/logout/saml/:issuer',
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ class AccessToken extends Base {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
token: { type: 'string', minLength: 32 },
|
||||
samlSessionId: { type: ['string', 'null'] },
|
||||
expiresIn: { type: 'integer' },
|
||||
revokedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||
},
|
||||
@@ -28,8 +29,37 @@ class AccessToken extends Base {
|
||||
},
|
||||
});
|
||||
|
||||
async terminateRemoteSamlSession() {
|
||||
if (!this.samlSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this
|
||||
.$relatedQuery('user');
|
||||
|
||||
const firstIdentity = await user
|
||||
.$relatedQuery('identities')
|
||||
.first();
|
||||
|
||||
const samlAuthProvider = await firstIdentity
|
||||
.$relatedQuery('samlAuthProvider')
|
||||
.throwIfNotFound();
|
||||
|
||||
const response = await samlAuthProvider.terminateRemoteSession(this.samlSessionId);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async revoke() {
|
||||
return await this.$query().patch({ revokedAt: new Date().toISOString() });
|
||||
const response = await this.$query().patch({ revokedAt: new Date().toISOString() });
|
||||
|
||||
try {
|
||||
await this.terminateRemoteSamlSession();
|
||||
} catch (error) {
|
||||
// TODO: should it silently fail or not?
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -13,6 +13,28 @@ class Config extends Base {
|
||||
value: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
static async isInstallationCompleted() {
|
||||
const installationCompletedEntry = await this
|
||||
.query()
|
||||
.where({
|
||||
key: 'installation.completed'
|
||||
})
|
||||
.first();
|
||||
|
||||
const installationCompleted = installationCompletedEntry?.value?.data === true;
|
||||
|
||||
return installationCompleted;
|
||||
}
|
||||
|
||||
static async markInstallationCompleted() {
|
||||
return await this.query().insert({
|
||||
key: 'installation.completed',
|
||||
value: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Config;
|
||||
|
@@ -45,6 +45,10 @@ class Role extends Base {
|
||||
get isAdmin() {
|
||||
return this.key === 'admin';
|
||||
}
|
||||
|
||||
static async findAdmin() {
|
||||
return await this.query().findOne({ key: 'admin' });
|
||||
}
|
||||
}
|
||||
|
||||
export default Role;
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { URL } from 'node:url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import appConfig from '../config/app.js';
|
||||
import axios from '../helpers/axios-with-proxy.js';
|
||||
import Base from './base.js';
|
||||
import Identity from './identity.ee.js';
|
||||
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
||||
@@ -61,27 +63,71 @@ class SamlAuthProvider extends Base {
|
||||
});
|
||||
|
||||
static get virtualAttributes() {
|
||||
return ['loginUrl'];
|
||||
return ['loginUrl', 'remoteLogoutUrl'];
|
||||
}
|
||||
|
||||
get loginUrl() {
|
||||
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
||||
}
|
||||
|
||||
get config() {
|
||||
const callbackUrl = new URL(
|
||||
get loginCallBackUrl() {
|
||||
return new URL(
|
||||
`/login/saml/${this.issuer}/callback`,
|
||||
appConfig.baseUrl
|
||||
).toString();
|
||||
}
|
||||
|
||||
get remoteLogoutUrl() {
|
||||
return this.entryPoint;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return {
|
||||
callbackUrl,
|
||||
callbackUrl: this.loginCallBackUrl,
|
||||
cert: this.certificate,
|
||||
entryPoint: this.entryPoint,
|
||||
issuer: this.issuer,
|
||||
signatureAlgorithm: this.signatureAlgorithm,
|
||||
logoutUrl: this.remoteLogoutUrl
|
||||
};
|
||||
}
|
||||
|
||||
generateLogoutRequestBody(sessionId) {
|
||||
const logoutRequest = `
|
||||
<samlp:LogoutRequest
|
||||
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
ID="${uuidv4()}"
|
||||
Version="2.0"
|
||||
IssueInstant="${new Date().toISOString()}"
|
||||
Destination="${this.remoteLogoutUrl}">
|
||||
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${this.issuer}</saml:Issuer>
|
||||
<samlp:SessionIndex>${sessionId}</samlp:SessionIndex>
|
||||
</samlp:LogoutRequest>
|
||||
`;
|
||||
|
||||
const encodedLogoutRequest = Buffer.from(logoutRequest).toString('base64')
|
||||
|
||||
return encodedLogoutRequest
|
||||
}
|
||||
|
||||
async terminateRemoteSession(sessionId) {
|
||||
const logoutRequest = this.generateLogoutRequestBody(sessionId);
|
||||
|
||||
const response = await axios.post(
|
||||
this.remoteLogoutUrl,
|
||||
new URLSearchParams({
|
||||
SAMLRequest: logoutRequest,
|
||||
}).toString(),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export default SamlAuthProvider;
|
||||
|
@@ -373,6 +373,19 @@ class User extends Base {
|
||||
return apps;
|
||||
}
|
||||
|
||||
static async createAdminUser({ email, password, fullName }) {
|
||||
const adminRole = await Role.findAdmin();
|
||||
|
||||
const adminUser = await this.query().insert({
|
||||
email,
|
||||
password,
|
||||
fullName,
|
||||
roleId: adminRole.id
|
||||
});
|
||||
|
||||
return adminUser;
|
||||
}
|
||||
|
||||
async $beforeInsert(queryContext) {
|
||||
await super.$beforeInsert(queryContext);
|
||||
|
||||
|
14
packages/backend/src/routes/api/v1/installation/users.js
Normal file
14
packages/backend/src/routes/api/v1/installation/users.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { authorizeInstallation } from '../../../../helpers/authorize-installation.js';
|
||||
import createUserAction from '../../../../controllers/api/v1/installation/users/create-user.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
authorizeInstallation,
|
||||
asyncHandler(createUserAction)
|
||||
);
|
||||
|
||||
export default router;
|
@@ -18,6 +18,7 @@ import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.
|
||||
import rolesRouter from './api/v1/admin/roles.ee.js';
|
||||
import permissionsRouter from './api/v1/admin/permissions.ee.js';
|
||||
import adminUsersRouter from './api/v1/admin/users.ee.js';
|
||||
import installationUsersRouter from './api/v1/installation/users.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -40,5 +41,7 @@ router.use('/api/v1/admin/users', adminUsersRouter);
|
||||
router.use('/api/v1/admin/roles', rolesRouter);
|
||||
router.use('/api/v1/admin/permissions', permissionsRouter);
|
||||
router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter);
|
||||
router.use('/api/v1/installation/users', installationUsersRouter);
|
||||
|
||||
|
||||
export default router;
|
||||
|
@@ -11,3 +11,7 @@ export const createConfig = async (params = {}) => {
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const createInstallationCompletedConfig = async () => {
|
||||
return await createConfig({ key: 'installation.completed', value: { data: true } });
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ global.beforeAll(async () => {
|
||||
logger.silent = true;
|
||||
|
||||
// Remove default roles and permissions before running the test suite
|
||||
await knex.raw('TRUNCATE TABLE roles, permissions CASCADE');
|
||||
await knex.raw('TRUNCATE TABLE config, roles, permissions CASCADE');
|
||||
});
|
||||
|
||||
global.beforeEach(async () => {
|
||||
|
@@ -16024,6 +16024,11 @@ uuid@^9.0.0:
|
||||
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz"
|
||||
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
|
||||
|
||||
uuid@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
|
||||
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
|
||||
|
||||
v8-compile-cache@^2.0.3:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"
|
||||
|
Reference in New Issue
Block a user