Skip to content

Commit 22b95b6

Browse files
committed
feat: enhance authentication auditing and IP handling
- Implemented client IP resolution in the authentication process, allowing for better tracking of user login attempts. - Added auditing capabilities for authentication events, including success and failure logs with associated IP addresses. - Updated the authentication service to validate client IP against allowed networks, enhancing security measures. - Enhanced the audit schema to include IP information, providing more context for audit logs. - Introduced new unit tests to ensure the reliability of the authentication auditing features and IP validation logic.
1 parent cd73464 commit 22b95b6

27 files changed

Lines changed: 835 additions & 219 deletions

File tree

Makefile

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ IMG_NAME = "ghcr.io/libertech-fr/sesame-orchestrator"
1212
TEST_IMG_NAME = "sesame-orchestrator-test-local"
1313
BASE_NAME = "sesame"
1414
APP_NAME = "sesame-orchestrator"
15+
# Volume dédié : évite de monter les node_modules du mac (darwin) dans le conteneur Alpine (linux-*-musl),
16+
# ce qui casse les binaires optionnels (ex. oxc-parser / Nuxt). Nom sans guillemets (APP_NAME en contient).
17+
NODE_MODULES_VOLUME = sesame-orchestrator-node-modules
1518
PLATFORM = "linux/amd64"
1619

1720
include .env
@@ -109,8 +112,9 @@ dev: ## Start development environment
109112
-p $(APP_API_PORT):4000 \
110113
-p $(APP_API_PORT_SECURE):4443 \
111114
-v $(CURDIR):/data \
115+
-v $(NODE_MODULES_VOLUME):/data/node_modules \
112116
-v $(CURDIR)/etc/supervisor:/etc/supervisor \
113-
$(IMG_NAME) yarn start:dev
117+
$(IMG_NAME) sh -lc 'test -f node_modules/.yarn-integrity || yarn install --non-interactive; yarn start:dev'
114118

115119
debug: ## Start debug environment
116120
@docker run --rm -it \
@@ -131,7 +135,8 @@ debug: ## Start debug environment
131135
-p $(APP_API_DEBUG_PORT):9229 \
132136
-p $(APP_WEB_DEBUG_PORT):24678 \
133137
-v $(CURDIR):/data \
134-
$(IMG_NAME) yarn start:debug
138+
-v $(NODE_MODULES_VOLUME):/data/node_modules \
139+
$(IMG_NAME) sh -lc 'test -f node_modules/.yarn-integrity || yarn install --non-interactive; yarn start:debug'
135140

136141
install: ## Install dependencies
137142
@docker run -it --rm \
@@ -141,6 +146,7 @@ install: ## Install dependencies
141146
--platform $(PLATFORM) \
142147
--network dev \
143148
-v $(CURDIR):/data \
149+
-v $(NODE_MODULES_VOLUME):/data/node_modules \
144150
$(IMG_NAME) yarn install
145151

146152
exec: ## Run a shell in the container
@@ -152,6 +158,7 @@ exec: ## Run a shell in the container
152158
--network dev \
153159
-e SESAME_SENTRY_DSN=$(SESAME_SENTRY_DSN) \
154160
-v $(CURDIR):/data \
161+
-v $(NODE_MODULES_VOLUME):/data/node_modules \
155162
$(IMG_NAME) bash
156163

157164
build-test-image: ## Build local Docker image dedicated to tests

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"helmet": "^7.1.0",
5858
"hibp": "^14.1.2",
5959
"ioredis": "^5.4.1",
60+
"ip-range-check": "^0.2.0",
6061
"is-plain-object": "^5.0.0",
6162
"joi": "^18.0.1",
6263
"liquidjs": "^10.25.5",
@@ -107,6 +108,7 @@
107108
"@types/passport-http": "^0.3.11",
108109
"@types/passport-jwt": "^4.0.1",
109110
"@types/passport-local": "^1.0.38",
111+
"@types/request-ip": "^0.0.41",
110112
"@types/supertest": "^6.0.2",
111113
"@typescript-eslint/eslint-plugin": "^8.15.0",
112114
"@typescript-eslint/parser": "^8.15.0",

apps/api/src/_common/functions/resolve-client-ip.ts

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Request } from 'express';
2-
import requestIp from 'request-ip';
32

