From d1a23760b1699c8249bf20e8df23e6bd352ee97b Mon Sep 17 00:00:00 2001 From: Taras Kushnir Date: Fri, 2 Jan 2026 20:37:42 +0200 Subject: [PATCH] Enforce 2FA code expiration timeout before session timeout --- pkg/portal/login.go | 1 + pkg/portal/settings.go | 10 ++++++++-- pkg/portal/twofactor.go | 17 +++++++++++++++-- pkg/session/common.go | 1 + 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pkg/portal/login.go b/pkg/portal/login.go index da02293e..e5bfe232 100644 --- a/pkg/portal/login.go +++ b/pkg/portal/login.go @@ -155,6 +155,7 @@ func (s *Server) postLogin(w http.ResponseWriter, r *http.Request) { _ = sess.Set(session.KeyUserEmail, user.Email) _ = sess.Set(session.KeyUserName, user.Name) _ = sess.Set(session.KeyTwoFactorCode, code) + _ = sess.Set(session.KeyTwoFactorCodeTimestamp, time.Now().UTC()) _ = sess.Set(session.KeyUserID, user.ID) // this is needed in case we will be routed to another server that does not have our session in memory // (previously we persisted ONLY logged in sessions, but if we're rerouted during login, it will break) diff --git a/pkg/portal/settings.go b/pkg/portal/settings.go index ade524e3..ee04d2ad 100644 --- a/pkg/portal/settings.go +++ b/pkg/portal/settings.go @@ -299,6 +299,7 @@ func (s *Server) editEmail(w http.ResponseWriter, r *http.Request) (*ViewModel, } func (s *Server) putGeneralSettings(w http.ResponseWriter, r *http.Request) (*ViewModel, error) { + tnow := time.Now().UTC() ctx := r.Context() user, err := s.SessionUser(ctx, s.Session(w, r)) @@ -332,13 +333,18 @@ func (s *Server) putGeneralSettings(w http.ResponseWriter, r *http.Request) (*Vi } sentCode, hasSentCode := sess.Get(ctx, session.KeyTwoFactorCode).(int) + codeTimestamp, ok := sess.Get(ctx, session.KeyTwoFactorCodeTimestamp).(time.Time) + if !ok { + slog.ErrorContext(ctx, "Failed to get verification code timestamp") + } formCode := r.FormValue(common.ParamVerificationCode) // we "used" the code now _ = sess.Delete(session.KeyTwoFactorCode) + _ = sess.Delete(session.KeyTwoFactorCodeTimestamp) - if enteredCode, err := strconv.Atoi(formCode); !hasSentCode || (err != nil) || (enteredCode != sentCode) { - slog.WarnContext(ctx, "Code verification failed", "actual", formCode, "expected", sentCode, common.ErrAttr(err)) + if enteredCode, err := strconv.Atoi(formCode); !hasSentCode || (err != nil) || (enteredCode != sentCode) || (!codeTimestamp.IsZero() && tnow.After(codeTimestamp.Add(twoFactorCodeDuration))) { + slog.WarnContext(ctx, "Code verification failed", "actual", formCode, "expected", sentCode, "timestamp", codeTimestamp, common.ErrAttr(err)) renderCtx.TwoFactorError = "Code is not valid." return &ViewModel{Model: renderCtx, View: settingsGeneralFormTemplate}, nil } diff --git a/pkg/portal/twofactor.go b/pkg/portal/twofactor.go index 7e6025d8..34d97009 100644 --- a/pkg/portal/twofactor.go +++ b/pkg/portal/twofactor.go @@ -6,16 +6,22 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/PrivateCaptcha/PrivateCaptcha/pkg/common" "github.com/PrivateCaptcha/PrivateCaptcha/pkg/session" ) +const ( + twoFactorCodeDuration = 10 * time.Minute +) + var ( renderContextNothing = struct{}{} ) func (s *Server) postTwoFactor(w http.ResponseWriter, r *http.Request) { + tnow := time.Now().UTC() ctx := r.Context() err := r.ParseForm() @@ -51,6 +57,11 @@ func (s *Server) postTwoFactor(w http.ResponseWriter, r *http.Request) { return } + codeTimestamp, ok := sess.Get(ctx, session.KeyTwoFactorCodeTimestamp).(time.Time) + if !ok { + slog.ErrorContext(ctx, "Failed to get verification code timestamp") + } + data := &loginRenderContext{ CsrfRenderContext: CsrfRenderContext{ Token: s.XSRF.Token(email), @@ -59,9 +70,9 @@ func (s *Server) postTwoFactor(w http.ResponseWriter, r *http.Request) { } formCode := strings.TrimSpace(r.FormValue(common.ParamVerificationCode)) - if enteredCode, err := strconv.Atoi(formCode); (err != nil) || (enteredCode != sentCode) { + if enteredCode, err := strconv.Atoi(formCode); (err != nil) || (enteredCode != sentCode) || (!codeTimestamp.IsZero() && tnow.After(codeTimestamp.Add(twoFactorCodeDuration))) { data.CodeError = "Code is not valid." - slog.WarnContext(ctx, "Code verification failed", "actual", formCode, "expected", sentCode, common.ErrAttr(err)) + slog.WarnContext(ctx, "Code verification failed", "actual", formCode, "expected", sentCode, "timestamp", codeTimestamp, common.ErrAttr(err)) s.render(w, r, "login/twofactor-form.html", data) return } @@ -84,6 +95,7 @@ func (s *Server) postTwoFactor(w http.ResponseWriter, r *http.Request) { _ = sess.Set(session.KeyLoginStep, loginStepCompleted) _ = sess.Delete(session.KeyTwoFactorCode) + _ = sess.Delete(session.KeyTwoFactorCodeTimestamp) _ = sess.Delete(session.KeyUserEmail) _ = sess.Set(session.KeyPersistent, true) @@ -124,5 +136,6 @@ func (s *Server) resend2fa(w http.ResponseWriter, r *http.Request) { } _ = sess.Set(session.KeyTwoFactorCode, code) + _ = sess.Set(session.KeyTwoFactorCodeTimestamp, time.Now().UTC()) s.render(w, r, "login/resend.html", renderContextNothing) } diff --git a/pkg/session/common.go b/pkg/session/common.go index 8f9b4000..d28e1c45 100644 --- a/pkg/session/common.go +++ b/pkg/session/common.go @@ -27,6 +27,7 @@ const ( KeyPersistent KeyNotificationID KeyReturnURL + KeyTwoFactorCodeTimestamp // Add new fields _above_ SESSION_KEYS_COUNT )