Мой вариант аутентификации с помощью JWT в FastAPI + React

в 8:15, , рубрики: fastapi, jwt, jwt auth, python, React

В создании своих pet проектов у многих возникает вопрос аутентификации пользователя. Это может быть связано с персональным отображением страниц, настройки доступа и т.д.

В этой статье я хочу показать мое решение вопроса. Сразу скажу, что оно может быть не идеальным, могут быть другие варианты решения, но, мне кажется, для pet проекта, а может и больше этого вполне достаточно.

Что будем использовать?

Я решил поэкспериментировать с JWT (JSON web token) токеном, так как его не нужно хранить в базе или где то на сервере, это упрощает архитектуру приложения.

Для работы нам необходимо установить PyJWT

pip install pyjwt

Помимо этого можно установить passlib для хэширования паролей

pip install  "passlib [bcrypt]"

Как это работает?

Наш токен представляет собой длинную строку без пробелов, это выглядит так:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Он не зашифрован, поэтому любой может получить информацию из токена, это можно посмотреть на https://jwt.io/. Но, хоть токен и не зашифрован, он его подписан. Поэтому, когда мы получаем, отправленный нами токен, его можно проверить, убедиться действительно ли отправили его именно мы.

Также токен имеет срок действия, поэтому мы можем создать его на месяц, неделю, час и т.д. Если какой-нибудь не добросовестный человек попробует изменить срок действия или закодированную информацию, то мы обнаружим это при декодировании токена.

Сам токен хранится на стороне клиента, поэтому нам не нужно выделять место для его хранения на сервере.

Наша система должна работать примерно следующим образом:

При регистрации пользователя или при его входе в аккаунт мы выдаем новый токен, в который кодируем дату создания и необходимые данные -> клиент получает этот токен и сохраняет его на своей стороне-> при следующем обращении к серверу клиент отправляет токен -> сервер расшифровывает токен и возвращает результат.

Реализуем сторону сервера

Для этого я создал python фаил и класс UserAuth. В этом классе мы реализуем методы:

  1. Создание токена

    data - данные для кодирования в токен, expires_delta - время действия токена

def create_access_token(self, data:dict, expires_delta: timedelta) -> str:
  1. Декодирование токена, token - сам токен

def decode_token(self,token:str):
  
  1. Выдача токена при авторизации, для этого примера в токен будем записывать почту и пароль, можно выбрать и другой набор данных.

def login_for_access_token(self,email:str, password:str) -> Token:
  1. Валидация данных. Проверяем существование записи с таким логином и паролем.

def validate_user(self, email:str, password: str) -> Union[UserDTO, bool]:

Выдача токена

Выдача токена происходит при авторизации пользователя, поэтому напишем логику для

login_for_access_token(). Здесь я проверяю существование пользователями с введенными данными, если его нет, вызываю ошибку. Затем создаю токен с определенным временем жизни. Время жизни можно вынести в файл настроек для удобного изменения.

    def login_for_access_token(self, email: str, password: str) -> Token:
        user: UserDTO = self.validate_user(email, password) #проверка введенных данных
      
        if not user:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Incorrect username or password",
                headers={"WWW-Authenticate": "Bearer"},
            )
      
        access_token_expires = timedelta(minutes=15) #время действия токена
        #данные для кодирования
        access_token = self.create_access_token(
            data={"email": user.email, "password": user.password},
            expires_delta=access_token_expires
        ) #создание токена
        return Token(access_token=access_token, token_type="bearer", access_token_expires=str(access_token_expires))

Для удобного представления токена я использую pydantic схему с несколькими полями:

access_token - сам токен

token_type - вид токена

access_token_expires - продолжительность действия

from pydantic import BaseModel

class Token(BaseModel):
    access_token: str
    token_type: str
    access_token_expires: str

Валидация пользователя

Для проверки полученных данных я написал простой метод с несложной проверкой, где я обращаюсь к базе и проверяю, существует ли запись с такой почтой и паролем. Я не усложняю этот пример хэшированием данных, но вы можете это сделать:

    def validate_user(self, email: str, password: str) -> Union[UserDTO, bool]:
        user: UserDTO = user_repository.select_user_by_email(email)
        if user and user.password.__eq__(password):
            return user
        else:
            return False

UserDTO - pydantic схема, которая состоит из нескольких полей

from pydantic import BaseModel
class UserDTO(BaseModel):
    id: int
    login: str
    email: str
    password: str

Создание токена

На прошлом шаге мы проверили переданные данные, теперь эти данные нужно закодировать в наш токен и добавить продолжительность действия. Для этого напишем тело create_access_token():

    def create_access_token(self, data: dict, expires_delta: timedelta) -> str:
        to_encode = data.copy() #копируем данные для кодирования
        expire = datetime.now(timezone.utc) + expires_delta
        to_encode.update({"exp": expire}) #к текущему времени прибавляем время жизни
        encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
        return encoded_jwt

при создании токена необходимо указать секретный ключ, который можно получить с помощью этой команды:

rand openssl -hex 32

Для примера можете использовать этот ключ:

09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7

