Backend finished

Co-authored-by: Álvaro <alvaro6gv@users.noreply.github.com>
This commit is contained in:
2025-11-10 19:49:26 +01:00
parent a2e823c4c3
commit d2f3cad487
18 changed files with 330 additions and 1 deletions

10
backend/.env Normal file
View File

@@ -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

7
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
cai3/*
app/__pycache__/*
app/core/__pycache__/*
app/db/__pycache__/*
app/models/__pycache__/*
app/routes/__pycache__/*
app/schemas/__pycache__/*

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -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()

View File

@@ -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"},
)

View File

View File

@@ -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()

16
backend/app/main.py Normal file
View File

@@ -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"])

View File

View File

@@ -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)

View File

121
backend/app/routes/auth.py Normal file
View File

@@ -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."
}

View File

@@ -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

View File

View File

@@ -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

13
backend/requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
uvicorn
fastapi
sqlalchemy
pymysql
pydantic
pydantic_settings
dotenv
pyotp
passlib
PyJWT
bcrypt
qrcode
pillow

View File

@@ -3,7 +3,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min.js'
const App = () => { const App = () => {
return ( return (
<></> <>
</>
) )
} }