From 1e0544802f99d793dadbf237cc31fc3fed075e7f Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Sat, 20 Feb 2021 19:31:18 +0100 Subject: [PATCH] Oauth code flow in API and frontend --- api/src/config/oauth2orize.js | 0 api/src/config/passport.js | 64 ++-- api/src/index.js | 6 +- api/src/models/AccessToken.js | 4 +- api/src/models/AuthorizationCode.js | 33 ++ api/src/models/Client.js | 20 ++ api/src/models/RefreshToken.js | 4 +- api/src/models/index.js | 2 + api/src/routes/api/users.js | 1 + api/src/routes/auth.js | 312 ++++++++++++++++--- api/src/routes/index.js | 3 + frontend/src/App.js | 25 +- frontend/src/api.js | 129 +++++++- frontend/src/components/LoginButton.js | 10 + frontend/src/components/LoginForm.js | 58 ---- frontend/src/components/RegistrationForm.tsx | 37 --- frontend/src/components/index.js | 3 +- frontend/src/config.json | 9 + frontend/src/index.js | 8 +- frontend/src/pages/HomePage.js | 36 +-- frontend/src/pages/LoginPage.js | 18 -- frontend/src/pages/LoginRedirectPage.tsx | 97 ++++++ frontend/src/pages/LogoutPage.js | 10 +- frontend/src/pages/RegistrationPage.tsx | 18 -- frontend/src/pages/TracksPage.tsx | 5 +- frontend/src/pages/UploadPage.tsx | 15 +- frontend/src/pages/index.js | 3 +- frontend/src/reducers/auth.js | 30 ++ frontend/src/reducers/index.js | 3 +- frontend/src/reducers/login.js | 14 +- frontend/src/store.js | 10 + 31 files changed, 707 insertions(+), 280 deletions(-) create mode 100644 api/src/config/oauth2orize.js create mode 100644 api/src/models/AuthorizationCode.js create mode 100644 api/src/models/Client.js create mode 100644 frontend/src/components/LoginButton.js delete mode 100644 frontend/src/components/LoginForm.js delete mode 100644 frontend/src/components/RegistrationForm.tsx create mode 100644 frontend/src/config.json delete mode 100644 frontend/src/pages/LoginPage.js create mode 100644 frontend/src/pages/LoginRedirectPage.tsx delete mode 100644 frontend/src/pages/RegistrationPage.tsx create mode 100644 frontend/src/reducers/auth.js create mode 100644 frontend/src/store.js diff --git a/api/src/config/oauth2orize.js b/api/src/config/oauth2orize.js new file mode 100644 index 0000000..e69de29 diff --git a/api/src/config/passport.js b/api/src/config/passport.js index 8d97068..f3daf41 100644 --- a/api/src/config/passport.js +++ b/api/src/config/passport.js @@ -8,6 +8,31 @@ const { User, AccessToken, RefreshToken } = require('../models'); const secret = require('../config').secret; +// used to serialize the user for the session +passport.serializeUser(function (user, done) { + done(null, user._id); +}); + +// used to deserialize the user +passport.deserializeUser(function (id, done) { + User.findById(id, function (err, user) { + done(err, user); + }); +}); + +async function loginWithPassword(email, password, done) { + try { + const user = await User.findOne({ email: email }); + if (!user || !user.validPassword(password)) { + return done(null, false, { errors: { 'email or password': 'is invalid' } }); + } + + return done(null, user); + } catch (err) { + done(err); + } +} + passport.use( 'usernameAndPassword', new LocalStrategy( @@ -16,18 +41,19 @@ passport.use( passwordField: 'user[password]', session: false, }, - async function (email, password, done) { - try { - const user = await User.findOne({ email: email }); - if (!user || !user.validPassword(password)) { - return done(null, false, { errors: { 'email or password': 'is invalid' } }); - } + loginWithPassword, + ), +); - return done(null, user); - } catch (err) { - done(err); - } +passport.use( + 'usernameAndPasswordSession', + new LocalStrategy( + { + usernameField: 'email', + passwordField: 'password', + session: true, }, + loginWithPassword, ), ); @@ -57,7 +83,7 @@ passport.use( async function (token, done) { try { // we used to put the user ID into the token directly :( - const {id} = token + const { id } = token; const user = await User.findById(id); return done(null, user || false); } catch (err) { @@ -74,7 +100,7 @@ passport.use( const accessToken = await AccessToken.findOne({ token }).populate('user'); if (accessToken && accessToken.user) { // TODO: scope - return done(null, user, { scope: accessToken.scope }); + return done(null, accessToken.user, { scope: accessToken.scope }); } else { return done(null, false); } @@ -134,12 +160,12 @@ passport.use( /** * This function creates a middleware that does a passport authentication. */ -function createMiddleware(strategies, required) { +function createMiddleware(strategies, required = true, session = false) { return (req, res, next) => { - passport.authenticate(strategies, { session: false }, (err, user, info) => { + passport.authenticate(strategies, { session }, (err, user, info) => { // If this authentication produced an error, throw it. In a chain of // multiple strategies, errors are ignored, unless every strategy errors. - if (required && err) { + if (err) { return next(err); } @@ -154,9 +180,9 @@ function createMiddleware(strategies, required) { return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } }); } - req.user = user - req.authInfo = info - req.scope = (info && info.scope) || '*' + req.user = user; + req.authInfo = info; + req.scope = (info && info.scope) || '*'; return next(); })(req, res, next); @@ -173,6 +199,8 @@ module.exports = { // on the /users/login route, and later on oauth routes usernameAndPassword: createMiddleware('usernameAndPassword', true), + usernameAndPasswordSession: createMiddleware('usernameAndPasswordSession', false, true), + // will be used to verify a refresh token on the route that will exchange the // refresh token for a new access token (not in use yet) refreshToken: createMiddleware('refreshToken', true), diff --git a/api/src/index.js b/api/src/index.js index fc371a6..8a8b5cc 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -6,16 +6,14 @@ const cors = require('cors'); const errorhandler = require('errorhandler'); const passport = require('passport'); -require('./config/passport') +require('./config/passport'); const isProduction = process.env.NODE_ENV === 'production'; // Create global app object const app = express(); - app.use(cors()); -app.use(passport.initialize()); // Normal express config defaults app.use(require('morgan')('dev')); @@ -26,6 +24,8 @@ app.use(require('method-override')()); app.use(express.static(path.join(__dirname, 'public'))); app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })); +app.use(passport.initialize()); +app.use(passport.session()); if (!isProduction) { app.use(errorhandler()); diff --git a/api/src/models/AccessToken.js b/api/src/models/AccessToken.js index 9cb4552..f658743 100644 --- a/api/src/models/AccessToken.js +++ b/api/src/models/AccessToken.js @@ -6,6 +6,7 @@ const schema = new mongoose.Schema( { token: { index: true, type: String, required: true, unique: true }, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true }, expiresAt: { type: Date, required: true }, scope: { type: String, required: true, defaultValue: '*' }, }, @@ -30,12 +31,13 @@ class AccessToken extends mongoose.Model { return 'Bearer ' + this.token; } - static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) { + static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) { const token = crypto.randomBytes(32).toString('hex'); return new AccessToken({ token, user, + client, expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), scope, }); diff --git a/api/src/models/AuthorizationCode.js b/api/src/models/AuthorizationCode.js new file mode 100644 index 0000000..ea109d2 --- /dev/null +++ b/api/src/models/AuthorizationCode.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); +const crypto = require('crypto'); + +const schema = new mongoose.Schema( + { + code: { type: String, unique: true, required: true }, + user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true }, + scope: { type: String, required: true, defaultValue: '*' }, + redirectUri: {type: String, required: true}, + expiresAt: {type: Date, required: true}, + }, + { timestamps: true }, +); + +class AuthorizationCode extends mongoose.Model { + static generate(client, user, redirectUri, scope = '*', expiresInSeconds = 60) { + const code = crypto.randomBytes(8).toString('hex'); + + return new AuthorizationCode({ + code, + user, + client, + redirectUri, + expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), + scope, + }); + } +} + +mongoose.model(AuthorizationCode, schema); + +module.exports = AuthorizationCode; diff --git a/api/src/models/Client.js b/api/src/models/Client.js new file mode 100644 index 0000000..3beb310 --- /dev/null +++ b/api/src/models/Client.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); + +const schema = new mongoose.Schema( + { + clientId: { type: String, required: true }, // this is external, so we do not use the ObjectID + validRedirectUris: [{ type: String }], + + // this implementation deals with public clients only, so the following fields are not required: + // scope: {type: String, required: true, default: '*'}, // max possible scope + // confidential: {type: Boolean}, // whether this is a non-public, aka confidential client + // clientSecret: { type: String }, + }, + { timestamps: true }, +); + +class Client extends mongoose.Model {} + +mongoose.model(Client, schema); + +module.exports = Client; diff --git a/api/src/models/RefreshToken.js b/api/src/models/RefreshToken.js index f57b27e..dfe463c 100644 --- a/api/src/models/RefreshToken.js +++ b/api/src/models/RefreshToken.js @@ -8,6 +8,7 @@ const schema = new mongoose.Schema( { token: { index: true, type: String, required: true, unique: true }, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + client: { type: mongoose.Schema.Types.ObjectId, ref: 'Client', required: true }, expiresAt: { type: Date, required: false }, scope: { type: String, required: true, defaultValue: '*' }, }, @@ -28,11 +29,12 @@ class RefreshToken extends mongoose.Model { return this.expiresAt == null || this.expiresAt < new Date() } - static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) { + static generate(client, user, scope = '*', expiresInSeconds = 24 * 60 * 60) { const token = crypto.randomBytes(32).toString('hex'); return new RefreshToken({ token, + client, user, expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), scope, diff --git a/api/src/models/index.js b/api/src/models/index.js index 640fda2..332a4a1 100644 --- a/api/src/models/index.js +++ b/api/src/models/index.js @@ -1,4 +1,6 @@ module.exports.AccessToken = require('./AccessToken') +module.exports.AuthorizationCode = require('./AuthorizationCode') +module.exports.Client = require('./Client') module.exports.Comment = require('./Comment') module.exports.RefreshToken = require('./RefreshToken') module.exports.Track = require('./Track') diff --git a/api/src/routes/api/users.js b/api/src/routes/api/users.js index 3138f68..8451217 100644 --- a/api/src/routes/api/users.js +++ b/api/src/routes/api/users.js @@ -42,6 +42,7 @@ router.put( }), ); +// Remove this at some point router.post('/users/login', auth.usernameAndPassword, wrapRoute((req, res) => { diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 9c6d486..59b1e7a 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -1,45 +1,287 @@ -const passport = require('passport') -const {LocalStrategy} = require('passport-local') -const secret = require('../config').secret; -const User = require('../models/User'); +const router = require('express').Router(); +const passport = require('passport'); +const { URL } = require('url'); +const querystring = require('querystring'); -function getTokenFromHeader(req) { - const authorization = req.headers.authorization; - const [tokenType, token] = (authorization && authorization.split(' ')) || []; +const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models'); +const wrapRoute = require('../_helpers/wrapRoute'); - if (tokenType === 'Token' || tokenType === 'Bearer') { - return token; - } - - return null; +// Check whether the "bigScope" fully includes the "smallScope". +function scopeIncludes(smallScope, bigScope) { + const smallScopeParts = smallScope.split(/\s/); + const bigScopeParts = bigScope.split(/\s/); + return bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part)); } -const jwtOptional = jwt({ - secret: secret, - userProperty: 'authInfo', - credentialsRequired: false, - getToken: getTokenFromHeader, - algorithms: ['HS256'], -}); +function returnError(res, error, errorDescription = undefined, status = 400) { + return res + .status(status) + .json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) }); +} -async function getUserIdMiddleware(req, res, next) { - try { - const authorization = req.headers.authorization; - const [tokenType, token] = (authorization && authorization.split(' ')) || []; +function redirectWithParams(res, redirectUri, params) { + const targetUrl = new URL(redirectUri); + for (const [key, value] of Object.entries(params)) { + targetUrl.searchParams.append(key, value); + } + return res.redirect(targetUrl.toString()); +} - if (tokenType === 'Token' || tokenType === 'Bearer') { +const ALL_SCOPE_NAMES = ` + tracks.create + tracks.update + tracks.list + tracks.show + tracks.delete + users.update + users.show + tracks.comments.create + tracks.comments.update + tracks.comments.list + tracks.comments.show +`.split(/\s/); - // only parse the token as jwt if it looks like one, otherwise we get an error - return jwtOptional(req, res, next); +function isValidScope(scope) { + return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' ')); +} - } else if (tokenType === 'OBSUserId') { - req.authInfo = { id: token.trim() }; - next(); - req.authInfo = null; - next(); +router.post( + '/login', + passport.authenticate('usernameAndPasswordSession'), + wrapRoute((req, res, next) => { + if (!req.user) { + return res.redirect('/login'); } - } catch (err) { - next(err); - } -} + if (req.session.next) { + res.redirect(req.session.next); + req.session.next = null; + return; + } + return res.type('html').end('You are logged in.'); + }), +); + +router.get( + '/login', + wrapRoute(async (req, res) => { + if (req.user) { + return res.type('html').end('Already logged in, nothing to do.'); + } + + res + .type('html') + .end( + '
', + ); + }), +); + +router.get( + '/authorize', + passport.authenticate('session'), + wrapRoute(async (req, res) => { + if (!req.user) { + console.log(req); + req.session.next = req.url; + return res.redirect('/login'); + } + + try { + const { + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + scope = '*', // fallback to "all" scope + } = req.query; + + // 1. Find our client and check if it exists + if (!clientId) { + return returnError(res, 'invalid_request', 'client_id parameter required'); + } + + const client = await Client.findOne({ clientId }); + if (!client) { + return returnError(res, 'invalid_client', 'unknown client'); + } + + // 2. Check that we have a redirect_uri. In addition to [RFC6749] we + // *always* require a redirect_uri. + if (!redirectUri) { + return returnError(res, 'invalid_request', 'redirect_uri parameter required'); + } + + // We enforce that the redirectUri exactly matches one of the provided URIs + if (!client.validRedirectUris.includes(redirectUri)) { + return returnError(res, 'invalid_request', 'invalid redirect_uri'); + } + + // 3. Find out which type of response to use. [RFC6749] requires one of + // "code" or "token", but "token" is implicit grant and we do not support + // that. + + if (responseType !== 'code') { + return redirectWithParams(res, redirectUri, { + error: 'unsupported_grant_type', + error_description: 'only authorization code flow with PKCE is supported by this server', + }); + } + + // 4. Get the scope. + if (!isValidScope(scope)) { + return redirectWithParams(res, redirectUri, { + error: 'invalid_scope', + error_description: 'the requested scope is not known', + }); + } + + // Ok, let's save all this in the session, and show a dialog for the + // decision to the user. + + req.session.authorizationTransaction = { + responseType, + clientId, + redirectUri, + scope, + expiresAt: new Date().getTime() + 1000 * 60 * 2, // 2 minute decision time + }; + + res.type('html').end(` +

