From f69106d41622b949751c70d69da785cd02447f13 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:49:37 +0300 Subject: [PATCH] 2fa: TOTP-based two-factor authentication --- templates/two_factor_login.html | 31 ++++++++ templates/two_factor_settings.html | 34 ++++++++ two_factor.v | 124 +++++++++++++++++++++++++++++ two_factor_routes.v | 112 ++++++++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 templates/two_factor_login.html create mode 100644 templates/two_factor_settings.html create mode 100644 two_factor.v create mode 100644 two_factor_routes.v diff --git a/templates/two_factor_login.html b/templates/two_factor_login.html new file mode 100644 index 0000000..5affc55 --- /dev/null +++ b/templates/two_factor_login.html @@ -0,0 +1,31 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+

%twofa_title

+

%twofa_login_prompt

+ + .form-error { + @ctx.form_error + } + +
+
+
+ +
+
+ +
+
+
+
+ + @include 'layout/footer.html' + + diff --git a/templates/two_factor_settings.html b/templates/two_factor_settings.html new file mode 100644 index 0000000..c0a0850 --- /dev/null +++ b/templates/two_factor_settings.html @@ -0,0 +1,34 @@ + + + + @include 'layout/head.html' + + + @include 'layout/header.html' + +
+

%twofa_title

+ + @if enabled +

%twofa_status_enabled

+
+ +
+ @else +

%twofa_setup_intro

+

%twofa_secret @secret

+

@provisioning_uri

