Implementación de códigos CAPTCHA gráficos y SMS en React con Ant Design

En aplicaciones web modernas, la autenticación de usuarios frecuentemente incluye mecanismos como CAPTCHA visual o códigos enviados por SMS. Estos pueden implementarse mediante servicios backend o generarse directamente en el frontend con React.

Este ejemplo simula un flujo real de inicio de sesión con:

  • CAPTCHA gráfico generado dinámicamente y validado en tiempo real.
  • Código SMS generado localmente con temporizador de reenvío.

Estructura del proyecto

app/
├── node_modules/
├── package.json
├── src/
│   ├── index.js
│   └── components/
│       ├── CaptchaRenderer.tsx    // Componente de CAPTCHA visual
│       ├── SmsTimerButton.tsx     // Botón con contador para SMS
│       └── AuthForm.tsx           // Formulario principal
├── tsconfig.json
└── yarn.lock

Formulario de autenticación (AuthForm.tsx)

import React, { useRef, useState } from 'react';
import { Form, Input, Button, Row, Col, Tabs, Space } from 'antd';
import { UserOutlined, LockOutlined, MobileOutlined } from '@ant-design/icons';
import CaptchaRenderer from './CaptchaRenderer';
import SmsTimerButton from './SmsTimerButton';

const AuthForm: React.FC = () => {
  const [form] = Form.useForm();
  const captchaRef = useRef<{ validate: (input: string) => boolean }>(null);
  const smsRef = useRef<{ validate: (input: string) => boolean }>(null);

  type AuthMode = 'credentials' | 'mobile';
  const [mode, setMode] = useState<AuthMode>('credentials');

  const handleTabChange = (key: string) => {
    setMode(key as AuthMode);
  };

  const layout = {
    labelCol: { span: 6, offset: 2 },
    wrapperCol: { span: 12 },
  };

  const tailLayout = {
    wrapperCol: { offset: 8, span: 16 },
  };

  const handleSubmit = (values: any) => {
    console.log('Datos enviados:', values);
  };

  const handleReset = () => {
    form.resetFields();
  };

  return (
    <Form
      style={{ maxWidth: 600, border: "1px dashed #ccc" }}
      {...layout}
      form={form}
      onFinish={handleSubmit}
    >
      <Tabs
        activeKey={mode}
        onChange={handleTabChange}
        centered
        type="card"
      >
        <Tabs.TabPane key="credentials" tab="Usuario/Contraseña" />
        <Tabs.TabPane key="mobile" tab="Teléfono Móvil" />
      </Tabs>

      {mode === 'credentials' && (
        <>
          <Form.Item
            name="user"
            label="Usuario"
            rules={[
              { required: true, message: 'Campo obligatorio' },
              { type: 'string', whitespace: true, message: 'No puede estar vacío' }
            ]}
          >
            <Input prefix=<UserOutlined /> placeholder="Nombre de usuario" />
          </Form.Item>

          <Form.Item
            name="pass"
            label="Contraseña"
            rules={[
              { required: true, message: 'Campo obligatorio' },
              { min: 5, message: 'Mínimo 5 caracteres' },
              { max: 25, message: 'Máximo 25 caracteres' }
            ]}
          >
            <Input.Password prefix=<LockOutlined /> placeholder="Contraseña" />
          </Form.Item>

          <Form.Item label="Verificación visual" extra="Confirme que no es un robot">
            <Row gutter={8}>
              <Col span={6}>
                <Form.Item
                  name="visualCode"
                  noStyle
                  rules={[
                    { required: true, message: 'Ingrese el código mostrado' },
                    {
                      validator: (_, value) => {
                        if (!value) return Promise.reject('');
                        const isValid = captchaRef.current?.validate(value);
                        return isValid 
                          ? Promise.resolve() 
                          : Promise.reject(new Error('Código incorrecto'));
                      }
                    }
                  ]}
                >
                  <Input placeholder="Código" />
                </Form.Item>
              </Col>
              <Col>
                <CaptchaRenderer ref={captchaRef} />
              </Col>
            </Row>
          </Form.Item>
        </>
      )}

      {mode === 'mobile' && (
        <>
          <Form.Item
            name="phone"
            label="Teléfono"
            rules={[
              { required: true, message: 'Campo obligatorio' },
              { pattern: /^1\d{10}$/, message: 'Formato inválido' }
            ]}
          >
            <Input prefix=<MobileOutlined /> placeholder="Número de móvil" />
          </Form.Item>

          <Form.Item label="Código SMS">
            <Row gutter={8}>
              <Col span={11}>
                <Form.Item
                  name="smsCode"
                  noStyle
                  rules={[
                    { required: true, message: 'Ingrese el código recibido' },
                    { pattern: /^\d{6}$/, message: 'Debe ser un código de 6 dígitos' },
                    {
                      validator: (_, value) => {
                        if (!value) return Promise.reject('');
                        const isValid = smsRef.current?.validate(value);
                        return isValid 
                          ? Promise.resolve() 
                          : Promise.reject(new Error('Código incorrecto'));
                      }
                    }
                  ]}
                >
                  <Input prefix=<LockOutlined /> placeholder="Código SMS" />
                </Form.Item>
              </Col>
              <Col>
                <SmsTimerButton form={form} ref={smsRef} field="phone" />
              </Col>
            </Row>
          </Form.Item>
        </>
      )}

      <Form.Item {...tailLayout}>
        <Space>
          <Button type="primary" htmlType="submit">Iniciar sesión</Button>
          <Button onClick={handleReset}>Limpiar</Button>
        </Space>
      </Form.Item>
    </Form>
  );
};