43
/**
54
* Lit une en-tête HTTP (string ou première entrée d'un tableau).
@@ -37,73 +36,11 @@ function normalizeIp(raw: string | undefined | null): string | null {
3736
return value.startsWith('::ffff:') ? value.slice(7) : value;
3837
}
3938

40-
function isLoopbackIp(ip: string | null): boolean {
41-
return ip === '127.0.0.1' || ip === '::1';
42-
}
43-
44-
function isPrivateIpv4(ip: string): boolean {
45-
const parts = ip.split('.').map((p): number => Number.parseInt(p, 10));
46-
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
47-
return false;
48-
}
49-
const [a, b] = parts;
50-
if (a === 10) {
51-
return true;
52-
}
53-
if (a === 172 && b >= 16 && b <= 31) {
54-
return true;
55-
}
56-
if (a === 192 && b === 168) {
57-
return true;
58-
}
59-
return false;
60-
}
61-
62-
function hasForwardingHeaders(req: Request): boolean {
63-
const forwarded = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip'] as const;
64-
return forwarded.some((name) => Boolean(normalizeIp(firstCsvSegment(headerString(req, name)) ?? headerString(req, name) ?? null)));
65-
}
66-
67-
function hostLooksLocal(req: Request): boolean {
68-
const host = headerString(req, 'host');
69-
if (!host) {
70-
return false;
71-
}
72-
const hostname = host.split(':')[0]?.trim().toLowerCase();
73-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
74-
}
75-
76-
function localDevFallbackIp(req: Request, peerIp: string | null): string | null {
77-
if (!hostLooksLocal(req) || hasForwardingHeaders(req) || !peerIp) {
78-
return null;
79-
}
80-
if (isLoopbackIp(peerIp) || isPrivateIpv4(peerIp)) {
81-
return null;
82-
}
83-
return '127.0.0.1';
84-
}
85-
8639
/** Paire TCP (souvent le dernier proxy / relai), normalisée. */
8740
function tcpPeerIp(req: Request): string | null {
8841
return normalizeIp(req.socket?.remoteAddress ?? null);
8942
}
9043

