Skip to content

Commit

Permalink
refactor: use a separate exchange token
Browse files Browse the repository at this point in the history
  • Loading branch information
albanm committed Nov 22, 2024
1 parent 0a966ca commit e2d2e04
Show file tree
Hide file tree
Showing 24 changed files with 317 additions and 145 deletions.
3 changes: 2 additions & 1 deletion api/config/custom-environment-variables.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ module.exports = {
kid: 'JWT_KID',
jwtDurations: {
initialToken: 'JWT_DURATION_INITIAL',
exchangedToken: 'JWT_DURATION_EXCHANGED',
idToken: 'JWT_DURATION_ID',
exchangeToken: 'JWT_DURATION_EXCHANGE',
invitationToken: 'JWT_DURATION_INVIT',
partnerInvitationToken: 'JWT_DURATION_PARTNER_INVIT',
'2FAToken': 'JWT_DURATION_2FA'
Expand Down
3 changes: 2 additions & 1 deletion api/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ module.exports = {
},
jwtDurations: {
initialToken: '15m',
exchangedToken: '30d',
exchangeToken: '30d',
idToken: '15m',
invitationToken: '10d',
partnerInvitationToken: '10d',
'2FAToken': '30d'
Expand Down
3 changes: 2 additions & 1 deletion api/config/development.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ module.exports = {
anonymousContactForm: true,
jwtDurations: {
initialToken: '5m',
// exchangedToken: '5m',
// idToken: '5m',
// exchangeToken: '5m',
invitationToken: '5m'
},
i18n: {
Expand Down
5 changes: 4 additions & 1 deletion api/config/test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ module.exports = {
manageSites: true,
managePartners: true,
cipherPassword: 'test',
serveUi: false
serveUi: false,
observer: {
active: false
}
}
6 changes: 4 additions & 2 deletions api/config/type/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,16 @@
"type": "object",
"required": [
"initialToken",
"exchangedToken",
"idToken",
"exchangeToken",
"invitationToken",
"partnerInvitationToken",
"2FAToken"
],
"properties": {
"initialToken": { "type": "string" },
"exchangedToken": { "type": "string" },
"idToken": { "type": "string" },
"exchangeToken": { "type": "string" },
"invitationToken": { "type": "string" },
"partnerInvitationToken": { "type": "string" },
"2FAToken": { "type": "string" }
Expand Down
8 changes: 5 additions & 3 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"#i18n": "./i18n/index.ts"
},
"dependencies": {
"@data-fair/lib-express": "^1.9.0",
"@data-fair/lib-node": "^2.0.1",
"@data-fair/lib-express": "^1.10.0",
"@data-fair/lib-node": "^2.0.3",
"@data-fair/lib-utils": "^1.1.0",
"accept-language-parser": "^1.5.0",
"capitalize": "^2.0.4",
Expand All @@ -39,6 +39,7 @@
"memoizee": "^0.4.17",
"mjml": "^4.15.3",
"mongodb": "^6.8.0",
"ms": "^2.1.3",
"multer": "^1.4.5-lts.1",
"nanoid": "^5.0.7",
"node-cron": "^3.0.3",
Expand All @@ -51,6 +52,7 @@
"seedrandom": "^3.0.5",
"simple-oauth2": "^5.1.0",
"slugify": "^1.6.6",
"tinycolor2": "^1.6.0"
"tinycolor2": "^1.6.0",
"useragent": "^2.3.0"
}
}
6 changes: 0 additions & 6 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,6 @@ app.use('/api/2fa', twoFA)
app.use('/api/oauth-tokens', oauthTokens)
if (config.manageSites) app.use('/api/sites', sites)

// maintain compatibility for installed clients that have an older version of sd-vue
app.post('/api/session/keepalive', async (req, res, next) => {
await keepalive(req, res)
res.status(204).send()
})

app.use('/api/', (req, res) => {
res.status(404).send('unknown api endpoint')
})
Expand Down
37 changes: 19 additions & 18 deletions api/src/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import bodyParser from 'body-parser'
import { nanoid } from 'nanoid'
import Cookies from 'cookies'
import Debug from 'debug'
import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByHost, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, unsetSessionCookies, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider } from '#services'
import { sendMail, postUserIdentityWebhook, getOidcProviderId, oauthGlobalProviders, initOidcProvider, getOAuthProviderById, getOAuthProviderByState, reqSite, getSiteByHost, check2FASession, is2FAValid, cookie2FAName, getTokenPayload, prepareCallbackUrl, signToken, decodeToken, setSessionCookies, getDefaultUserOrg, unsetSessionCookies, keepalive, logoutOAuthToken, readOAuthToken, writeOAuthToken, authCoreProviderMemberInfo, patchCoreOAuthUser, unshortenInvit, getLimits, setNbMembersLimit, getSamlProviderId, saml2GlobalProviders, saml2ServiceProvider, initServerSession } from '#services'
import type { SdStorage } from '../storages/interface.ts'
import type { ActionPayload, User, UserWritable } from '#types'
import type { ActionPayload, ServerSession, User, UserWritable } from '#types'
import eventsLog, { type EventLogContext } from '@data-fair/lib-express/events-log.js'
import emailValidator from 'email-validator'
import { reqI18n, __all } from '#i18n'
Expand All @@ -27,9 +27,9 @@ export default router
// html forms
router.use(bodyParser.urlencoded({ limit: '100kb' }))

async function confirmLog (storage: SdStorage, user: User) {
async function confirmLog (storage: SdStorage, user: User, serverSession: ServerSession) {
if (!storage.readonly) {
await storage.updateLogged(user.id)
await storage.updateLogged(user.id, serverSession.id)
if (user.emailConfirmed === false) {
await storage.confirmEmail(user.id)
postUserIdentityWebhook(user)
Expand Down Expand Up @@ -211,8 +211,9 @@ router.post('/password', rejectCoreIdUser, async (req, res, next) => {
eventsLog.info('sd.auth.password.ok', 'a user successfully authenticated using password', logContext)
// this is used by data-fair app integrated login
if (req.is('application/x-www-form-urlencoded')) {
const token = await signToken(payload, config.jwtDurations.exchangedToken)
setSessionCookies(req, res, token, getDefaultUserOrg(user, orgId, depId))
const serverSession = initServerSession(req)
storage.addUserSession(user.id, serverSession)
await setSessionCookies(req, res, payload, serverSession.id, getDefaultUserOrg(user, orgId, depId))
debug(`Password based authentication of user ${user.name}, form mode`)
res.redirect(query.redirect || config.defaultLoginRedirect || reqSiteUrl(req) + '/simple-directory/me')
} else {
Expand Down Expand Up @@ -348,10 +349,12 @@ router.get('/token_callback', async (req, res, next) => {
const payload = getTokenPayload(user)
if (decoded.rememberMe) payload.rememberMe = 1
if (decoded.adminMode && payload.isAdmin) payload.adminMode = 1
const token = await signToken(payload, config.jwtDurations.exchangedToken)

await confirmLog(storage, user)
setSessionCookies(req, res, token, getDefaultUserOrg(user, query.id_token_org, query.id_token_dep))
const serverSession = initServerSession(req)
storage.addUserSession(user.id, serverSession)

await confirmLog(storage, user, serverSession)
await setSessionCookies(req, res, payload, serverSession.id, getDefaultUserOrg(user, query.id_token_org, query.id_token_dep))

eventsLog.info('sd.auth.callback.ok', 'a session was initialized after successful auth', logContext)

Expand All @@ -375,7 +378,7 @@ router.get('/token_callback', async (req, res, next) => {

// Used to extend an older but still valid token from a user
// TODO: deprecate this whole route, replaced by simpler /keepalive
router.post('/exchange', async (req, res, next) => {
/* router.post('/exchange', async (req, res, next) => {
const logContext: EventLogContext = { req }
const idToken = ((req.cookies && req.cookies.id_token) || (req.headers && req.headers.authorization && req.headers.authorization.split(' ').pop()) || req.query.id_token) as string | undefined
Expand Down Expand Up @@ -425,14 +428,17 @@ router.post('/exchange', async (req, res, next) => {
// TODO: sending token in response is deprecated and will be removed ?
res.set('Deprecation', 'true')
res.send(token)
})
}) */

router.post('/keepalive', async (req, res, next) => {
const loggedUser = reqUserAuthenticated(req)
const storage = storages.globalStorage
let user = loggedUser.id === '_superadmin' ? superadmin : await storage.getUser(loggedUser.id)
if (!user) throw httpError(404)

debug(`Exchange session token for user ${loggedUser.name}`)
await keepalive(req, res, user)

const coreIdProvider = user.coreIdProvider
if (coreIdProvider && coreIdProvider.type === 'oauth') {
let provider
Expand Down Expand Up @@ -477,9 +483,6 @@ router.post('/keepalive', async (req, res, next) => {
return res.status(401).send('Échec de prolongation de la session avec le fournisseur d\'identité principal')
}
}

debug(`Exchange session token for user ${loggedUser.name}`)
await keepalive(req, res, user)
res.status(204).send()
})

Expand Down Expand Up @@ -559,9 +562,8 @@ router.post('/asadmin', async (req, res, next) => {
payload.name += ' (administration)'
payload.asAdmin = { id: loggedUser.id, name: loggedUser.name }
delete payload.isAdmin
const token = await signToken(payload, config.jwtDurations.exchangedToken)
debug(`Exchange session token for user ${user.name} from an admin session`)
setSessionCookies(req, res, token, getDefaultUserOrg(user))
await setSessionCookies(req, res, payload, null, getDefaultUserOrg(user))

eventsLog.info('sd.auth.asadmin.ok', 'a session was created as a user from an admin session', logContext)

Expand All @@ -577,9 +579,8 @@ router.delete('/asadmin', async (req, res, next) => {
if (!user) return res.status(401).send('User does not exist anymore')
const payload = getTokenPayload(user)
payload.adminMode = 1
const token = await signToken(payload, config.jwtDurations.exchangedToken)
debug(`Exchange session token for user ${user.name} from an asAdmin session`)
setSessionCookies(req, res, token, getDefaultUserOrg(user))
await setSessionCookies(req, res, payload, null, getDefaultUserOrg(user))

eventsLog.info('sd.auth.asadmin.done', 'a session as a user from an admin session was terminated', logContext)

Expand Down
15 changes: 14 additions & 1 deletion api/src/auth/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import useragent from 'useragent'
import config from '#config'
import type { Site, User, Organization } from '#types'
import type { Request } from 'express'
import type { Site, User, Organization, ServerSession } from '#types'
import { type OAuthProvider } from '../oauth/service.ts'
import storages from '#storages'
import { nanoid } from 'nanoid'

type OAuthMemberInfo = { create: boolean, org?: Organization, readOnly: boolean, role: string }

Expand Down Expand Up @@ -69,3 +72,13 @@ export const patchCoreOAuthUser = async (provider: OAuthProvider, user: User, oa
}
return await storages.globalStorage.patchUser(user.id, patch)
}

export const initServerSession = (req: Request): ServerSession => {
const agentHeader = req.get('user-agent')
const deviceName = agentHeader ? useragent.parse(agentHeader).toString() : 'appareil inconnu'
return {
id: nanoid(),
createdAt: new Date().toISOString(),
deviceName
}
}
6 changes: 6 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type User } from '#types'
import type { ApiConfig } from '../config/type/index.ts'
import { assertValid } from '../config/type/index.ts'
import config from 'config'
import ms from 'ms'

export type { ApiConfig } from '../config/type/index.ts'

Expand Down Expand Up @@ -30,3 +31,8 @@ export const superadmin: User = {
email: apiConfig.adminCredentials?.email ?? '',
organizations: []
}

export const jwtDurations = {
idToken: ms(apiConfig.jwtDurations.idToken) / 1000,
exchangeToken: ms(apiConfig.jwtDurations.exchangeToken) / 1000
}
10 changes: 9 additions & 1 deletion api/src/mongo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite } from '#types'
import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite, ServerSession } from '#types'
import type { Avatar } from '#services'
import type { OrgInDb, UserInDb } from './storages/mongo.ts'

Expand Down Expand Up @@ -56,6 +56,14 @@ export class SdMongo {
return mongo.db.collection<OrganizationOverwrite>('ldap-organizations-overwrite')
}

get ldapUserSessions () {
return mongo.db.collection<{ _id: string, sessions: ServerSession[] }>('ldap-user-sessions')
}

get fileUserSessions () {
return mongo.db.collection<{ _id: string, sessions: ServerSession[] }>('file-user-sessions')
}

init = async () => {
await mongo.connect(config.mongo.url, config.mongo.options)
await mongo.configure({
Expand Down
5 changes: 4 additions & 1 deletion api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ server.keepAliveTimeout = (60 * 1000) + 1000
server.headersTimeout = (60 * 1000) + 2000

export const start = async () => {
session.init('http://localhost:' + config.port)
session.init('http://localhost:' + config.port, 'fr', (req) => {
// on keepalive route we accept a older token if it is accompanied by a valid exchange token
return req.method === 'POST' && req.url === '/api/auth/keepalive'
})
await mongo.init()
await locks.start(mongo.db)
await Promise.all([
Expand Down
21 changes: 16 additions & 5 deletions api/src/storages/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type { FindMembersParams, FindOrganizationsParams, FindUsersParams, SdSto
import type { FileParams } from '../../config/type/index.ts'
import config from '#config'
import userName from '../utils/user-name.ts'
import type { Member, Organization, Partner, User, UserWritable } from '#types'
import type { Member, Organization, Partner, User, UserWritable, ServerSession } from '#types'
import { readFileSync } from 'node:fs'
import type { Password } from '../utils/passwords.ts'
import type { PatchMemberBody } from '#doc/organizations/patch-member-req/index.ts'
import type { OrganizationPost } from '#doc/organizations/post-req/index.ts'
import type { UserRef } from '@data-fair/lib-express'
import type { TwoFA } from '#services'
import mongo from '#mongo'

type StoredOrganization = Omit<Organization, 'members'> & { members: { id: string, role: string, department?: string }[] }

Expand Down Expand Up @@ -45,7 +46,7 @@ function sortCompare (sort: Record<string, 1 | -1>) {
}

class FileStorage implements SdStorage {
private users: User[]
private users: Omit<User, 'sessions'>[]
private organizations: StoredOrganization[]

constructor (params: FileParams, org?: Organization) {
Expand Down Expand Up @@ -79,15 +80,17 @@ class FileStorage implements SdStorage {

async getUser (id: string) {
// Find user by strict equality of properties passed in filter
const user = this.users.find(u => u.id === id)
const user = this.users.find(u => u.id === id) as User | undefined
if (!user) return
user.sessions = (await mongo.fileUserSessions.findOne({ _id: user.id }))?.sessions
return this.cleanUser(user)
}

async getUserByEmail (email: string) {
// Case insensitive comparison
const user = this.users.find(u => u.email.toLowerCase() === email.toLowerCase())
const user = this.users.find(u => u.email.toLowerCase() === email.toLowerCase()) as User | undefined
if (!user) return
user.sessions = (await mongo.fileUserSessions.findOne({ _id: user.id }))?.sessions
return this.cleanUser(user)
}

Expand All @@ -97,6 +100,14 @@ class FileStorage implements SdStorage {
return user?.password as Password
}

async addUserSession (userId: string, serverSession: ServerSession): Promise<void> {
await mongo.ldapUserSessions.updateOne({ _id: userId }, { $push: { sessions: serverSession } }, { upsert: true })
}

async deleteUserSession (userId: string, serverSessionId: string): Promise<void> {
await mongo.ldapUserSessions.updateOne({ _id: userId }, { $pull: { sessions: { id: serverSessionId } } })
}

async findUsers (params: FindUsersParams) {
let filteredUsers = this.users.map(user => this.cleanUser(user))
const ids = params.ids
Expand All @@ -122,7 +133,7 @@ class FileStorage implements SdStorage {
let members: Member[] = (orga.members ?? []).map(m => {
const user = this.users.find(u => u.id === m.id)
if (!user) throw Error('unknown user as member ' + m.id)
return { ...m, name: user.name, email: user.email }
return { ...m, name: user.name, email: user.email, host: user.host, emailConfirmed: user.emailConfirmed, plannedDeletion: user.plannedDeletion }
})
if (params.q) {
const lq = params.q.toLowerCase()
Expand Down
6 changes: 4 additions & 2 deletions api/src/storages/interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { User, UserWritable, Organization, Site, Member, Partner } from '#types'
import type { User, UserWritable, Organization, Site, Member, Partner, ServerSession } from '#types'
import type { OrganizationPost } from '#doc/organizations/post-req/index.ts'
import type { PatchMemberBody } from '#doc/organizations/patch-member-req/index.ts'
import type { UserRef } from '@data-fair/lib-express'
Expand Down Expand Up @@ -48,12 +48,14 @@ export interface SdStorage {
getUser(userId: string): Promise<User | undefined>
createUser(user: UserWritable, byUser?: { id: string, name: string }): Promise<User>
getUserByEmail(email: string, site?: Site): Promise<User | undefined>
updateLogged(userId: string): Promise<void>
updateLogged(userId: string, serverSessionId: string): Promise<void>
confirmEmail(userId: string): Promise<void>
deleteUser(userId: string): Promise<void>
patchUser (userId: string, patch: any, byUser?: { id: string, name: string }): Promise<User>
findInactiveUsers (): Promise<User[]>
findUsersToDelete (): Promise<User[]>
addUserSession (userId: string, serverSession: ServerSession): Promise<void>
deleteUserSession (userId: string, serverSessionId: string): Promise<void>

getOrganization(ordId: string): Promise<Organization | undefined>
createOrganization(org: OrganizationPost, user: UserRef): Promise<Organization>
Expand Down
Loading

0 comments on commit e2d2e04

Please sign in to comment.