Аутентификация и роли
Сервис работает, но пускает кого угодно: создать игру или выдать экземпляр может любой. Закроем управляющие эндпоинты. Пройдём путь от простого к рабочему: сначала 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.