taskori-auth est le point d'entrée unique pour l'identité Taskori. Il gère l'inscription, la connexion (avec 2FA TOTP optionnel), l'émission des JWT, la rotation des refresh tokens, la vérification d'email, la réinitialisation de mot de passe et le système d'invitation de membres. Il répond également à l'URL principale taskori.app pour toutes les pages publiques.
Les autres micro-services (taskori-manage, taskori-ts, taskori-project, taskori-share) font confiance au JWT signé par ce service — ils n'ont pas leur propre base d'utilisateurs.
| Couche | Technologie |
|---|---|
| Langage | PHP 8.1 |
| Serveur HTTP | Apache 2 (mod_rewrite activé) |
| JWT | firebase/php-jwt ^6.10, algorithme HS256 |
| 2FA | robthree/twofactorauth ^2.0 (TOTP RFC 6238) |
phpmailer/phpmailer ^6.9 via SMTP |
|
| Sessions | Redis (PHP extension redis via PECL) |
| Base de données | MariaDB / MySQL — base taskori_auth |
| Reverse proxy | Traefik (labels dans docker-compose) |
| Container | Docker, image registry.lorva.dev/taskori/taskori-auth |
| Supervision | Supervisord |
| i18n | FR / EN / ES (détection Accept-Language) |
taskori-auth/
├── api/
│ ├── auth/
│ │ ├── login.php — POST /api/auth/login (API headless)
│ │ ├── refresh.php — POST /api/auth/refresh
│ │ ├── verify.php — GET|POST /api/auth/verify
│ │ ├── logout.php
│ │ └── register.php
│ ├── permissions.php — POST /api/permissions
│ └── invalidate-permissions.php
├── pages/ — Pages HTML (interface web)
│ ├── login.php
│ ├── register.php
│ ├── account.php
│ ├── invite.php
│ ├── users.php
│ ├── company.php
│ ├── forgot-password.php
│ ├── reset-password.php
│ ├── verify-email.php
│ └── resend-verification.php
├── lib/
│ ├── jwt.php — JWTHelper, cookies, validation redirect_uri
│ ├── auth_middleware.php — Guard pour pages protégées
│ ├── company_functions.php — CRUD companies + provision bucket share
│ ├── invitation_functions.php — Création/vérification invitations
│ ├── plan.php — Limites par plan, effective_plan()
│ ├── role_functions.php — Seed rôles company, permissions
│ ├── mail_functions.php — Emails transactionnels PHPMailer
│ ├── db.php — Singleton PDO
│ └── i18n.php
├── config/
│ ├── config.php — Bootstrap constantes
│ └── conf.php.tpl — Template généré par entrypoint.sh
├── sql/
│ ├── schema.sql
│ └── migration_00*.sql
├── Dockerfile
├── docker-compose.yml
├── entrypoint.sh
└── .gitlab-ci.yml
users
id, uid (12 hex), email, password (bcrypt), firstname, lastname,
lang (en|fr|es), email_verified, email_verification_token,
totp_secret, twofa_enabled, twofa_confirmed, twofa_recovery_codes (JSON),
deleted_at, created_at, updated_at
companies
id, name, uid (12 hex), plan (solo|pro|business),
subscription_status, trial_ends_at, subscription_ends_at,
timezone, created_at, updated_at
user_roles — rôles globaux : admin, manager, employee
user_company_roles — liaison N-N : user_id, company_id, role_id, company_role_id
company_roles — rôles custom par company : uid, name, color, is_default
company_role_permissions — role_id, app, page, level (none|read|write)
user_invitations
id, email, company_id, role_id, token (64 hex), expires_at (+48h),
used (0|1), used_at, created_at
refresh_tokens
id, user_id, token_hash (sha256), expires_at (+30j), revoked, created_at
password_resets — user_id, token, expires_at (+1h)
trusted_devices — user_id, token_hash, user_agent_hash, expires_at (+30j)
Algorithme : HS256, TTL : 3600 secondes (1 heure).
| Claim | Type | Description |
|---|---|---|
user_uid |
string | UID 12 hex de l'utilisateur |
company_uid |
string | UID 12 hex de la company |
company_name |
string | Nom de la company |
role |
string | Rôle effectif : admin, manager, employee ou nom custom |
role_uid |
string|null | UID du company_role (null si rôle global) |
plan |
string | Plan stocké en DB : solo, pro, business |
timezone |
string | Timezone de la company (ex: Europe/Paris) |
firstname |
string | Prénom de l'utilisateur |
lastname |
string | Nom de famille |
email |
string | Email de l'utilisateur |
lang |
string | Langue préférée : en, fr, es |
iat |
int | Timestamp d'émission |
exp |
int | Timestamp d'expiration (iat + 3600) |
Important : Le payload expose le
planstocké, pas le plan effectif. Le plan effectif (tenant compte du statut d'abonnement, du trial, du grace period) est calculé côté applicatif viaeffective_plan()danslib/plan.php. Ne pas utiliserplandu JWT comme source de vérité pour les gates de fonctionnalité.
Sélection de la company : si un user appartient à plusieurs companies, le login prend la plus privilégiée (admin > manager) via ORDER BY dans la requête SQL.
| Nom | Contenu | TTL | Path | Flags |
|---|---|---|---|---|
taskori_token |
JWT access token | 1h | / |
Secure, HttpOnly, SameSite=Lax, domain=.taskori.app |
taskori_refresh |
Refresh token (plain) | 30j | /api/auth |
Secure, HttpOnly, SameSite=Lax, domain=.taskori.app |
taskori_td |
Trusted device token | 30j | / |
Secure, HttpOnly, SameSite=Strict, domain=.taskori.app |
Le cookie taskori_refresh est limité à /api/auth pour qu'il ne soit pas envoyé sur les autres requêtes.
POST /api/auth/loginAuthentification headless (pour taskori-sync et clients API).
Body JSON :
{ "email": "[email protected]", "password": "..." }
Réponse 200 :
{
"token": "<jwt>",
"refresh_token": "<64 hex chars>",
"expires_in": 3600,
"user": {
"uid": "...", "email": "...", "firstname": "...", "lastname": "...",
"company_uid": "...", "plan": "...", "role": "..."
}
}
Codes d'erreur :
400 — email ou password manquant401 — identifiants invalides403 email_not_verified — email non vérifié403 twofa_required — user avec 2FA activé : non supporté via API headless (retourne une erreur explicite, ne contourne pas le 2FA)403 no_company_linked — user sans companyPOST /api/auth/refreshRotation du refresh token. Accepte le token via cookie taskori_refresh ou body JSON { "refresh_token": "..." }.
Réponse 200 :
{ "token": "<nouveau jwt>", "refresh_token": "<nouveau token>", "expires_in": 3600 }
Le refresh token précédent est invalidé (UPDATE en place, pas INSERT).
Codes d'erreur :
400 — pas de refresh token fourni401 — token invalide, expiré ou user supprimé (soft-delete)403 — plus de company liéeGET|POST /api/auth/verifyValide un JWT pour les sous-applications. Accepte le token dans :
Authorization: Bearer <token>taskori_token{ "token": "..." }Réponse 200 :
{ "valid": true, "user": { "id": ..., "email": "...", "role": "...", "exp": ..., "iat": ... } }
Réponse 401 :
{ "valid": false, "error": "Invalid or expired token" }
POST /api/permissionsRetourne les permissions d'un rôle.
Auth : cookie taskori_token valide requis.
Body JSON : { "role_uid": "..." } — si vide (admin), retourne tout en write.
Réponse :
{
"project": { "clients": "write", "projects": "write", "tasks": "write", "templates": "write" },
"ts": { "timesheets": "write", "expense_reports": "write" },
"manage": { "users": "write", "expenses": "write", "payslips": "write", "invoices": "write", "roles": "write" }
}
Niveaux possibles : none, read, write.
| URL | Fichier | Description |
|---|---|---|
/login |
pages/login.php |
Connexion (step 1 : identifiants, step 2 : TOTP si 2FA activé) |
/register |
pages/register.php |
Inscription (avec ou sans token d'invitation) |
/logout |
pages/logout.php |
Suppression cookies + redirection |
/account |
pages/account.php |
Profil, changement MDP, gestion 2FA |
/invite |
pages/invite.php |
Envoi d'invitations (admin/manager) |
/users |
pages/users.php |
Liste des membres de la company |
/company |
pages/company.php |
Paramètres company |
/forgot-password |
pages/forgot-password.php |
Demande de reset MDP |
/reset-password |
pages/reset-password.php |
Application du nouveau MDP via token |
/verify-email |
pages/verify-email.php |
Validation email |
/resend-verification |
pages/resend-verification.php |
Renvoi email de vérification |
1. GET /login → formulaire email/password
2. POST /login
├── Vérification email + password_verify() (bcrypt)
├── Vérification email_verified = 1
├── Récupération company + rôle (ORDER BY privilège DESC, LIMIT 1)
├── Si twofa_confirmed = 1 ET appareil non trusted :
│ ├── session_regenerate_id(true)
│ ├── $_SESSION['2fa_pending'] = { user_id, redirect_uri }
│ └── Affichage step 2FA
└── Sinon : issue_jwt_and_redirect()
3. (si 2FA) POST /login { action: verify_2fa, totp_code: ... }
├── Vérification TOTP (fenêtre ±1 step soit ±30s)
├── OU utilisation d'un recovery code (8 chars hex, consommé 1 fois)
├── Option "trust_device" : cookie taskori_td (30j) + DB trusted_devices
└── issue_jwt_and_redirect()
4. issue_jwt_and_redirect() :
├── JWTHelper::encode(payload) → cookie taskori_token
├── bin2hex(32 bytes) → INSERT refresh_tokens (hash sha256)
├── JWTHelper::setRefreshCookie()
└── header Location: redirect_uri
POST /api/auth/login → { token, refresh_token, user }
↓ (quand token expiré)
POST /api/auth/refresh → { token, refresh_token }
Note : la 2FA n'est pas supportée via l'API headless. Un user avec 2FA activé reçoit 403 twofa_required.
Sous-app reçoit requête avec cookie taskori_token (ou header Bearer)
↓
GET/POST /api/auth/verify → { valid: true, user: { ... } }
La 2FA est basée sur TOTP (RFC 6238), implémentée via RobThree\Auth\TwoFactorAuth.
/account)action=enable_2fa : génère un secret TOTP + 8 recovery codes (hex 8 chars chacun), stockés en DB. twofa_confirmed = 0.action=confirm_2fa : vérifie le premier code avec verifyCode(..., 1) (fenêtre ±1 pas). Si OK → twofa_confirmed = 1. Email de confirmation envoyé.action=disable_2fa : nécessite le mot de passe courant ET un code TOTP valide. Efface totp_secret, twofa_recovery_codes, supprime tous les trusted_devices du user.
8 codes de secours générés à l'activation (bin2hex(random_bytes(4)) = 8 chars hex). Stockés en clair dans twofa_recovery_codes (JSON array). À chaque utilisation, le code consommé est retiré du tableau (splice + UPDATE).
Cookie taskori_td (30j, SameSite=Strict). À chaque connexion avec 2FA, si la checkbox "Faire confiance à cet appareil" est cochée :
trusted_devices avec hash(sha256, token) + hash(sha256, user_agent)POST /pages/register.php
├── Validation : firstname, email, password ≥ 8 chars, confirmation
├── Email unique vérifié
├── Transaction :
│ ├── INSERT users (uid=12hex, bcrypt, email_verified=0, token vérif)
│ ├── INSERT companies (uid=12hex, plan='solo')
│ ├── INSERT user_company_roles (role=admin)
│ └── seedCompanyRoles() → rôles manager + employee avec permissions defaults
├── COMMIT
├── send_verification_email()
└── provision_share_bucket(company_uid, 'solo') ← appel HTTP interne taskori-share
GET /register?invite=<token>
├── Validation token : non expiré, non utilisé
├── Transaction :
│ ├── INSERT users
│ └── INSERT user_company_roles (company + rôle de l'invitation)
├── markInvitationUsed()
└── Pas de création de company, pas de provision bucket
provision_share_bucket(company_uid, plan) dans lib/company_functions.php :
POST à TASKORI_SHARE_INTERNAL_URL/api/internal/bucket?company_uid=...&plan=...X-Taskori-Share-Token: <TASKORI_SHARE_TOKEN>Bucket::getOrCreate côté taskori-share.Accessible depuis /invite (admin ou manager seulement).
createInvitation(pdo, email, company_id, role_id)
├── Vérification quota : active_users + pending_invitations < plan_max_users
├── Token = bin2hex(32 bytes) = 64 chars hex
├── expires_at = NOW() + 48h
└── INSERT user_invitations
'limit_reached' (string) si quota atteint, false si erreur technique, le token sinon.getPendingInvitations() fait un DELETE des invitations expirées en passant.admin (filtré côté page invite).invite.php)| Plan | Max users |
|---|---|
solo |
1 |
pro |
10 |
business |
∞ |
lib/plan.php expose effective_plan(company) qui calcule le plan réel en tenant compte du statut d'abonnement :
subscription_status |
Plan effectif |
|---|---|
active / lifetime |
company.plan |
trialing non expiré |
pro si solo, sinon company.plan |
trialing expiré |
solo |
past_due < 7 jours |
company.plan (grâce) |
past_due > 7 jours |
solo |
canceled avant subscription_ends_at |
company.plan |
canceled après subscription_ends_at |
solo |
limited / inconnu |
solo |
Limites quantitatives :
| Plan | Users | Projets actifs | Stockage |
|---|---|---|---|
solo |
1 | 2 | 10 GB |
pro |
10 | ∞ | 25 GB |
business |
∞ | ∞ | 50 GB |
user_roles)admin : accès total, créé automatiquement à l'inscriptionmanager : accès write sur la plupart des modulesemployee : accès restreintcompany_roles)Créés automatiquement à l'inscription via seedCompanyRoles(). Chaque company obtient manager et employee avec des permissions par défaut sur les apps project, ts, manage.
Le champ role_uid dans le JWT pointe vers un company_role.uid (null pour admin).
| Rôle | project/clients | project/tasks | ts/timesheets | manage/users | manage/invoices |
|---|---|---|---|---|---|
manager |
write | write | write | write | write |
employee |
read | write | write | none | none |
| Variable | Description |
|---|---|
DB_HOST |
Hôte MariaDB |
DB_NAME |
Nom de la base (taskori_auth) |
DB_USER |
Utilisateur DB |
DB_PASS |
Mot de passe DB |
JWT_SECRET |
Clé de signature HS256 (partagée avec tous les services) |
ENCRYPTION_KEY |
Clé de chiffrement AES |
| Variable | Défaut | Description |
|---|---|---|
APP_URL |
https://taskori.app |
URL publique du service |
TASKORI_AUTH_URL |
https://taskori.app |
Alias APP_URL |
TASKORI_SHARE_INTERNAL_URL |
`` | URL interne taskori-share (ex: http://taskori-share-web) |
TASKORI_SHARE_TOKEN |
`` | Token X-Taskori-Share-Token pour les appels internes |
SMTP_HOST |
`` | Serveur SMTP |
SMTP_PORT |
587 |
Port SMTP |
SMTP_SECURE |
tls |
tls ou ssl |
SMTP_USER |
`` | Utilisateur SMTP |
SMTP_PASS |
`` | Mot de passe SMTP |
SMTP_FROM |
`` | Adresse expéditeur |
SMTP_FROMNAME |
Taskori |
Nom expéditeur |
SENTRY_DSN |
`` | DSN Sentry (optionnel) |
REDIS_HOST |
redis |
Hôte Redis (sessions PHP) |
REDIS_PORT |
6379 |
Port Redis |
entrypoint.shgénèreconfig/conf.phpdepuisconf.php.tplviaenvsubstau démarrage du container.
.gitlab-ci.ymlRunner dédié : tag taskori-auth, tourne sur taskori-prod.
Stages :
test (toutes branches) — lint PHP 8.1 : php -l sur tous les fichiers .php hors vendor/. Arrêt si parse error ou fatal error.
build (main seulement) — Build Docker + push vers registry.lorva.dev/taskori/taskori-auth avec deux tags : $CI_COMMIT_REF_SLUG et latest.
deploy (main seulement) — Alpine avec docker + ssh :
.env en filtrant les variables CI (DB_, AUTH_, JWT_, SMTP_, TASKORI_, etc.)scp .env et docker-compose.yml vers /docker/taskori-auth/ sur $DEPLOY_HOSTdocker pull + docker compose up -dConfigurer dans GitLab Settings > CI/CD > Variables :
| Variable | Description |
|---|---|
DEPLOY_HOST |
IP ou hostname de taskori-prod |
DEPLOY_USER |
Utilisateur SSH de déploiement |
DB_HOST, DB_NAME, DB_USER, DB_PASS |
Accès base de données |
JWT_SECRET |
Clé JWT (partagée avec tous les services) |
ENCRYPTION_KEY |
Clé de chiffrement |
TASKORI_SHARE_INTERNAL_URL |
URL interne taskori-share |
TASKORI_SHARE_TOKEN |
Token appels internes share |
SMTP_* |
Configuration email |
SENTRY_DSN |
DSN Sentry (optionnel) |
CI_REGISTRY_USER, CI_REGISTRY_PASSWORD, CI_REGISTRY |
Registry GitLab (auto-injectés) |
La clé SSH de déploiement est attendue dans ~/.ssh/id_ed25519 (injectée via variable CI protégée de type File).
Le container est connecté à trois réseaux :
| Réseau | IP | Usage |
|---|---|---|
taskori-auth-network |
interne | Communication avec Redis sidecar |
web (externe) |
172.32.0.185 |
Accessible par Traefik |
db (externe) |
172.34.0.185 |
Accès MariaDB partagé |
Trois routers avec priorités différentes sur taskori.app :
| Router | Règle | Priorité | Rate limit |
|---|---|---|---|
taskori-auth-login |
/login, /register, /api/auth, /account, etc. |
20 | 30 req/min, burst 15 |
taskori-auth-api |
/api |
10 | 60 req/min, burst 15 |
taskori-auth-web |
* (catch-all) |
1 | 120 req/min, burst 30 |
Headers de sécurité appliqués sur tous les routers : HSTS (1 an, includeSubdomains, preload), X-Frame-Options deny, X-Content-Type-Options, XSS filter, Referrer-Policy strict-origin.
Montés dans /opt/taskori-auth/logs sur l'hôte (logs Apache2).
lib/auth_middleware.php — inclure en haut de toute page nécessitant une session active :
require_once __DIR__ . '/../lib/auth_middleware.php';
// $_AUTH_USER est disponible (objet stdClass avec les claims du JWT)
Comportement : lit taskori_token, décode le JWT. Si invalide ou absent → redirection vers /login?redirect_uri=<current>.
deleted_at IS NOT NULL) sont rejetés à la connexion et au refresh.taskori.app. Toute valeur non conforme est ignorée, défaut vers https://portal.taskori.app.taskori_refresh limité à /api/auth. taskori_td en SameSite=Strict.REDIS_HOST/REDIS_PORT).JWT_SECRET est identique sur tous les micro-services Taskori. Sa rotation implique de redéployer simultanément tous les services.403 twofa_required sur /api/auth/login (taskori-sync). À prévoir si la 2FA doit être supportée côté app desktop.1. plan JWT vs plan effectif
Le JWT contient plan tel qu'en DB, pas le plan effectif (trial, past_due, etc.). Les gates de fonctionnalité doivent appeler effective_plan() (disponible dans lib/plan.php) ou recharger la company depuis la DB plutôt que lire $payload->plan.
2. company_name absente du JWT dans certains flows
company_name est présent dans le payload de api/auth/login.php mais pas systématiquement dans pages/login.php (issue_jwt_and_redirect() ne l'inclut pas). Ne pas en dépendre côté clients consommant le JWT depuis la page web.
3. 2FA non supportée via l'API headless
Le client desktop taskori-sync (Tauri) utilise /api/auth/login. Si un user active la 2FA, il ne peut plus se connecter via l'app desktop. C'est intentionnel (erreur explicite twofa_required) mais à documenter pour les utilisateurs.
4. Provision bucket non bloquante
Si taskori-share est indisponible lors de l'inscription, la company est créée sans bucket de stockage. Le bucket sera créé automatiquement à la première demande d'upload via Bucket::getOrCreate côté share. Les uploads ne seront pas bloqués mais la carte "Stockage" dans l'admin affichera 0 octet utilisé jusqu'à ce premier déclenchement.
5. Migration idempotente au runtime
Les colonnes 2FA (totp_secret, twofa_enabled, twofa_confirmed, twofa_recovery_codes) et la table trusted_devices sont créées via ALTER TABLE ... ADD COLUMN IF NOT EXISTS à chaque requête sur /login et /account. C'est une technique de migration lazy — préférer les migrations SQL propres dans /sql/ pour les nouvelles colonnes.
6. Déploiement
Ne jamais redémarrer le service manuellement sur taskori-prod. Pusher sur main et laisser le pipeline GitLab gérer le déploiement. Surveiller le pipeline dans GitLab CI.
7. JWT_SECRET partagé
JWT_SECRET doit être strictement identique entre taskori-auth, taskori-manage, taskori-ts, taskori-project et taskori-share. Une rotation nécessite un déploiement coordonné de tous les services (les JWT en cours seront invalides).
lib/login_security.phpAjouté le 2026-05-30. Comme l'auth est centralisé, la protection ici couvre toutes les apps. Hooks sur le login web (pages/login.php) et l'API (api/auth/login.php).
1. Ban IP générique (anti-spray)
5 échecs en 10 min depuis une même IP → ban 1 h, escalade jusqu'au ban permanent au bout de 10 bans.
2. Verrou de compte en escalade (3 échecs = 1 palier ; le compteur repart de zéro à la première connexion réussie) :
| Palier | Échecs cumulés | Verrou du compte | Déblocage |
|---|---|---|---|
| 1 | 3 | 10 min | auto |
| 2 | 6 | 30 min | auto |
| 3 | 9 | bloqué | manuel (admin) + filet auto 24 h |
| 4+ | 12 | bloqué | manuel uniquement |
À chaque palier, si l'IP n'a jamais réussi à se connecter à ce compte → l'IP est bloquée et un email (FR/EN/ES, send_login_security_email) prévient l'utilisateur : IP bloquée, contacter le support, lien de réinitialisation du mot de passe.
Quand l'IP ou le compte est bloqué, le login renvoie le même message générique qu'un mauvais mot de passe (web : invalid_credentials ; API : 401 Invalid credentials). Un attaquant ne peut pas savoir qu'il est bloqué ; seul le client légitime est informé (email).
taskori_auth)login_attempts, blocked_ips, ip_geo (cache de géolocalisation).users : login_failed_streak, login_lockout_level, login_locked_until, login_lock_manual.sql/migration_005_login_security.sql et le bloc d'auto-migration de pages/login.php (idempotent, IF NOT EXISTS).⚠️ Le ban IP suppose un vrai IP client : voir Traefik → Vrai IP client derrière Cloudflare. Gestion/visibilité depuis taskori-admin → page Sécurité.