diff --git a/README.md b/README.md index 7f42f86..80efdf7 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ Environment variables (will overwrite other server configs) | HMD_GOOGLE_CLIENTID | no example | Google API client id | | HMD_GOOGLE_CLIENTSECRET | no example | Google API client secret | | HMD_IMGUR_CLIENTID | no example | Imgur API client id | +| HMD_EMAIL | `true` or `false` | set to allow email register and signin | | HMD_IMAGE_UPLOAD_TYPE | `imgur`, `s3` or `filesystem` | Where to upload image. For S3, see our [S3 Image Upload Guide](docs/guides/s3-image-upload.md) | | HMD_S3_ACCESS_KEY_ID | no example | AWS access key id | | HMD_S3_SECRET_ACCESS_KEY | no example | AWS secret key | @@ -171,6 +172,7 @@ Server settings `config.json` | heartbeatinterval | `5000` | socket.io heartbeat interval | | heartbeattimeout | `10000` | socket.io heartbeat timeout | | documentmaxlength | `100000` | note max length | +| email | `true` or `false` | set to allow email register and signin | | imageUploadType | `imgur`(default), `s3` or `filesystem` | Where to upload image | s3 | `{ "accessKeyId": "YOUR_S3_ACCESS_KEY_ID", "secretAccessKey": "YOUR_S3_ACCESS_KEY", "region": "YOUR_S3_REGION", "bucket": "YOUR_S3_BUCKET_NAME" }` | When `imageUploadType` be setted to `s3`, you would also need to setup this key, check our [S3 Image Upload Guide](docs/guides/s3-image-upload.md) | diff --git a/app.js b/app.js index da4278a..91d8145 100644 --- a/app.js +++ b/app.js @@ -17,6 +17,8 @@ var morgan = require('morgan'); var passportSocketIo = require("passport.socketio"); var helmet = require('helmet'); var i18n = require('i18n'); +var flash = require('connect-flash'); +var validator = require('validator'); //core var config = require("./lib/config.js"); @@ -145,6 +147,8 @@ app.use(function (req, res, next) { } }); +app.use(flash()); + //passport app.use(passport.initialize()); app.use(passport.session()); @@ -362,6 +366,47 @@ if (config.google) { failureRedirect: config.serverurl + '/' })); } +// email auth +if (config.email) { + app.post('/register', urlencodedParser, function (req, res, next) { + if (!req.body.email || !req.body.password) return response.errorBadRequest(res); + if (!validator.isEmail(req.body.email)) return response.errorBadRequest(res); + models.User.findOrCreate({ + where: { + email: req.body.email + }, + defaults: { + password: req.body.password + } + }).spread(function (user, created) { + if (user) { + if (created) { + if (config.debug) logger.info('user registered: ' + user.id); + req.flash('info', "You've successfully registered, please signin."); + } else { + if (config.debug) logger.info('user found: ' + user.id); + req.flash('error', "This email has been used, please try another one."); + } + return res.redirect(config.serverurl + '/'); + } + req.flash('error', "Failed to register your account, please try again."); + return res.redirect(config.serverurl + '/'); + }).catch(function (err) { + logger.error('auth callback failed: ' + err); + return response.errorInternalError(res); + }); + }); + app.post('/login', urlencodedParser, function (req, res, next) { + if (!req.body.email || !req.body.password) return response.errorBadRequest(res); + if (!validator.isEmail(req.body.email)) return response.errorBadRequest(res); + setReturnToFromReferer(req); + passport.authenticate('local', { + successReturnToOrRedirect: config.serverurl + '/', + failureRedirect: config.serverurl + '/', + failureFlash: 'Invalid email or password.' + })(req, res, next); + }); +} //logout app.get('/logout', function (req, res) { if (config.debug && req.isAuthenticated()) @@ -389,7 +434,7 @@ app.get('/me', function (req, res) { }).then(function (user) { if (!user) return response.errorNotFound(res); - var profile = models.User.parseProfile(user.profile); + var profile = models.User.getProfile(user); res.send({ status: 'ok', id: req.user.id, diff --git a/lib/auth.js b/lib/auth.js index 76a962f..f167ced 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -7,6 +7,8 @@ var GithubStrategy = require('passport-github').Strategy; var GitlabStrategy = require('passport-gitlab2').Strategy; var DropboxStrategy = require('passport-dropbox-oauth2').Strategy; var GoogleStrategy = require('passport-google-oauth20').Strategy; +var LocalStrategy = require('passport-local').Strategy; +var validator = require('validator'); //core var config = require('./config.js'); @@ -35,12 +37,10 @@ function callback(accessToken, refreshToken, profile, done) { if (user.accessToken != accessToken) { user.accessToken = accessToken; needSave = true; - } if (user.refreshToken != refreshToken) { user.refreshToken = refreshToken; needSave = true; - } if (needSave) { user.save().then(function () { @@ -57,7 +57,7 @@ function callback(accessToken, refreshToken, profile, done) { }).catch(function (err) { logger.error('auth callback failed: ' + err); return done(err, null); - }) + }); } //facebook @@ -109,4 +109,25 @@ if (config.google) { clientSecret: config.google.clientSecret, callbackURL: config.serverurl + '/auth/google/callback' }, callback)); +} +// email +if (config.email) { + passport.use(new LocalStrategy({ + usernameField: 'email' + }, + function(email, password, done) { + if (!validator.isEmail(email)) return done(null, false); + models.User.findOne({ + where: { + email: email + } + }).then(function (user) { + if (!user) return done(null, false); + if (!user.verifyPassword(password)) return done(null, false); + return done(null, user); + }).catch(function (err) { + logger.error(err); + return done(err); + }); + })); } \ No newline at end of file diff --git a/lib/config.js b/lib/config.js index a906dfa..669fcaa 100644 --- a/lib/config.js +++ b/lib/config.js @@ -94,6 +94,7 @@ var google = (process.env.HMD_GOOGLE_CLIENTID && process.env.HMD_GOOGLE_CLIENTSE clientSecret: process.env.HMD_GOOGLE_CLIENTSECRET } : config.google || false; var imgur = process.env.HMD_IMGUR_CLIENTID || config.imgur || false; +var email = process.env.HMD_EMAIL || config.email || false; function getserverurl() { var url = ''; @@ -151,6 +152,7 @@ module.exports = { dropbox: dropbox, google: google, imgur: imgur, + email: email, imageUploadType: imageUploadType, s3: s3, s3bucket: s3bucket diff --git a/lib/migrations/20161201050312-support-email-signin.js b/lib/migrations/20161201050312-support-email-signin.js new file mode 100644 index 0000000..bdea7c8 --- /dev/null +++ b/lib/migrations/20161201050312-support-email-signin.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.addColumn('Users', 'email', Sequelize.TEXT); + queryInterface.addColumn('Users', 'password', Sequelize.TEXT); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.removeColumn('Users', 'email', Sequelize.TEXT); + queryInterface.removeColumn('Users', 'password', Sequelize.TEXT); + } +}; diff --git a/lib/models/user.js b/lib/models/user.js index 7272f68..aaf344d 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -3,6 +3,7 @@ // external modules var md5 = require("blueimp-md5"); var Sequelize = require("sequelize"); +var scrypt = require('scrypt'); // core var logger = require("../logger.js"); @@ -29,8 +30,30 @@ module.exports = function (sequelize, DataTypes) { }, refreshToken: { type: DataTypes.STRING + }, + email: { + type: Sequelize.TEXT, + validate: { + isEmail: true + } + }, + password: { + type: Sequelize.TEXT, + set: function(value) { + var hash = scrypt.kdfSync(value, scrypt.paramsSync(0.1)).toString("hex"); + this.setDataValue('password', hash); + } } }, { + instanceMethods: { + verifyPassword: function(attempt) { + if (scrypt.verifyKdfSync(new Buffer(this.password, "hex"), attempt)) { + return this; + } else { + return false; + } + } + }, classMethods: { associate: function (models) { User.hasMany(models.Note, { @@ -42,6 +65,9 @@ module.exports = function (sequelize, DataTypes) { constraints: false }); }, + getProfile: function (user) { + return user.profile ? User.parseProfile(user.profile) : (user.email ? User.parseProfileByEmail(user.email) : null); + }, parseProfile: function (profile) { try { var profile = JSON.parse(profile); @@ -81,6 +107,13 @@ module.exports = function (sequelize, DataTypes) { break; } return photo; + }, + parseProfileByEmail: function (email) { + var photoUrl = 'https://www.gravatar.com/avatar/' + md5(email); + return { + name: email.substring(0, email.lastIndexOf("@")), + photo: photoUrl += '?s=96' + }; } } }); diff --git a/lib/realtime.js b/lib/realtime.js index b50e05b..73f831f 100644 --- a/lib/realtime.js +++ b/lib/realtime.js @@ -131,7 +131,7 @@ function updateNote(note, callback) { } }).then(function (user) { if (!user) return callback(null, null); - note.lastchangeuserprofile = models.User.parseProfile(user.profile); + note.lastchangeuserprofile = models.User.getProfile(user); return finishUpdateNote(note, _note, callback); }).catch(function (err) { logger.error(err); @@ -455,10 +455,10 @@ function startConnection(socket) { return failConnection(404, 'note not found', socket); } var owner = note.ownerId; - var ownerprofile = note.owner ? models.User.parseProfile(note.owner.profile) : null; + var ownerprofile = note.owner ? models.User.getProfile(note.owner) : null; var lastchangeuser = note.lastchangeuserId; - var lastchangeuserprofile = note.lastchangeuser ? models.User.parseProfile(note.lastchangeuser.profile) : null; + var lastchangeuserprofile = note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null; var body = LZString.decompressFromBase64(note.content); var createtime = note.createdAt; @@ -468,7 +468,7 @@ function startConnection(socket) { var authors = {}; for (var i = 0; i < note.authors.length; i++) { var author = note.authors[i]; - var profile = models.User.parseProfile(author.user.profile); + var profile = models.User.getProfile(author.user); authors[author.userId] = { userid: author.userId, color: author.color, @@ -598,7 +598,7 @@ function buildUserOutData(user) { function updateUserData(socket, user) { //retrieve user data from passport if (socket.request.user && socket.request.user.logged_in) { - var profile = models.User.parseProfile(socket.request.user.profile); + var profile = models.User.getProfile(socket.request.user); user.photo = profile.photo; user.name = profile.name; user.userid = socket.request.user.id; diff --git a/lib/response.js b/lib/response.js index 0004f82..aae3985 100755 --- a/lib/response.js +++ b/lib/response.js @@ -66,7 +66,10 @@ function showIndex(req, res, next) { gitlab: config.gitlab, dropbox: config.dropbox, google: config.google, - signin: req.isAuthenticated() + email: config.email, + signin: req.isAuthenticated(), + infoMessage: req.flash('info'), + errorMessage: req.flash('error') }); } @@ -94,7 +97,8 @@ function responseHackMD(res, note) { github: config.github, gitlab: config.gitlab, dropbox: config.dropbox, - google: config.google + google: config.google, + email: config.email }); } @@ -202,9 +206,9 @@ function showPublishNote(req, res, next) { body: body, useCDN: config.usecdn, owner: note.owner ? note.owner.id : null, - ownerprofile: note.owner ? models.User.parseProfile(note.owner.profile) : null, + ownerprofile: note.owner ? models.User.getProfile(note.owner) : null, lastchangeuser: note.lastchangeuser ? note.lastchangeuser.id : null, - lastchangeuserprofile: note.lastchangeuser ? models.User.parseProfile(note.lastchangeuser.profile) : null, + lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null, robots: meta.robots || false, //default allow robots GA: meta.GA, disqus: meta.disqus @@ -591,9 +595,9 @@ function showPublishSlide(req, res, next) { meta: JSON.stringify(obj.meta || {}), useCDN: config.usecdn, owner: note.owner ? note.owner.id : null, - ownerprofile: note.owner ? models.User.parseProfile(note.owner.profile) : null, + ownerprofile: note.owner ? models.User.getProfile(note.owner) : null, lastchangeuser: note.lastchangeuser ? note.lastchangeuser.id : null, - lastchangeuserprofile: note.lastchangeuser ? models.User.parseProfile(note.lastchangeuser.profile) : null, + lastchangeuserprofile: note.lastchangeuser ? models.User.getProfile(note.lastchangeuser) : null, robots: meta.robots || false, //default allow robots GA: meta.GA, disqus: meta.disqus diff --git a/package.json b/package.json index 267e65a..6fbd1d8 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "blueimp-md5": "^2.4.0", "body-parser": "^1.15.2", "bootstrap": "^3.3.7", + "bootstrap-validator": "^0.11.5", "chance": "^1.0.4", "cheerio": "^0.22.0", "codemirror": "git+https://github.com/hackmdio/CodeMirror.git", "compression": "^1.6.2", + "connect-flash": "^0.1.1", "connect-session-sequelize": "^3.2.0", "cookie": "0.3.1", "cookie-parser": "1.4.3", @@ -84,6 +86,7 @@ "passport-github": "^1.1.0", "passport-gitlab2": "^2.2.0", "passport-google-oauth20": "^1.0.0", + "passport-local": "^1.0.0", "passport-twitter": "^1.0.4", "passport.socketio": "^3.6.2", "pdfobject": "^2.0.201604172", @@ -95,6 +98,7 @@ "request": "^2.75.0", "reveal.js": "^3.3.0", "sequelize": "^3.24.3", + "scrypt": "^6.0.3", "select2": "^3.5.2-browserify", "sequelize-cli": "^2.4.0", "sharp": "^0.16.2", @@ -109,6 +113,7 @@ "to-markdown": "^3.0.1", "toobusy-js": "^0.5.1", "uws": "^0.11.0", + "validator": "^6.2.0", "velocity-animate": "^1.3.1", "visibilityjs": "^1.2.4", "viz.js": "^1.3.0", diff --git a/public/css/cover.css b/public/css/cover.css index f918682..dcf7321 100644 --- a/public/css/cover.css +++ b/public/css/cover.css @@ -305,6 +305,9 @@ input { text-align: left; color: black; } +.modal-body { + color: black; +} .btn-file { position: relative; diff --git a/public/views/foot.ejs b/public/views/foot.ejs index 4a54ac4..829d4d3 100644 --- a/public/views/foot.ejs +++ b/public/views/foot.ejs @@ -19,6 +19,7 @@ + <%- include build/index-scripts %> <% } else { %> diff --git a/public/views/index.ejs b/public/views/index.ejs index 729987c..789565a 100644 --- a/public/views/index.ejs +++ b/public/views/index.ejs @@ -51,6 +51,12 @@

