45. ReactJS Authentication Tutorial

Tatsuya Asami
68 min readSep 4, 2018

--

【所要時間】

13時間2分( 8月29,30日, 9月3,4日)

【概要】

Reactを使った認証のチュートリアル

Reactのコンセプト、バックエンドAPIを頼るQ&Aの動作の確認

【要約・学んだこと】

React Components

componentsを使う最大のメリットは、UIの様々な部分をカプセル化して独立化し、再利用可能とする点だ。

Defining Components in React

ComponentsにはFunctional ComponentsとClass Componentsがある。Functional Components and Class Components

  • Funcitonal Componentsは内部にstateを持たず、Class Componentsは内部にstateを持つことができる。

Functional Components

function UserProfile(props) {
return (
<div className="user-profile">
<img src={props.userProfile.picture} />
<p>{props.userProfile.name}</p>
</div>
);
}

Class Components

class SubscriptionForm extends React.Component {
constructor(props) {
super(props);

this.state = {
acceptedTerms: false,
email: '',
};
}

updateCheckbox(checked) {
this.setState({
acceptedTerms: checked,
});
}

updateEmail(value) {
this.setState({
email: value,
});
}


submit() {
// ... use email and acceptedTerms in an ajax request or similar ...
}

render() {
return (
<form>
<input
type="email"
onChange={(event) => {this.updateEmail(event.target.value)}}
value={this.state.email}
/>
<input
type="checkbox"
checked={this.state.acceptedTerms}
onChange={(event) => {this.updateCheckbox(event.target.checked)}}
/>
<button onClick={() => {this.submit()}}>Submit</button>

</form>
)
}
}

このcomponentは2つのinputと1つのbutton elementを定義している。
最初のinputはユーザーがemail addressを入力する。
2つ目のinputはチャットボックスは、ユーザーが任意の条件に同意できるかどうかを定義している。
buttonはユーザーがsubscriptionプロセスを終了するときにクリックするプロセスだ。

また、componentは acceptedTermsemail のstateを持つ。
acceptedTerms はフィールドを使用して架空の条件に関するユーザーの選択肢を表現し、email は電子メールアドレスを保持する。
そしてユーザーがsubmit buttonをクリックすると、このフォームが内部 stateを使い、AJAX request を発行する。

Re-Rendering React Components

re-renderingはcomponentが受け取るprops、または内部stateを変更することだ。

  • stateを持つcomponentを使う時は、setStateメソッドでstateを変更することでre-renderできる。(stateではなくsetState)

Note: ReactではsetState()の直後にthis.stateで更新することを保証しない。ライブラリはよりたくさんのことを変更する機会を待っている。さらに詳しくは check the official documentation on setState().

  • stateを持たないcomponentでは、渡されるpropsを変更することがre-renderのトリガーとなる。Reactでは、propsはcomponentを渡すpropertyにすぎない。
function UserProfile(props) {
return (
<div className="user-profile">
<img src={props.userProfile.picture} />
<p>{props.userProfile.name}</p>
</div>
);
}

ここで渡される、使われるのはuserProfileというpropertyだけだ。しかし、ここではpropertyを渡すpropsが抜けている。この場合どこで、どうやってcomponentを使うのかが抜けている。これを行うには、componentをHTML elementのように使うだけだ。(JSXのいい機能だ。)

import React from 'react'; 
import UserProfile from './UserProfile';

class App extends () {
constructor(props) {
super(props);
this.state = {
user: {
name: 'Bruno Krebs',
picture: 'https://cdn.auth0.com/blog/profile-picture/bruno- krebs.png',
},
};
}
render() {
return (
<div>
<UserProfile userProfile={this.state.user} />
</div>
);
}
}

これが子componentにpropsを渡す方法だ。親component(App)のuserが変わると、component全体のre-renderがトリガーされ、その後、UserProfileに渡されたpropsが変更され、re-renderもまたトリガーされる。

Note: Reactはpropsが変わってもclass componentsをre-renderする。これはfunctional componentsにはない動作だ。

What You Will Build with React

ここからsimple Q&A appを作る。それはユーザーがお互いに質問と回答をできるアプリだ。ここではNode.jsとExpressを使って大まかなバックエンドAPIをつくる。

Developing a Backend API with Node.js and Express

ExpressはNode.jsの最小限のwebフレームワークで、あまり知られてはいない。ここでは簡単にアプリをサービス上で実行するために構築する。(バックエンドアプリ)

# create a directory for your Express API 
mkdir qa-api
# move into it
cd qa-api
# use NPM to start the project
npm init -y

npm init -y でpackage.jsonファイルを呼び出す。このファイルはバックエンドAPIの詳細(dependenciesのような)を保有する。

npm i body-parser cors express helmet morgan

このコマンドを実行し、下記の5つのdependenciesをプロジェクトにインストールする。

  • body-parser: 入ってくるリクエストのbodyをJSON objectsにコンバートするために使うライブラリ
  • cors: EXPRESSを設定して、ヘッダーに他のoriginからくるAPIアクセプトのリクエストを追加するために、使用するライブラリ。これはCross-Origin Resource Sharing (CORS) としても知られる
  • express: これがEXPRESS自身
  • helmet: 様々なHTTPヘッダーでEXPRESS appを保護するのに役立つライブラリ
  • morgan:EXPRESSアプリにいくらかのロギング機能を追加するライブラ

これらのlibraryをインストールした後、 NPMがpackage.jsonファイルがdependencies propertyにそれらを含むように変更したのがわかる。また、package-lock.jsonというファイルが追加されたのがわかる。
NPMはこのファイルを使い、プロジェクトを使っている他の人(または他の環境での自分自身)が、いつもインストールしているものと互換性のあるバージョンを取得するようにする。

