diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..b32e7a0 --- /dev/null +++ b/backend/.env @@ -0,0 +1,10 @@ +DB_URL=mysql+pymysql://admin:55ii.P4I.1;@miarma.net:3307/cai3 +SECRET_KEY=77ea2fd1f4145c4b73a0d0e5b4f97c1dc175d7cd2ec6c75d22afaafe99a15380 +ALGORITHM=HS256 +EXPIRATION=86400 + +TOTP_INTERVAL=30 +TOTP_DIGITS=6 +TOTP_ALGORITHM=SHA1 +TOTP_ISSUER=SSII2FA +TOTP_NAME_PREFIX=user \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..5ce6c91 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +cai3/* +app/__pycache__/* +app/core/__pycache__/* +app/db/__pycache__/* +app/models/__pycache__/* +app/routes/__pycache__/* +app/schemas/__pycache__/* \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..c91d2d9 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,22 @@ +from pydantic_settings import BaseSettings +from dotenv import load_dotenv +import os + +load_dotenv() + +class Settings(BaseSettings): + DB_URL: str = os.getenv('DB_URL') + SECRET_KEY: str = os.getenv('SECRET_KEY') + ALGORITHM: str = os.getenv('ALGORITHM', 'HS256') + EXPIRATION: int = int(os.getenv('EXPIRATION', 86400)) + + TOTP_INTERVAL: int = int(os.getenv('TOTP_INTERVAL', 30)) + TOTP_DIGITS: int = int(os.getenv('TOTP_DIGITS', 6)) + TOTP_ALGORITHM: str = os.getenv('TOTP_ALGORITHM', 'SHA1') + TOTP_ISSUER: str = os.getenv('TOTP_ISSUER', 'SSII2FA') + TOTP_NAME_PREFIX: str = os.getenv('TOTP_NAME_PREFIX', 'user') + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..10186ba --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,35 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta, timezone +from app.core.config import settings +import jwt +from fastapi import HTTPException, status + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def generate_jwt(data: dict, expires: timedelta | None = None): + to_encode = data.copy() + if expires: + expire = datetime.now(timezone.utc) + expires + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def verify_jwt(token: str): + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido o expirado", + headers={"WWW-Authenticate": "Bearer"}, + ) \ No newline at end of file diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..eeaa71c --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from app.core.config import settings + +engine = create_engine( + settings.DB_URL, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..998e07a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,16 @@ +from typing import Union +from fastapi import FastAPI +from app.routes import users, auth + +""" + ENDPOINTS: + GET /users/ + GET /users/{user_id} + POST /users + POST /2fa +""" +app = FastAPI(title="FastAPI + MariaDB + 2FA Example") + +# 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/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..b140785 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String, Boolean +from app.db.database import Base + +class UserModel(Base): + __tablename__ = "users" + user_id = Column(Integer, primary_key=True, autoincrement=True) + user_name = Column(String(64), unique=True, nullable=False) + password = Column(String(256), nullable=False) + totp_secret = Column(String(32), nullable=True) + has_2fa = Column(Boolean(False), default=False, nullable=False) \ No newline at end of file diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..31313f0 --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.db.database import get_db +from app.models.user import UserModel +from app.schemas.user import UserLogin, User2FA, Token2FA +from app.core.security import verify_password, generate_jwt, verify_jwt +from datetime import timedelta +import hashlib +import pyotp +from app.core.config import settings +import qrcode +import io +import base64 + +router = APIRouter() + +def get_digest_function(algorithm: str): + algo = algorithm.upper() + if algo == "SHA256": + return hashlib.sha256 + elif algo == "SHA512": + return hashlib.sha512 + else: + return hashlib.sha1 + +@router.post("/login") +def login(user: UserLogin, db: Session = Depends(get_db)): + existing_user = db.query(UserModel).filter(UserModel.user_name == user.user_name).first() + if not existing_user: + raise HTTPException(status_code=404, detail="El usuario no existe") + + if not verify_password(user.password, existing_user.password): + raise HTTPException(status_code=401, detail="Credenciales inválidas") + + pre_auth_token = generate_jwt( + data={"user_id": existing_user.user_id, "scope": "pre_auth"}, + expires=timedelta(minutes=2) + ) + + return {"pre_auth_token": pre_auth_token} + +@router.post("/2fa") +def two_factor_auth(user_2fa: User2FA, db: Session = Depends(get_db)): + token = verify_jwt(user_2fa.pre_auth_token) + user_id = token.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Token inválido o expirado") + + user = db.query(UserModel).filter(UserModel.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Usuario no encontrado") + + digest_func = get_digest_function(settings.TOTP_ALGORITHM) + totp = pyotp.TOTP( + s=user.totp_secret, + digits=settings.TOTP_DIGITS, + digest=digest_func, + interval=settings.TOTP_INTERVAL, + issuer=settings.TOTP_ISSUER, + name=f"{settings.TOTP_NAME_PREFIX}:{user.user_name}" + ) + + if not totp.verify(user_2fa.totp_code): + raise HTTPException(status_code=401, detail="Código 2FA inválido") + + expires = timedelta(seconds=settings.EXPIRATION) + token = generate_jwt( + data={"user_id": user.user_id, "user_name": user.user_name, "scope": "2fa_authenticated"}, + expires=expires + ) + + return { + "access_token": token, + "token_type": "bearer", + "message": "Autenticación 2FA exitosa" + } + +@router.post("/enable-2fa") +def enable_2fa(token2fa: Token2FA, db: Session = Depends(get_db)): + token = verify_jwt(token2fa.token) + user_id = token.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Token inválido o expirado") + + user = db.query(UserModel).filter(UserModel.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="Usuario no encontrado") + + secret = pyotp.random_base32() + user.totp_secret = secret + db.commit() + + digest_func = get_digest_function(settings.TOTP_ALGORITHM) + + totp = pyotp.TOTP( + s=secret, + digits=settings.TOTP_DIGITS, + digest=digest_func, + interval=settings.TOTP_INTERVAL, + issuer=settings.TOTP_ISSUER, + name=f"{settings.TOTP_NAME_PREFIX}:{user.user_name}" + ) + + otpauth_url = totp.provisioning_uri( + name=f"{settings.TOTP_NAME_PREFIX}:{user.user_name}", + issuer_name=settings.TOTP_ISSUER + ) + + qr = qrcode.make(otpauth_url) + buf = io.BytesIO() + qr.save(buf, format="PNG") + img_base64 = base64.b64encode(buf.getvalue()).decode("utf-8") + + return { + "user_id": user.user_id, + "user_name": user.user_name, + "totp_secret": secret, + "otpauth_url": otpauth_url, + "qr_base64": img_base64, + "message": "2FA habilitado correctamente. Escanea el QR o introduce el código en tu app de autenticación." + } \ No newline at end of file diff --git a/backend/app/routes/users.py b/backend/app/routes/users.py new file mode 100644 index 0000000..84ce5af --- /dev/null +++ b/backend/app/routes/users.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.db.database import get_db +from app.models.user import UserModel +from app.schemas.user import UserRead, UserRegister +from app.core.security import hash_password +import pyotp + +router = APIRouter() + +@router.get("/users", response_model=List[UserRead]) +def get_all_users(db: Session = Depends(get_db)): + users = db.query(UserModel).all() + if not users: + raise HTTPException(status_code=404, detail="No hay usuarios registrados") + return users + +@router.get("/users/{user_id}", response_model=UserRead) +def get_user_by_id(user_id: int, db: Session = Depends(get_db)): + user = db.query(UserModel).filter(UserModel.user_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="No se ha encontrado el usuario.") + + return user + +@router.post("/users", response_model=UserRead) +async def create_user(user: UserRegister, db: Session = Depends(get_db)): + existing_user = db.query(UserModel).filter(UserModel.user_name == user.user_name).first() + if existing_user: + raise HTTPException(status_code=400, detail="El nombre de usuario ya existe") + + hashed_password = hash_password(user.password) + + new_user = UserModel( + user_name=user.user_name, + password=hashed_password, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + return new_user \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..cc98fb3 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, Field +from typing import Optional + +# POST /login +class UserLogin(BaseModel): + user_name: str + password: str + +# POST /register +class UserRegister(BaseModel): + user_name: str + password: str + +# POST /2fa +class User2FA(BaseModel): + user_name: str + pre_auth_token: str + totp_code: str + +# POST /enable-2fa +class Token2FA(BaseModel): + token: str + +# GET /users +# GET /users/{user_id} +class UserRead(BaseModel): + user_id: int + user_name: str + + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..db7a422 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +uvicorn +fastapi +sqlalchemy +pymysql +pydantic +pydantic_settings +dotenv +pyotp +passlib +PyJWT +bcrypt +qrcode +pillow \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a4cfc0b..711b655 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,7 +3,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min.js' const App = () => { return ( - <> + <> + + ) }