facebook, twitter and discord login methods

This commit is contained in:
Evert Prants 2017-08-03 15:57:17 +03:00
parent 298f9bf818
commit d178a8ee40
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
13 changed files with 802 additions and 3 deletions

31
package-lock.json generated
View File

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

View File

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

111
scripts/http.js Normal file
View File

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

346
server/api/external.js Normal file
View File

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

View File

@ -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 () {
}
}
}

200
server/routes/api.js Normal file
View File

@ -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 <in-page javascript handeled>
* 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

View File

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

View File

@ -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.')
})
})
}
})

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

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

View File

@ -24,3 +24,4 @@ block body
a#create(href="/register") Create an account
.right
h3 More options
include includes/external.pug

View File

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