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;