Skip to content

Commit a9c9711

Browse files
GustedGusted
authored andcommitted
feat: add configurable cooldown to claim usernames (go-gitea#6422)
Add a new option that allows instances to set a cooldown period to claim old usernames. In the context of public instances this can be used to prevent old usernames to be claimed after they are free and allow graceful migration (by making use of the redirect feature) to a new username. The granularity of this cooldown is a day. By default this feature is disabled and thus no cooldown period. The `CreatedUnix` column is added the `user_redirect` table, for existing redirects the timestamp is simply zero as we simply do not know when they were created and are likely already over the cooldown period if the instance configures one. Users can always reclaim their 'old' user name again within the cooldown period. Users can also always reclaim 'old' names of organization they currently own within the cooldown period. Creating and renaming users as an admin user are not affected by the cooldown period for moderation and user support reasons. To avoid abuse of the cooldown feature, such that a user holds a lot of usernames, a new option is added `MAX_USER_REDIRECTS` which sets a limit to the amount of user redirects a user may have, by default this is disabled. If a cooldown period is set then the default is 5. This feature operates independently of the cooldown period feature. Added integration and unit testing. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6422 Reviewed-by: Earl Warren <[email protected]> Reviewed-by: 0ko <[email protected]> Reviewed-by: Otto <[email protected]> Co-authored-by: Gusted <[email protected]> Co-committed-by: Gusted <[email protected]>
1 parent a9c4a25 commit a9c9711

File tree

19 files changed

+561
-16
lines changed

19 files changed

+561
-16
lines changed

models/fixtures/user_redirect.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
id: 1
33
lower_name: olduser1
44
redirect_user_id: 1
5+
created_unix: 1730000000

models/forgejo_migrations/migrate.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ var migrations = []*Migration{
9090
NewMigration("Migrate `secret` column to store keying material", MigrateTwoFactorToKeying),
9191
// v26 -> v27
9292
NewMigration("Add `hash_blake2b` column to `package_blob` table", AddHashBlake2bToPackageBlob),
93+
// v27 -> v28
94+
NewMigration("Add `created_unix` column to `user_redirect` table", AddCreatedUnixToRedirect),
9395
}
9496

9597
// GetCurrentDBVersion returns the current Forgejo database version.

models/forgejo_migrations/v27.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2024 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package forgejo_migrations //nolint:revive
5+
6+
import (
7+
"code.gitea.io/gitea/modules/timeutil"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
func AddCreatedUnixToRedirect(x *xorm.Engine) error {
13+
type UserRedirect struct {
14+
ID int64 `xorm:"pk autoincr"`
15+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL DEFAULT 0"`
16+
}
17+
return x.Sync(new(UserRedirect))
18+
}

models/user/redirect.go

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ package user
66
import (
77
"context"
88
"fmt"
9+
"slices"
10+
"strconv"
911
"strings"
12+
"time"
1013

1114
"code.gitea.io/gitea/models/db"
15+
"code.gitea.io/gitea/modules/setting"
16+
"code.gitea.io/gitea/modules/timeutil"
1217
"code.gitea.io/gitea/modules/util"
18+
19+
"xorm.io/builder"
1320
)
1421

1522
// ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error.
@@ -31,11 +38,25 @@ func (err ErrUserRedirectNotExist) Unwrap() error {
3138
return util.ErrNotExist
3239
}
3340

41+
type ErrCooldownPeriod struct {
42+
ExpireTime time.Time
43+
}
44+
45+
func IsErrCooldownPeriod(err error) bool {
46+
_, ok := err.(ErrCooldownPeriod)
47+
return ok
48+
}
49+
50+
func (err ErrCooldownPeriod) Error() string {
51+
return fmt.Sprintf("cooldown period for claiming this username has not yet expired: the cooldown period ends at %s", err.ExpireTime)
52+
}
53+
3454
// Redirect represents that a user name should be redirected to another
3555
type Redirect struct {
36-
ID int64 `xorm:"pk autoincr"`
37-
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
38-
RedirectUserID int64 // userID to redirect to
56+
ID int64 `xorm:"pk autoincr"`
57+
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
58+
RedirectUserID int64 // userID to redirect to
59+
CreatedUnix timeutil.TimeStamp `xorm:"created NOT NULL DEFAULT 0"`
3960
}
4061

4162
// TableName provides the real table name
@@ -47,14 +68,24 @@ func init() {
4768
db.RegisterModel(new(Redirect))
4869
}
4970

50-
// LookupUserRedirect look up userID if a user has a redirect name
51-
func LookupUserRedirect(ctx context.Context, userName string) (int64, error) {
71+
// GetUserRedirect returns the redirect for a given username, this is a
72+
// case-insenstive operation.
73+
func GetUserRedirect(ctx context.Context, userName string) (*Redirect, error) {
5274
userName = strings.ToLower(userName)
5375
redirect := &Redirect{LowerName: userName}
5476
if has, err := db.GetEngine(ctx).Get(redirect); err != nil {
55-
return 0, err
77+
return nil, err
5678
} else if !has {
57-
return 0, ErrUserRedirectNotExist{Name: userName}
79+
return nil, ErrUserRedirectNotExist{Name: userName}
80+
}
81+
return redirect, nil
82+
}
83+
84+
// LookupUserRedirect look up userID if a user has a redirect name
85+
func LookupUserRedirect(ctx context.Context, userName string) (int64, error) {
86+
redirect, err := GetUserRedirect(ctx, userName)
87+
if err != nil {
88+
return 0, err
5889
}
5990
return redirect.RedirectUserID, nil
6091
}
@@ -78,10 +109,66 @@ func NewUserRedirect(ctx context.Context, ID int64, oldUserName, newUserName str
78109
})
79110
}
80111

112+
// LimitUserRedirects deletes the oldest entries in user_redirect of the user,
113+
// such that the amount of user_redirects is at most `n` amount of entries.
114+
func LimitUserRedirects(ctx context.Context, userID, n int64) error {
115+
// NOTE: It's not possible to combine these two queries into one due to a limitation of MySQL.
116+
keepIDs := make([]int64, n)
117+
if err := db.GetEngine(ctx).SQL("SELECT id FROM user_redirect WHERE redirect_user_id = ? ORDER BY created_unix DESC LIMIT "+strconv.FormatInt(n, 10), userID).Find(&keepIDs); err != nil {
118+
return err
119+
}
120+
121+
_, err := db.GetEngine(ctx).Exec(builder.Delete(builder.And(builder.Eq{"redirect_user_id": userID}, builder.NotIn("id", keepIDs))).From("user_redirect"))
122+
return err
123+
}
124+
81125
// DeleteUserRedirect delete any redirect from the specified user name to
82126
// anything else
83127
func DeleteUserRedirect(ctx context.Context, userName string) error {
84128
userName = strings.ToLower(userName)
85129
_, err := db.GetEngine(ctx).Delete(&Redirect{LowerName: userName})
86130
return err
87131
}
132+
133+
// CanClaimUsername returns if its possible to claim the given username,
134+
// it checks if the cooldown period for claiming an existing username is over.
135+
// If there's a cooldown period, the second argument returns the time when
136+
// that cooldown period is over.
137+
// In the scenario of renaming, the doerID can be specified to allow the original
138+
// user of the username to reclaim it within the cooldown period.
139+
func CanClaimUsername(ctx context.Context, username string, doerID int64) (bool, time.Time, error) {
140+
// Only check for a cooldown period if UsernameCooldownPeriod is a positive number.
141+
if setting.Service.UsernameCooldownPeriod <= 0 {
142+
return true, time.Time{}, nil
143+
}
144+
145+
userRedirect, err := GetUserRedirect(ctx, username)
146+
if err != nil {
147+
if IsErrUserRedirectNotExist(err) {
148+
return true, time.Time{}, nil
149+
}
150+
return false, time.Time{}, err
151+
}
152+
153+
// Allow reclaiming of user's own username.
154+
if userRedirect.RedirectUserID == doerID {
155+
return true, time.Time{}, nil
156+
}
157+
158+
// We do not know if the redirect user id was for an organization, so
159+
// unconditionally execute the following query to retrieve all users that
160+
// are part of the "Owner" team. If the redirect user ID is not an organization
161+
// the returned list would be empty.
162+
ownerTeamUIDs := []int64{}
163+
if err := db.GetEngine(ctx).SQL("SELECT uid FROM team_user INNER JOIN team ON team_user.`team_id` = team.`id` WHERE team.`org_id` = ? AND team.`name` = 'Owners'", userRedirect.RedirectUserID).Find(&ownerTeamUIDs); err != nil {
164+
return false, time.Time{}, err
165+
}
166+
167+
if slices.Contains(ownerTeamUIDs, doerID) {
168+
return true, time.Time{}, nil
169+
}
170+
171+
// Multiply the value of UsernameCooldownPeriod by the amount of seconds in a day.
172+
expireTime := userRedirect.CreatedUnix.Add(86400 * setting.Service.UsernameCooldownPeriod).AsLocalTime()
173+
return time.Until(expireTime) <= 0, expireTime, nil
174+
}

models/user/user.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,18 @@ func createUser(ctx context.Context, u *User, createdByAdmin bool, overwriteDefa
669669
return err
670670
}
671671

672+
// Check if the new username can be claimed.
673+
// Skip this check if done by an admin.
674+
if !createdByAdmin {
675+
if ok, expireTime, err := CanClaimUsername(ctx, u.Name, -1); err != nil {
676+
return err
677+
} else if !ok {
678+
return ErrCooldownPeriod{
679+
ExpireTime: expireTime,
680+
}
681+
}
682+
}
683+
672684
// set system defaults
673685
u.KeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate
674686
u.Visibility = setting.Service.DefaultUserVisibilityMode

models/user/user_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,31 @@ func TestCreateUserWithoutCustomTimestamps(t *testing.T) {
393393
assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd)
394394
}
395395

