--[==[

	webb | session-based authentication
	Written by Cosmin Apreutesei. Public Domain.

SESSIONS

	[try_]login([auth][, switch_user]) -> realusr   login and set lang (if not set)
	usr([field|'*']) -> val | t | usr         get current user field(s) or id
	tenant()                                  get current tenant
	touch_usr([usr])                          update (current) user's atime
	gen_auth_token(email) -> token            generate a one-time long-lived auth token
	gen_auth_code('email', email) -> code     generate a one-time short-lived auth code
	gen_auth_code('phone', phone) -> code     generate a one-time short-lived auth code
	usr_[create|update|create_or_update]({k->v}) -> usr    create and/or update a user
	usr_delete(usr)                           delete a user
	clear_userinfo_cache([usr])               clear usr table cache

SCHEMA

	auth_schema                              auth schema

CONFIG

	secret                       <required>  secret to encrypt sessions and passwords
	session_cookie_name          'session'   name of the session cookie
	session_cookie_secure_flag   true        set Secure flag to cookie
	auto_create_user             true        auto-create anonymous users
	allow_create_user            true        allow create new users on auth

	auth_token_lifetime          3600        forgot-password token lifetime
	auth_token_maxcount          2           max unexpired tokens allowed
	auth_code_lifetime           300         one-time auth code lifetime
	auth_code_maxcount           6           max unexpired auth codes allowed

API DOC

	[try_]login([auth][, switch_user]) -> usr

	Login using an auth object (see below).

[real]usr() -> usr

	Get the current user id. Same as calling `login()` without args.

[real]usr(field) -> v

	Get the value of a a specific field from the user info.

[real]usr'*' -> t

	Get full user info.

touch_usr()

	Update user's access time. Call it on every request as a way of tracking
	user activity, eg. for knowing when to send those annoying "forgot items
	in your cart" emails.

gen_auth_token(email) -> token

	Generate a long-lived authentication token to be put in a link and sent
	in a forgot-password email, and used once with 'token' auth type.
	Using it also validates the email that it was generated for.
	Errors:
		'email_not_found' - no account with this email.
		'too_many_tokens' - auth_token_maxcount limit reached.

gen_auth_code('email', email) -> code
gen_auth_code('phone', phone) -> code

	Generate a 6-digit short-lived authentication code to be sent via
	email or phone and be used back once with 'code' auth type.
	If there's no user account with that email/phone, a new one is created.
	Using it also validates the phone or email that it was generated for.
	Errors:
		'too_many_tokens' - auth_code_maxcount limit reached.

AUTH OBJECT

{type = 'session'}

	login using session cookie (default). if there's no session cookie
	or it's invalid, an anonymous user is created subject to auto_create_user.

{type = 'logout'}

	Clears the session cookie and creates an anonymous user and returns it.

{type = 'anonymous'}

	login using session cookie but logout and create an anonymous user
	if the logged in user is not anonymous.

{type = 'pass', action = 'login', email = , pass = }

	login to an existing user using its email and password.
	errors:
		'user_pass' - the email or password is wrong.

{type = 'pass', action = 'create', email = , pass = }

	create a user with a password and login to it.
	errors:
		'email_taken' - email already used on another account.

{type = 'nopass', email = }

	login using only user.

{type = 'update', email = , phone = , pass = , name = }

	update the info of the currently logged in user.
	errors:
		'email_taken' - email already used on another account.
		'phone_taken' - phone already used on another account.

{type = 'token', token = }

	login using a temporary token that was generated by a remember password
	form. a token can be used only once.
	errors:
		'invalid_token'` - token was not found or expired.

{type = 'code', code = }

	login using a temporary 6-digit code that was generated by a sign-in form.
	a sign-in code can be used only once.
	errors:
		'invalid_code' - code was not found or expired.

USER SWITCHING

Regardless of how the user is authenticated, the session cookie is
updated and it will be sent with the reply. If there was already a user
logged in before and it was a different user, the callback
`switch_user(new_usr, old_usr)` is called. If that previous user was
anonymous then that user is also deleted afterwards.

]==]

require'glue'
require'query'
require'schema'
require'blake3'
require'bcrypt'

local function fullname(firstname, lastname)
	return (catany('', firstname, lastname) or ''):trim()
end

--schema ---------------------------------------------------------------------

function auth_schema()

	import'schema_std'

	tables.tenant = {
		tenant      , idpk,
		name        , name,
		host        , name,
		active      , bool1,
		ctime       , ctime,
	}

	tables.usr = {
		usr         , idpk    ,
		tenant      , id      , not_null, fk,
		anonymous   , bool1   ,
		email       , email   , uk,
		emailvalid  , bool0   ,
		pass        , hash    ,
		active      , bool1   ,
		title       , name    ,
		name        , name    ,
		phone       , strid   ,
		phonevalid  , bool0   ,
		sex         , enum'M F O',
		birthday    , date    ,
		newsletter  , bool0   ,
		roles       , text    ,
		note        , text    ,
		theme       , strid   ,
		clientip    , strid   , --when it was created
		atime       , atime   , --last access time
		ctime       , ctime   , --creation time
		mtime       , mtime   , --last modification time
	}

	tables.sess = {
		token       , hash   , not_null, pk,
		usr         , id     , not_null, child_fk,
		expires     , time   , not_null,
		clientip    , strid  , --when it was created
		ctime       , ctime  ,
	}

	tables.usrtoken = {
		token       , hash   , not_null, pk,
		usr         , id     , not_null, child_fk,
		expires     , time   , not_null,
		validates   , enum'email phone', not_null,
		ctime       , ctime  ,
	}

	if _G.multilang() then

		import'lang'

		add_cols('usr after note', {
			lang        , lang    , weak_fk,
			country     , country , weak_fk,
		})

	end

end

--RULE: 'admin' roles can only create and update users with the same tenant as them.
--RULE: 'dev' roles can create and update users with any tenant.
--RULE: 'admin' roles cannot create or update 'dev' roles.
local function usr_insert_or_update_args(t, dt)
	t = update(dt or {}, t)
	t.roles = isstr(t.roles) and index(collect(words(t.roles))) or t.roles
	if http_request() then
		local roles = usr'roles'
		t.tenant = t.tenant or tenant()
		allow(roles.admin or roles.dev, 'must be admin or dev to create or update user')
		allow(roles.dev or not t.roles.dev, 'only devs can create or update devs')
		allow(roles.dev or t.tenant == tenant(), 'only devs can create/move users of/to other tenants')
	end
	t.roles = t.roles ~= nil and cat(keys(t.roles, true), ' ') or nil
	return t
end

function usr_create(t)
	t = usr_insert_or_update_args(t, {anonymous = false})
	return insert_row('usr', t)
end

function usr_update(t)
	t = usr_insert_or_update_args(t)
	update_row('usr', t)
	clear_userinfo_cache(t.usr)
end

function usr_create_or_update(t)
	t = usr_insert_or_update_args(t, {anonymous = false})
	t.usr = insert_or_update_row('usr', t)
	clear_userinfo_cache(t.usr)
	return t.usr
end

function usr_delete(usr_to_delete)
	if http_request() then
		local roles = usr'roles'
		allow(roles.dev or roles.admin, 'only devs and admins can remove users')
		allow(roles.dev or row.tenant == tenant(), 'only devs can remove users of other tenants')
	end
	delete_row('usr', {['usr:old'] = usr_to_delete})
	clear_userinfo_cache(usr_to_delete)
end

qmacro.usr     = function() return sqlval(usr()) end
qmacro.realusr = function() return sqlval(realusr()) end
qmacro.tenant  = function() return sqlval(tenant()) end

--config ---------------------------------------------------------------------

local webb_secret = memoize(function()
	local s = assert(config'secret', 'secret not configured')
	assert(#s >= 32, 'secret too short')
	return s
end)

local function secret_hash(s)
	local secret = webb_secret()
	local token = blake3(s, nil, secret)
	return tohex(token) --64 bytes
end

--session cookie -------------------------------------------------------------

--TODO: session cache and userinfo cache are never garbage-collected!

local session_cache = {}

local function session_cache_get(sid, time)
	local t = session_cache[sid]
	if not t then return nil end
	local usr, expires = t[1], t[2]
	if expires <= time then
		session_cache[sid] = nil
		return nil
	end
	return usr
end

local function session_cache_update(sid, usr, expires)
	if not usr then
		session_cache[sid] = nil
	else
		local t = session_cache[sid]
		if t then
			t[1], t[2] = usr, expires
		else
			t = {usr, expires}
			session_cache[sid] = t
		end
	end
end

local function load_session()
	local cookies = headers('cookie'); if not cookies then return end
	local session_cookie_name = config('session_cookie_name', 'session')
	local s = cookies[session_cookie_name]; if not s then return end
	local ver, s = s:match'^(%d)|(.*)$'; if not ver then return end
	ver = tonumber(ver); if not ver then return end
	if ver == 1 then
		local sid, sig = s:match'^(.-)|(.*)$'; if not sid then return end
		if secret_hash(sid) ~= sig then return end
		local now = time()
		local usr, expires = session_cache_get(sid, now)
		if not usr then
			usr, expires = first_row_vals([[
				select usr, expires
				from sess where token = ? and expires > ?
				]], sid, now)
			if not usr then return end
			session_cache_update(sid, usr, expires)
		end
		return {id = sid, usr = usr}
	end
end
local session = http_once_per_request(function()
	return load_session() or {}
end)

local function session_usr()
	return session().usr
end

local function save_session(sess)
	local secure_flag = config('session_cookie_secure_flag', true)
	local session_cookie_name = config('session_cookie_name', 'session')
	sess.expires = sess.expires or time() + 2 * 365 * 24 * 3600 --2 years
	if sess.usr then --login
		if not sess.id then
			sess.id = tohex(random_string(16))
			query([[
				insert into sess
					(token, expires, usr)
				values
					(?, ?, ?)
				]],
				sess.id,
				sess.expires,
				sess.usr
			)
		else
			query([[
				update sess set
					usr = ?,
					expires = ?
				where
					token = ?
				]], sess.usr, sess.expires, sess.id)
		end
		session_cache_update(sess.id, sess.usr, sess.expires)
		local sig = secret_hash(sess.id)
		setheader('set-cookie', {
			[session_cookie_name] = {
				value = '1|'..sess.id..'|'..sig,
				attrs = {
					Path = '/',
					Expires = sess.expires,
					Secure = secure_flag or nil, --prevent MITM
					HttpOnly = true, --prevent JS access
					SameSite = 'strict', --prevent BREACH (but also img tracking)
				},
			},
		})
	elseif sess.id then --logout
		query('delete from sess where token = ?', sess.id)
		session_cache_update(sess.id)
		sess.id = nil
		setheader('set-cookie', {
			[session_cookie_name] = {
				value = '0',
				attrs = {
					Path = '/',
					Expires = 0,
					Secure = secure_flag,
					HttpOnly = true,
					SameSite = 'strict',
				},
			},
		})
	end
end

local function save_usr(usr)
	local sess = session()
	if not sess.id or sess.usr ~= usr then
		sess.usr = usr
		save_session(sess)
	end
end

--authentication frontend ----------------------------------------------------

local auth = {} --auth.<type>(auth) -> usr, can_create

local function authenticate(a)
	local auth = auth[a and istab(a) and a.type or 'session']
	if not auth then
		return nil, 'invalid argument'
	end
	http_log('', 'auth', 'auth', '%s', a)
	local usr, err = auth(a)
	if usr then
		http_log('', 'auth', 'auth-ok', 'usr=%d', usr)
		return usr
	else
		http_log('', 'auth', 'auth-fail', '%s', err)
		return nil, err
	end
end

local weak_vals_mt = {__mode = 'v'}

local userinfo = memoize(function(usr)
	local t = usr and first_row([[
		select
			usr,
			tenant,
			anonymous,
			email,
			emailvalid,
			if(pass is not null, 1, 0) as haspass,
			roles,
			name,
			phone,
			phonevalid,
			#if multilang()
			lang,
			country,
			#endif
			theme,
			atime,
			ctime,
			mtime
		from
			usr
		where
			active = 1 and usr = ?
		]], usr)
	if not t then
		return {roles = {}}
	end
	t.haspass = tonumber(t.haspass) == 1
	t.roles = index(collect(words(t.roles) or {}))
	t.admin = t.roles.admin
	t.sessions = setmetatable({}, weak_vals_mt)
	return t
end)

function clear_userinfo_cache(usr)
	userinfo(poison, usr)
end

--session-cookie authentication ----------------------------------------------

local function valid_usr(usr)
	return userinfo(usr).usr
end

local function anonymous_usr(usr)
	return userinfo(usr).anonymous and usr
end

local function create_user()
	allow(config('allow_create_user', true))
	wait(0.1) --make flooding up the table a bit slower

	local tenant = check500(first_row([[
		select tenant from tenant where host = ?
	]], host()), 'no tenant for host %s', host())

	local usr = query([[
		insert into usr set
			tenant = :tenant,
			clientip = :clientip,
			#if multilang()
			lang = :lang,
			#endif
			atime = now(),
			ctime = now(),
			mtime = now()
	]], {
		tenant = tenant,
		clientip = client_ip(),
		lang = lang(),
	}).insert_id

	session().usr = usr

	return usr
end

function auth.session()
	local usr = valid_usr(session_usr())
	if not usr then
		if not config('auto_create_user', true) then
			return nil, 'session auth failed'
		end
		return create_user()
	end
	return usr
end

function auth.logout()
	save_usr(nil)
	return auth.session()
end

--anonymous authentication ---------------------------------------------------

function auth.anonymous()
	return anonymous_usr(session_usr()) or create_user()
end

--password authentication ----------------------------------------------------

local function email_pass_usr(email, pass)
	local u = first_row([[
		select u.usr, u.pass
		from usr u
		left join tenant t on t.tenant = u.tenant
		where
			u.active = 1
			and coalesce(t.active, 1) = 1
			and u.email = ?
		]], email, bcrypt_hash(pass))
	return u and bcrypt_verify(pass, u.pass) and u.usr or nil
end

local function email_usr(email)
	return first_row([[
		select usr from usr where
			email = ?
		]], email)
end

local function phone_usr(phone)
	return first_row([[
		select usr from usr where
			phone = ?
		]], phone)
end

local function delete_user(usr)
	query('delete from usr where usr = ?', usr)
end

--no-password authentication: enable only for debugging!
function auth.nopass(auth)
	if false then
		return first_row([[
			select u.usr
			from usr u
			left join tenant t on t.tenant = u.tenant
			where
				u.active = 1
				and coalesce(t.active, 1) = 1
				and email = ?
			]], auth.email)
	end
end

function auth.pass(auth)
	if auth.action == 'login' then
		local usr = email_pass_usr(auth.email, auth.pass)
		if not usr then
			return nil,
				S('invalid_email_or_pass', 'Invalid email or password'),
				'email_pass'
		else
			return usr
		end
	elseif auth.action == 'create' then
		local email = assert(json_str_arg(auth.email))
		assert(#email >= 1)
		local pass = assert(auth.pass)
		assert(isstr(pass) and #pass >= 1)
		if email_usr(email) then
			return nil,
				S('email_taken', 'Email already registered'),
				'email_taken'
		end
		local usr = anonymous_usr(session_usr()) or create_user()
		query([[
			update usr set
				anonymous = 0,
				emailvalid = 0,
				email = ?,
				pass = ?
			where
				usr = ?
			]], email, bcrypt_hash(pass), usr)
		clear_userinfo_cache(usr)
		return usr
	end
end

--one-time token or code authentication --------------------------------------

local function register_token(usr, token, validates, token_lifetime, token_maxcount)

	local now = time()
	local expires = now + token_lifetime

	--now it's a good time to garbage-collect expired tokens
	query('delete from usrtoken where expires <= from_unixtime(?)', now)

	--check if too many tokens were requested
	local n = first_row([[
		select count(1) from usrtoken where
			usr = ? and validates = ? and expires > from_unixtime(?)
		]], usr, validates, now)
	if tonumber(n) >= token_maxcount then
		return nil,
			S('too_many_tokens', 'Too many requests. Try again later.'),
			'too_many_tokens'
	end

	--add the token to db (break on collisions)
	query([[
		insert into usrtoken
			(token, usr, expires, validates, ctime)
		values
			(?, ?, ?, ?, ?)
		]], secret_hash(token), usr, expires, validates, now)

	return true
end

function gen_auth_token(email)

	local token_lifetime = config('auth_token_lifetime', 60 * 60)
	local token_maxcount = config('auth_token_maxcount', 2)

	--find the user with this email
	local usr = email_usr(email)
	if not usr then
		return nil,
			S('email_not_found', 'Email not registered'),
			'email_not_found'
	end

	local token = secret_hash(random_string(32))
	local ok, err = register_token(usr, token, 'email', token_lifetime, token_maxcount)
	http_log('note', 'auth', 'gen-token', 'usr=%s token=%s'..(ok and '' or ' error=%s'), usr, token, err)
	return ok and token or nil, err
end

function gen_auth_code(validates, s)

	local code_lifetime = config('auth_code_lifetime', 10 * 60)
	local code_maxcount = config('auth_code_maxcount', 6)

	local usr
	if validates == 'email' then
		usr = email_usr(s)
		if not usr then
			usr = anonymous_usr(session_usr()) or create_user() --grab a new one
			query('update usr set email = ? where usr = ?', s, usr)
			clear_userinfo_cache(usr)
		end
	elseif validates == 'phone' then
		usr = phone_usr(s)
		if not usr then
			usr = anonymous_usr(session_usr()) or create_user() --grab a new one
			query('update usr set phone = ? where usr = ?', s, usr)
			clear_userinfo_cache(usr)
		end
	else
		assert(false)
	end

	local code = secret_hash(random_string(64)):gsub('[a-f]', ''):sub(1, 6)
	assert(#code == 6)
	local ok, err = register_token(usr, code, validates, code_lifetime, code_maxcount)
	http_log('note', 'auth', 'gen-code', 'usr=%s code=%s validates=%s'..(ok and '' or ' error=%s'),
		usr, code, validates, err)
	return ok and code or nil, err
end

local function token_usr(token)
	if not token then return end
	wait(0.2) --slow down brute-forcing
	local t = first_row([[
		select ut.usr, ut.validates from
			usrtoken ut
			inner join usr u on u.usr = ut.usr
			left join tenant t on t.tenant = u.tenant
		where
			u.active = 1
			and coalesce(t.tenant, 1) = 1
			and ut.expires > now()
			and ut.token = ?
		]], secret_hash(token))
	if not t then return end
	return t.usr, t.validates
end

--one-time short code authentication -----------------------------------------

local function auth_token(token, auth)
	--find the user
	local usr, validates = token_usr(token)
	if not usr then
		return nil,
			S('invalid_token', 'Invalid token'),
			'invalid_token'
	end

	if validates == 'email' then
		query('update usr set emailvalid = 1, anonymous = 0 where usr = ?', usr)
		clear_userinfo_cache(usr)
	elseif validates == 'phone' then
		query('update usr set phonevalid = 1, anonymous = 0 where usr = ?', usr)
		clear_userinfo_cache(usr)
	end

	--remove the token because it's single use, and also to allow
	--the user to keep forgetting his password as much as he wants.
	query('delete from usrtoken where token = ?', secret_hash(token))

	return usr
end

function auth.token(auth)
	return auth_token(json_str_arg(auth.token))
end

function auth.code(auth)
	return auth_token(json_str_arg(auth.code))
end

--authentication logic -------------------------------------------------------

function try_login(auth, switch_user)
	switch_user = switch_user or pass
	local usr, err = authenticate(auth)
	if usr then
		local susr = valid_usr(session_usr())
		if susr and usr ~= susr then
			switch_user(susr, usr)
			if anonymous_usr(susr) then
				delete_user(susr)
				session().id = nil
				session().usr = nil
			end
		end
		save_usr(usr)
		local ui = userinfo(usr)
		setlang(ui.lang) --user lang has priority over action lang.
		setcountry(ui.country)
	end
	return usr, err
end
function login(...)
	return allow(try_login(...))
end

function realusr(attr)
	local usr = login()
	local t = userinfo(usr)
	if attr == '*' then
		return t
	elseif attr then
		return t[attr]
	else
		return usr
	end
end

function usr(attr)
	local realusr = login()
	local usr = args'usr' --impersonated user
	usr = usr and checkarg(id_arg(usr)) or realusr
	local u  = userinfo(usr)
	local ru = userinfo(realusr)
	if usr ~= realusr then
		allow(ru.roles.dev or ru.roles.admin and ru.tenant == u.tenant,
			'user impersonation denied')
	end
	u.realusr = realusr
	u.realusr_roles = ru.roles
	if attr == '*' then
		return u
	elseif attr then
		return u[attr]
	else
		return usr
	end
end

function tenant()
	return usr'tenant'
end

function touch_usr(usr)
	--only touch usr on page requests
	if args(1):find'%.' and not args(1):find'%.html$' then
		return
	end
	usr = usr or session_usr()
	if not usr then return end
	query([[
		update usr set
			atime = now(), mtime = mtime
		where usr = ?
	]], usr)
end

--update info (not really auth, but related) ---------------------------------

function auth.update(auth)

	local usr = allow(session_usr())
	local usr = userinfo(usr)
	allow(usr.usr)

	local email = json_str_arg(auth.email)
	local phone = json_str_arg(auth.phone)
	local pass  = json_str_arg(auth.pass)
	local name  = json_str_arg(auth.name)

	if email then
		local eusr = email_usr(email)
		if eusr and eusr ~= usr then
			return nil,
				S('email_taken', 'Email already registered'),
				'email_taken'
		end
	end

	if phone then
		local pusr = phone_usr(phone)
		if pusr and pusr ~= usr then
			return nil,
				S('phone_taken', 'Phone already registered'),
				'phone_taken'
		end
	end

	pass = pass and bcrypt_hash(pass)

	query([[
		update usr set
			#if auth.email then
				email = :email,
				emailvalid = if(email <=> :email, 0, emailvalid),
			#endif
			#if auth.phone then
				phone = :phone,
				phonevalid = if(phone <=> :phone, 0, phonevalid),
			#endif
			#if auth.pass then
				pass = :pass,
			#endif
			#if auth.name ~= nil then
				name = :name,
			#endif
			usr = usr
		where
			usr = :usr
		]], {
			auth = auth,
			email = email, phone = phone, pass = pass, name = name,
			usr = usr,
		})

	clear_userinfo_cache(usr)
	return usr
end

--self-test ------------------------------------------------------------------

if not ... then
	if false then
		http_run(auth_create_tables)
	else
		config('secret', '!xpAi$^!@#)fas!`5@cXiOZ{!9fdsjdkfh7zk')
		webb_request{
			uri = '/login.json',
			headers = {
				cookie = {
					session = '1|ac9b46efa4058b1bd70f47143c133193|93c11a3fb3d40bdf3d36c46e49c392107ddde9fd249857c9c8a9e11a022671fb',
				},
			},
		}
		http_run(function()
			--query('delete from usrtoken')
			--print(gen_auth_code('email', 'admin@mysite'))
			--prq(query'select * from sess')
			--prq(query'select * from usr')
		end)
	end
end

return auth_schema --so you can call schema:import'webb_auth'
