[File-X Demo] — OAuth 2.0 Implicit Grant Type

[File-X Demo] — OAuth 2.0 Implicit Grant Type

完成上一教程的“额外任务”后,我们现在可以开始编写“我的”页面了。

目标

页面分析

“我的”页面的页面布局与“发现”页面基本保持一致,应该可以从“发现”页面中“借鉴”一些代码;“我的”页面的页面内入主要是“当前用户”的事件列表。

我们需要从后台获取“当前用户”的事件列表,我们需要告诉服务器,“我是谁”。所以,我们需要带着代表用户身份的 Token 访问 API 服务器,API 服务器才能知道“我是谁”,进而拿到对应的数据。但是我们要怎样才能拿到代表用户身份的 Token 呢?这里我们需要使用 Feat.com 根据 “OAuth 2.0 Implicit Grant Type 认证机制” 提供的方法来获取。通过这个机制,认证服务会给我们的应用发放一个 Token ,我们的应用带着这个 Token 对 API 服务器进行访问,就可以拿到想要的数据了。

下图是 OAuth 2.0 Implicit Grant Type 的流程:

获取用户授权的准备工作 — 创建OAuth应用

访问 https://www.feat.com/application ,在 feat.com 上创建应用(你可能需要先注册登录)

  • “Grant Type” 选择 “Implicit”。
  • 填写 Redirect URIs

出于安全性的考虑中, feat.com 不支持将 Redirect URIs 设置为 `localhost`, `127.0.0.1` 这一类域名。所以在本地开发时,需要使用 `192.168.1.xxx` 这一类本地路由的ip(你可能需要对配置一下防火墙,确定浏览器可以通过外部IP地址访问开发服务器)。

组装授权连接

OAuth 应用创建完成之后,我们需要根据 featapi.com 上的文档说明,在应用内组合一个认证链接,当用户点击“认证”按钮时,页面需要转跳到 feat.com 上进行授权。

新建文件 src/AuthRequiredHint.js ,并编辑:

import React from 'react'
import { stringify } from 'qs'

const getAuthorizeLink = (next) => {
    const authorizeUrl = process.env.REACT_APP_FEAT_AUTHORIZE_URL
    const params = {
      client_id: process.env.REACT_APP_FEAT_CLIENT_ID,
      redirect_uri: window.location.origin + '/authorize'+(next ? `?${stringify({next})}` : '' ),
      response_type: 'token'
    }
    return `${authorizeUrl}?${stringify(params)}`
  }

function AuthRequiredHint(props) {
    return (
        <div className='Landing'>
        <div className='Landing__inner'>
          <div className='Landing__desc'>
            应用需要获取到您在feat.com上的授权后,才能进行下一步操作
          </div>
          <div className='Landing__action'>
            <a className='button' href={getAuthorizeLink(props.pathname)}>
              开始授权
            </a>
          </div>
        </div>
      </div>
    )
}

export default AuthRequiredHint;

上面的代码,我们使用 location.origin 来组装 redirect_uri, 这样做是为了方便我们线上部署。假设我们将应用部署到http://filex-demo.featapi.com,那么redirect_uri 就是 http://filex-demo.featapi.com/authorize,我们只需要修改一下feat.com上的应用设置即可重新oauth认证对接好。

打开 .env.local,在配置文件中添加两项

REACT_APP_FEAT_AUTHORIZE_URL=https://www.feat.com/authorize
REACT_APP_FEAT_CLIENT_ID=__Replace_This_With_Your_App_Client_ID__

__Replace_This_With_Your_App_Client_ID__ 需要替换为你在feat.com上创建的应用的Client ID。修改完成之后,需要重启开发服务器。

打开 src/index.css ,在文件底部添加样式

.Landing {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    background: white;
}
.Landing__inner {
    width: 80%;
    max-width: 280px;
}
.Landing__desc {
    margin-bottom: 16px;
}
.Landing__action {
    padding-top: 8px;
    padding-bottom: 8px;
    text-align: center;
}

