#!/usr/bin/env lua5.3 local db = require 'db' local account = require 'account' local service = require 'service' local cgi = require 'cgi' local html = require 'html' local citrine = require 'citrine' local forms = require 'forms' local get, post = {}, {} local function validate_username(txn, messages, username) local good = true if not forms.reasonable_string(username) then good = false table.insert(messages, "invalid username") end if account.lookup_username(txn, username) then good = false table.insert(messages, "this username is already in use.") end return good end local function validate_email(txn, messages, email) local good = true if not forms.reasonable_string(email) then good = false table.insert(messages, "invalid email address") end if account.uid_of(txn, email) then good = false table.insert(messages, "this email is already in use") end return good end local function register(form) local messages = {} if not form.password or not form.email or not form.username then return false, {"required fields missing"} end local txn = db.txn(true) form.email = form.email:match '^%s*(.*)%s*$' form.username = form.username:match '^%s*(.*)%s*$' validate_email(txn, messages, form.email) validate_username(txn, messages, form.username) if form.password ~= form.confirm_password then table.insert(messages, "passwords do not match") end if form.coinstar ~= "08:00" then table.insert(messages, "please answer the question correctly: when's 8am?") end if #messages > 0 then return false, messages else local user = assert(account.create_user( txn, form.email, form.password, form.username)) local token = user:issue_token "auth.citrons.xyz" txn:commit() return true, user.uid, token end end local function login(form) local txn = db.txn(true) local user = account.user_from_email(txn, form.email) if not user then return false, {"email not found"} end if not user:check_password(form.password) then return false, {"the password entered was incorrect."} end local token = user:issue_token "auth.citrons.xyz" txn:commit() return true, user.uid, token end local function logged_in(uid, token, redirect) local set_cookie = { "token="..token.."; Path=/; Secure; HttpOnly; Max-Age=99999999999", "uid="..uid.."; Path=/; Secure; HttpOnly; Max-Age=99999999999", } return { status = "302 See Other", location = redirect, set_cookie = set_cookie, content_type = "text/html" }, html.render_doc(function() html.a({href = redirect}, "click here to be redirected...") end) end local function get_user(txn, cookie) if cookie and cookie.uid and cookie.token then local user = account.get_user(txn, cookie.uid) if user and user:check_token(cookie.token) == "auth.citrons.xyz" then return user end end end local function user_json(user) local u = {} u.uid = user.uid u.username = user:get "username" u.ttl = 800 return u end local function login_page(login_messages, register_messages, values) return citrine.page {title = "log in", function() citrine.h1 "log in to auth.citrons.xyz" html.div({class = 'login-box'}, function() forms.login(nil, login_messages, values) forms.register(nil, register_messages, values) end) end} end local function auth_page(cookie, service_domain, meta) local sv, err = service.lookup(service_domain) if not sv then cgi.abort(503, [[ unable to connect to the requested service. it may be misconfigured, unavailable, or antimemetic. the error was: ]]..err) end local txn = db.txn(true) local user = get_user(txn, cookie) local token = user:issue_token(service_domain) local username = user:get "username" txn:commit() local endpoint = sv.endpoint or "login" if not endpoint:match '^%w+://' then endpoint = "https://"..service_domain.."/"..endpoint end if endpoint:match '^https?://%.*auth%.+citrons%.+xyz%.*' then cgi.abort(503, "the requested service's endpoint is invalid.") end return citrine.page {title = "authenticate", function() citrine.h1 "authenticate with auth.citrons.xyz" html.div({class = 'box'}, function() html.h2(function() html.text "connect to " html.i(sv.name or service_domain) html.text "?" end) html.p(function() html.text "you will be identified as " html.b(username) html.text " to " html.b(service_domain) html.text "." end) forms.connect(user.uid, token, meta, endpoint) end) end} end get["^/login$"] = function(info) local txn = db.txn() local user = get_user(txn, info.cookie) txn:commit() if not user then return 'text/html', login_page() elseif info.query.service then return 'text/html', auth_page( info.cookie, info.query.service, info.query.meta) else cgi.redirect(302, "/account") end end post["^/login$"] = function(info) if not info.form then cgi.abort(400) end local action = info.form.action local success, result, token if action == 'register' then success, result, token = register(info.form) elseif action == 'login' then success, result, token = login(info.form) else cgi.abort(400) end if not success then return 'text/html', login_page( action == 'login' and result, action == 'register' and result, info.form) else local redirect = "/login" local service = info.query.service if service then redirect = redirect.."?service="..html.url_encode(service) end return logged_in(result, token, redirect) end end local function account_page(user, token, messages) return citrine.page {title = "user profile", function() citrine.h1 "user profile" html.div({class = 'box user-settings'}, function() html.h2(user:get "username") forms.user_settings(user, token, messages) end) end} end get["^/account$"] = function(info) local txn = db.txn() local user = get_user(txn, info.cookie) if not user then cgi.redirect(302, "/login") end return 'text/html', account_page(user, info.cookie.token) end post["^/account$"] = function(info) local txn = db.txn(true) local user = get_user(txn, info.cookie) if not user then cgi.redirect(302, "/login") end local form = info.form if not form then cgi.abort(400) end -- prevent CSRF if form.token ~= info.cookie.token then cgi.abort(400) end if form.logout then if form.everywhere then user:revoke_tokens() else user:revoke_token(info.cookie.token) end txn:commit() cgi.redirect(302, "/login") end local messages = {} if form.email and form.email ~= user:get "email" then form.email = form.email:match '^%s*(.*)%s*$' if validate_email(txn, messages, form.email) then user:set_email(form.email) end end if form.username and form.username ~= user:get "username" then form.username = form.username:match '^%s*(.*)%s*$' if validate_username(txn, messages, form.username) then user:set_username(form.username) end end if form.password then if not user:check_password(form.password) then table.insert(messages, "password supplied is incorrect") end if not form.new_password or form.new_password ~= form.confirm_password then table.insert(messages, "passwords do not match") end if #messages == 0 then user:set_password(form.new_password) txn:commit() return 'text/html', citrine.page{function() citrine.h1 "password change" html.p "your password has been changed successfully." html.p(function() html.a({href = "/account"}, "return to profile page") end) end} end end txn:commit() user.txn = db.txn() return 'text/html', account_page(user, info.cookie.token, messages) end get["^/api/user/(%w+)$"] = function(info, uid) local txn = db.txn() local user = account.get_user(txn, uid) if not user then cgi.abort(404) end local j = user_json(user) return 'application/json', json.encode(j) end post["^/api/user/(%w+)/auth/([^/]+)$"] = function(info, uid, service) local txn = db.txn() local user = account.get_user(txn, uid) if not user then cgi.abort(404) end local j = {} if not info.form or not info.form.token then cgi.abort(400) end if user:check_token(info.form.token) == service then j.valid = true j.user = user_json(user) j.ttl = 120 else j.valid = false j.ttl = 9999999 end return 'application/json', json.encode(j) end post["^/api/user/(%w+)/invalidate$"] = function(info, uid) local txn = db.txn(true) local user = account.get_user(txn, uid) if not user then cgi.abort(404) end if not info.form or not info.form.token then cgi.abort(400) end if user:check_token(info.form.token) then user:revoke_token(info.form.token) end local j = user_json(user) txn:commit() return 'application/json', json.encode(j) end get["^/$"] = function() return 'text/html', citrine.page {function() citrine.h1 "account services at auth.citrons.xyz" html.p(function() html.text [[ experience authentication. auth.citrons.xyz provides a single account for arbitrary webthings. this service is mostly intended for webthings made by me (]] html.a({href = "https://citrons.xyz"}, "citrons") html.text ")." end) html.p(function() html.text [[ it is quite easy to integrate auth.citrons.xyz into your own services. it is however pertinent that you understand that you do so ]] html.em "at your own risk" html.text [[! I make no guarantees of the security and integrity of this service. upon comprehending this, you can use the ]] html.a({href = "/docs"}, "API") html.text [[ to incorporate the auth.citrons.xyz account system into your webthing (or non-webthing). ]] end) html.img { class = 'the-graph', src = '/static/graph.svg', alt = 'graph of world domination' } html.h2 "privacy policy" html.p [[ auth.citrons.xyz retains your supplied account details, such as your email and username. it also may output your IP address into a random log file. no data other than this (such as usage patterns, etc) is collected. ]] html.p [[ in the event that "hacker" is "able t0 get [my] database credentials and extract [my] entire database and m0ve the informati0n t0 an offshore server", they might be able to send you mean emails. oh no. ]] end} end local function serve(handlers) return function(info, path) for patt, handler in pairs(handlers) do local matches = {path:match(patt)} if matches[1] then return handler(info, table.unpack(matches)) end end cgi.abort(404) end end cgi.serve({GET = serve(get), POST = serve(post)}, citrine.error)