396+
func TestCreateUserClaimingUsername(t *testing.T) {
397+
require.NoError(t, unittest.PrepareTestDatabase())
398+
defer test.MockVariableValue(&setting.Service.UsernameCooldownPeriod, 1)()
399+
400+
_, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(&user_model.Redirect{RedirectUserID: 1, LowerName: "redirecting", CreatedUnix: timeutil.TimeStampNow()})
401+
require.NoError(t, err)
402+
403+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
404+
405+
user.Name = "redirecting"
406+
user.LowerName = strings.ToLower(user.Name)
407+
user.ID = 0
408+
user.Email = "[email protected]"
409+
410+
t.Run("Normal creation", func(t *testing.T) {
411+
err = user_model.CreateUser(db.DefaultContext, user)
412+
assert.True(t, user_model.IsErrCooldownPeriod(err))
413+
})
414+
415+
t.Run("Creation as admin", func(t *testing.T) {
416+
err = user_model.AdminCreateUser(db.DefaultContext, user)
417+
require.NoError(t, err)
418+
})
419+
}
420+
396421
func TestGetUserIDsByNames(t *testing.T) {
397422
require.NoError(t, unittest.PrepareTestDatabase())
398423

modules/setting/server_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"code.gitea.io/gitea/modules/test"
1010

1111
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
1213
)
1314

