Раздел 21 · Haskell

Аутентификация и роли

senior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Аутентификация и роли

Сервис работает, но пускает кого угодно: создать игру или выдать экземпляр может любой. Закроем управляющие эндпоинты. Пройдём путь от простого к рабочему: сначала BasicAuth, потом JWT-сессии для stateless-входа. А роли (админ клуба против участника) выразим типом, чтобы забытая проверка прав не компилировалась.

BasicAuth: самый дешёвый замок

BasicAuth это логин и пароль в заголовке. В Servant он выступает комбинатором в типе API, и обработчик получает уже опознанного пользователя.

type Protected = BasicAuth "meeple" AuthUser :> "games" :> ReqBody '[JSON] NewGame :> Post '[JSON] Game

Проверку задаёт BasicAuthCheck, который кладётся в контекст сервера:

authCheck :: BasicAuthCheck AuthUser
authCheck = BasicAuthCheck $ \(BasicAuthData user pass) ->
  if user == "admin" && pass == "secret"
    then pure (Authorized (AuthUser 0 Admin))
    else pure Unauthorized

BasicAuth годится для служебных ручек. Но пароль летит в каждом запросе, а сессия не сохраняется. Для входа с жизненным циклом берём JWT.

Роль это тип

Прежде чем закрывать эндпоинты, опишем, кто к нам ходит. Роль это не строка и не булев флаг isAdmin, а тип:

data Role = Admin | Member
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON)

data AuthUser = AuthUser
  { authUserId :: Int64
  , authRole :: Role
  }
  deriving stock (Eq, Show, Generic)
  deriving anyclass (FromJSON, ToJSON, FromJWT, ToJWT)

Инстансы FromJWT/ToJWT позволяют положить AuthUser прямо в токен и достать обратно: подписанный токен носит пользователя с собой, и серверу не нужно лезть в базу на каждый запрос, чтобы понять, кто пришёл.

JWT через servant-auth-server

JWT это подписанный токен: клиент носит его в заголовке, сервер проверяет подпись своим секретом. Управляющую часть API из урока 43 накрываем комбинатором Auth:

type MeepleApi =
  PublicApi
    :<|> "login" :> ReqBody '[JSON] LoginRequest :> Post '[JSON] LoginResponse
    :<|> (Auth '[JWT] AuthUser :> ManageApi)

Auth '[JWT] AuthUser :> ManageApi значит: чтобы попасть в управляющие маршруты, нужен валидный JWT, из которого достаётся AuthUser. Обработчик такой ветки получает не AuthUser, а AuthResult AuthUser: токена может не быть, он может протухнуть или оказаться поддельным.

Проверка прав

Из AuthResult право доступа добываем явно. Две функции: одна требует любого опознанного пользователя, другая ещё и роль администратора.

requireAuth :: AuthResult AuthUser -> AppM AuthUser
requireAuth (Authenticated u) = pure u
requireAuth _ = throwApp NotAuthorized

requireAdmin :: AuthResult AuthUser -> AppM AuthUser
requireAdmin result = do
  user <- requireAuth result
  case authRole user of
    Admin -> pure user
    Member -> throwApp NotAuthorized

Вот где роль-как-тип окупается: case authRole user of обязан разобрать обе ветки, и Member нельзя молча пропустить как админа. Чтобы выдать участнику права админа по ошибке, пришлось бы написать заведомо неверную ветку, а не просто забыть if. Обработчики используют это так:

manageServer :: AuthResult AuthUser -> ServerT ManageApi AppM
manageServer auth = createGame :<|> createMember :<|> lendCopy :<|> returnCopy
  where
    createGame ng = requireAdmin auth >> runDb (insertGame ng)
    lendCopy cid req = requireAuth auth >> {- выдача -} ...

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

Типом мы выражаем, кто пришёл, но не что он хочет сделать с конкретным объектом. Проверку вида “это твоя бронь, а не чужая” тип эндпоинта не закроет: данные о владельце известны только во время выполнения, и решение принимает обработчик.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.