Создание приложения для подкастов GRANDstack: эпизод 2

Это второй пост в серии о создании приложения для подкастов с использованием GRANDstack. Ознакомьтесь с первым постом Поиск подкастов GraphQL API с Neo4j и индексом подкастов, в котором мы начинаем создавать GraphQL API и реализовывать функции поиска по подкастам.

В предыдущем посте мы запустили наше приложение для подкастов GRANDstack с создания GraphQL API и добавления функции поиска подкастов с помощью Podcast Index API. После поиска подкастов следующее, что наши пользователи захотят сделать, это начать подписываться на них, поэтому в этом выпуске мы сосредоточимся на том, чтобы позволить пользователям регистрироваться и входить в наше приложение, а затем реализовать функцию подписаться на подкаст. Мы построили эту функциональность на прямой трансляции Neo4j, которую вы можете посмотреть здесь:

Аутентификация пользователя

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

Настраивать

Мы будем использовать два пакета для включения аутентификации пользователей:

npm install jsonwebtoken bcrypt
  • bcrypt - реализация алгоритма bcrypt, одностороннего алгоритма хеширования, обычно используемого для хеширования паролей. Мы будем использовать эту библиотеку для создания хеша пароля пользователя при регистрации. Мы будем хранить хэш в базе данных и сравнивать его с паролем, который они отправляют при попытке входа в систему.
  • jsonwebtoken реализация JavaScript веб-токена JSON (JWT), стандарта для криптографического кодирования данных JSON в токен, который затем можно использовать в качестве токена авторизации. Мы сгенерируем подписанный JWT после того, как пользователь успешно зарегистрируется или войдет в систему, в которой пользователь затем сможет использовать для выполнения аутентифицированных запросов к нашему GraphQL API.

Нам также потребуется сгенерировать случайный 256-битный секрет, который будет использоваться для подписи наших токенов. По умолчанию jsonwebtoken будет использовать алгоритм HS256, мы также можем выбрать алгоритм RSA256, который использует пары открытого / закрытого ключей. Мы будем придерживаться алгоритма HS256 по умолчанию и сохраним ключ как переменную среды, добавив его в наш .env файл:

.env

JWT_SECRET=<RANDOM_256_BIT_SECRET_HERE>

Подписаться

Сначала мы добавим новую signup мутацию в определения наших типов GraphQL. Это поле мутации будет принимать два аргумента: username и password. Наша новая мутация signup вернет объект AuthToken с единственным строковым полем с именем token.

type Mutation {
  signup(username: String!, password: String!): AuthToken
}
type AuthToken {
  token: String!
}

Затем мы реализуем функцию преобразователя для поля мутации signup. Мы еще не создали никаких преобразователей, потому что мы использовали преимущества преобразователей, сгенерированных для нас neo4j-graphql.js, но поскольку мы хотим выполнить некоторую пользовательскую логику в JavaScript, которую мы не можем выразить в Cypher, нам необходимо реализовать этот преобразователь функция.

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

import jwt from 'jsonwebtoken';
import { compareSync, hashSync } from 'bcrypt';
const resolvers = {
  Mutation: {
    signup: (obj, args, context, info) => {
      args.password = hashSync(args.password, 10);
      const session = context.driver.session();
      return session
        .run(
          `CREATE (u:User) SET u += $args, u.id = randomUUID()
           RETURN u`,
          { args }
        )
        .then((res) => {
          session.close();
          const { id, username } = res.records[0].get('u').properties;
          return {
            token: jwt.sign({ id, username }, process.env.JWT_SECRET, {
              expiredIn: '30d'
            })
          };
        });
    }
  }
};

Теперь, если мы выполним мутацию signup GraphQL, мы сможем создать новых пользователей и сгенерировать токен авторизации для каждого пользователя.

mutation {
  signup(username: "jennycat", password: "feedme") {
    token
  }
}

Мы также можем взять токен, вставить его в отладчик JWT в jwt.io, чтобы декодировать полезную нагрузку и посмотреть, какие значения закодированы в токене. Мы должны увидеть имя пользователя и случайный идентификатор, сгенерированный для пользователя.

