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

Session management enhancements #1894

Merged
merged 26 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d04cb91
cookie enhancements
holgerkoser May 29, 2024
9a4a11b
Merge branch 'master' into enh/session-management
holgerkoser May 29, 2024
bf1cb51
no ssl files in docker build
holgerkoser May 30, 2024
231dae3
Merge branch 'master' into enh/session-management
holgerkoser May 30, 2024
4464832
do not exit in vite config
holgerkoser Jun 3, 2024
bff0ef7
Merge branch 'enh/session-management' of github.com:gardener/dashboar…
holgerkoser Jun 3, 2024
bdb82f3
Signed cookies for authorization flow are not required
holgerkoser Jun 3, 2024
629e1f2
Merge branch 'master' into enh/session-management
holgerkoser Jun 3, 2024
731677c
Merge branch 'master' into enh/session-management
holgerkoser Jun 5, 2024
3ac79cc
PR feedback I
holgerkoser Jun 5, 2024
97b778e
Merge branch 'enh/session-management' of github.com:gardener/dashboar…
holgerkoser Jun 5, 2024
fe51998
fixed loadEnv usage
holgerkoser Jun 6, 2024
8b657c3
do not use loadEnv
holgerkoser Jun 6, 2024
f2bc492
reorder variables
holgerkoser Jun 6, 2024
e9e6a0c
delete `.env` file
holgerkoser Jun 6, 2024
c501354
fixed problem with login errors
holgerkoser Jun 6, 2024
1ad8f8d
fixed test
holgerkoser Jun 6, 2024
cc6cd55
PR feedback II
holgerkoser Jun 7, 2024
48e0e72
fixed hack
holgerkoser Jun 7, 2024
f2934ae
load ssl plugin only if no certs are generated
holgerkoser Jun 7, 2024
b42b162
Apply suggestions from code review
holgerkoser Jun 7, 2024
69822e5
Print to sdterr
holgerkoser Jun 7, 2024
69d98a7
Merge branch 'master' into enh/session-management
holgerkoser Jun 7, 2024
7860cb2
Apply suggestions from code review
holgerkoser Jun 10, 2024
d9624e6
Merge branch 'master' into enh/session-management
holgerkoser Jun 10, 2024
e80aa06
Improve script readability
holgerkoser Jun 10, 2024
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ scripts/bin
node_modules/
bin/
dist/
ssl/
tmp/
coverage/
my*
Expand Down
34 changes: 34 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion backend/__fixtures__/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const defaultConfig = {
ca,
client_id: 'dashboard',
redirect_uris: [
'http://localhost:8080/auth/callback'
'https://localhost:8443/auth/callback'
],
scope: 'openid email profile groups audience:server:client_id:dashboard audience:server:client_id:kube-kubectl',
clockTolerance: 42,
Expand Down
14 changes: 8 additions & 6 deletions backend/lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,18 @@ app.set('trust proxy', 1)
app.set('etag', false)
app.set('x-powered-by', false)

app.use(helmet.dnsPrefetchControl())
app.use(helmet.permittedCrossDomainPolicies())
app.use(helmet.noSniff())
app.use(helmet.hsts())
app.use(helmet.xDnsPrefetchControl())
app.use(helmet.xPermittedCrossDomainPolicies())
app.use(helmet.xContentTypeOptions())
if (process.env.NODE_ENV !== 'development') {
app.use(helmet.strictTransportSecurity())
}
app.use(noCache(STATIC_PATHS))
app.use('/auth', auth.router)
app.use('/webhook', githubWebhook.router)
app.use('/api', api.router)

app.use(helmet.xssFilter())
app.use(helmet.xXssProtection())
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ['\'self\''],
Expand All @@ -88,7 +90,7 @@ app.use(expressStaticGzip(PUBLIC_DIRNAME, {
}))
app.use(STATIC_PATHS, notFound)

app.use(helmet.frameguard({
app.use(helmet.xFrameOptions({
action: 'deny'
}))
app.use(historyFallback(INDEX_FILENAME))
Expand Down
13 changes: 8 additions & 5 deletions backend/lib/security/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

'use strict'

const HOST_PREFIX = '__Host-'

module.exports = {
COOKIE_HEADER_PAYLOAD: 'gHdrPyl',
COOKIE_SIGNATURE: 'gSgn',
COOKIE_TOKEN: 'gTkn',
COOKIE_CODE_VERIFIER: 'gCdVrfr',
GARDENER_AUDIENCE: 'gardener'
GARDENER_AUDIENCE: 'gardener',
COOKIE_HEADER_PAYLOAD: HOST_PREFIX + 'gHdrPyl',
COOKIE_SIGNATURE: HOST_PREFIX + 'gSgn',
COOKIE_TOKEN: HOST_PREFIX + 'gTkn',
COOKIE_CODE_VERIFIER: HOST_PREFIX + 'gCdVrfr',
COOKIE_STATE: HOST_PREFIX + 'gStt'
}
67 changes: 40 additions & 27 deletions backend/lib/security/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

const assert = require('assert').strict
const crypto = require('crypto')
const { split, join, some, every, includes, head, chain, pick } = require('lodash')
const { split, join, includes, head, chain, pick } = require('lodash')
const { Issuer, custom, generators, TokenSet } = require('openid-client')
const pRetry = require('p-retry')
const pTimeout = require('p-timeout')
Expand Down Expand Up @@ -42,6 +42,7 @@ const {
COOKIE_TOKEN,
COOKIE_SIGNATURE,
COOKIE_CODE_VERIFIER,
COOKIE_STATE,
GARDENER_AUDIENCE
} = require('./constants')

Expand All @@ -66,14 +67,6 @@ if (ca) {
httpOptions.ca = ca
}

const hasHttpsProtocol = uri => /^https:/.test(uri)
const secure = some(redirectUris, hasHttpsProtocol)
if (secure) {
assert.ok(every(redirectUris, hasHttpsProtocol), 'All \'redirect_uris\' must have the same protocol')
} else if (process.env.NODE_ENV === 'production') {
logger.warn('The Gardener Dashboard is running in production but you don\'t use Transport Layer Security (TLS) to secure the connection and the data')
}

let clientPromise

/**
Expand Down Expand Up @@ -162,9 +155,16 @@ async function authorizationUrl (req, res) {
const redirectPath = frontendRedirectUrl.pathname + frontendRedirectUrl.search
const redirectOrigin = frontendRedirectUrl.origin
const backendRedirectUri = getBackendRedirectUri(redirectOrigin)
const state = encodeState({
const state = generators.state()
res.cookie(COOKIE_STATE, {
redirectPath,
redirectOrigin
redirectOrigin,
state
}, {
secure: true,
httpOnly: true,
maxAge: 180_000, // cookie will be removed after 3 minutes
sameSite: 'Lax'
})
const client = await exports.getIssuerClient()
if (!includes(redirectUris, backendRedirectUri)) {
Expand All @@ -179,11 +179,10 @@ async function authorizationUrl (req, res) {
const codeChallengeMethod = getCodeChallengeMethod(client)
const codeVerifier = generators.codeVerifier()
res.cookie(COOKIE_CODE_VERIFIER, codeVerifier, {
secure,
secure: true,
httpOnly: true,
maxAge: 300000, // cookie will be removed after 5 minutes
sameSite: 'Lax',
path: '/auth/callback'
maxAge: 180_000, // cookie will be removed after 3 minutes
sameSite: 'Lax'
})
switch (codeChallengeMethod) {
case 'S256':
Expand Down Expand Up @@ -254,12 +253,12 @@ async function setCookies (res, tokenSet) {
const accessToken = tokenSet.access_token
const [header, payload, signature] = split(accessToken, '.')
res.cookie(COOKIE_HEADER_PAYLOAD, join([header, payload], '.'), {
secure,
secure: true,
expires: undefined,
sameSite: 'Lax'
})
res.cookie(COOKIE_SIGNATURE, signature, {
secure,
secure: true,
httpOnly: true,
expires: undefined,
sameSite: 'Lax'
Expand All @@ -270,7 +269,7 @@ async function setCookies (res, tokenSet) {
}
const encryptedValues = await encrypt(values.join(','))
res.cookie(COOKIE_TOKEN, encryptedValues, {
secure,
secure: true,
httpOnly: true,
expires: undefined,
sameSite: 'Lax'
Expand All @@ -279,19 +278,29 @@ async function setCookies (res, tokenSet) {
}

async function authorizationCallback (req, res) {
const { code, state } = req.query
const parameters = { code }
const options = {
secure: true,
path: '/'
}
const stateObject = {}
if (COOKIE_STATE in req.cookies) {
Object.assign(stateObject, req.cookies[COOKIE_STATE])
res.clearCookie(COOKIE_STATE, options)
}
const {
redirectPath,
redirectOrigin
} = decodeState(state)
redirectOrigin,
state
} = stateObject
const parameters = pick(req.query, ['code', 'state'])
const backendRedirectUri = getBackendRedirectUri(redirectOrigin)
const checks = {
response_type: 'code'
response_type: 'code',
state
}
if (COOKIE_CODE_VERIFIER in req.cookies) {
checks.code_verifier = req.cookies[COOKIE_CODE_VERIFIER]
res.clearCookie(COOKIE_CODE_VERIFIER)
res.clearCookie(COOKIE_CODE_VERIFIER, options)
}
const tokenSet = await authorizationCodeExchange(backendRedirectUri, parameters, checks)
await setCookies(res, tokenSet)
Expand Down Expand Up @@ -473,9 +482,13 @@ function authenticate (options = {}) {
}

function clearCookies (res) {
res.clearCookie(COOKIE_HEADER_PAYLOAD)
res.clearCookie(COOKIE_SIGNATURE)
res.clearCookie(COOKIE_TOKEN)
const options = {
secure: true,
path: '/'
}
res.clearCookie(COOKIE_HEADER_PAYLOAD, options)
res.clearCookie(COOKIE_SIGNATURE, options)
res.clearCookie(COOKIE_TOKEN, options)
}

exports = module.exports = {
Expand Down
5 changes: 3 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
"server.js"
],
"scripts": {
"serve": "node server.js",
"serve": "cross-env NODE_ENV=development node server.js",
"lint": "eslint --ext .js server.js .",
"test": "jest",
"test": "cross-env NODE_ENV=test jest",
"test-coverage": "yarn test --coverage"
},
"dependencies": {
Expand Down Expand Up @@ -74,6 +74,7 @@
"devDependencies": {
"@gardener-dashboard/test-utils": "workspace:^",
"abort-controller": "^3.0.0",
"cross-env": "^7.0.3",
"dockerfile-ast": "^0.6.1",
"eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0",
Expand Down
19 changes: 6 additions & 13 deletions backend/test/acceptance/auth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

const { pick, head } = require('lodash')
const assert = require('assert').strict
const { TokenSet } = require('openid-client')
const { TokenSet, generators } = require('openid-client')
const setCookieParser = require('set-cookie-parser')
const { mockRequest } = require('@gardener-dashboard/request')

Expand All @@ -17,7 +17,6 @@ const {
COOKIE_HEADER_PAYLOAD,
COOKIE_SIGNATURE,
COOKIE_TOKEN,
decodeState,
setCookies,
sign,
decrypt,
Expand Down Expand Up @@ -118,6 +117,7 @@ describe('auth', function () {
let agent
let getIssuerClientStub
let mockRefresh
let mockState

beforeAll(() => {
agent = createAgent()
Expand All @@ -134,34 +134,30 @@ describe('auth', function () {
})
getIssuerClientStub = jest.spyOn(security, 'getIssuerClient').mockResolvedValue(client)
mockRefresh = jest.spyOn(client, 'refresh')
mockState = jest.spyOn(generators, 'state').mockReturnValue('state')
})

it('should redirect to authorization url without frontend redirectUrl', async function () {
const redirectPath = '/'
const redirectUri = head(oidc.redirect_uris)
const redirectOrigin = new URL(redirectUri).origin

const res = await agent
.get('/auth')
.redirects(0)
.expect(302)

expect(getIssuerClientStub).toBeCalledTimes(1)
expect(mockState).toBeCalledTimes(1)
const url = new URL(res.headers.location)
expect(url.searchParams.get('client_id')).toBe(oidc.client_id)
expect(url.searchParams.get('redirect_uri')).toBe(redirectUri)
expect(url.searchParams.get('scope')).toBe(oidc.scope)
const state = url.searchParams.get('state')
expect(decodeState(state)).toEqual({
redirectPath,
redirectOrigin
})
expect(state).toEqual('state')
})

it('should redirect to authorization url with frontend redirectUrl', async function () {
const redirectPath = '/namespace/garden-foo/administration'
const redirectUri = head(oidc.redirect_uris)
const redirectOrigin = new URL(redirectUri).origin
const redirectUrl = new URL(redirectPath, redirectUri).toString()

const res = await agent
Expand All @@ -174,10 +170,7 @@ describe('auth', function () {
const url = new URL(res.headers.location)
expect(url.searchParams.get('redirect_uri')).toBe(redirectUri)
const state = url.searchParams.get('state')
expect(decodeState(state)).toEqual({
redirectPath,
redirectOrigin
})
expect(state).toEqual('state')
})

it('should fail to redirect to authorization url', async function () {
Expand Down
Loading