From 75de194f880b1129b8b2e44adf308cd9f0fbbbbc Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sun, 16 Jun 2019 12:28:46 +0300 Subject: [PATCH] Added local users --- migrations/002-local-auth.sql | 30 +++++++ package.json | 4 + public/index.html | 5 ++ public/index.js | 15 ++++ src/server.js | 30 +++++-- src/user.js | 152 ++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 migrations/002-local-auth.sql create mode 100644 src/user.js diff --git a/migrations/002-local-auth.sql b/migrations/002-local-auth.sql new file mode 100644 index 0000000..d9f8016 --- /dev/null +++ b/migrations/002-local-auth.sql @@ -0,0 +1,30 @@ +-- Up + +CREATE TABLE User ( + id INTEGER PRIMARY KEY, + username TEXT, + password TEXT, + email TEXT, + image TEXT, + created TEXT +); + +CREATE TABLE OAuth ( + id INTEGER PRIMARY KEY, + userId INTEGER, + remoteId TEXT, + created TEXT, + CONSTRAINT PE_fk_userId FOREIGN KEY (userId) + REFERENCES User (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +ALTER TABLE Track ADD COLUMN userId INTEGER; +ALTER TABLE Playlist ADD COLUMN userId INTEGER; +ALTER TABLE PlaylistEntry ADD COLUMN userId INTEGER; + +-- Down +DROP TABLE User; +DROP TABLE OAuth; +ALTER TABLE Track DROP COLUMN userId; +ALTER TABLE Playlist DROP COLUMN userId; +ALTER TABLE PlaylistEntry DROP COLUMN userId; diff --git a/package.json b/package.json index ff10302..7aa66b7 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,15 @@ "private": true, "dependencies": { "babel-plugin-transform-runtime": "^6.23.0", + "bcrypt": "^3.0.6", "bluebird": "^3.5.2", + "connect-redis": "^3.4.1", "express": "^4.16.3", "express-async-errors": "^3.0.0", + "express-session": "^1.16.2", "fluent-ffmpeg": "^2.1.2", "fs-extra": "^7.0.0", + "oauth-libre": "^0.9.17", "socket.io": "^2.1.1", "sqlite": "^3.0.2", "sqlite3": "^4.0.6" diff --git a/public/index.html b/public/index.html index 8ae9392..690c26e 100644 --- a/public/index.html +++ b/public/index.html @@ -112,6 +112,11 @@ +
User
+
+ + Log out +
diff --git a/public/index.js b/public/index.js index 7b121f2..dd577cc 100644 --- a/public/index.js +++ b/public/index.js @@ -6,6 +6,7 @@ var playing = document.getElementById('playing') var optdrop = document.getElementById('options-drop') var optmenu = document.getElementById('options') + var loggedin = document.getElementById('logged-in') var menu = document.getElementById('menu') @@ -48,6 +49,9 @@ sortdir: 'asc' } + // User info + var user = {} + window.mobilecheck = function() { var check = false; (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera); @@ -114,6 +118,7 @@ reject(new Error(xmlHttp.status)) } } + xmlHttp.withCredentials = true xmlHttp.open('GET', url, true) xmlHttp.send(null) }) @@ -583,6 +588,16 @@ } }) + function checkUser () { + httpGet('/user/info').then(function (data) { + user = data + loggedin.innerHTML = 'Logged in as ' + data.username + }, function (e) { + window.location.href = '/user/login' + }) + } + + checkUser() loadOptions() showTracks(1) handleHash(window.location.hash) diff --git a/src/server.js b/src/server.js index b1ec486..7e51145 100644 --- a/src/server.js +++ b/src/server.js @@ -2,9 +2,12 @@ import path from 'path' import sqlite from 'sqlite' import Promise from 'bluebird' import express from 'express' +import session from 'express-session' +import redis from 'connect-redis' import http from 'http' import https from 'https' import ffmpeg from 'fluent-ffmpeg' +import { user, userMiddleware } from './user' import lfmda from './lastfm' @@ -35,7 +38,20 @@ const router = express.Router() const sortfields = ['id', 'track', 'artist', 'title', 'album', 'year', 'file'] const srchcategories = ['title', 'artist', 'album'] -router.get('/tracks', async (req, res) => { +let SessionStore = redis(session) +app.use(session({ + key: values.session_key || 'Session', + secret: values.session_secret || 'ch4ng3 m3!', + store: new SessionStore(values.redis || { port: 6379 }), + resave: false, + saveUninitialized: true, + cookie: { + secure: process.env.NODE_ENV !== 'development', + maxAge: 2678400000 // 1 month + } +})) + +router.get('/tracks', userMiddleware, async (req, res) => { let page = parseInt(req.query.page) || 1 if (isNaN(page)) { page = 1 @@ -70,7 +86,7 @@ router.get('/tracks', async (req, res) => { }) }) -router.get('/tracks/search', async (req, res) => { +router.get('/tracks/search', userMiddleware, async (req, res) => { let query = req.query.q let streamable = (req.query.streamable === '1') let qr = '' @@ -150,7 +166,7 @@ router.get('/tracks/search', async (req, res) => { }) }) -router.get('/track/:id', async (req, res, next) => { +router.get('/track/:id', userMiddleware, async (req, res, next) => { let id = req.params.id let db = await dbPromise @@ -165,14 +181,14 @@ router.get('/track/:id', async (req, res, next) => { res.jsonp(track) }) -router.get('/playlists', async (req, res, next) => { +router.get('/playlists', userMiddleware, async (req, res, next) => { let db = await dbPromise let playlists = await db.all('SELECT * FROM Playlist') res.jsonp(playlists) }) -router.get('/playlist/:id', async (req, res, next) => { +router.get('/playlist/:id', userMiddleware, async (req, res, next) => { let id = req.params.id let db = await dbPromise let playlist = await db.get('SELECT title FROM Playlist WHERE id = ?', id) @@ -185,7 +201,7 @@ router.get('/playlist/:id', async (req, res, next) => { res.jsonp(playlist) }) -router.get('/serve/by-id/:id', async (req, res, next) => { +router.get('/serve/by-id/:id', userMiddleware, async (req, res, next) => { let id = req.params.id let dl = (req.query.dl === '1') let db = await dbPromise @@ -217,6 +233,8 @@ router.use((err, req, res, next) => { res.status(404).jsonp({error: 404}) }) +app.use('/user', user(dbPromise, values.oauth)) + app.use('/api', router) app.use('/file/track', express.static(path.resolve(values.directory))) app.use('/', express.static(path.join(process.cwd(), 'public'))) diff --git a/src/user.js b/src/user.js new file mode 100644 index 0000000..d8310fb --- /dev/null +++ b/src/user.js @@ -0,0 +1,152 @@ +import express from 'express' +import bcrypt from 'bcrypt' +import { PromiseOAuth2 } from 'oauth-libre' +import crypto from 'crypto' + +const router = express.Router() + +async function userInfoPublic (db, id) { + let u = await db.get('SELECT id, username, image FROM User WHERE id = ?', id) + if (!u) return {} + return { + id: u.id, + username: u.username, + image: u.image + } +} + +export async function userInfo (db, id) { + return db.get('SELECT * FROM User WHERE id = ?', id) +} + +export function userMiddleware (req, res, next) { + if (!req.session || !req.session.user) return res.status(401).jsonp({ error: 'Unauthorized' }) + next() +} + +export function user (dbPromise, oauth) { + router.get('/info', userMiddleware, async (req, res) => { + res.jsonp(await userInfoPublic(await dbPromise, req.session.user)) + }) + + router.get('/info/:id', userMiddleware, async (req, res) => { + if (isNaN(parseInt(req.params.id))) throw new Error('Invalid user ID!') + res.jsonp(await userInfoPublic(await dbPromise, parseInt(req.params.id))) + }) + + if (!oauth) return router + + let oauth2 = new PromiseOAuth2(oauth.clientId, oauth.clientSecret, oauth.baseUrl, oauth.authorizePath, oauth.tokenPath) + + router.get('/login/oauth/_redirect', async (req, res) => { + let code = req.query.code + let state = req.query.state + if (!code || !state) throw new Error('Something went wrong!') + if (!req.session.oauthState || req.session.oauthState !== state) throw new Error('Possible request forgery detected! Try again.') + + delete req.session.oauthState + + let tokens + try { + tokens = await oauth2.getOAuthAccessToken(code, { grant_type: 'authorization_code', redirect_uri: oauth.redirectUri }) + } catch (e) { + throw new Error('No authorization!') + } + + let accessToken = tokens[2].access_token + + // Get user information on remote + let userInfo + try { + userInfo = await oauth2.get(oauth.baseUrl + oauth.userPath, accessToken) + userInfo = JSON.parse(userInfo) + } catch (e) { + userInfo = null + } + + if (!userInfo) throw new Error('Couldn\'t get user information!') + + // Let's see if there's a link for this user already.. + let db = await dbPromise + let userLocal = await db.get('SELECT * FROM OAuth WHERE remoteId = ?', userInfo.id) + + // User and link both exist + if (userLocal) { + if (req.session.user) throw new Error('You are already logged in!') + + req.session.user = userLocal.userId + return res.redirect('/') + } + + // If we're logged in, create a link + if (req.session.user) { + await db.run('INSERT INTO OAuth (userId,remoteId,created) VALUES (?,?,?)', req.session.user, userInfo.id, new Date()) + return res.redirect('/') + } + + // Create a new user and log in + let newU = await db.get('INSERT INTO User (username,email,image,created) VALUES (?,?,?,?)', userInfo.username, userInfo.email, userInfo.image, new Date()) + await db.run('INSERT INTO OAuth (userId,remoteId,created) VALUES (?,?,?)', newU.id, userInfo.id, new Date()) + req.session.user = newU.id + res.redirect('/') + }) + + router.get('/login/oauth', async (req, res) => { + let state = req.session.oauthState || crypto.randomBytes(10).toString('hex') + req.session.oauthState = state + return res.redirect(oauth2.getAuthorizeUrl({ + 'redirect_uri': oauth.redirectUri, + 'scope': oauth.scope, + 'response_type': 'code', + 'state': state + })) + }) + + router.use('/login', async (req, res, next) => { + if (req.session && req.session.user) return res.redirect('/') + let header = req.get('authorization') || '' + let token = header.split(/\s+/).pop() || '' + let auth = Buffer.from(token, 'base64').toString() + let parts = auth.split(/:/) + let username = parts[0] + let password = parts[1] + + let message = oauth != null ? 'Enter \'oauth\' to log in remotely.' : 'Log in' + req.message = message + + if ((!username || !password) && (username !== 'oauth' && oauth)) { + return next() + } + + if ((username === 'oauth' || username === 'oa') && oauth) { + return res.redirect('/user/login/oauth') + } + + let db = await dbPromise + let user = await db.get('SELECT * FROM User WHERE username = ?', username) + if (!user) return next() + + if (!user.password && oauth) { + return res.redirect('/user/login/oauth') + } + + // Compare passwords + let ures = await bcrypt.compare(password, user.password) + if (!ures) return next() + + // Set login success + req.message = '' + req.session.user = user.id + res.redirect('/') + }, function (req, res, next) { + if (!req.message) return next() + res.status(401).set('WWW-Authenticate', 'Basic realm="' + req.message + '", charset="UTF-8"').end() + }) + + router.use('/logout', function (req, res) { + delete req.session.user + res.redirect('/') + }) + + return router +}