最后,修改 src/Me.js

import React from 'react';
import AuthRequiredHint from './AuthRequiredHint'
function Me() {
    return (
        <AuthRequiredHint />
    )
}

export default Me;

打开浏览器,访问 http://192.168.1.122/me , 可以看到的效果如下图:

注意通过局域网IP进行访问,你的地址应该会跟这个不一样。

点击“开始授权”按钮,浏览器会打开 feat.com 上的授权页面,如无意外,浏览器的页面大致如下图所示:

如果出现 403 错误,请先确认应用不是通过 `localhost` 或者 `127.0.0.1` 等本机IP地址访问。

我们点击“授权”,页面最终应该会转跳回“发现”页面。那我们怎么能获取到Token呢?我们来分析一下。feat.com页面已经成功的将页面转跳回我们的应用,那说明feat.com上的授权动作是已经完成了,那我们的应用为什么会直接返回“发现”页面,而不是“我的”页面,或者其他页面(好像没有其他页面)呢。我们来检查一下我们的前端路由设置:

function App () {
  return (
    <Router>
      <Switch>
        <Route path='/explore' exact component={Explore} />
        <Route path='/me' exact component={Me} />
        <Route path='/user/:uid' exact component={UserPage} />
        <Route path='/event/new' exact component={EventCreation} />
        <Route path='/event/:id/comment' exact component={EventComment} />
        <Route
          path='/event/:id/comment/:cid/edit'
          exact
          component={CommentEdit}
        />
        <Route
          path='/event/:id/comment/:cid/reply'
          exact
          component={CommentReply}
        />
        <Route path='/event/:id/comment/new' exact component={CommentCreate} />
        <Redirect to='/explore' />
      </Switch>
    </Router>
  )
}

我们可以看到,最底下有一个重定向的设置,当没有匹配到任何路由时,会回到“发现”页面。那很有可能我们的重定向地址没有在路由中进行声明。经过观察(实际上并没有观察,直接搜索/authorize就好),我们的确没有在路由上面添加任何对应/authorize路径的代码,

在重定向路由中,提取用户token信息

接下来,我们添加代码来处理/authorize

编辑 src/App.js

import Authorize from './Authorize'
function App () {
  return (
    <Router>
      <Switch>
        <Route path='/explore' exact component={Explore} />
        <Route path='/me' exact component={Me} />
        <Route path='/user/:uid' exact component={UserPage} />
        <Route path='/event/new' exact component={EventCreation} />
        <Route path='/event/:id/comment' exact component={EventComment} />
        <Route
          path='/event/:id/comment/:cid/edit'
          exact
          component={CommentEdit}
        />
        <Route
          path='/event/:id/comment/:cid/reply'
          exact
          component={CommentReply}
        />
        <Route path='/event/:id/comment/new' exact component={CommentCreate} />
        <Route path='/authorize' exact component={Authorize} />
        <Redirect to='/explore' />
      </Switch>
    </Router>
  )
}

新建src/Authorize.js

import React from 'react'
import { parse } from 'qs'

function Authorize(props) {
    const {
        location: {
            search, 
            hash,
        }
    } = props;

    const token = hash.slice(1);
    const query = parse(search.slice(1));
    console.log(token, query);
    // 通过获取当前用户信息来验证是否已成功获取Token
    fetch(`${process.env.REACT_APP_FEAT_API_ENDPOINT}/api/user/basic-info/`, {
        method: 'GET',
        headers: {
            Authorization: `${query.token_type} ${token}`,
            Accept: 'application/json',
        }
    })
    .then((res) => res.json())
    .then(({ data }) => {
        console.log(data);
    })
    .catch((err) => {
        console.log(err);
    })

    return (
        <div>
            Authorize
        </div>
    )
}

export default Authorize;

当前用户基本信息的API的相关说明,可以到 featapi.com 上查阅

打开浏览器,访问“我的”页面,完成认证流程,返回我们的应用,浏览器页面中可以看到:

在 console 中可以看到:

到这里,我们就已经把 OAuth 2.0 Implicit Grant Type 的授权对接上了。接下来,我们就要想办法将 Token 信息存起来,方便应用后续的调用。

保存用户 Token

在一个开始的时候,我们修改了 src/Me.js 的代码,直接显示AuthRequiredHint的内容。其实完成的逻辑应该是:

function Me() {
    if (hasTokenInfo) {
        return (
            <div>
                My Event List
            </div>
        )
    }
    return <AuthRequiredHint />
}

接下来,我们就要在“全局”范围内,存储token信息,并考虑中提供一些辅助方法来帮助代码编写。现阶段,我们先使用 React 的 Context API 来存储 token 数据。

这里的“全局”不一定是指运行环境的global,只是想说这个应该在足够上层的地方存储 Token 信息,这样整个应用范围内都拿到相关的参数。

准备存储 token 信息的地方

新建 src/AuthInfoProvider.js,并编辑

import React, { useReducer, useEffect } from 'react';

const defaultState = {
    currentUser: null,
    token: undefined,
}
const STORAGE_KEY = 'authInfo';
const reducer = (state, action) => {
    switch (action.type) {
        case 'set-token':
            return {
                ...state,
                token: action.payload,
            }
        case 'set-current-user':
            return {
                ...state,
                currentUser: action.payload,
            };
        case 'reset':
            return defaultState;
        default: 
            return state;
    }
}
let initState;
try {
    initState = JSON.parse(localStorage.getItem(STORAGE_KEY));
} catch {
    
}
initState = initState || defaultState;

export const AuthInfo = React.createContext(initState);

function AuthInfoProvider(props) {
    const [state, dispatch] = useReducer(reducer, initState);
    useEffect(() => {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
    }, [state]);

    return (
        <AuthInfo.Provider 
            value={{
                state, 
                dispatch,
            }}
        >
            {props.children}
        </AuthInfo.Provider>
    )
}

export default AuthInfoProvider;

编辑 src/App.js,将AuthInfoProvider 放置在最外层

import AuthInfoProvider from './AuthInfoProvider';
function App(props) {
    return (
        <AuthInfoProvider>
            <Router>
                {/* ...routes */}
            </Router>
        </AuthInfoProvider>
    )
}

存储 token 信息

接下来,我们要将token信息,存放到AuthInfoContext当中。

打开src/Authorize.js,并编辑

import React, { useEffect, useContext } from 'react'
import { parse } from 'qs'
import { Redirect } from 'react-router-dom';
import { AuthInfo } from './AuthInfoProvider';
import { fetchUserBasicInfo } from './requests';


