Files
vikunja/pkg/user/totp.go
kolaente 0f98c19ab6 fix: add TTL-based expiry and cleanup for used TOTP passcode entries
Store a unix timestamp instead of a boolean, and treat entries older
than 90 seconds as expired. A background goroutine lazily cleans up
expired keys after each successful validation to prevent unbounded
growth in the keyvalue store.
2026-03-23 10:34:49 +00:00

248 lines
6.8 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program 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 <https://www.gnu.org/licenses/>.
package user
import (
"fmt"
"image"
"strconv"
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"xorm.io/xorm"
)
// TOTP holds a user's totp setting in the database.
type TOTP struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
UserID int64 `xorm:"bigint not null" json:"-"`
Secret string `xorm:"text not null" json:"secret"`
// The totp entry will only be enabled after the user verified they have a working totp setup.
Enabled bool `xorm:"null" json:"enabled"`
// The totp url used to be able to enroll the user later
URL string `xorm:"text null" json:"url"`
}
// TableName holds the table name for totp secrets
func (t *TOTP) TableName() string {
return "totp"
}
// TOTPPasscode is used to validate a users totp passcode
type TOTPPasscode struct {
User *User `json:"-"`
Passcode string `json:"passcode"`
}
// TOTPEnabledForUser checks if totp is enabled for a user - not if it is activated, use GetTOTPForUser to check that.
func TOTPEnabledForUser(s *xorm.Session, user *User) (bool, error) {
if !config.ServiceEnableTotp.GetBool() {
return false, nil
}
t := &TOTP{}
_, err := s.Where("user_id = ?", user.ID).Get(t)
return t.Enabled, err
}
// GetTOTPForUser returns the current state of totp settings for the user.
func GetTOTPForUser(s *xorm.Session, user *User) (t *TOTP, err error) {
t = &TOTP{}
exists, err := s.Where("user_id = ?", user.ID).Get(t)
if err != nil {
return
}
if !exists {
return nil, ErrTOTPNotEnabled{}
}
return
}
// EnrollTOTP creates a new TOTP entry for the user - it does not enable it yet.
func EnrollTOTP(s *xorm.Session, user *User) (t *TOTP, err error) {
isEnrolled, err := s.Where("user_id = ?", user.ID).Exist(&TOTP{})
if err != nil {
return
}
if isEnrolled {
return nil, ErrTOTPAlreadyEnabled{}
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Vikunja",
AccountName: user.Username,
})
if err != nil {
return
}
t = &TOTP{
UserID: user.ID,
Secret: key.Secret(),
Enabled: false,
URL: key.URL(),
}
_, err = s.Insert(t)
return
}
// EnableTOTP enables totp for a user. The provided passcode is used to verify the user has a working totp setup.
func EnableTOTP(s *xorm.Session, passcode *TOTPPasscode) (err error) {
t, err := ValidateTOTPPasscode(s, passcode)
if err != nil {
return
}
_, err = s.
Where("id = ?", t.ID).
Cols("enabled").
Update(&TOTP{Enabled: true})
return
}
// DisableTOTP removes all totp settings for a user.
func DisableTOTP(s *xorm.Session, user *User) (err error) {
_, err = s.
Where("user_id = ?", user.ID).
Delete(&TOTP{})
return
}
// ValidateTOTPPasscode validated totp codes of users.
func ValidateTOTPPasscode(s *xorm.Session, passcode *TOTPPasscode) (t *TOTP, err error) {
t, err = GetTOTPForUser(s, passcode.User)
if err != nil {
return
}
if !totp.Validate(passcode.Passcode, t.Secret) {
return nil, ErrInvalidTOTPPasscode{Passcode: passcode.Passcode}
}
// Prevent passcode reuse within the validity window.
// Store the timestamp when the passcode was used; treat entries older than
// 90 seconds (30s TOTP window + clock skew) as expired.
const totpUsedTTL = 90 * time.Second
usedKey := fmt.Sprintf("totp_used_%s_%s", strconv.FormatInt(passcode.User.ID, 10), passcode.Passcode)
val, exists, err := keyvalue.Get(usedKey)
if err != nil {
return nil, err
}
if exists {
if usedAt, ok := val.(int64); ok && time.Since(time.Unix(usedAt, 0)) < totpUsedTTL {
return nil, ErrTOTPPasscodeUsed{}
}
// Entry expired — allow reuse, overwrite below
}
// Mark this passcode as used with the current timestamp
err = keyvalue.Put(usedKey, time.Now().Unix())
if err != nil {
return nil, err
}
// Lazily clean up expired entries to prevent unbounded growth
go cleanupExpiredTOTPKeys(totpUsedTTL)
return
}
func cleanupExpiredTOTPKeys(ttl time.Duration) {
keys, err := keyvalue.ListKeys("totp_used_")
if err != nil {
return
}
for _, key := range keys {
val, exists, err := keyvalue.Get(key)
if err != nil || !exists {
continue
}
if usedAt, ok := val.(int64); ok && time.Since(time.Unix(usedAt, 0)) >= ttl {
_ = keyvalue.Del(key)
}
}
}
// GetTOTPQrCodeForUser returns a qrcode for a user's totp setting
func GetTOTPQrCodeForUser(s *xorm.Session, user *User) (qrcode image.Image, err error) {
t, err := GetTOTPForUser(s, user)
if err != nil {
return
}
key, err := otp.NewKeyFromURL(t.URL)
if err != nil {
return
}
return key.Image(300, 300)
}
// HandleFailedTOTPAuth handles informing the user of failed TOTP attempts and blocking the account after 10 attempts
func HandleFailedTOTPAuth(s *xorm.Session, user *User) {
log.Errorf("Invalid TOTP credentials provided for user %d", user.ID)
key := user.GetFailedTOTPAttemptsKey()
err := keyvalue.IncrBy(key, 1)
if err != nil {
log.Errorf("Could not increase failed TOTP attempts for user %d: %s", user.ID, err)
return
}
a, _, err := keyvalue.Get(key)
if err != nil {
log.Errorf("Could get failed TOTP attempts for user %d: %s", user.ID, err)
return
}
attempts := a.(int64)
if attempts == 3 {
err = notifications.Notify(user, &InvalidTOTPNotification{User: user}, s)
if err != nil {
log.Errorf("Could not send failed TOTP notification to user %d: %s", user.ID, err)
return
}
}
if attempts < 10 {
return
}
log.Infof("Blocking user account %d after 10 failed TOTP password attempts", user.ID)
err = RequestUserPasswordResetToken(s, user)
if err != nil {
log.Errorf("Could not reset password of user %d after 10 failed TOTP attempts: %s", user.ID, err)
return
}
err = notifications.Notify(user, &PasswordAccountLockedAfterInvalidTOTPNotification{
User: user,
}, s)
if err != nil {
log.Errorf("Could send password information mail to user %d after 10 failed TOTP attempts: %s", user.ID, err)
return
}
err = user.SetStatus(s, StatusAccountLocked)
if err != nil {
log.Errorf("Could not disable user %d: %s", user.ID, err)
}
}