Skip to content

Commit dafcc48

Browse files
Selfie age verification!
1 parent 42bd0bd commit dafcc48

19 files changed

Lines changed: 3790 additions & 199 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,7 @@ tunnel/server/target/*
7777
tunnel/client/target/*
7878

7979
# UI Agents skills
80-
.agents/*
80+
.agents/*
81+
82+
# Age Verification Model
83+
/backend/model

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ I have included system files inside of /systemd folder that are used for https:/
124124

125125
- The API routes are documented in `example.com/openapi` and should be used by the
126126
frontend code.
127+
- You might need to run `npm rebuild @tensorflow/tfjs-node --build-from-source` on backend to make selfie verification work!
127128

128129
> You may view API routes without deploying at https://backend.ecli.app/openapi for production or https://backend.canary.ecli.app/openapi for canary.
129130
> Canary version of EcliPanel are offline during non developmet periods.

backend/bun.lock

Lines changed: 189 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@
4646
"redis": "latest",
4747
"reflect-metadata": "latest",
4848
"sharp": "^0.34.5",
49+
"@canvas/image": "latest",
50+
"@tensorflow/tfjs-node": "latest",
51+
"@vladmandic/face-api": "latest",
4952
"speakeasy": "latest",
5053
"srvx": "latest",
5154
"ssh2": "^1.17.0",

backend/pnpm-lock.yaml

Lines changed: 799 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/handlers/authHandler.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,10 +1153,13 @@ export async function authRoutes(app: any, prefix = '') {
11531153
if (user) {
11541154
user.portalType = 'educational';
11551155
user.educationLimits = {
1156-
memory: eduPlan?.memory ?? 2048,
1157-
disk: eduPlan?.disk ?? 20480,
1158-
cpu: eduPlan?.cpu ?? 400,
1159-
serverLimit: eduPlan?.serverLimit ?? 2,
1156+
memory: eduPlan?.memory ?? 4096,
1157+
disk: eduPlan?.disk ?? 51200,
1158+
cpu: eduPlan?.cpu ?? 600,
1159+
serverLimit: eduPlan?.serverLimit ?? 3,
1160+
portCount: eduPlan?.portCount ?? 3,
1161+
emailSendDailyLimit: eduPlan?.emailSendDailyLimit ?? 10,
1162+
emailSendQueueLimit: eduPlan?.emailSendQueueLimit ?? 10,
11601163
};
11611164
user.limits = user.educationLimits ? { ...user.educationLimits } : null;
11621165
ctx.log.info({ eduPlan: eduPlan?.id ?? null, limits: user.limits }, 'Applying educational plan limits to user');
@@ -1285,10 +1288,13 @@ export async function authRoutes(app: any, prefix = '') {
12851288
user.studentVerifiedAt = new Date();
12861289

12871290
const defaultEduLimits = {
1288-
memory: eduPlan?.memory ?? 2048,
1289-
disk: eduPlan?.disk ?? 20480,
1290-
cpu: eduPlan?.cpu ?? 400,
1291-
serverLimit: eduPlan?.serverLimit ?? 2,
1291+
memory: eduPlan?.memory ?? 4096,
1292+
disk: eduPlan?.disk ?? 51200,
1293+
cpu: eduPlan?.cpu ?? 600,
1294+
serverLimit: eduPlan?.serverLimit ?? 3,
1295+
portCount: eduPlan?.portCount ?? 3,
1296+
emailSendDailyLimit: eduPlan?.emailSendDailyLimit ?? 10,
1297+
emailSendQueueLimit: eduPlan?.emailSendQueueLimit ?? 10,
12921298
};
12931299

12941300
const existingLimits = user.educationLimits || {};
@@ -1299,6 +1305,9 @@ export async function authRoutes(app: any, prefix = '') {
12991305
disk: Math.max(existingLimits.disk || 0, currentBaseLimits.disk || 0, defaultEduLimits.disk),
13001306
cpu: Math.max(existingLimits.cpu || 0, currentBaseLimits.cpu || 0, defaultEduLimits.cpu),
13011307
serverLimit: Math.max(existingLimits.serverLimit || 0, currentBaseLimits.serverLimit || 0, defaultEduLimits.serverLimit),
1308+
portCount: Math.max(existingLimits.portCount || 0, currentBaseLimits.portCount || 0, defaultEduLimits.portCount),
1309+
emailSendDailyLimit: Math.max(existingLimits.emailSendDailyLimit || 0, currentBaseLimits.emailSendDailyLimit || 0, defaultEduLimits.emailSendDailyLimit),
1310+
emailSendQueueLimit: Math.max(existingLimits.emailSendQueueLimit || 0, currentBaseLimits.emailSendQueueLimit || 0, defaultEduLimits.emailSendQueueLimit),
13021311
};
13031312

13041313
if (!keepExistingPaidTier) {

backend/src/handlers/idVerificationHandler.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { IDVerification } from '../models/idVerification.entity';
33
import { authenticate } from '../middleware/auth';
44
import { hasPermissionSync } from '../middleware/authorize';
55
import { User } from '../models/user.entity';
6-
import { canPerformIdVerification } from '../utils/eu';
6+
import { canPerformIdVerification, getMinimumAgeForCountry } from '../utils/eu';
77
import { encryptBuffer } from '../utils/crypto';
88
import { encryptBufferWithWorker } from '../workers/cryptoWorker';
9+
import { estimateAgeFromSelfie } from '../services/faceApiService';
910
import path from 'path';
1011
import fs from 'fs';
1112
import { pipeline } from 'stream/promises';
@@ -85,6 +86,123 @@ export async function idVerificationRoutes(app: any, prefix = '') {
8586
detail: { summary: 'Submit ID verification', description: 'User submits scanned ID and selfie for manual review.', tags: ['Identity'] }
8687
});
8788

89+
app.post(prefix + '/id-verification/age-selfie', async (ctx: any) => {
90+
const user = ctx.user as User;
91+
if (!user) {
92+
ctx.set.status = 401;
93+
return { error: 'Unauthorized' };
94+
}
95+
96+
const body = (ctx.body || {}) as any;
97+
const selfie = Array.isArray(body.selfie) ? body.selfie[0] : body.selfie;
98+
if (!selfie) {
99+
ctx.set.status = 400;
100+
return { error: 'selfie_required', message: 'A selfie image is required for age verification.' };
101+
}
102+
103+
const settings = user.settings && typeof user.settings === 'object' ? { ...user.settings } : {};
104+
const attempts = Number(settings.ageVerificationSelfieAttempts ?? 0);
105+
if (attempts >= 3) {
106+
ctx.set.status = 403;
107+
return { error: 'selfie_attempts_exceeded', message: 'Maximum selfie verification attempts reached.' };
108+
}
109+
110+
const dateOfBirth = body.dateOfBirth ? new Date(String(body.dateOfBirth)) : user.dateOfBirth ? new Date(String(user.dateOfBirth)) : null;
111+
if (!dateOfBirth || isNaN(dateOfBirth.getTime())) {
112+
ctx.set.status = 400;
113+
return { error: 'date_of_birth_required', message: 'Your date of birth is required for selfie age verification.' };
114+
}
115+
const age = ((): number | null => {
116+
const now = new Date();
117+
let calculated = now.getUTCFullYear() - dateOfBirth.getUTCFullYear();
118+
const monthDiff = now.getUTCMonth() - dateOfBirth.getUTCMonth();
119+
const dayDiff = now.getUTCDate() - dateOfBirth.getUTCDate();
120+
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) calculated -= 1;
121+
return Number.isFinite(calculated) ? calculated : null;
122+
})();
123+
if (age === null) {
124+
ctx.set.status = 400;
125+
return { error: 'invalid_date_of_birth', message: 'dateOfBirth must be a valid date string in YYYY-MM-DD format.' };
126+
}
127+
128+
try {
129+
const data = await selfie.arrayBuffer();
130+
const buffer = Buffer.from(data);
131+
const predictedAge = await estimateAgeFromSelfie(buffer);
132+
if (predictedAge === null) {
133+
ctx.set.status = 400;
134+
return { error: 'no_face_detected', message: 'Could not detect a face in the selfie. Please try again.' };
135+
}
136+
137+
const effectiveCountry = typeof body.billingCountry === 'string' ? body.billingCountry : user.billingCountry;
138+
const minimumAge = await getMinimumAgeForCountry(effectiveCountry);
139+
if (age < minimumAge) {
140+
await AppDataSource.getRepository(User).save({
141+
id: user.id,
142+
suspended: true,
143+
fraudFlag: true,
144+
fraudReason: `Underage account (<${minimumAge} years)`,
145+
fraudDetectedAt: new Date(),
146+
});
147+
ctx.set.status = 400;
148+
return { error: 'minimum_age', message: `Users must be at least ${minimumAge} years old.` };
149+
}
150+
151+
const maxDelta = 11;
152+
const difference = Math.abs(predictedAge - age);
153+
const remaining = Math.max(0, 3 - attempts - 1);
154+
155+
if (difference > maxDelta) {
156+
settings.ageVerificationSelfieAttempts = attempts + 1;
157+
settings.ageVerificationSelfieLastAttemptAt = new Date().toISOString();
158+
await AppDataSource.getRepository(User).save({ id: user.id, settings });
159+
160+
ctx.set.status = 400;
161+
return {
162+
error: 'age_mismatch',
163+
message: `Estimated age ${predictedAge.toFixed(1)} does not match your DOB age ${age}. Please ensure your face is clearly visible in the selfie and try again.`,
164+
attempts: settings.ageVerificationSelfieAttempts,
165+
remaining,
166+
};
167+
}
168+
169+
settings.ageVerificationSelfieAttempts = 0;
170+
settings.ageVerificationSelfieVerifiedAt = new Date().toISOString();
171+
172+
const update: any = { id: user.id, settings };
173+
if (!user.dateOfBirth) {
174+
update.dateOfBirth = dateOfBirth;
175+
}
176+
await AppDataSource.getRepository(User).save(update);
177+
178+
return {
179+
success: true,
180+
age: predictedAge,
181+
difference,
182+
maxError: maxDelta,
183+
attempts: 0,
184+
remaining: 3,
185+
};
186+
} catch (err: any) {
187+
ctx.log.error({ err: err?.message || err }, 'Selfie age verification failed');
188+
ctx.set.status = 500;
189+
return {
190+
error: 'age_verification_failed',
191+
message: 'Failed to estimate age from the selfie. Please try again later.',
192+
details: String(err?.message || err || 'unknown error'),
193+
};
194+
}
195+
}, { beforeHandle: authenticate,
196+
body: t.Any(),
197+
response: {
198+
200: t.Object({ success: t.Boolean(), age: t.Number(), difference: t.Number(), maxError: t.Number(), attempts: t.Number(), remaining: t.Number() }),
199+
400: t.Object({ error: t.String(), message: t.String(), details: t.Optional(t.String()) }),
200+
401: t.Object({ error: t.String() }),
201+
403: t.Object({ error: t.String(), message: t.Optional(t.String()) }),
202+
},
203+
detail: { summary: 'Verify age from selfie', description: 'Estimate a user age from selfie data and compare against the provided date of birth.', tags: ['Identity'] }
204+
});
205+
88206
app.get(prefix + '/id-verification/:id', async (ctx: any) => {
89207
const userId = Number(ctx.params['id']);
90208
const requester = ctx.user;

backend/src/handlers/planHandler.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export async function planRoutes(app: any, prefix = '') {
138138
if (plan.serverLimit != null) planLimits.serverLimit = Number(plan.serverLimit);
139139
if (plan.databases != null) planLimits.databases = Number(plan.databases);
140140
if (plan.backups != null) planLimits.backups = Number(plan.backups);
141+
if (plan.emailSendDailyLimit != null) planLimits.emailSendDailyLimit = Number(plan.emailSendDailyLimit);
142+
if (plan.emailSendQueueLimit != null) planLimits.emailSendQueueLimit = Number(plan.emailSendQueueLimit);
143+
if (plan.portCount != null) planLimits.portCount = Number(plan.portCount);
141144

142145
const isCustomLimits = (userLimits: Record<string, any> | null | undefined, planLimits: Record<string, number>) => {
143146
if (!userLimits || Object.keys(userLimits).length === 0) return false;

backend/src/handlers/userHandler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,9 +2120,10 @@ export async function userRoutes(app: any, prefix = '') {
21202120
}
21212121

21222122
if ('dateOfBirth' in payload) {
2123-
if (user.idVerified && !isAdmin && requester.id === user.id) {
2123+
const hasVerifiedDob = Boolean(user.idVerified || user.settings?.ageVerificationSelfieVerifiedAt);
2124+
if (hasVerifiedDob && !isAdmin && requester.id === user.id) {
21242125
ctx.set.status = 403;
2125-
return { error: 'date_of_birth_locked', message: 'Date of birth is locked after identity verification and can only be updated by an administrator.' };
2126+
return { error: 'date_of_birth_locked', message: 'Date of birth is locked after identity or selfie verification and can only be updated by an administrator.' };
21262127
}
21272128
const dateOfBirth = parseDateOfBirth(payload.dateOfBirth);
21282129
if (!dateOfBirth) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
let initialized = false
5+
let faceapi: any = null
6+
let tf: any = null
7+
let imageLib: any = null
8+
9+
const MODEL_PATH = process.env.FACE_API_MODEL_PATH
10+
? String(process.env.FACE_API_MODEL_PATH)
11+
: path.join(process.cwd(), 'model')
12+
const MODEL_BASE_URL = process.env.FACE_API_MODEL_BASE_URL || 'https://raw.githubusercontent.com/vladmandic/face-api/master/model'
13+
const REQUIRED_MODEL_FILES = [
14+
'ssd_mobilenetv1_model-weights_manifest.json',
15+
'ssd_mobilenetv1_model.bin',
16+
'age_gender_model-weights_manifest.json',
17+
'age_gender_model.bin',
18+
'face_landmark_68_model-weights_manifest.json',
19+
'face_landmark_68_model.bin',
20+
]
21+
22+
async function downloadFile(filename: string): Promise<void> {
23+
const url = `${MODEL_BASE_URL}/${filename}`
24+
const response = await fetch(url)
25+
if (!response.ok) {
26+
throw new Error(`Failed to download FaceAPI model file ${filename}: ${response.status} ${response.statusText}`)
27+
}
28+
const data = Buffer.from(await response.arrayBuffer())
29+
fs.writeFileSync(path.join(MODEL_PATH, filename), data)
30+
}
31+
32+
async function ensureModelFiles(): Promise<void> {
33+
if (!fs.existsSync(MODEL_PATH)) {
34+
fs.mkdirSync(MODEL_PATH, { recursive: true })
35+
}
36+
37+
for (const filename of REQUIRED_MODEL_FILES) {
38+
const filePath = path.join(MODEL_PATH, filename)
39+
if (!fs.existsSync(filePath)) {
40+
await downloadFile(filename)
41+
}
42+
}
43+
}
44+
45+
async function initFaceApi(): Promise<void> {
46+
if (initialized) return
47+
48+
// @ts-ignore
49+
const tfModule = await import('@tensorflow/tfjs-node')
50+
// @ts-ignore
51+
const faceApiModule = await import('@vladmandic/face-api')
52+
// @ts-ignore
53+
const imageModule = await import('@canvas/image')
54+
55+
tf = tfModule?.default || tfModule
56+
faceapi = faceApiModule?.default || faceApiModule
57+
imageLib = imageModule?.default || imageModule
58+
if (!faceapi.tf) {
59+
faceapi.tf = tf
60+
}
61+
62+
await ensureModelFiles()
63+
64+
await faceapi.nets.ssdMobilenetv1.loadFromDisk(MODEL_PATH)
65+
await faceapi.nets.ageGenderNet.loadFromDisk(MODEL_PATH)
66+
await faceapi.nets.faceLandmark68Net.loadFromDisk(MODEL_PATH)
67+
68+
initialized = true
69+
}
70+
71+
export async function estimateAgeFromSelfie(buffer: Buffer): Promise<number | null> {
72+
await initFaceApi()
73+
74+
const canvas = await imageLib.imageFromBuffer(buffer)
75+
const imageData = imageLib.getImageData(canvas)
76+
77+
const tensor = tf.tidy(() => {
78+
const rgba = tf.tensor(Array.from(imageData?.data || []), [canvas.height, canvas.width, 4], 'int32')
79+
const channels = tf.split(rgba, 4, 2)
80+
const rgb = tf.stack([channels[0], channels[1], channels[2]], 2)
81+
const reshape = tf.reshape(rgb, [1, canvas.height, canvas.width, 3])
82+
return reshape
83+
})
84+
85+
try {
86+
const options = new faceapi.SsdMobilenetv1Options({ minConfidence: 0.3, maxResults: 1 })
87+
const result = await faceapi.detectSingleFace(tensor, options).withFaceLandmarks().withAgeAndGender()
88+
if (!result || typeof result.age !== 'number') {
89+
return null
90+
}
91+
return Number(result.age)
92+
} finally {
93+
tensor.dispose()
94+
}
95+
}

0 commit comments

Comments
 (0)