Oauth code flow in API and frontend
This commit is contained in:
parent
254b262a72
commit
1e0544802f
0
api/src/config/oauth2orize.js
Normal file
0
api/src/config/oauth2orize.js
Normal file
|
@ -8,15 +8,19 @@ const { User, AccessToken, RefreshToken } = require('../models');
|
||||||
|
|
||||||
const secret = require('../config').secret;
|
const secret = require('../config').secret;
|
||||||
|
|
||||||
passport.use(
|
// used to serialize the user for the session
|
||||||
'usernameAndPassword',
|
passport.serializeUser(function (user, done) {
|
||||||
new LocalStrategy(
|
done(null, user._id);
|
||||||
{
|
});
|
||||||
usernameField: 'user[email]',
|
|
||||||
passwordField: 'user[password]',
|
// used to deserialize the user
|
||||||
session: false,
|
passport.deserializeUser(function (id, done) {
|
||||||
},
|
User.findById(id, function (err, user) {
|
||||||
async function (email, password, done) {
|
done(err, user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginWithPassword(email, password, done) {
|
||||||
try {
|
try {
|
||||||
const user = await User.findOne({ email: email });
|
const user = await User.findOne({ email: email });
|
||||||
if (!user || !user.validPassword(password)) {
|
if (!user || !user.validPassword(password)) {
|
||||||
|
@ -27,7 +31,29 @@ passport.use(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
done(err);
|
done(err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passport.use(
|
||||||
|
'usernameAndPassword',
|
||||||
|
new LocalStrategy(
|
||||||
|
{
|
||||||
|
usernameField: 'user[email]',
|
||||||
|
passwordField: 'user[password]',
|
||||||
|
session: false,
|
||||||
},
|
},
|
||||||
|
loginWithPassword,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
passport.use(
|
||||||
|
'usernameAndPasswordSession',
|
||||||
|
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),
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
33
api/src/models/AuthorizationCode.js
Normal file
33
api/src/models/AuthorizationCode.js
Normal 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
20
api/src/models/Client.js
Normal 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;
|
|
@ -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,
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
function returnError(res, error, errorDescription = undefined, status = 400) {
|
||||||
|
return res
|
||||||
|
.status(status)
|
||||||
|
.json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwtOptional = jwt({
|
function redirectWithParams(res, redirectUri, params) {
|
||||||
secret: secret,
|
const targetUrl = new URL(redirectUri);
|
||||||
userProperty: 'authInfo',
|
for (const [key, value] of Object.entries(params)) {
|
||||||
credentialsRequired: false,
|
targetUrl.searchParams.append(key, value);
|
||||||
getToken: getTokenFromHeader,
|
}
|
||||||
algorithms: ['HS256'],
|
return res.redirect(targetUrl.toString());
|
||||||
});
|
}
|
||||||
|
|
||||||
|
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/);
|
||||||
|
|
||||||
|
function isValidScope(scope) {
|
||||||
|
return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
passport.authenticate('usernameAndPasswordSession'),
|
||||||
|
wrapRoute((req, res, next) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserIdMiddleware(req, res, next) {
|
|
||||||
try {
|
try {
|
||||||
const authorization = req.headers.authorization;
|
const {
|
||||||
const [tokenType, token] = (authorization && authorization.split(' ')) || [];
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: responseType,
|
||||||
|
scope = '*', // fallback to "all" scope
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
if (tokenType === 'Token' || tokenType === 'Bearer') {
|
// 1. Find our client and check if it exists
|
||||||
|
if (!clientId) {
|
||||||
// only parse the token as jwt if it looks like one, otherwise we get an error
|
return returnError(res, 'invalid_request', 'client_id parameter required');
|
||||||
return jwtOptional(req, res, next);
|
|
||||||
|
|
||||||
} else if (tokenType === 'OBSUserId') {
|
|
||||||
req.authInfo = { id: token.trim() };
|
|
||||||
next();
|
|
||||||
req.authInfo = null;
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
next(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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -34,7 +139,7 @@ class API {
|
||||||
...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
|
||||||
|
|
10
frontend/src/components/LoginButton.js
Normal file
10
frontend/src/components/LoginButton.js
Normal 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>
|
||||||
|
}
|
|
@ -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
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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
9
frontend/src/config.json
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"authorizationEndpoint": "http://localhost:3000/authorize",
|
||||||
|
"tokenEndpoint": "http://localhost:3000/token",
|
||||||
|
"clientId": "123",
|
||||||
|
"scope": "*",
|
||||||
|
"redirectUri": "http://localhost:3001/redirect"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
|
97
frontend/src/pages/LoginRedirectPage.tsx
Normal file
97
frontend/src/pages/LoginRedirectPage.tsx
Normal 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
|
|
@ -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="/" />
|
||||||
|
|
|
@ -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
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
|
||||||
|
api.getValidAccessToken().then((accessToken) => {
|
||||||
|
xhr.setRequestHeader('Authorization', accessToken)
|
||||||
xhr.send(formData)
|
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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
30
frontend/src/reducers/auth.js
Normal file
30
frontend/src/reducers/auth.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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})
|
||||||
|
|
|
@ -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
10
frontend/src/store.js
Normal 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
|
Loading…
Reference in a new issue