В предыдущей части мы подготовили сервис GraphQL, к работе со слоем бизнес-логики.
Предстоящие задачи:
Подключиться из React + Apollo
Создать CID – идентификатор клиента (браузера) и записать его в Cookie
Достоверно связать websocket с клиентом CID
Обработка авторизации
Сам по себе пакет Gqlgen не предоставляет инструментов для работы с HTTP. Это не минус, ему это не нужно.
Конечно, наше приложение должно уметь ставить cookie и читать заголовки.
Все это решается с помощью middleware, в Gorilla mux это делается так:
// Инициализируем стор
st := store.NewStore(store.Options{})
// Создадим роутер
router := mux.NewRouter()
// Инициализируем middleware
// Передадим Store в качестве параметра
router.Use(middleware.AuthMiddleware(st))
router.Use(middleware.CorsMiddleware(st))
Код AuthMiddleware, стандартная middleware:
func AuthMiddleware(store *store.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Метод из Store, обрабатывает логику авторизации
r = store.AuthorizationHTTP(w, r)
next.ServeHTTP(w, r)
})
}
}
Код CorsMiddleware, ни какой логики выполнять не будем, все "захардкодим":
func CorsMiddleware(store *store.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers",
"Accept, Content-Type, Content-Length, Accept-Encoding")
if r.Method == "OPTIONS" {
return
}
next.ServeHTTP(w, r)
})
}
}
Метод AuthorizationHTTP
Тот самый метод что вызывается в AuthMiddleware. Здесь мы проверяем токены, выполняем авторизацию, работаем с cookie. Официальный пример здесь.
func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
ctx := r.Context()
return r.WithContext(ctx)
}
Метаданные: UID, CID и тд. будем сохранять в контекст. Напишем немного логики, чтобы в дальнейшем к ним можно было легко обратиться
package model
// Структура мета данных
type Meta struct {
Uid int
Cid string
Role string
Reconnect bool
Authorized bool
}
// Приватный ключ контекста
type key struct {
name string
}
// Читаем Meta из контекста
func (m *Meta) Value(ctx context.Context) *Meta {
meta := ctx.Value(key{"meta"})
if meta == nil {
return m
}
return meta.(*Meta)
}
// Пишем Meta в контекст
func (m *Meta) WithContext(ctx context.Context) context.Context {
ctx = context.WithValue(ctx, key{"meta"}, m)
return ctx
}
Теперь у нас есть все инструменты для авторизации дальнейших действий пользователя. Доработаем AuthorizationHTTP:
func (s *Store) AuthorizationHTTP(w http.ResponseWriter, r *http.Request) *http.Request {
ctx := r.Context()
// Создадим мету
meta := &model.Meta{}
// Проверим наличие токена Cid
cidCookie, err := r.Cookie("_cid")
if err != nil {
// Токена нет
cid := uuid.New().String()
cidCookie = &http.Cookie{
Name: "_cid",
Value: cid,
HttpOnly: true,
}
http.SetCookie(w, cidCookie)
}
// Прочитаем Cid и запишем в Meta
if cidCookie != nil {
meta.Cid = cidCookie.Value
}
return r.WithContext(
// Запишем Meta в контекст
meta.WithContext(ctx),
)
}
Напомню, про методы авторизации которые клиент обязан дернуть вызвать, в первую очередь.
Auth(ctx context.Context) (*model.Auth, error)
Если вернет reconnect, это значит что React Apollo должен перезапустить соединение.
- Зачем так сложно?
- Иногда не получается достоверно определить клиента websocket.
Он нам сейчас не очень интересен.
Метод подписки на авторизацию по websocket.
Кейс: пользователь в форме вводит свой username после чего получает сообщение с "Кнопкой входа" на емайл.
Условия "нажатия кнопки входа" на фронте не известны, тем не менее React должен понять, что там где-то – нажали эту кнопку. Когда React получит сообщение об имеющейся авторизации все что нужно, это вызвать любой HTTP запрос, и произойдет установка cookie с токеном пользователя.
Метод:
AuthSubscription(ctx context.Context) (<-chan *model.Auth, error)
Возвращает канал, все что в него записанно будет отправлено по websocket.
Следует учесть что каждая новая вкладка браузера создает дополнительный канал chan *model.Auth. По факту создаются разные клиенты, но связанные единым CID.
Отписка канала (закрытие вкладки), об этом можно узнать в методе ctx.Done:
func AuthSubscription(ctx context.Context) (<-chan *model.Auth, error) {
// Получим мета из контекста
meta := &model.Meta{}
meta = meta.Value(ctx)
// Создадим websocket id
wsid := uuid.New().String()
// Создадим канал
ch := make(chan *model.Auth)
// Логика по добавлению слушателя
fmt.Printf("Connect CID: %v, WSID: %v\n", meta.Cid, wsid)
// Логика по удалению слушателя
go func() {
<- ctx.Done()
fmt.Printf("Disconnect CID: %v, WSID: %v\n", meta.Cid, wsid)
}()
return ch, nil
}
Создадим фронт
Установим React
npx create-react-app front --template typescript
Установим Apollo Client и зависимости
npm install @apollo/client graphql subscriptions-transport-ws
Для упрощения процесса создания интерфейсов установим MUI этот момент не будем разбирать, так как он не входит в основную задачу.
Соединение Apollo Client
Соберем функцию соединения с АПИ, документация:
const connect = () => {
const httpLink = new HttpLink({
uri: 'http://localhost:2000/graphql',
credentials: 'include',
});
const wsLink = new WebSocketLink({
uri: 'ws://localhost:2000/graphql',
options: {
reconnect: true,
}
});
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink,
);
return new ApolloClient({
link: splitLink,
cache: new InMemoryCache(),
});
}
Мы хотим иметь возможность управлять соединением, переподключаться в случае необходимости. Для этой задачи лучше всего подходит контекст документация:
interface ContextTypes {}
export const ConnectContext = createContext<Partial<ContextTypes>>({})
export const ConnectProvider: FC = ({ children }) => {
const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)
return(
<ConnectContext.Provider value={{
}}>
<ApolloProvider client={state}>
{ children }
</ApolloProvider>
</ConnectContext.Provider>
)
}
Будем делать запрос авторизации по GET и websocket:
type TAuth = {
authorized: boolean
method: string
reconnect: boolean
}
interface ContextTypes {
auth: TAuth
logout: () => void
}
export const AuthorizationContext = createContext<Partial<ContextTypes>>({})
export const AuthorizationProvider: FC = ({ children }) => {
// GET запрос Auth
const { loading, error, data } = useQuery(QUERY);
useEffect(() => {
console.log("GET Auth", loading, error, data)
}, [loading, error, data])
// Слушаем Auth websocket
const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
SUBSCR
);
useEffect(() => {
console.log("Websocket Auth", wsLoading, wsError, wsData)
}, [wsLoading, wsError, wsData])
return (
<AuthorizationContext.Provider value={{
}}>
{children}
</AuthorizationContext.Provider>
)
}
const SUBSCR = gql`
subscription{
authSubscription{
authorized,
method,
reconnect
}
}
`;
const QUERY = gql`
query {
auth{
authorized,
method,
reconnect
}
}
`;
Эксперименты
При подключении клиента, терминал с запущенным сервисом на Go сообщает нам примерно следующее:
# При подключении
Connect CID: UUID_1, WSID: UUID_2
# При закрытии соединения
Disconnect CID: UUID_1, WSID: UUID_2
UUID_1 должен совпадать с cookie CID, иначе доставки не состоится.
Кейс отличающегося cookie CID с websocket ID
Шаги воспроизведения:
Удаляем токен CID,
обновляем вкладку
проверяем запись CID в Cookie
смотрим запись CID клиента, подключившегося по websocket (терминал Go)
Записи различаются, связано с Apollo, двигаться в сторону ping-pong
Что произошло?
Вебсокет соединение устанавливается прежде чем клиент (браузер) получает CID cookie.
ЗЫ: Почти уверен что это костыль и решение есть в Apollo. Если кто то знает как решать, буду рад в комментариях
Как решать?
Нужно сообщить на фронт о том что CID только что был установлен, для этого передадим флаг Reconnect.
Допишем логику AuthorizationHTTP:
// ...
cidCookie, err := r.Cookie("_cid")
if err != nil {
meta.Reconnect = true
// ...
}
Поправим метод AuthQuery:
func AuthQuery(ctx context.Context) (*model.Auth, error) {
// Получим мета из контекста
meta := &model.Meta{}
meta = meta.Value(ctx)
auth := &model.Auth{}
// Отправим флаг Reconnect, если он есть
auth.Reconnect = meta.Reconnect
return auth, nil
}
React ConnectProvider.
Свесим метод позволяющий выполнять перезапуск соединения
interface ContextTypes {
reconnect: () => void
}
export const ConnectContext = createContext<Partial<ContextTypes>>({})
export const ConnectProvider: FC = ({ children }) => {
const [state, setState] = useState<ApolloClient<NormalizedCacheObject>>(connect)
const reconnect = () => setState(connect)
return(
<ConnectContext.Provider value={{
reconnect
}}>
<ApolloProvider client={state}>
{ children }
</ApolloProvider>
</ConnectContext.Provider>
)
}
AuthorizationProvider вытащим из контекста метод reconnect
export const AuthorizationProvider: FC = ({ children }) => {
const { reconnect } = useContext(ConnectContext)
// GET запрос Auth
const { loading, error, data } = useQuery(QUERY);
useEffect(() => {
if (!loading && !error && data) {
const { reconnect: rc } = data.auth
if (rc === true && reconnect) {
// Если получен флаг reconnect
reconnect()
}
}
}, [loading, error, data])
// Слушаем Auth websocket
const { loading: wsLoading, error: wsError, data: wsData } = useSubscription(
SUBSCR
);
useEffect(() => {
console.log("Websocket Auth", wsLoading, wsError, wsData)
}, [wsLoading, wsError, wsData])
return (
<AuthorizationContext.Provider value={{
}}>
{children}
</AuthorizationContext.Provider>
)
}
Готово
Теперь мы всегда получаем одиноковые идентификаторы клиента в HTTP и WS запросах.
Закончим данный этап. Исходники здесь
Далее необходимо собрать слушателей websocket, и прикрутить авторизацию через СМС.