Auth flow. The next

This commit is contained in:
SleepWalker 2016-03-01 22:36:14 +02:00
parent a317bfd3d4
commit 57f0cf30e6
28 changed files with 438 additions and 243 deletions

View File

@ -13,7 +13,6 @@ import messages from './Activation.messages';
class Body extends BaseAuthBody {
static propTypes = {
...BaseAuthBody.propTypes,
activate: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
@ -48,10 +47,6 @@ class Body extends BaseAuthBody {
</div>
);
}
onFormSubmit() {
this.props.activate(this.serialize());
}
}
export default function Activation() {

View File

@ -22,7 +22,7 @@ export default class AppInfo extends Component {
return (
<div className={styles.appInfo}>
<div className={styles.logoContainer}>
<h2 className={styles.logo}>{name}</h2>
<h2 className={styles.logo}>{name || 'Ely Accounts'}</h2>
</div>
<div className={styles.descriptionContainer}>
<p className={styles.description}>

View File

@ -8,6 +8,8 @@ import AuthError from './AuthError';
export default class BaseAuthBody extends Component {
static propTypes = {
clearErrors: PropTypes.func.isRequired,
resolve: PropTypes.func.isRequired,
reject: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string
})
@ -20,6 +22,10 @@ export default class BaseAuthBody extends Component {
;
}
onFormSubmit() {
this.props.resolve(this.serialize());
}
onClearErrors = this.props.clearErrors;
form = {};

View File

@ -14,7 +14,6 @@ import passwordMessages from './Password.messages';
class Body extends BaseAuthBody {
static propTypes = {
...BaseAuthBody.propTypes,
login: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
@ -37,10 +36,6 @@ class Body extends BaseAuthBody {
</div>
);
}
onFormSubmit() {
this.props.login(this.serialize());
}
}
export default function Login() {

View File

@ -1,25 +1,9 @@
import React, { Component, PropTypes } from 'react';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { logout } from 'components/auth/actions';
class Logout extends Component {
export class Logout extends Component {
static displayName = 'Logout';
static propTypes = {
logout: PropTypes.func.isRequired
};
componentWillMount() {
this.props.logout();
}
render() {
return <span />;
return <span/>;
}
}
export default connect(null, {
logout
})(Logout);

View File

@ -1,43 +1,9 @@
import React, { Component, PropTypes } from 'react';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { oAuthValidate, oAuthComplete } from 'components/auth/actions';
class OAuthInit extends Component {
export default class OAuthInit extends Component {
static displayName = 'OAuthInit';
static propTypes = {
query: PropTypes.shape({
client_id: PropTypes.string.isRequired,
redirect_uri: PropTypes.string.isRequired,
response_type: PropTypes.string.isRequired,
scope: PropTypes.string.isRequired,
state: PropTypes.string
}),
validate: PropTypes.func.isRequired
};
componentWillMount() {
const {query} = this.props;
this.props.validate({
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state
}).then(this.props.complete);
}
render() {
return <span />;
}
}
export default connect((state) => ({
query: state.routing.location.query
}), {
validate: oAuthValidate,
complete: oAuthComplete
})(OAuthInit);

View File

@ -1,7 +1,6 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { routeActions } from 'react-router-redux';
import { TransitionMotion, spring } from 'react-motion';
import ReactHeight from 'react-height';
@ -10,6 +9,7 @@ import { Form } from 'components/ui/Form';
import {helpLinks as helpLinksStyles} from 'components/auth/helpLinks.scss';
import panelStyles from 'components/ui/panel.scss';
import icons from 'components/ui/icons.scss';
import authFlow from 'services/authFlow';
import * as actions from './actions';
@ -28,7 +28,6 @@ class PanelTransition extends Component {
password: PropTypes.string
})
}).isRequired,
goBack: React.PropTypes.func.isRequired,
setError: React.PropTypes.func.isRequired,
clearErrors: React.PropTypes.func.isRequired,
path: PropTypes.string.isRequired,
@ -211,8 +210,7 @@ class PanelTransition extends Component {
onGoBack = (event) => {
event.preventDefault();
this.body.onGoBack && this.body.onGoBack();
this.props.goBack();
authFlow.goBack();
};
getHeader(key, props) {
@ -341,14 +339,10 @@ class PanelTransition extends Component {
export default connect((state) => ({
user: state.user,
auth: state.auth,
path: state.routing.location.pathname
path: state.routing.location.pathname,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow)
}), {
goBack: routeActions.goBack,
login: actions.login,
logout: actions.logout,
register: actions.register,
activate: actions.activate,
clearErrors: actions.clearErrors,
oAuthComplete: actions.oAuthComplete,
setError: actions.setError
})(PanelTransition);

View File

@ -15,8 +15,6 @@ import messages from './Password.messages';
class Body extends BaseAuthBody {
static propTypes = {
...BaseAuthBody.propTypes,
login: PropTypes.func.isRequired,
logout: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
@ -56,17 +54,6 @@ class Body extends BaseAuthBody {
</div>
);
}
onFormSubmit() {
this.props.login({
...this.serialize(),
login: this.props.user.email || this.props.user.username
});
}
onGoBack() {
this.props.logout();
}
}
export default function Password() {

View File

@ -15,15 +15,7 @@ import styles from './passwordChange.scss';
class Body extends BaseAuthBody {
static propTypes = {
...BaseAuthBody.propTypes/*,
// Я так полагаю, это правила валидации?
login: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
login: PropTypes.shape({
login: PropTypes.stirng
})
})*/
...BaseAuthBody.propTypes
};
render() {
@ -56,10 +48,6 @@ class Body extends BaseAuthBody {
</div>
);
}
onFormSubmit() {
this.props.login(this.serialize());
}
}
export default function PasswordChange() {
@ -75,10 +63,14 @@ export default function PasswordChange() {
<Message {...passwordChangedMessages.change} />
</button>
),
Links: () => (
<Link to="/oauth/permissions">
Links: (props) => (
<a href="#" onClick={(event) => {
event.preventDefault();
props.reject();
}}>
<Message {...passwordChangedMessages.skipThisStep} />
</Link>
</a>
)
};
}

View File

@ -14,8 +14,6 @@ import messages from './Permissions.messages';
class Body extends BaseAuthBody {
static propTypes = {
...BaseAuthBody.propTypes,
login: PropTypes.func.isRequired,
oAuthComplete: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.string,
scopes: PropTypes.array.isRequired
@ -52,20 +50,14 @@ class Body extends BaseAuthBody {
<Message {...messages.theAppNeedsAccess2} />
</div>
<ul className={styles.permissionsList}>
{scopes.map((scope) => (
<li>{<Message {...messages[`scope_${scope}`]} />}</li>
{scopes.map((scope, key) => (
<li key={key}>{<Message {...messages[`scope_${scope}`]} />}</li>
))}
</ul>
</div>
</div>
);
}
onFormSubmit() {
this.props.oAuthComplete({
accept: true
});
}
}
export default function Permissions() {
@ -85,9 +77,7 @@ export default function Permissions() {
<a href="#" onClick={(event) => {
event.preventDefault();
props.onAuthComplete({
accept: false
});
props.reject();
}}>
<Message {...messages.decline} />
</a>

View File

@ -82,10 +82,6 @@ class Body extends BaseAuthBody {
</div>
);
}
onFormSubmit() {
this.props.register(this.serialize());
}
}
export default function Register() {

View File

@ -19,30 +19,26 @@ export function login({login = '', password = '', rememberMe = false}) {
token: resp.jwt
}));
dispatch(authenticate(resp.jwt));
dispatch(redirectToGoal());
return dispatch(authenticate(resp.jwt));
})
.catch((resp) => {
if (resp.errors.login === ACTIVATION_REQUIRED) {
dispatch(updateUser({
return dispatch(updateUser({
isActive: false,
isGuest: false
}));
dispatch(redirectToGoal());
} else if (resp.errors.password === PASSWORD_REQUIRED) {
dispatch(updateUser({
return dispatch(updateUser({
username: login,
email: login
}));
dispatch(routeActions.push('/password'));
} else {
if (resp.errors.login === LOGIN_REQUIRED && password) {
dispatch(logout());
}
const errorMessage = resp.errors[Object.keys(resp.errors)[0]];
dispatch(setError(errorMessage));
throw new Error(errorMessage);
}
// TODO: log unexpected errors
@ -73,6 +69,7 @@ export function register({
.catch((resp) => {
const errorMessage = resp.errors[Object.keys(resp.errors)[0]];
dispatch(setError(errorMessage));
throw new Error(errorMessage);
// TODO: log unexpected errors
})
@ -87,43 +84,22 @@ export function activate({key = ''}) {
)
.then((resp) => {
dispatch(updateUser({
isGuest: false,
isActive: true
}));
dispatch(authenticate(resp.jwt));
dispatch(redirectToGoal());
return dispatch(authenticate(resp.jwt));
})
.catch((resp) => {
const errorMessage = resp.errors[Object.keys(resp.errors)[0]];
dispatch(setError(errorMessage));
throw new Error(errorMessage);
// TODO: log unexpected errors
})
;
}
function redirectToGoal() {
return (dispatch, getState) => {
const {user} = getState();
switch (user.goal) {
case 'oauth':
dispatch(routeActions.push('/oauth/permissions'));
break;
case 'account':
default:
dispatch(routeActions.push('/'));
break;
}
// dispatch(updateUser({ // TODO: mb create action resetGoal?
// goal: null
// }));
};
}
export const ERROR = 'error';
export function setError(error) {
return {
@ -138,10 +114,7 @@ export function clearErrors() {
}
export function logout() {
return (dispatch) => {
dispatch(logoutUser());
dispatch(routeActions.push('/login'));
};
return logoutUser();
}
// TODO: move to oAuth actions?
@ -174,28 +147,26 @@ export function oAuthComplete(params = {}) {
`/api/oauth/complete?${query}`,
typeof params.accept === 'undefined' ? {} : {accept: params.accept}
)
.then((resp) => {
if (resp.status === 401 && resp.name === 'Unauthorized') {
// TODO: temporary solution for oauth init by guest
// TODO: request serivce should handle http status codes
dispatch(routeActions.push('/oauth/permissions'));
return;
}
if (resp.redirectUri) {
location.href = resp.redirectUri;
}
})
.catch((resp = {}) => { // TODO
handleOauthParamsValidation(resp);
if (resp.statusCode === 401 && resp.error === 'accept_required') {
dispatch(routeActions.push('/oauth/permissions'));
}
if (resp.statusCode === 401 && resp.error === 'access_denied') {
// user declined permissions
location.href = resp.redirectUri;
return {
redirectUri: resp.redirectUri
};
}
handleOauthParamsValidation(resp);
if (resp.status === 401 && resp.name === 'Unauthorized') {
const error = new Error('Unauthorized');
error.unauthorized = true;
throw error;
}
if (resp.statusCode === 401 && resp.error === 'accept_required') {
const error = new Error('Permissions accept required');
error.acceptRequired = true;
throw error;
}
});
};
@ -212,17 +183,22 @@ function getOAuthRequest(oauth) {
}
function handleOauthParamsValidation(resp = {}) {
const error = new Error('Error completing request');
if (resp.statusCode === 400 && resp.error === 'invalid_request') {
alert(`Invalid request (${resp.parameter} required).`);
throw error;
}
if (resp.statusCode === 400 && resp.error === 'unsupported_response_type') {
alert(`Invalid response type '${resp.parameter}'.`);
throw error;
}
if (resp.statusCode === 400 && resp.error === 'invalid_scope') {
alert(`Invalid scope '${resp.parameter}'.`);
throw error;
}
if (resp.statusCode === 401 && resp.error === 'invalid_client') {
alert('Can not find application you are trying to authorize.');
throw error;
}
}

View File

@ -53,6 +53,6 @@ export function authenticate(token) {
return (dispatch) => {
request.setAuthToken(token);
dispatch(fetchUserData());
return dispatch(fetchUserData());
};
}

View File

@ -1,16 +1,32 @@
import React, { Component } from 'react';
import AuthPage from 'pages/auth/AuthPage';
import Login from 'components/auth/Login';
import { connect } from 'react-redux';
export default class IndexPage extends Component {
import authFlow from 'services/authFlow';
class IndexPage extends Component {
displayName = 'IndexPage';
componentWillMount() {
if (this.props.user.isGuest) {
authFlow.login();
}
}
render() {
const {user, children} = this.props;
return (
<AuthPage>
<Login />
</AuthPage>
<div>
<h1>
Hello {user.username}!
</h1>
{children}
</div>
);
}
}
export default connect((state) => ({
user: state.user
}))(IndexPage);

View File

@ -5,7 +5,7 @@ import RootPage from 'pages/root/RootPage';
import IndexPage from 'pages/index/IndexPage';
import AuthPage from 'pages/auth/AuthPage';
import { authenticate, updateUser } from 'components/user/actions';
import { authenticate } from 'components/user/actions';
import OAuthInit from 'components/auth/OAuthInit';
import Register from 'components/auth/Register';
@ -17,72 +17,37 @@ import Logout from 'components/auth/Logout';
import PasswordChange from 'components/auth/PasswordChange';
import ForgotPassword from 'components/auth/ForgotPassword';
import authFlow from 'services/authFlow';
export default function routesFactory(store) {
function checkAuth(nextState, replace) {
const state = store.getState();
const pathname = state.routing.location.pathname;
let forcePath;
let goal;
if (!state.user.isGuest) {
if (!state.user.isActive) {
forcePath = '/activation';
} else if (!state.user.shouldChangePassword) {
forcePath = '/password-change';
}
} else {
if (state.user.email || state.user.username) {
forcePath = '/password';
} else {
forcePath = '/login';
}
}
// TODO: validate that we have all required data on premissions page
if (forcePath && pathname !== forcePath) {
switch (pathname) {
case '/':
goal = 'account';
break;
case '/oauth/permissions':
goal = 'oauth';
break;
}
if (goal) {
store.dispatch(updateUser({ // TODO: mb create action resetGoal?
goal
}));
}
replace({pathname: forcePath});
}
}
const state = store.getState();
if (state.user.token) {
// authorizing user if it is possible
store.dispatch(authenticate(state.user.token));
}
authFlow.setStore(store);
const onEnter = {
onEnter: ({location}, replace) => authFlow.handleRequest(location.pathname, replace)
};
return (
<Route path="/" component={RootPage}>
<IndexRoute component={IndexPage} onEnter={checkAuth} />
<IndexRoute component={IndexPage} />
<Route path="oauth" component={OAuthInit} {...onEnter} />
<Route path="logout" component={Logout} {...onEnter} />
<Route path="auth" component={AuthPage}>
<Route path="/login" components={new Login()} onEnter={checkAuth} />
<Route path="/password" components={new Password()} onEnter={checkAuth} />
<Route path="/register" components={new Register()} />
<Route path="/activation" components={new Activation()} />
<Route path="/oauth/permissions" components={new Permissions()} onEnter={checkAuth} />
<Route path="/password-change" components={new PasswordChange()} />
<Route path="/forgot-password" components={new ForgotPassword()} />
<Route path="/login" components={new Login()} {...onEnter} />
<Route path="/password" components={new Password()} {...onEnter} />
<Route path="/register" components={new Register()} {...onEnter} />
<Route path="/activation" components={new Activation()} {...onEnter} />
<Route path="/oauth/permissions" components={new Permissions()} {...onEnter} />
<Route path="/password-change" components={new PasswordChange()} {...onEnter} />
<Route path="/forgot-password" components={new ForgotPassword()} {...onEnter} />
</Route>
<Route path="oauth" component={OAuthInit} />
<Route path="logout" component={Logout} />
</Route>
);
}

5
src/services/authFlow.js Normal file
View File

@ -0,0 +1,5 @@
import AuthFlow from './authFlow/AuthFlow';
// TODO: a way to unload service (when we are on account page)
export default new AuthFlow();

View File

@ -0,0 +1,9 @@
export default class AbstractState {
resolve() {}
goBack() {
throw new Error('There is no way back');
}
reject() {}
enter() {}
leave() {}
}

View File

@ -0,0 +1,19 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class ActivationState extends AbstractState {
enter(context) {
const {user} = context.getState();
if (user.isActive) {
context.setState(new CompleteState());
} else {
context.navigate('/activation');
}
}
resolve(context, payload) {
context.run('activate', payload)
.then(() => context.setState(new CompleteState()));
}
}

View File

@ -0,0 +1,116 @@
import { routeActions } from 'react-router-redux';
import * as actions from 'components/auth/actions';
import {updateUser} from 'components/user/actions';
import RegisterState from './RegisterState';
import LoginState from './LoginState';
import OAuthState from './OAuthState';
import ForgotPasswordState from './ForgotPasswordState';
const availableActions = {
...actions,
updateUser
};
export default class AuthFlow {
constructor(states) {
this.states = states;
}
setStore(store) {
this.navigate = (route) => {
const {routing} = this.getState();
if (routing.location.pathname !== route) {
this.ignoreRequest = true; // TODO: remove me
if (this.replace) {
this.replace(route);
}
store.dispatch(routeActions.push(route));
}
this.replace = null;
};
this.getState = store.getState.bind(store);
this.dispatch = store.dispatch.bind(store);
}
resolve(payload = {}) {
this.state.resolve(this, payload);
}
reject(payload = {}) {
this.state.reject(this, payload);
}
goBack() {
this.state.goBack(this);
}
run(actionId, payload) {
if (!availableActions[actionId]) {
throw new Error(`Action ${actionId} does not exists`);
}
return this.dispatch(availableActions[actionId](payload));
}
setState(state) {
if (!state) {
throw new Error('State is required');
}
if (this.state instanceof state.constructor) {
// already in this state
return;
}
this.state && this.state.leave(this);
this.state = state;
this.state.enter(this);
}
handleRequest(path, replace) {
this.replace = replace;
if (this.ignoreRequest) {
this.ignoreRequest = false;
return;
}
switch (path) {
case '/oauth':
this.setState(new OAuthState());
break;
case '/register':
this.setState(new RegisterState());
break;
case '/forgot-password':
this.setState(new ForgotPasswordState());
break;
case '/login':
case '/password':
case '/activation':
case '/password-change':
case '/oauth/permissions':
this.setState(new LoginState());
break;
case '/logout':
this.run('logout');
this.setState(new LoginState());
break;
default:
throw new Error(`Unsupported request: ${path}`);
}
}
login() {
this.setState(new LoginState());
}
}

View File

@ -0,0 +1,15 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class ChangePasswordState extends AbstractState {
enter(context) {
context.navigate('/password-change');
}
reject(context) {
context.run('updateUser', {
shouldChangePassword: false
});
context.setState(new CompleteState());
}
}

View File

@ -0,0 +1,32 @@
import AbstractState from './AbstractState';
import LoginState from './LoginState';
import PermissionsState from './PermissionsState';
import ActivationState from './ActivationState';
import ChangePasswordState from './ChangePasswordState';
export default class CompleteState extends AbstractState {
enter(context) {
const {auth, user} = context.getState();
if (user.isGuest) {
context.setState(new LoginState());
} else if (!user.isActive) {
context.setState(new ActivationState());
} else if (user.shouldChangePassword) {
context.setState(new ChangePasswordState());
} else if (auth.oauth) {
context.run('oAuthComplete').then((resp) => {
location.href = resp.redirectUri;
}, (resp) => {
// TODO
if (resp.unauthorized) {
context.setState(new LoginState());
} else if (resp.acceptRequired) {
context.setState(new PermissionsState());
}
});
} else {
context.navigate('/');
}
}
}

View File

@ -0,0 +1,16 @@
import AbstractState from './AbstractState';
import LoginState from './LoginState';
export default class ForgotPasswordState extends AbstractState {
enter(context) {
context.navigate('/forgot-password');
}
goBack(context) {
context.setState(new LoginState());
}
reject(context) {
context.navigate('/send-message');
}
}

View File

@ -0,0 +1,24 @@
import AbstractState from './AbstractState';
import PasswordState from './PasswordState';
import ForgotPasswordState from './ForgotPasswordState';
export default class LoginState extends AbstractState {
enter(context) {
const {user} = context.getState();
if (user.email || user.username) {
context.setState(new PasswordState());
} else {
context.navigate('/login');
}
}
resolve(context, payload) {
context.run('login', payload)
.then(() => context.setState(new PasswordState()));
}
reject(context) {
context.setState(new ForgotPasswordState());
}
}

View File

@ -0,0 +1,16 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class OAuthState extends AbstractState {
enter(context) {
const query = context.getState().routing.location.query;
context.run('oAuthValidate', {
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state
}).then(() => context.setState(new CompleteState()));
}
}

View File

@ -0,0 +1,34 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
import ForgotPasswordState from './ForgotPasswordState';
export default class PasswordState extends AbstractState {
enter(context) {
const {user} = context.getState();
if (!user.isGuest) {
context.setState(new CompleteState());
} else {
context.navigate('/password');
}
}
resolve(context, {password}) {
const {user} = context.getState();
context.run('login', {
password,
login: user.email || user.username
})
.then(() => context.setState(new CompleteState()));
}
reject(context) {
context.setState(new ForgotPasswordState());
}
goBack(context) {
context.run('logout');
context.setState(new LoginState());
}
}

View File

@ -0,0 +1,21 @@
import AbstractState from './AbstractState';
export default class PermissionsState extends AbstractState {
enter(context) {
context.navigate('/oauth/permissions');
}
resolve(context) {
this.process(context, true);
}
reject(context) {
this.process(context, false);
}
process(context, accept) {
context.run('oAuthComplete', {
accept
}).then((resp) => location.href = resp.redirectUri);
}
}

View File

@ -0,0 +1,22 @@
import AbstractState from './AbstractState';
import CompleteState from './CompleteState';
export default class RegisterState extends AbstractState {
enter(context) {
const {user} = context.getState();
if (!user.isGuest) {
context.setState(new CompleteState());
} else {
context.navigate('/register');
}
}
resolve(context, payload) {
context.run('register', payload)
.then(() => context.setState(new CompleteState()));
}
reject(context) {
}
}

View File

@ -28,9 +28,11 @@ function buildQuery(data) {
let authToken;
const checkStatus = (resp) => Promise[resp.status >= 200 && resp.status < 300 ? 'resolve' : 'reject'](resp);
const toJSON = (resp) => resp.json();
// if resp.success does not exist - degradating to HTTP status codes
const rejectWithJSON = (resp) => toJSON(resp).then((resp) => {throw resp;});
const handleResponse = (resp) => Promise[resp.success || typeof resp.success === 'undefined' ? 'resolve' : 'reject'](resp);
const getDefaultHeaders = () => {
const header = {Accept: 'application/json'};
@ -51,7 +53,8 @@ export default {
},
body: buildQuery(data)
})
.then(toJSON)
.then(checkStatus)
.then(toJSON, rejectWithJSON)
.then(handleResponse)
;
},
@ -65,7 +68,8 @@ export default {
return fetch(url, {
headers: getDefaultHeaders()
})
.then(toJSON)
.then(checkStatus)
.then(toJSON, rejectWithJSON)
.then(handleResponse)
;
},