91-
/**
92-
* Nitro / certains proxies posent X-Forwarded-For = IP du pair TCP sans ajouter la vraie IP client.
93-
* Dans ce cas l’en-tête est trompeur : on l’ignore pour request-ip aussi.
94-
*/
95-
function requestForIpResolution(req: Request): Request {
96-
const peer = tcpPeerIp(req);
97-
const xffRaw = headerString(req, 'x-forwarded-for');
98-
const xffFirst = normalizeIp(firstCsvSegment(xffRaw) ?? (xffRaw?.trim() || null));
99-
if (!peer || !xffFirst || xffFirst !== peer) {
100-
return req;
101-
}
102-
const headers = { ...req.headers } as Request['headers'];
103-
delete headers['x-forwarded-for'];
104-
return { ...req, headers } as Request;
105-
}
106-
10744
/**
10845
* Résout l'IP client réelle derrière CDN / reverse-proxy (Cloudflare, nginx, etc.).
10946
* À utiliser pour l'auth et les audits ; combiner avec `trust proxy` sur Express si besoin.
@@ -114,10 +51,6 @@ function requestForIpResolution(req: Request): Request {
11451
*/
11552
export function resolveClientIp(req: Request): string | null {
11653
const peerIp = tcpPeerIp(req);
117-
const forcedLocalIp = localDevFallbackIp(req, peerIp);
118-
if (forcedLocalIp) {
119-
return forcedLocalIp;
120-
}
12154
const orderedHeaders = ['cf-connecting-ip', 'true-client-ip', 'x-real-ip', 'x-forwarded-for'] as const;
12255

12356
for (const name of orderedHeaders) {
@@ -134,11 +67,6 @@ export function resolveClientIp(req: Request): string | null {
13467
return ip;
13568
}
13669

137-
const fromLib = normalizeIp(requestIp.getClientIp(requestForIpResolution(req)) ?? null);
138-
if (fromLib) {
139-
return fromLib;
140-
}
141-
14270
const expressIp = normalizeIp(req.ip ?? null);
14371
if (expressIp) {
14472
return expressIp;

apps/api/src/_common/plugins/mongoose/history.plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Audits } from '~/core/audits/_schemas/audits.schema'
44
import * as _ from 'radash'
55
import { RequestContext } from "nestjs-request-context"
66
import { Logger } from "@nestjs/common"
7+
import { resolveClientIp } from '~/_common/functions/resolve-client-ip'
78

89
export const HISTORY_PLUGIN_BEFORE_KEY = '_auditBefore'
910

@@ -89,6 +90,12 @@ function resolveAgent(): any {
8990
}
9091
}
9192

93+
function resolveIp(): string | null {
94+
const req = RequestContext.currentContext?.req
95+
if (!req) return null
96+
return resolveClientIp(req)
97+
}
98+
9299
export function historyPlugin(schema: Schema, options: HistoryPluginOptions) {
93100
const defaultOptions = {
94101
auditsModelName: Audits.name,
@@ -126,11 +133,13 @@ export function historyPlugin(schema: Schema, options: HistoryPluginOptions) {
126133

127134
logger.log(`Creating audit log for ${mergedOptions.collectionName} ${after?._id ?? before?._id}`)
128135
const agent = resolveAgent()
136+
const ip = resolveIp()
129137
const AuditsModel: Model<any> = this.model(mergedOptions.auditsModelName!)
130138
await AuditsModel.create({
131139
coll: mergedOptions.collectionName,
132140
documentId: after?._id ?? before?._id,
133141
op: before ? AuditOperation.UPDATE : AuditOperation.INSERT,
142+
ip: ip ?? undefined,
134143
agent,
135144
data: after,
136145
changes,
@@ -159,11 +168,13 @@ export function historyPlugin(schema: Schema, options: HistoryPluginOptions) {
159168

160169
logger.log(`Creating audit log for ${mergedOptions.collectionName} ${after?._id ?? before?._id}`)
161170
const agent = resolveAgent()
171+
const ip = resolveIp()
162172
const AuditsModel: Model<any> = this.model.db.model(mergedOptions.auditsModelName!)
163173
await AuditsModel.create({
164174
coll: mergedOptions.collectionName,
165175
documentId: after?._id ?? before?._id,
166176
op: before ? AuditOperation.UPDATE : AuditOperation.INSERT,
177+
ip: ip ?? undefined,
167178
agent,
168179
data: after,
169180
changes,

apps/api/src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ export const validationSchema = Joi.object({
173173
SESAME_IDENTITY_DOUBLON_SEARCH_ATTRIBUTES: Joi
174174
.string()
175175
.default(''),
176+
177+
/**
178+
* Active trust proxy Express (1 hop) pour que req.ip / X-Forwarded-For reflètent le client derrière un reverse-proxy.
179+
* @see https://expressjs.com/en/guide/behind-proxies.html
180+
*/
181+
SESAME_TRUST_PROXY: Joi
182+
.string()
183+
.valid('0', '1', 'false', 'true', 'on', 'off', '')
184+
.default('0'),
176185
});
177186

178187
/**
@@ -201,6 +210,8 @@ export interface ConfigInstance {
201210
lang: string
202211
logLevel: string
203212
nameQueue: string
213+
/** Si true, Express applique trust proxy (1 hop) pour les adresses IP client derrière un proxy. */
214+
trustProxy: boolean
204215
bodyParser: {
205216
limit: string
206217
}
@@ -299,6 +310,7 @@ export default (): ConfigInstance => ({
299310
lang: process.env['LANG'] || 'en',
300311
logLevel: process.env['SESAME_LOG_LEVEL'] || 'info',
301312
nameQueue: process.env['SESAME_NAME_QUEUE'] || 'sesame',
313+
trustProxy: /^(1|true|on|yes)$/i.test(process.env['SESAME_TRUST_PROXY'] || ''),
302314
bodyParser: {
303315
limit: '500mb',
304316
},

apps/api/src/core/audits/_schemas/audits.schema.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export enum AuditOperation {
1818
DELETE = 'delete',
1919
/** Remplacement complet d'un enregistrement */
2020
REPLACE = 'replace',
21+
/** Tentative d'authentification */
22+
AUTHENTICATION = 'authentication',
2123
}
2224

2325
/**
@@ -98,15 +100,15 @@ export class Audits extends AbstractSchema {
98100
* Type d'opération effectuée sur le document.
99101
* Détermine la nature de la modification auditée.
100102
*
101-
* @type {'insert' | 'update' | 'delete' | 'replace'}
103+
* @type {'insert' | 'update' | 'delete' | 'replace' | 'authentication'}
102104
* @see {AuditOperation}
103105
*/
104106
@Prop({
105107
type: String,
106108
required: true,
107109
enum: AuditOperation,
108110
})
109-
public op!: 'insert' | 'update' | 'delete' | 'replace'
111+
public op!: 'insert' | 'update' | 'delete' | 'replace' | 'authentication'
110112

111113
/**
112114
* Agent (utilisateur ou système) qui a effectué l'opération.
@@ -142,6 +144,17 @@ export class Audits extends AbstractSchema {
142144
*/
143145
@Prop({ type: Array, of: Object })
144146
public changes?: ChangesType[]
147+
148+
/**
149+
* Adresse IP source associée à l'action auditée.
150+
* Principalement utilisée pour les événements d'authentification,
151+
* mais disponible globalement pour tous les audits.
152+
*
153+
* @type {string}
154+
* @optional
155+
*/
156+
@Prop({ type: String })
157+
public ip?: string
145158
}
146159

147160
/**

apps/api/src/core/audits/audits.controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class AuditsController extends AbstractController {
3737
coll: 1,
3838
documentId: 1,
3939
op: 1,
40+
ip: 1,
4041
agent: 1,
4142
'changes.path': 1,
4243
'changes.type': 1,
@@ -47,6 +48,7 @@ export class AuditsController extends AbstractController {
4748
coll: 1,
4849
documentId: 1,
4950
op: 1,
51+
ip: 1,
5052
agent: 1,
5153
changes: 1,
5254
metadata: 1,
@@ -56,6 +58,7 @@ export class AuditsController extends AbstractController {
5658
coll: 1,
5759
documentId: 1,
5860
op: 1,
61+
ip: 1,
5962
'agent.name': 1,
6063
'changes.path': 1,
6164
'changes.value': 1,

apps/api/src/core/audits/audits.service.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Injectable } from '@nestjs/common'
22
import { InjectModel } from '@nestjs/mongoose'
3-
import { Audits } from '~/core/audits/_schemas/audits.schema'
3+
import { Audits, AuditOperation } from '~/core/audits/_schemas/audits.schema'
44
import { Model } from 'mongoose'
55
import { AbstractServiceSchema } from '~/_common/abstracts/abstract.service.schema'
6+
import { Types } from 'mongoose'
67

78
/**
89
* Service de gestion des audits et de l'historique des enregistrements.
@@ -40,4 +41,44 @@ export class AuditsService extends AbstractServiceSchema<Audits> {
4041
const values = await this._model.distinct('coll', { coll: { $exists: true, $ne: null } })
4142
return values.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).sort()
4243
}
44+
45+
public async createAuthenticationAudit(params: {
46+
username: string
47+
ip: string | null
48+
result: 'success' | 'failure'
49+
reason: string
50+
agentId?: Types.ObjectId | string
51+
}): Promise<Audits> {
52+
const agentObjectId =
53+
params.agentId instanceof Types.ObjectId
54+
? params.agentId
55+
: typeof params.agentId === 'string' && Types.ObjectId.isValid(params.agentId)
56+
? new Types.ObjectId(params.agentId)
57+
: new Types.ObjectId()
58+
59+
const createdAt = new Date()
60+
61+
return this._model.create({
62+
coll: 'auth',
63+
documentId: agentObjectId,
64+
op: AuditOperation.AUTHENTICATION,
65+
ip: params.ip ?? undefined,
66+
agent: {
67+
$ref: 'agents',
68+
id: agentObjectId,
69+
name: params.username,
70+
},
71+
data: {
72+
event: 'authentication_attempt',
73+
username: params.username,
74+
ip: params.ip,
75+
result: params.result,
76+
reason: params.reason,
77+
},
78+
metadata: {
79+
createdBy: params.username,
80+
createdAt,
81+
},
82+
})
83+
}
4384
}

0 commit comments

Comments
 (0)