fix: activate-by-code asks the login gates before it signs in

After the mailed code verifies, ActivateByCode consults the login gates
with source password, as Login does. A gate answer (for example a
second-factor challenge) replaces the token response and sets no session
cookie; a gate error answers the opaque 500. The account is activated
either way. Without a gate, or when every gate allows, the answer is
unchanged. With require_activation off an account can enable a second
factor before it activates, so the mailed code no longer stands in for it.
This commit is contained in:
Jakub Zych
2026-11-05 17:54:21 +01:00
parent 97f83cc2cf
commit b179e6b2e5
5 changed files with 181 additions and 12 deletions

View File

@@ -588,8 +588,15 @@ func Activate(app *backpack.App) http.HandlerFunc {
}
}
// ActivateByCode activates from a public "{id}!{code}" value and mints a token.
// With golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
// ActivateByCode activates from a public "{id}!{code}" value, consults the
// login gates and mints a token. The gates run as on password Login, with
// source password, because the mailed code is the first factor here: with
// golem15.user.activation.require_activation off an account can enable a
// second factor before it activates, and the code must not stand in for it.
// A gate answer replaces the token response with its status and body and a
// gate error answers the opaque 500; neither mints a token or sets a
// session cookie, and the account stays activated either way. With
// golem15.user.jwt.token_cookie on, the token goes into the auth.cookie
// session cookie instead of the body.
func ActivateByCode(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -638,6 +645,28 @@ func ActivateByCode(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": "This activation link is invalid or has expired"})
return
}
// The activation above is committed whatever the gates answer. A
// gate answer replaces the token response; a gate error fails
// closed.
gate, err := CheckLoginGates(r.Context(), app, classes.LoginAttempt{
User: user,
Source: classes.LoginSourcePassword,
Fields: fields,
Request: r,
})
if err != nil {
pluginLog.Error("golem15.user: login gate", "user_id", user.ID, "err", err)
writeOpaque500(w)
return
}
if gate != nil {
status := gate.Status
if status == 0 {
status = http.StatusOK
}
writeJSON(w, status, gate.Body)
return
}
token, err := mintFor(app, secret, user, requestURL(r))
if err != nil {
writeOpaque500(w)

View File

@@ -12,8 +12,9 @@ import (
// nil allows the sign-in, a result replaces the token response, and an error
// must fail the sign-in closed (answer the opaque 500, issue no token). It
// looks the registry up on every call and allows the sign-in when none is
// published. Password Login calls it; a social-login plugin calls it before
// it issues a token.
// published. Password Login calls it, activate-by-code calls it after the
// mailed code verified (source password), and a social-login plugin calls
// it before it issues a token.
func CheckLoginGates(ctx context.Context, app *backpack.App, a classes.LoginAttempt) (*classes.LoginGateResult, error) {
if app == nil {
return nil, nil