gitlyx / user / user.v
529 lines · 447 sloc · 13.52 KB · 13ca859655a005b2a87a4935764694302b4f2fda
Raw
1module main
2
3import crypto.sha256
4import crypto.bcrypt
5import time
6import os
7
8// bcrypt_cost is the work factor for password hashing. 12 is a good balance of
9// security and speed on current hardware.
10const bcrypt_cost = 12
11
12struct User {
13 id int @[primary; sql: serial]
14 full_name string
15 username string @[unique]
16 github_username string
17 password string
18 salt string
19 created_at time.Time
20 is_github bool
21 is_registered bool
22 is_blocked bool
23 is_admin bool
24 oauth_state string @[skip]
25mut:
26 // for github oauth XSRF protection
27 namechanges_count int
28 last_namechange_time int
29 posts_count int
30 last_post_time int
31 avatar string
32 emails []Email @[skip]
33 login_attempts int
34}
35
36struct Email {
37 id int @[primary; sql: serial]
38 user_id int
39 email string @[unique]
40}
41
42struct Contributor {
43 id int @[primary; sql: serial]
44 user_id int @[unique: 'contributor']
45 repo_id int @[unique: 'contributor']
46}
47
48pub fn (mut app App) set_user_block_status(user_id int, status bool) ! {
49 sql app.db {
50 update User set is_blocked = status where id == user_id
51 }!
52}
53
54pub fn (mut app App) set_user_admin_status(user_id int, status bool) ! {
55 sql app.db {
56 update User set is_admin = status where id == user_id
57 }!
58}
59
60// hash_password_with_salt returns a bcrypt hash of the password. bcrypt
61// generates and embeds its own random salt with a tunable cost factor, so the
62// `salt` argument is ignored for new hashes (kept only so existing callers and
63// the User.salt column are unaffected).
64fn hash_password_with_salt(password string, salt string) string {
65 return bcrypt.generate_from_password(password.bytes(), bcrypt_cost) or { '' }
66}
67
68// compare_password_with_hash verifies a password against a stored hash. It
69// accepts both new bcrypt hashes ($2...) and legacy salted-SHA-256 hashes, so
70// users created before the bcrypt migration can still log in; their hash is
71// upgraded to bcrypt on next login (see maybe_upgrade_password_hash).
72fn compare_password_with_hash(password string, salt string, hashed string) bool {
73 if password_hash_is_legacy(hashed) {
74 legacy := sha256.sum('${password}${salt}'.bytes()).hex()
75 return legacy == hashed
76 }
77 bcrypt.compare_hash_and_password(password.bytes(), hashed.bytes()) or { return false }
78 return true
79}
80
81// password_hash_is_legacy reports whether a stored hash uses the old
82// salted-SHA-256 scheme (anything that is not a bcrypt `$2...` hash) and should
83// be upgraded to bcrypt after a successful login.
84fn password_hash_is_legacy(hashed string) bool {
85 return !hashed.starts_with('$2')
86}
87
88// maybe_upgrade_password_hash rehashes a legacy password with bcrypt after the
89// user has successfully authenticated, so old SHA-256 hashes are phased out
90// transparently. The plaintext password is only available at login time.
91fn (mut app App) maybe_upgrade_password_hash(user User, password string) {
92 if !password_hash_is_legacy(user.password) {
93 return
94 }
95 new_hash := hash_password_with_salt(password, '')
96 if new_hash == '' {
97 return
98 }
99 app.update_user_password_hash(user.id, new_hash) or {
100 app.info('failed to upgrade password hash for user ${user.id}: ${err}')
101 }
102}
103
104fn (mut app App) update_user_password_hash(user_id int, hashed string) ! {
105 sql app.db {
106 update User set password = hashed where id == user_id
107 }!
108}
109
110pub fn (mut app App) register_user(username string, password string, salt string, emails []string, github bool, is_admin bool) !bool {
111 mut user := app.get_user_by_username(username) or { User{} }
112
113 if user.id != 0 && user.is_registered {
114 app.info('User ${username} already exists')
115 return error('username `${username}` is already taken')
116 }
117
118 // A non-registered row with this username exists (e.g. a GitHub shadow user).
119 // Block normal registration; the GitHub flow handles upgrading shadow users itself.
120 if user.id != 0 && !github {
121 app.info('Username ${username} is reserved by an unregistered/shadow user')
122 return error('username `${username}` is already taken')
123 }
124
125 user = app.get_user_by_email(emails[0]) or { User{} }
126
127 if user.id != 0 && user.is_registered {
128 app.info('Email ${emails[0]} is already in use')
129 return error('email `${emails[0]}` is already in use')
130 }
131
132 if user.id == 0 {
133 // Final guard: make sure no Email row points at this address even if
134 // the parent user lookup didn't surface (orphaned/duplicate rows).
135 if app.email_exists(emails[0]) {
136 return error('email `${emails[0]}` is already in use')
137 }
138
139 user = User{
140 username: username
141 password: password
142 salt: salt
143 created_at: time.now()
144 is_registered: true
145 is_github: github
146 github_username: username
147 avatar: default_avatar_name
148 is_admin: is_admin
149 }
150
151 app.add_user(user) or {
152 if is_unique_constraint_error(err) {
153 return error('username `${username}` or email `${emails[0]}` is already in use')
154 }
155 return err
156 }
157
158 mut u := app.get_user_by_username(user.username) or {
159 app.info('User was not inserted')
160 return error('user `${username}` was not inserted (lookup after insert failed: ${err})')
161 }
162
163 if u.password != user.password {
164 app.info('User was not inserted (password mismatch after insert)')
165 return error('user `${username}` was not inserted (password mismatch after insert)')
166 }
167 if u.username != user.username {
168 app.info('User was not inserted (username mismatch after insert)')
169 return error('user `${username}` was not inserted (username mismatch after insert: got `${u.username}`)')
170 }
171
172 app.add_activity(u.id, 'joined')!
173
174 for email in emails {
175 app.add_email(u.id, email) or {
176 if is_unique_constraint_error(err) {
177 return error('email `${email}` is already in use')
178 }
179 return err
180 }
181 }
182
183 u.emails = app.find_user_emails(u.id)
184 } else {
185 // Update existing user
186 if !github {
187 app.create_user_dir(username)
188
189 return true
190 }
191
192 if user.is_registered {
193 sql app.db {
194 update User set is_github = true where id == user.id
195 }!
196 return true
197 }
198 }
199 app.create_user_dir(username)
200
201 return true
202}
203
204fn is_unique_constraint_error(err IError) bool {
205 return err.msg().to_lower().contains('unique constraint')
206}
207
208pub fn (app App) email_exists(value string) bool {
209 rows := sql app.db {
210 select from Email where email == value limit 1
211 } or { [] }
212 return rows.len > 0
213}
214
215fn (mut app App) create_user_dir(username string) {
216 user_path := '${app.config.repo_storage_path}/${username}'
217
218 os.mkdir(user_path) or {
219 app.info('Failed to create ${user_path}')
220 app.info('Error: ${err}')
221 return
222 }
223}
224
225pub fn (mut app App) update_user_avatar(user_id int, filename_or_url string) ! {
226 sql app.db {
227 update User set avatar = filename_or_url where id == user_id
228 }!
229}
230
231pub fn (mut app App) add_user(user User) ! {
232 sql app.db {
233 insert user into User
234 }!
235}
236
237pub fn (mut app App) add_email(user_id int, email string) ! {
238 user_email := Email{
239 user_id: user_id
240 email: email
241 }
242
243 sql app.db {
244 insert user_email into Email
245 }!
246}
247
248pub fn (mut app App) add_contributor(user_id int, repo_id int) ! {
249 if !app.contains_contributor(user_id, repo_id) {
250 contributor := Contributor{
251 user_id: user_id
252 repo_id: repo_id
253 }
254
255 sql app.db {
256 insert contributor into Contributor
257 }!
258 }
259}
260
261pub fn (app App) get_username_by_id(id int) ?string {
262 users := sql app.db {
263 select from User where id == id limit 1
264 } or { [] }
265
266 if users.len == 0 {
267 return none
268 }
269
270 return users.first().username
271}
272
273pub fn (app App) get_user_by_username(value string) ?User {
274 users := sql app.db {
275 select from User where username == value limit 1
276 } or { [] }
277
278 if users.len == 0 {
279 return none
280 }
281
282 mut user := users.first()
283 emails := app.find_user_emails(user.id)
284 user.emails = emails
285
286 return user
287}
288
289pub fn (app App) get_user_by_id(id int) ?User {
290 users := sql app.db {
291 select from User where id == id
292 } or { [] }
293
294 if users.len == 0 {
295 return none
296 }
297
298 mut user := users.first()
299 emails := app.find_user_emails(user.id)
300 user.emails = emails
301
302 return user
303}
304
305pub fn (mut app App) get_user_by_github_username(name string) ?User {
306 users := sql app.db {
307 select from User where github_username == name limit 1
308 } or { [] }
309
310 if users.len == 0 {
311 return none
312 }
313
314 mut user := users.first()
315 emails := app.find_user_emails(user.id)
316 user.emails = emails
317
318 return user
319}
320
321pub fn (mut app App) get_user_by_email(value string) ?User {
322 emails := sql app.db {
323 select from Email where email == value
324 } or { [] }
325
326 if emails.len != 1 {
327 return none
328 }
329
330 return app.get_user_by_id(emails[0].user_id)
331}
332
333pub fn (app App) find_user_emails(user_id int) []Email {
334 emails := sql app.db {
335 select from Email where user_id == user_id
336 } or { [] }
337
338 return emails
339}
340
341pub fn (mut app App) find_repo_registered_contributor(id int) []User {
342 contributors := sql app.db {
343 select from Contributor where repo_id == id
344 } or { [] }
345 mut users := []User{cap: contributors.len}
346 for contributor in contributors {
347 user := app.get_user_by_id(contributor.user_id) or { continue }
348
349 users << user
350 }
351 return users
352}
353
354pub fn (mut app App) get_all_registered_users_as_page(offset int) []User {
355 // FIXME: 30 -> admin_users_per_page
356 mut users := sql app.db {
357 select from User where is_registered == true limit 30 offset offset
358 } or { [] }
359 for i, user in users {
360 users[i].emails = app.find_user_emails(user.id)
361 }
362 return users
363}
364
365pub fn (mut app App) get_all_registered_user_count() int {
366 return sql app.db {
367 select count from User where is_registered == true
368 } or { 0 }
369}
370
371fn (mut app App) search_users(query string) []User {
372 q :=
373 'select id, full_name, username, avatar from ${sql_table('User')} where is_blocked is false and ' +
374 '(username like ${sql_like_pattern(query)} or full_name like ${sql_like_pattern(query)})'
375 repo_rows := db_exec_values(mut app.db, q) or { return [] }
376 mut users := []User{}
377 for row in repo_rows {
378 users << User{
379 id: row[0].int()
380 full_name: row[1]
381 username: row[2]
382 avatar: row[3]
383 }
384 }
385 return users
386}
387
388pub fn (mut app App) get_users_count() !int {
389 return sql app.db {
390 select count from User
391 }!
392}
393
394pub fn (mut app App) get_count_repo_contributors(id int) !int {
395 return sql app.db {
396 select count from Contributor where repo_id == id
397 } or { 0 }
398}
399
400pub fn (mut app App) contains_contributor(user_id int, repo_id int) bool {
401 count := sql app.db {
402 select count from Contributor where repo_id == repo_id && user_id == user_id
403 } or { 0 }
404 return count > 0
405}
406
407pub fn (mut app App) increment_user_post(mut user User) ! {
408 user.posts_count++
409
410 u := *user
411 id := u.id
412 now := int(time.now().unix())
413 lastplus := int(time.unix(u.last_post_time).add_days(1).unix())
414
415 if now >= lastplus {
416 user.last_post_time = now
417 sql app.db {
418 update User set posts_count = 0, last_post_time = now where id == id
419 }!
420 }
421
422 sql app.db {
423 update User set posts_count = posts_count + 1 where id == id
424 }!
425}
426
427pub fn (mut app App) increment_user_login_attempts(user_id int) ! {
428 sql app.db {
429 update User set login_attempts = login_attempts + 1 where id == user_id
430 }!
431}
432
433pub fn (mut app App) update_user_login_attempts(user_id int, attempts int) ! {
434 sql app.db {
435 update User set login_attempts = attempts where id == user_id
436 }!
437}
438
439pub fn (mut app App) check_user_blocked(user_id int) bool {
440 user := app.get_user_by_id(user_id) or { return false }
441 return user.is_blocked
442}
443
444fn (mut app App) change_username(user_id int, username string) ! {
445 sql app.db {
446 update User set username = username where id == user_id
447 }!
448
449 sql app.db {
450 update Repo set user_name = username where user_id == user_id
451 }!
452}
453
454fn (mut app App) change_full_name(user_id int, full_name string) ! {
455 sql app.db {
456 update User set full_name = full_name where id == user_id
457 }!
458}
459
460fn (mut app App) incement_namechanges(user_id int) ! {
461 now := int(time.now().unix())
462 sql app.db {
463 update User set namechanges_count = namechanges_count + 1, last_namechange_time = now
464 where id == user_id
465 }!
466}
467
468fn (mut app App) check_username(username string) (bool, User) {
469 if username.len == 0 {
470 return false, User{}
471 }
472 mut user := app.get_user_by_username(username) or { return false, User{} }
473 return user.is_registered, user
474}
475
476pub fn (mut app App) auth_user(mut ctx Context, user User, ip string) ! {
477 token := app.add_token(user.id, ip)!
478 app.update_user_login_attempts(user.id, 0)!
479 expire_date := time.now().add_days(200)
480 // HttpOnly keeps the session token out of reach of JavaScript (so an XSS
481 // payload can't steal it); SameSite=Lax stops the cookie from riding along
482 // on cross-site requests, mitigating CSRF. Set `secure: true` as well when
483 // deploying behind HTTPS.
484 ctx.set_cookie(
485 name: 'token'
486 value: token
487 expires: expire_date
488 path: '/'
489 http_only: true
490 same_site: .same_site_lax_mode
491 )
492}
493
494pub fn (mut app App) is_logged_in(mut ctx Context) bool {
495 token_cookie := ctx.get_cookie('token') or { return false }
496 token := app.get_token(token_cookie) or { return false }
497 is_user_blocked := app.check_user_blocked(token.user_id)
498 if is_user_blocked {
499 app.handle_logout(mut ctx)
500 return false
501 }
502 return true
503}
504
505pub fn (mut app App) get_user_from_cookies(ctx &Context) ?User {
506 token_cookie := ctx.get_cookie('token') or { return none }
507 token := app.get_token(token_cookie) or { return none }
508 mut user := app.get_user_by_id(token.user_id) or { return none }
509 return user
510}
511
512// activity_level maps a per-day commit count to a heatmap intensity level 0..4,
513// scaled by the user's busiest day across the window.
514fn activity_level(count int, max int) int {
515 if count <= 0 || max <= 0 {
516 return 0
517 }
518 ratio := f64(count) / f64(max)
519 if ratio > 0.75 {
520 return 4
521 }
522 if ratio > 0.5 {
523 return 3
524 }
525 if ratio > 0.25 {
526 return 2
527 }
528 return 1
529}
530