1415
func TestDisplayNameDefault(t *testing.T) {
@@ -34,3 +35,41 @@ func TestDisplayNameCustomFormat(t *testing.T) {
3435
displayName := generateDisplayName()
3536
assert.Equal(t, "Forgejo - Beyond coding. We Forge.", displayName)
3637
}
38+
39+
func TestMaxUserRedirectsDefault(t *testing.T) {
40+
iniStr := ``
41+
cfg, err := NewConfigProviderFromData(iniStr)
42+
require.NoError(t, err)
43+
loadServiceFrom(cfg)
44+
45+
assert.EqualValues(t, 0, Service.UsernameCooldownPeriod)
46+
assert.EqualValues(t, 0, Service.MaxUserRedirects)
47+
48+
iniStr = `[service]
49+
MAX_USER_REDIRECTS = 8`
50+
cfg, err = NewConfigProviderFromData(iniStr)
51+
require.NoError(t, err)
52+
loadServiceFrom(cfg)
53+
54+
assert.EqualValues(t, 0, Service.UsernameCooldownPeriod)
55+
assert.EqualValues(t, 8, Service.MaxUserRedirects)
56+
57+
iniStr = `[service]
58+
USERNAME_COOLDOWN_PERIOD = 3`
59+
cfg, err = NewConfigProviderFromData(iniStr)
60+
require.NoError(t, err)
61+
loadServiceFrom(cfg)
62+
63+
assert.EqualValues(t, 3, Service.UsernameCooldownPeriod)
64+
assert.EqualValues(t, 5, Service.MaxUserRedirects)
65+
66+
iniStr = `[service]
67+
USERNAME_COOLDOWN_PERIOD = 3
68+
MAX_USER_REDIRECTS = 8`
69+
cfg, err = NewConfigProviderFromData(iniStr)
70+
require.NoError(t, err)
71+
loadServiceFrom(cfg)
72+
73+
assert.EqualValues(t, 3, Service.UsernameCooldownPeriod)
74+
assert.EqualValues(t, 8, Service.MaxUserRedirects)
75+
}

modules/setting/service.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ var Service = struct {
8585
DefaultOrgMemberVisible bool
8686
UserDeleteWithCommentsMaxTime time.Duration
8787
ValidSiteURLSchemes []string
88+
UsernameCooldownPeriod int64
89+
MaxUserRedirects int64
8890

8991
// OpenID settings
9092
EnableOpenIDSignIn bool
@@ -257,6 +259,14 @@ func loadServiceFrom(rootCfg ConfigProvider) {
257259
}
258260
}
259261
Service.ValidSiteURLSchemes = schemes
262+
Service.UsernameCooldownPeriod = sec.Key("USERNAME_COOLDOWN_PERIOD").MustInt64(0)
263+
264+
// Only set a default if USERNAME_COOLDOWN_PERIOD's feature is active.
265+
maxUserRedirectsDefault := int64(0)
266+
if Service.UsernameCooldownPeriod > 0 {
267+
maxUserRedirectsDefault = 5
268+
}
269+
Service.MaxUserRedirects = sec.Key("MAX_USER_REDIRECTS").MustInt64(maxUserRedirectsDefault)
260270

261271
mustMapSetting(rootCfg, "service.explore", &Service.Explore)
262272

options/locale/locale_en-US.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ lang_select_error = Select a language from the list.
630630

631631
username_been_taken = The username is already taken.
632632
username_change_not_local_user = Non-local users are not allowed to change their username.
633+
username_claiming_cooldown = The username cannot be claimed, because its cooldown period is not yet over. It can be claimed on %[1]s.
633634
repo_name_been_taken = The repository name is already used.
634635
repository_force_private = Force Private is enabled: private repositories cannot be made public.
635636
repository_files_already_exist = Files already exist for this repository. Contact the system administrator.
@@ -765,6 +766,8 @@ update_profile_success = Your profile has been updated.
765766
change_username = Your username has been changed.
766767
change_username_prompt = Note: Changing your username also changes your account URL.
767768
change_username_redirect_prompt = The old username will redirect until someone claims it.
769+
change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period.
770+
change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period.
768771
continue = Continue
769772
cancel = Cancel
770773
language = Language
@@ -2883,6 +2886,8 @@ settings.update_settings = Update settings
28832886
settings.update_setting_success = Organization settings have been updated.
28842887
settings.change_orgname_prompt = Note: Changing the organization name will also change your organization's URL and free the old name.
28852888
settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed.
2889+
settings.change_orgname_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period.
2890+
settings.change_orgname_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period.
28862891
settings.update_avatar_success = The organization's avatar has been updated.
28872892
settings.delete = Delete organization
28882893
settings.delete_account = Delete this organization

routers/api/v1/admin/user.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ func RenameUser(ctx *context.APIContext) {
523523
newName := web.GetForm(ctx).(*api.RenameUserOption).NewName
524524

525525
// Check if user name has been changed
526-
if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil {
526+
if err := user_service.AdminRenameUser(ctx, ctx.ContextUser, newName); err != nil {
527527
switch {
528528
case user_model.IsErrUserAlreadyExist(err):
529529
ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken"))

0 commit comments

Comments
 (0)