Authentification OpenID Connect

Implémentation du modèle d'authentification "Authorization code with
PKCE [1]"

[1] https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce
This commit is contained in:
2020-06-18 09:48:45 +02:00
parent 59806edc10
commit 713b8cc3ea
25 changed files with 628 additions and 184 deletions

View File

@ -1,29 +1,22 @@
import React from 'react';
import { HashRouter as Router, Route, Redirect, Switch } from "react-router-dom";
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
import { HomePage } from './HomePage/HomePage';
import { store } from '../store/store';
import { Provider } from 'react-redux';
import { logout } from '../store/actions/logout';
import { OAuth2Page } from './OAuth2Page/OAuth2Page';
export class App extends React.Component {
render() {
return (
<Provider store={store}>
<Router>
<BrowserRouter>
<Switch>
<Route path="/" exact component={HomePage} />
<Route path="/logout" exact component={() => {
this.logout();
return <Redirect to="/" />;
}} />
<Route path="/oauth2/:action" exact component={OAuth2Page} />
<Route component={() => <Redirect to="/" />} />
</Switch>
</Router>
</BrowserRouter>
</Provider>
);
}
logout() {
store.dispatch(logout());
}
}

View File

@ -1,11 +1,27 @@
import React from 'react';
import { Page } from '../Page';
import { useSelector } from 'react-redux';
import { RootState } from '../../store/reducers/root';
export function HomePage() {
const currentUser = useSelector((state: RootState) => state.auth.currentUser);
return (
<Page title="Daddy - Accueil">
<div className="container is-fluid">
<section className="section">
<div className="columns">
<div className="column is-4 is-offset-4">
<div className="box">
{
currentUser ?
<p>Bonjour <span className="has-text-weight-bold">{currentUser.email}</span> !</p> :
<p>Veuillez vous authentifier.</p>
}
</div>
</div>
</div>
</section>
</div>
</Page>
);

View File

@ -1,36 +1,48 @@
import React from 'react';
import logo from '../resources/logo.svg';
import { useSelector } from 'react-redux';
import { RootState } from '../store/reducers/root';
import { Link } from 'react-router-dom';
export class Navbar extends React.PureComponent {
render() {
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<a className="navbar-item" href="#/">
<img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} />
<h1 className="is-size-4">Daddy</h1>
</a>
<a role="button" className="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div className="navbar-menu">
<div className="navbar-end">
<div className="navbar-item">
<a className="button is-small" href="#/logout">
export function Navbar() {
const isAuthenticated = useSelector<RootState>(state => state.auth.isAuthenticated);
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<div className="container is-fluid">
<div className="navbar-brand">
<a className="navbar-item" href="#/">
<img src={logo} style={{marginRight:'5px',width:'28px',height:'28px'}} />
<h1 className="is-size-4">Daddy</h1>
</a>
<a role="button" className="navbar-burger" aria-label="menu" aria-expanded="false">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div className="navbar-menu">
<div className="navbar-end">
<div className="navbar-item">
{
isAuthenticated ?
<Link className="button is-small" to="/oauth2/logout">
<span className="icon">
<i className="fas fa-sign-out-alt"></i>
</span>
<span>Se déconnecter</span>
</a>
</div>
</Link> :
<Link className="button is-small" to="/oauth2/login">
<span className="icon">
<i className="fas fa-sign-in-alt"></i>
</span>
<span>Se connecter</span>
</Link>
}
</div>
</div>
</div>
</nav>
);
}
}
</div>
</nav>
);
};

View File

@ -0,0 +1,31 @@
import React, { useEffect } from 'react';
import { Page } from '../Page';
import { useDispatch } from 'react-redux';
import { logout, login, handleOAuth2Callback } from '../../store/actions/auth';
export function OAuth2Page({ match, location, history }) {
const dispatch = useDispatch();
const { action } = match.params;
useEffect(() => {
switch(action) {
case 'logout':
dispatch(logout());
history.push("/");
break;
case 'login':
dispatch(login());
break;
case 'callback':
dispatch(handleOAuth2Callback(location.search));
history.push("/");
break;
}
}, [action]);
return (
<Page title="Daddy - OAuth2">
</Page>
);
}