<%= __('Best way to write and share your knowledge in markdown.') %>

+ <% if (infoMessage && infoMessage.length > 0) { %> +
<%= infoMessage %>
+ <% } %> + <% if (errorMessage && errorMessage.length > 0) { %> +
<%= errorMessage %>
+ <% } %> <% if(facebook || twitter || github || gitlab || dropbox || google) { %>
@@ -195,6 +201,7 @@ + <%- include build/cover-scripts %> <% } else { %> <%- include build/cover-pack-scripts %> diff --git a/public/views/signin-modal.ejs b/public/views/signin-modal.ejs index a3ca63f..acbad25 100644 --- a/public/views/signin-modal.ejs +++ b/public/views/signin-modal.ejs @@ -7,7 +7,7 @@ - diff --git a/webpackBaseConfig.js b/webpackBaseConfig.js index dc636df..7d5c3e1 100644 --- a/webpackBaseConfig.js +++ b/webpackBaseConfig.js @@ -168,6 +168,7 @@ module.exports = { path.join(__dirname, 'node_modules/select2/select2-bootstrap.css'), ], "cover-pack": [ + "validator", "script!listPagnation", "expose?select2!select2", "expose?moment!moment", @@ -222,6 +223,7 @@ module.exports = { "index-pack": [ "expose?Spinner!spin.js", "script!jquery-ui-resizable", + "validator", "expose?jsyaml!js-yaml", "script!mermaid", "expose?moment!moment",