+ You are about to confirm a login to client ${clientId} + with redirectUri ${redirectUri} and scope ${scope}. + You have 2 minutes time for your decision. +

+ +
+ +
+
+ +
+ `); + } catch (err) { + console.error(err); + res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' }); + } + }), +); + +router.post( + ['/authorize/approve', '/authorize/decline'], + passport.authenticate('session'), + wrapRoute(async (req, res) => { + if (!req.session.authorizationTransaction) { + return res.sendStatus(400); + } + + if (!req.user) { + return res.sendStatus(400); + } + + const { clientId, redirectUri, scope, expiresAt } = req.session.authorizationTransaction; + + if (expiresAt < new Date().getTime()) { + return res.status(400).type('html').end(`Your authorization has expired. Please go back and retry the process.`); + } + + const client = await Client.findOne({ clientId }); + + // invalidate the transaction + req.session.authorizationTransaction = null; + + if (req.path === '/authorize/approve') { + const code = AuthorizationCode.generate(client, req.user, redirectUri, scope); + await code.save(); + + return redirectWithParams(res, redirectUri, { code: code.code, scope }); + } else { + return redirectWithParams(res, redirectUri, { error: 'access_denied' }); + } + }), +); + +/** + * This function is called when the client presents an authorization code + * (generated above) and wants it turned into an access (and possibly refresh) + * token. + */ +router.get( + '/token', + wrapRoute(async (req, res) => { + const { + grant_type: grantType, + code, + client_id: clientId, + redirect_uri: redirectUri, + // + } = req.query; + + if (!grantType || grantType !== 'authorization_code') { + return returnError( + res, + 'unsupported_grant_type', + 'only authorization code flow with PKCE is supported by this server', + ); + } + + if (!code) { + return returnError(res, 'invalid_request', 'code parameter required'); + } + + if (!code) { + return returnError(res, 'invalid_client', 'client_id parameter required'); + } + + if (!redirectUri) { + return returnError(res, 'invalid_request', 'redirect_uri parameter required'); + } + + const client = await Client.findOne({ clientId }); + + if (!client) { + return returnError(res, 'invalid_client', 'invalid client_id'); + } + + const authorizationCode = await AuthorizationCode.findOne({ code }); + if ( !authorizationCode ) { + console.log('no code found') + return returnError(res, 'invalid_grant', 'invalid authorization code'); + } + if (authorizationCode.redirectUri !== redirectUri) { + console.log('redirect_uri mismatch') + return returnError(res, 'invalid_grant', 'invalid authorization code'); + } + if (authorizationCode.expiresAt <= new Date().getTime()) { + console.log('expired') + return returnError(res, 'invalid_grant', 'invalid authorization code'); + } + if (!client._id.equals(authorizationCode.client)) { + console.log('client mismatch', authorizationCode.client, client._id) + return returnError(res, 'invalid_grant', 'invalid authorization code'); + } + + // invalidate auth code now, before generating tokens + await AuthorizationCode.deleteOne({ _id: authorizationCode._id }); + + const accessToken = AccessToken.generate(authorizationCode.client, authorizationCode.user, authorizationCode.scope); + + const refreshToken = RefreshToken.generate( + authorizationCode.client, + authorizationCode.user, + authorizationCode.scope, + ); + + await Promise.all([accessToken.save(), refreshToken.save()]); + + return res.json({ + access_token: accessToken.token, + token_type: 'Bearer', + expires_in: Math.round((accessToken.expiresAt - new Date().getTime()) / 1000), + refresh_token: refreshToken.token, + scope: accessToken.scope, + }); + }), +); + +module.exports = router; diff --git a/api/src/routes/index.js b/api/src/routes/index.js index 1b24dcf..5a39f4d 100644 --- a/api/src/routes/index.js +++ b/api/src/routes/index.js @@ -2,4 +2,7 @@ const router = require('express').Router(); router.use('/api', require('./api')); +// no prefix +router.use(require('./auth')); + module.exports = router; diff --git a/frontend/src/App.js b/frontend/src/App.js index 7b03ada..1453b0c 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,27 +4,19 @@ import {Icon, Button} from 'semantic-ui-react' import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom' import styles from './App.module.scss' -import api from './api' import { - LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage, UploadPage, - RegistrationPage, -} from './pages' + LoginRedirectPage, +} from 'pages' +import {LoginButton} from 'components' const App = connect((state) => ({login: state.login}))(function App({login}) { - // update the API header on each render, the App is rerendered when the login changes - if (login) { - api.setAuthorizationHeader('Token ' + login.token) - } else { - api.setAuthorizationHeader(null) - } - return (
@@ -68,9 +60,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { ) : ( <>
  • - +
  • )} @@ -92,11 +82,8 @@ const App = connect((state) => ({login: state.login}))(function App({login}) { - - - - - + + diff --git a/frontend/src/api.js b/frontend/src/api.js index d4327fe..e04cd99 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,19 +1,124 @@ import {stringifyParams} from 'query' +import globalStore from 'store' +import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth' +import {setLogin} from 'reducers/login' +import config from 'config.json' class API { - setAuthorizationHeader(authorization) { - this.authorization = authorization + constructor(store) { + this.store = store + this._getValidAccessTokenPromise = null + } + + /** + * Return an access token, if it is (still) valid. If not, and a refresh + * token exists, use the refresh token to issue a new access token. If that + * fails, or neither is available, return `null`. This should usually result + * in a redirect to login. + */ + async getValidAccessToken() { + // prevent multiple parallel refresh processes + if (this._getValidAccessTokenPromise) { + return await this._getValidAccessTokenPromise + } else { + this._getValidAccessTokenPromise = this._getValidAccessToken() + const result = await this._getValidAccessTokenPromise + this._getValidAccessTokenPromise = null + return result + } + } + + async _getValidAccessToken() { + let {auth} = this.store.getState() + + if (!auth) { + return null + } + + const {tokenType, accessToken, refreshToken, expiresAt} = auth + + // access token is valid + if (accessToken && expiresAt > new Date().getTime()) { + return `${tokenType} ${accessToken}` + } + + if (!refreshToken) { + return null + } + + // Try to use the refresh token + const url = new URL(config.auth.tokenEndpoint) + url.searchParams.append('refresh_token', refreshToken) + url.searchParams.append('grant_type', 'refresh_token') + url.searchParams.append('scope', config.auth.scope) + const response = await window.fetch(url.toString()) + const json = await response.json() + + if (response.status === 200 && json != null && json.error == null) { + auth = this.getAuthFromTokenResponse(json) + this.store.dispatch(setAuth(auth)) + return `${auth.tokenType} ${auth.accessToken}` + } else { + console.warn('Could not use refresh token, error response:', json) + this.store.dispatch(resetAuth()) + return null + } + } + + async exchangeAuthorizationCode(code) { + const url = new URL(config.auth.tokenEndpoint) + url.searchParams.append('code', code) + url.searchParams.append('grant_type', 'authorization_code') + url.searchParams.append('client_id', config.auth.clientId) + url.searchParams.append('redirect_uri', config.auth.redirectUri) + const response = await window.fetch(url.toString()) + const json = await response.json() + + if (json.error) { + return json + } + + const auth = api.getAuthFromTokenResponse(json) + this.store.dispatch(setAuth(auth)) + + const {user} = await this.get('/user') + this.store.dispatch(setLogin(user)) + + return true + } + + getLoginUrl() { + const loginUrl = new URL(config.auth.authorizationEndpoint) + loginUrl.searchParams.append('client_id', config.auth.clientId) + loginUrl.searchParams.append('scope', config.auth.scope) + loginUrl.searchParams.append('redirect_uri', config.auth.redirectUri) + loginUrl.searchParams.append('response_type', 'code') + + // TODO: Implement PKCE + + return loginUrl.toString() } async fetch(url, options = {}) { + const accessToken = await this.getValidAccessToken() + const response = await window.fetch('/api' + url, { ...options, headers: { ...(options.headers || {}), - Authorization: this.authorization, + Authorization: accessToken, }, }) + if (response.status === 401) { + // Unset login, since 401 means that we're not logged in. On the next + // request with `getValidAccessToken()`, this will be detected and the + // refresh token is used (if still valid). + this.store.dispatch(invalidateAccessToken()) + + throw new Error('401 Unauthorized') + } + if (response.status === 200) { return await response.json() } else { @@ -26,15 +131,15 @@ class API { let headers = {...(options.headers || {})} if (!(typeof body === 'string' || body instanceof FormData)) { - body = JSON.stringify(body) - headers['Content-Type'] = 'application/json' + body = JSON.stringify(body) + headers['Content-Type'] = 'application/json' } return await this.fetch(url, { ...options, body, method: 'post', - headers + headers, }) } @@ -46,8 +151,18 @@ class API { async delete(url, options = {}) { return await this.get(url, {...options, method: 'delete'}) } + + getAuthFromTokenResponse(tokenResponse) { + return { + tokenType: tokenResponse.token_type, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt: new Date().getTime() + tokenResponse.expires_in * 1000, + scope: tokenResponse.scope, + } + } } -const api = new API() +const api = new API(globalStore) export default api diff --git a/frontend/src/components/LoginButton.js b/frontend/src/components/LoginButton.js new file mode 100644 index 0000000..dbe3c4a --- /dev/null +++ b/frontend/src/components/LoginButton.js @@ -0,0 +1,10 @@ +import {Button} from 'semantic-ui-react' + +import api from 'api' + +export default function LoginButton(props) { + // TODO: Implement PKCE, generate login URL when clicked (with challenge), + // and then redirect there. + const href = api.getLoginUrl() + return +} diff --git a/frontend/src/components/LoginForm.js b/frontend/src/components/LoginForm.js deleted file mode 100644 index c3a90bd..0000000 --- a/frontend/src/components/LoginForm.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import {connect} from 'react-redux' -import {Form, Button} from 'semantic-ui-react' - -import {login as loginAction} from '../reducers/login' - -async function fetchLogin(email, password) { - const response = await window.fetch('/api/users/login', { - body: JSON.stringify({user: {email, password}}), - method: 'post', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }) - - const result = await response.json() - - if (result.user) { - return result.user - } else { - throw new Error('invalid credentials') - } -} - -const LoginForm = connect( - (state) => ({loggedIn: Boolean(state.login)}), - (dispatch) => ({ - dispatchLogin: (user) => dispatch(loginAction(user)), - }) -)(function LoginForm({loggedIn, dispatchLogin, className}) { - const [email, setEmail] = React.useState('') - const [password, setPassword] = React.useState('') - const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), []) - const onChangePassword = React.useCallback((e) => setPassword(e.target.value), []) - - const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [ - email, - password, - dispatchLogin, - ]) - - return loggedIn ? null : ( -
    - - - - - - - - - -
    - ) -}) - -export default LoginForm diff --git a/frontend/src/components/RegistrationForm.tsx b/frontend/src/components/RegistrationForm.tsx deleted file mode 100644 index 6bd54d6..0000000 --- a/frontend/src/components/RegistrationForm.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react' -import {Form, Button} from 'semantic-ui-react' - -export default function RegistrationForm({onSubmit: onSubmitOuter}) { - const [username, setUsername] = React.useState(null) - const [email, setEmail] = React.useState(null) - const [password, setPassword] = React.useState(null) - const [password2, setPassword2] = React.useState(null) - - const onChangeUsername = React.useCallback((e) => setUsername(e.target.value), []) - const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), []) - const onChangePassword = React.useCallback((e) => setPassword(e.target.value), []) - const onChangePassword2 = React.useCallback((e) => setPassword2(e.target.value), []) - - const onSubmit = React.useCallback(() => { - if (username && email && password && password2 === password) { - onSubmitOuter({username, email, password}) - } - }, [username, email, password, password2, onSubmitOuter]) - - return ( -
    - - - - - - - ) -} diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index b06eb9f..533d682 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -1,7 +1,6 @@ export {default as FileDrop} from './FileDrop' export {default as FormattedDate} from './FormattedDate' -export {default as LoginForm} from './LoginForm' +export {default as LoginButton} from './LoginButton' export {default as Map} from './Map' export {default as Page} from './Page' -export {default as RegistrationForm} from './RegistrationForm' export {default as StripMarkdown} from './StripMarkdown' diff --git a/frontend/src/config.json b/frontend/src/config.json new file mode 100644 index 0000000..679f41e --- /dev/null +++ b/frontend/src/config.json @@ -0,0 +1,9 @@ +{ + "auth": { + "authorizationEndpoint": "http://localhost:3000/authorize", + "tokenEndpoint": "http://localhost:3000/token", + "clientId": "123", + "scope": "*", + "redirectUri": "http://localhost:3001/redirect" + } +} diff --git a/frontend/src/index.js b/frontend/src/index.js index 3b32f09..00c01fc 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -6,14 +6,8 @@ import './index.css' import App from './App' import {Provider} from 'react-redux' -import {compose, createStore} from 'redux' -import persistState from 'redux-localstorage' -import rootReducer from './reducers' - -const enhancer = compose(persistState(['login'])) - -const store = createStore(rootReducer, undefined, enhancer) +import store from './store' // TODO: remove Settings.defaultLocale = 'de-DE' diff --git a/frontend/src/pages/HomePage.js b/frontend/src/pages/HomePage.js index cb5b97a..951dc19 100644 --- a/frontend/src/pages/HomePage.js +++ b/frontend/src/pages/HomePage.js @@ -1,14 +1,14 @@ import _ from 'lodash' import React from 'react' -import {connect} from 'react-redux' -import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react' +import {Tab, Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react' import {useObservable} from 'rxjs-hooks' import {of, pipe, from} from 'rxjs' import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' +import {fromLonLat} from 'ol/proj' import {Duration} from 'luxon' import api from '../api' -import {Map, Page, LoginForm} from '../components' +import {Map, Page} from '../components' import {TrackListItem} from './TracksPage' @@ -20,8 +20,9 @@ function formatDuration(seconds) { function WelcomeMap() { return ( - + + ) } @@ -41,7 +42,7 @@ function Stats() { - + {Number(stats?.publicTrackLength / 1000).toFixed(1)} km track length @@ -64,19 +65,6 @@ function Stats() { ) } -const LoginState = connect((state) => ({login: state.login}))(function LoginState({login}) { - return login ? ( - <> -
    Logged in as {login.username}
    - - ) : ( - <> -
    Login
    - - - ) -}) - function MostRecentTrack() { const track: Track | null = useObservable( () => @@ -88,8 +76,6 @@ function MostRecentTrack() { [] ) - console.log(track) - return ( <>

    Most recent track

    @@ -110,19 +96,15 @@ export default function HomePage() { - + - - - + - - - + ) diff --git a/frontend/src/pages/LoginPage.js b/frontend/src/pages/LoginPage.js deleted file mode 100644 index 39bae4b..0000000 --- a/frontend/src/pages/LoginPage.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import {connect} from 'react-redux' -import {Redirect} from 'react-router-dom' - -import {Page, LoginForm} from '../components' - -const LoginPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginPage({loggedIn}) { - return loggedIn ? ( - - ) : ( - -

    Login

    - -
    - ) -}) - -export default LoginPage diff --git a/frontend/src/pages/LoginRedirectPage.tsx b/frontend/src/pages/LoginRedirectPage.tsx new file mode 100644 index 0000000..1953025 --- /dev/null +++ b/frontend/src/pages/LoginRedirectPage.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {connect} from 'react-redux' +import {Redirect, useLocation, useHistory} from 'react-router-dom' +import {Icon, Message} from 'semantic-ui-react' +import {useObservable} from 'rxjs-hooks' +import {switchMap, pluck, distinctUntilChanged} from 'rxjs/operators' + +import {Page} from 'components' +import api from 'api' + +const LoginRedirectPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function LoginRedirectPage({ + loggedIn, +}) { + const location = useLocation() + const history = useHistory() + const {search} = location + + /* eslint-disable react-hooks/exhaustive-deps */ + + // Hook dependency arrays in this block are intentionally left blank, we want + // to keep the initial state, but reset the url once, ASAP, to not leak the + // query parameters. This is considered good practice by OAuth. + const searchParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(search).entries()), []) + + React.useEffect(() => { + history.replace({...location, search: ''}) + }, []) + /* eslint-enable react-hooks/exhaustive-deps */ + + if (loggedIn) { + return + } + + const {error, error_description: errorDescription, code} = searchParams + + if (error) { + return ( + + + + + Login error + The login server reported: {errorDescription || error}. + + + + ) + } + + return +}) + +function ExchangeAuthCode({code}) { + const result = useObservable( + (_$, args$) => + args$.pipe( + pluck(0), + distinctUntilChanged(), + switchMap((code) => api.exchangeAuthorizationCode(code)) + ), + null, + [code] + ) + + let content + if (result === null) { + content = ( + + + + Logging you in + Hang tight... + + + ) + } else if (result === true) { + content = + } else { + const {error, error_description: errorDescription} = result + content = ( + <> + + + + Login error + The login server reported: {errorDescription || error}. + + +
    {JSON.stringify(result, null, 2)}
    + + ) + } + + return {content} +} + +export default LoginRedirectPage diff --git a/frontend/src/pages/LogoutPage.js b/frontend/src/pages/LogoutPage.js index 829728d..2b908ae 100644 --- a/frontend/src/pages/LogoutPage.js +++ b/frontend/src/pages/LogoutPage.js @@ -2,16 +2,14 @@ import React from 'react' import {connect} from 'react-redux' import {Redirect} from 'react-router-dom' -import {logout as logoutAction} from '../reducers/login' +import {resetAuth} from 'reducers/auth' const LogoutPage = connect( (state) => ({loggedIn: Boolean(state.login)}), - (dispatch) => ({ - dispatchLogout: () => dispatch(logoutAction()), - }) -)(function LogoutPage({loggedIn, dispatchLogout}) { + {resetAuth} +)(function LogoutPage({loggedIn, resetAuth}) { React.useEffect(() => { - dispatchLogout() + resetAuth() }) return loggedIn ? null : diff --git a/frontend/src/pages/RegistrationPage.tsx b/frontend/src/pages/RegistrationPage.tsx deleted file mode 100644 index 171bd2c..0000000 --- a/frontend/src/pages/RegistrationPage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import {connect} from 'react-redux' -import {Redirect} from 'react-router-dom' - -import {Page, RegistrationForm} from '../components' - -const RegistrationPage = connect((state) => ({loggedIn: Boolean(state.login)}))(function RegistrationPage({loggedIn}) { - return loggedIn ? ( - - ) : ( - -

    Register

    - -
    - ) -}) - -export default RegistrationPage diff --git a/frontend/src/pages/TracksPage.tsx b/frontend/src/pages/TracksPage.tsx index 7b64007..1cd1084 100644 --- a/frontend/src/pages/TracksPage.tsx +++ b/frontend/src/pages/TracksPage.tsx @@ -37,7 +37,6 @@ function TracksPageTabs() { function TrackList({privateFeed}: {privateFeed: boolean}) { const [page, setPage] = useQueryParam('page', 1, Number) - console.log('page', page) const pageSize = 10 @@ -86,7 +85,7 @@ function TrackList({privateFeed}: {privateFeed: boolean}) { } function maxLength(t, max) { - if (t.length > max) { + if (t && t.length > max) { return t.substring(0, max) + ' ...' } else { return t @@ -99,7 +98,7 @@ export function TrackListItem({track, privateFeed = false}) { - {track.title} + {track.title || 'Unnamed track'} Created by {track.author.username} on {track.createdAt} diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx index 0367053..b29b28f 100644 --- a/frontend/src/pages/UploadPage.tsx +++ b/frontend/src/pages/UploadPage.tsx @@ -58,13 +58,11 @@ function FileUploadStatus({ const xhr = new XMLHttpRequest() const onProgress = (e) => { - console.log('progress', e) const progress = (e.loaded || 0) / (e.total || 1) setProgress(progress) } const onLoad = (e) => { - console.log('loaded', e) onComplete(id, xhr.response) } @@ -72,17 +70,18 @@ function FileUploadStatus({ xhr.onload = onLoad xhr.upload.onprogress = onProgress xhr.open('POST', '/api/tracks') - xhr.setRequestHeader('Authorization', api.authorization) - xhr.send(formData) + + api.getValidAccessToken().then((accessToken) => { + xhr.setRequestHeader('Authorization', accessToken) + xhr.send(formData) + }) return () => xhr.abort() }, [file]) return ( - - {' '} - {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'} + {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'} ) } @@ -113,8 +112,6 @@ export default function UploadPage() { }, [labelRef.current]) function onSelectFiles(fileList) { - console.log('UPLOAD', fileList) - const newFiles = Array.from(fileList).map((file) => ({ id: 'file-' + String(Math.floor(Math.random() * 1000000)), file, diff --git a/frontend/src/pages/index.js b/frontend/src/pages/index.js index 50f90b9..84669f4 100644 --- a/frontend/src/pages/index.js +++ b/frontend/src/pages/index.js @@ -1,8 +1,7 @@ export {default as HomePage} from './HomePage' -export {default as LoginPage} from './LoginPage' export {default as LogoutPage} from './LogoutPage' export {default as NotFoundPage} from './NotFoundPage' -export {default as RegistrationPage} from './RegistrationPage' +export {default as LoginRedirectPage} from './LoginRedirectPage' export {default as TrackPage} from './TrackPage' export {default as TracksPage} from './TracksPage' export {default as UploadPage} from './UploadPage' diff --git a/frontend/src/reducers/auth.js b/frontend/src/reducers/auth.js new file mode 100644 index 0000000..428338e --- /dev/null +++ b/frontend/src/reducers/auth.js @@ -0,0 +1,30 @@ +const initialState = null + +export function setAuth(auth) { + return {type: 'AUTH.SET', payload: {auth}} +} + +export function resetAuth() { + return {type: 'AUTH.RESET'} +} + +export function invalidateAccessToken() { + return {type: 'AUTH.INVALIDATE_ACCESS_TOKEN'} +} + +export default function loginReducer(state = initialState, action) { + switch (action.type) { + case 'AUTH.SET': + return action.payload.auth + case 'AUTH.INVALIDATE_ACCESS_TOKEN': + return state && { + ...state, + accessToken: null, + expiresAt: 0, + } + case 'AUTH.RESET': + return null + default: + return state + } +} diff --git a/frontend/src/reducers/index.js b/frontend/src/reducers/index.js index 0cfe682..88a63d5 100644 --- a/frontend/src/reducers/index.js +++ b/frontend/src/reducers/index.js @@ -1,5 +1,6 @@ import {combineReducers} from 'redux' import login from './login' +import auth from './auth' -export default combineReducers({login}) +export default combineReducers({login, auth}) diff --git a/frontend/src/reducers/login.js b/frontend/src/reducers/login.js index 32b8777..9137112 100644 --- a/frontend/src/reducers/login.js +++ b/frontend/src/reducers/login.js @@ -1,19 +1,17 @@ const initialState = null -export function login(user) { - return {type: 'LOGIN.LOGIN', payload: {user}} -} - -export function logout() { - return {type: 'LOGIN.LOGOUT'} +export function setLogin(user) { + return {type: 'LOGIN.SET', payload: {user}} } export default function loginReducer(state = initialState, action) { switch (action.type) { - case 'LOGIN.LOGIN': + case 'LOGIN.SET': return action.payload.user - case 'LOGIN.LOGOUT': + + case 'AUTH.RESET': // cross reducer action :) return null + default: return state } diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..f5e54b6 --- /dev/null +++ b/frontend/src/store.js @@ -0,0 +1,10 @@ +import {compose, createStore} from 'redux' +import persistState from 'redux-localstorage' + +import rootReducer from './reducers' + +const enhancer = compose(persistState(['login', 'auth'])) + +const store = createStore(rootReducer, undefined, enhancer) + +export default store