также нужно указать используемый алгоритм для подписи, в моем случае это "HS256 ". Эти данные так же удобно вынести в файл конфигурации.

Проверка полученного токена

На этом шаге мы уже проверили данные и выдали токен, теперь остается получать и проверять выданный нами токен при последующих обращениях пользователя.

В своем проекте я делаю это отдельным запросом. Для этого реализуем decode_token_and_get_token():

    def get_current_user(self, token: str):
        #заранее подготовим исключение
        credentials_exception = HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Could not validate credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )
        try:
            # декодировка токена
            payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
            
            #данные из токена
            email: str = payload.get("email")
            password: str = payload.get("password")
            exp: str = payload.get("exp")

            #если в токене нет поля email
            if email is None:
                raise credentials_exception
                
            #если время жизни токена истекло
            if datetime.fromtimestamp(float(exp)) - datetime.now() < timedelta(0):
                raise credentials_exception

        except InvalidTokenError:
            raise credentials_exception

        #проверка данных
        user: UserDTO = self.validate_user(email, password)
        
        if user is None:
            raise credentials_exception
        return user

При декодировании токена необходимо указать токен, секретный ключ и алгоритм, которым мы его кодировали. После декодирования мы словарь из которого можно забрать необходимые данные.

Создаем Routers

Для обращения к нашему приложению напишем несколько роутеров. Я создал отдельный файл routers. При таком подходе не забудь добавить новый роутер в main файл.

#создаем новый роутер
router = APIRouter(
    prefix="/users_api",
    tags=["Users"],
)

#экземпляр класса
user_auth = UserAuth()

Нам нужно будет два роутера - login() для авторизации и read_me() - для проверки токена.

@router.post("/login")
async def login(user: UserLogin):
    #получаем токен и возращаем клиенту
    token = user_auth.login_for_access_token(user.email, user.password)
    return token
@router.post("/me")
async def read_me(token: TokenGet):
    #декодируем токен и получаем обьект пользователя
    return user_auth.decode_token(token.token)

TokenGet - pydantic схема для удобного отображения с одним полем token

Для просмотра результата можно запустить наше приложение с помощью Uvicorn

uvicorn src.main:app --use-colors --log-level ёdebug --reload

Если перейти в документацию нашего приложения, то мы увидим наши методы:

для входа и выдачи токена

для входа и выдачи токена
для отправления и проверки токена

для отправления и проверки токена

Сразу хочу сказать, что при обращении извне к приложению может возникнуть ошибка политики корсов. Чтобы ее решить необходимо указать в main файле приложения следующее:

app = FastAPI()
app.include_router(user_router)

#указываем адреса, которые могут обращаться к нашему приложению
origins = [
    "http://localhost:5173",
    "http://127.0.0.1:5173",
    "http://localhost:5174",
    "http://127.0.0.1:5174",

]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

На этом заканчивается часть сервера.

Сторона клиента

На клиенте я использую React JS и сборщик проекта Vite.

Работа с токеном

Для начала создадим файл Auth.jsx. В нем реализуем логику сохранения и получения токена. Хранить токен будем в localStorage под ключом 'Token'

export const setToken = (token) => {
    localStorage.setItem('Token', token)
}

получаем сохраненный токен из localStorage

export const getToken = () => {
    return localStorage.getItem('Token')
}

Отправляем токен и получаем данные клиента. Для этого отправляем запрос к нашему серверу, адрес можно узнать в docs FastApi вашего приложения. В теле запроса указываем наш токен и дожидаемся результата.

export async function getUserByToken(token) {
    var ans = false;

    const res = await axios.post(`http://127.0.0.1:8000/users_api/me`, {
        "token": token
    }).then((resp) => {
        if (resp.status === 200) {
            const response = resp.data
            ans = response
        }else{
            ans  = false
        }
    }).catch((error) => console.error(error));
    return ans
}

Страница пользователя

Возьмем условную страницу пользователя, которую мы хотим отображать персонализировано. Для этого, при загрузке страницы, я обращаюсь к нашему хранилищу за токеном, который отправляю серверу

//мпортируем методы из созданного нами фаила на прошлом шаге
import {getUserByToken, getToken, setToken} from "../../Auth.jsx"

//переменная для сохранения данных пользователя
const [userData, setUserData] = useState()


// используем UseEffect, чтобы запросить данные при загрузке страницы
useEffect(() => {
        let token = getToken()
        if (token) {
            let user = getUserByToken(token)
            user.then(function(result) {
                user = result
                setUserData(user)
            })
        }
    }, []);

Таким образом мы отправили токен, получили данные пользователя и сохранили в нашем состоянии.

Для проверки можете использовать небольшой пример страницы :

return (
        <>
            {userData ? (
                <div>пользователь {userData.id}</div>
            ) :(
                <div>данные не получены</div>
            )
            }
        </>

Дожидаемся получения ответа и отображаем страницу.

Заключение

Спасибо всем, кто дочитал эту статью до конца, я вам очень признателен. Этот решения довольно прост в реализации и подойдет для pet-проектов. Конечно, его можно доработать и я займусь этим в будущем. Спасибо всем.

Автор: Viacheslav-hub

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js