AuthController: - register() accetta `role` diretto (compliance_manager, org_admin, auditor, board_member, consultant) - Aggiunto validateInvite() → POST /api/auth/validate-invite (no auth) OnboardingController: - Aggiunto lookupPiva() → POST /api/onboarding/lookup-piva (no auth, rate limit 10/min) usato da register.html per P.IVA lookup pre-login Router (index.php): - Aggiunto POST:validateInvite e POST:lookupPiva api.js: - register() invia sia `role` che `user_type` per retrocompatibilità simulate-nis2.php: - SIM-06: B2B provisioning via X-Provision-Secret → org + JWT + API Key - Filtro NIS2_SIM=SIM06 via goto per skip SIM-01→05 indipendenti - readEnvValue() helper per leggere PROVISION_SECRET da .env register.html: - lookupPiva usa /onboarding/lookup-piva (endpoint pubblico) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
374 lines
13 KiB
PHP
374 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Auth Controller
|
|
*
|
|
* Gestisce registrazione, login, JWT tokens, profilo utente.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
require_once APP_PATH . '/services/RateLimitService.php';
|
|
|
|
class AuthController extends BaseController
|
|
{
|
|
/**
|
|
* Restituisce l'IP reale del client, gestendo proxy/nginx.
|
|
*/
|
|
private function getClientIP(): string
|
|
{
|
|
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
|
// Fidati di X-Forwarded-For solo se la richiesta arriva da localhost (nginx proxy)
|
|
if (in_array($remoteAddr, ['127.0.0.1', '::1', 'unknown'])
|
|
&& !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
$xForwardedFor = $_SERVER['HTTP_X_FORWARDED_FOR'];
|
|
$ips = array_map('trim', explode(',', $xForwardedFor));
|
|
$firstIp = filter_var($ips[0], FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
|
if ($firstIp !== false) {
|
|
return $firstIp;
|
|
}
|
|
}
|
|
return $remoteAddr;
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/register
|
|
*/
|
|
public function register(): void
|
|
{
|
|
// Rate limiting
|
|
$ip = $this->getClientIP();
|
|
RateLimitService::check("register:{$ip}", RATE_LIMIT_AUTH_REGISTER);
|
|
RateLimitService::increment("register:{$ip}"); // $ip defined above via getClientIP()
|
|
|
|
$this->validateRequired(['email', 'password', 'full_name']);
|
|
|
|
$email = strtolower(trim($this->getParam('email')));
|
|
$password = $this->getParam('password');
|
|
$fullName = trim($this->getParam('full_name'));
|
|
$phone = $this->getParam('phone');
|
|
|
|
// Supporta sia `role` diretto (nuovo register.html) che `user_type` legacy
|
|
$validRoles = ['super_admin', 'org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee', 'consultant'];
|
|
$roleParam = trim($this->getParam('role', ''));
|
|
$userType = $this->getParam('user_type', 'azienda');
|
|
|
|
if ($roleParam && in_array($roleParam, $validRoles, true) && $roleParam !== 'super_admin') {
|
|
$role = $roleParam;
|
|
} elseif ($userType === 'consultant') {
|
|
$role = 'consultant';
|
|
} else {
|
|
$role = 'employee';
|
|
}
|
|
|
|
// Validazione email
|
|
if (!$this->validateEmail($email)) {
|
|
$this->jsonError('Formato email non valido', 400, 'INVALID_EMAIL');
|
|
}
|
|
|
|
// Validazione password
|
|
$passwordErrors = $this->validatePassword($password);
|
|
if (!empty($passwordErrors)) {
|
|
$this->jsonError(
|
|
implode('. ', $passwordErrors),
|
|
400,
|
|
'WEAK_PASSWORD'
|
|
);
|
|
}
|
|
|
|
// Verifica email duplicata
|
|
$existing = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]);
|
|
if ($existing) {
|
|
$this->jsonError('Email già registrata', 409, 'EMAIL_EXISTS');
|
|
}
|
|
|
|
// Crea utente
|
|
$userId = Database::insert('users', [
|
|
'email' => $email,
|
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
|
'full_name' => $fullName,
|
|
'phone' => $phone,
|
|
'role' => $role,
|
|
'is_active' => 1,
|
|
]);
|
|
|
|
// Genera tokens
|
|
$accessToken = $this->generateJWT($userId);
|
|
$refreshToken = $this->generateRefreshToken($userId);
|
|
|
|
// Audit log
|
|
$this->currentUser = ['id' => $userId];
|
|
$this->logAudit('user_registered', 'user', $userId, ['user_type' => $userType]);
|
|
|
|
$this->jsonSuccess([
|
|
'user' => [
|
|
'id' => $userId,
|
|
'email' => $email,
|
|
'full_name' => $fullName,
|
|
'role' => $role,
|
|
],
|
|
'access_token' => $accessToken,
|
|
'refresh_token' => $refreshToken,
|
|
'expires_in' => JWT_EXPIRES_IN,
|
|
], 'Registrazione completata', 201);
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/login
|
|
*/
|
|
public function login(): void
|
|
{
|
|
// Rate limiting
|
|
$ip = $this->getClientIP();
|
|
RateLimitService::check("login:{$ip}", RATE_LIMIT_AUTH_LOGIN);
|
|
RateLimitService::increment("login:{$ip}");
|
|
|
|
$this->validateRequired(['email', 'password']);
|
|
|
|
$email = strtolower(trim($this->getParam('email')));
|
|
$password = $this->getParam('password');
|
|
|
|
// Trova utente
|
|
$user = Database::fetchOne(
|
|
'SELECT * FROM users WHERE email = ? AND is_active = 1',
|
|
[$email]
|
|
);
|
|
|
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
|
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
|
|
}
|
|
|
|
// Aggiorna ultimo login
|
|
Database::update('users', [
|
|
'last_login_at' => date('Y-m-d H:i:s'),
|
|
], 'id = ?', [$user['id']]);
|
|
|
|
// Genera tokens
|
|
$accessToken = $this->generateJWT((int) $user['id']);
|
|
$refreshToken = $this->generateRefreshToken((int) $user['id']);
|
|
|
|
// Carica organizzazioni
|
|
$organizations = Database::fetchAll(
|
|
'SELECT uo.organization_id, uo.role, uo.is_primary, o.name, o.sector, o.entity_type
|
|
FROM user_organizations uo
|
|
JOIN organizations o ON o.id = uo.organization_id
|
|
WHERE uo.user_id = ? AND o.is_active = 1',
|
|
[$user['id']]
|
|
);
|
|
|
|
$this->currentUser = $user;
|
|
$this->logAudit('user_login', 'user', (int) $user['id']);
|
|
|
|
$this->jsonSuccess([
|
|
'user' => [
|
|
'id' => (int) $user['id'],
|
|
'email' => $user['email'],
|
|
'full_name' => $user['full_name'],
|
|
'role' => $user['role'],
|
|
'preferred_language' => $user['preferred_language'],
|
|
],
|
|
'organizations' => $organizations,
|
|
'access_token' => $accessToken,
|
|
'refresh_token' => $refreshToken,
|
|
'expires_in' => JWT_EXPIRES_IN,
|
|
], 'Login effettuato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/logout
|
|
*/
|
|
public function logout(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
// Invalida tutti i refresh token dell'utente
|
|
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
|
|
|
|
$this->logAudit('user_logout', 'user', $this->getCurrentUserId());
|
|
|
|
$this->jsonSuccess(null, 'Logout effettuato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/refresh
|
|
*/
|
|
public function refresh(): void
|
|
{
|
|
$this->validateRequired(['refresh_token']);
|
|
|
|
$refreshToken = $this->getParam('refresh_token');
|
|
$hashedToken = hash('sha256', $refreshToken);
|
|
|
|
// Transazione atomica per evitare race condition (double-spend del refresh token)
|
|
Database::beginTransaction();
|
|
try {
|
|
// SELECT FOR UPDATE: blocca il record per tutta la transazione
|
|
$tokenRecord = Database::fetchOne(
|
|
'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW() FOR UPDATE',
|
|
[$hashedToken]
|
|
);
|
|
|
|
if (!$tokenRecord) {
|
|
Database::rollback();
|
|
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
|
|
}
|
|
|
|
// Elimina vecchio token atomicamente
|
|
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
|
|
|
|
// Genera nuovi tokens
|
|
$userId = (int) $tokenRecord['user_id'];
|
|
$accessToken = $this->generateJWT($userId);
|
|
$newRefreshToken = $this->generateRefreshToken($userId);
|
|
|
|
Database::commit();
|
|
} catch (Throwable $e) {
|
|
Database::rollback();
|
|
throw $e;
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'access_token' => $accessToken,
|
|
'refresh_token' => $newRefreshToken,
|
|
'expires_in' => JWT_EXPIRES_IN,
|
|
], 'Token rinnovato');
|
|
}
|
|
|
|
/**
|
|
* GET /api/auth/me
|
|
*/
|
|
public function me(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
$user = $this->getCurrentUser();
|
|
|
|
// Carica organizzazioni
|
|
$organizations = Database::fetchAll(
|
|
'SELECT uo.organization_id, uo.role as org_role, uo.is_primary,
|
|
o.name, o.sector, o.entity_type, o.subscription_plan
|
|
FROM user_organizations uo
|
|
JOIN organizations o ON o.id = uo.organization_id
|
|
WHERE uo.user_id = ? AND o.is_active = 1',
|
|
[$user['id']]
|
|
);
|
|
|
|
$this->jsonSuccess([
|
|
'id' => (int) $user['id'],
|
|
'email' => $user['email'],
|
|
'full_name' => $user['full_name'],
|
|
'phone' => $user['phone'],
|
|
'role' => $user['role'],
|
|
'preferred_language' => $user['preferred_language'],
|
|
'last_login_at' => $user['last_login_at'],
|
|
'created_at' => $user['created_at'],
|
|
'organizations' => $organizations,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/auth/profile
|
|
*/
|
|
public function updateProfile(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
$updates = [];
|
|
|
|
if ($this->hasParam('full_name')) {
|
|
$updates['full_name'] = trim($this->getParam('full_name'));
|
|
}
|
|
if ($this->hasParam('phone')) {
|
|
$updates['phone'] = $this->getParam('phone');
|
|
}
|
|
if ($this->hasParam('preferred_language')) {
|
|
$lang = $this->getParam('preferred_language');
|
|
if (in_array($lang, ['it', 'en', 'fr', 'de'])) {
|
|
$updates['preferred_language'] = $lang;
|
|
}
|
|
}
|
|
|
|
if (empty($updates)) {
|
|
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
|
}
|
|
|
|
Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]);
|
|
|
|
$this->logAudit('profile_updated', 'user', $this->getCurrentUserId(), $updates);
|
|
|
|
$this->jsonSuccess($updates, 'Profilo aggiornato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/change-password
|
|
*/
|
|
public function changePassword(): void
|
|
{
|
|
$this->requireAuth();
|
|
$this->validateRequired(['current_password', 'new_password']);
|
|
|
|
$currentPassword = $this->getParam('current_password');
|
|
$newPassword = $this->getParam('new_password');
|
|
|
|
// Verifica password attuale
|
|
if (!password_verify($currentPassword, $this->currentUser['password_hash'])) {
|
|
$this->jsonError('Password attuale non corretta', 400, 'WRONG_PASSWORD');
|
|
}
|
|
|
|
// Validazione nuova password
|
|
$errors = $this->validatePassword($newPassword);
|
|
if (!empty($errors)) {
|
|
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
|
|
}
|
|
|
|
Database::update('users', [
|
|
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
|
|
], 'id = ?', [$this->getCurrentUserId()]);
|
|
|
|
// Invalida tutti i refresh token (force re-login)
|
|
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
|
|
|
|
$this->logAudit('password_changed', 'user', $this->getCurrentUserId());
|
|
|
|
$this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.');
|
|
}
|
|
|
|
/**
|
|
* POST /api/auth/validate-invite
|
|
*
|
|
* Valida un codice invito B2B e restituisce piano e metadati.
|
|
* Nessuna autenticazione richiesta — usato dalla pagina register.html
|
|
* prima della registrazione per mostrare l'anteprima del piano.
|
|
*
|
|
* Body: { "invite_token": "inv_xxxx..." }
|
|
* Response: { valid: true, plan: "professional", duration_months: 12, ... }
|
|
*/
|
|
public function validateInvite(): void
|
|
{
|
|
$token = trim($this->getParam('invite_token', ''));
|
|
|
|
if (!$token) {
|
|
$this->jsonError('invite_token mancante', 400, 'MISSING_TOKEN');
|
|
}
|
|
|
|
require_once APP_PATH . '/controllers/InviteController.php';
|
|
$result = InviteController::resolveInvite($token);
|
|
|
|
if (!$result['valid']) {
|
|
$this->jsonError($result['error'], 422, $result['code'] ?? 'INVALID_INVITE');
|
|
}
|
|
|
|
$inv = $result['invite'];
|
|
|
|
$this->jsonSuccess([
|
|
'valid' => true,
|
|
'plan' => $inv['plan'],
|
|
'duration_months' => (int) $inv['duration_months'],
|
|
'expires_at' => $inv['expires_at'],
|
|
'remaining_uses' => (int)$inv['max_uses'] - (int)$inv['used_count'],
|
|
'channel' => $inv['channel'],
|
|
'label' => $inv['label'],
|
|
'restrict_vat' => $inv['restrict_vat'] ? true : false,
|
|
'restrict_email' => $inv['restrict_email'] ? true : false,
|
|
], 'Invito valido');
|
|
}
|
|
}
|