export default AuthForm;

Componente CAPTCHA visual (CaptchaRenderer.tsx)

import * as React from 'react';

interface CaptchaProps {
  ref: React.Ref<{ validate: (input: string) => boolean }>;
}

const CaptchaRenderer = React.forwardRef(({ }, ref) => {
  const [state, setState] = React.useState({
    id: 'captcha-container',
    canvasId: 'captcha-canvas',
    width: 150,
    height: 36,
    type: 'mixed',
    secret: '',
    digits: '0123456789'.split(''),
    letters: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
  });

  React.useImperativeHandle(ref, () => ({
    validate: (input: string) => {
      return input?.toLowerCase() === state.secret?.toLowerCase();
    }
  }));

  React.useEffect(() => {
    initializeCanvas();
    generateNewCode();
  }, []);

  const initializeCanvas = () => {
    const container = document.getElementById(state.id);
    if (!container) return;

    const canvas = document.createElement('canvas');
    canvas.id = state.canvasId;
    canvas.width = container.offsetWidth || 150;
    canvas.height = container.offsetHeight || 36;
    canvas.style.cursor = 'pointer';
    container.innerHTML = '';
    container.appendChild(canvas);

    canvas.onclick = generateNewCode;
  };

  const generateNewCode = () => {
    const canvas = document.getElementById(state.canvasId) as HTMLCanvasElement;
    if (!canvas || !canvas.getContext) return;

    const ctx = canvas.getContext('2d');
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    let charset = state.digits;
    if (state.type === 'mixed') charset = [...state.digits, ...state.letters];
    else if (state.type === 'letters') charset = state.letters;

    let code = '';
    for (let i = 0; i < 4; i++) {
      const char = charset[Math.floor(Math.random() * charset.length)];
      code += char;

      ctx.font = `${Math.random() * 18 + 18}px Arial`;
      ctx.fillStyle = `rgb(${Math.random()*100+50},${Math.random()*100+50},${Math.random()*100+50})`;
      ctx.shadowBlur = 2;
      ctx.shadowColor = 'rgba(0,0,0,0.3)';

      const x = (canvas.width / 5) * (i + 1);
      const y = canvas.height / 2;
      const angle = (Math.random() - 0.5) * 0.5;

      ctx.save();
      ctx.translate(x, y);
      ctx.rotate(angle);
      ctx.fillText(char, 0, 0);
      ctx.restore();
    }

    for (let i = 0; i < 3; i++) {
      ctx.strokeStyle = `rgba(${Math.random()*255},${Math.random()*255},${Math.random()*255},0.5)`;
      ctx.beginPath();
      ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
      ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
      ctx.stroke();
    }

    setState(prev => ({ ...prev, secret: code }));
  };

  return <div id={state.id} style={{
    width: '32%',
    height: '36px',
    marginLeft: '5%',
    display: 'inline-block',
    backgroundColor: '#f0f9ff'
  }} />;
});

export default CaptchaRenderer;

Componente de temporizador SMS (SmsTimerButton.tsx)

import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { Button, Form } from 'antd';

const generateCode = (length: number = 6): string => {
  const chars = '0123456789';
  return Array.from({ length }, () => 
    chars[Math.floor(Math.random() * chars.length)]
  ).join('');
};

interface SmsButtonProps {
  form: any;
  field: string;
}

const SmsTimerButton = forwardRef(({ form, field }: SmsButtonProps, ref) => {
  const [secret, setSecret] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [counter, setCounter] = useState(0);
  const [buttonText, setButtonText] = useState('Enviar código');
  const [canSend, setCanSend] = useState(false);

  const fieldValue = Form.useWatch(field, form);

  useEffect(() => {
    form.validateFields([field])
      .then(() => setCanSend(true))
      .catch(() => setCanSend(false));
  }, [fieldValue, form]);

  useEffect(() => {
    if (!isSending) return;

    const timer = setInterval(() => {
      setCounter(prev => {
        if (prev >= 60) {
          clearInterval(timer);
          setButtonText('Reenviar código');
          setIsSending(false);
          setCounter(0);
          return 0;
        } else {
          setButtonText(`(${60 - prev}s) Reenviar`);
          return prev + 1;
        }
      });
    }, 1000);

    return () => clearInterval(timer);
  }, [isSending]);

  useImperativeHandle(ref, () => ({
    validate: (input: string) => input === secret
  }));

  const handleSend = () => {
    if (isSending) return;

    const newCode = generateCode();
    setSecret(newCode);
    setIsSending(true);
    setCounter(0);
    setButtonText('(60s) Reenviar');

    console.log('Código generado:', newCode, 'para:', fieldValue);
    // En producción: enviar vía API/SMS gateway
  };

  return (
    <Button
      onClick={handleSend}
      disabled={!canSend || isSending}
      style={{ borderRadius: 0, border: 'none' }}
    >
      {buttonText}
    </Button>
  );
});

export default SmsTimerButton;

Etiquetas: React ant-design CAPTCHA sms-verification TypeScript

Publicado el 8-12 19:55