summaryrefslogtreecommitdiff
path: root/auth.cgi
blob: 3005f0207c5f1e9b7c5b7b7efd5ec61d86db287c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
#!/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 = 800
	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)