| 1 | // Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. |
| 2 | // Use of this source code is governed by a GPL license that can be found in the LICENSE file. |
| 3 | module main |
| 4 | |
| 5 | import veb |
| 6 | import time |
| 7 | import crypto.hmac |
| 8 | import crypto.sha256 |
| 9 | import encoding.hex |
| 10 | |
| 11 | const two_factor_pending_cookie = 'pending_2fa' |
| 12 | const two_factor_pending_ttl = 300 // seconds |
| 13 | |
| 14 | fn (mut app App) pending_2fa_key(user User) []u8 { |
| 15 | return '${user.password}:${user.salt}'.bytes() |
| 16 | } |
| 17 | |
| 18 | fn (mut app App) sign_pending_2fa(user User, expires i64) string { |
| 19 | payload := '${user.id}:${expires}' |
| 20 | mac := hmac.new(app.pending_2fa_key(user), payload.bytes(), sha256.sum, sha256.block_size) |
| 21 | return '${payload}:${hex.encode(mac)}' |
| 22 | } |
| 23 | |
| 24 | fn (mut app App) verify_pending_2fa(token string) ?User { |
| 25 | parts := token.split(':') |
| 26 | if parts.len != 3 { |
| 27 | return none |
| 28 | } |
| 29 | user_id := parts[0].int() |
| 30 | expires := parts[1].i64() |
| 31 | sig := parts[2] |
| 32 | if expires < time.now().unix() { |
| 33 | return none |
| 34 | } |
| 35 | user := app.get_user_by_id(user_id) or { return none } |
| 36 | payload := '${user_id}:${expires}' |
| 37 | expected := hmac.new(app.pending_2fa_key(user), payload.bytes(), sha256.sum, sha256.block_size) |
| 38 | if hex.encode(expected) != sig { |
| 39 | return none |
| 40 | } |
| 41 | return user |
| 42 | } |
| 43 | |
| 44 | @['/login/2fa'] |
| 45 | pub fn (mut app App) two_factor_prompt(mut ctx Context) veb.Result { |
| 46 | pending := ctx.get_cookie(two_factor_pending_cookie) or { return ctx.redirect_to_login() } |
| 47 | app.verify_pending_2fa(pending) or { return ctx.redirect_to_login() } |
| 48 | return $veb.html('templates/two_factor_login.html') |
| 49 | } |
| 50 | |
| 51 | @['/login/2fa'; post] |
| 52 | pub fn (mut app App) handle_two_factor_login(mut ctx Context, code string) veb.Result { |
| 53 | pending := ctx.get_cookie(two_factor_pending_cookie) or { return ctx.redirect_to_login() } |
| 54 | user := app.verify_pending_2fa(pending) or { return ctx.redirect_to_login() } |
| 55 | tf := app.find_two_factor(user.id) or { return ctx.redirect_to_login() } |
| 56 | if !tf.is_enabled || !verify_totp(tf.secret, code.trim_space()) { |
| 57 | ctx.error('Invalid verification code') |
| 58 | return $veb.html('templates/two_factor_login.html') |
| 59 | } |
| 60 | ctx.set_cookie(name: two_factor_pending_cookie, value: '') |
| 61 | app.auth_user(mut ctx, user, ctx.ip()) or { |
| 62 | ctx.error('There was an error while logging in') |
| 63 | return ctx.redirect_to_login() |
| 64 | } |
| 65 | app.add_security_log(user_id: user.id, kind: .logged_in) or { app.info(err.str()) } |
| 66 | return ctx.redirect('/${user.username}') |
| 67 | } |
| 68 | |
| 69 | @['/:username/settings/2fa'] |
| 70 | pub fn (mut app App) view_two_factor_settings(mut ctx Context, username string) veb.Result { |
| 71 | if !ctx.logged_in || ctx.user.username != username { |
| 72 | return ctx.redirect_to_index() |
| 73 | } |
| 74 | tf := app.find_two_factor(ctx.user.id) or { |
| 75 | TwoFactor{ |
| 76 | user_id: ctx.user.id |
| 77 | } |
| 78 | } |
| 79 | enabled := tf.is_enabled |
| 80 | mut secret := '' |
| 81 | mut provisioning_uri := '' |
| 82 | if !enabled { |
| 83 | secret = if tf.secret == '' { generate_totp_secret() } else { tf.secret } |
| 84 | app.upsert_two_factor(ctx.user.id, secret, false) or {} |
| 85 | provisioning_uri = totp_provisioning_uri(username, secret) |
| 86 | } |
| 87 | return $veb.html('templates/two_factor_settings.html') |
| 88 | } |
| 89 | |
| 90 | @['/:username/settings/2fa/enable'; post] |
| 91 | pub fn (mut app App) handle_enable_two_factor(mut ctx Context, username string) veb.Result { |
| 92 | if !ctx.logged_in || ctx.user.username != username { |
| 93 | return ctx.redirect_to_index() |
| 94 | } |
| 95 | code := ctx.form['code'].trim_space() |
| 96 | tf := app.find_two_factor(ctx.user.id) or { return ctx.redirect('/${username}/settings/2fa') } |
| 97 | if !verify_totp(tf.secret, code) { |
| 98 | ctx.error('Invalid verification code') |
| 99 | return ctx.redirect('/${username}/settings/2fa') |
| 100 | } |
| 101 | app.upsert_two_factor(ctx.user.id, tf.secret, true) or {} |
| 102 | return ctx.redirect('/${username}/settings/2fa') |
| 103 | } |
| 104 | |
| 105 | @['/:username/settings/2fa/disable'; post] |
| 106 | pub fn (mut app App) handle_disable_two_factor(mut ctx Context, username string) veb.Result { |
| 107 | if !ctx.logged_in || ctx.user.username != username { |
| 108 | return ctx.redirect_to_index() |
| 109 | } |
| 110 | app.delete_two_factor(ctx.user.id) or {} |
| 111 | return ctx.redirect('/${username}/settings/2fa') |
| 112 | } |
| 113 | |