В создании своих 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-проектов. Конечно, его можно доработать и я займусь этим в будущем. Спасибо всем.

Комментарии (9)


  1. savostin
    29.09.2024 09:45

    Что же происходит когда token expires?


  1. Viacheslav-hub Автор
    29.09.2024 09:45

    Здравствуйте, вот простая проверка на время жизни, если оно истекло, вызываю HTTPException:

    if datetime.fromtimestamp(float(exp)) - datetime.now() < timedelta(0):
                    raise credentials_exception
    credentials_exception = HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Could not validate credentials",
                headers={"WWW-Authenticate": "Bearer"},
            )

    Возможна и другая логика при истечении времени токена


  1. milssky
    29.09.2024 09:45

    А чего код не оформили? :) Где-то отступы есть, где-то нет. Где-то пробелы в параметрах метода есть, где-то нет и т.п. Где-то типы возвращаемых значений поставили, а где-то нет.

    Даже для пет-проекта решение как минимум неудобно:

    1. Настройки токена зашиты жестко в код. При этом есть какой-то объект настроек (видимо, что-то на базе pydantic-settings). Почему бы не использовать его для всех настроек?

    2. Поведение функции validate_user максимально неудобно. Зачем возвращать из нее булево значение, если можно выкинуть исключение? При этом вы дальше как раз на базе этого значения кидаете исключение. Ну и else в этой функции лишний. Почитайте про guard clause.

    3. Логирования нет.

    4. Работа с исключениями в get_current_user заставила нервно улыбнуться. Не надо так писать :) Да и вообще вся функция просится на переработку

    5. В строке запуска ювикорна какая-то лажа

    В js я не волоку, но жонглирование типами, например в переменной ans в getUserByToken -- явно не самая клевая идея.

    Резюме для всех, кто захочет применить это на практике. Не надо. Возьмите какой-нит fastapi-users и не парьтесь.


    1. Viacheslav-hub Автор
      29.09.2024 09:45

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


  1. aronsky
    29.09.2024 09:45
    +1

    А чего пароль в токен не добавили? Что-то вроде «доя упрощения примера я засунул юзера вместе с паролем в токен, но вы можете так не делать»


  1. KivApple
    29.09.2024 09:45
    +1

    Как реализовать функцию "выйти со всех устройств"?

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

    Как я понимаю, чистый jwt имеет смысл либо совсем короткоживущий (секунды, как промежуточный ключ авторизации в каком-то более сложном процессе), либо при общении между микросервисами (когда токен полученный от пользователя уже валидирован фронтовым микросервисом каким-то stateful способом). Во всех остальных случаях он является огромной дырой.

    Закрыть эту дыру можно механизмом отзыва токенов. Хранить в каком-нибудь Redis список отозванных токенов. Такая проверка будет быстрее, чем по БД.

    В свою очередь в своих петах я реализую лениво просто один токен на все сессии. У юзера в БД есть колонка auth_token. Если там NULL в момент авторизации, генерирую случайную строку, кладу в БД и отдаю юзеру. Если там уже что-то есть, возвращаю то что есть. Все запросы начинаются с поиска в БД юзера по токену (это, конечно же, колонка с уникальным индексом). Выход с одного устройства осуществляется просто забыванием токена без запроса на сервер. Выход со всех устройств приводит к записи NULL в колонку токена (или можно сразу перегенерировать токен, если нам нужна функция "выйти со всех устройств, кроме текущего").

    Соответственно, таким образом как и с JWT мы не плодим строчки на каждую забытую сессию и не нуждаемся в JOIN с информацией о пользователе, зато позволяем юзеру прибить все сессии в случае компрометации учётной записи.

    Можно дополнительно завернуть такой токен в JWT, если хочется индивидуального времени жизни токенов. Хотя я такое не люблю, я предпочитаю сайты с вечной авторизацией (собственно, все популярные сервисы типа гугла или вконтакте авторизуют пользователя навсегда). Если хочется особой секурности, можно сделать два токена. Один вечный, другой сессионный. Все запросы идут со вторым, но если приходит ошибка 401, то через специальный эндпойнт и вечный токен, получается новый сессионный токен (таким образом мы значительно понижаем шансы при MITM, что утечет вечный токен, ибо его ещё надо поймать). Но для пета скорее всего это будет излишне, так как кража токена прямо из HTTPS маловероятна в целом.

    Мне видится основными сценариями атаки либо вирус на компьютере (в таком случае следует полагать, что украдено всё - и все токены, и пароли, единственное, что может сделать пользователь - сменить пароль и воспользоваться функцией "выйти со всех устройств"), либо забыл выйти с общего компьютера (поможет только "выйти со всех устройств"). Ещё есть фишинг, но там как и в случае с вирусом без смены пароля и "выйти со всех устройств" не обойтись.


    1. Viacheslav-hub Автор
      29.09.2024 09:45

      Спасибо за ваш за развернутый и действительно полезный отзыв! Я обязательно попробую реализовать такой сценарий использования. У чистого JWT,конечно,есть свои недостатки и в таком виде использовать его в продакшне небезопасно,нужно делать более сложную структуру


  1. KivApple
    29.09.2024 09:45

    Также я бы добавил, что секретный ключ JWT критически важно надёжно хранить и уж точно не копипастить из статьи (знание ключа позволяет генерировать jwt с ЛЮБЫМ содержанием, которые будут неотличимы от легитимных). Если очень лень реализовывать механизм конфигурации и генерировать свой ключ, можно генерировать случайный ключ при запуске приложения. Тогда, конечно, перезапуск приложения будет грохать все активные сессии, но это лучше, чем использовать чужой ключ.

    На месте автора статьи, я бы вставил сниппет с генерацией при запуске.


    1. Viacheslav-hub Автор
      29.09.2024 09:45

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