Merge pull request #1875 from automatisch/logout-saml
This commit is contained in:
@@ -36,7 +36,6 @@ services:
|
|||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:21.1
|
image: quay.io/keycloak/keycloak:21.1
|
||||||
restart: always
|
restart: always
|
||||||
container_name: keycloak
|
|
||||||
environment:
|
environment:
|
||||||
- KEYCLOAK_ADMIN=admin
|
- KEYCLOAK_ADMIN=admin
|
||||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||||
|
@@ -67,6 +67,7 @@
|
|||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"raw-body": "^2.5.2",
|
"raw-body": "^2.5.2",
|
||||||
"showdown": "^2.1.0",
|
"showdown": "^2.1.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.7.1",
|
"winston": "^3.7.1",
|
||||||
"xmlrpc": "^1.3.2"
|
"xmlrpc": "^1.3.2"
|
||||||
},
|
},
|
||||||
|
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
@@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js';
|
|||||||
|
|
||||||
const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds
|
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 user = await User.query().findById(userId).throwIfNotFound();
|
||||||
const token = await crypto.randomBytes(48).toString('hex');
|
const token = await crypto.randomBytes(48).toString('hex');
|
||||||
|
|
||||||
await AccessToken.query().insert({
|
await AccessToken.query().insert({
|
||||||
token,
|
token,
|
||||||
|
samlSessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresIn: TOKEN_EXPIRES_IN,
|
expiresIn: TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
|
@@ -5,8 +5,11 @@ import passport from 'passport';
|
|||||||
import appConfig from '../config/app.js';
|
import appConfig from '../config/app.js';
|
||||||
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
|
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
|
||||||
import SamlAuthProvider from '../models/saml-auth-provider.ee.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';
|
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js';
|
||||||
|
|
||||||
|
const asyncNoop = async () => { };
|
||||||
|
|
||||||
export default function configurePassport(app) {
|
export default function configurePassport(app) {
|
||||||
app.use(
|
app.use(
|
||||||
passport.initialize({
|
passport.initialize({
|
||||||
@@ -19,6 +22,10 @@ export default function configurePassport(app) {
|
|||||||
{
|
{
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
getSamlOptions: async function (request, done) {
|
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 { issuer } = request.params;
|
||||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||||
|
|
||||||
@@ -35,7 +42,7 @@ export default function configurePassport(app) {
|
|||||||
return done(null, authProvider.config);
|
return done(null, authProvider.config);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async function (request, user, done) {
|
async function signonVerify(request, user, done) {
|
||||||
const { issuer } = request.params;
|
const { issuer } = request.params;
|
||||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||||
|
|
||||||
@@ -53,10 +60,38 @@ export default function configurePassport(app) {
|
|||||||
user,
|
user,
|
||||||
authProvider
|
authProvider
|
||||||
);
|
);
|
||||||
|
|
||||||
|
request.samlSessionId = user.sessionIndex;
|
||||||
|
|
||||||
return done(null, foundUserWithIdentity);
|
return done(null, foundUserWithIdentity);
|
||||||
},
|
},
|
||||||
function (request, user, done) {
|
async function logoutVerify(request, user, done) {
|
||||||
return done(null, null);
|
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',
|
'/login/saml/:issuer/callback',
|
||||||
passport.authenticate('saml', {
|
passport.authenticate('saml', {
|
||||||
session: false,
|
session: false,
|
||||||
failureRedirect: '/',
|
|
||||||
failureFlash: true,
|
|
||||||
}),
|
}),
|
||||||
async (req, res) => {
|
async (request, response) => {
|
||||||
const token = await createAuthTokenByUserId(req.currentUser.id);
|
const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId);
|
||||||
|
|
||||||
const redirectUrl = new URL(
|
const redirectUrl = new URL(
|
||||||
`/login/callback?token=${token}`,
|
`/login/callback?token=${token}`,
|
||||||
appConfig.webAppUrl
|
appConfig.webAppUrl
|
||||||
).toString();
|
).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' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
userId: { type: 'string', format: 'uuid' },
|
userId: { type: 'string', format: 'uuid' },
|
||||||
token: { type: 'string', minLength: 32 },
|
token: { type: 'string', minLength: 32 },
|
||||||
|
samlSessionId: { type: ['string', 'null'] },
|
||||||
expiresIn: { type: 'integer' },
|
expiresIn: { type: 'integer' },
|
||||||
revokedAt: { type: ['string', 'null'], format: 'date-time' },
|
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() {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import appConfig from '../config/app.js';
|
import appConfig from '../config/app.js';
|
||||||
|
import axios from '../helpers/axios-with-proxy.js';
|
||||||
import Base from './base.js';
|
import Base from './base.js';
|
||||||
import Identity from './identity.ee.js';
|
import Identity from './identity.ee.js';
|
||||||
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
||||||
@@ -61,27 +63,71 @@ class SamlAuthProvider extends Base {
|
|||||||
});
|
});
|
||||||
|
|
||||||
static get virtualAttributes() {
|
static get virtualAttributes() {
|
||||||
return ['loginUrl'];
|
return ['loginUrl', 'remoteLogoutUrl'];
|
||||||
}
|
}
|
||||||
|
|
||||||
get loginUrl() {
|
get loginUrl() {
|
||||||
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
get config() {
|
get loginCallBackUrl() {
|
||||||
const callbackUrl = new URL(
|
return new URL(
|
||||||
`/login/saml/${this.issuer}/callback`,
|
`/login/saml/${this.issuer}/callback`,
|
||||||
appConfig.baseUrl
|
appConfig.baseUrl
|
||||||
).toString();
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get remoteLogoutUrl() {
|
||||||
|
return this.entryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
get config() {
|
||||||
return {
|
return {
|
||||||
callbackUrl,
|
callbackUrl: this.loginCallBackUrl,
|
||||||
cert: this.certificate,
|
cert: this.certificate,
|
||||||
entryPoint: this.entryPoint,
|
entryPoint: this.entryPoint,
|
||||||
issuer: this.issuer,
|
issuer: this.issuer,
|
||||||
signatureAlgorithm: this.signatureAlgorithm,
|
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;
|
export default SamlAuthProvider;
|
||||||
|
@@ -16024,6 +16024,11 @@ uuid@^9.0.0:
|
|||||||
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz"
|
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz"
|
||||||
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
|
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:
|
v8-compile-cache@^2.0.3:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"
|
resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"
|
||||||
|
Reference in New Issue
Block a user