#やったこと
フロントエンド: React, バックエンド: Rails(APIモード)のアプリ開発における基本的なログイン処理周りの実装
- 未ログインなら、/loginにリダイレクト
- 確認中は「Loading」を表示させる
- Railsのログイン機能は、devise_auth_tokenを利用: 【Ruby on Rails】devise_token_authでTwitterログイン機能の実装
- ログイン中のユーザーの情報は、reduxを用いて状態管理する。
#Rails
まずは、ログイン中のユーザーをjsonとして返すために、userコントローラーを作成します。
$ docker-compose run api rails g controller users
users_controller
module Api
module V1
class UsersController < ApplicationController
before_action :authenticate_api_v1_user!
def currentuser
@user = current_api_v1_user
render json: { status: 'SUCCESS', message: 'Loaded the user', data: @user}
end
end
end
end
- current_userを使うと、簡単にログイン中のユーザーを返してくれる。ログインしてなかったら、エラーを返す。ルートのパスが/api/v1/user/なので、current_api_v1_userになる。
omniauth_callbacks_controller
def render_data_or_redirect(message, data, user_data = {})
##if Rails.env.production? コメントアウト!
if ['inAppBrowser', 'newWindow'].include?(omniauth_window_type)
render_data(message, user_data.merge(data))
# 通常、elsif内の処理が実行されるはず。
elsif auth_origin_url
redirect_to DeviseTokenAuth::Url.generate(auth_origin_url, data.merge(blank: true))
else
fallback_render data[:error] || 'An error occurred'
end
else
# @resource.credentials = auth_hash["credentials"]
##render json: @resource, status: :ok コメントアウト!
end
end
-
http://127.0.0.1:3000/api/v1/auth/twitter?auth_origin_url=localhost:8000 にアクセスすると、
「http://localhost:8000/?auth_token=XXX&blank=true&client_id=XXXX&config=&expiry=XXXX&uid=XXX 」にリダイレクトされるように処理が行われている。auth_tokenなどはreact側のAuth.js内でlocalstorageに保存される処理がされる。詳細は下に記載。
#React
###要約
- Routingでログインが必要なコンポーネントは、Authコンポーネントを経由させて出力させる。
- Authコンポネートで、未ログイン・ログイン済みを確認して、未ログインならリダイレクト、ログイン済みなら、パスのコンポーネントを表示させた上で、reduxのglobal stateにログイン中ユーザーの情報をセットする。
###redux
なんで必要?
-> state管理(状態管理)を楽にするため。reduxが無いと、親から子、子から親などコンポーネント間のデータの受け渡しが必要であるが、reduxを使うと、一つの情報源に全てのコンポーネントが直接アクセスできる。
今回はなぜ使った?
-> ログイン中ユーザーの情報はどのコンポーネントでも使いうるため、"Global State"として、全体で管理したかったから。
###App.js
App.js
import React, { Component } from 'react';
import './App.css';
import Home from './containers/Home';
import Term from './containers/Term';
import Info from './containers/Info';
import Auth from './containers/Auth';
import Login from './containers/Login';
import { BrowserRouter, Route, Switch } from 'react-router-dom'
class App extends Component {
render() {
return (
<div className="App">
<BrowserRouter>
<Switch>
<Route path="/login" component={Login} />
<Route path="/info" component={Info} />
<Route path="/term" component={Term} />
<Auth>
<Route exact path="/" component={Home} />
</Auth>
</Switch>
</BrowserRouter>
</div >
);
}
}
export default App;
###Login.js
Login.js
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
const styles = theme => ({
});
class Login extends React.Component {
loginTwitter() {
window.location.href = process.env.REACT_APP_API127_URL + '/api/v1/auth/twitter?auth_origin_url=' + process.env.REACT_APP_BASE_URL;
}
render() {
const { classes } = this.props;
return (
<div className={classes.login}>
<p>未ログイン</p>
<Button variant="contained" color="secondary" onClick={this.loginTwitter}>
Twitterで登録・ログイン
</Button>
</div>
)
}
}
Login.propTypes = {
classes: PropTypes.object.isRequired,
};
export default withStyles(styles, { withTheme: true })(Login);
- ボタンをクリックすると、loginTwitter()が実行される。
- loginTwitter()では、以前、実装したように、http://127.0.0.1:3000/api/v1/auth/twitter?auth_origin_url=localhost:8000 にアクセスし、Rails側で処理され、ログイン処理後、localhost:8000/に戻ってくる。
###Home.js
Home.js
import React from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
const styles = theme => ({
home: {
backgroundColor: "red",
width: "50%"
},
});
class Home extends React.Component {
render() {
const { classes } = this.props;
return (
<div className={classes.home}>
<p>ログイン済み</p>
</div>
)
}
}
Home.propTypes = {
classes: PropTypes.object.isRequired,
};
export default withStyles(styles, { withTheme: true })(Home);
###Auth.js
Auth.js
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from '../actions';
import _ from 'lodash';
import { Redirect, Route } from 'react-router-dom'
import "normalize.css";
import queryString from 'query-string';
import axios from 'axios';
const styles = theme => ({
});
class Auth extends React.Component {
constructor(props) {
super()
this.state = {
isLoading: true,
}
}
componentDidMount() {
let tokens = queryString.parse(_.get(this, "props.location.search"))
if (!_.isEmpty(tokens.auth_token)) {
localStorage.setItem('auth_token', tokens.auth_token)
localStorage.setItem('client_id', tokens.client_id)
localStorage.setItem('uid', tokens.uid)
window.location.href = process.env.REACT_APP_BASE_URL
} else {
this.setState({
isLoading: true,
})
const auth_token = localStorage.auth_token
const client_id = localStorage.client_id
const uid = localStorage.uid
axios.get(process.env.REACT_APP_API_URL + '/api/v1/user/currentuser', {
headers: {
'access-token': auth_token,
'client': client_id,
'uid': uid
}
})
.then((response) => {
this.setState({
isLoading: false,
isLoggedin: true,
});
this.props.actions.setCurrentUserSuccess(response.data.data)
})
.catch(() => {
this.setState({
isLoading: false,
isLoggedin: false,
});
});
}
}
render() {
const { CurrentUserReducer } = this.props;
const isLoggedin = this.state.isLoggedin;
const isLoading = this.state.isLoading;
const { classes } = this.props;
console.log(isLoading)
if (isLoading) {
return (
<div>loading</div>
)
} else {
if (isLoggedin) {
return (
<Route children={this.props.children} />
)
} else {
console.log(isLoading)
return (
<Redirect to={'/login'} />
)
}
}
}
}
Auth.propTypes = {
classes: PropTypes.object.isRequired,
};
const mapState = (state, ownProps) => ({
CurrentUserReducer: state.CurrentUserReducer,
});
function mapDispatch(dispatch) {
return {
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(mapState, mapDispatch)(Auth);
- このコンポーネントのコードこそが、今回の記事のいちばん重要なポイント。
- componentDidmount()は、render後に実行される。初めのif文では、ログイン処理を行ってrails側から戻ってきているかどうかを判別。
- 先程、説明したように、rails側でログイン処理が終わると、devise_token_authでの認証に必要なtokenがクエリとしてURLに乗せられて、/に戻ってきます。if文ではqueryStringというモジュールで、URLの文字列を分析し、?auth_token="..."が存在しているかどうかを判定し、存在していたら、認証に必要なトークンなどをlocalstorageに保存しています。
- else内では、ログインしているかどうかを判定するために、rails側で先程実装したように、/api/v1/user/currentuserにアクセスして、ログインをしていたら、actionを発行して、reduxを使用して、global stateとして、CurrentUserReducerにログイン中のユーザー情報を保存するようにしています。
- localのstateとしては、isLoggedin(ログインしているかどうか)とisLoading(currentuserを確認中かどうか)を管理していて、これらを使って、Loadingを表示するか、そのままログイン済みユーザーのみ表示させたいコンポーネントへのアクセスを許可するか、/loginにリダイレクトするかを決めています。
- Reactのライフサイクルを理解しきっていないため、タイミングの調節が結構苦労しました。
#redux
###実装の概要
- index.jsでcreateStoreする、Providerで配る。
- reducers/rootReducer.jsで複数のreducerを管理している。
- actions/index.js内で、アクションの内容を記述している。(今回は、setCurrentUserSuccessしか使っていませんが...)
- reducers/CurrentUserReducer.js内でdispatchされたactionに対するstateの変更の記述をしている。
###起こっていること
- Auth.js内のthis.props.actions.setCurrentUserSuccess(response.data.data)でactions/index.jsに対して、データが渡されて、actionのオブジェクトがcreateされる。
- createされたactionのオブジェクトはreducers/CurrentUserReducer.jsに渡されて、stateの変更が行われる。
###index.js
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { theme } from './materialui/theme'
import { BrowserRouter as Router } from 'react-router-dom';
import createBrowserHistory from 'history/createBrowserHistory';
import { Provider } from 'react-redux';
import rootReducer from './reducers/rootReducer';
import thunk from 'redux-thunk'
import logger from 'redux-logger'
import { createStore, applyMiddleware, compose } from 'redux';
const history = createBrowserHistory();
const store = createStore(
rootReducer,
applyMiddleware(thunk, logger)
);
ReactDOM.render(
<Provider store={store}>
<MuiThemeProvider theme={theme} >
<Router>
<App />
</Router>
</MuiThemeProvider>
</Provider >
, document.getElementById('root'));
###actions/index.js
index.js
import axios from 'axios'
export const setCurrentUser = () => {
return (dispatch) => {
const auth_token = localStorage.auth_token
const client_id = localStorage.client_id
const uid = localStorage.uid
return axios.get(process.env.REACT_APP_API_URL + '/api/v1/user/currentuser', {
headers: {
'access-token': auth_token,
'client': client_id,
'uid': uid
}
})
.then(response => dispatch(setCurrentUserSuccess(response.data.data)))
.catch(error => dispatch(setCurrentUserFailure(error)))
};
}
export const setCurrentUserRequest = () => ({
type: 'SET_CURRENTUSER_REQUEST',
})
export const setCurrentUserSuccess = (json) => ({
type: 'SET_CURRENTUSER_SUCCESS',
items: json,
})
export const setCurrentUserFailure = (error) => ({
type: 'SET_CURRENTUSER_FAILURE',
items: error,
})
###reducers/CurrentUserReducer.js
CurrentUserReducers.js
const initialState = {
isLoggedin: false,
isLoading: false,
items: []
};
const CurrentUserReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_CURRENTUSER_REQUEST':
return {
...state,
isLoggedin: false,
isLoading: true,
items: [],
};
case 'SET_CURRENTUSER_SUCCESS':
if (!action.items) {
return {
...state,
isLoggedin: false,
isLoading: false,
items: action.items,
};
} else {
return {
...state,
isLoggedin: true,
isLoading: false,
items: action.items,
};
}
case 'SET_CURRENTUSER_FAILURE':
return {
...state,
isLoggedin: false,
isLoading: false,
error: action.error,
};
default:
return state;
}
};
export default CurrentUserReducer;
###reducers/rootReducer.js
rootReducer.js
import { combineReducers } from 'redux'
import { routerReducer } from 'react-router-redux'
import CurrentUserReducer from './CurrentUserReducer'
const rootReducer = combineReducers({
CurrentUserReducer,
router: routerReducer,
})
export default rootReducer