Verified Commit 497ac869 authored by Evert Prants's avatar Evert Prants
Browse files

change license, add user settings, social account unlink

parent 772d6aab
Icy Network Primary Web Application - Authentication and News
Copyright (C) 2017 Icy Network - Evert Prants <evert@lunasqu.ee>
MIT License
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Copyright (c) 2017 Evert Prants
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
......@@ -5,17 +5,17 @@
<p>Icy Network may collect and save some information about our users.</p>
<h3>Basic Account Information</h3>
<p>Icy Network uses your username to identify you in our system. Please only enter Usernames which you are comfortable with other people seeing.</p>
<p>Your email addess is used to send you updates and important information about our Services and the status of your account. You may unsubscribe from update emails at any time. We will never provide, sell, or leak your email address to Third-Party sites nor send you malicious or spam emails. Please always verify that the sender is an IcyNet.eu email address before clicking on any links on emails claiming to be from us. We will never ask you for your personal information or passwords via email.</p>
<p>Your email addess is used to send you updates and important information about our Services and the status of your account. You may unsubscribe from update emails at any time. We will never provide, sell, or leak your email address to Third-Party sites nor send you malicious or spam emails. We will never ask you for your personal information or passwords via email.</p>
<h3>Additional Information</h3>
<p>We use your IP Address to track our visitor traffic in order for us to better allocate resources to the Services which are used the most. Your IP address is not available publicly and is only used internally within our systems.</p>
<h2>Cookies</h2>
<p>Like many websites, we use Cookies in our Services. A Cookie is a small file saved onto your computer by your web browser which contains a bit of information about your presence on Icy Network websites. Icy Network uses temporary session cookies in order to save log-in sessions, which means that you won't have to log in every time you visit our website.</p>
<p>Like many websites, we use Cookies in our Services. A Cookie is a small file saved onto your computer by your web browser which contains a bit of information about your presence on Icy Network websites. Icy Network uses temporary session cookies, which are used to save log-in sessions, which means that you won't have to log in every time you visit our website.</p>
<h2>External Logins</h2>
<p>By logging in from external websites, you agree to these Policies.</p>
<h3>Twitter</h3>
<p>By logging in with Twitter, we will only ask you for your Screen Name, Public Profile Name and Email Address for the above-mentioned purposes. We will never Tweet on your behalf nor see your Tweets.</p>
<p>By logging in with Twitter, we will only ask you for your Screen Name, Public Profile Name and Email Address for the above-mentioned purposes. We are unable to Tweet on your behalf nor see your Tweets.</p>
<h3>Facebook</h3>
<p>By logging in with Facebook, we will only ask you for your Public Profile and Email Address. We will use your Name as your Display Name, which can be changed from your Account Settings after logging in. Your profile picture may be downloaded onto our servers and used as your network-wide profile image. You may change your profile picture from your Account Settings at any time. Your Email Address will only be used to send you updates, which you can opt-out of. We can not and will not post on your behalf.</p>
<p>By logging in with Facebook, we will only ask you for your Public Profile and Email Address. We will use your Name as your Display Name, which can be changed from your Account Settings after logging in. Your profile picture may be downloaded onto our servers and used as your network-wide profile image. You may change your profile picture from your Account Settings at any time. Your Email Address will only be used to send you updates, which you can opt-out of. We can not: post on your behalf, see your friends list nor see your posts.</p>
<h3>Discord</h3>
<p>By logging in with Discord, we will only ask you for your Username and Email Address for the above-mentioned purposes. We do not ask you for any other information and we will not know which Discord Servers you're on.</p>
</div>
......@@ -14,20 +14,4 @@
<h4>Credits</h4>
<p>Google Play and the Google Play logo are trademarks of Google Inc.</p>
<p>Apple and the Apple logo are trademarks of Apple Inc., registered in the U.S. and other countries. App Store is a service mark of Apple Inc., registered in the U.S. and other countries.</p>
<h2>Icy Network Software License</h2>
<a href="https://github.com/IcyNet/IcyNet.eu" target="_blank">Icy Network Primary Web Application - Authentication and News</a><br>
Copyright (C) 2017 Icy Network - Evert Prants &lt;evert@lunasqu.ee&gt;<br>
<br>
This program is free software: you can redistribute it and/or modify<br>
it under the terms of the GNU General Public License as published by<br>
the Free Software Foundation, either version 3 of the License, or<br>
(at your option) any later version.<br>
<br>
This program is distributed in the hope that it will be useful,<br>
but WITHOUT ANY WARRANTY; without even the implied warranty of<br>
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the<br>
GNU General Public License for more details.<br>
<br>
You should have received a copy of the GNU General Public License<br>
along with this program. If not, see &lt;<a href="http://www.gnu.org/licenses/" target="_blank">http://www.gnu.org/licenses/</a>&gt;.<br>
</div>
......@@ -24,7 +24,7 @@
"authentication"
],
"author": "Icy Network",
"license": "GPL-3.0",
"license": "MIT",
"bugs": {
"url": "https://github.com/IcyNet/IcyNet.eu/issues"
},
......
......@@ -35,9 +35,12 @@ module.exports = function () {
this.logProcess = (pid, msg) => {
if (msg.indexOf('warn') === 0) {
msg = msg.substring(5)
console.warn('[%s] %s', pid, msg)
} else if (msg.indexOf('error') === 0) {
msg = msg.substring(6)
console.error('[%s] %s', pid, msg)
} else {
console.log('[%s] %s', pid, msg)
}
console.log('[%s] %s', pid, msg)
}
}
......@@ -35,6 +35,21 @@ const API = {
await await models.External.query().insert(data)
return true
},
remove: async (user, service) => {
user = await UAPI.User.ensureObject(user, ['password'])
let userExterns = await models.External.query().orderBy('created_at', 'asc').where('user_id', user.id)
if (!userExterns.length) {
return false
}
// Do not remove the service the user signed up with
if (userExterns[0] && (user.password === '' || user.password === null) && userExterns[0].service === service) {
return false
}
return models.External.query().delete().where('user_id', user.id).andWhere('service', service)
}
},
Facebook: {
......@@ -97,12 +112,13 @@ const API = {
avatar_file: profilepic,
activated: 1,
ip_address: data.ip_address,
created_at: new Date()
created_at: new Date(),
updated_at: new Date()
}
// Check if the username is already taken
if (await UAPI.User.get(udataLimited.username) != null) {
udataLimited.username = 'FB' + UAPI.Hash(4)
udataLimited.username = udataLimited.username + UAPI.Hash(4)
}
// Check if the email Facebook gave us is already registered, if so,
......@@ -209,12 +225,13 @@ const API = {
avatar_file: profilepic,
activated: 1,
ip_address: ipAddress,
updated_at: new Date(),
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)
udataLimited.username = udataLimited.username + UAPI.Hash(4)
}
// Check if the email Twitter gave us is already registered, if so,
......@@ -320,6 +337,7 @@ const API = {
avatar_file: profilepic,
activated: 1,
ip_address: ipAddress,
updated_at: new Date(),
created_at: new Date()
}
......
......@@ -86,9 +86,42 @@ const API = {
return null
},
socialStatus: async function (user) {
user = await API.User.ensureObject(user, ['password'])
if (!user) return null
let external = await models.External.query().orderBy('created_at', 'asc').where('user_id', user.id)
let enabled = {}
for (let i in external) {
let ext = external[i]
enabled[ext.service] = true
}
let accountSourceIsExternal = user.password === null || user.password === ''
let obj = {
enabled: enabled,
password: !accountSourceIsExternal
}
if (accountSourceIsExternal) {
obj.source = external[0].service
}
return obj
},
update: async function (user, data) {
user = await API.User.ensureObject(user)
if (!user) return {error: 'No such user.'}
data = Object.assign({
updated_at: new Date()
}, data)
return models.User.query().patchAndFetchById(user.id, data)
},
Login: {
password: async function (user, password) {
user = await API.User.ensureObject(user)
user = await API.User.ensureObject(user, ['password'])
if (!user.password) return false
return bcryptTask({task: 'compare', password: password, hash: user.password})
},
......@@ -193,6 +226,7 @@ const API = {
let email = config.email && config.email.enabled
let data = Object.assign(regdata, {
created_at: new Date(),
updated_at: new Date(),
activated: email ? 0 : 1
})
......
......@@ -62,12 +62,7 @@ function createSession (req, user) {
// 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)
res.jsonp({error: error, redirect: redirect})
}
/** FACEBOOK LOGIN
......@@ -94,6 +89,17 @@ router.post('/external/facebook/callback', wrap(async (req, res) => {
JsonData(req, res, null, uri)
}))
router.get('/external/facebook/remove', wrap(async (req, res) => {
if (!req.session.user) return res.redirect('/login')
let done = await APIExtern.Common.remove(req.session.user, 'fb')
if (!done) {
req.flash('message', {error: true, text: 'Unable to unlink social media account'})
}
res.redirect('/user/manage')
}))
/** TWITTER LOGIN
* OAuth1.0a flows
* Tokens in configs
......@@ -147,6 +153,18 @@ router.get('/external/twitter/callback', wrap(async (req, res) => {
res.redirect(uri)
}))
router.get('/external/twitter/remove', wrap(async (req, res) => {
if (!req.session.user) return res.redirect('/login')
let done = await APIExtern.Common.remove(req.session.user, 'twitter')
if (!done) {
req.flash('message', {error: true, text: 'Unable to unlink social media account'})
}
res.redirect('/user/manage')
}))
/** DISCORD LOGIN
* OAuth2 flows
* Tokens in configs
......@@ -205,6 +223,18 @@ router.get('/external/discord/callback', wrap(async (req, res) => {
res.redirect(uri)
}))
router.get('/external/discord/remove', wrap(async (req, res) => {
if (!req.session.user) return res.redirect('/login')
let done = await APIExtern.Common.remove(req.session.user, 'discord')
if (!done) {
req.flash('message', {error: true, text: 'Unable to unlink social media account'})
}
res.redirect('/user/manage')
}))
/* ========
* NEWS
* ========
......@@ -247,4 +277,9 @@ router.get('/news', wrap(async (req, res) => {
res.jsonp(articles)
}))
// 404
router.use((req, res) => {
res.status(404).jsonp({error: 'Not found'})
})
module.exports = router
......@@ -8,6 +8,7 @@ import wrap from '../../scripts/asyncRoute'
import http from '../../scripts/http'
import API from '../api'
import News from '../api/news'
import emailer from '../api/emailer'
import apiRouter from './api'
import oauthRouter from './oauth2'
......@@ -21,6 +22,17 @@ let accountLimiter = new RateLimit({
message: 'Whoa, slow down there, buddy! You just hit our rate limits. Try again in 1 hour.'
})
function setSession (req, user) {
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
}
}
router.use(wrap(async (req, res, next) => {
let messages = req.flash('message')
if (!messages || !messages.length) {
......@@ -29,6 +41,18 @@ router.use(wrap(async (req, res, next) => {
messages = messages[0]
}
// Update user session every 30 minutes
if (req.session.user) {
if (!req.session.user.session_refresh) {
req.session.user.session_refresh = Date.now() + 1800000
}
if (req.session.user.session_refresh < Date.now()) {
let udata = await API.User.get(req.session.user.id)
setSession(req, udata)
}
}
res.locals.message = messages
next()
}))
......@@ -117,6 +141,49 @@ router.get('/login/verify', (req, res) => {
res.render('totp-check')
})
router.get('/user/manage', wrap(async (req, res) => {
if (!req.session.user) return res.redirect('/login')
let totpEnabled = false
let socialStatus = await API.User.socialStatus(req.session.user)
if (socialStatus.password) {
totpEnabled = await API.User.Login.totpTokenRequired(req.session.user)
}
if (config.twitter && config.twitter.api) {
if (!socialStatus.enabled.twitter) {
res.locals.twitter_auth = true
} else if (!socialStatus.source && socialStatus.source !== 'twitter') {
res.locals.twitter_auth = false
}
}
if (config.discord && config.discord.api) {
if (!socialStatus.enabled.discord) {
res.locals.discord_auth = true
} else if (!socialStatus.source && socialStatus.source !== 'discord') {
res.locals.discord_auth = false
}
}
if (config.facebook && config.facebook.client) {
if (!socialStatus.enabled.fb) {
res.locals.facebook_auth = config.facebook.client
} else if (!socialStatus.source && socialStatus.source !== 'fb') {
res.locals.facebook_auth = false
}
}
res.render('settings', {totp: totpEnabled, password: socialStatus.password})
}))
router.get('/user/manage/password', wrap(async (req, res) => {
if (!req.session.user) return res.redirect('/login')
res.render('password_new')
}))
/*
=================
POST HANDLING
......@@ -125,10 +192,9 @@ router.get('/login/verify', (req, res) => {
function formError (req, res, error, redirect) {
// Security measures: never store any passwords in any session
if (req.body.password) {
delete req.body.password
if (req.body.password_repeat) {
delete req.body.password_repeat
for (let key in req.body) {
if (key.indexOf('password') !== -1) {
delete req.body[key]
}
}
......@@ -239,14 +305,7 @@ router.post('/login', wrap(async (req, res) => {
// TODO: Ban checks
// Set session
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
}
setSession(req, user)
let uri = '/'
if (req.session.redirectUri) {
......@@ -340,6 +399,91 @@ router.post('/register', accountLimiter, wrap(async (req, res) => {
res.redirect('/login')
}))
router.post('/user/manage', wrap(async (req, res, next) => {
if (!req.session.user) return next()
if (req.body.csrf !== req.session.csrf) {
return formError(req, res, 'Invalid session! Try reloading the page.')
}
if (!req.body.display_name) {
return formError(req, res, 'Display Name cannot be blank.')
}
let displayName = req.body.display_name
if (!displayName || !displayName.match(/^([^\\`]{3,32})$/i)) {
return formError(req, res, 'Invalid display name!')
}
// No change
if (displayName === req.session.user.display_name) {
return res.redirect('/user/manage')
}
let success = await API.User.update(req.session.user, {
display_name: displayName
})
if (success.error) {
return formError(req, res, success.error)
}
req.session.user.display_name = displayName
req.flash('message', {error: false, text: 'Settings changed successfully. Please note that it may take time to update on other websites and devices.'})
res.redirect('/user/manage')
}))
router.post('/user/manage/password', wrap(async (req, res, next) => {
if (!req.session.user) return next()
if (req.body.csrf !== req.session.csrf) {
return formError(req, res, 'Invalid session! Try reloading the page.')
}
if (!req.body.password_old) {
return formError(req, res, 'Please enter your current password.')
}
let passwordMatch = await API.User.Login.password(req.session.user, req.body.password_old)
if (!passwordMatch) {
return formError(req, res, 'The password you provided is incorrect.')
}
let password = req.body.password
if (!password || password.length < 8 || password.length > 32) {
return formError(req, res, 'Invalid password! Keep it between 8 and 32 characters!')
}
let passwordAgain = req.body.password_repeat
if (!passwordAgain || password !== passwordAgain) {
return formError(req, res, 'The passwords do not match!')
}
password = await API.User.Register.hashPassword(password)
let success = await API.User.update(req.session.user, {
password: password
})
if (success.error) {
return formError(req, res, success.error)
}
let user = req.session.user
console.warn('[SECURITY AUDIT] User \'%s\' password has been changed from %s', user.username, req.realIP)
if (config.email && config.email.enabled) {
await emailer.pushMail('password_alert', user.email, {
display_name: user.display_name,
ip: req.realIP
})
}
req.flash('message', {error: false, text: 'Password changed successfully.'})
return res.redirect('/user/manage')
}))
/*
=============
DOCUMENTS
......@@ -347,10 +491,10 @@ router.post('/register', accountLimiter, wrap(async (req, res) => {
*/
const docsDir = path.join(__dirname, '../../documents')
router.get('/docs/:name', (req, res) => {
router.get('/docs/:name', (req, res, next) => {
let doc = path.join(docsDir, req.params.name + '.html')
if (!fs.existsSync(docsDir) || !fs.existsSync(doc)) {
return res.status(404).end()
return next()
}
doc = fs.readFileSync(doc, {encoding: 'utf8'})
......@@ -410,6 +554,10 @@ router.get('/activate/:token', wrap(async (req, res) => {
router.use('/api', apiRouter)
router.use((req, res) => {
res.status(404).render('404')
})
router.use((err, req, res, next) => {
console.error(err)
next()
......
......@@ -49,12 +49,12 @@ router.get('/user', oauth.bearer, wrap(async (req, res) => {
}
// Include Email
if (accessToken.scope.indexOf('email') != -1) {
if (accessToken.scope.indexOf('email') !== -1) {
udata.email = user.email
}
// Include privilege number
if (accessToken.scope.indexOf('privilege') != -1) {
if (accessToken.scope.indexOf('privilege') !== -1) {
udata.privilege = user.nw_privilege
}
......
......@@ -109,6 +109,7 @@ $(document).ready(function () {
dataType: 'json',
data: response,
success: function (data) {
console.log(data)
if (data.error) {
$('.message').addClass('error')
$('.message span').text(data.error)
......
......@@ -231,6 +231,14 @@ input:not([type="submit"])
.boxcont
.box
padding: 20px
margin: auto
margin-top: 5%
background-color: #fff
box-shadow: 5px 5px 15px #868686
border: 1px solid #ddd
h1:first-child, h2:first-child, h3:first-child
margin-top: 0
.left, .right
display: inline-block
width: 50%
......@@ -241,16 +249,13 @@ input:not([type="submit"])
width: 46%
&#login
max-width: 700px
&#settings
max-width: 700px
min-height: 380px
&#totpcheck
max-width: 400px
padding: 20px
margin: auto
margin-top: 5%
background-color: #fff
box-shadow: 5px 5px 15px #868686
border: 1px solid #ddd
h1, h2, h3
margin-top: 0
&#error
width: 200px
.pgn
display: inline-block
......@@ -423,6 +428,9 @@ input.invalid
border-color: #ffffff
margin: 20px 0
.option
display: block
@media all and (max-width: 800px)
.navigator
padding: 0 10px
......
......@@ -3,3 +3,4 @@ p Before you can log in, you must activate your account.
p Click on or copy the following link into your URL bar in order to activate your Icy Network account
a.activate(href=domain + "/activate/" + activation_token, target="_blank", rel="nofollow")= domain + "/activate/" + activation_token
p If you did not register for an account on Icy Network, please ignore this email.
small This email has been sent to you because of an action performed on the IcyNet.eu website.