From d178a8ee40822770f113c719c57d6f22ba9fd0ea Mon Sep 17 00:00:00 2001 From: Evert Date: Thu, 3 Aug 2017 15:57:17 +0300 Subject: [PATCH] facebook, twitter and discord login methods --- package-lock.json | 31 ++ package.json | 1 + scripts/http.js | 111 +++++++ server/api/external.js | 346 +++++++++++++++++++++ server/api/index.js | 11 + server/routes/api.js | 200 ++++++++++++ server/routes/index.js | 28 +- src/script/main.js | 29 ++ src/style/main.styl | 18 ++ static/image/sign-in-with-twitter-link.png | Bin 0 -> 2683 bytes views/includes/external.pug | 25 ++ views/login.pug | 1 + views/totp.pug | 4 + 13 files changed, 802 insertions(+), 3 deletions(-) create mode 100644 scripts/http.js create mode 100644 server/api/external.js create mode 100644 server/routes/api.js create mode 100644 static/image/sign-in-with-twitter-link.png create mode 100644 views/includes/external.pug diff --git a/package-lock.json b/package-lock.json index ba0d0f6..4f2d726 100644 --- a/package-lock.json +++ b/package-lock.json @@ -419,6 +419,12 @@ "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw==", "dev": true }, + "basic-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", + "integrity": "sha1-RSIe5Cn37h5QNb4/UVM/HN/SmIQ=", + "optional": true + }, "bcryptjs": { "version": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" @@ -2874,6 +2880,19 @@ } } }, + "morgan": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.8.2.tgz", + "integrity": "sha1-eErHc05KRTqcbm6GgKkyknXItoc=", + "optional": true, + "requires": { + "basic-auth": "1.1.0", + "debug": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz", + "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz", + "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "on-headers": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz" + } + }, "ms": { "version": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" @@ -2922,6 +2941,18 @@ "version": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, + "oauth-libre": { + "version": "0.9.17", + "resolved": "https://registry.npmjs.org/oauth-libre/-/oauth-libre-0.9.17.tgz", + "integrity": "sha1-5Jg39iFj4QVj49BRNd82DcYYpxI=", + "requires": { + "bluebird": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz", + "body-parser": "https://registry.npmjs.org/body-parser/-/body-parser-1.17.2.tgz", + "express": "https://registry.npmjs.org/express/-/express-4.15.3.tgz", + "express-session": "https://registry.npmjs.org/express-session/-/express-session-1.15.3.tgz", + "morgan": "1.8.2" + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 2f6c3c3..205a5c3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "mysql": "^2.13.0", "nodemailer": "^4.0.1", "notp": "^2.0.3", + "oauth-libre": "^0.9.17", "objection": "^0.8.4", "pug": "^2.0.0-rc.3", "stylus": "^0.54.5", diff --git a/scripts/http.js b/scripts/http.js new file mode 100644 index 0000000..3c4c4fb --- /dev/null +++ b/scripts/http.js @@ -0,0 +1,111 @@ +import url from 'url' +import qs from 'querystring' + +function HTTP_GET (link, headers = {}, lback) { + if(lback && lback >= 4) throw new Error('infinite loop!') // Prevent infinite loop requests + let parsed = url.parse(link) + let opts = { + host: parsed.hostname, + port: parsed.port, + path: parsed.path, + headers: { + 'User-Agent': 'Squeebot/Commons-2.0.0', + 'Accept': '*/*', + 'Accept-Language': 'en-GB,enq=0.5' + } + } + + if(headers) { + opts.headers = Object.assign(opts.headers, headers) + } + + let reqTimeOut + + let httpModule = parsed.protocol === 'https:' ? require('https') : require('http') + return new Promise((resolve, reject) => { + let req = httpModule.get(opts, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + if(!lback) { + lback = 1 + } else { + lback += 1 + } + + return HTTP_GET(res.headers.location, headers, lback).then(resolve, reject) + } + + let data = '' + + reqTimeOut = setTimeout(() => { + req.abort() + data = null + reject(new Error('Request took too long!')) + }, 5000) + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + clearTimeout(reqTimeOut) + + resolve(data) + }) + }).on('error', (e) => { + reject(new Error(e.message)) + }) + + req.setTimeout(10000) + }) +} + +function HTTP_POST (link, headers = {}, data) { + let parsed = url.parse(link) + let post_data = qs.stringify(data) + + let opts = { + host: parsed.host, + port: parsed.port, + path: parsed.path, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(post_data), + 'User-Agent': 'Squeebot/Commons-2.0.0' + } + } + + if (headers) { + opts.headers = Object.assign(opts.headers, headers) + } + + if (opts.headers['Content-Type'] === 'application/json') { + post_data = JSON.stringify(data) + } + + return new Promise((resolve, reject) => { + let httpModule = parsed.protocol === 'https:' ? require('https') : require('http') + let req = httpModule.request(opts, (res) => { + res.setEncoding('utf8') + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + resolve(data) + }) + }).on('error', (e) => { + reject(new Error(e)) + }) + + req.write(post_data) + req.end() + }) +} + +module.exports = { + GET: HTTP_GET, + POST: HTTP_POST +} diff --git a/server/api/external.js b/server/api/external.js new file mode 100644 index 0000000..1595830 --- /dev/null +++ b/server/api/external.js @@ -0,0 +1,346 @@ +import config from '../../scripts/load-config' +import database from '../../scripts/load-database' +import http from '../../scripts/http' +import models from './models' +import UAPI from './index' +import qs from 'querystring' +import oauth from 'oauth-libre' + +let twitterApp +let discordApp + +const API = { + Common: { + getExternal: async (service, identifier) => { + let extr = await models.External.query().where('service', service).andWhere('identifier', identifier) + if (!extr || !extr.length) return null + extr = extr[0] + extr.user = null + + if (extr.user_id !== null) { + let user = await UAPI.User.get(extr.user_id) + if (user) { + extr.user = user + } + } + + return extr + }, + new: async (service, identifier, user) => { + let data = { + user_id: user.id, + service: service, + identifier: identifier, + created_at: new Date() + } + + await await models.External.query().insert(data) + return true + } + }, + Facebook: { + callback: async (user, data) => { + if (!data.authResponse || data.status !== 'connected') { + return {error: 'No Authorization'} + } + + let uid = data.authResponse.userID + if (!uid) { + return {error: 'No Authorization'} + } + + // Get facebook user information in order to create a new user or verify + let fbdata + let intel = { + access_token: data.authResponse.accessToken, + fields: 'name,email,picture,short_name' + } + + try { + fbdata = await http.GET('https://graph.facebook.com/v2.10/' + uid + '?' + qs.stringify(intel)) + fbdata = JSON.parse(fbdata) + } catch (e) { + return {error: 'Could not get user information', errorObject: e} + } + + if (fbdata.error) { + return {error: fbdata.error.message} + } + + let exists = await API.Common.getExternal('fb', uid) + + if (user) { + if (exists) return {error: null, user: user} + + await API.Common.new('fb', uid, user) + return {error: null, user: user} + } + + // Callback succeeded with user id and the external table exists, we log in the user + if (exists) { + return {error: null, user: exists.user} + } + + // Determine profile picture + let profilepic = '' + if (fbdata.picture) { + if (fbdata.picture.is_silhouette == false && fbdata.picture.url) { + // TODO: Download the profile image and save it locally + profilepic = fbdata.picture.url + } + } + + // Create a new user + let udataLimited = { + username: fbdata.short_name || 'FB' + UAPI.Hash(4), + display_name: fbdata.name, + email: fbdata.email || '', + avatar_file: profilepic, + activated: 1, + ip_address: data.ip_address, + created_at: new Date() + } + + // Check if the username is already taken + if (await UAPI.User.get(udataLimited.username) != null) { + udataLimited.username = 'FB' + UAPI.Hash(4) + } + + // Check if the email Facebook gave us is already registered, if so, + // associate an external node with the user bearing the email + if (udataLimited.email && udataLimited.email !== '') { + let getByEmail = await UAPI.User.get(udataLimited.email) + if (getByEmail) { + await API.Common.new('fb', getByEmail.id, getByEmail) + return {error: null, user: getByEmail} + } + } + + // Create a new user based on the information we got from Facebook + let newUser = await models.User.query().insert(udataLimited) + await API.Common.new('fb', uid, newUser) + + return {error: null, user: newUser} + } + }, + Twitter: { + oauthApp: function () { + if (!twitterApp) { + let redirectUri = config.server.domain + '/api/external/twitter/callback' + twitterApp = new oauth.PromiseOAuth( + 'https://api.twitter.com/oauth/request_token', + 'https://api.twitter.com/oauth/access_token', + config.twitter.api, + config.twitter.api_secret, + '1.0A', + redirectUri, + 'HMAC-SHA1' + ) + } + }, + getRequestToken: async function () { + if (!twitterApp) API.Twitter.oauthApp() + let tokens + + try { + tokens = await twitterApp.getOAuthRequestToken() + } catch (e) { + console.error(e) + return {error: 'No tokens returned'} + } + + if (tokens[2].oauth_callback_confirmed !== "true") return {error: 'No tokens returned.'} + + return {error: null, token: tokens[0], token_secret: tokens[1]} + }, + getAccessTokens: async function (token, secret, verifier) { + if (!twitterApp) API.Twitter.oauthApp() + let tokens + + try { + tokens = await twitterApp.getOAuthAccessToken(token, secret, verifier) + } catch (e) { + console.error(e) + return {error: 'No tokens returned'} + } + + if (!tokens || !tokens.length) return {error: 'No tokens returned'} + + return {error: null, access_token: tokens[0], access_token_secret: tokens[1]} + }, + callback: async function (user, accessTokens, ipAddress) { + if (!twitterApp) API.Twitter.oauthApp() + let twdata + try { + let resp = await twitterApp.get('https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true', accessTokens.access_token, + accessTokens.access_token_secret) + twdata = JSON.parse(resp[0]) + } catch (e) { + console.error(e) + return {error: 'Failed to verify user credentials.'} + } + + let uid = twdata.id_str + let exists = await API.Common.getExternal('twitter', uid) + + if (user) { + if (exists) return {error: null, user: user} + + await API.Common.new('twitter', uid, user) + return {error: null, user: user} + } + + // Callback succeeded with user id and the external table exists, we log in the user + if (exists) { + return {error: null, user: exists.user} + } + + // Determine profile picture + let profilepic = '' + if (twdata.profile_image_url_https) { + // TODO: Download the profile image and save it locally + profilepic = twdata.profile_image_url_https + } + + // Create a new user + let udataLimited = { + username: twdata.screen_name, + display_name: twdata.name, + email: twdata.email || '', + avatar_file: profilepic, + activated: 1, + ip_address: ipAddress, + created_at: new Date() + } + + // Check if the username is already taken + if (await UAPI.User.get(udataLimited.username) != null) { + udataLimited.username = 'Tw' + UAPI.Hash(4) + } + + // Check if the email Twitter gave us is already registered, if so, + // associate an external node with the user bearing the email + if (udataLimited.email && udataLimited.email !== '') { + let getByEmail = await UAPI.User.get(udataLimited.email) + if (getByEmail) { + await API.Common.new('twitter', getByEmail.id, getByEmail) + return {error: null, user: getByEmail} + } + } + + // Create a new user based on the information we got from Twitter + let newUser = await models.User.query().insert(udataLimited) + await API.Common.new('twitter', uid, newUser) + + return {error: null, user: newUser} + } + }, + Discord: { + oauth2App: function() { + if (discordApp) return + discordApp = new oauth.PromiseOAuth2( + config.discord.api, + config.discord.api_secret, + 'https://discordapp.com/api/', + 'oauth2/authorize', + 'oauth2/token' + ) + + discordApp.useAuthorizationHeaderforGET(true) + }, + getAuthorizeURL: function () { + if (!discordApp) API.Discord.oauth2App() + let state = UAPI.Hash(6) + let redirectUri = config.server.domain + '/api/external/discord/callback' + + const params = { + 'client_id': config.discord.api, + 'redirect_uri': redirectUri, + 'scope': 'identify email', + 'response_type': 'code', + 'state': state + } + + let url = discordApp.getAuthorizeUrl(params) + + return {error: null, state: state, url: url} + }, + getAccessToken: async function (code) { + if (!discordApp) API.Discord.oauth2App() + + let redirectUri = config.server.domain + '/api/external/discord/callback' + let tokens + try { + tokens = await discordApp.getOAuthAccessToken(code, {grant_type: 'authorization_code', redirect_uri: redirectUri}) + } catch (e) { + console.error(e) + return {error: 'No Authorization'} + } + + if (!tokens.length) return {error: 'No Tokens'} + tokens = tokens[2] + + return {error: null, accessToken: tokens.access_token} + }, + callback: async function (user, accessToken, ipAddress) { + if (!discordApp) API.Discord.oauth2App() + + let ddata + try { + let resp = await discordApp.get('https://discordapp.com/api/users/@me', accessToken) + ddata = JSON.parse(resp) + } catch (e) { + console.error(e) + return {error: 'Could not get user information'} + } + + let uid = ddata.id + let exists = await API.Common.getExternal('discord', uid) + + if (user) { + if (exists) return {error: null, user: user} + + await API.Common.new('discord', uid, user) + return {error: null, user: user} + } + + // Callback succeeded with user id and the external table exists, we log in the user + if (exists) { + return {error: null, user: exists.user} + } + + // Determine profile picture + let profilepic = '' + // TODO: Download the profile image and save it locally + + // Create a new user + let udataLimited = { + username: 'D' + ddata.discriminator, + display_name: ddata.username, + email: ddata.email || '', + avatar_file: profilepic, + activated: 1, + ip_address: ipAddress, + created_at: new Date() + } + + // Check if the email Discord gave us is already registered, if so, + // associate an external node with the user bearing the email + if (udataLimited.email && udataLimited.email !== '') { + let getByEmail = await UAPI.User.get(udataLimited.email) + if (getByEmail) { + await API.Common.new('discord', uid, getByEmail) + return {error: null, user: getByEmail} + } + } + + // Create a new user based on the information we got from Discord + let newUser = await models.User.query().insert(udataLimited) + await API.Common.new('discord', uid, newUser) + + return {error: null, user: newUser} + } + } +} + +module.exports = API diff --git a/server/api/index.js b/server/api/index.js index 7e96272..3d6ecbd 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -154,6 +154,11 @@ const API = { }, totpAquire: async function (user) { user = await API.User.ensureObject(user) + + // Do not allow totp for users who have registered using an external service + if (!user.password || user.password === '') return null + + // Get existing tokens for the user and delete them if found let getToken = await models.TotpToken.query().where('user_id', user.id) if (getToken && getToken.length) { await models.TotpToken.query().delete().where('user_id', user.id) @@ -163,6 +168,7 @@ const API = { user_id: user.id, token: API.Hash(16), recovery_code: API.Hash(8), + activated: 0, created_at: new Date() } @@ -215,6 +221,11 @@ const API = { return {error: null, user: user} } } + }, + External: { + serviceCallback: async function () { + + } } } diff --git a/server/routes/api.js b/server/routes/api.js new file mode 100644 index 0000000..88fbbf2 --- /dev/null +++ b/server/routes/api.js @@ -0,0 +1,200 @@ +import express from 'express' +import parseurl from 'parseurl' +import config from '../../scripts/load-config' +import wrap from '../../scripts/asyncRoute' +import API from '../api' +import APIExtern from '../api/external' + +let router = express.Router() + +// Turn things like 'key1[key2]': 'value' into key1: {key2: 'value'} because facebook +function objectAssembler (insane) { + let object = {} + for (let key in insane) { + let value = insane[key] + if (key.indexOf('[') !== -1) { + let subKey = key.match(/^([\w]+)\[(\w+)\]$/) + if (subKey[1] && subKey[2]) { + if (!object[subKey[1]]) { + object[subKey[1]] = {} + } + + object[subKey[1]][subKey[2]] = value + } + } else { + object[key] = value + } + } + return object +} + +// Create a session and return a redirect uri if provided +function createSession (req, user) { + let uri = '/' + req.session.user = { + id: user.id, + username: user.username, + display_name: user.display_name, + email: user.email, + avatar_file: user.avatar_file, + session_refresh: Date.now() + 1800000 // 30 minutes + } + + if (req.session.redirectUri) { + uri = req.session.redirectUri + delete req.session.redirectUri + } + + if (req.query.redirect) { + uri = req.query.redirect + } + + return uri +} + +// Either give JSON or make a redirect +function JsonData (req, res, error, redirect='/') { + if (req.headers['content-type'] == 'application/json') { + return res.jsonp({error: error, redirect: redirect}) + } + + req.flash('message', {error: true, text: error}) + res.redirect(redirect) +} + +/** FACEBOOK LOGIN + * Ajax POST only + * No tokens saved in configs, everything works out-of-the-box + */ +router.post('/external/facebook/callback', wrap(async (req, res) => { + let sane = objectAssembler(req.body) + sane.ip_address = req.realIP + + let response = await APIExtern.Facebook.callback(req.session.user, sane) + + if (response.error) { + return JsonData(req, res, response.error) + } + + // Create session + let uri = '/' + if (!req.session.user) { + let user = response.user + uri = createSession(req, user) + } + + JsonData(req, res, null, uri) +})) + +/** TWITTER LOGIN + * OAuth1.0a flows + * Tokens in configs + */ +router.get('/external/twitter/login', wrap(async (req, res) => { + if (!config.twitter || !config.twitter.api) return res.redirect('/') + let tokens = await APIExtern.Twitter.getRequestToken() + + if (tokens.error) { + return res.jsonp({error: tokens.error}) + } + + req.session.twitter_auth = tokens + if (req.query.returnTo) { + req.session.twitter_auth.returnTo = req.query.returnTo + } + + res.redirect('https://twitter.com/oauth/authenticate?oauth_token=' + tokens.token) +})) + +router.get('/external/twitter/callback', wrap(async (req, res) => { + if (!config.twitter || !config.twitter.api) return res.redirect('/login') + if (!req.session.twitter_auth) return res.redirect('/login') + let ta = req.session.twitter_auth + let uri = ta.returnTo || '/login' + + if (!req.query.oauth_verifier) { + req.flash('message', {error: true, text: 'Couldn\'t get a verifier'}) + return res.redirect(uri) + } + + let accessTokens = await APIExtern.Twitter.getAccessTokens(ta.token, ta.token_secret, req.query.oauth_verifier) + delete req.session.twitter_auth + + if (accessTokens.error) { + req.flash('message', {error: true, text: 'Couldn\'t get an access token'}) + return res.redirect(uri) + } + + let response = await APIExtern.Twitter.callback(req.session.user, accessTokens, req.realIP) + if (response.error) { + req.flash('message', {error: true, text: response.error}) + return res.redirect(uri) + } + + if (!req.session.user) { + let user = response.user + uri = createSession(req, user) + } + + res.redirect(uri) +})) + +/** DISCORD LOGIN + * OAuth2 flows + * Tokens in configs + */ +router.get('/external/discord/login', wrap(async (req, res) => { + if (!config.discord || !config.discord.api) return res.redirect('/') + + let infos = APIExtern.Discord.getAuthorizeURL() + + req.session.discord_auth = { + returnTo: req.query.returnTo || '/login', + state: infos.state + } + + res.redirect(infos.url) +})) + +router.get('/external/discord/callback', wrap(async (req, res) => { + if (!config.discord || !config.discord.api) return res.redirect('/login') + if (!req.session.discord_auth) return res.redirect('/login') + + let code = req.query.code + let state = req.query.state + let da = req.session.discord_auth + let uri = da.returnTo || '/login' + + if (!code) { + req.flash('message', {error: true, text: 'No authorization.'}) + return res.redirect(uri) + } + + if (!state || state !== da.state) { + req.flash('message', {error: true, text: 'Request got intercepted, try again.'}) + return res.redirect(uri) + } + + delete req.session.discord_auth + + let accessToken = await APIExtern.Discord.getAccessToken(code) + if (accessToken.error) { + req.flash('message', {error: true, text: accessToken.error}) + return res.redirect(uri) + } + + let response = await APIExtern.Discord.callback(req.session.user, accessToken.accessToken, req.realIP) + if (response.error) { + req.flash('message', {error: true, text: response.error}) + return res.redirect(uri) + } + + if (!req.session.user) { + let user = response.user + uri = createSession(req, user) + } + + res.redirect(uri) +})) + +module.exports = router diff --git a/server/routes/index.js b/server/routes/index.js index 99b21ca..6d8bceb 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -4,6 +4,8 @@ import config from '../../scripts/load-config' import wrap from '../../scripts/asyncRoute' import API from '../api' +import apiRouter from './api' + let router = express.Router() router.use(wrap(async (req, res, next) => { @@ -38,6 +40,14 @@ router.get('/login', wrap(async (req, res) => { return res.redirect(uri) } + if (config.twitter && config.twitter.api) { + res.locals.twitter_auth = true + } + + if (config.facebook && config.facebook.client) { + res.locals.facebook_auth = config.facebook.client + } + res.render('login') })) @@ -52,6 +62,13 @@ router.get('/register', wrap(async (req, res) => { } res.locals.formkeep = dataSave + if (config.twitter && config.twitter.api) { + res.locals.twitter_auth = true + } + + if (config.facebook && config.facebook.client) { + res.locals.facebook_auth = config.facebook.client + } res.render('register') })) @@ -59,9 +76,10 @@ router.get('/register', wrap(async (req, res) => { router.get('/user/two-factor', wrap(async (req, res) => { if (!req.session.user) return res.redirect('/login') let twoFaEnabled = await API.User.Login.totpTokenRequired(req.session.user) - if (twoFaEnabled) return res.redirect('/') + let newToken = await API.User.Login.totpAquire(req.session.user) + if (!newToken) return res.redirect('/') res.locals.uri = newToken res.render('totp') @@ -149,7 +167,8 @@ router.post('/login/verify', wrap(async (req, res) => { username: user.username, display_name: user.display_name, email: user.email, - avatar_file: user.avatar_file + avatar_file: user.avatar_file, + session_refresh: Date.now() + 1800000 // 30 minutes } let uri = '/' @@ -197,7 +216,8 @@ router.post('/login', wrap(async (req, res) => { username: user.username, display_name: user.display_name, email: user.email, - avatar_file: user.avatar_file + avatar_file: user.avatar_file, + session_refresh: Date.now() + 1800000 // 30 minutes } let uri = '/' @@ -300,6 +320,8 @@ router.get('/activate/:token', wrap(async (req, res) => { res.redirect('/login') })) +router.use('/api', apiRouter) + router.use((err, req, res, next) => { console.error(err) next() diff --git a/src/script/main.js b/src/script/main.js index 48f154c..27de1f1 100644 --- a/src/script/main.js +++ b/src/script/main.js @@ -34,4 +34,33 @@ $(document).ready(function () { scrollTop: dest - $('.navigator').innerHeight() }, 1000, 'swing') }) + + window.checkLoginState = function () { + FB.getLoginStatus(function(response) { + console.log(response) + $.ajax({ + type: 'post', + url: '/api/external/facebook/callback', + dataType: 'json', + data: response, + success: (data) => { + if (data.error) { + console.log(data) + $('.message').addClass('error') + $('.message span').text(data.error) + return + } + + if (data.redirect) { + return window.location.href = data.redirect + } + + window.location.reload() + } + }).fail(function() { + $('.message').addClass('error') + $('.message span').text('An error occured.') + }) + }) + } }) diff --git a/src/style/main.styl b/src/style/main.styl index 7510d33..e7aff6b 100644 --- a/src/style/main.styl +++ b/src/style/main.styl @@ -140,6 +140,24 @@ input[type="submit"] h1, h2, h3 margin-top: 0 +.dlbtn + display: block + img + width: 200px + &.apple + img + width: 175px + padding: 0 12px + +.twitterLogin + display: inline-block + padding: 10px + width: 215px + margin: 5px 0 + background-color: #fff + border: 1px solid #ddd + border-radius: 5px + @media all and (max-width: 800px) .navigator padding: 0 10px diff --git a/static/image/sign-in-with-twitter-link.png b/static/image/sign-in-with-twitter-link.png new file mode 100644 index 0000000000000000000000000000000000000000..7dd94c76e4cf75b94b9eb74ac40c3084befe5910 GIT binary patch literal 2683 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 literal 0 HcmV?d00001 diff --git a/views/includes/external.pug b/views/includes/external.pug new file mode 100644 index 0000000..a8683f0 --- /dev/null +++ b/views/includes/external.pug @@ -0,0 +1,25 @@ +.external-login + if facebook_auth + div#fb-root + script. + window.fbAsyncInit = function() { + FB.init({ + appId : '#{facebook_auth}', + cookie : true, + xfbml : true, + version : 'v2.8' + }); + FB.AppEvents.logPageView(); + }; + + (function(d, s, id) { + var js, fjs = d.getElementsByTagName(s)[0]; + if (d.getElementById(id)) return; + js = d.createElement(s); js.id = id; + js.src = "//connect.facebook.net/en_US/sdk.js#xfbml=1&version=v2.10&appId=1124948240960869"; + fjs.parentNode.insertBefore(js, fjs); + }(document, 'script', 'facebook-jssdk')); + fb:login-button(scope="public_profile,email", onlogin="checkLoginState();" data-max-rows="1", data-size="large", data-button-type="login_with", data-show-faces="false", data-auto-logout-link="false", data-use-continue-as="false") + if twitter_auth + a.twitterLogin(href="/api/external/twitter/login") + img(src="/static/image/sign-in-with-twitter-link.png") diff --git a/views/login.pug b/views/login.pug index 66ad54e..704db1b 100644 --- a/views/login.pug +++ b/views/login.pug @@ -24,3 +24,4 @@ block body a#create(href="/register") Create an account .right h3 More options + include includes/external.pug diff --git a/views/totp.pug b/views/totp.pug index 0da3277..12b04d8 100644 --- a/views/totp.pug +++ b/views/totp.pug @@ -28,3 +28,7 @@ block body li You will now be asked for a code every time you log in h3 Authenticator app p We recommend using Google Authenticator + a.dlbtn(href='https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1', target="_blank") + img(alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png') + a.dlbtn.apple(href='https://itunes.apple.com/us/app/google-authenticator/id388497605', target="_blank") + img(alt='Download on the App Store' src='https://devimages-cdn.apple.com/app-store/marketing/guidelines/images/badge-download-on-the-app-store.svg')