commit cedb312c2fa686a7d23c77872cbd48be2b00d342 Author: Paul Liverman III Date: Tue Mar 13 02:10:31 2018 -0700 Squashed 'applications/users/' content from commit 1269ef3 git-subtree-dir: applications/users git-subtree-split: 1269ef321098783e8699978b9c4dea9592d8f3e3 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d907c43 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7786f75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016-2018 Paul Liverman III + +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: + +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. diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..e652b7e --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,51 @@ +## Installation + +(Note: These instructions need to be rewritten for use with the new service + locator and utility modules I've made recently.) + +1. From application root: `git submodule add https://github.com/lazuscripts/users users` + +2. Inside your application: `@include "users/users"` + +3. In migrations, do the following: + ```moonscript + import create_table, types from require "lapis.db.schema" + create_table "users", { + {"id", types.serial primary_key: true} + {"name", types.varchar unique: true} + {"digest", types.text} + {"admin", types.boolean default: false} + } + ``` + +4. `bcrypt` Lua Rock must be installed! + +5. If `@session.redirect` is defined when going to the login route, a user will be + redirected there after logging in. + +Note: Assumes you have a route named "index" to redirect to when things go +wrong. + +**WARNING**: Now expects you to display informational messages stored in +`@session.info`. + +## Config + +`digest_rounds 9` must be set. Use a higher or lower number depending on how +long it takes to calculate a digest or how paranoid you want to be. See this bit +about tuning: https://github.com/mikejsavage/lua-bcrypt#tuning + +## Access + +To get the Users model for use outside of this sub-application:
+`Users = require "users.models.Users"` + +Named routes: + +- user_new +- user_me +- user_admin +- user_edit +- user_login +- user_logout +- user_list diff --git a/locator_config.moon b/locator_config.moon new file mode 100644 index 0000000..80432d6 --- /dev/null +++ b/locator_config.moon @@ -0,0 +1,6 @@ +import Sessions from require "models" + +{ + before_filter: => + @user = Sessions\get(@session) +} diff --git a/migrations.moon b/migrations.moon new file mode 100644 index 0000000..ef5f5c4 --- /dev/null +++ b/migrations.moon @@ -0,0 +1,66 @@ +db = require "lapis.db" +import create_table, types, add_column, rename_column, create_index, drop_index from require "lapis.db.schema" + +{ + [1]: => + create_table "users", { + {"id", types.serial primary_key: true} + {"name", types.varchar unique: true} + {"email", types.text unique: true} + {"digest", types.text} + {"admin", types.boolean default: false} + + {"created_at", types.time} + {"updated_at", types.time} + } + create_table "sessions", { + {"user_id", types.foreign_key} + + {"created_at", types.time} + {"updated_at", types.time} + } + + [1518430372]: => + add_column "sessions", "id", types.serial primary_key: true + rename_column "sessions", "created_at", "opened_at" + rename_column "sessions", "updated_at", "closed_at" + + create_index "users", "id", unique: true + create_index "users", "name", unique: true + create_index "users", "email", unique: true + + create_index "sessions", "id", unique: true + + [1518968812]: => + import autoload from require "locator" + import settings from autoload "utility" + + settings["users.allow-sign-up"] = true + settings["users.allow-name-change"] = true + settings["users.admin-only-mode"] = false + settings["users.require-email"] = true + settings["users.require-unique-email"] = true + settings["users.allow-email-change"] = true + settings["users.session-timeout"] = 60 * 60 * 24 -- default is one day + + settings["users.minimum-password-length"] = 12 + settings["users.maximum-character-repetition"] = 6 + settings["users.bcrypt-digest-rounds"] = 12 + -- settings["users.password-check-fn"] = nil -- should return true if passes, falsy and error message if fails + settings.save! + + drop_index "users", "email" -- replacing because it was a unique index + db.query "ALTER TABLE users DROP CONSTRAINT users_email_key" + create_index "users", "email" + + [1519416945]: => + import autoload from require "locator" + import settings from autoload "utility" + + settings["users.require-recaptcha"] = false -- protect against bots for sign-up (default off because it requires set-up) + -- settings["users.recaptcha-sitekey"] = nil -- provided by admin panel + -- settings["users.recaptcha-secret"] = nil -- provided by admin panel + settings.save! + + -- NOTE may need to run a migration to allow null emails ? +} diff --git a/models/Sessions.moon b/models/Sessions.moon new file mode 100644 index 0000000..ded58de --- /dev/null +++ b/models/Sessions.moon @@ -0,0 +1,32 @@ +import Model from require "lapis.db.model" +import Users from require "models" + +import locate, autoload from require "locator" +import to_seconds, for_db from locate "datetime" +import settings from autoload "utility" +import time from os + +class Sessions extends Model + @create: (values, opts) => + now = time! + values.opened_at = for_db now + values.closed_at = for_db now + settings["users.session-timeout"] + return super values, opts + + get: (cookie) => + if cookie.session_id + if session = @find id: cookie.session_id + now = time! + if now - to_seconds(session.closed_at) <= 0 + if user = Users\find id: session.user_id + session\update closed_at: for_db now + settings["users.session-timeout"] + return user + else + session\update closed_at: for_db now + cookie.session_id = nil + + close: (cookie) => + if cookie.session_id + if session = @find id: cookie.session_id + session\update closed_at: for_db time! + cookie.session_id = nil diff --git a/models/Users.moon b/models/Users.moon new file mode 100644 index 0000000..a4b7808 --- /dev/null +++ b/models/Users.moon @@ -0,0 +1,35 @@ +import Model from require "lapis.db.model" + +import autoload from require "locator" +import settings from autoload "utility" + +class Users extends Model + @timestamp: true + + @constraints: { + name: (value) => + if not value + return "User names must exist." + + -- TODO make this customizable ? + if value\find "%W" + return "User names can only contain alphanumeric characters." + + if Users\find name: value + return "User names must be unique." + + -- TODO make this extendable? / make this show up BEFORE it gets to erroring at the model + lower = value\lower! + if (lower == "admin") or (lower == "administrator") or (lower == "new") or (lower == "edit") or (lower == "create") or (lower == "login") or (lower == "logout") or (lower == "me") + return "User names must not be 'admin', 'administrator', 'new', 'edit', 'create', 'login', 'logout', or 'me'." + + email: (value) => + if settings["users.require-email"] and (not value) + return "Email addresses must exist." + + -- TODO figure out how to check for valid email address + + if settings["users.require-unique-email"] + if Users\find email: value + return "Email addresses must be unique." + } diff --git a/users.moon b/users.moon new file mode 100644 index 0000000..7c281ed --- /dev/null +++ b/users.moon @@ -0,0 +1,335 @@ +lapis = require "lapis" +http = require "lapis.nginx.http" +csrf = require "lapis.csrf" +config = require("lapis.config").get! + +bcrypt = require "bcrypt" + +import decode from require "cjson" +import respond_to, capture_errors, assert_error, yield_error from require "lapis.application" +import assert_valid, validate_functions from require "lapis.validate" +import trim from require "lapis.util" +import locate, autoload from require "locator" +import settings from autoload "utility" + +import Users, Sessions from require "models" + +validate_functions.not_equals = (...) -> + return not validate_functions.equals(...) +validate_functions.unique_user = (input) -> + return not Users\find name: input +validate_functions.unique_email = (input) -> + return not Users\find email: input +validate_functions.max_repetitions = (input, max) -> + tab = {} + for i=1,#input + char = input\sub i, i + if tab[char] + tab[char] += 1 + else + tab[char] = 1 + for k,v in pairs tab + if v >= settings["users.maximum-character-repetition"] + return false + return true + +class extends lapis.Application + @path: "/users" + @name: "user_" + + [new: "/new"]: respond_to { + before: => + unless settings["users.allow-sign-up"] + @session.info = "Sign ups are disabled." + return redirect_to: @params.redirect or @url_for "index" + + GET: => + if @user + @session.info = "You are logged into an account already." + return redirect_to: @params.redirect or @url_for "user_me" + + @csrf_token = csrf.generate_token(@) + + return render: locate "views.user_new" + + POST: capture_errors { + on_error: => + @session.info = "The following errors occurred:" + for err in *@errors + @session.info ..= " #{err}" + return redirect_to: @url_for "user_new", nil, redirect: @params.redirect + + => + csrf.assert_token(@) + + if settings["users.require-recaptcha"] + body = http.simple "https://www.google.com/recaptcha/api/siteverify", { + secret: settings["users.recaptcha-secret"] + response: @params["g-recaptcha-response"] + } + unless decode(body).success + yield_error "You failed to complete the reCAPTCHA challenge." + + assert_valid @params, { + {"name", exists: true, "You must have a username."} + {"name", unique_user: true, "That username is taken."} + {"password", exists: true, "You must enter a password."} + {"password", min_length: settings["users.minimum-password-length"], "Your password must be at least #{settings["users.minimum-password-length"]} characters long."} + {"password", max_repetitions: settings["users.maximum-character-repetition"], "Your password must not have more than #{settings["users.maximum-character-repetition"]} repetitions of the same character."} + } + + if settings["users.password-check-fn"] + fn = locate settings["users.password-check-fn"] + assert_error fn(@params.password) + + if settings["users.require-email"] + assert_valid @params, { + {"email", exists: true, "You must enter an email address."} + } + if settings["users.require-unique-email"] + assert_valid @params, { + {"email", unique_email: true, "That email address is already tied to another account."} + } + + digest = bcrypt.digest @params.password, settings["users.bcrypt-digest-rounds"] + + user = assert_error Users\create { + name: trim @params.name -- NOTE this might allow an empty username by using spaces to fool validation functions + email: trim @params.email + digest: digest + } + + -- if there are no admins, they become one + unless Users\find admin: true + user\update admin: true + + if settings["users.admin-only-mode"] and (not user.admin) + yield_error "Your account was created. However, this site is in admin-only mode right now, so you are not logged in." + + session = assert_error Sessions\create user_id: user.id + @session.session_id = session.id + @session.info = "Account created, you are logged in." + return redirect_to: @params.redirect or @url_for "user_me" + } + } + + [me: "/me"]: => + unless @user + @session.info = "You are not logged in." + return redirect_to: @url_for "user_login", nil, redirect: @url_for "user_me" + + return render: locate "views.user_me" + + [edit: "/edit"]: respond_to { + before: => + unless @user + @session.info = "You are not logged in." + return redirect_to: @url_for "user_login", nil, redirect: @url_for "user_edit" + + GET: => + @csrf_token = csrf.generate_token(@) + return render: locate "views.user_edit" + + POST: capture_errors { + on_error: => + @session.info = "The following errors occurred:" + for err in *@errors + @session.info ..= " #{err}" + return redirect_to: @url_for "user_edit" + + => + csrf.assert_token(@) + + if @params.name + unless settings["users.allow-name-change"] + yield_error "You cannot change your username." + assert_valid @params, { + {"name", exists: true, "You must have a username."} + {"name", not_equals: @user.name, "You must enter a different username to change it."} + {"name", unique_user: true, "That username is taken."} + } + assert_error @user\update name: trim @params.name + @session.info = "Username updated." + return redirect_to: @url_for "user_edit" + + if @params.email + unless settings["users.allow-email-change"] + yield_error "You cannot change your email address." + if settings["users.require-email"] + assert_valid @params, { + {"email", exists: true, "You must enter an email address."} + } + if settings["users.require-unique-email"] + assert_valid @params, { + {"email", unique_email: true, "That email address is already tied to another account."} + } + assert_error @user\update email: trim @params.email + @session.info = "Email address updated." + return redirect_to: @url_for "user_edit" + + if @params.password + assert_valid @params, { + {"password", exists: true, "You must enter a password."} + {"password", min_length: settings["users.minimum-password-length"], "Your password must be at least #{settings["users.minimum-password-length"]} characters long."} + {"password", max_repetitions: settings["users.maximum-character-repetition"], "Your password must not have more than #{settings["users.maximum-character-repetition"]} repetitions of the same character."} + } + if settings["users.password-check-fn"] + fn = locate settings["users.password-check-fn"] + assert_error fn(@params.password) + + unless bcrypt.verify @params.oldpassword, @user.digest + yield_error "Incorrect password." + + assert_error @user\update digest: bcrypt.digest @params.password, settings["users.bcrypt-digest-rounds"] + @session.info = "Password updated." + return redirect_to: @url_for "user_edit" + + if @params.delete + Sessions\close(@session) + assert_error @user\delete! + @session.info = "Account deleted." + return redirect_to: @url_for "index" + + @csrf_token = csrf.generate_token(@) + return render: locate "views.user_edit" + } + } + + [admin: "/admin"]: respond_to { + before: => + unless @user + @session.info = "You are not logged in." + return redirect_to: @url_for "user_login", nil, redirect: @url_for "user_admin" + + unless @user.admin + @session.info = "You are not an administrator." + return redirect_to: @url_for "index" + + GET: => + @csrf_token = csrf.generate_token(@) + if @params.id + @user_editing = Users\find id: @params.id -- query params option + unless @user_editing + @user_editing = @user + return render: locate "views.user_admin" + + POST: capture_errors { + on_error: => + @session.info = "The following errors occurred:" + for err in *@errors + @session.info ..= " #{err}" + return redirect_to: @url_for "user_admin" + + => + csrf.assert_token(@) + + -- figure out who we're editing + if @params.change_via_name and @params.change_via_name\len! > 0 + @user_editing = Users\find name: @params.change_via_name + elseif @params.change_via_id + @user_editing = Users\find id: @params.change_via_id + + unless @user_editing + yield_error "No user selected. You have been automatically selected." + + -- figure out what we're editing and do it + if @params.name + assert_valid @params, { + {"name", exists: true, "They must have a username."} + {"name", not_equals: @user_editing.name, "You must enter a different username to change it."} + {"name", unique_user: true, "That username is taken."} + } + assert_error @user_editing\update name: trim @params.name + @session.info = "Username updated." + return redirect_to: @url_for "user_admin" + + if @params.email + if settings["users.require-email"] + assert_valid @params, { + {"email", exists: true, "They must have an email address."} + } + if settings["users.require-unique-email"] + assert_valid @params, { + {"email", unique_email: true, "That email address is already tied to another account."} + } + assert_error @user_editing\update email: trim @params.email + @session.info = "Email address updated." + return redirect_to: @url_for "user_admin" + + if @params.password + assert_error @user_editing\update digest: bcrypt.digest @params.password, settings["users.bcrypt-digest-rounds"] + @session.info = "Password updated. (Warning: Admins editing passwords are not restricted to secure passwords, as these are intended to be TEMPORARY only!)" + -- TODO require a password edited by an admin to be changed upon login + return redirect_to: @url_for "user_admin" + + if @params.admin ~= nil + assert_error @user_editing\update admin: @params.admin + if @params.admin + @session.info = "#{@user_editing.name} is now an admin!" + else + @session.info = "#{@user_editing.name} is no longer an admin." + return redirect_to: @url_for "user_admin" + + if @params.delete + assert_error @user_editing\delete! + @session.info = "Account deleted. You are now viewing your own account." + @user_editing = @user + + @csrf_token = csrf.generate_token(@) + return render: locate "views.user_admin" + } + } + + [list: "/list"]: => + unless @user + @session.info = "You are not logged in." + return redirect_to: @url_for "user_login", nil, redirect: @url_for "user_list" + + unless @user.admin + @session.info = "You are not an administrator." + return redirect_to: @url_for "index" + + @users = Users\select "WHERE true ORDER BY name ASC" + return render: locate "views.user_list" + + [login: "/login"]: respond_to { + before: => + if @user + @session.info = "You are already logged in." + return redirect_to: @params.redirect or @url_for "user_me" + + GET: => + @csrf_token = csrf.generate_token(@) + return render: locate "views.user_login" + + POST: capture_errors { + on_error: => + @session.info = "The following errors occurred:" + for err in *@errors + @session.info ..= " #{err}" + return redirect_to: @url_for "user_login" + + => + csrf.assert_token(@) + + user = assert_error Users\find name: trim @params.name + if settings["users.admin-only-mode"] and (not user.admin) + yield_error "This site is in admin-only mode. You cannot log in." + + if bcrypt.verify @params.password, user.digest + session = assert_error Sessions\create user_id: user.id + @session.session_id = session.id + @session.info = "Logged in." + return redirect_to: @params.redirect or @session.redirect or @url_for "index" + + @session.info = "Invalid username or password." + return redirect_to: @url_for "user_login", nil, redirect: @params.redirect or @session.redirect + } + } + + [logout: "/logout"]: => + @session.id = nil + @session.redirect = nil + Sessions\close(@session) + @session.info = "You have been logged out." + return redirect_to: @params.redirect or @url_for "index" diff --git a/views/user_admin.moon b/views/user_admin.moon new file mode 100644 index 0000000..5848bbf --- /dev/null +++ b/views/user_admin.moon @@ -0,0 +1,85 @@ +import Widget from require "lapis.html" + +class extends Widget + content: => + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + }, -> + p "Selected User: #{@user_editing.name} (#{@user_editing.id})" + element "table", -> + tr -> + td "Switch (by name)?" + td -> input type: "text", name: "change_via_name", placeholder: @user_editing.name + tr -> + td "Switch (by ID)?" + td -> input type: "number", name: "change_via_id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Change username? " + input type: "text", name: "name", value: @user_editing.name, autocomplete: "username" + br! + input type: "hidden", name: "id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Change email? " + input type: "text", name: "email", value: @user_editing.email + br! + input type: "hidden", name: "id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Change password? " + input type: "password", name: "password" + br! + input type: "hidden", name: "id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + onsubmit: "return confirm('Are you sure you want to do this?');" + }, -> + text "Administrator? " + input type: "checkbox", name: "admin", checked: @user_editing.admin + br! + input type: "hidden", name: "id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + + form { + action: @url_for "user_admin" + method: "POST" + enctype: "multipart/form-data" + onsubmit: "return confirm('Are you sure you want to do this?');" + }, -> + text "Delete user? " + input type: "checkbox", name: "delete" + br! + input type: "hidden", name: "id", value: @user_editing.id + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" diff --git a/views/user_edit.moon b/views/user_edit.moon new file mode 100644 index 0000000..cad28b4 --- /dev/null +++ b/views/user_edit.moon @@ -0,0 +1,56 @@ +import Widget from require "lapis.html" + +class extends Widget + content: => + form { + action: @url_for "user_edit" + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Change username? " + input type: "text", name: "name", value: @user.name, autocomplete: "username" + br! + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_edit" + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Change email address? " + input type: "email", name: "email", value: @user.email + br! + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_edit" + method: "POST" + enctype: "multipart/form-data" + }, -> + p "Change password?" + element "table", -> + tr -> + td "Old password:" + td -> input type: "password", name: "oldpassword" + tr -> + td "New password:" + td -> input type: "password", name: "password" + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + hr! + + form { + action: @url_for "user_edit" + method: "POST" + enctype: "multipart/form-data" + onsubmit: "return confirm('Are you sure you want to do this?');" + }, -> + text "Delete user? " + input type: "checkbox", name: "delete" + br! + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" diff --git a/views/user_list.moon b/views/user_list.moon new file mode 100644 index 0000000..c508373 --- /dev/null +++ b/views/user_list.moon @@ -0,0 +1,28 @@ +import Widget from require "lapis.html" + +class extends Widget + content: => + p "#{#@users} users." + element "table", -> + thead -> + tr -> + th "ID" + th "Admin" + th "Username" + th "Email Address" + tbody -> + for user in *@users + tr -> + td user.id + if user.admin + td "✔" + else + td! -- "❌" + td user.name + td user.email + tfoot -> + tr -> + th "ID" + th "Admin" + th "Username" + th "Email Address" diff --git a/views/user_login.moon b/views/user_login.moon new file mode 100644 index 0000000..313e3fd --- /dev/null +++ b/views/user_login.moon @@ -0,0 +1,17 @@ +import Widget from require "lapis.html" + +class extends Widget + content: => + form { + action: @url_for "user_login", nil, redirect: @params.redirect + method: "POST" + enctype: "multipart/form-data" + }, -> + text "Username: " + input type: "text", name: "name", autocomplete: "username" + br! + text "Password: " + input type: "password", name: "password" + br! + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" diff --git a/views/user_me.moon b/views/user_me.moon new file mode 100644 index 0000000..733fa54 --- /dev/null +++ b/views/user_me.moon @@ -0,0 +1,8 @@ +import Widget from require "lapis.html" + +class extends Widget + content: => + p "Username: #{@user.name} (#{@user.id})" + p "Email Address: #{@user.email}" + p "Is admin? #{@user.admin}" + p -> a href: @url_for("user_edit"), "Edit" diff --git a/views/user_new.moon b/views/user_new.moon new file mode 100644 index 0000000..323e313 --- /dev/null +++ b/views/user_new.moon @@ -0,0 +1,26 @@ +import Widget from require "lapis.html" + +import autoload from require "locator" +import settings from autoload "utility" + +class extends Widget + content: => + form { + action: @url_for "user_new", nil, redirect: @params.redirect + method: "POST" + enctype: "multipart/form-data" + }, -> + p "Username: " + input type: "text", name: "name", autocomplete: "username" + p "Email: " + input type: "email", name: "email" + p "Password: " + input type: "password", name: "password" + br! + if settings["users.require-recaptcha"] + div class: "g-recaptcha", "data-sitekey": settings["users.recaptcha-sitekey"] + input type: "hidden", name: "csrf_token", value: @csrf_token + input type: "submit" + + if settings["users.require-recaptcha"] + script src: "https://www.google.com/recaptcha/api.js"