Squashed 'applications/users/' content from commit 1269ef3

git-subtree-dir: applications/users
git-subtree-split: 1269ef321098783e8699978b9c4dea9592d8f3e3
This commit is contained in:
Paul Liverman III 2018-03-13 02:10:31 -07:00
commit cedb312c2f
14 changed files with 767 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.lua

21
LICENSE Normal file
View File

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

51
ReadMe.md Normal file
View File

@ -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:<br>
`Users = require "users.models.Users"`
Named routes:
- user_new
- user_me
- user_admin
- user_edit
- user_login
- user_logout
- user_list

6
locator_config.moon Normal file
View File

@ -0,0 +1,6 @@
import Sessions from require "models"
{
before_filter: =>
@user = Sessions\get(@session)
}

66
migrations.moon Normal file
View File

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

32
models/Sessions.moon Normal file
View File

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

35
models/Users.moon Normal file
View File

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

335
users.moon Normal file
View File

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

85
views/user_admin.moon Normal file
View File

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

56
views/user_edit.moon Normal file
View File

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

28
views/user_list.moon Normal file
View File

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

17
views/user_login.moon Normal file
View File

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

8
views/user_me.moon Normal file
View File

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

26
views/user_new.moon Normal file
View File

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