最後にbackend ソースコードを開発する。qa-apiディレクトリにsrcディレクトリをつくり、index.jsというファイルをその中に作る。

index.jsに下記のコードを記入する。

//全ては5つのrequire statementsから始まる。これらはNPMでインストールした全てのライブラリをロードする。
//import dependencies
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
//新しいアプリを定義するためにExpressをつかう。
// define the Express app
const app = express();
//databaseとして動作するarrayを作る。本物のアプリではMongo, PostgreSQL, MySQLなどの実際のdatabaseを使う。
// the database
const questions = [];
//Express appのメソッドを呼ぶ。Expressと共にインストールしたそれぞれのライブラリを設定する。
// enhance your app security with Helmet
app.use(helmet());
//Express appのメソッドを呼ぶ。Expressと共にインストールしたそれぞれのライブラリを設定する。
// use bodyParser to parse application/json content-type
app.use(bodyParser.json());
//Express appのメソッドを呼ぶ。Expressと共にインストールしたそれぞれのライブラリを設定する。
// enable all CORS requests
app.use(cors());
//Express appのメソッドを呼ぶ。Expressと共にインストールしたそれぞれのライブラリを設定する。
// log HTTP requests
app.use(morgan('combined'));
//最初のendpointを定義する。これはquestions listをリクエストした人に送る。
// retrieve all questions
app.get('/', (req, res) => {
const qs = questions.map(q => ({
id: q.id,
title: q.title,
description: q.description,
answers: q.answers.length,
}));
res.send(qs);
});
//別のendpointを定義する。single questionのリクエストに反応する。(今は全ての質問)
// get a specific question
app.get('/:id', (req, res) => {
const question = questions.filter(q => (q.id === parseInt(req.params.id)));
if (question.length > 1) return res.status(500).send();
if (question.length === 0) return res.status(404).send();
res.send(question[0]);
});
//3つ目のendpointを定義する。誰かがPOST HTTPリクエストをAPIに送るたびにアクティベートされる。ここでのゴールはリクエストのbodyに送られたメッセージを取得して、データベースのnew Questionに挿入することだ。
// insert a new question
app.post('/', (req, res) => {
const {title, description} = req.body;
const newQuestion = {
id: questions.length + 1,
title,
description,
answers: [],
};
questions.push(newQuestion);
res.status(200).send();
});
//最後のendpointをAPIで定義する。特定の質問に回答を挿入する。この場合、route parameterを使い、新しい質問を追加しなければいけない質問を特定するために、idをコールする。
// insert a new answer to a question
app.post('/answer/:id', (req, res) => {
const {answer} = req.body;
const question = questions.filter(q => (q.id === parseInt(req.params.id)));
if (question.length > 1) return res.status(500).send();
if (question.length === 0) return res.status(404).send();
question[0].answers.push({
answer,
});
res.status(200).send();
});
最後にバックエンドAPIを実行するためにExpress appでlisten functionを呼ぶ。
// start the server
app.listen(8081, () => {
console.log('listening on port 8081');
});

このアプリを実行する。

# from the qa-app directory
node src

全てが順調に動いているなら、新たにターミナルを開き、下記のコマンドを発行する。

//空のarray([])が出力されるHTTP GETリクエストのトリガー
# issue an HTTP GET request
curl localhost:8081
//APIにPOST requestをする
# issue a POST request
curl -X POST -H 'Content-Type: application/json' -d '{
"title": "How do I make a sandwich?",
"description": "I am trying very hard, but I do not know how to make a delicious sandwich. Can someone help me?"
}' localhost:8081
////APIにPOST requestをする
curl -X POST -H 'Content-Type: application/json' -d '{
"title": "What is React?",
"description": "I have been hearing a lot about React. What is it?"
}' localhost:8081
//これらの質問が適切に挿入されたかを確かめるための他のGETリクエスト
# re-issue the GET request
curl localhost:8081

Developing Applications with React

バックエンドAPIが動作し、React applicationの開発を始める準備ができた。下記のコマンドをqa-apiディレクトリがある場所と同じqa-appディレクトリで実行し開始する。

# this command was introduced on npm@5.2.0 
npx create-react-app qa-react

このコマンドはNPMをダウンロードし、create-react-appを実行する。qa-reactは新しいアプリのディレクトリになる。

下記のコマンドでReactを始める

# move into the new directory
cd qa-react

# start your React app
npm start

Ctrl + Cで一度ストップし、下記のコマンドを実行する。

npm i react-router react-router-dom

このコマンドでアプリのナビゲーションを取り扱う2つのライブラリをダウンロードする。react-routerはつなぎ目のないナビゲーションを可能にするメインライブラリで、react-router-domはDOM bindingsをReact Routerに提供する。

Cleaning Up your React App

始める前に./src/App.test.jsファイルを削除する。なぜならここでは自動でtestを作りたくないからだ。これは重要だが、このチュートリアルとは関係ないので飛ばす。
また、./src/logo.svgと./src/App.cssも削除する。これらを削除した後、./src/App.jsファイルを開き、下記のコードに置き換える。

import React, { Component } from 'react';

class App extends Component {
render() {
return (
<div>
<p>Work in progress.</p>
</div>
);
}
}

export default App;

Configuring the React Router in Your App

アプリのReact Routerの設定をする。React Routerについて理解するには、他で学ぶ必要がある。to learn more about React Router, please, head to the official documentation.

./src/index.jsファイルを下記のコードに置き換える。

import React from 'react';
import ReactDOM from 'react-dom';
import {BrowserRouter} from 'react-router-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

ReactDOM.render(
<BrowserRouter>
<App/>
</BrowserRouter>,
document.getElementById('root')
);
registerServiceWorker();

