feat(auth): add staged account request validation and admin review workflow
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled

This commit is contained in:
2026-04-16 20:06:58 +00:00
parent 81727dd3e9
commit b2b024d0b6
16 changed files with 805 additions and 30 deletions
+6
View File
@@ -58,3 +58,9 @@ Project Change ID[date-time] - application-version - Type - Summary:
- 1 - I added `ShouldEnableNewAccountRequestNotificationsFallback` in `models/user/setting_options.go` so the code can detect when the deleted user is the last active admin with this notification enabled and the acting admin is currently unsubscribed.
- 2 - I modified `routers/web/admin/users.go` so, after a successful admin-driven user deletion, the acting admin is automatically subscribed to new account request notifications when they deleted the last subscribed admin.
- 3 - I modified `routers/api/v1/admin/user.go` so the same automatic fallback also applies to admin deletions performed through the API, keeping the behavior consistent across both deletion entry points.
10 - [2026-04-16 23:02:55] - v.1.27.0-dev-42-g81727dd3e9 - Type: Added - Implemented a staged new account request workflow with email validation, admin review, request statuses, and automatic expiry cleanup.
- 1 - I added `models/user/account_request.go` to store and manage account request states, validation expiry, retry counting, approval or rejection metadata, validation code generation, and expired pending-request cleanup.
- 2 - I modified `routers/web/auth/auth.go`, added `routers/web/auth/account_request.go`, and updated `templates/user/auth/signup_inner.tmpl` so registration now creates pending account requests, resends validation mail when appropriate, blocks repeated unconfirmed attempts after five tries, and moves validated requests into administrator review instead of activating them immediately.
- 3 - I modified `routers/web/admin/users.go`, added `routers/web/admin/account_request.go`, updated `routers/web/web.go`, and updated `templates/admin/user/edit.tmpl` so administrators can see the account request status for a user and explicitly activate, reject, or unblock requests from the admin user edit page.
- 4 - I modified `services/mailer/mail_user.go`, added the new account request mail templates, updated `options/locale/locale_en-US.json`, and modified `services/user/user.go` with `services/cron/tasks_extended.go` so the workflow now sends dedicated validation, approval, and rejection emails, preserves rejected or blocked accounts from inactive-user cleanup, and automatically deletes only expired requests that never completed email validation.
+276
View File
@@ -0,0 +1,276 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/optional"
"xorm.io/builder"
)
const (
SettingsKeyAccountRequestStatus = "account_request.status"
SettingsKeyAccountRequestExpiresUnix = "account_request.expires_unix"
SettingsKeyAccountRequestSignupAttempts = "account_request.signup_attempts"
SettingsKeyAccountRequestEmailValidatedUnix = "account_request.email_validated_unix"
SettingsKeyAccountRequestReviewedUnix = "account_request.reviewed_unix"
SettingsKeyAccountRequestReviewedBy = "account_request.reviewed_by"
AccountRequestStatusPendingEmailValidation = "pending_email_validation"
AccountRequestStatusPendingAdminReview = "pending_admin_review"
AccountRequestStatusRejected = "rejected"
AccountRequestStatusBlocked = "blocked"
AccountRequestValidationHours = 48
AccountRequestMaxAttempts = 5
)
func GetAccountRequestStatus(ctx context.Context, user *User) (string, error) {
return GetUserSetting(ctx, user.ID, SettingsKeyAccountRequestStatus)
}
func SetAccountRequestStatus(ctx context.Context, userID int64, status string) error {
if status == "" {
return DeleteUserSetting(ctx, userID, SettingsKeyAccountRequestStatus)
}
return SetUserSetting(ctx, userID, SettingsKeyAccountRequestStatus, status)
}
func getAccountRequestInt64(ctx context.Context, userID int64, key string) (int64, error) {
value, err := GetUserSetting(ctx, userID, key)
if err != nil || value == "" {
return 0, err
}
return strconv.ParseInt(value, 10, 64)
}
func setAccountRequestInt64(ctx context.Context, userID int64, key string, value int64) error {
if value == 0 {
return DeleteUserSetting(ctx, userID, key)
}
return SetUserSetting(ctx, userID, key, strconv.FormatInt(value, 10))
}
func GetAccountRequestExpiry(ctx context.Context, user *User) (time.Time, error) {
unix, err := getAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestExpiresUnix)
if err != nil || unix == 0 {
return time.Time{}, err
}
return time.Unix(unix, 0), nil
}
func RefreshAccountRequestValidationWindow(ctx context.Context, user *User) error {
return setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestExpiresUnix, time.Now().Add(AccountRequestValidationHours*time.Hour).Unix())
}
func GetAccountRequestSignupAttempts(ctx context.Context, user *User) (int, error) {
value, err := getAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestSignupAttempts)
return int(value), err
}
func SetAccountRequestSignupAttempts(ctx context.Context, userID int64, attempts int) error {
return setAccountRequestInt64(ctx, userID, SettingsKeyAccountRequestSignupAttempts, int64(attempts))
}
func IsAccountRequestExpired(ctx context.Context, user *User) (bool, error) {
status, err := GetAccountRequestStatus(ctx, user)
if err != nil || status != AccountRequestStatusPendingEmailValidation {
return false, err
}
expiresAt, err := GetAccountRequestExpiry(ctx, user)
if err != nil || expiresAt.IsZero() {
return false, err
}
return time.Now().After(expiresAt), nil
}
func ActivateAccountRequestPrimaryEmail(ctx context.Context, user *User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
addr, exist, err := db.Get[EmailAddress](ctx, builder.Eq{"uid": user.ID, "lower_email": strings.ToLower(user.Email)})
if err != nil {
return err
}
if !exist {
return fmt.Errorf("no such email: %d (%s)", user.ID, user.Email)
}
if addr.IsActivated {
return nil
}
return updateActivation(ctx, addr, true)
})
}
func ResetAccountRequestToPendingEmailValidation(ctx context.Context, user *User) error {
if err := SetAccountRequestStatus(ctx, user.ID, AccountRequestStatusPendingEmailValidation); err != nil {
return err
}
if err := RefreshAccountRequestValidationWindow(ctx, user); err != nil {
return err
}
if err := SetAccountRequestSignupAttempts(ctx, user.ID, 1); err != nil {
return err
}
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestEmailValidatedUnix)
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestReviewedUnix)
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestReviewedBy)
user.ProhibitLogin = false
_, err := db.GetEngine(ctx).ID(user.ID).Cols("prohibit_login").Update(user)
return err
}
func IncrementAccountRequestSignupAttempts(ctx context.Context, user *User) (attempts int, blocked bool, _ error) {
attempts, err := GetAccountRequestSignupAttempts(ctx, user)
if err != nil {
return 0, false, err
}
attempts++
if err := SetAccountRequestSignupAttempts(ctx, user.ID, attempts); err != nil {
return 0, false, err
}
if attempts < AccountRequestMaxAttempts {
return attempts, false, nil
}
if err := SetAccountRequestStatus(ctx, user.ID, AccountRequestStatusBlocked); err != nil {
return 0, false, err
}
user.ProhibitLogin = true
_, err = db.GetEngine(ctx).ID(user.ID).Cols("prohibit_login").Update(user)
return attempts, true, err
}
func MarkAccountRequestPendingAdminReview(ctx context.Context, user *User) error {
if err := ActivateAccountRequestPrimaryEmail(ctx, user); err != nil {
return err
}
if err := SetAccountRequestStatus(ctx, user.ID, AccountRequestStatusPendingAdminReview); err != nil {
return err
}
if err := setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestEmailValidatedUnix, time.Now().Unix()); err != nil {
return err
}
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestExpiresUnix)
return nil
}
func RejectAccountRequest(ctx context.Context, user *User, reviewerID int64) error {
if err := SetAccountRequestStatus(ctx, user.ID, AccountRequestStatusRejected); err != nil {
return err
}
if err := setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestReviewedUnix, time.Now().Unix()); err != nil {
return err
}
if err := setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestReviewedBy, reviewerID); err != nil {
return err
}
user.ProhibitLogin = true
_, err := db.GetEngine(ctx).ID(user.ID).Cols("prohibit_login").Update(user)
return err
}
func ApproveAccountRequest(ctx context.Context, user *User, reviewerID int64) error {
if err := ActivateAccountRequestPrimaryEmail(ctx, user); err != nil {
return err
}
var err error
user.IsActive = true
user.ProhibitLogin = false
if user.Rands, err = GetUserSalt(); err != nil {
return err
}
if err := UpdateUserCols(ctx, user, "is_active", "prohibit_login", "rands"); err != nil {
return err
}
if err := setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestReviewedUnix, time.Now().Unix()); err != nil {
return err
}
if err := setAccountRequestInt64(ctx, user.ID, SettingsKeyAccountRequestReviewedBy, reviewerID); err != nil {
return err
}
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestStatus)
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestExpiresUnix)
_ = DeleteUserSetting(ctx, user.ID, SettingsKeyAccountRequestSignupAttempts)
return nil
}
func UnblockAccountRequest(ctx context.Context, user *User) error {
return ResetAccountRequestToPendingEmailValidation(ctx, user)
}
func GenerateAccountRequestValidationCode(user *User) string {
data := makeTimeLimitCodeHashData(&TimeLimitCodeOptions{Purpose: TimeLimitCodeActivateAccount}, user)
code := base.CreateTimeLimitCode(data, AccountRequestValidationHours*60, time.Now(), nil)
code += fmt.Sprintf("%x", []byte(user.LowerName))
return code
}
func VerifyAccountRequestValidationCode(ctx context.Context, code string) (user *User) {
if user = GetVerifyUser(ctx, code); user != nil {
prefix := code[:base.TimeLimitCodeLength]
data := makeTimeLimitCodeHashData(&TimeLimitCodeOptions{Purpose: TimeLimitCodeActivateAccount}, user)
if base.VerifyTimeLimitCode(time.Now(), data, AccountRequestValidationHours*60, prefix) {
return user
}
}
return nil
}
func GetUserByAnyEmail(ctx context.Context, email string) (*User, error) {
if len(email) == 0 {
return nil, ErrUserNotExist{Name: email}
}
addr := &EmailAddress{LowerEmail: strings.ToLower(strings.TrimSpace(email))}
has, err := db.GetEngine(ctx).Get(addr)
if err != nil {
return nil, err
}
if !has {
return nil, ErrUserNotExist{Name: email}
}
return GetUserByID(ctx, addr.UID)
}
func DeleteExpiredPendingAccountRequests(ctx context.Context) error {
users, _, err := SearchUsers(ctx, SearchUserOptions{
Types: []UserType{UserTypeIndividual, UserTypeUserReserved},
IsActive: optional.Some(false),
ListOptions: db.ListOptionsAll,
})
if err != nil {
return err
}
for _, user := range users {
status, err := GetAccountRequestStatus(ctx, user)
if err != nil || status != AccountRequestStatusPendingEmailValidation {
if err != nil {
return err
}
continue
}
expired, err := IsAccountRequestExpired(ctx, user)
if err != nil {
return err
}
if !expired {
continue
}
if _, err := db.GetEngine(ctx).Where("uid=?", user.ID).Delete(&EmailAddress{}); err != nil {
return err
}
if _, err := db.GetEngine(ctx).Where("uid=?", user.ID).Delete(&Setting{}); err != nil {
return err
}
if _, err := db.GetEngine(ctx).ID(user.ID).Delete(&User{}); err != nil {
return err
}
}
return nil
}
+36
View File
@@ -379,6 +379,16 @@
"auth.disable_register_prompt": "Registration is disabled. Please contact your site administrator.",
"auth.disable_register_mail": "Email confirmation for registration is disabled.",
"auth.manual_activation_only": "Contact your site administrator to complete activation.",
"auth.account_request_validation_sent": "We sent you a validation email for this account request. The link is valid for 48 hours.",
"auth.account_request_validation_pending_signup": "A validation email has already been sent for this account request. Please validate it before creating another account.",
"auth.account_request_validation_resent": "We sent a new validation email for this account request.",
"auth.account_request_validation_expired": "This account request validation link has expired. Please register again.",
"auth.account_request_admin_review_pending": "Your email address has been validated. An administrator will now review your account request.",
"auth.account_request_validated_waiting_admin": "Your account request was validated successfully. Please wait for an administrator to review it.",
"auth.account_request_resend": "Did not receive the email? Resend",
"auth.account_request_resend_not_available": "This account request can no longer receive a validation email.",
"auth.account_request_blocked": "This account request has been blocked after %d unconfirmed attempts. An administrator must unblock it.",
"auth.account_request_rejected_signup": "This account request was rejected. An administrator must unblock it before the same email address can be used again.",
"auth.remember_me": "Remember This Device",
"auth.remember_me.compromised": "The login token is not valid anymore which may indicate a compromised account. Please check your account for unusual activities.",
"auth.forgot_password_title": "Forgot Password",
@@ -467,6 +477,18 @@
"mail.new_account_request.text_1": "A new user account request is awaiting administrator review on %s.",
"mail.new_account_request.text_2": "Requested account: <b>%[1]s</b> (%[2]s).",
"mail.new_account_request.text_3": "Review and activate the account here:",
"mail.account_request.validate": "Validate your account request",
"mail.account_request.validate.title": "%s, validate your account request",
"mail.account_request.validate.text_1": "Hi <b>%[1]s</b>, please validate your account request for %[2]s.",
"mail.account_request.validate.text_2": "Click the following link within <b>%s</b> to validate your email address and submit the request for administrator review:",
"mail.account_request.approved": "Your account request for %s was approved",
"mail.account_request.approved.title": "Your account request for %s was approved",
"mail.account_request.approved.text_1": "Hi <b>%[1]s</b>, your account request for %[2]s was approved.",
"mail.account_request.approved.text_2": "You can now sign in with your account.",
"mail.account_request.rejected": "Your account request for %s was rejected",
"mail.account_request.rejected.title": "Your account request for %s was rejected",
"mail.account_request.rejected.text_1": "Hi <b>%[1]s</b>, your account request for %[2]s was rejected.",
"mail.account_request.rejected.text_2": "The account remains blocked until an administrator unblocks it.",
"mail.register_notify": "Welcome to %s",
"mail.register_notify.title": "%[1]s, welcome to %[2]s",
"mail.register_notify.text_1": "This is your registration confirmation email for %s!",
@@ -3051,6 +3073,20 @@
"admin.users.purge_help": "Forcibly delete user and any repositories, organizations, and packages owned by the user. All comments will be deleted too.",
"admin.users.still_own_packages": "This user still owns one or more packages. Delete these packages first.",
"admin.users.deletion_success": "The user account has been deleted.",
"admin.users.account_request.status": "Account request status",
"admin.users.account_request.attempts": "Unconfirmed attempts: %d",
"admin.users.account_request.activate": "Activate Request",
"admin.users.account_request.reject": "Reject Request",
"admin.users.account_request.unblock": "Unblock Request",
"admin.users.account_request.approved": "The account request has been approved.",
"admin.users.account_request.rejected": "The account request has been rejected.",
"admin.users.account_request.unblocked": "The account request has been unblocked and a new validation email was sent.",
"admin.users.account_request.invalid_action": "This account request action is not available for the current request state.",
"admin.users.account_request.active_managed_externally": "Activation for this account is currently managed by the account request workflow.",
"admin.users.account_request.status.pending_email_validation": "Waiting for email validation",
"admin.users.account_request.status.pending_admin_review": "Waiting for administrator review",
"admin.users.account_request.status.rejected": "Rejected",
"admin.users.account_request.status.blocked": "Blocked",
"admin.users.reset_2fa": "Reset 2FA",
"admin.users.list_status_filter.menu_text": "Filter",
"admin.users.list_status_filter.reset": "Reset",
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"strconv"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/mailer"
)
func loadAccountRequestData(ctx *context.Context, user *user_model.User) bool {
status, err := user_model.GetAccountRequestStatus(ctx, user)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return false
}
ctx.Data["AccountRequestStatus"] = status
attempts, err := user_model.GetAccountRequestSignupAttempts(ctx, user)
if err != nil {
ctx.ServerError("GetAccountRequestSignupAttempts", err)
return false
}
ctx.Data["AccountRequestSignupAttempts"] = attempts
return true
}
func accountRequestRedirect(ctx *context.Context, userID int64) {
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(userID, 10) + "/edit")
}
func AccountRequestActivate(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
status, err := user_model.GetAccountRequestStatus(ctx, u)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
if status != user_model.AccountRequestStatusPendingAdminReview {
ctx.Flash.Error(ctx.Tr("admin.users.account_request.invalid_action"))
accountRequestRedirect(ctx, u.ID)
return
}
if err := user_model.ApproveAccountRequest(ctx, u, ctx.Doer.ID); err != nil {
ctx.ServerError("ApproveAccountRequest", err)
return
}
mailer.SendAccountRequestApprovedMail(u)
log.Trace("Account request approved by admin (%s): %s", ctx.Doer.Name, u.Name)
ctx.Flash.Success(ctx.Tr("admin.users.account_request.approved"))
accountRequestRedirect(ctx, u.ID)
}
func AccountRequestReject(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
status, err := user_model.GetAccountRequestStatus(ctx, u)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
if status != user_model.AccountRequestStatusPendingAdminReview {
ctx.Flash.Error(ctx.Tr("admin.users.account_request.invalid_action"))
accountRequestRedirect(ctx, u.ID)
return
}
if err := user_model.RejectAccountRequest(ctx, u, ctx.Doer.ID); err != nil {
ctx.ServerError("RejectAccountRequest", err)
return
}
mailer.SendAccountRequestRejectedMail(u)
log.Trace("Account request rejected by admin (%s): %s", ctx.Doer.Name, u.Name)
ctx.Flash.Success(ctx.Tr("admin.users.account_request.rejected"))
accountRequestRedirect(ctx, u.ID)
}
func AccountRequestUnblock(ctx *context.Context) {
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
status, err := user_model.GetAccountRequestStatus(ctx, u)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
if status != user_model.AccountRequestStatusRejected && status != user_model.AccountRequestStatusBlocked {
ctx.Flash.Error(ctx.Tr("admin.users.account_request.invalid_action"))
accountRequestRedirect(ctx, u.ID)
return
}
if err := user_model.UnblockAccountRequest(ctx, u); err != nil {
ctx.ServerError("UnblockAccountRequest", err)
return
}
mailer.SendAccountRequestValidationMail(translation.NewLocale(u.Language), u)
log.Trace("Account request unblocked by admin (%s): %s", ctx.Doer.Name, u.Name)
ctx.Flash.Success(ctx.Tr("admin.users.account_request.unblocked"))
accountRequestRedirect(ctx, u.ID)
}
+15 -3
View File
@@ -323,10 +323,13 @@ func editUserCommon(ctx *context.Context) {
// EditUser show editing user page
func EditUser(ctx *context.Context) {
editUserCommon(ctx)
prepareUserInfo(ctx)
u := prepareUserInfo(ctx)
if ctx.Written() {
return
}
if !loadAccountRequestData(ctx, u) {
return
}
ctx.HTML(http.StatusOK, tplUserEdit)
}
@@ -428,12 +431,21 @@ func EditUserPost(ctx *context.Context) {
}
}
requestStatus, err := user_model.GetAccountRequestStatus(ctx, u)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
wasActive := u.IsActive
requestedActive := form.Active
if requestStatus != "" {
requestedActive = u.IsActive
}
opts := &user_service.UpdateOptions{
FullName: optional.Some(form.FullName),
Website: optional.Some(form.Website),
Location: optional.Some(form.Location),
IsActive: optional.Some(form.Active),
IsActive: optional.Some(requestedActive),
IsAdmin: user_service.UpdateOptionFieldFromValue(form.Admin),
AllowGitHook: optional.Some(form.AllowGitHook),
AllowImportLocal: optional.Some(form.AllowImportLocal),
@@ -454,7 +466,7 @@ func EditUserPost(ctx *context.Context) {
}
log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name)
if !wasActive && form.Active && setting.Service.RegisterManualConfirm && !setting.Service.RegisterEmailConfirm {
if !wasActive && requestedActive && requestStatus == "" && setting.Service.RegisterManualConfirm && !setting.Service.RegisterEmailConfirm {
mailer.SendActivateAccountMail(translation.NewLocale(u.Language), u)
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"html/template"
"net/http"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/mailer"
user_service "code.gitea.io/gitea/services/user"
)
func renderPendingAccountRequestSignup(ctx *context.Context, form *forms.RegisterForm, email string, message template.HTML, showResend bool) {
ctx.Data["PendingAccountRequestMessage"] = message
ctx.Data["PendingAccountRequestEmail"] = email
ctx.Data["ShowResendAccountRequestButton"] = showResend
ctx.HTML(http.StatusOK, tplSignUp)
}
func handleExistingAccountRequestOnSignup(ctx *context.Context, form *forms.RegisterForm) bool {
existing, err := user_model.GetUserByAnyEmail(ctx, form.Email)
if err != nil {
if user_model.IsErrUserNotExist(err) {
return true
}
ctx.ServerError("GetUserByAnyEmail", err)
return false
}
status, err := user_model.GetAccountRequestStatus(ctx, existing)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return false
}
switch status {
case user_model.AccountRequestStatusPendingEmailValidation:
expired, err := user_model.IsAccountRequestExpired(ctx, existing)
if err != nil {
ctx.ServerError("IsAccountRequestExpired", err)
return false
}
if expired {
if err := user_service.DeleteUser(ctx, existing, false); err != nil {
ctx.ServerError("DeleteUser", err)
return false
}
return true
}
attempts, blocked, err := user_model.IncrementAccountRequestSignupAttempts(ctx, existing)
if err != nil {
ctx.ServerError("IncrementAccountRequestSignupAttempts", err)
return false
}
if blocked {
renderPendingAccountRequestSignup(ctx, form, form.Email, ctx.Tr("auth.account_request_blocked", user_model.AccountRequestMaxAttempts), false)
return false
}
_ = attempts
renderPendingAccountRequestSignup(ctx, form, form.Email, ctx.Tr("auth.account_request_validation_pending_signup"), true)
return false
case user_model.AccountRequestStatusPendingAdminReview:
renderPendingAccountRequestSignup(ctx, form, form.Email, ctx.Tr("auth.account_request_admin_review_pending"), false)
return false
case user_model.AccountRequestStatusRejected:
renderPendingAccountRequestSignup(ctx, form, form.Email, ctx.Tr("auth.account_request_rejected_signup"), false)
return false
case user_model.AccountRequestStatusBlocked:
renderPendingAccountRequestSignup(ctx, form, form.Email, ctx.Tr("auth.account_request_blocked", user_model.AccountRequestMaxAttempts), false)
return false
default:
return true
}
}
func handleAccountRequestValidation(ctx *context.Context, user *user_model.User) {
status, err := user_model.GetAccountRequestStatus(ctx, user)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
switch status {
case user_model.AccountRequestStatusPendingEmailValidation:
expired, err := user_model.IsAccountRequestExpired(ctx, user)
if err != nil {
ctx.ServerError("IsAccountRequestExpired", err)
return
}
if expired {
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_validation_expired"))
return
}
if err := user_model.MarkAccountRequestPendingAdminReview(ctx, user); err != nil {
ctx.ServerError("MarkAccountRequestPendingAdminReview", err)
return
}
if err := mailer.SendNewAccountRequestMail(ctx, user); err != nil {
log.Error("SendNewAccountRequestMail: %v", err)
}
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_validated_waiting_admin"))
case user_model.AccountRequestStatusPendingAdminReview:
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_admin_review_pending"))
case user_model.AccountRequestStatusRejected:
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_rejected_signup"))
case user_model.AccountRequestStatusBlocked:
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_blocked", user_model.AccountRequestMaxAttempts))
default:
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
}
}
func ResendAccountRequestValidationPost(ctx *context.Context) {
if setting.Service.DisableRegistration || setting.Service.AllowOnlyExternalRegistration {
ctx.HTTPError(http.StatusForbidden)
return
}
email := strings.TrimSpace(ctx.FormString("email"))
user, err := user_model.GetUserByAnyEmail(ctx, email)
if err != nil {
if user_model.IsErrUserNotExist(err) {
ctx.Flash.Error(ctx.Tr("auth.account_request_resend_not_available"))
ctx.Redirect(setting.AppSubURL + "/user/sign_up")
return
}
ctx.ServerError("GetUserByAnyEmail", err)
return
}
status, err := user_model.GetAccountRequestStatus(ctx, user)
if err != nil {
ctx.ServerError("GetAccountRequestStatus", err)
return
}
if status != user_model.AccountRequestStatusPendingEmailValidation {
ctx.Flash.Error(ctx.Tr("auth.account_request_resend_not_available"))
ctx.Redirect(setting.AppSubURL + "/user/sign_up")
return
}
if err := user_model.RefreshAccountRequestValidationWindow(ctx, user); err != nil {
ctx.ServerError("RefreshAccountRequestValidationWindow", err)
return
}
mailer.SendAccountRequestValidationMail(ctx.Locale, user)
ctx.Data["PendingAccountRequestMessage"] = ctx.Tr("auth.account_request_validation_resent")
ctx.Data["PendingAccountRequestEmail"] = email
ctx.Data["ShowResendAccountRequestButton"] = true
prepareSignUpPageData(ctx)
ctx.HTML(http.StatusOK, tplSignUp)
}
+19 -3
View File
@@ -575,6 +575,10 @@ func SignUpPost(ctx *context.Context) {
return
}
if !handleExistingAccountRequestOnSignup(ctx, form) {
return
}
if form.Password != form.Retype {
ctx.Data["Err_Password"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("form.password_not_match"), tplSignUp, &form)
@@ -730,10 +734,12 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, possibleLinkAcc
}
if setting.Service.RegisterManualConfirm {
if err := mailer.SendNewAccountRequestMail(ctx, u); err != nil {
log.Error("SendNewAccountRequestMail: %v", err)
if err := user_model.ResetAccountRequestToPendingEmailValidation(ctx, u); err != nil {
ctx.ServerError("ResetAccountRequestToPendingEmailValidation", err)
return false
}
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.manual_activation_only"))
mailer.SendAccountRequestValidationMail(ctx.Locale, u)
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.account_request_validation_sent"))
return false
}
@@ -799,6 +805,11 @@ func Activate(ctx *context.Context) {
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
if user := user_model.VerifyAccountRequestValidationCode(ctx, code); user != nil {
handleAccountRequestValidation(ctx, user)
return
}
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
@@ -844,6 +855,11 @@ func ActivatePost(ctx *context.Context) {
}
// TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated
if user := user_model.VerifyAccountRequestValidationCode(ctx, code); user != nil {
handleAccountRequestValidation(ctx, user)
return
}
user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code)
if user == nil { // if code is wrong
renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code"))
+4
View File
@@ -569,6 +569,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, openIDSignInEnabled)
m.Get("/sign_up", auth.SignUp)
m.Post("/sign_up", web.Bind(forms.RegisterForm{}), auth.SignUpPost)
m.Post("/sign_up/resend", auth.ResendAccountRequestValidationPost)
m.Get("/link_account", auth.LinkAccount)
m.Post("/link_account_signin", web.Bind(forms.SignInForm{}), auth.LinkAccountPostSignIn)
m.Post("/link_account_signup", web.Bind(forms.RegisterForm{}), auth.LinkAccountPostRegister)
@@ -784,6 +785,9 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Combo("/new").Get(admin.NewUser).Post(web.Bind(forms.AdminCreateUserForm{}), admin.NewUserPost)
m.Get("/{userid}", admin.ViewUser)
m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind(forms.AdminEditUserForm{}), admin.EditUserPost)
m.Post("/{userid}/request/activate", admin.AccountRequestActivate)
m.Post("/{userid}/request/reject", admin.AccountRequestReject)
m.Post("/{userid}/request/unblock", admin.AccountRequestUnblock)
m.Post("/{userid}/delete", admin.DeleteUser)
m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost)
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
+11
View File
@@ -34,6 +34,16 @@ func registerDeleteInactiveUsers() {
})
}
func registerDeleteExpiredAccountRequests() {
RegisterTaskFatal("delete_expired_account_requests", &BaseConfig{
Enabled: true,
RunAtStart: false,
Schedule: "@every 1h",
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
return user_service.DeleteExpiredAccountRequests(ctx)
})
}
func registerDeleteRepositoryArchives() {
RegisterTaskFatal("delete_repo_archives", &BaseConfig{
Enabled: false,
@@ -226,6 +236,7 @@ func registerRebuildIssueIndexer() {
func initExtendedTasks() {
registerDeleteInactiveUsers()
registerDeleteExpiredAccountRequests()
registerDeleteRepositoryArchives()
registerGarbageCollectRepositories()
registerRewriteAllPublicKeys()
+55 -18
View File
@@ -20,11 +20,14 @@ import (
)
const (
mailAuthActivate templates.TplName = "user/auth/activate"
mailAuthActivateEmail templates.TplName = "user/auth/activate_email"
mailAuthNewAccountRequest templates.TplName = "user/auth/new_account_request"
mailAuthResetPassword templates.TplName = "user/auth/reset_passwd"
mailAuthRegisterNotify templates.TplName = "user/auth/register_notify"
mailAuthActivate templates.TplName = "user/auth/activate"
mailAuthActivateEmail templates.TplName = "user/auth/activate_email"
mailAuthAccountRequestApproved templates.TplName = "user/auth/account_request_approved"
mailAuthAccountRequestRejected templates.TplName = "user/auth/account_request_rejected"
mailAuthAccountRequestValidate templates.TplName = "user/auth/account_request_validate"
mailAuthNewAccountRequest templates.TplName = "user/auth/new_account_request"
mailAuthResetPassword templates.TplName = "user/auth/reset_passwd"
mailAuthRegisterNotify templates.TplName = "user/auth/register_notify"
)
// sendUserMail sends a mail to the user
@@ -52,20 +55,65 @@ func sendUserMail(language string, u *user_model.User, tpl templates.TplName, co
SendAsync(msg)
}
func sendSimpleUserMail(language string, u *user_model.User, tpl templates.TplName, subject, info string, data map[string]any) {
locale := translation.NewLocale(language)
if data == nil {
data = map[string]any{}
}
data["locale"] = locale
data["DisplayName"] = u.DisplayName()
data["Language"] = locale.Language()
var content bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(tpl), data); err != nil {
log.Error("Template: %v", err)
return
}
msg := sender_service.NewMessage(u.EmailTo(), subject, content.String())
msg.Info = fmt.Sprintf("UID: %d, %s", u.ID, info)
SendAsync(msg)
}
// SendActivateAccountMail sends an activation mail to the user (new user registration)
func SendActivateAccountMail(locale translation.Locale, u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}
sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account")
}
func SendAccountRequestValidationMail(locale translation.Locale, u *user_model.User) {
if setting.MailService == nil {
return
}
validationURL := fmt.Sprintf("%suser/activate?code=%s", setting.AppURL, user_model.GenerateAccountRequestValidationCode(u))
sendSimpleUserMail(locale.Language(), u, mailAuthAccountRequestValidate, locale.TrString("mail.account_request.validate"), "account request validation", map[string]any{
"ValidationURL": validationURL,
"AccountRequestCodeLives": timeutil.MinutesToFriendly(user_model.AccountRequestValidationHours*60, locale),
})
}
func SendAccountRequestApprovedMail(u *user_model.User) {
if setting.MailService == nil {
return
}
locale := translation.NewLocale(u.Language)
sendSimpleUserMail(u.Language, u, mailAuthAccountRequestApproved, locale.TrString("mail.account_request.approved", setting.AppName), "account request approved", nil)
}
func SendAccountRequestRejectedMail(u *user_model.User) {
if setting.MailService == nil {
return
}
locale := translation.NewLocale(u.Language)
sendSimpleUserMail(u.Language, u, mailAuthAccountRequestRejected, locale.TrString("mail.account_request.rejected", setting.AppName), "account request rejected", nil)
}
// SendResetPasswordMail sends a password reset mail to the user
func SendResetPasswordMail(u *user_model.User) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
@@ -76,7 +124,6 @@ func SendResetPasswordMail(u *user_model.User) {
// SendActivateEmailMail sends confirmation email to confirm new email address
func SendActivateEmailMail(u *user_model.User, email string) {
if setting.MailService == nil {
// No mail service configured
return
}
locale := translation.NewLocale(u.Language)
@@ -91,7 +138,6 @@ func SendActivateEmailMail(u *user_model.User, email string) {
}
var content bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthActivateEmail), data); err != nil {
log.Error("Template: %v", err)
return
@@ -99,18 +145,15 @@ func SendActivateEmailMail(u *user_model.User, email string) {
msg := sender_service.NewMessage(email, locale.TrString("mail.activate_email"), content.String())
msg.Info = fmt.Sprintf("UID: %d, activate email", u.ID)
SendAsync(msg)
}
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
func SendRegisterNotifyMail(u *user_model.User) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR user is inactive
return
}
locale := translation.NewLocale(u.Language)
data := map[string]any{
"locale": locale,
"DisplayName": u.DisplayName(),
@@ -119,7 +162,6 @@ func SendRegisterNotifyMail(u *user_model.User) {
}
var content bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthRegisterNotify), data); err != nil {
log.Error("Template: %v", err)
return
@@ -127,7 +169,6 @@ func SendRegisterNotifyMail(u *user_model.User) {
msg := sender_service.NewMessage(u.EmailTo(), locale.TrString("mail.register_notify", setting.AppName), content.String())
msg.Info = fmt.Sprintf("UID: %d, registration notify", u.ID)
SendAsync(msg)
}
@@ -156,7 +197,6 @@ func SendNewAccountRequestMail(ctx context.Context, requester *user_model.User)
if !enabled {
continue
}
langMap[admin.Language] = append(langMap[admin.Language], admin)
}
@@ -165,7 +205,6 @@ func SendNewAccountRequestMail(ctx context.Context, requester *user_model.User)
return err
}
}
return nil
}
@@ -182,7 +221,6 @@ func sendNewAccountRequestMailPerLang(lang string, emailTos []*user_model.User,
}
var content bytes.Buffer
if err := LoadedTemplates().BodyTemplates.ExecuteTemplate(&content, string(mailAuthNewAccountRequest), data); err != nil {
return err
}
@@ -192,6 +230,5 @@ func sendNewAccountRequestMailPerLang(lang string, emailTos []*user_model.User,
msg.Info = fmt.Sprintf("UID: %d, new account request notification", requester.ID)
SendAsync(msg)
}
return nil
}
+22 -3
View File
@@ -294,10 +294,25 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
return err
}
// FIXME: should only update authorized_keys file once after all deletions.
for _, u := range inactiveUsers {
status, err := user_model.GetAccountRequestStatus(ctx, u)
if err != nil {
return err
}
switch status {
case user_model.AccountRequestStatusPendingAdminReview, user_model.AccountRequestStatusRejected, user_model.AccountRequestStatusBlocked:
continue
case user_model.AccountRequestStatusPendingEmailValidation:
expired, err := user_model.IsAccountRequestExpired(ctx, u)
if err != nil {
return err
}
if !expired {
continue
}
}
if err = DeleteUser(ctx, u, false); err != nil {
// Ignore inactive users that were ever active but then were set inactive by admin
if repo_model.IsErrUserOwnRepos(err) || organization.IsErrUserHasOrgs(err) || packages_model.IsErrUserOwnPackages(err) {
log.Warn("Inactive user %q has repositories, organizations or packages, skipping deletion: %v", u.Name, err)
continue
@@ -310,5 +325,9 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
}
}
}
return nil // TODO: there could be still inactive users left, and the number would increase gradually
return nil
}
func DeleteExpiredAccountRequests(ctx context.Context) error {
return user_model.DeleteExpiredPendingAccountRequests(ctx)
}
+33 -1
View File
@@ -103,10 +103,18 @@
<div class="divider"></div>
{{if .AccountRequestStatus}}
<div class="ui info message">
<p><strong>{{ctx.Locale.Tr "admin.users.account_request.status"}}</strong>: {{ctx.Locale.Tr (printf "admin.users.account_request.status.%s" .AccountRequestStatus)}}</p>
{{if .AccountRequestSignupAttempts}}<p>{{ctx.Locale.Tr "admin.users.account_request.attempts" .AccountRequestSignupAttempts}}</p>{{end}}
</div>
{{end}}
<div class="inline field">
<div class="ui checkbox">
<label><strong>{{ctx.Locale.Tr "admin.users.is_activated"}}</strong></label>
<input name="active" type="checkbox" {{if .User.IsActive}}checked{{end}}>
<input name="active" type="checkbox" {{if .User.IsActive}}checked{{end}} {{if .AccountRequestStatus}}disabled{{end}}>
{{if .AccountRequestStatus}}<p class="help">{{ctx.Locale.Tr "admin.users.account_request.active_managed_externally"}}</p>{{end}}
</div>
</div>
<div class="inline field">
@@ -169,6 +177,30 @@
</form>
</div>
{{if .AccountRequestStatus}}
<div class="ui attached segment">
{{if eq .AccountRequestStatus "pending_admin_review"}}
<div class="field tw-flex tw-flex-wrap tw-gap-2">
<form method="post" action="./request/activate">
{{.CsrfTokenHtml}}
<button class="ui primary button">{{ctx.Locale.Tr "admin.users.account_request.activate"}}</button>
</form>
<form method="post" action="./request/reject">
{{.CsrfTokenHtml}}
<button class="ui red button">{{ctx.Locale.Tr "admin.users.account_request.reject"}}</button>
</form>
</div>
{{else if or (eq .AccountRequestStatus "blocked") (eq .AccountRequestStatus "rejected")}}
<div class="field">
<form method="post" action="./request/unblock">
{{.CsrfTokenHtml}}
<button class="ui primary button">{{ctx.Locale.Tr "admin.users.account_request.unblock"}}</button>
</form>
</div>
{{end}}
</div>
{{end}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "settings.avatar"}}
</h4>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.account_request.approved.title" AppName}}</title>
</head>
<body>
<p>{{.locale.Tr "mail.account_request.approved.text_1" (.DisplayName|DotEscape) AppName}}</p><br>
<p>{{.locale.Tr "mail.account_request.approved.text_2"}}</p><br>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
</body>
</html>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.account_request.rejected.title" AppName}}</title>
</head>
<body>
<p>{{.locale.Tr "mail.account_request.rejected.text_1" (.DisplayName|DotEscape) AppName}}</p><br>
<p>{{.locale.Tr "mail.account_request.rejected.text_2"}}</p><br>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
</body>
</html>
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="format-detection" content="telephone=no,date=no,address=no,email=no,url=no">
<title>{{.locale.Tr "mail.account_request.validate.title" (.DisplayName|DotEscape)}}</title>
</head>
<body>
<p>{{.locale.Tr "mail.account_request.validate.text_1" (.DisplayName|DotEscape) AppName}}</p><br>
<p>{{.locale.Tr "mail.account_request.validate.text_2" .AccountRequestCodeLives}}</p><p><a href="{{.ValidationURL}}">{{.ValidationURL}}</a></p><br>
<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>
<p>© <a href="{{AppUrl}}">{{AppName}}</a></p>
</body>
</html>
+14 -2
View File
@@ -10,10 +10,22 @@
{{if .IsFirstTimeRegistration}}
<p>{{ctx.Locale.Tr "auth.sign_up_tip"}}</p>
{{end}}
<form class="ui form" action="{{.SignUpLink}}" method="post">
{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister)}}
{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeRegister)}}
{{template "base/alert" .}}
{{if .PendingAccountRequestMessage}}
<div class="ui info message">
<p>{{.PendingAccountRequestMessage}}</p>
{{if .ShowResendAccountRequestButton}}
<form class="ui form" action="{{AppSubUrl}}/user/sign_up/resend" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="email" value="{{.PendingAccountRequestEmail}}">
<button class="ui button" type="submit">{{ctx.Locale.Tr "auth.account_request_resend"}}</button>
</form>
{{end}}
</div>
{{end}}
{{end}}
<form class="ui form" action="{{.SignUpLink}}" method="post">
{{if .DisableRegistration}}
<p>{{ctx.Locale.Tr "auth.disable_register_prompt"}}</p>
{{else}}