20
frontend/src/config.ts Normal file
View File

@ -0,0 +1,20 @@
export const Config = {
// The OpenID Connect client_id
oauth2ClientId: get<string>("oauth2ClientId", "daddy"),
oauth2Scope: get<string>("oauth2Scope", "email email_verified openid offline_access"),
oauth2RedirectURI: get<string>("oauth2RedirectURI", "http://localhost:8081/oauth2/callback"),
oauth2Audience: get<string>("oauth2Audience", ""),
oauth2AuthorizeURL: get<string>("oauth2AuthorizeURL", "http://localhost:4444/oauth2/auth"),
oauth2TokenURL: get<string>("oauth2TokenURL", "http://localhost:4444/oauth2/token"),
oauth2LogoutURL: get<string>("oauth2LogoutURL", "http://localhost:4444/oauth2/sessions/logout"),
oauth2PostLogoutRedirectURI: get<string>("oauth2PostLogoutRedirectURI", "http://localhost:8081")
};
function get<T>(key: string, defaultValue: T):T {
const config = window['__CONFIG__'] || {};
if (config && config.hasOwnProperty(key)) {
return config[key] as T;
} else {
return defaultValue;
}
}

View File

@ -5,16 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Daddy</title>
<% for (var css in htmlWebpackPlugin.files.css) { %>
<link href="<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
<link href="/<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
<% } %>
<% if (htmlWebpackPlugin.files.favicon) { %>
<link rel="shortcut icon" href="<%= htmlWebpackPlugin.files.favicon%>">
<link rel="shortcut icon" href="/<%= htmlWebpackPlugin.files.favicon%>">
<% } %>
</head>
<body>
<div id="app" class="is-fullheight"></div>
<script src="/config.js"></script>
<% for (var chunk in htmlWebpackPlugin.files.chunks) { %>
<script type="text/javascript" src="<%= htmlWebpackPlugin.files.chunks[chunk].entry %>"></script>
<script type="text/javascript" src="/<%= htmlWebpackPlugin.files.chunks[chunk].entry %>"></script>
<% } %>
</body>
</html>

View File

@ -2,6 +2,7 @@ import './sass/_all.scss';
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './components/App';
import { Config } from './config';
import '@fortawesome/fontawesome-free/js/fontawesome'
import '@fortawesome/fontawesome-free/js/solid'

View File

@ -0,0 +1,69 @@
import { Action } from "redux";
import { AccessGrant } from "../../util/auth";
import { IdToken } from "../../types/idToken";
export const LOGOUT = "LOGOUT_REQUEST";
export function logout() {
return { type: LOGOUT };
};
export const LOGIN_REQUEST = "LOGIN_REQUEST";
export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAILURE = "LOGIN_FAILURE";
export function login() {
return { type: LOGIN_REQUEST };
};
export const HANDLE_OAUTH2_CALLBACK_REQUEST = "HANDLE_OAUTH2_CALLBACK_REQUEST";
export const HANDLE_OAUTH2_CALLBACK_SUCCESS = "HANDLE_OAUTH2_CALLBACK_SUCCESS";
export const HANDLE_OAUTH2_CALLBACK_FAILURE = "HANDLE_OAUTH2_CALLBACK_FAILURE";
export interface handleOAuth2CallbackAction extends Action {
search: string
}
export function handleOAuth2Callback(search: string): handleOAuth2CallbackAction {
return { type: HANDLE_OAUTH2_CALLBACK_REQUEST, search };
};
export interface handleOAuth2CallbackSuccessAction extends Action {
grant: AccessGrant
}
export function handleOAuth2CallbackSuccess(grant: AccessGrant): handleOAuth2CallbackSuccessAction {
return { type: HANDLE_OAUTH2_CALLBACK_SUCCESS, grant };
};
export const PARSE_ID_TOKEN_REQUEST = "PARSE_ID_TOKEN_REQUEST";
export const PARSE_ID_TOKEN_SUCCESS = "PARSE_ID_TOKEN_SUCCESS";
export const PARSE_ID_TOKEN_FAILURE = "PARSE_ID_TOKEN_FAILURE";
export interface parseIdTokenAction extends Action {
rawIdToken: string
};
export function parseIdToken(rawIdToken: string): parseIdTokenAction {
return { type: PARSE_ID_TOKEN_REQUEST, rawIdToken };
};
export interface parseIdTokenSuccessAction extends Action {
idToken: IdToken
}
export function parseIdTokenSuccess(idToken: IdToken): parseIdTokenSuccessAction {
return { type: PARSE_ID_TOKEN_SUCCESS, idToken };
};
export const SET_CURRENT_USER = 'SET_CURRENT_USER';
export interface setCurrentUserAction extends Action {
email: string
}
export function setCurrentUser(email: string): setCurrentUserAction {
return { type: SET_CURRENT_USER, email };
}

