0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-22 18:38:31 +02:00
Nicolas c0c11c551c
fix: enforce single-use TOTP passcodes across all 2FA surfaces
The web 2FA login and password-reset paths validated the passcode and then
wrote LastUsedPasscode in a non-atomic read-check-write sequence, so two
parallel submissions of the same code could each authenticate (TOCTOU). The
Basic-Auth X-Gitea-OTP path never recorded the used passcode at all, letting
a captured code be replayed for its whole validity window.

Add TwoFactor.ValidateAndConsumeTOTP, which validates and atomically marks
the passcode used via a conditional UPDATE (rejecting replays and racing
duplicates), and route the web login, password-reset, and Basic-Auth paths
through it.

Assisted-by: Claude:claude-opus-4-8
2026-06-13 18:38:06 +02:00

159 lines
3.9 KiB
Go

// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"net/http"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/web"
"gitea.dev/services/context"
"gitea.dev/services/forms"
)
var (
tplTwofa templates.TplName = "user/auth/twofa"
tplTwofaScratch templates.TplName = "user/auth/twofa_scratch"
)
// TwoFactor shows the user a two-factor authentication page.
func TwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
if performAutoLogin(ctx) {
return
}
// Ensure user is in a 2FA session.
if ctx.Session.Get("twofaUid") == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
ctx.HTML(http.StatusOK, tplTwofa)
}
// TwoFactorPost validates a user's two-factor authentication token.
func TwoFactorPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
ctx.Data["Title"] = ctx.Tr("twofa")
// Ensure user is in a 2FA session.
idSess := ctx.Session.Get("twofaUid")
if idSess == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
id := idSess.(int64)
twofa, err := auth.GetTwoFactorByUID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
// Validate the passcode and atomically consume it to prevent reuse/replay.
ok, err := twofa.ValidateAndConsumeTOTP(ctx, form.Passcode)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ok {
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if ctx.Session.Get("linkAccount") != nil {
err = linkAccountFromContext(ctx, u)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
}
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
handleSignIn(ctx, u, remember)
return
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
}
// TwoFactorScratch shows the scratch code form for two-factor authentication.
func TwoFactorScratch(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa_scratch")
if performAutoLogin(ctx) {
return
}
// Ensure user is in a 2FA session.
if ctx.Session.Get("twofaUid") == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
ctx.HTML(http.StatusOK, tplTwofaScratch)
}
// TwoFactorScratchPost validates and invalidates a user's two-factor scratch token.
func TwoFactorScratchPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorScratchAuthForm)
ctx.Data["Title"] = ctx.Tr("twofa_scratch")
// Ensure user is in a 2FA session.
idSess := ctx.Session.Get("twofaUid")
if idSess == nil {
ctx.ServerError("UserSignIn", errors.New("not in 2FA session"))
return
}
id := idSess.(int64)
twofa, err := auth.GetTwoFactorByUID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
// Validate the passcode with the stored TOTP secret.
if twofa.VerifyScratchToken(form.Token) {
// Invalidate the scratch token.
_, err = twofa.GenerateScratchToken()
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
if err = auth.UpdateTwoFactor(ctx, twofa); err != nil {
ctx.ServerError("UserSignIn", err)
return
}
remember := ctx.Session.Get("twofaRemember").(bool)
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
ctx.ServerError("UserSignIn", err)
return
}
handleSignInFull(ctx, u, remember)
if ctx.Written() {
return
}
ctx.Flash.Info(ctx.Tr("auth.twofa_scratch_used"))
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_scratch_token_incorrect"), tplTwofaScratch, forms.TwoFactorScratchAuthForm{})
}