ここではBrowserRouterをreact-router-domライブラリからインポートした。このルータの内部にAPP componentをカプセル化する。ここでやるのはこれだけだ。

Note: これはReactアプリをrenderさせるロジックだ。document.getElementByld(‘root’)は、ReactがアプリをrenderするHTML elementを定義する。このroot elementは./public/index.htmlファイルで見つかる。

Configuring Bootstrap in Your React App

よりよいUIの見た目にするため、Bootstrapの設定をする。Bootstrapは簡単に見た目がよく、応答性のあるウェブアプリを作るのに役立つ。

ReactとBootstrapを統合する方法はいくつかあるが、ここではinteractive componentsが必要ないので、最も簡単な戦略が使える。それは./public/index.htmlファイルを開き、下記のコードにかきかえる。

<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... tags above the title stay untouched ... -->
<title>Q&App</title>
<link rel="stylesheet" href="https://bootswatch.com/4/flatly/bootstrap.min.css">
</head>
<!-- ... body definition stays untouched ... -->
</html>

この場合、React appのtitleをQ&Appに変更し、flatlyと呼ばれるBootstrapのバリエーションをアプリにロードした。 Bootswatch で使えるバリエーションを使うか、Bootstrapのデフォルトを使っても良い。

Creating a Navigation Bar in Your React App

アプリがBootstrapを使う設定をした。React componentを作る準備ができた。このセクションでは、NavBar(Navigation barを表す)と呼ばれるcomponentを作り、それをReact appに加える。

srcディレクトリの中にNavBarと呼ばれるディレクトリを作る。そこにNavBar.jsというファイルを作り、下記のコードを挿入する。

import React from 'react';
import {Link} from 'react-router-dom';
function NavBar() {
return (
<nav className="navbar navbar-dark bg-primary fixed-top">
<Link className="navbar-brand" to="/">
Q&App
</Link>
</nav>
);
}
export default NavBar;

これはfunctional componentだ。stateless componentを作ることができる。なぜならinternal stateを持つ必要がないからだ。

./src/App.jsファイルを開き、下記のように更新する。

import React, { Component } from 'react';
import NavBar from './NavBar/NavBar';
class App extends Component {
render() {
return (
<div>
<NavBar/>
<p>Work in progress.</p>
</div>
);
}
}
export default App;

navigation barが上部に表示されたが、App componentのコンテンツの”work in progress”が消えた。navigation barはCSS class(fixed-top)をBootstrapに提供されているので、トップに固定される。
つまりこのcomponentは通常のdiv elementと同様に、デフォルトの垂直方向のスペースを取っていない。

これを修正するために、 ./src/index/cssファイルにmargin-topルールを加える。

body {
margin: 0;
padding: 0;
font-family: sans-serif;
margin-top: 100px;
}

これで表示された。

Creating a Class Component with React

バックエンドから質問を取得してユーザーに表示するためのstateful component(class component)を作る。

質問を取得するために、 Axios というライブラリが必要。AixiosはブラウザとNode.jsのためのpromise-based HTTPクライアントだ。このチュートリアルではブラウザ(React app)のみで使う。

このコマンドでaxiosをインストール

npm i axios

新たにQuestionsディレクトリをsrcフォルダに作り、Questions.jsをそのディレクトリに入れ、下記のコードを書く。

