Oauth code flow in API and frontend

This commit is contained in:
Paul Bienkowski 2021-02-20 19:31:18 +01:00
parent 254b262a72
commit 1e0544802f
31 changed files with 707 additions and 280 deletions

View file

View file

@ -8,6 +8,31 @@ const { User, AccessToken, RefreshToken } = require('../models');
const secret = require('../config').secret; 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( passport.use(
'usernameAndPassword', 'usernameAndPassword',
new LocalStrategy( new LocalStrategy(
@ -16,18 +41,19 @@ passport.use(
passwordField: 'user[password]', passwordField: 'user[password]',
session: false, session: false,
}, },
async function (email, password, done) { loginWithPassword,
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); passport.use(
} catch (err) { 'usernameAndPasswordSession',
done(err); new LocalStrategy(
} {
usernameField: 'email',
passwordField: 'password',
session: true,
}, },
loginWithPassword,
), ),
); );
@ -57,7 +83,7 @@ passport.use(
async function (token, done) { async function (token, done) {
try { try {
// we used to put the user ID into the token directly :( // we used to put the user ID into the token directly :(
const {id} = token const { id } = token;
const user = await User.findById(id); const user = await User.findById(id);
return done(null, user || false); return done(null, user || false);
} catch (err) { } catch (err) {
@ -74,7 +100,7 @@ passport.use(
const accessToken = await AccessToken.findOne({ token }).populate('user'); const accessToken = await AccessToken.findOne({ token }).populate('user');
if (accessToken && accessToken.user) { if (accessToken && accessToken.user) {
// TODO: scope // TODO: scope
return done(null, user, { scope: accessToken.scope }); return done(null, accessToken.user, { scope: accessToken.scope });
} else { } else {
return done(null, false); return done(null, false);
} }
@ -134,12 +160,12 @@ passport.use(
/** /**
* This function creates a middleware that does a passport authentication. * 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) => { 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 // If this authentication produced an error, throw it. In a chain of
// multiple strategies, errors are ignored, unless every strategy errors. // multiple strategies, errors are ignored, unless every strategy errors.
if (required && err) { if (err) {
return next(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' } }); return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
} }
req.user = user req.user = user;
req.authInfo = info req.authInfo = info;
req.scope = (info && info.scope) || '*' req.scope = (info && info.scope) || '*';
return next(); return next();
})(req, res, next); })(req, res, next);
@ -173,6 +199,8 @@ module.exports = {
// on the /users/login route, and later on oauth routes // on the /users/login route, and later on oauth routes
usernameAndPassword: createMiddleware('usernameAndPassword', true), usernameAndPassword: createMiddleware('usernameAndPassword', true),
usernameAndPasswordSession: createMiddleware('usernameAndPasswordSession', false, true),
// will be used to verify a refresh token on the route that will exchange the // 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) // refresh token for a new access token (not in use yet)
refreshToken: createMiddleware('refreshToken', true), refreshToken: createMiddleware('refreshToken', true),

View file

@ -6,16 +6,14 @@ const cors = require('cors');
const errorhandler = require('errorhandler'); const errorhandler = require('errorhandler');
const passport = require('passport'); const passport = require('passport');
require('./config/passport') require('./config/passport');
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
// Create global app object // Create global app object
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(passport.initialize());
// Normal express config defaults // Normal express config defaults
app.use(require('morgan')('dev')); app.use(require('morgan')('dev'));
@ -26,6 +24,8 @@ app.use(require('method-override')());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })); app.use(session({ secret: 'obsobs', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
if (!isProduction) { if (!isProduction) {
app.use(errorhandler()); app.use(errorhandler());

View file

@ -6,6 +6,7 @@ const schema = new mongoose.Schema(
{ {
token: { index: true, type: String, required: true, unique: true }, token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: 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 }, expiresAt: { type: Date, required: true },
scope: { type: String, required: true, defaultValue: '*' }, scope: { type: String, required: true, defaultValue: '*' },
}, },
@ -30,12 +31,13 @@ class AccessToken extends mongoose.Model {
return 'Bearer ' + this.token; 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'); const token = crypto.randomBytes(32).toString('hex');
return new AccessToken({ return new AccessToken({
token, token,
user, user,
client,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope, scope,
}); });

View file

@ -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;

20
api/src/models/Client.js Normal file
View file

@ -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;

View file

@ -8,6 +8,7 @@ const schema = new mongoose.Schema(
{ {
token: { index: true, type: String, required: true, unique: true }, token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: 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 }, expiresAt: { type: Date, required: false },
scope: { type: String, required: true, defaultValue: '*' }, scope: { type: String, required: true, defaultValue: '*' },
}, },
@ -28,11 +29,12 @@ class RefreshToken extends mongoose.Model {
return this.expiresAt == null || this.expiresAt < new Date() 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'); const token = crypto.randomBytes(32).toString('hex');
return new RefreshToken({ return new RefreshToken({
token, token,
client,
user, user,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds), expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope, scope,

View file

@ -1,4 +1,6 @@
module.exports.AccessToken = require('./AccessToken') module.exports.AccessToken = require('./AccessToken')
module.exports.AuthorizationCode = require('./AuthorizationCode')
module.exports.Client = require('./Client')
module.exports.Comment = require('./Comment') module.exports.Comment = require('./Comment')
module.exports.RefreshToken = require('./RefreshToken') module.exports.RefreshToken = require('./RefreshToken')
module.exports.Track = require('./Track') module.exports.Track = require('./Track')

View file

@ -42,6 +42,7 @@ router.put(
}), }),
); );
// Remove this at some point
router.post('/users/login', router.post('/users/login',
auth.usernameAndPassword, auth.usernameAndPassword,
wrapRoute((req, res) => { wrapRoute((req, res) => {

View file

@ -1,45 +1,287 @@
const passport = require('passport') const router = require('express').Router();
const {LocalStrategy} = require('passport-local') const passport = require('passport');
const secret = require('../config').secret; const { URL } = require('url');
const User = require('../models/User'); const querystring = require('querystring');
function getTokenFromHeader(req) { const { AuthorizationCode, AccessToken, RefreshToken, Client } = require('../models');
const authorization = req.headers.authorization; const wrapRoute = require('../_helpers/wrapRoute');
const [tokenType, token] = (authorization && authorization.split(' ')) || [];
if (tokenType === 'Token' || tokenType === 'Bearer') { // Check whether the "bigScope" fully includes the "smallScope".
return token; function scopeIncludes(smallScope, bigScope) {
} const smallScopeParts = smallScope.split(/\s/);
const bigScopeParts = bigScope.split(/\s/);
return null; return bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part));
} }
const jwtOptional = jwt({ function returnError(res, error, errorDescription = undefined, status = 400) {
secret: secret, return res
userProperty: 'authInfo', .status(status)
credentialsRequired: false, .json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) });
getToken: getTokenFromHeader, }
algorithms: ['HS256'],
});
async function getUserIdMiddleware(req, res, next) { function redirectWithParams(res, redirectUri, params) {
try { const targetUrl = new URL(redirectUri);
const authorization = req.headers.authorization; for (const [key, value] of Object.entries(params)) {
const [tokenType, token] = (authorization && authorization.split(' ')) || []; 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 function isValidScope(scope) {
return jwtOptional(req, res, next); return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
}
} else if (tokenType === 'OBSUserId') { router.post(
req.authInfo = { id: token.trim() }; '/login',
next(); passport.authenticate('usernameAndPasswordSession'),
req.authInfo = null; wrapRoute((req, res, next) => {
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(
'<form method="post"><input name="email" value="test@example.com" /><input type="password" name="password" value="hunter2" /><button type="submit">Login</button></form>',
);
}),
);
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(`
<p>
You are about to confirm a login to client <code>${clientId}</code>
with redirectUri <code>${redirectUri}</code> and scope <code>${scope}</code>.
You have 2 minutes time for your decision.
</p>
<form method="post" action="/authorize/approve">
<input type="submit" value="Authorize" />
</form>
<form method="post" action="/authorize/decline">
<input type="submit" value="Decline" />
</form>
`);
} 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;

View file

@ -2,4 +2,7 @@ const router = require('express').Router();
router.use('/api', require('./api')); router.use('/api', require('./api'));
// no prefix
router.use(require('./auth'));
module.exports = router; module.exports = router;

View file

@ -4,27 +4,19 @@ import {Icon, Button} from 'semantic-ui-react'
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom' import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
import styles from './App.module.scss' import styles from './App.module.scss'
import api from './api'
import { import {
LoginPage,
LogoutPage, LogoutPage,
NotFoundPage, NotFoundPage,
TracksPage, TracksPage,
TrackPage, TrackPage,
HomePage, HomePage,
UploadPage, UploadPage,
RegistrationPage, LoginRedirectPage,
} from './pages' } from 'pages'
import {LoginButton} from 'components'
const App = connect((state) => ({login: state.login}))(function App({login}) { 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 ( return (
<Router> <Router>
<div className={styles.App}> <div className={styles.App}>
@ -68,9 +60,7 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
) : ( ) : (
<> <>
<li> <li>
<Button as={Link} to="/login"> <LoginButton as='a' compact />
Login
</Button>
</li> </li>
</> </>
)} )}
@ -92,11 +82,8 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Route path={`/tracks/:slug`} exact> <Route path={`/tracks/:slug`} exact>
<TrackPage /> <TrackPage />
</Route> </Route>
<Route path="/register" exact> <Route path="/redirect" exact>
<RegistrationPage /> <LoginRedirectPage />
</Route>
<Route path="/login" exact>
<LoginPage />
</Route> </Route>
<Route path="/logout" exact> <Route path="/logout" exact>
<LogoutPage /> <LogoutPage />

View file

@ -1,19 +1,124 @@
import {stringifyParams} from 'query' 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 { class API {
setAuthorizationHeader(authorization) { constructor(store) {
this.authorization = authorization 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 = {}) { async fetch(url, options = {}) {
const accessToken = await this.getValidAccessToken()
const response = await window.fetch('/api' + url, { const response = await window.fetch('/api' + url, {
...options, ...options,
headers: { headers: {
...(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) { if (response.status === 200) {
return await response.json() return await response.json()
} else { } else {
@ -26,15 +131,15 @@ class API {
let headers = {...(options.headers || {})} let headers = {...(options.headers || {})}
if (!(typeof body === 'string' || body instanceof FormData)) { if (!(typeof body === 'string' || body instanceof FormData)) {
body = JSON.stringify(body) body = JSON.stringify(body)
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
return await this.fetch(url, { return await this.fetch(url, {
...options, ...options,
body, body,
method: 'post', method: 'post',
headers headers,
}) })
} }
@ -46,8 +151,18 @@ class API {
async delete(url, options = {}) { async delete(url, options = {}) {
return await this.get(url, {...options, method: 'delete'}) 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 export default api

View file

@ -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 <Button as='a' href={href} {...props}>Login</Button>
}

View file

@ -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 : (
<Form className={className} onSubmit={onSubmit}>
<Form.Field>
<label>e-Mail</label>
<input value={email} onChange={onChangeEmail} name='email' />
</Form.Field>
<Form.Field>
<label>Password</label>
<input type="password" value={password} onChange={onChangePassword} name='password' />
</Form.Field>
<Button type="submit">Submit</Button>
</Form>
)
})
export default LoginForm

View file

@ -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 (
<Form onSubmit={onSubmit}>
<Form.Input label="Username" value={username} onChange={onChangeUsername} name="username" />
<Form.Input label="e-Mail" value={email} onChange={onChangeEmail} name="email" />
<Form.Input label="Password" type="password" value={password} onChange={onChangePassword} name="password" />
<Form.Input
label="Password (repeat)"
type="password"
value={password2}
onChange={onChangePassword2}
name="password2"
error={password2 != null && password !== password2 ? 'Your passwords do not match.' : null}
/>
<Button type="submit">Submit</Button>
</Form>
)
}

View file

@ -1,7 +1,6 @@
export {default as FileDrop} from './FileDrop' export {default as FileDrop} from './FileDrop'
export {default as FormattedDate} from './FormattedDate' 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 Map} from './Map'
export {default as Page} from './Page' export {default as Page} from './Page'
export {default as RegistrationForm} from './RegistrationForm'
export {default as StripMarkdown} from './StripMarkdown' export {default as StripMarkdown} from './StripMarkdown'

9
frontend/src/config.json Normal file
View file

@ -0,0 +1,9 @@
{
"auth": {
"authorizationEndpoint": "http://localhost:3000/authorize",
"tokenEndpoint": "http://localhost:3000/token",
"clientId": "123",
"scope": "*",
"redirectUri": "http://localhost:3001/redirect"
}
}

View file

@ -6,14 +6,8 @@ import './index.css'
import App from './App' import App from './App'
import {Provider} from 'react-redux' import {Provider} from 'react-redux'
import {compose, createStore} from 'redux'
import persistState from 'redux-localstorage'
import rootReducer from './reducers' import store from './store'
const enhancer = compose(persistState(['login']))
const store = createStore(rootReducer, undefined, enhancer)
// TODO: remove // TODO: remove
Settings.defaultLocale = 'de-DE' Settings.defaultLocale = 'de-DE'

View file

@ -1,14 +1,14 @@
import _ from 'lodash' import _ from 'lodash'
import React from 'react' import React from 'react'
import {connect} from 'react-redux' import {Tab, Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
import {useObservable} from 'rxjs-hooks' import {useObservable} from 'rxjs-hooks'
import {of, pipe, from} from 'rxjs' import {of, pipe, from} from 'rxjs'
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
import {fromLonLat} from 'ol/proj'
import {Duration} from 'luxon' import {Duration} from 'luxon'
import api from '../api' import api from '../api'
import {Map, Page, LoginForm} from '../components' import {Map, Page} from '../components'
import {TrackListItem} from './TracksPage' import {TrackListItem} from './TracksPage'
@ -20,8 +20,9 @@ function formatDuration(seconds) {
function WelcomeMap() { function WelcomeMap() {
return ( return (
<Map style={{height: '24rem'}}> <Map style={{height: '60rem', maxHeight: '80vh'}}>
<Map.TileLayer /> <Map.TileLayer />
<Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} />
</Map> </Map>
) )
} }
@ -41,7 +42,7 @@ function Stats() {
<Segment> <Segment>
<Loader active={stats == null} /> <Loader active={stats == null} />
<Statistic.Group widths={4} size="tiny"> <Statistic.Group widths={2} size="mini" >
<Statistic> <Statistic>
<Statistic.Value>{Number(stats?.publicTrackLength / 1000).toFixed(1)}</Statistic.Value> <Statistic.Value>{Number(stats?.publicTrackLength / 1000).toFixed(1)}</Statistic.Value>
<Statistic.Label>km track length</Statistic.Label> <Statistic.Label>km track length</Statistic.Label>
@ -64,19 +65,6 @@ function Stats() {
) )
} }
const LoginState = connect((state) => ({login: state.login}))(function LoginState({login}) {
return login ? (
<>
<Header as="h2">Logged in as {login.username} </Header>
</>
) : (
<>
<Header as="h2">Login</Header>
<LoginForm />
</>
)
})
function MostRecentTrack() { function MostRecentTrack() {
const track: Track | null = useObservable( const track: Track | null = useObservable(
() => () =>
@ -88,8 +76,6 @@ function MostRecentTrack() {
[] []
) )
console.log(track)
return ( return (
<> <>
<h2>Most recent track</h2> <h2>Most recent track</h2>
@ -110,19 +96,15 @@ export default function HomePage() {
<Page> <Page>
<Grid> <Grid>
<Grid.Row> <Grid.Row>
<Grid.Column width={16}> <Grid.Column width={10}>
<WelcomeMap /> <WelcomeMap />
</Grid.Column> </Grid.Column>
</Grid.Row> <Grid.Column width={6}>
<Grid.Row>
<Grid.Column width={10}>
<Stats /> <Stats />
<MostRecentTrack /> <MostRecentTrack />
</Grid.Column> </Grid.Column>
<Grid.Column width={6}>
<LoginState />
</Grid.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
</Page> </Page>
) )

View file

@ -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 ? (
<Redirect to="/" />
) : (
<Page small>
<h2>Login</h2>
<LoginForm />
</Page>
)
})
export default LoginPage

View file

@ -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 <Redirect to="/" />
}
const {error, error_description: errorDescription, code} = searchParams
if (error) {
return (
<Page small>
<Message icon error>
<Icon name="warning sign" />
<Message.Content>
<Message.Header>Login error</Message.Header>
The login server reported: {errorDescription || error}.
</Message.Content>
</Message>
</Page>
)
}
return <ExchangeAuthCode code={code} />
})
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 = (
<Message icon info>
<Icon name="circle notched" loading />
<Message.Content>
<Message.Header>Logging you in</Message.Header>
Hang tight...
</Message.Content>
</Message>
)
} else if (result === true) {
content = <Redirect to="/" />
} else {
const {error, error_description: errorDescription} = result
content = (
<>
<Message icon error>
<Icon name="warning sign" />
<Message.Content>
<Message.Header>Login error</Message.Header>
The login server reported: {errorDescription || error}.
</Message.Content>
</Message>
<pre>{JSON.stringify(result, null, 2)}</pre>
</>
)
}
return <Page small>{content}</Page>
}
export default LoginRedirectPage

View file

@ -2,16 +2,14 @@ import React from 'react'
import {connect} from 'react-redux' import {connect} from 'react-redux'
import {Redirect} from 'react-router-dom' import {Redirect} from 'react-router-dom'
import {logout as logoutAction} from '../reducers/login' import {resetAuth} from 'reducers/auth'
const LogoutPage = connect( const LogoutPage = connect(
(state) => ({loggedIn: Boolean(state.login)}), (state) => ({loggedIn: Boolean(state.login)}),
(dispatch) => ({ {resetAuth}
dispatchLogout: () => dispatch(logoutAction()), )(function LogoutPage({loggedIn, resetAuth}) {
})
)(function LogoutPage({loggedIn, dispatchLogout}) {
React.useEffect(() => { React.useEffect(() => {
dispatchLogout() resetAuth()
}) })
return loggedIn ? null : <Redirect to="/" /> return loggedIn ? null : <Redirect to="/" />

View file

@ -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 ? (
<Redirect to="/" />
) : (
<Page small>
<h2>Register</h2>
<RegistrationForm />
</Page>
)
})
export default RegistrationPage

View file

@ -37,7 +37,6 @@ function TracksPageTabs() {
function TrackList({privateFeed}: {privateFeed: boolean}) { function TrackList({privateFeed}: {privateFeed: boolean}) {
const [page, setPage] = useQueryParam<number>('page', 1, Number) const [page, setPage] = useQueryParam<number>('page', 1, Number)
console.log('page', page)
const pageSize = 10 const pageSize = 10
@ -86,7 +85,7 @@ function TrackList({privateFeed}: {privateFeed: boolean}) {
} }
function maxLength(t, max) { function maxLength(t, max) {
if (t.length > max) { if (t && t.length > max) {
return t.substring(0, max) + ' ...' return t.substring(0, max) + ' ...'
} else { } else {
return t return t
@ -99,7 +98,7 @@ export function TrackListItem({track, privateFeed = false}) {
<Item.Image size="tiny" src={track.author.image} /> <Item.Image size="tiny" src={track.author.image} />
<Item.Content> <Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title} {track.title || 'Unnamed track'}
</Item.Header> </Item.Header>
<Item.Meta> <Item.Meta>
Created by {track.author.username} on {track.createdAt} Created by {track.author.username} on {track.createdAt}

View file

@ -58,13 +58,11 @@ function FileUploadStatus({
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
const onProgress = (e) => { const onProgress = (e) => {
console.log('progress', e)
const progress = (e.loaded || 0) / (e.total || 1) const progress = (e.loaded || 0) / (e.total || 1)
setProgress(progress) setProgress(progress)
} }
const onLoad = (e) => { const onLoad = (e) => {
console.log('loaded', e)
onComplete(id, xhr.response) onComplete(id, xhr.response)
} }
@ -72,17 +70,18 @@ function FileUploadStatus({
xhr.onload = onLoad xhr.onload = onLoad
xhr.upload.onprogress = onProgress xhr.upload.onprogress = onProgress
xhr.open('POST', '/api/tracks') 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() return () => xhr.abort()
}, [file]) }, [file])
return ( return (
<span> <span>
<Loader inline size="mini" active /> <Loader inline size="mini" active /> {progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
{' '}
{progress < 1 ? (progress * 100).toFixed(0) + ' %' : 'Processing...'}
</span> </span>
) )
} }
@ -113,8 +112,6 @@ export default function UploadPage() {
}, [labelRef.current]) }, [labelRef.current])
function onSelectFiles(fileList) { function onSelectFiles(fileList) {
console.log('UPLOAD', fileList)
const newFiles = Array.from(fileList).map((file) => ({ const newFiles = Array.from(fileList).map((file) => ({
id: 'file-' + String(Math.floor(Math.random() * 1000000)), id: 'file-' + String(Math.floor(Math.random() * 1000000)),
file, file,

View file

@ -1,8 +1,7 @@
export {default as HomePage} from './HomePage' export {default as HomePage} from './HomePage'
export {default as LoginPage} from './LoginPage'
export {default as LogoutPage} from './LogoutPage' export {default as LogoutPage} from './LogoutPage'
export {default as NotFoundPage} from './NotFoundPage' 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 TrackPage} from './TrackPage'
export {default as TracksPage} from './TracksPage' export {default as TracksPage} from './TracksPage'
export {default as UploadPage} from './UploadPage' export {default as UploadPage} from './UploadPage'

View file

@ -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
}
}

View file

@ -1,5 +1,6 @@
import {combineReducers} from 'redux' import {combineReducers} from 'redux'
import login from './login' import login from './login'
import auth from './auth'
export default combineReducers({login}) export default combineReducers({login, auth})

View file

@ -1,19 +1,17 @@
const initialState = null const initialState = null
export function login(user) { export function setLogin(user) {
return {type: 'LOGIN.LOGIN', payload: {user}} return {type: 'LOGIN.SET', payload: {user}}
}
export function logout() {
return {type: 'LOGIN.LOGOUT'}
} }
export default function loginReducer(state = initialState, action) { export default function loginReducer(state = initialState, action) {
switch (action.type) { switch (action.type) {
case 'LOGIN.LOGIN': case 'LOGIN.SET':
return action.payload.user return action.payload.user
case 'LOGIN.LOGOUT':
case 'AUTH.RESET': // cross reducer action :)
return null return null
default: default:
return state return state
} }

10
frontend/src/store.js Normal file
View file

@ -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