И если мы проверим Neo4j, мы увидим узел User, созданный в базе данных с именем пользователя, сгенерированным идентификатором пользователя и хешированным паролем, которые хранятся в базе данных.

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

Авторизоваться

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

Сначала мы добавляем поле login к типу Mutation в наших определениях типа GraphQL:

type Mutation {
  signup(username: String!, password: String!): AuthToken
  login(username: String!, password: String!): AuthToken
}

И реализуйте функцию преобразователя для этого поля мутации в index.js. Мы найдем пользователя по имени пользователя и сравним предоставленный пароль с хешированным паролем, хранящимся в базе данных. Если пароль не совпадает, мы выдаем ошибку, в противном случае мы генерируем токен авторизации JWT.

const resolvers = {
  signup: (obj, args, context, info) => {...},
  login: (obj, args, context, info) => {
    const session = context.driver.session();
    return session
      .run(
        `MATCH (u:User {username: $username}))
        RETURN u LIMIT 1`,
        { username: args.username }
      )
      .then((res) => {
        session.close();
        const { id, username, password } = res.records[0].get('u').properties;
        if (!compareSync(args.password, password)) {
          throw new Error('Authorization Error');
        }
        return {
          token: jwt.sign({ id, username }, process.env.JWT_SECRET, {
            expiresIn: '30d'
          })
        };
      });
  }
};

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

mutation {
  login(username: "jennycat", password: "feedme") {
    token
  }
}

Проверенные запросы GraphQL

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

{
  "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImI3YjBmYmZkLTJjNzMtNDBiZS1hZGUxLTM1MjMzZWJhZDE5ZSIsInVzZXJuYW1lIjoiamVubnljYXQiLCJpYXQiOjE2MDc3NDI2MDUsImV4cCI6MTYxMDMzNDYwNX0._2xHiqrrwZR3ZXH9np9O2oWcx6iBsWd4OZLnd6DjtqY"
}

Найдите аутентифицированного пользователя

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

Параметры шифра

Мы уже видели мощную @cypher директивную функциональность библиотеки neo4j-graphql.js, которая позволяет нам определять настраиваемую логику с помощью Cypher в нашей схеме GraphQL. Мы видели, что любые аргументы поля GraphQL передаются в оператор Cypher как параметры Cypher. Теперь мы воспользуемся преимуществом функции Cypher Parameters директивы @cypher, которая позволит нам передавать значения в оператор Cypher с помощью объекта контекста GraphQL.

Любые значения в объекте context.cypherParams будут доступны в запросах Cypher с использованием директивы схемы @cypher. Это можно использовать для вставки информации о пользователе в эти запросы Cypher.

Давайте обновим наш экземпляр ApolloServer, где мы указываем значение объекта контекста. Вместо объекта мы также можем определить объект контекста с помощью функции. Эта функция вызывается при каждом запросе, и ей передается объект запроса, который будет включать заголовок авторизации при выполнении аутентифицированного запроса GraphQL. Мы возьмем токен авторизации из заголовка запроса, проверим его и добавим идентификатор пользователя в объект cypherParams (помните, что каждый токен кодирует идентификатор пользователя и имя пользователя). Затем этот идентификатор пользователя будет доступен в запросе Cypher при использовании директивы схемы @cypher.

const server = new ApolloServer({
  context: ({ req }) => {
    const token = req?.headers?.authorization?.slice(7);
    let userId;
    if (token) {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      userId = decoded.id;
    }
    return {
      cypherParams: { userId },
      driver,
      neo4jDatabase: process.env.NEO4J_DATABASE
    };
  },
  schema
});

Теперь, если токен авторизации указан в запросе GraphQL, мы можем ссылаться на $cypheParams.userId в нашем запросе Cypher, чтобы ссылаться на текущего аутентифицированного пользователя.

Давайте добавим поле запроса GraphQL для возврата текущего пользователя и поиска его в базе данных по идентификатору пользователя.