View File

@ -1,7 +0,0 @@
export const LOGOUT_REQUEST = "LOGOUT_REQUEST";
export const LOGOUT_SUCCESS = "LOGOUT_SUCCESS";
export const LOGOUT_FAILURE = "LOGOUT_FAILURE";
export function logout() {
return { type: LOGOUT_REQUEST };
};

View File

@ -0,0 +1,41 @@
import { Action } from "redux";
import { User } from "../../types/user";
import { SET_CURRENT_USER, setCurrentUserAction, LOGOUT } from "../actions/auth";
export interface AuthState {
isAuthenticated: boolean
currentUser: User
}
const defaultState = {
isAuthenticated: false,
currentUser: null,
};
export function authReducer(state = defaultState, action: Action): AuthState {
switch (action.type) {
case SET_CURRENT_USER:
return handleSetCurrentUser(state, action as setCurrentUserAction);
case LOGOUT:
return handleLogout(state);
}
return state;
}
function handleSetCurrentUser(state: AuthState, { email }: setCurrentUserAction): AuthState {
return {
...state,
isAuthenticated: true,
currentUser: {
email
}
};
};
function handleLogout(state: AuthState): AuthState {
return {
...state,
isAuthenticated: false,
currentUser: null,
};
}

View File

@ -1,8 +1,18 @@
import { Action } from "redux";
export interface FlagsState {
actions: { [actionName: string]: ActionState }
}
export interface ActionState {
isLoading: boolean
}
const defaultState = {
actions: {}
};
export function flagsReducer(state = defaultState, action: any) {
export function flagsReducer(state = defaultState, action: Action): FlagsState {
const matches = (/^(.*)_((SUCCESS)|(FAILURE)|(REQUEST))$/).exec(action.type);
if(!matches) return state;

View File

@ -1,6 +1,13 @@
import { combineReducers } from 'redux';
import { flagsReducer } from './flags';
import { flagsReducer, FlagsState } from './flags';
import { authReducer, AuthState } from './auth';
export interface RootState {
auth: AuthState,
flags: FlagsState,
}
export const rootReducer = combineReducers({
flags: flagsReducer,
auth: authReducer,
});

View File

@ -0,0 +1,98 @@
import { put, takeLatest, all } from 'redux-saga/effects';
import {
LOGOUT, LOGIN_REQUEST,
HANDLE_OAUTH2_CALLBACK_REQUEST, handleOAuth2CallbackAction,
HANDLE_OAUTH2_CALLBACK_FAILURE, handleOAuth2CallbackSuccess,
parseIdTokenAction, parseIdToken,
PARSE_ID_TOKEN_REQUEST, PARSE_ID_TOKEN_FAILURE, parseIdTokenSuccess,
setCurrentUser, LOGIN_FAILURE,
} from '../actions/auth';
import {
createLoginSession, LoginSession,
createAccessTokenRequest, saveAccessGrant,
saveLoginSessionState, getSavedLoginSessionState,
getLogoutURL, getSavedAccessGrant, clearAccessGrant
} from '../../util/auth';
import qs from 'qs';
import { UnauthorizedError } from '../../util/daddy';
import jwtDecode from 'jwt-decode';
import { IdToken } from '../../types/idToken';
export function* authRootSaga() {
yield all([
takeLatest(LOGIN_REQUEST, loginSaga),
takeLatest(LOGOUT, logoutSaga),
takeLatest(HANDLE_OAUTH2_CALLBACK_REQUEST, handleOAuth2CallbackSaga),
takeLatest(PARSE_ID_TOKEN_REQUEST, parseIDTokenSaga),
]);
}
export function* loginSaga() {
try {
const loginSession: LoginSession = yield createLoginSession();
console.log('Code verifier is ', loginSession.verifier);
console.log('State is ', loginSession.state);
saveLoginSessionState(loginSession.verifier, loginSession.state);
window.location.replace(loginSession.redirectUrl);
} catch(err) {
yield put({ type: LOGIN_FAILURE, err });
}
}
export function* logoutSaga() {
const accessGrant = getSavedAccessGrant();
const logoutURL = getLogoutURL(accessGrant.id_token);
clearAccessGrant();
window.location.replace(logoutURL);
}
export function* handleOAuth2CallbackSaga({ search }: handleOAuth2CallbackAction) {
const query = search.substring(1);
const params = qs.parse(query);
const loginSession = getSavedLoginSessionState();
console.log('Stored state verifier is', loginSession.state);
if (loginSession.state !== params.state) {
yield put({ type: HANDLE_OAUTH2_CALLBACK_FAILURE, err: new Error("Invalid state") });
return;
}
console.log('Stored code verifier is', loginSession.verifier);
console.log('Authorization code is', params.code);
const req = createAccessTokenRequest(params.code as string, loginSession.verifier);
let grant;
try {
grant = yield fetch(req.url, { method: "POST", body: req.data })
.then(res => {
if (res.status === 401) return Promise.reject(new UnauthorizedError());
return res;
})
.then(res => res.json());
} catch(err) {
yield put({ type: HANDLE_OAUTH2_CALLBACK_FAILURE, err });
return;
}
console.log("Access grant is", grant);
saveAccessGrant(grant);
yield put(handleOAuth2CallbackSuccess(grant));
yield put(parseIdToken(grant.id_token));
};
export function* parseIDTokenSaga({ rawIdToken }: parseIdTokenAction) {
let idToken: IdToken;
try {
idToken = jwtDecode(rawIdToken);
} catch(err) {
yield put({ type: PARSE_ID_TOKEN_FAILURE, err });
return;
}
yield put(parseIdTokenSuccess(idToken));
yield put(setCurrentUser(idToken.email));
};

View File

@ -1,9 +1,21 @@
import { UnauthorizedError } from "../../util/daddy";
import { put } from 'redux-saga/effects';
import { logout } from '../actions/logout';
import { put, all, takeEvery } from 'redux-saga/effects';
import { logout } from '../actions/auth';
export function* failureRootSaga() {
yield all([
takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga),
]);
}
export function* failuresSaga(action) {
if (action.error instanceof UnauthorizedError) {
yield put(logout());
}
}
export function patternFromRegExp(re: any) {
return (action: any) => {
return re.test(action.type);
};
}

View File

@ -1,17 +0,0 @@
import { call, put } from 'redux-saga/effects';
import { LOGOUT_FAILURE, LOGOUT_SUCCESS } from '../actions/logout';
export function* logoutSaga() {
try {
yield call(fetch, '/logout', { mode: 'no-cors', credentials: 'include' });
} catch(err) {
yield put({ type: LOGOUT_FAILURE, error: err });
return;
}
yield put({ type: LOGOUT_SUCCESS });
}
export function* logoutSuccessSaga() {
window.location.reload();
}

View File

@ -1,18 +1,10 @@
import { all, takeEvery, takeLatest } from 'redux-saga/effects';
import { failuresSaga } from './failure';
import { LOGOUT_REQUEST, LOGOUT_SUCCESS } from '../actions/logout';
import { logoutSaga, logoutSuccessSaga } from './logout';
import { all } from 'redux-saga/effects';
import { failureRootSaga } from './failure';
import { authRootSaga } from './auth';
export function* rootSaga() {
yield all([
takeEvery(patternFromRegExp(/^.*_FAILURE/), failuresSaga),
takeLatest(LOGOUT_REQUEST, logoutSaga),
takeLatest(LOGOUT_SUCCESS, logoutSuccessSaga)
failureRootSaga(),
authRootSaga(),
]);
}
export function patternFromRegExp(re: any) {
return (action: any) => {
return re.test(action.type);
};
}

View File

@ -0,0 +1,3 @@
export interface IdToken {
email: string
}

View File

@ -0,0 +1,3 @@
export interface User {
email: string
}

126
frontend/src/util/auth.ts Normal file
View File

@ -0,0 +1,126 @@
import { Config } from '../config';
export interface LoginSession {
state: string
redirectUrl: string
verifier: string
}
export function generateRandomString() {
var array = new Uint32Array(28);
window.crypto.getRandomValues(array);
return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
}
export function sha256(plain): PromiseLike<any> {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest('SHA-256', data);
}
export function pkceChallengeFromVerifier(v): PromiseLike<string> {
return sha256(v)
.then(hashed => base64urlencode(hashed));
}
export function base64urlencode(str) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function createLoginSession(): Promise<LoginSession> {
// Based on https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce
const state = generateRandomString();
const verifier = generateRandomString();
return new Promise<LoginSession>((resolve, reject) => {
try {
pkceChallengeFromVerifier(verifier).then(challenge => {
console.log('Code challenge is', challenge);
let redirectUrl=`${Config.oauth2AuthorizeURL}`;
redirectUrl += `?audience=${encodeURIComponent(Config.oauth2Audience)}`;
redirectUrl += `&scope=${encodeURIComponent(Config.oauth2Scope)}`;
redirectUrl += `&response_type=code`;
redirectUrl += `&client_id=${encodeURIComponent(Config.oauth2ClientId)}`
redirectUrl += `&code_challenge=${encodeURIComponent(challenge)}`;
redirectUrl += `&code_challenge_method=S256`
redirectUrl += `&redirect_uri=${encodeURIComponent(Config.oauth2RedirectURI)}`;
redirectUrl += `&state=${encodeURIComponent(state)}`;
return resolve({
state,
redirectUrl,
verifier,
});
});
} catch(err) {
return reject(err);
}
});
};
export interface AccessTokenRequest {
data: FormData,
url: string
}
export function createAccessTokenRequest(code: string, verifier: string): AccessTokenRequest {
const data = new FormData();
data.append('grant_type', 'authorization_code');
data.append('client_id', Config.oauth2ClientId);
data.append('code_verifier', verifier);
data.append('code', code);
data.append('redirect_uri', Config.oauth2RedirectURI);
return {
url: Config.oauth2TokenURL,
data,
};
};
export function getLogoutURL(rawIdToken: string): string {
let logoutURL = Config.oauth2LogoutURL;
logoutURL += `?post_logout_redirect_uri=${encodeURIComponent(Config.oauth2PostLogoutRedirectURI)}`;
logoutURL += `&id_token_hint=${encodeURIComponent(rawIdToken)}`;
return logoutURL;
}
export interface AccessGrant {
access_token: string
expires_in: number
id_token: string
refresh_token: string
scope: string
token_type: string
}
export function saveLoginSessionState(verifier: string, state: string) {
window.localStorage.setItem('login_verifier', verifier);
window.localStorage.setItem('login_state', state);
}
export function getSavedLoginSessionState(cleanup = true) {
const loginSession = {
verifier: window.localStorage.getItem('login_verifier'),
state: window.localStorage.getItem('login_state')
};
if (cleanup) {
window.localStorage.removeItem('login_verifier');
window.localStorage.removeItem('login_state');
}
return loginSession;
}
export function saveAccessGrant(grant: AccessGrant) {
window.localStorage.setItem('access_grant', JSON.stringify(grant));
}
export function getSavedAccessGrant(): AccessGrant {
const raw = window.localStorage.getItem('access_grant');
if (raw === "") return null;
return JSON.parse(raw) as AccessGrant;
}
export function clearAccessGrant() {
window.localStorage.removeItem('access_grant');
}