+
+
+ +
+
+ +
+
+ @end +
+ + @include 'layout/footer.html' + + diff --git a/two_factor.v b/two_factor.v new file mode 100644 index 0000000..85e4f26 --- /dev/null +++ b/two_factor.v @@ -0,0 +1,124 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import time +import encoding.base32 +import encoding.binary +import crypto.hmac +import crypto.sha1 +import crypto.rand as crypto_rand + +struct TwoFactor { + id int @[primary; sql: serial] +mut: + user_id int + secret string + is_enabled bool + created_at int +} + +const totp_period = 30 +const totp_digits = 6 +const totp_issuer = 'Gitly' + +fn (mut app App) find_two_factor(user_id int) ?TwoFactor { + rows := sql app.db { + select from TwoFactor where user_id == user_id limit 1 + } or { []TwoFactor{} } + if rows.len == 0 { + return none + } + return rows.first() +} + +fn (mut app App) upsert_two_factor(user_id int, secret string, is_enabled bool) ! { + if existing := app.find_two_factor(user_id) { + id := existing.id + sql app.db { + update TwoFactor set secret = secret, is_enabled = is_enabled where id == id + }! + return + } + tf := TwoFactor{ + user_id: user_id + secret: secret + is_enabled: is_enabled + created_at: int(time.now().unix()) + } + sql app.db { + insert tf into TwoFactor + }! +} + +fn (mut app App) delete_two_factor(user_id int) ! { + sql app.db { + delete from TwoFactor where user_id == user_id + }! +} + +fn (mut app App) user_has_two_factor(user_id int) bool { + tf := app.find_two_factor(user_id) or { return false } + return tf.is_enabled +} + +fn generate_totp_secret() string { + mut buf := []u8{len: 20} + for i in 0 .. buf.len { + buf[i] = u8(crypto_rand.int_u64(256) or { 0 }) + } + enc := base32.encode_to_string(buf) + return enc.trim_right('=') +} + +fn decode_base32_secret(secret string) ![]u8 { + mut padded := secret.to_upper().replace(' ', '') + for padded.len % 8 != 0 { + padded += '=' + } + return base32.decode(padded.bytes())! +} + +fn hotp(key []u8, counter u64) int { + mut buf := []u8{len: 8} + binary.big_endian_put_u64(mut buf, counter) + mac := hmac.new(key, buf, sha1.sum, sha1.block_size) + offset := int(mac[mac.len - 1] & 0x0f) + bin := ((u32(mac[offset]) & 0x7f) << 24) | ((u32(mac[offset + 1]) & 0xff) << 16) | ((u32(mac[ + offset + 2]) & 0xff) << 8) | (u32(mac[offset + 3]) & 0xff) + mut modulus := u32(1) + for _ in 0 .. totp_digits { + modulus *= 10 + } + return int(bin % modulus) +} + +fn totp_code_for(secret string, t i64) !string { + key := decode_base32_secret(secret)! + counter := u64(t / totp_period) + code := hotp(key, counter) + mut s := code.str() + for s.len < totp_digits { + s = '0' + s + } + return s +} + +fn verify_totp(secret string, code string) bool { + if code.len != totp_digits { + return false + } + now := time.now().unix() + for offset in [i64(-1), 0, 1] { + expected := totp_code_for(secret, now + offset * totp_period) or { continue } + if expected == code { + return true + } + } + return false +} + +fn totp_provisioning_uri(username string, secret string) string { + label := '${totp_issuer}:${username}' + return 'otpauth://totp/${label}?secret=${secret}&issuer=${totp_issuer}&algorithm=SHA1&digits=${totp_digits}&period=${totp_period}' +} diff --git a/two_factor_routes.v b/two_factor_routes.v new file mode 100644 index 0000000..dba2301 --- /dev/null +++ b/two_factor_routes.v @@ -0,0 +1,112 @@ +// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by a GPL license that can be found in the LICENSE file. +module main + +import veb +import time +import crypto.hmac +import crypto.sha256 +import encoding.hex + +const two_factor_pending_cookie = 'pending_2fa' +const two_factor_pending_ttl = 300 // seconds + +fn (mut app App) pending_2fa_key(user User) []u8 { + return '${user.password}:${user.salt}'.bytes() +} + +fn (mut app App) sign_pending_2fa(user User, expires i64) string { + payload := '${user.id}:${expires}' + mac := hmac.new(app.pending_2fa_key(user), payload.bytes(), sha256.sum, sha256.block_size) + return '${payload}:${hex.encode(mac)}' +} + +fn (mut app App) verify_pending_2fa(token string) ?User { + parts := token.split(':') + if parts.len != 3 { + return none + } + user_id := parts[0].int() + expires := parts[1].i64() + sig := parts[2] + if expires < time.now().unix() { + return none + } + user := app.get_user_by_id(user_id) or { return none } + payload := '${user_id}:${expires}' + expected := hmac.new(app.pending_2fa_key(user), payload.bytes(), sha256.sum, sha256.block_size) + if hex.encode(expected) != sig { + return none + } + return user +} + +@['/login/2fa'] +pub fn (mut app App) two_factor_prompt(mut ctx Context) veb.Result { + pending := ctx.get_cookie(two_factor_pending_cookie) or { return ctx.redirect_to_login() } + app.verify_pending_2fa(pending) or { return ctx.redirect_to_login() } + return $veb.html('templates/two_factor_login.html') +} + +@['/login/2fa'; post] +pub fn (mut app App) handle_two_factor_login(mut ctx Context, code string) veb.Result { + pending := ctx.get_cookie(two_factor_pending_cookie) or { return ctx.redirect_to_login() } + user := app.verify_pending_2fa(pending) or { return ctx.redirect_to_login() } + tf := app.find_two_factor(user.id) or { return ctx.redirect_to_login() } + if !tf.is_enabled || !verify_totp(tf.secret, code.trim_space()) { + ctx.error('Invalid verification code') + return $veb.html('templates/two_factor_login.html') + } + ctx.set_cookie(name: two_factor_pending_cookie, value: '') + app.auth_user(mut ctx, user, ctx.ip()) or { + ctx.error('There was an error while logging in') + return ctx.redirect_to_login() + } + app.add_security_log(user_id: user.id, kind: .logged_in) or { app.info(err.str()) } + return ctx.redirect('/${user.username}') +} + +@['/:username/settings/2fa'] +pub fn (mut app App) view_two_factor_settings(mut ctx Context, username string) veb.Result { + if !ctx.logged_in || ctx.user.username != username { + return ctx.redirect_to_index() + } + tf := app.find_two_factor(ctx.user.id) or { + TwoFactor{ + user_id: ctx.user.id + } + } + enabled := tf.is_enabled + mut secret := '' + mut provisioning_uri := '' + if !enabled { + secret = if tf.secret == '' { generate_totp_secret() } else { tf.secret } + app.upsert_two_factor(ctx.user.id, secret, false) or {} + provisioning_uri = totp_provisioning_uri(username, secret) + } + return $veb.html('templates/two_factor_settings.html') +} + +@['/:username/settings/2fa/enable'; post] +pub fn (mut app App) handle_enable_two_factor(mut ctx Context, username string) veb.Result { + if !ctx.logged_in || ctx.user.username != username { + return ctx.redirect_to_index() + } + code := ctx.form['code'].trim_space() + tf := app.find_two_factor(ctx.user.id) or { return ctx.redirect('/${username}/settings/2fa') } + if !verify_totp(tf.secret, code) { + ctx.error('Invalid verification code') + return ctx.redirect('/${username}/settings/2fa') + } + app.upsert_two_factor(ctx.user.id, tf.secret, true) or {} + return ctx.redirect('/${username}/settings/2fa') +} + +@['/:username/settings/2fa/disable'; post] +pub fn (mut app App) handle_disable_two_factor(mut ctx Context, username string) veb.Result { + if !ctx.logged_in || ctx.user.username != username { + return ctx.redirect_to_index() + } + app.delete_two_factor(ctx.user.id) or {} + return ctx.redirect('/${username}/settings/2fa') +} -- 2.39.5