fix: login refuses a not-activated account while require_activation is on

The refusal is the same 401 invalid-login answer as a wrong password and is counted by the login throttle.
The activation is checked after the password and before the deactivation restore, so a refused account stays deactivated.
require_activation false keeps the previous behaviour.
This commit is contained in:
Jakub Zych
2026-10-22 21:25:53 +02:00
parent 930b1587a0
commit 15ff9793ba
6 changed files with 287 additions and 23 deletions

View File

@@ -44,6 +44,17 @@ var errNoAvatar = errors.New("golem15.user: no avatar")
const invalidLoginBody = "Invalid email or password"
// Login authenticates email and password and returns a JWT plus the user payload.
//
// It checks in this order: a ban or an active suspension
// (classes.RejectIfThrottled), the password (bcrypt), the activation, then
// restores a deactivated (deleted_at) account and sends it the reactivate
// mail, records the successful attempt and mints the token. With
// golem15.user.activation.require_activation on (the default) the correct
// password of a not-activated account gets the same 401 invalid-login answer
// as a wrong password and counts as a failed attempt for the throttle; an
// account that is also deactivated is refused before the restore, so it
// stays deactivated and gets no mail. With the key off such an account signs
// in as before. Activation gates login, deactivation does not.
func Login(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -75,13 +86,14 @@ func Login(app *backpack.App) http.HandlerFunc {
}
}
if user == nil || !bouncer.CheckPassword(user.Password, password) {
if user != nil {
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
writeOpaque500(w)
return
}
}
writeInvalidLogin(w, r, app)
refuseLogin(ctx, w, r, app, gdb, user, ip)
return
}
// The activation gate runs after the password check, so a wrong
// password and a not-activated account cost the same and answer the
// same, and before the restore, so a refused account is not revived.
if _, _, _, requireActivation := registrationSettings(app); requireActivation && !user.IsActivated {
refuseLogin(ctx, w, r, app, gdb, user, ip)
return
}
if user.DeletedAt.Valid {
@@ -1174,6 +1186,18 @@ func writeInvalidLogin(w http.ResponseWriter, r *http.Request, app *backpack.App
})
}
// refuseLogin is the one refusal /login gives after the throttle check: it
// records a failed attempt for user (a nil user, an unknown email, records
// nothing) and answers the 401 invalid-login body. The wrong-password and
// the not-activated branches share it, so their answers cannot be told apart.
func refuseLogin(ctx context.Context, w http.ResponseWriter, r *http.Request, app *backpack.App, gdb *gorm.DB, user *models.User, ip string) {
if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); err != nil && !classes.IsLoginRejected(err) {
writeOpaque500(w)
return
}
writeInvalidLogin(w, r, app)
}
func writeUnauthorized(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthorized"})
}