Backend finished
Co-authored-by: Álvaro <alvaro6gv@users.noreply.github.com>
This commit is contained in:
10
backend/.env
Normal file
10
backend/.env
Normal 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
7
backend/.gitignore
vendored
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal 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()
|
||||||
35
backend/app/core/security.py
Normal file
35
backend/app/core/security.py
Normal 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"},
|
||||||
|
)
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
18
backend/app/db/database.py
Normal file
18
backend/app/db/database.py
Normal 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
16
backend/app/main.py
Normal 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"])
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
10
backend/app/models/user.py
Normal file
10
backend/app/models/user.py
Normal 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)
|
||||||
0
backend/app/routes/__init__.py
Normal file
0
backend/app/routes/__init__.py
Normal file
121
backend/app/routes/auth.py
Normal file
121
backend/app/routes/auth.py
Normal 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."
|
||||||
|
}
|
||||||
44
backend/app/routes/users.py
Normal file
44
backend/app/routes/users.py
Normal 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
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
31
backend/app/schemas/user.py
Normal file
31
backend/app/schemas/user.py
Normal 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
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
uvicorn
|
||||||
|
fastapi
|
||||||
|
sqlalchemy
|
||||||
|
pymysql
|
||||||
|
pydantic
|
||||||
|
pydantic_settings
|
||||||
|
dotenv
|
||||||
|
pyotp
|
||||||
|
passlib
|
||||||
|
PyJWT
|
||||||
|
bcrypt
|
||||||
|
qrcode
|
||||||
|
pillow
|
||||||
@@ -3,7 +3,9 @@ import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
|||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<></>
|
<>
|
||||||
|
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user