type Query {
  currentUser: User
  @cypher(
    statement: """
    MATCH (u:User {id: $cypherParams.userId})
    RETURN u
    """
  )
}
type User {
  username: String
  id: ID!
}

Теперь мы можем запросить аутентифицированного в данный момент пользователя.

{
  currentUser {
    username
    id
  }
}

Подкаст Подписаться

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

Подписаться на мутацию

Мы добавим еще одно поле мутации GraphQL, subscribeToPodcast, которое будет принимать единственный аргумент, itunesId подкаста, и подписывать пользователя на подкаст. Однако мы еще не сохранили данные подкастов в базе данных - наша функция поиска подкастов вызывает API индекса подкастов и возвращает результаты, но не обновляет базу данных. В мутации subscribeToPodcast мы хотим убедиться, что у нас есть детали подкаста для хранения в базе данных, поэтому сначала мы вызовем индекс подкастов, чтобы получить детали подкаста, сохраним детали в узле Podcast, а затем создадим связь SUBSCRIBES_TO. соединение узла User и узла Podcast. Мы также добавляем тип Podcast к нашим определениям типа GraphQL для представления этих узлов.

type Mutation {
  signup(username: String!, password: String!): AuthToken
  login(username: String!, password: String!): AuthToken
  subscribeToPodcast(itunesId: String!): Podcast
  @cypher(
    statement: """
    WITH toString(timestamp()/1000) AS timestamp
    WITH {
    `User-Agent`: 'GRANDstackFM',
    `X-Auth-Date`: timestamp,
    `X-Auth-Key`: apoc.static.get('podcastkey'),
    `Authorization`: apoc.util.sha1([apoc.static.get('podcastkey') + apoc.static.get('podcastsecret') + timestamp])
    } AS headers
    CALL apoc.load.jsonParams('https://api.podcastindex.org/api/1.0/podcasts/byitunesid?id=' + apoc.text.urlencode($itunesId), headers, '', '') YIELD value
    WITH value.feed AS feed
    MATCH (u:User {id: $cypherParams.userId})
    MERGE (p:Podcast {itunesId: $itunesId})
    SET p.title       = feed.title,
        p.link        = feed.link,
        p.description = feed.description,
        p.feedURL     = feed.url,
        p.image       = feed.artwork
    MERGE (u)-[:SUBSCRIBES_TO]->(p)
    RETURN p
    """
  )
}
type Podcast {
  itunesId: ID!
  title: String
  link: String
  feedURL: String
  description: String
  image: String
}

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

Получить список подкастов с подпиской для аутентифицированного пользователя

Мы также хотим вернуть список подкастов, на которые подписан аутентифицированный пользователь. Для этого мы добавляем поле запроса subscribedPodcasts, которое будет использовать значение $cypherParams.userID для поиска узла User в базе данных и всех подписанных подкастов для пользователя.

type Query {
  subscribedPodcasts: [Podcast]
  @cypher(
    statement: """
    MATCH (u:User {id: $cypherParams.userId})-[:SUBSCRIBES_TO]->(p:Podcast)
    RETURN p
    """
  )
}

Теперь мы можем запрашивать наши подписанные подкасты - не забудьте включить наш токен авторизации в качестве заголовка авторизации.

{
  subscribedPodcasts {
    title
    description
    itunesId
    feedURL
    image
  }
}

Мы реализовали регистрацию пользователей, вход в систему и подписку на подкасты в нашем GraphQL API. Есть еще несколько случаев, которые нам нужно будет решить (убедиться, что пользователи не могут зарегистрировать одно и то же имя пользователя, улучшенная обработка ошибок, обработка сброса пароля и т. Д.), Но теперь у нас есть базовая аутентификация в нашем GraphQL API. В следующем выпуске мы начнем анализировать URL-адреса каналов и добавлять на график выпуски и плейлисты. Присоединяйтесь к нам в четверг в 14:00 по тихоокеанскому времени в Neo4j Livestream, чтобы следить за нами вживую.

Ресурсы