diff --git a/backend/app/main.py b/backend/app/main.py
index 998e07a..e8a1f9f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,6 +1,7 @@
from typing import Union
from fastapi import FastAPI
from app.routes import users, auth
+from fastapi.middleware.cors import CORSMiddleware
"""
ENDPOINTS:
@@ -11,6 +12,19 @@ from app.routes import users, auth
"""
app = FastAPI(title="FastAPI + MariaDB + 2FA Example")
+origins = [
+ "http://localhost:5173", # tu frontend (vite, react, etc.)
+ "http://127.0.0.1:5173",
+]
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=origins, # or ["*"] para permitir todo (solo dev)
+ allow_credentials=True,
+ allow_methods=["*"], # GET, POST, PUT, DELETE...
+ allow_headers=["*"], # Authorization, Content-Type...
+)
+
# Registramos las rutas
app.include_router(users.router, tags=["Users"])
app.include_router(auth.router, tags=["Authentication"])
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 711b655..311f145 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,11 +1,355 @@
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
+import { useMemo, useState } from 'react'
+
+import LoginForm from '@/components/forms/LoginForm.jsx'
+import RegisterForm from '@/components/forms/RegisterForm.jsx'
+import TotpForm from '@/components/forms/TotpForm.jsx'
+import Enable2FA from '@/components/forms/Enable2FA.jsx'
+import AlertMessage from '@/components/feedback/AlertMessage.jsx'
+import {
+ enableTwoFactor,
+ listUsers,
+ login,
+ registerUser,
+ verifyTwoFactor
+} from '@/api/auth.js'
+
+const defaultAuthState = {
+ userName: '',
+ preAuthToken: '',
+ accessToken: '',
+ totpSecret: '',
+ otpauthUrl: '',
+ qrBase64: ''
+}
+
+const decodeBase64 = (value) => {
+ if (typeof globalThis.atob === 'function') {
+ return globalThis.atob(value)
+ }
+
+ if (typeof globalThis.Buffer !== 'undefined') {
+ return globalThis.Buffer.from(value, 'base64').toString('binary')
+ }
+
+ throw new Error('Decodificador base64 no disponible')
+}
+
+const decodeJwt = (token) => {
+ try {
+ const [, payload] = token.split('.')
+ if (!payload) {
+ return null
+ }
+
+ const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = normalized.padEnd(normalized.length + (4 - (normalized.length % 4 || 4)) % 4, '=')
+ const decoded = decodeBase64(padded)
+ return JSON.parse(decoded)
+ } catch (error) {
+ console.warn('No se pudo decodificar el token', error)
+ return null
+ }
+}
+
+const getErrorMessage = (error, fallback = 'Ha ocurrido un error inesperado') => {
+ if (error?.response?.data?.detail) {
+ return error.response.data.detail
+ }
+ if (error?.message) {
+ return error.message
+ }
+ return fallback
+}
+
const App = () => {
+ const [step, setStep] = useState('login')
+ const [activeTab, setActiveTab] = useState('login')
+ const [authState, setAuthState] = useState(defaultAuthState)
+ const [feedback, setFeedback] = useState({ type: 'info', message: '' })
+ const [loading, setLoading] = useState(false)
+ const [users, setUsers] = useState([])
+
+ const resetFlow = () => {
+ setStep('login')
+ setActiveTab('login')
+ setAuthState(defaultAuthState)
+ setUsers([])
+ setFeedback({ type: 'info', message: '' })
+ }
+
+ const showFeedback = (type, message) => {
+ setFeedback({ type, message })
+ }
+
+ const clearFeedback = () => {
+ setFeedback((prev) => ({ ...prev, message: '' }))
+ }
+
+ const handleRegister = async (payload) => {
+ setLoading(true)
+ clearFeedback()
+ try {
+ const result = await registerUser(payload)
+ showFeedback('success', `Usuario ${result.user_name} creado correctamente`)
+ setActiveTab('login')
+ } catch (error) {
+ showFeedback('danger', getErrorMessage(error, 'No se pudo registrar el usuario'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleLogin = async (payload) => {
+ setLoading(true)
+ clearFeedback()
+ try {
+ const result = await login(payload)
+ setAuthState({
+ ...defaultAuthState,
+ userName: payload.user_name,
+ preAuthToken: result.pre_auth_token
+ })
+ setStep('totp')
+ showFeedback('success', 'Paso 1 completado. Introduce el codigo TOTP para continuar')
+ } catch (error) {
+ showFeedback('danger', getErrorMessage(error, 'No se pudo iniciar sesion'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleEnableTwoFactor = async () => {
+ const token = authState.accessToken || authState.preAuthToken
+ if (!token) {
+ showFeedback('warning', 'Necesitas iniciar sesion primero')
+ return
+ }
+
+ setLoading(true)
+ clearFeedback()
+ try {
+ const result = await enableTwoFactor(token)
+ setAuthState((prev) => ({
+ ...prev,
+ userName: result.user_name,
+ totpSecret: result.totp_secret,
+ otpauthUrl: result.otpauth_url,
+ qrBase64: result.qr_base64
+ }))
+ setStep('enable')
+ showFeedback('info', 'Escanea el QR y luego valida el codigo TOTP')
+ } catch (error) {
+ showFeedback('danger', getErrorMessage(error, 'No se pudo habilitar el 2FA'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleVerifyTwoFactor = async (code) => {
+ if (!authState.preAuthToken) {
+ showFeedback('warning', 'Primero inicia sesion con usuario y clave')
+ return
+ }
+
+ setLoading(true)
+ clearFeedback()
+ try {
+ const result = await verifyTwoFactor({
+ user_name: authState.userName,
+ pre_auth_token: authState.preAuthToken,
+ totp_code: code
+ })
+
+ setAuthState((prev) => ({
+ ...prev,
+ accessToken: result.access_token
+ }))
+ setStep('dashboard')
+ showFeedback('success', result.message || 'Autenticacion completada')
+ } catch (error) {
+ showFeedback('danger', getErrorMessage(error, 'El codigo TOTP no es valido'))
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleFetchUsers = async () => {
+ setLoading(true)
+ clearFeedback()
+ setUsers([])
+ try {
+ const result = await listUsers()
+ setUsers(result)
+ showFeedback('info', 'Listado de usuarios actualizado')
+ } catch (error) {
+ if (error?.response?.status === 404) {
+ showFeedback('warning', error.response.data.detail || 'No hay usuarios registrados')
+ } else {
+ showFeedback('danger', getErrorMessage(error, 'No se pudo obtener la lista de usuarios'))
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const tokenPayload = useMemo(() => {
+ if (!authState.accessToken) {
+ return null
+ }
+ return decodeJwt(authState.accessToken)
+ }, [authState.accessToken])
+
return (
- <>
-
- >
+
+
+
+
+
+
+
Portal 2FA
+
clearFeedback() : undefined}
+ />
+
+ {step === 'login' && (
+ <>
+
+ -
+
+
+ -
+
+
+
+
+ {activeTab === 'login' ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {step === 'totp' && (
+ <>
+
+ Usuario {authState.userName} autenticado en primer factor. Genera el codigo en tu app y verificalo para continuar.
+
+
+
+ >
+ )}
+
+ {step === 'enable' && (
+ <>
+ setStep('totp')}
+ onRefresh={handleEnableTwoFactor}
+ isLoading={loading}
+ />
+
+ >
+ )}
+
+ {step === 'dashboard' && (
+
+
+
Token de acceso
+ {authState.accessToken}
+
+
+ {tokenPayload && (
+
+
Payload decodificado
+
+ {JSON.stringify(tokenPayload, null, 2)}
+
+
+ )}
+
+
+
+
+
+
+ {users.length > 0 && (
+
+
+
+
+ | ID |
+ Usuario |
+
+
+
+ {users.map((user) => (
+
+ | {user.user_id} |
+ {user.user_name} |
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {step !== 'login' && step !== 'dashboard' && (
+
+ )}
+
+
+
+
+
+
)
}
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
new file mode 100644
index 0000000..7fd89ac
--- /dev/null
+++ b/frontend/src/api/auth.js
@@ -0,0 +1,26 @@
+import client from './client'
+
+export const registerUser = async (payload) => {
+ const { data } = await client.post('/users', payload)
+ return data
+}
+
+export const login = async (payload) => {
+ const { data } = await client.post('/login', payload)
+ return data
+}
+
+export const enableTwoFactor = async (token) => {
+ const { data } = await client.post('/enable-2fa', { token })
+ return data
+}
+
+export const verifyTwoFactor = async (payload) => {
+ const { data } = await client.post('/2fa', payload)
+ return data
+}
+
+export const listUsers = async () => {
+ const { data } = await client.get('/users')
+ return data
+}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..28442e1
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,11 @@
+import axios from 'axios'
+
+const client = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ timeout: 10000
+})
+
+export default client
diff --git a/frontend/src/components/feedback/AlertMessage.jsx b/frontend/src/components/feedback/AlertMessage.jsx
new file mode 100644
index 0000000..f27be1b
--- /dev/null
+++ b/frontend/src/components/feedback/AlertMessage.jsx
@@ -0,0 +1,16 @@
+const AlertMessage = ({ type = 'info', message, onClose }) => {
+ if (!message) {
+ return null
+ }
+
+ return (
+
+ {message}
+ {onClose && (
+
+ )}
+
+ )
+}
+
+export default AlertMessage
diff --git a/frontend/src/components/forms/Enable2FA.jsx b/frontend/src/components/forms/Enable2FA.jsx
new file mode 100644
index 0000000..161c9cd
--- /dev/null
+++ b/frontend/src/components/forms/Enable2FA.jsx
@@ -0,0 +1,42 @@
+const Enable2FA = ({ secret, otpauthUrl, qrBase64, onBack, onRefresh, isLoading }) => {
+ return (
+
+
Escanea el codigo o introduce la clave manualmente en tu app TOTP.
+ {qrBase64 ? (
+

+ ) : (
+
+ No se pudo generar la imagen QR. Usa la clave manual.
+
+ )}
+
+
Clave TOTP:
+
{secret}
+
+
+
URL OTPAuth:
+
{otpauthUrl}
+
+
+
+
+
+
+ )
+}
+
+export default Enable2FA
diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx
new file mode 100644
index 0000000..720fbb7
--- /dev/null
+++ b/frontend/src/components/forms/LoginForm.jsx
@@ -0,0 +1,54 @@
+import { useState } from 'react'
+
+const LoginForm = ({ onSubmit, isLoading }) => {
+ const [formState, setFormState] = useState({
+ user_name: '',
+ password: ''
+ })
+
+ const handleChange = (event) => {
+ const { name, value } = event.target
+ setFormState((prev) => ({ ...prev, [name]: value }))
+ }
+
+ const handleSubmit = (event) => {
+ event.preventDefault()
+ onSubmit(formState)
+ }
+
+ return (
+
+ )
+}
+
+export default LoginForm
diff --git a/frontend/src/components/forms/RegisterForm.jsx b/frontend/src/components/forms/RegisterForm.jsx
new file mode 100644
index 0000000..92171e7
--- /dev/null
+++ b/frontend/src/components/forms/RegisterForm.jsx
@@ -0,0 +1,72 @@
+import { useState } from 'react'
+
+const RegisterForm = ({ onSubmit, isLoading }) => {
+ const [formState, setFormState] = useState({
+ user_name: '',
+ password: '',
+ confirmPassword: ''
+ })
+ const [localError, setLocalError] = useState('')
+
+ const handleChange = (event) => {
+ const { name, value } = event.target
+ setFormState((prev) => ({ ...prev, [name]: value }))
+ setLocalError('')
+ }
+
+ const handleSubmit = (event) => {
+ event.preventDefault()
+ if (formState.password !== formState.confirmPassword) {
+ setLocalError('Las claves no coinciden')
+ return
+ }
+ onSubmit({ user_name: formState.user_name, password: formState.password })
+ }
+
+ return (
+
+ )
+}
+
+export default RegisterForm
diff --git a/frontend/src/components/forms/TotpForm.jsx b/frontend/src/components/forms/TotpForm.jsx
new file mode 100644
index 0000000..ffbc4bb
--- /dev/null
+++ b/frontend/src/components/forms/TotpForm.jsx
@@ -0,0 +1,48 @@
+import { useState } from 'react'
+
+const TotpForm = ({ onSubmit, isLoading, onBack }) => {
+ const [totpCode, setTotpCode] = useState('')
+
+ const handleSubmit = (event) => {
+ event.preventDefault()
+ onSubmit(totpCode.replace(/\s+/g, ''))
+ }
+
+ return (
+
+ )
+}
+
+export default TotpForm