change license, add user settings, social account unlink

This commit is contained in:
Evert Prants 2017-08-24 21:36:40 +03:00
parent 772d6aab1e
commit 497ac86980
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
19 changed files with 403 additions and 68 deletions

29
LICENSE
View File

@ -1,16 +1,21 @@
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.

View File

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

View File

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

View File

@ -24,7 +24,7 @@
"authentication"
],
"author": "Icy Network",
"license": "GPL-3.0",
"license": "MIT",
"bugs": {
"url": "https://github.com/IcyNet/IcyNet.eu/issues"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
h3 Hello, #{display_name}!
p This email was sent to you because your password on Icy Network has been changed.
p If you did indeed change the password of this account yourself, you may safely ignore this email.
p
b However, if you did not change your password on Icy Network, contact an Icy Network administrator immediately!
small This email has been sent to you because of an action performed on the IcyNet.eu website.

View File

@ -0,0 +1 @@
|Your Password on Icy Network has been changed!

12
views/404.pug Normal file
View File

@ -0,0 +1,12 @@
extends layout.pug
block title
|Icy Network - 404
block body
.wrapper
.boxcont
.box#error
h1 404
p Page not found
a(href="..") Go back

View File

@ -1,5 +1,8 @@
extends layout.pug
block title
|Icy Network - Legal Notice
block body
.document
.content !{doc}

28
views/password_new.pug Normal file
View File

@ -0,0 +1,28 @@
extends layout.pug
block title
|Icy Network - Change User Password
block body
.wrapper
.boxcont
.box#totpcheck
h1 Change Your Password
if message
if message.error
.message.error
else
.message
span #{message.text}
form#loginForm(method="POST", action="")
input(type="hidden", name="csrf", value=csrf)
if !token
label(for="password_old") Current Password
input(type="password", name="password_old", id="password_old")
else
input(type="hidden", name="token", value=token)
label(for="password") New Password
input(type="password", name="password", id="password")
label(for="password_repeat") Repeat New Password
input(type="password", name="password_repeat", id="password_repeat")
div#repeatcheck(style="display: none")
input(type="submit", value="Change")

48
views/settings.pug Normal file
View File

@ -0,0 +1,48 @@
extends layout.pug
block title
|Icy Network - User Settings
block body
.wrapper
.boxcont
.box#settings
h1 User Settings
.left
if message
if message.error
.message.error
else
.message
span #{message.text}
form#loginForm(method="POST", action="")
input(type="hidden", name="csrf", value=csrf)
label(for="username") Username
input(type="text", name="username", id="username", value=user.username, disabled)
label(for="display_name") Display Name
input(type="text", name="display_name", id="display_name", value=user.display_name)
input(type="submit", value="Save Settings")
.right
h3 Social Media Accounts
include includes/external.pug
if twitter_auth == false
a.option(href="/api/external/twitter/remove") Unlink Twitter
if facebook_auth == false
a.option(href="/api/external/facebook/remove") Unlink Facebook
if discord_auth == false
a.option(href="/api/external/discord/remove") Unlink Discord
h3 Other Options
if password
a.option(href="/user/manage/password")
i.fa.fa-fw.fa-lock
|Change Password
if totp
a.option(href="/user/two-factor/disable")
i.fa.fa-fw.fa-lock
|Disable Two-Factor Authentication
else
a.option(href="/user/two-factor")
i.fa.fa-fw.fa-lock
|Enable Two-Factor Authentication
a.option(href="/user/manage/email")
i.fa.fa-fw.fa-envelope
|Change Email Address