diff --git a/.gitignore b/.gitignore index 44c0c11..b2c2066 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ logs/ nginx.conf.compiled db.*.sqlite .vscode/ +.local/ diff --git a/app.lua b/app.lua index 23814fa..6c66b96 100644 --- a/app.lua +++ b/app.lua @@ -1,6 +1,11 @@ local lapis = require("lapis") local app = lapis.Application() +app:enable("etlua") +app.layout = require "views.base" + +app:include("apps.users", {path = "/user"}) + app:get("/", function() return "Welcome to Lapis " .. require("lapis.version") end) diff --git a/apps/users.lua b/apps/users.lua new file mode 100644 index 0000000..3b9a72d --- /dev/null +++ b/apps/users.lua @@ -0,0 +1,181 @@ +local app = require("lapis").Application() + +local db = require("lapis.db") +local constants = require("constants") + +local bcrypt = require("bcrypt") +local rand = require("openssl.rand") + +local models = require("models") +local Users = models.Users +local Sessions = models.Sessions + +local function authenticate_user(user, password) + return bcrypt.verify(password, user.password_hash) +end + +local function create_session_key() + return rand.bytes(16):gsub(".", function(c) return string.format("%02x", string.byte(c)) end) +end + +local function create_session(user_id) + local days = 30 + local expires_at = os.time() + (days * 24 * 60 * 60) + + return Sessions:create({ + key = create_session_key(), + user_id = user_id, + expires_at = expires_at, + }) +end + +local function validate_session(session_key) + local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', session_key, os.time()) + print(#session) + if #session > 0 then + return Users:find({id = session[1].user_id}) + end + + return nil +end + +local function validate_password(password) + if #password < 10 or password:match("%s") then + return false + end + + if #password > 255 then + return false + end + + local r = password:match("%u+") and + password:match("%l+") and + password:match("%d+") and + password:match("%p+") + return r ~= nil and true +end + +local function validate_username(username) + if #username < 3 or #username > 20 then + return false + end + + return username:match("^[%w_-]+$") and true +end + +app:get("user", "/:username", function(self) + local user = Users:find({username = self.params.username}) + if not user then + return {status = 404} + end + + if self.session.flash.just_logged_in then + self.just_logged_in = true + self.session.flash = {} + end + local me = validate_session(self.session.session_key) + if not me and user.permission == constants.PermissionLevel.GUEST then + return {status = 404} + end + self.user = user + return {render = "user.user"} +end) + +app:get("user_login", "/login", function(self) + if self.session.session_key then + local user = validate_session(self.session.session_key) + if user ~= nil then + return {redirect_to = self:url_for("user", {username = user.username})} + end + end + + if self.session.flash then + self.err = self.session.flash.error + self.session.flash = {} + end + return {render = "user.login"} +end) + +app:post("user_login", "/login", function(self) + if self.session.session_key then + local user = validate_session(self.session.session_key) + if user ~= nil then + return {redirect_to = self:url_for("user", {username = user.username})} + end + end + local username = self.params.username + local password = self.params.password + local user = Users:find({username = username}) + if not user then + self.session.flash = {error = "Invalid username or password"} + return {redirect_to = self:url_for("user_login")} + end + if not authenticate_user(user, password) then + self.session.flash = {error = "Invalid username or password"} + return {redirect_to = self:url_for("user_login")} + end + local session = create_session(user.id) + self.session.flash = {just_logged_in = true} + self.session.session_key = session.key + return {redirect_to = self:url_for("user", {username = username})} +end) + +app:get("user_signup", "/signup", function(self) + if self.session.session_key then + local user = validate_session(self.session.session_key) + if user ~= nil then + return {redirect_to = self:url_for("user", {username = user.username})} + end + end + if self.session.flash then + self.err = self.session.flash.error + self.session.flash = {} + end + return {render = "user.signup"} +end) + +app:post("user_signup", "/signup", function(self) + if self.session.session_key then + local user = validate_session(self.session.session_key) + if user ~= nil then + return {redirect_to = self:url_for("user", {username = user.username})} + end + end + + local username = self.params.username + local password = self.params.password + local password2 = self.params.password2 + local user = Users:find({username = username}) + if user then + self.session.flash = {error = "Username '" .. username .. "' is already taken."} + return {redirect_to = self:url_for("user_signup")} + end + + if not validate_username(username) then + self.session.flash = {error = "Username must be 3-20 characters with only upper and lowercase letters, hyphens, and underscores."} + return {redirect_to = self:url_for("user_signup")} + end + + if not validate_password(password) then + self.session.flash = {error = "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces."} + return {redirect_to = self:url_for("user_signup")} + end + + if password ~= password2 then + self.session.flash = {error = "Passwords do not match."} + return {redirect_to = self:url_for("user_signup")} + end + + local new_user = Users:create({ + username = username, + password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS), + permission = constants.PermissionLevel.GUEST, + }) + + local session = create_session(new_user.id) + self.session.flash = {just_logged_in = true} + self.session.session_key = session.key + return {redirect_to = self:url_for("user", {username = username})} +end) + +return app \ No newline at end of file diff --git a/config.lua b/config.lua index de79e42..8899bd2 100644 --- a/config.lua +++ b/config.lua @@ -8,4 +8,5 @@ config("development", { database = "db.dev.sqlite" }, secret = "SUPER SECRET", + session_name = "porom_session", }) diff --git a/constants.lua b/constants.lua new file mode 100644 index 0000000..9e04f51 --- /dev/null +++ b/constants.lua @@ -0,0 +1,11 @@ +local Constants = {} + +Constants.PermissionLevel = { + GUEST = 0, + USER = 1, + ADMIN = 2, +} + +Constants.BCRYPT_ROUNDS = 10 + +return Constants diff --git a/create_default_admin.lua b/create_default_admin.lua new file mode 100644 index 0000000..950ba6d --- /dev/null +++ b/create_default_admin.lua @@ -0,0 +1,32 @@ +local bcrypt = require("bcrypt") +local models = require("models") +local constants = require("constants") + +local alphabet = "-_@0123456789abcdefghijklmnopqrstuvwABCDEFGHIJKLMNOPQRSTUVWXYZ" + +local function create_admin() + local username = "admin" + local root_count = models.Users:count("username = ?", username) + if root_count ~= 0 then + print("admin account already exists.") + return + end + + local password = "" + for _ = 1, 16 do + local randi = math.random(#alphabet) + password = password .. alphabet:sub(randi, randi) + end + + local hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS) + + models.Users:create({ + username = username, + password_hash = hash, + permission = constants.PermissionLevel.ADMIN, + }) + + print("Admin account created, use \"admin\" as the login and \"" .. password .. "\" as the password. This will only be shown once.") +end + +create_admin() \ No newline at end of file diff --git a/migrations.lua b/migrations.lua new file mode 100644 index 0000000..010f861 --- /dev/null +++ b/migrations.lua @@ -0,0 +1,18 @@ +local db = require("lapis.db") +local schema = require("lapis.db.schema") +local types = schema.types + +return { + [1] = function () + schema.create_table("sessions", { + {"id", types.integer{primary_key = true}}, + {"key", types.text{unique = true}}, + {"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"}, + {"expires_at", types.integer}, + {"created_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"}, + }) + + db.query("CREATE INDEX sessions_user_id ON sessions(user_id)") + db.query("CREATE INDEX session_keys ON sessions(key)") + end +} diff --git a/models.lua b/models.lua index baa96c0..b36f72e 100644 --- a/models.lua +++ b/models.lua @@ -1,2 +1,21 @@ -local autoload = require("lapis.util").autoload -return autoload("models") +local Model = require("lapis.db.model").Model + +local constants = require("constants") + +local Users, Users_mt = Model:extend("users") + +function Users_mt:is_guest() + return self.permission == constants.PermissionLevel.GUEST +end + +local ret = { + Users = Users, + Topics = Model:extend("topics"), + Threads = Model:extend("threads"), + Posts = Model:extend("posts"), + PostHistory = Model:extend("post_history"), + Sessions = Model:extend("sessions"), +} + +return ret + diff --git a/views/base.etlua b/views/base.etlua new file mode 100644 index 0000000..11dcb62 --- /dev/null +++ b/views/base.etlua @@ -0,0 +1,10 @@ + + + + + Porom + + + <% content_for("inner") %> + + diff --git a/views/user/login.etlua b/views/user/login.etlua new file mode 100644 index 0000000..8733948 --- /dev/null +++ b/views/user/login.etlua @@ -0,0 +1,12 @@ +

Log In

+ +<% if err then %> +

<%= err %>

+<% end %> +
+
+
+
+
+ +
diff --git a/views/user/signup.etlua b/views/user/signup.etlua new file mode 100644 index 0000000..e34290e --- /dev/null +++ b/views/user/signup.etlua @@ -0,0 +1,15 @@ +

Sign up

+ +<% if err then %> +

<%= err %>

+<% end %> +
+
+
+
+
+
+
+ +
+

After you sign up, an administrator will need to confirm your account before you will be allowed to post.

diff --git a/views/user/user.etlua b/views/user/user.etlua new file mode 100644 index 0000000..80ccc34 --- /dev/null +++ b/views/user/user.etlua @@ -0,0 +1,7 @@ +<% if just_logged_in then %> +

Logged in successfully.

+<% end %> +

<%= user.username %>

+<% if user:is_guest() then %> +

You are a guest. An administrator needs to approve your account before you will be able to post.

+<% end %>