forked from snap-cloud/snapCloud
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathvalidation.lua
575 lines (522 loc) · 19.3 KB
/
validation.lua
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
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
-- Validation and errors
-- =====================
--
-- Written by Bernat Romagosa and Michael Ball
--
-- Copyright (C) 2019 by Bernat Romagosa and Michael Ball
--
-- This file is part of Snap Cloud.
--
-- Snap Cloud is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
-- published by the Free Software Foundation, either version 3 of
-- the License, or (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <http://www.gnu.org/licenses/>.
local yield_error = package.loaded.yield_error
local db = package.loaded.db
local Collections = package.loaded.Collections
local CollectionMemberships = package.loaded.CollectionMemberships
local Users = package.loaded.Users
local Projects = package.loaded.Projects
local Tokens = package.loaded.Tokens
local url = require 'socket.url'
local exceptions = require 'lib.exceptions'
local socket = require('socket')
require 'responses'
require 'email'
-- TODO: This shouldn't be global.
-- Move specific types of errors (e.g. disk) to respective locations.
err = {
not_logged_in = { msg = 'You are not logged in', status = 401 },
auth = {
msg = 'You do not have permission to perform this action',
status = 403 },
wrong_password = {
msg = 'The provided password is wrong',
status = 403 },
nonexistent_user =
{ msg = 'No user with this username exists', status = 404 },
nonexistent_email =
{ msg = 'No users are associated to this email account', status = 404 },
nonexistent_project =
{ msg = 'This project does not exist or is private', status = 404 },
nonexistent_collection =
{ msg = 'This collection does not exist', status = 404 },
expired_token = { msg = 'This token has expired', status = 401 },
invalid_token =
{ msg = 'This token is either invalid or has expired', status = 401 },
nonvalidated_user =
{ msg = 'Your user has not been validated.', status = 403 },
nonvalidated_user_plaintext = {
msg = 'This user has not been validated within the first 3 days ' ..
'after its creation.\nPlease use the cloud menu to ask for ' ..
'a new validation link.',
status = 401 },
nonvalidated_user_html = {
msg = '<p>This user has not been validated within the first 3 days ' ..
'after its creation.</p>' ..
'<p>Please <a href="/run">open the Snap<em>!</em> editor</a> and' ..
' then use the cloud menu to ask for a new validation link:</p>' ..
'<p><img src="/static/cloud_menu.png" alt="Snap! User Menu"></img></p>',
status = 401 },
invalid_role = { msg = 'This user role is not valid', status = 401 },
banned = { msg = 'Your user has been banned', status = 403 },
user_too_new = {
msg = 'Your user has not had enough activity in this community' ..
' and it cannot yet create collections.',
status = 403 },
update_project_fail =
{ msg = 'Project could not be updated', status = 500 },
unparseable_xml =
{ msg = 'Project file could not be parsed', status = 500 },
file_not_found = { msg = 'Project file not found', status = 404 },
mail_body_empty = { msg = 'Missing email body contents', status = 400 },
project_already_in_collection =
{ msg = 'This project is already in that collection', status = 409 },
collection_contains_unshared_projects = {
msg = 'This collection cannot be shared' ..
' as it contains private projects',
status = 409 },
collection_contains_unpublished_projects = {
msg = 'This collection cannot be published' ..
' as it contains unpublished projects',
status = 409 },
unpublished_project_in_ffa_collection = {
msg = 'You need to first publish your project before you can add it' ..
' to this collection',
status = 409 },
too_many_password_resets = {
msg = 'A password reset email has already been sent to this user ' ..
'recently.<br/>Please check your spam folder, or wait a few ' ..
'minutes and try again.',
status = 429 },
project_already_flagged =
{ msg = 'You have already flagged this project.', status = 409 },
project_never_flagged =
{ msg = 'This project was not flagged by you.', status = 404 },
method_not_allowed = {
msg = 'This API endpoint does not respond to this HTTP method.',
status = 405 },
too_fast =
{ msg = 'Too many requests. Slow down.', status = 429 },
too_soon =
{ msg = 'Please wait a few seconds and try again.', status = 429 },
session_reused =
{ msg = 'Please use the Snap! site, not scripts.', status = 409 },
tor_not_allowed =
{ msg = 'Sorry. We cannot let you use Tor for that.', status = 403 },
teacher_account_required = {
msg = 'You must be verified as a teacher to perform this action.',
status = 403
},
student_cannot_change_email = {
msg = 'Student account email addresses cannot be changed. Please contact your instructor for help.',
status = 403
},
generic_not_found = { msg = 'The requested resource does not exist.', status = 404 }
}
-- NOTE: From now on, define local functions, and export them at the bottom of this file.
local assert_exists = function (resource)
if not resource then
yield_error(err.generic_not_found)
end
return resource
end
local assert_current_user_logged_in = function(self)
if not self.current_user then
yield_error(err.not_logged_in)
end
return self.current_user
end
-- The remaining functions are all global.
assert_all = function (assertions, self)
for _, assertion in pairs(assertions) do
if (type(assertion) == 'string') then
_G['assert_' .. assertion](self)
else
assertion(self)
end
end
end
-- User permissions and roles
-- User roles:
-- standard: Can view published and shared projects, can do anything to own
-- projects, can see basic user profile data. Can delete oneself.
-- reviewer: Same as standard, plus: Can unpublish projects.
-- moderator: Same as reviewer, plus: Can delete published and shared projects.
-- Can block users. Can delete users. Can verify users.
-- admin: Can do everything.
-- banned: Same as a standard user, but can't modify or add anything.
assert_role = function (self, role, message)
if not self.current_user then
yield_error(message or err.not_logged_in)
elseif self.current_user.role ~= role then
yield_error(message or err.auth)
end
end
assert_min_role = function (self, expected_role)
if not self.current_user:has_min_role(expected_role) then
yield_error(err.auth)
end
end
assert_admin = function (self, message)
assert_role(self, 'admin', message)
end
assert_can_set_role = function (self, role)
-- TODO use a numeric lookup table like in models >> User >> has_min_role
local can_set = {
admin = {
admin =
{ admin = true, moderator = true, reviewer = true,
standard = true, banned = true },
moderator =
{ admin = true, moderator = true, reviewer = true,
standard = true, banned = true },
reviewer =
{ admin = true, moderator = true, reviewer = true,
standard = true, banned = true },
standard =
{ admin = true, moderator = true, reviewer = true,
standard = true, banned = true },
banned =
{ admin = true, moderator = true, reviewer = true,
standard = true, banned = true }
},
moderator = {
admin = {}, moderator = {},
reviewer =
{ moderator = true, reviewer = true, standard = true,
banned = true },
standard =
{ moderator = true, reviewer = true, standard = true,
banned = true },
banned =
{ moderator = true, reviewer = true, standard = true,
banned = true }
},
reviewer = {
admin = {}, moderator = {}, reviewer = {}, banned = {},
standard = { reviewer = true, standard = true }
},
standard =
{ admin = {}, moderator = {}, reviewer = {}, standard = {},
banned = {} },
banned =
{ admin = {}, moderator = {}, reviewer = {}, standard = {},
banned = {} }
}
if not can_set[self.current_user.role][self.queried_user.role][role] then
yield_error(err.auth)
end
end
users_match = function (self)
return (self.session.username == tostring(self.params.username))
end
assert_users_match = function (self, message)
if (not users_match(self)) then
-- Someone is trying to impersonate someone else
yield_error(message or err.auth)
end
end
assert_user_exists = function (self, message)
if not self.queried_user then
yield_error(message or err.nonexistent_user)
end
return self.queried_user
end
assert_users_have_email = function (self, message)
local users =
Users:select(
'where email = ?',
self.params.email or '',
{ fields = 'username' })
if users and users[1] then
return users
else
yield_error(message or err.nonexistent_email)
end
end
assert_user_can_create_accounts = function(self)
assert_current_user_logged_in(self)
if self.current_user:isadmin() then return end
if not self.current_user.verified then
yield_error(err.nonvalidated_user)
end
if not self.current_user.is_teacher then
yield_error(err.teacher_account_required)
end
end
-- Projects and Collections
assert_can_share = function (self, item)
assert_current_user_logged_in(self)
assert_exists(item)
if item.type == 'project' then
if (item.username ~= self.current_user.username) then
assert_min_role(self, 'reviewer')
end
elseif item.type == 'collection' then
if (item.creator_id ~= self.current_user.id) then
assert_min_role(self, 'reviewer')
end
end
end
assert_can_delete = function (self, item)
assert_current_user_logged_in(self)
assert_exists(item)
if item.type == 'project' then
if (item.username ~= self.current_user.username) then
assert_min_role(self, 'moderator')
end
elseif item.type == 'collection' then
if (item.creator_id ~= self.current_user.id) then
assert_min_role(self, 'moderator')
end
end
end
assert_project_exists = function (self, project)
local proj = self.project or project
if not proj then
yield_error(err.nonexistent_project)
end
return proj
end
assert_can_view_project = function (self, project)
local proj = self.project or project
if (not proj.ispublished and not proj.ispublic
and not users_match(self)
and not (
(self.current_user ~= nil) and self.current_user:isadmin()
)
)
then
yield_error(err.nonexistent_project)
end
end
-- Tokens
check_token = function (self, token, purpose, on_success)
if token then
local query =
db.select("date_part('day', now() - ?::timestamp)",
token.created)[1]
if query.date_part < 4 and token.purpose == purpose then
local user = Users:find({ username = token.username })
-- Do not delete user verification tokens just yet
if purpose ~= 'verify_user' then token:delete() end
return on_success(user)
elseif token.purpose ~= purpose then
-- We simply ignore tokens with different purposes
return html_message_page(self, 'Invalid token', '<p>' ..
err.invalid_token.msg .. '</p>')
else
-- We delete expired tokens with 'verify_user' purpose
token:delete()
return html_message_page(self, 'Expired token', '<p>' ..
err.expired_token.msg .. '</p>')
end
else
-- This token does not exist anymore, or never existed
return html_message_page(self, 'Invalid token', '<p>' ..
err.invalid_token.msg .. '</p>')
end
end
find_token = function (username, purpose)
return Tokens:find({ username = username, purpose = purpose})
end
--- Creates a token and sends an email
-- @param self: request object
-- @param purpose string: token purpose and route name
-- @param username string
-- @param email string
create_token = function (self, purpose, user)
local token_value
assert_exists(user)
-- First check whether there's an existing token for the same user and
-- purpose. If we find it, we'll just reset its creation date and reuse it.
local existing_token = find_token(user.username, purpose)
if existing_token then
token_value = existing_token.value
existing_token:update({
created = db.format_date()
})
else
token_value = secure_token()
Tokens:create({
username = user.username,
created = db.format_date(),
value = token_value,
purpose = purpose
})
end
send_mail(
user.email,
mail_subjects[purpose] .. user.username,
mail_bodies[purpose],
self:build_url(self:url_for(
purpose,
{
username = url.build_path({ user.username }),
token = url.build_path({ token_value })
}
))
)
end
-- Collections
local can_edit_collection = function (self, collection)
-- Users can edit their own collections
assert_current_user_logged_in(self)
assert_exists(collection)
return (collection.creator_id == self.current_user.id) or
is_editor(self, collection)
end
is_editor = function (self, collection)
if collection.editor_ids then
for _, editor_id in pairs(collection.editor_ids) do
if editor_id == self.current_user.id then return true end
end
end
return false
end
assert_collection_exists = function (self)
local creator = Users:find({ username = tostring(self.params.username) })
if not creator then
yield_error(err.nonexistent_collection)
end
local collection = Collections:find(creator.id, self.params.name)
if not collection then
yield_error(err.nonexistent_collection)
end
return collection
end
assert_can_view_collection = function (self, collection)
if (not collection.shared and not collection.published
and not (
users_match(self) or
can_edit_collection(self, collection))
) then
yield_error(err.nonexistent_collection)
end
end
assert_can_add_project_to_collection = function (self, project, collection)
-- Admins can add any project to any collection.
if self.current_user:isadmin() then return end
-- Users can add their own published projects to "free for all" collections
if collection.free_for_all and
project.username == self.current_user.username then
if not project.ispublished then
yield_error(err.unpublished_project_in_ffa_collection)
else
return
end
end
-- Users can add their own projects and published projects to collections
-- they can edit
if can_edit_collection(self, collection) then
return project.username == self.current_user.username or
project.ispublished
end
yield_error(err.nonexistent_project)
end
assert_project_not_in_collection = function (self, project, collection)
-- We can't add a project twice to a collection
if CollectionMemberships:find(collection.id, project.id) then
yield_error(err.project_already_in_collection)
end
end
assert_can_create_collection = function (self)
-- Spammer guard.
-- Non-validated users and users without at least a project can't create
-- collections.
if not self.current_user.verified then
yield_error(err.nonvalidated_user)
end
local project_count =
Projects:select(
'where username = ?',
self.current_user.username,
{ fields = 'count(*) as count'}
)[1].count
if (project_count == 0) then
yield_error(err.user_too_new)
end
end
-- Project name filter
-- Matches project names that are typical in courses like BJC or Teals.
course_name_filter = function ()
local expressions = {
'^[0-9]+\\.[0-9]+',
'u[0-9]+l[0-9]+',
'm[0-9]+l[0-9]+',
'^lab *[0-9]+',
'^unit([0-9]+| )',
'^ap ',
'create *task',
'^coin *flip',
'week *[0-9]+',
'lesson *[0-9]+',
'task *[0-9]+',
'do now'
}
local filter = ''
for _, expression in pairs(expressions) do
filter = filter .. ' and (projectname !~* ' ..
"'" .. expression .. "')"
end
return filter
end
-- Rate limiting
rate_limit = function (self)
if ngx.shared.session_cache:get(self.session.access_id) or
(self.session.access_id == nil) then
self.session.first_access = os.time()
self.session.access_id = socket.gettime() .. '-' .. math.random()
yield_error(err.session_reused)
exceptions.rvn:captureMessage(err.session_reused)
else
ngx.shared.session_cache:set(self.session.access_id, true)
self.session.access_id = socket.gettime() .. '-' .. math.random()
end
if ((os.time() - self.session.first_access) < 2) then
yield_error(err.too_soon)
exceptions.rvn:captureMessage(err.too_soon)
end
if self.session.current_access_time == nil then
self.session.current_access_time = os.time()
return
end
self.session.previous_access_time = self.session.current_access_time
self.session.current_access_time = os.time()
if self.session.allowed_time_difference == nil then
self.session.allowed_time_difference = 2
end
local time_diff =
(self.session.current_access_time - self.session.previous_access_time)
if time_diff < self.session.allowed_time_difference then
-- you're being punished with double time, capped at 30 secs
self.session.allowed_time_difference =
math.min(self.session.allowed_time_difference * 2, 30)
yield_error(err.too_fast)
exceptions.rvn:captureMessage(err.too_fast)
else
self.session.allowed_time_difference = 2
end
end
-- Block certain requests from being made using Tor
prevent_tor_access = function (self)
local file = io.open('lib/torbulkexitlist', 'r')
local tor_ips = file:read('all')
local ip = ngx.var.remote_addr
if tor_ips:find(ip) then
yield_error(err.tor_not_allowed)
end
end
return {
assert_exists = assert_exists,
assert_current_user_logged_in = assert_current_user_logged_in
}