Makrosites

Suas ideias em realidade digital!

Login seguro em PHP para API: password_hash + JWT + rate limit (sem framework)

Login seguro em PHP para API: password_hash + JWT + rate limit (sem framework)

Por que “login seguro” é diferente de “login que funciona”?

Em API, um login que “funciona” pode ser fácil de quebrar com brute force, vazamento de token ou senha fraca. Aqui você vai implementar um login seguro com:

1) Banco: tabela de usuários (exemplo)

CREATE TABLE users (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(190) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(30) NOT NULL DEFAULT 'user',
  ustatus VARCHAR(20) NOT NULL DEFAULT 'ativo',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

2) Salvando senha do jeito certo (cadastro)

Na hora de registrar o usuário, gere o hash:

<?php
$hash = password_hash($password, PASSWORD_DEFAULT);
// salva $hash no campo password_hash

3) Rate limit: proteção contra brute force

Você pode fazer rate limit simples com banco (recomendado) ou arquivo/cache. Aqui vai um modelo com banco.

Tabela de tentativas

CREATE TABLE auth_attempts (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  ip VARCHAR(64) NOT NULL,
  email VARCHAR(190) NULL,
  tries INT NOT NULL DEFAULT 0,
  locked_until DATETIME NULL,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_ip_email (ip, email)
);

Funções de rate limit

<?php
function authKey(string $ip, ?string $email): array {
  return [$ip, strtolower(trim((string)$email))];
}

function isLocked(PDO $pdo, string $ip, ?string $email): bool {
  [$ip, $email] = authKey($ip, $email);
  $stmt = $pdo->prepare("SELECT locked_until FROM auth_attempts WHERE ip=:ip AND email=:email LIMIT 1");
  $stmt->execute([':ip' => $ip, ':email' => $email]);
  $row = $stmt->fetch(PDO::FETCH_ASSOC);
  return $row && !empty($row['locked_until']) && strtotime($row['locked_until']) > time();
}

function registerFail(PDO $pdo, string $ip, ?string $email, int $maxTries = 5, int $lockMinutes = 10): void {
  [$ip, $email] = authKey($ip, $email);

  $stmt = $pdo->prepare("INSERT INTO auth_attempts (ip,email,tries) VALUES (:ip,:email,1)
                         ON DUPLICATE KEY UPDATE tries = tries+1");
  $stmt->execute([':ip' => $ip, ':email' => $email]);

  $stmt = $pdo->prepare("SELECT tries FROM auth_attempts WHERE ip=:ip AND email=:email LIMIT 1");
  $stmt->execute([':ip' => $ip, ':email' => $email]);
  $tries = (int)($stmt->fetch(PDO::FETCH_ASSOC)['tries'] ?? 0);

  if ($tries >= $maxTries) {
    $until = (new DateTimeImmutable())->modify("+{$lockMinutes} minutes")->format('Y-m-d H:i:s');
    $pdo->prepare("UPDATE auth_attempts SET locked_until=:u WHERE ip=:ip AND email=:email")
        ->execute([':u' => $until, ':ip' => $ip, ':email' => $email]);
  }
}

function resetAttempts(PDO $pdo, string $ip, ?string $email): void {
  [$ip, $email] = authKey($ip, $email);
  $pdo->prepare("DELETE FROM auth_attempts WHERE ip=:ip AND email=:email")
      ->execute([':ip' => $ip, ':email' => $email]);
}

4) Endpoint de login (email/senha) com JWT

Este exemplo:

<?php
header('Content-Type: application/json; charset=UTF-8');

$pdo = getPDO();
$ip  = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';

$body = json_decode(file_get_contents('php://input'), true);
$email = strtolower(trim($body['email'] ?? ''));
$pass  = (string)($body['password'] ?? '');

if ($email === '' || $pass === '') {
  http_response_code(422);
  echo json_encode(['error' => 'email e password são obrigatórios']);
  exit;
}

if (isLocked($pdo, $ip, $email)) {
  http_response_code(429);
  echo json_encode(['error' => 'Muitas tentativas. Aguarde e tente novamente.']);
  exit;
}

$stmt = $pdo->prepare("SELECT id, email, password_hash, role, ustatus FROM users WHERE email=:e LIMIT 1");
$stmt->execute([':e' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user || $user['ustatus'] !== 'ativo' || !password_verify($pass, $user['password_hash'])) {
  registerFail($pdo, $ip, $email, 5, 10);
  http_response_code(401);
  echo json_encode(['error' => 'Credenciais inválidas']);
  exit;
}

// sucesso
resetAttempts($pdo, $ip, $email);

// access token (15 min)
$jwt = generateJWT([
  'id'   => (int)$user['id'],
  'email'=> $user['email'],
  'role' => $user['role']
], 'SEGREDO_SUPER_SECRETO', 900);

echo json_encode([
  'token' => $jwt,
  'token_type' => 'Bearer',
  'expires_in' => 900
]);

5) Protegendo rotas (Bearer + validação)

<?php
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.*)$/i', $hdr, $m)) {
  http_response_code(401);
  echo json_encode(['error' => 'Token ausente']);
  exit;
}

$payload = validateJWT($m[1], 'SEGREDO_SUPER_SECRETO');
if (!$payload) {
  http_response_code(401);
  echo json_encode(['error' => 'Token inválido/expirado']);
  exit;
}

Boas práticas finais (produção)