function Authorize(props) {
    const {
        state,
        dispatch
    } = useContext(AuthInfo);
    const {
        location: {
            search, 
            hash,
        }
    } = props;

    const token = hash.slice(1);
    const query = parse(search.slice(1));

    useEffect(() => {
        if (token) {
            // 尝试获取数据
             const tokenPayload = {
                token_type: query.token_type,
                access_token: token,
                expires_in: query.expires_in,
            }
            dispatch({
                type: 'set-token',
                payload: tokenPayload
            });
            // 将原来的fetch请求,移动到 `requests.js` 中
            fetchUserBasicInfo(tokenPayload)
            .then(({ data }) => {
                dispatch({
                    type: 'set-current-user',
                    payload: data,
                })
            })
            .catch((err) => {
                global.alert(err.message)
            });
        }
    // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // 返回到认证起始页面
    if (state.token) {
        return <Redirect to={query.next || '/me'} />
    }
    
    if (!token) {
        return <div>Invalid request</div>
    }

    return (
        <div>
            Authorize
        </div>
    )
}

export default Authorize;

编辑 src/requests.js,添加 fetchUserBasicInfo 方法

export const fetchUserBasicInfo = async (token) => {
    const url = `${API_ENDPOINT}/api/user/basic-info/`;
    return await fetch(url, {
        method: 'GET',
        headers: {
            Authorization: `${token.token_type} ${token.access_token}`
        }
    }).then(resHelper);
}

来到这里,我们发现认证完成之后,页面是可以回到“我的”页面了,但是还是显示“需要认证的提示”。这里我们可以使用 React Developer Tools 来查看我们的数据是否已经准备好了。

应用内读取 token 信息

接下来,我们编辑一下 src/Me.js,根据 AuthInfo 的情况来渲染页面

import React from 'react';
import { AuthInfo } from './AuthInfoProvider';
import AuthRequiredHint from './AuthRequiredHint';
function Me() {
    const { state } = useContext(AuthInfo);
    if (state.token) {
        return (
            <div>MY Event List </div>
        )
    }
    return <AuthRequiredHint />
}

此处可以直接修改 <AuthInfoProvider value={fakeData} /> 的方式来验证逻辑是否完成

编辑完成之后,当我们再次访问的/me 页面的时候,我们可以看到如下图所示的页面

继续编辑“我的”页面

调整页面布局

我们从src/Explore.js中抄来一部分代码,来完成三段式布局:

import React from 'react';
import { AuthInfo } from './AuthInfoProvider';
import AuthRequiredHint from './AuthRequiredHint';
function Me() {
    const { state, dispatch } = useContext(AuthInfo);
    if (state.token) {
        return (
           <div className='App'>
            <div className="App__header">
                <div className="PageHeader">
                {state.currentUser ? (
                    <div className='AvatarStamp pl_12'>
                        <img className='AvatarStamp__avatar' src={state.currentUser.avatar} />
                        <span className='AvatarStamp__username'>{state.currentUser.username}</span>
                    </div>
                    ) : <div className='pl_12'>...</div>}
                    <div className="PageHeader__right pr_12">
                        <button 
                            className='button button_merge'
                            onClick={() => {
                                dispatch({
                                    type: 'reset'
                                })
                            }}
                        >
                            退出
                        </button>
                    </div>
                </div>
            </div>
            <div className="App__content">
                MY Event List 
            </div>
            <div className="App__footer">
                <BottomBar />
            </div>
        </div>
        )
    }
    return <AuthRequiredHint />
}

打开 src/index.css,添加样式

.AvatarStamp {
  display: flex;
  align-items: center;
}
.AvatarStamp__avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  margin-right: 8px;
}

调用 EventList 组件

继续从src/Explore.js中抄来 EventList 的调用,然后修改为 EventList 组件提供的 request 方法。

import React, { useContext, useCallback } from 'react';
import { fetchEventList } from './requests'

function Me(props) {
    const { state } = useContext(AuthInfo);
     const handleListRequest = useCallback((pagination) => {
        if (!state.token) {
            return;
        }
        return fetchEventList(pagination, state.token);
    }, [state.token]);

    if (state.token) {
        <div className='App'>
            {/* ... */}
                <EventList 
                    request={handleListRequest}
                    showUserInfo={false}
                />
            {/* ... */}
        </div>
    }

    // ...
}

编辑src/requests.js, 添加方法 fetchEventList,相关API文档可查阅: FileX.Event: list

export const fetchEventList = async (params, token) => {
    const query = params ? stringify(params) : '';
    const baseURL = `${API_ENDPOINT}/api/xfile/event/`;
    const url = query ? `${baseURL}?${query}` : baseURL;
    const headers = {
        Accept: 'application/json',
        Authorization: `${token.token_type} ${token.access_token}`
    }
    
    return await fetch(url, {
        headers
    }).then(resHelper);
}

刷新“我的”页面,就可以看到当前用户的“事件”列表了:

小结

这个部分的教程中,我们主要介绍了:

  • OAuth 2.0 Implicit Grant Type 的流程
  • 如何使用 React 的 Context API 提供储存、调用应用内的“全局”数据

在下一篇教程中,我们将会继续深入了解 File-X API 提供的功能,并实现 File-X 事件的基本管理功能。

发表评论

您的电子邮箱地址不会被公开。

您可以使用以下 HTML标签和属性:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

Captcha Code