import React, {Component} from 'react';
import {Link} from 'react-router-dom';
import axios from 'axios';
class Questions extends Component {
constructor(props) {
super(props);
this.state = {
questions: null,
};
}
async componentDidMount() {
const questions = (await axios.get('
http://localhost:8081/')).data;
this.setState({
questions,
});
}
render() {
return (
<div className="container">
<div className="row">
{this.state.questions === null && <p>Loading questions...</p>}
{
this.state.questions && this.state.questions.map(question => (
<div key={question.id} className="col-sm-12 col-md-4 col-lg-3">
<Link to={`/question/${question.id}`}>
<div className="card text-white bg-success mb-3">
<div className="card-header">Answers: {question.answers}</div>
<div className="card-body">
<h4 className="card-title">{question.title}</h4>
<p className="card-text">{question.description}</p>
</div>
</div>
</Link>
</div>
))
}
</div>
</div>
)
}
}
export default Questions;

前述の通り、このstateful componentはバックエンドAPIで利用可能なquestionsを保有する。そのため、componentはquestions propertyを使いnullとセットし、Reactがcomponent(これはcomponentDidMountのトリガー)のマウンティングを終えると、GET リクエスト(axios.getコールを通じて)をバックエンドに発行する。

リクエストをし、バックエンドから反応があるまでの間、Reactはcomponentと”loading questions…” メッセージをrenderする。(メッセージの前にthis.state.questions === null && を追加し、そのように動作するようにしたため)

そして、Axiosがバックエンドからレスポンスを得る度に、questions constantの内部にreturnされたdataを置く。そしてcomponentのstate(this.setState)と共に更新する。

questionsがどのように表示されるかについて、Bootstrapに供給されたCSS classのあるdiv elementsの束を使い、 Card component を作る。

加えて、Link(react-router-domからの)というcomponentを使うのに気をつける。これはクリックされた時に /question/${question.id} をこのredirectユーザーにパスする。

次のセクションでユーザーに選ばれたquestionのanswersを示すcomponentを作る。

次は新しいcomponentを使うための App componentのコードを更新する。

import React, { Component } from 'react';
import NavBar from './NavBar/NavBar';
import Questions from './Questions/Questions';
class App extends Component {
render() {
return (
<div>
<NavBar/>
<Questions/>
</div>
);
}
}
export default App;

Routing Users with React Router

重要な手順として、React appでroutingをどう扱うかを学ぶ必要がある。このセクションでは、バックエンドで利用可能なquestionsの詳細を示すcomponentを作る間の出来事を学ぶ。

Questionディレクトリを作り、その中にQuestion.jsをつくり、下記のコードを挿入する。

import React, {Component} from 'react';
import axios from 'axios';
class Question extends Component {
constructor(props) {
super(props);
this.state = {
question: null,
};
}
async componentDidMount() {
const { match: { params } } = this.props;
const question = (await axios.get(`http://localhost:8081/${params.questionId}`)).data;
this.setState({
question,
});
}
render() {
const {question} = this.state;
if (question === null) return <p>Loading ...</p>;
return (
<div className="container">
<div className="row">
<div className="jumbotron col-12">
<h1 className="display-3">{question.title}</h1>
<p className="lead">{question.description}</p>
<hr className="my-4" />
<p>Answers:</p>
{
question.answers.map((answer, idx) => (
<p className="lead" key={idx}>{answer.answer}</p>
))
}
</div>
</div>
</div>
)
}
}
export default Question;

このcomponentはQuestionsと似た動作をする。Axiosを使ったstateful componentで、GETリクエストをquestionの詳細全体を取得するendpointに発行する。そしてresponse backを取得する度にページを更新する。

このcomponentのrenderの取得方法が新しいので、App.jsを開き下記のコンテンツと置き換える。

import React, { Component } from 'react';
import {Route} from 'react-router-dom';
import NavBar from './NavBar/NavBar';
import Question from './Question/Question';
import Questions from './Questions/Questions';
class App extends Component {
render() {
return (
<div>
<NavBar/>
<Route exact path='/' component={Questions}/>
<Route exact path='/question/:questionId' component={Question}/>
</div>
);
}
}
export default App;

Questions componentのrenderが欲しい時と、Question componentのrenderが欲しい時に2つのRoute elements(react-router-domに提供された)をReactに伝える。

Reactにユーザーが/ (exact path=’/’)に移動してQuestionsをそれらに見せたい場合や、/question/:questionId に移動する場合、特定の質問の詳細をそれらに表示させたい。

questionIDに注意する。Questions componentを作った時、questionのidを使ったリンクを加えた。React Routerはこのidをリンクのフォームに使い、Question component(params.questionId)に与える。このidを使い、componentはAxiosをバックエンドに何のquestionがリクエストされているのかを正確に伝えるのに使う。

アプリを開くとホームページに全てのquestionsが表示される。そして特定のquestionをナビゲートすることができる。しかし、まだ加えていないので新しいcomponentにanswerが見えない。

questionにanswerを加えるために、下記のリクエストを発行する。

curl -X POST -H 'Content-Type: application/json' -d '{
"answer": "Just spread butter on the bread, and that is it."
}' localhost:8081/answer/1

回答が表示された。

Securing your React App

このセクションでは、ユーザーのquestionsとanswersを反映したり、logoを表示する方法などを簡単に実行する方法を学ぶ。

Auth0をsubscribingすることで、認証機能を助け、バックエンドの安全にし、React appを安全な状態にする。また、Question componentをreactし、認証されたユーザーがquestionにanswerできるようにする。

Configuring an Auth0 Account

まずはAuth0に再アップし、アプリに組み込む必要がある。 sign up for a free Auth0 account
フリーアカウントで下記の機能にアクセスできる。

アプリ内でAuth0 applicationを使用するために諸々設定する。

setting tabのAllowed Callbackに http://localhost:3000/callback を設定する。これはAuth0で認証するプロセスで、ユーザーがUniversal login pageにredirectされる。認証プロセスが終わると、アプリにredirect backする。
セキュリティの理由から、Auth0はこのフィールドに登録されたURLからのユーザーにのみredirectする。

Securing your Backend API with Auth0

Node.js APIとAuth0を安全にするために、2つのライブラリをインストール、設定する。

