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 = () => {
|
||||
return (
|
||||
<></>
|
||||
<>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user