  • express-jwt: JSON web Token(JWT)を検証し、req.user にその属性を設定するミドルゥエア。
  • jwks-rsa: JWKS(JSON Web Key Set) endpointからRSA公開キーを取得するライブラリ。
# from the qa-api directory 
npm i express-jwt jwks-rsa

qa-api/src/index.jsにライブラリをインポートする。

// ... other require statements ...
const jwt = require('express-jwt');
const jwksRsa = require('jwks-rsa');

そして最初のPOST endpointの直前に下記のconstantを作る。

// get a specific question
app.get('/:id', (req, res) => {
const question = questions.filter(q => (q.id === parseInt(req.params.id)));
if (question.length > 1) return res.status(500).send();
if (question.length === 0) return res.status(404).send();
res.send(question[0]);
});
const checkJwt = jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://<YOUR_AUTH0_DOMAIN>/.well-known/jwks.json`
}),
// Validate the audience and the issuer.
audience: '<YOUR_AUTH0_CLIENT_ID>',
issuer: `https://<YOUR_AUTH0_DOMAIN>/`,
algorithms: ['RS256']
});
// insert a new question
app.post('/', (req, res) => {
const {title, description} = req.body;
const newQuestion = {
id: questions.length + 1,
title,
description,
answers: [],
};

このconstantはID tokensを検証するExpress middlewareだ。動作させるために<YOUR_AUTH0_CLIENT_ID> placeholderをAuth0 ApplicationのClient ID fieldに表示されているvalueに置き換える必要がある。

また、<YOUR_AUTH0_DOMAIN>をDomin field(bk-tmp.auth0.com)の現在のvalueと置き換える必要がある。

そして2つのPOST endpointにcheckJwtミドルウェアを使用させる必要がある。これを行うには、endpointを次のように置き換える。

// insert a new question
app.post('/', checkJwt, (req, res) => {
const {title, description} = req.body;
const newQuestion = {
id: questions.length + 1,
title,
description,
answers: [],
author: req.user.name,
};
questions.push(newQuestion);
res.status(200).send();
});
// insert a new answer to a question
app.post('/answer/:id', checkJwt, (req, res) => {
const {answer} = req.body;
const question = questions.filter(q => (q.id === parseInt(req.params.id)));
if (question.length > 1) return res.status(500).send();
if (question.length === 0) return res.status(404).send();
question[0].answers.push({
answer,
author: req.user.name,
});
res.status(200).send();
});

まず、どちらのendpointもcheckJwtを使いたいと宣言している。これは認証されていないユーザーが利用できないようにしている。次にどちらもauthorという新しいpropertyを加えている。

これらの新しいpropertyはユーザーが発行したリクエストの名前(req.user.name)を受け取る。

これでバックエンドAPI(node src)を再び開始し、React appにrefactorできる。

Note: checkJwtミドルウェアをGET endpointに加えていないのは、公共アクセスを許可したいからだ。これは認証されていないユーザーがQuestionとAnswerを見えるようにするためだが、彼らは新しい質問や答えを作れない。

Note: バックエンドAPIはメモリにデータを保持するだけなので、リスタートすると以前あった全ての質問と回答を失う。curlを通じて質問を追加するために、Auth0からID Tokenを取得しなければいけない。

Securing your React App with Auth0

Auth0でReactアプリを安全にするために、auth0-jsをインストールする。これはAuth0に提供されたライブラリで、SPAsを安全にする。

# from the qa-react directory 
npm install auth0-js

認証ワークフローに役立つClassを作成する。srcディレクトリにAuth.jsを作り、下記のコードを挿入する。

import auth0 from 'auth0-js';class Auth {
constructor() {
this.auth0 = new auth0.WebAuth({
// the following three lines MUST be updated
domain: '<YOUR_AUTH0_DOMAIN>',
audience: 'https://<YOUR_AUTH0_DOMAIN>/userinfo',
clientID: '<YOUR_AUTH0_CLIENT_ID>',
redirectUri: 'http://localhost:3000/callback',
responseType: 'token id_token',
scope: 'openid profile'
});
this.getProfile = this.getProfile.bind(this);
this.handleAuthentication = this.handleAuthentication.bind(this);
this.isAuthenticated = this.isAuthenticated.bind(this);
this.signIn = this.signIn.bind(this);
this.signOut = this.signOut.bind(this);
}
getProfile() {
return this.profile;
}
getIdToken() {
return this.idToken;
}
handleAuthentication() {
return new Promise((resolve, reject) => {
this.auth0.parseHash((err, authResult) => {
if (err) return reject(err);
if (!authResult || !authResult.idToken) {
return reject(err);
}
this.idToken = authResult.idToken;
this.profile = authResult.idTokenPayload;
// set the time that the id token will expire at
this.expiresAt = authResult.expiresIn * 1000 + new Date().getTime();
resolve();
});
})
}
isAuthenticated() {
return new Date().getTime() < this.expiresAt;
}
signIn() {
this.auth0.authorize();
}
signOut() {
// clear id token, profile, and expiration
this.idToken = null;
this.profile = null;
this.expiresAt = null;
}
}
const auth0Client = new Auth();export default auth0Client;

ここでも<YOUR_AUTH0_CLIENT_ID> and <YOUR_AUTH0_DOMAIN> を自分のアドレスに書き換える。

Auth classに7つのメソッドを定義した。

  • constructor: Auth0 valueでauth0.WebAuth のinstanceを作成し、他の重要な設定をインポートする。例えば、Auth0はユーザー(redirectUri) にhttp://localhost:3000/callback URL (the same one you inserted in the Allowed Callback URLs field previously)をredirectするように定義している。
  • getProfile: このメソッドは認証されたユーザーのプロファイルがあればreturnする。
  • getIdToken: このメソッドはAuth0の現在のユーザーに作られた idToken POST endpointにリクエストを発行しているときに使う。
  • handleAuthentication: このメソッドはアプリがAuth0にユーザーがredirectされた直後に呼び出す。このメソッドは単にユーザー詳細とid tokenを取得するためにURLのハッシュセグメントを読む。
  • isAuthenticated: このメソッドは認証されたユーザーがいるかどうかをreturnする。
  • signIn: このメソッドは認証プロセスを初期化する。つまりユーザーをAuth0のログインページに送る。
  • signOut: このメソッドはprofile, id_token, expiresAtto null の設定によってサインアウトする。

最後にAuth classのinstanceを作り、公表する。このアプリでAuth classのinstanceは複数持てない。

このhelper classを定義したら、NavBar componentをrefactorし、ユーザーが認証できるようにすることが可能。NavBar.jsファイルを下記のコードに置き換える。

import React from 'react';
import {Link, withRouter} from 'react-router-dom';
import auth0Client from '../Auth';
function NavBar(props) {
const signOut = () => {
auth0Client.signOut();
props.history.replace('/');
};
return (
<nav className="navbar navbar-dark bg-primary fixed-top">
<Link className="navbar-brand" to="/">
Q&App
</Link>
{
!auth0Client.isAuthenticated() &&
<button className="btn btn-dark" onClick={auth0Client.signIn}>Sign In</button>
}
{
auth0Client.isAuthenticated() &&
<div>
<label className="mr-2 text-white">{auth0Client.getProfile().name}</label>
<button className="btn btn-dark" onClick={() => {signOut()}}>Sign Out</button>
</div>
}
</nav>
);
}
export default withRouter(NavBar);

navigation bar componentは2つの新しいelementをインポートした。

  • withRouter: これはReact Routerに供給されたcomponentで、 navigation 機能(e.g., access to the history object) を使用してcomponentを強化する。
  • auth0Client: 定義したAuth classの singleton instance

auth0client instanceで、NavBarはSign In button(認証されていないユーザー)かSign Out button(認証されたユーザー)にrenderしなくてはいけないかどうかを決める。もしユーザーが適切に認証されたら、このcomponentはその名前を表示する。そしてもしユーザーが認証されたら、Sign Out buttonを叩き、componentはauth0ClientメッソドのsignOutをコールし、ユーザーをホームにredirectする。

NavBar componentをrefactorした後、callback route(http://localhost:3000/callback)を取り扱うcomponentを作る必要がある。srcディレクトリにCallback.jsを作り、下記のコードを挿入する。

import React, {Component} from 'react';
import {withRouter} from 'react-router-dom';
import auth0Client from './Auth';
class Callback extends Component {
async componentDidMount() {
await auth0Client.handleAuthentication();
this.props.history.replace('/');
}
render() {
return (
<p>Loading profile...</p>
);
}
}
export default withRouter(Callback);

このcomponentは2つのことを定義する。

1つはhandleAuthenticationメソッドをAuth0によって送られたユーザー情報を取得する。2つ目はhandleAuthenticationプロセスが終わった後に、ユーザーをhome page(history.replace(‘/’))にredirectする。
しばらくの間componentは”Loading profile”というメッセージを表示する。

Auth0との統合がおわったので、App.jsファイルを下記のように更新する。

import React, { Component } from 'react';
import {Route} from 'react-router-dom';
import NavBar from './NavBar/NavBar';
import Question from './Question/Question';
import Questions from './Questions/Questions';
import Callback from './Callback';
class App extends Component {
render() {
return (
<div>
<NavBar/>
<Route exact path='/' component={Questions}/>
<Route exact path='/question/:questionId' component={Question}/>
<Route exact path='/callback' component={Callback}/>
</div>
);
}
}
export default App;

React appを再び実行すると、Auth0経由で認証ができる。

Adding Features to Authenticated Users

Autho0とReact appの統合が終わったので、認証されたユーザーがアクセスできる特徴を追加する。

1つ目は認証されたユーザーが質問を作ることを可能にする。そしてQuestion componentをrefactorし、フォームを表示するので、認証されたユーザーが質問に回答できる。

アプリの新しいrouteである/new-questionにこれを作成する。これはcomponentがユーザーが認証されているかどうかをチェックすることで守られる。ユーザーが認証されていなければ、このcomponentは彼らをAutho0にredirectし、認証できるようにする。もしユーザーがすでに認証されていたら、componentはReaxtが新しい質問が作られたフォームをrenderできる。

まずはSecuredRouteディレクトリを作り、その中にSecuredRoute.jsを作り、下記のコードを挿入する。

import React from 'react'; 
import {Route} from 'react-router-dom';
import auth0Client from '../Auth';
function SecuredRoute(props) {
const {component: Component, path} = props;
return (
<Route path={path} render={() => {
if (!auth0Client.isAuthenticated()) return auth0Client.signIn();
return <Component />
}} />
);
}
export default SecuredRoute;

このcomopnentの目的はルート上で構成するルートにアクセスを制限をすること。この場合2つのpropertyをもつfunctional componentを作る。
もう1つのComponetはユーザーが認証されたら、renderすることができ、pathはReact Routerに提供されたデフォルトのRoute componentの設定ができる。

しかし、いかなるrenderをする前に、このcomponentはユーザーがisAuthenticatedかどうかをチェックする。もしそうでなければ、このcomponentはsignInメソッドのトリガーとなり、ユーザーをログインページにredirectする。

SecuredRoute componentを作った後、フォームをユーザーが質問を作った場所にrenderするcomponentを作る。NewQuestionディレクトリをつくり、そこにNewQuestion.jsを作り、下記のコードを記入する。

import React, {Component} from 'react';
import {withRouter} from 'react-router-dom';
import auth0Client from '../Auth';
import axios from 'axios';
class NewQuestion extends Component {
constructor(props) {
super(props);
this.state = {
disabled: false,
title: '',
description: '',
};
}
updateDescription(value) {
this.setState({
description: value,
});
}
updateTitle(value) {
this.setState({
title: value,
});
}
async submit() {
this.setState({
disabled: true,
});
await axios.post('http://localhost:8081', {
title: this.state.title,
description: this.state.description,
}, {
headers: { 'Authorization': `Bearer ${auth0Client.getIdToken()}` }
});
this.props.history.push('/');
}
render() {
return (
<div className="container">
<div className="row">
<div className="col-12">
<div className="card border-primary">
<div className="card-header">New Question</div>
<div className="card-body text-left">
<div className="form-group">
<label htmlFor="exampleInputEmail1">Title:</label>
<input
disabled={this.state.disabled}
type="text"
onBlur={(e) => {this.updateTitle(e.target.value)}}
className="form-control"
placeholder="Give your question a title."
/>
</div>
<div className="form-group">
<label htmlFor="exampleInputEmail1">Description:</label>
<input
disabled={this.state.disabled}
type="text"
onBlur={(e) => {this.updateDescription(e.target.value)}}
className="form-control"
placeholder="Give more context to your question."
/>
</div>
<button
disabled={this.state.disabled}
className="btn btn-primary"
onClick={() => {this.submit()}}>
Submit
</button>
</div>
</div>
</div>
</div>
</div>
)
}
}
export default withRouter(NewQuestion);

このclass componentは下記のstateを持つ。

  • disabled: ユーザーがSubmit buttonを押したあと、elementを入力することをできなくする。
  • title: ユーザーが尋ねられている質問のタイトルを定義できるようにする。
  • description: ユーザーが質問の記述を定義できるようにする。

constructor and render 以外にも3つの定義が必要。

  • updateDescription: componentのstateのdescriptionの更新に使うメソッド
  • updateTitle: componentのstateのdescriptionの更新に使うメソッド
  • submit: 新しい質問をバックエンドに発行し、リクエストされている間input fieldをブロックするメソッド。

submitメソッドでは、auth0Clientを現在のユーザーがリクエストに加えるID Tokenの取得に使う。tokenなしでは、バックエンドAPIはリクエストを拒否する。

UIの観点から、このcomponentはBootstrap classの束を使い、いいフォームを製造する。Bootstrapについて詳しく学ぶにはこれを参照。 check this resource after finishing the tutorial if you need to learn about forms on Bootstrap.

ここで動作を確認する。まずはApp.jsに新たなrouteを登録する。

import React, { Component } from 'react';
import {Route} from 'react-router-dom';
import NavBar from './NavBar/NavBar';
import Question from './Question/Question';
import Questions from './Questions/Questions';
import Callback from './Callback';
import NewQuestion from './NewQuestion/NewQuestion';
import SecuredRoute from './SecuredRoute/SecuredRoute';
class App extends Component {
render() {
return (
<div>
<NavBar/>
<Route exact path='/' component={Questions}/>
<Route exact path='/question/:questionId' component={Question}/>
<Route exact path='/callback' component={Callback}/>
<SecuredRoute path='/new-question' component={NewQuestion} />
</div>
);
}
}
export default App;

そしてQuestions.jsに新しいルートを加える。

// ... import statements ...

class Questions extends Component {
// ... constructor and componentDidMount ...

render() {
return (
<div className="container">
<div className="row">
<Link to="/new-question">
<div className="card text-white bg-secondary mb-3">
<div className="card-header">Need help? Ask here!</div>
<div className="card-body">
<h4 className="card-title">+ New Question</h4>
<p className="card-text">Don't worry. Help is on the way!</p>
</div>
</div>
</Link>
<!-- ... loading questions message ... -->
<!-- ... questions' cards ... -->
</div>
</div>
)
}
}

export default Questions;

認証後新しい質問を作ることができる。

そしてQuestion componentをrefactorして、ユーザーが質問に回答できる場所のフォームを含める。このフォームを定義するために、SubmitAnswer.jsをQuestionディレクトリに作り、下記のコードを記入する。

import React, {Component, Fragment} from 'react';
import {withRouter} from 'react-router-dom';
import auth0Client from '../Auth';
class SubmitAnswer extends Component {
constructor(props) {
super(props);
this.state = {
answer: '',
};
}
updateAnswer(value) {
this.setState({
answer: value,
});
}
submit() {
this.props.submitAnswer(this.state.answer);
this.setState({
answer: '',
});
}
render() {
if (!auth0Client.isAuthenticated()) return null;
return (
<Fragment>
<div className="form-group text-center">
<label htmlFor="exampleInputEmail1">Answer:</label>
<input
type="text"
onChange={(e) => {this.updateAnswer(e.target.value)}}
className="form-control"
placeholder="Share your answer."
value={this.state.answer}
/>
</div>
<button
className="btn btn-primary"
onClick={() => {this.submit()}}>
Submit
</button>
<hr className="my-4" />
</Fragment>
)
}
}
export default withRouter(SubmitAnswer);

このcomponentはNewQuestion componentと似た動作をする。異なる点は、自身のPOST requestを扱う代わりに、他の人に委託することだ。
また、ユーザーが認証されなければ、このcomponentは何もrenderしない。

Question.jsファイルを下記のコードに置き換える。

import React, {Component} from 'react';
import axios from 'axios';
import SubmitAnswer from './SubmitAnswer';
import auth0Client from '../Auth';
class Question extends Component {
constructor(props) {
super(props);
this.state = {
question: null,
};
this.submitAnswer = this.submitAnswer.bind(this);
}
async componentDidMount() {
await this.refreshQuestion();
}
async refreshQuestion() {
const { match: { params } } = this.props;
const question = (await axios.get(`http://localhost:8081/${params.questionId}`)).data;
this.setState({
question,
});
}
async submitAnswer(answer) {
await axios.post(`http://localhost:8081/answer/${this.state.question.id}`, {
answer,
}, {
headers: { 'Authorization': `Bearer ${auth0Client.getIdToken()}` }
});
await this.refreshQuestion();
}
render() {
const {question} = this.state;
if (question === null) return <p>Loading ...</p>;
return (
<div className="container">
<div className="row">
<div className="jumbotron col-12">
<h1 className="display-3">{question.title}</h1>
<p className="lead">{question.description}</p>
<hr className="my-4" />
<SubmitAnswer questionId={question.id} submitAnswer={this.submitAnswer} />
<p>Answers:</p>
{
question.answers.map((answer, idx) => (
<p className="lead" key={idx}>{answer.answer}</p>
))
}
</div>
</div>
</div>
)
}
}
export default Question;

ここでは、submitAnswerメソッドがバックエンドAPI(user’s ID Token)にリクエストを発行し、refreshQuestionメソッドを定義している。このメソッドは2つの状況で質問の内容をリフレッシュする。Reactがこのcomponent(componentDidMount)を最初にするときと、renderバックエンドAPIがsubmitAnswerメソッドのPOST リクエストに反応した直後だ

Keeping Users Signed In after a Refresh

ログイン後にページを更新するとサインアウトされる。これはtokenがメモリに保存されないので、更新するとメモリが消えるからだ。

解決するにはAuth0に提供されている。Silent Authenticationをつかう。これはアプリがロードされるたびに、サイレントリクエストをAuth0に送り、現在のユーザーが有効なセッションかどうかを確認する。

有効なセクションであればAuth0はidTokenとidTokenPayloadを返す。

サイレント認証を使うために、Authとappをrefactorする必要がある。しかし、そのまえにAuth0アカウントを設定する必要がある。

まずはAuth0の設定を追加する。

  1. Allowed Web Origins: http://localhost:3000 このvalueがないと、Auth0はアプリからのAJAXリクエストを拒否する。
  2. Allowed Logout URLs: http://localhost:3000 ユーザーがAuth0のセッションを終わらせることができる。endpointに近い機能で、log out endpointは、プロセスのあとでユーザーをホワイトリストに登録されたURLにredirectする。

次はAuth0がユーザーがGoogleを通じて認証できるのに使うdevelopment keyを置き換える。

この機能を使う理由は、Auth0はGoogleに登録されたdevelopment keyを使うために、新しいアカウントを自動で設定するからだ。しかし、ユーザーがよりシリアスに使い始めるときには、自身のキーに置き換えるべきだ。また、アプリはsilent authenticationを実行しようとするたびに、development keyを使い、Auth0はアクティブなセッションがないことをreturnする。

これらのkeyを設定するために、Connections→SocialにあるClient IDとClient Secretにkeyを挿入する。keyはここから取得する。the Connect your app to Google documentation provided by Auth0.

これでAuth0アカウントの設定は終了。

続いてcodeを修正する。./src/Auth.jsを下記のようにする。

import auth0 from 'auth0-js';

class Auth {
// ... constructor, getProfile, getIdToken, signIn ...

handleAuthentication() {
return new Promise((resolve, reject) => {
this.auth0.parseHash((err, authResult) => {
if (err) return reject(err);
if (!authResult || !authResult.idToken) {
return reject(err);
}
this.setSession(authResult);
resolve();
});
})
}

setSession(authResult, step) {
this.idToken = authResult.idToken;
this.profile = authResult.idTokenPayload;
// set the time that the id token will expire at
this.expiresAt = authResult.expiresIn * 1000 + new Date().getTime();
}

signOut() {
this.auth0.logout({
returnTo: 'http://localhost:3000',
clientID: '<YOUR_AUTH0_CLIENT_ID>',
});
}

silentAuth() {
return new Promise((resolve, reject) => {
this.auth0.checkSession({}, (err, authResult) => {
if (err) return reject(err);
this.setSession(authResult);
resolve();
});
});
}
}

// ... auth0Client and export ...

ここでは

  • setSession: ユーザーの詳細をセットアップするためのメソッド
  • setSession methodを使うためにhandleAuthenticationメソッドをrefactor
  • silentAuthメソッドをauh0-js(このメソッドもsetSessionを使う)に提供された checkSession functionを呼ぶために追加。
  • signOut functionをrefactorし、Auth0のログアウトendpointを呼び出し、その後にユーザーがredirectする必要がある場所を知らせる。(http://localhost:3000のこと)

そして./src/App.jsでは下記のコードに書き換える

import React, { Component } from 'react';
import NavBar from './NavBar/NavBar';
import Question from './Question/Question';
import Questions from './Questions/Questions';
import Callback from './Callback';
import NewQuestion from './NewQuestion/NewQuestion';
import SecuredRoute from './SecuredRoute/SecuredRoute';
import {Route, withRouter} from 'react-router-dom';
import auth0Client from './Auth';
class App extends Component {
async componentDidMount() {
if (this.props.location.pathname === '/callback') return;
try {
await auth0Client.silentAuth();
this.forceUpdate();
} catch (err) {
if (err.error === 'login_required') return;
console.log(err.error);
}
}
render() {
return (
<div>
<NavBar/>
<Route exact path='/' component={Questions}/>
<Route exact path='/question/:questionId' component={Question}/>
<Route exact path='/callback' component={Callback}/>
<SecuredRoute path='/new-question' component={NewQuestion} />
</div>
);
}
}
export default App;

ここではアプリがロード(componentDidMount)するときにやりたいことを定義する。

  1. リクエストされたルートが/callbackの場合、アプリはなにもしない。これはユーザーが/callbackルートをリクエストする時の正しい動作だ。なぜなら認証プロセス後にAuth0によるredirectを取得するからだ。この場合、Callback componentが扱うプロセスをそのままにできる。
  2. リクエストされたルートがそれ以外の場合、アプリはsilentAuthを試したい。そして、エラーが起きなければ、アプリはforceUpdateを呼ぶので、ユーザーは名前を見ることができ、サインインされる。
  3. silentAuthにエラーがあれば、アプリはエラーがlogin_requiredかどうかをチェックする。もしその場合、アプリは何もしない。なぜならユーザーはサインインしていないからだ。(もしくは使うべきでないdevelopment keyを使っているからだ)
  4. login_requiredでないエラーがある場合、エラーはconsoleに記録される。実際には、エラーを誰かに知らせるのが良いので、何が起きているかをチェックできる。

以上で完成。

【わからなかったこと】

【感想】

多数のライブラリ、それに伴い必要なjsファイル、1つの動作に対して影響する場所を見つけるなど非常に複雑に感じた。
component間でデータを渡したりするReactの動作、ライブラリが持つ機能(そもそもライブラリを導入した方がいいという発想)、さらにはJSのコードの理解を深めていかないと難しいと思った。特にJSのコードの理解や慣れがもっと必要だと思った。

--

--

Tatsuya Asami
Tatsuya Asami

Written by Tatsuya Asami

Front end engineer. React, TypeScript, Three.js

No responses yet