From 856b40b30b18c36f0fd48dc71fc78762f8825360 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Fri, 14 Jul 2023 14:24:40 +0000 Subject: [PATCH 1/6] Rename files. --- models/auth/{token.go => access_token.go} | 0 models/auth/{token_scope.go => access_token_scope.go} | 0 models/auth/{token_scope_test.go => access_token_scope_test.go} | 0 models/auth/{token_test.go => access_token_test.go} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename models/auth/{token.go => access_token.go} (100%) rename models/auth/{token_scope.go => access_token_scope.go} (100%) rename models/auth/{token_scope_test.go => access_token_scope_test.go} (100%) rename models/auth/{token_test.go => access_token_test.go} (100%) diff --git a/models/auth/token.go b/models/auth/access_token.go similarity index 100% rename from models/auth/token.go rename to models/auth/access_token.go diff --git a/models/auth/token_scope.go b/models/auth/access_token_scope.go similarity index 100% rename from models/auth/token_scope.go rename to models/auth/access_token_scope.go diff --git a/models/auth/token_scope_test.go b/models/auth/access_token_scope_test.go similarity index 100% rename from models/auth/token_scope_test.go rename to models/auth/access_token_scope_test.go diff --git a/models/auth/token_test.go b/models/auth/access_token_test.go similarity index 100% rename from models/auth/token_test.go rename to models/auth/access_token_test.go From dec60d774500041d36651cdb250245e316669a04 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Thu, 3 Aug 2023 14:38:20 +0000 Subject: [PATCH 2/6] Implement secure auth tokens. --- .../config-cheat-sheet.en-us.md | 7 +- .../config-cheat-sheet.zh-cn.md | 1 - models/auth/auth_token.go | 60 +++++++++ modules/context/context_cookie.go | 44 ------- modules/setting/security.go | 2 - options/locale/locale_en-US.ini | 2 + routers/install/install.go | 11 +- routers/web/auth/2fa.go | 6 +- routers/web/auth/auth.go | 60 +++++---- routers/web/auth/openid.go | 19 +-- routers/web/auth/webauthn.go | 3 +- routers/web/home.go | 3 +- services/auth/auth_token.go | 115 ++++++++++++++++++ services/auth/auth_token_test.go | 107 ++++++++++++++++ services/auth/main_test.go | 17 +++ services/auth/middleware.go | 2 +- services/cron/tasks_basic.go | 12 ++ 17 files changed, 368 insertions(+), 103 deletions(-) create mode 100644 models/auth/auth_token.go create mode 100644 services/auth/auth_token.go create mode 100644 services/auth/auth_token_test.go create mode 100644 services/auth/main_test.go diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md index fc2184e88459..fa3b8441bd7b 100644 --- a/docs/content/doc/administration/config-cheat-sheet.en-us.md +++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md @@ -519,7 +519,6 @@ And the following unique queues: - `SECRET_KEY`: **\**: Global secret key. This key is VERY IMPORTANT, if you lost it, the data encrypted by it (like 2FA secret) can't be decrypted anymore. - `SECRET_KEY_URI`: **\**: Instead of defining SECRET_KEY, this option can be used to use the key stored in a file (example value: `file:/etc/gitea/secret_key`). It shouldn't be lost like SECRET_KEY. - `LOGIN_REMEMBER_DAYS`: **7**: Cookie lifetime, in days. -- `COOKIE_USERNAME`: **gitea\_awesome**: Name of the cookie used to store the current username. - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**: Name of cookie used to store authentication information. - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**: Header name for reverse proxy @@ -956,6 +955,12 @@ Default templates for project boards: - `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts. - `UPDATE_EXISTING`: **true**: Create new users, update existing user data and disable users that are not in external source anymore (default) or only create new users if UPDATE_EXISTING is set to false. +#### Cron - Cleanup auth tokens (`cron.cleanup_auth_tokens`) + +- `ENABLED`: **true**: Enable cleanup of expired auth tokens. +- `RUN_AT_START`: **false**: Run job at start time (if ENABLED). +- `SCHEDULE`: **@midnight**: Cron syntax for the job. + ### Extended cron tasks (not enabled by default) #### Cron - Garbage collect all repositories (`cron.git_gc_repos`) diff --git a/docs/content/doc/administration/config-cheat-sheet.zh-cn.md b/docs/content/doc/administration/config-cheat-sheet.zh-cn.md index d0af323dc049..66e60819d884 100644 --- a/docs/content/doc/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/doc/administration/config-cheat-sheet.zh-cn.md @@ -124,7 +124,6 @@ menu: - `INSTALL_LOCK`: 是否允许运行安装向导,(跟管理员账号有关,十分重要)。 - `SECRET_KEY`: 全局服务器安全密钥 **最好改成你自己的** (当你运行安装向导的时候会被设置为一个随机值)。 - `LOGIN_REMEMBER_DAYS`: Cookie 保存时间,单位天。 -- `COOKIE_USERNAME`: 保存用户名的 cookie 名称。 - `COOKIE_REMEMBER_NAME`: 保存自动登录信息的 cookie 名称。 - `REVERSE_PROXY_AUTHENTICATION_USER`: 反向代理认证的 HTTP 头名称。 diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go new file mode 100644 index 000000000000..02473883406f --- /dev/null +++ b/models/auth/auth_token.go @@ -0,0 +1,60 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist") + +type AuthToken struct { + ID string `xorm:"pk"` + TokenHash string + UserID int64 `xorm:"INDEX"` + ExpiresUnix timeutil.TimeStamp +} + +func init() { + db.RegisterModel(new(AuthToken)) +} + +func InsertAuthToken(ctx context.Context, t *AuthToken) error { + _, err := db.GetEngine(ctx).Insert(t) + return err +} + +func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) { + at := &AuthToken{} + + has, err := db.GetEngine(ctx).ID(id).Get(at) + if err != nil { + return nil, err + } + if !has { + return nil, ErrAuthTokenNotExist + } + return at, nil +} + +func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error { + _, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t) + return err +} + +func DeleteAuthTokenByID(ctx context.Context, id string) error { + _, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{}) + return err +} + +func DeleteExpiredAuthTokens(ctx context.Context) error { + _, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{}) + return err +} diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go index 9ce67a529815..b6f8dadb5665 100644 --- a/modules/context/context_cookie.go +++ b/modules/context/context_cookie.go @@ -4,16 +4,11 @@ package context import ( - "encoding/hex" "net/http" "strings" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" - - "github.com/minio/sha256-simd" - "golang.org/x/crypto/pbkdf2" ) const CookieNameFlash = "gitea_flash" @@ -45,42 +40,3 @@ func (ctx *Context) DeleteSiteCookie(name string) { func (ctx *Context) GetSiteCookie(name string) string { return middleware.GetSiteCookie(ctx.Req, name) } - -// GetSuperSecureCookie returns given cookie value from request header with secret string. -func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) { - val := ctx.GetSiteCookie(name) - return ctx.CookieDecrypt(secret, val) -} - -// CookieDecrypt returns given value from with secret string. -func (ctx *Context) CookieDecrypt(secret, val string) (string, bool) { - if val == "" { - return "", false - } - - text, err := hex.DecodeString(val) - if err != nil { - return "", false - } - - key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New) - text, err = util.AESGCMDecrypt(key, text) - return string(text), err == nil -} - -// SetSuperSecureCookie sets given cookie value to response header with secret string. -func (ctx *Context) SetSuperSecureCookie(secret, name, value string, maxAge int) { - text := ctx.CookieEncrypt(secret, value) - ctx.SetSiteCookie(name, text, maxAge) -} - -// CookieEncrypt encrypts a given value using the provided secret -func (ctx *Context) CookieEncrypt(secret, value string) string { - key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New) - text, err := util.AESGCMEncrypt(key, []byte(value)) - if err != nil { - panic("error encrypting cookie: " + err.Error()) - } - - return hex.EncodeToString(text) -} diff --git a/modules/setting/security.go b/modules/setting/security.go index 7064d7a008f4..60e20e924c80 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -19,7 +19,6 @@ var ( SecretKey string InternalToken string // internal access token LogInRememberDays int - CookieUserName string CookieRememberName string ReverseProxyAuthUser string ReverseProxyAuthEmail string @@ -104,7 +103,6 @@ func loadSecurityFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("security") InstallLock = HasInstallLock(rootCfg) LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7) - CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome") SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY") if SecretKey == "" { // FIXME: https://github.com/go-gitea/gitea/issues/16832 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a76750e44fbd..c9b80c42f6c2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -357,6 +357,7 @@ disable_register_prompt = Registration is disabled. Please contact your site adm disable_register_mail = Email confirmation for registration is disabled. manual_activation_only = Contact your site administrator to complete activation. remember_me = Remember This Device +remember_me.compromised = The login token is not valid anymore which may indicate a compromised account. Please check your account for unusual activities. forgot_password_title= Forgot Password forgot_password = Forgot password? sign_up_now = Need an account? Register now. @@ -2688,6 +2689,7 @@ dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for w dashboard.sync_external_users = Synchronize external user data dashboard.cleanup_hook_task_table = Cleanup hook_task table dashboard.cleanup_packages = Cleanup expired packages +dashboard.cleanup_auth_tokens = Cleanup auth tokens dashboard.server_uptime = Server Uptime dashboard.current_goroutine = Current Goroutines dashboard.current_memory_usage = Current Memory Usage diff --git a/routers/install/install.go b/routers/install/install.go index 6a8f56127135..111e1723a771 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -33,6 +33,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/routers/common" + auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/forms" "gitea.com/go-chi/session" @@ -552,11 +553,13 @@ func SubmitInstall(ctx *context.Context) { u, _ = user_model.GetUserByName(ctx, u.Name) } - days := 86400 * setting.LogInRememberDays - ctx.SetSiteCookie(setting.CookieUserName, u.Name, days) + nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("CreateAuthTokenForUserID", err) + return + } - ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), - setting.CookieRememberName, u.Name, days) + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) // Auto-login for admin if err = ctx.Session.Set("uid", u.ID); err != nil { diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index 4791b043131d..3b921ccb9f7d 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -26,8 +26,7 @@ var ( func TwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } @@ -99,8 +98,7 @@ func TwoFactorPost(ctx *context.Context) { func TwoFactorScratch(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa_scratch") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3bf133f56222..2fdd1d4e1bdd 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -43,41 +43,48 @@ const ( TplActivate base.TplName = "user/auth/activate" ) -// AutoSignIn reads cookie and try to auto-login. -func AutoSignIn(ctx *context.Context) (bool, error) { +// autoSignIn reads cookie and try to auto-login. +func autoSignIn(ctx *context.Context) (bool, error) { if !db.HasEngine { return false, nil } - uname := ctx.GetSiteCookie(setting.CookieUserName) - if len(uname) == 0 { - return false, nil - } - isSucceed := false defer func() { if !isSucceed { - log.Trace("auto-login cookie cleared: %s", uname) - ctx.DeleteSiteCookie(setting.CookieUserName) ctx.DeleteSiteCookie(setting.CookieRememberName) } }() - u, err := user_model.GetUserByName(ctx, uname) + t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName)) if err != nil { - if !user_model.IsErrUserNotExist(err) { - return false, fmt.Errorf("GetUserByName: %w", err) + switch err { + case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired: + return false, nil } + return false, err + } + if t == nil { return false, nil } - if val, ok := ctx.GetSuperSecureCookie( - base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { + u, err := user_model.GetUserByID(ctx, t.UserID) + if err != nil { + if !user_model.IsErrUserNotExist(err) { + return false, fmt.Errorf("GetUserByID: %w", err) + } return false, nil } isSucceed = true + nt, token, err := auth_service.RegenerateAuthToken(ctx, t) + if err != nil { + return false, err + } + + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) + if err := updateSession(ctx, nil, map[string]any{ // Set session IDs "uid": u.ID, @@ -113,11 +120,15 @@ func resetLocale(ctx *context.Context, u *user_model.User) error { return nil } -func checkAutoLogin(ctx *context.Context) bool { +func CheckAutoLogin(ctx *context.Context) bool { // Check auto-login - isSucceed, err := AutoSignIn(ctx) + isSucceed, err := autoSignIn(ctx) if err != nil { - ctx.ServerError("AutoSignIn", err) + if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) { + ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true) + return false + } + ctx.ServerError("autoSignIn", err) return true } @@ -141,8 +152,7 @@ func checkAutoLogin(ctx *context.Context) bool { func SignIn(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("sign_in") - // Check auto-login - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } @@ -290,10 +300,13 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) { func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { if remember { - days := 86400 * setting.LogInRememberDays - ctx.SetSiteCookie(setting.CookieUserName, u.Name, days) - ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), - setting.CookieRememberName, u.Name, days) + nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) + if err != nil { + ctx.ServerError("CreateAuthTokenForUserID", err) + return setting.AppSubURL + "/" + } + + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) } if err := updateSession(ctx, []string{ @@ -368,7 +381,6 @@ func getUserName(gothUser *goth.User) string { func HandleSignOut(ctx *context.Context) { _ = ctx.Session.Flush() _ = ctx.Session.Destroy(ctx.Resp, ctx.Req) - ctx.DeleteSiteCookie(setting.CookieUserName) ctx.DeleteSiteCookie(setting.CookieRememberName) ctx.Csrf.DeleteCookie(ctx) middleware.DeleteRedirectToCookie(ctx.Resp) diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 00fc17f09801..9a5b815d59e6 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -16,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/forms" ) @@ -36,23 +35,7 @@ func SignInOpenID(ctx *context.Context) { return } - // Check auto-login. - isSucceed, err := AutoSignIn(ctx) - if err != nil { - ctx.ServerError("AutoSignIn", err) - return - } - - redirectTo := ctx.FormString("redirect_to") - if len(redirectTo) > 0 { - middleware.SetRedirectToCookie(ctx.Resp, redirectTo) - } else { - redirectTo = ctx.GetSiteCookie("redirect_to") - } - - if isSucceed { - middleware.DeleteRedirectToCookie(ctx.Resp) - ctx.RedirectToFirst(redirectTo) + if CheckAutoLogin(ctx) { return } diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go index e369f860811f..08e6c80e7753 100644 --- a/routers/web/auth/webauthn.go +++ b/routers/web/auth/webauthn.go @@ -26,8 +26,7 @@ var tplWebAuthn base.TplName = "user/auth/webauthn" func WebAuthn(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("twofa") - // Check auto-login. - if checkAutoLogin(ctx) { + if CheckAutoLogin(ctx) { return } diff --git a/routers/web/home.go b/routers/web/home.go index b94e3e9eb593..8cce35fc1c81 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -54,8 +54,7 @@ func Home(ctx *context.Context) { } // Check auto-login. - uname := ctx.GetSiteCookie(setting.CookieUserName) - if len(uname) != 0 { + if ctx.GetSiteCookie(setting.CookieRememberName) != "" { ctx.Redirect(setting.AppSubURL + "/user/login") return } diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go new file mode 100644 index 000000000000..a51673444e8a --- /dev/null +++ b/services/auth/auth_token.go @@ -0,0 +1,115 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "strings" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +var ( + ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format") + ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired") + ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid") +) + +func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) { + if len(value) == 0 { + return nil, nil + } + + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return nil, ErrAuthTokenInvalidFormat + } + + t, err := auth_model.GetAuthTokenByID(ctx, parts[0]) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return nil, ErrAuthTokenExpired + } + return nil, err + } + + if t.ExpiresUnix < timeutil.TimeStampNow() { + return nil, ErrAuthTokenExpired + } + + hashedToken := sha256.Sum256([]byte(parts[1])) + + if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 { + return nil, ErrAuthTokenInvalidHash + } + + return t, nil +} + +func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) { + token, hash, err := generateTokenAndHash() + if err != nil { + return nil, "", err + } + + newToken := &auth_model.AuthToken{ + ID: t.ID, + TokenHash: hash, + UserID: t.UserID, + ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), + } + + if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil { + return nil, "", err + } + + return newToken, token, nil +} + +func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) { + t := &auth_model.AuthToken{ + UserID: userID, + ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), + } + + var err error + t.ID, err = util.CryptoRandomString(10) + if err != nil { + return nil, "", err + } + + token, hash, err := generateTokenAndHash() + if err != nil { + return nil, "", err + } + + t.TokenHash = hash + + if err := auth_model.InsertAuthToken(ctx, t); err != nil { + return nil, "", err + } + + return t, token, nil +} + +func generateTokenAndHash() (string, string, error) { + buf, err := util.CryptoRandomBytes(32) + if err != nil { + return "", "", err + } + + token := hex.EncodeToString(buf) + + hashedToken := sha256.Sum256([]byte(token)) + + return token, hex.EncodeToString(hashedToken[:]), nil +} diff --git a/services/auth/auth_token_test.go b/services/auth/auth_token_test.go new file mode 100644 index 000000000000..654275df1732 --- /dev/null +++ b/services/auth/auth_token_test.go @@ -0,0 +1,107 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + "time" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" +) + +func TestCheckAuthToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("Empty", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "") + assert.NoError(t, err) + assert.Nil(t, token) + }) + + t.Run("InvalidFormat", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "dummy") + assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat) + assert.Nil(t, token) + }) + + t.Run("NotFound", func(t *testing.T) { + token, err := CheckAuthToken(db.DefaultContext, "notexists:dummy") + assert.ErrorIs(t, err, ErrAuthTokenExpired) + assert.Nil(t, token) + }) + + t.Run("Expired", func(t *testing.T) { + timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + timeutil.Unset() + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token) + assert.ErrorIs(t, err, ErrAuthTokenExpired) + assert.Nil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) + + t.Run("InvalidHash", func(t *testing.T) { + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token+"dummy") + assert.ErrorIs(t, err, ErrAuthTokenInvalidHash) + assert.Nil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) + + t.Run("Valid", func(t *testing.T) { + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token) + assert.NoError(t, err) + assert.NotNil(t, at2) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) + }) +} + +func TestRegenerateAuthToken(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + defer timeutil.Unset() + + at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2) + assert.NoError(t, err) + assert.NotNil(t, at) + assert.NotEmpty(t, token) + + timeutil.Set(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC)) + + at2, token2, err := RegenerateAuthToken(db.DefaultContext, at) + assert.NoError(t, err) + assert.NotNil(t, at2) + assert.NotEmpty(t, token2) + + assert.Equal(t, at.ID, at2.ID) + assert.Equal(t, at.UserID, at2.UserID) + assert.NotEqual(t, token, token2) + assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix) + + assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID)) +} diff --git a/services/auth/main_test.go b/services/auth/main_test.go new file mode 100644 index 000000000000..70cb186b8d37 --- /dev/null +++ b/services/auth/main_test.go @@ -0,0 +1,17 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", ".."), + }) +} diff --git a/services/auth/middleware.go b/services/auth/middleware.go index d1955a4c9001..420641796d51 100644 --- a/services/auth/middleware.go +++ b/services/auth/middleware.go @@ -149,7 +149,7 @@ func VerifyAuthWithOptions(options *VerifyOptions) func(ctx *context.Context) { // Redirect to log in page if auto-signin info is provided and has not signed in. if !options.SignOutRequired && !ctx.IsSigned && - len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 { + ctx.GetSiteCookie(setting.CookieRememberName) != "" { if ctx.Req.URL.Path != "/user/events" { middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) } diff --git a/services/cron/tasks_basic.go b/services/cron/tasks_basic.go index 2e6560ec0c9d..5e22af22fe47 100644 --- a/services/cron/tasks_basic.go +++ b/services/cron/tasks_basic.go @@ -8,6 +8,7 @@ import ( "time" "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" git_model "code.gitea.io/gitea/models/git" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" @@ -156,6 +157,16 @@ func registerCleanupPackages() { }) } +func registerCleanupAuthTokens() { + RegisterTaskFatal("cleanup_auth_tokens", &BaseConfig{ + Enabled: true, + RunAtStart: true, + Schedule: "@midnight", + }, func(ctx context.Context, _ *user_model.User, _ Config) error { + return auth_model.DeleteExpiredAuthTokens(ctx) + }) +} + func initBasicTasks() { if setting.Mirror.Enabled { registerUpdateMirrorTask() @@ -172,4 +183,5 @@ func initBasicTasks() { if setting.Packages.Enabled { registerCleanupPackages() } + registerCleanupAuthTokens() } From c47e8c9d867289b3972d0f8b64026b94d6970f29 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Thu, 3 Aug 2023 16:06:08 +0000 Subject: [PATCH 3/6] Add migration. --- models/auth/auth_token.go | 2 +- models/migrations/migrations.go | 2 ++ models/migrations/v1_21/v271.go | 21 +++++++++++++++++++++ services/auth/auth_token.go | 2 ++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 models/migrations/v1_21/v271.go diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go index 02473883406f..49b1ebd72a69 100644 --- a/models/auth/auth_token.go +++ b/models/auth/auth_token.go @@ -15,7 +15,7 @@ import ( var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist") -type AuthToken struct { +type AuthToken struct { //nolint:revive ID string `xorm:"pk"` TokenHash string UserID int64 `xorm:"INDEX"` diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index b2140a1eb132..ddcb87551cc4 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -523,6 +523,8 @@ var migrations = []Migration{ NewMigration("Drop deleted branch table", v1_21.DropDeletedBranchTable), // v270 -> v271 NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo), + // v271 -> v272 + NewMigration("Add auth_token table", v1_21.CreateAuthTokenTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_21/v271.go b/models/migrations/v1_21/v271.go new file mode 100644 index 000000000000..79333ffb47b6 --- /dev/null +++ b/models/migrations/v1_21/v271.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func CreateAuthTokenTable(x *xorm.Engine) error { + type AuthToken struct { + ID string `xorm:"pk"` + TokenHash string + UserID int64 `xorm:"INDEX"` + ExpiresUnix timeutil.TimeStamp + } + + return x.Sync(new(AuthToken)) +} diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go index a51673444e8a..b95104cfae04 100644 --- a/services/auth/auth_token.go +++ b/services/auth/auth_token.go @@ -18,6 +18,8 @@ import ( "code.gitea.io/gitea/modules/util" ) +// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies + var ( ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format") ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired") From 2204c021353374fe05cd9ce14ffe80be91e56616 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Wed, 11 Oct 2023 14:34:28 +0000 Subject: [PATCH 4/6] Use constant. --- routers/install/install.go | 3 ++- routers/web/auth/auth.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/routers/install/install.go b/routers/install/install.go index 111e1723a771..026a9269e123 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/util" @@ -559,7 +560,7 @@ func SubmitInstall(ctx *context.Context) { return } - ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) // Auto-login for admin if err = ctx.Session.Set("uid", u.ID); err != nil { diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 2fdd1d4e1bdd..0904a4b16c22 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -83,7 +83,7 @@ func autoSignIn(ctx *context.Context) (bool, error) { return false, err } - ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) if err := updateSession(ctx, nil, map[string]any{ // Set session IDs @@ -306,7 +306,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe return setting.AppSubURL + "/" } - ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, 86400*setting.LogInRememberDays) + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) } if err := updateSession(ctx, []string{ From 3256531332a89899546ff3ae6ec5b62573acb990 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Thu, 12 Oct 2023 14:10:06 +0000 Subject: [PATCH 5/6] Remove cron job. Add index. --- .../administration/config-cheat-sheet.en-us.md | 6 ------ models/auth/auth_token.go | 4 ++-- models/migrations/v1_21/v271.go | 4 ++-- options/locale/locale_en-US.ini | 1 - routers/web/auth/auth.go | 4 ++++ services/auth/auth_token.go | 6 ++++++ services/cron/tasks_basic.go | 11 ----------- 7 files changed, 14 insertions(+), 22 deletions(-) diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index f972b6dd5cfc..6d6bb3897ace 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -954,12 +954,6 @@ Default templates for project boards: - `SCHEDULE`: **@midnight** : Interval as a duration between each synchronization, it will always attempt synchronization when the instance starts. - `UPDATE_EXISTING`: **true**: Create new users, update existing user data and disable users that are not in external source anymore (default) or only create new users if UPDATE_EXISTING is set to false. -#### Cron - Cleanup auth tokens (`cron.cleanup_auth_tokens`) - -- `ENABLED`: **true**: Enable cleanup of expired auth tokens. -- `RUN_AT_START`: **false**: Run job at start time (if ENABLED). -- `SCHEDULE`: **@midnight**: Cron syntax for the job. - ### Extended cron tasks (not enabled by default) #### Cron - Garbage collect all repositories (`cron.git_gc_repos`) diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go index 49b1ebd72a69..65f1b169eb2a 100644 --- a/models/auth/auth_token.go +++ b/models/auth/auth_token.go @@ -18,8 +18,8 @@ var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist") type AuthToken struct { //nolint:revive ID string `xorm:"pk"` TokenHash string - UserID int64 `xorm:"INDEX"` - ExpiresUnix timeutil.TimeStamp + UserID int64 `xorm:"INDEX"` + ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"` } func init() { diff --git a/models/migrations/v1_21/v271.go b/models/migrations/v1_21/v271.go index 79333ffb47b6..99d9124fca67 100644 --- a/models/migrations/v1_21/v271.go +++ b/models/migrations/v1_21/v271.go @@ -13,8 +13,8 @@ func CreateAuthTokenTable(x *xorm.Engine) error { type AuthToken struct { ID string `xorm:"pk"` TokenHash string - UserID int64 `xorm:"INDEX"` - ExpiresUnix timeutil.TimeStamp + UserID int64 `xorm:"INDEX"` + ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"` } return x.Sync(new(AuthToken)) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e352183144b7..25db047720df 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2703,7 +2703,6 @@ dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for w dashboard.sync_external_users = Synchronize external user data dashboard.cleanup_hook_task_table = Cleanup hook_task table dashboard.cleanup_packages = Cleanup expired packages -dashboard.cleanup_auth_tokens = Cleanup auth tokens dashboard.server_uptime = Server Uptime dashboard.current_goroutine = Current Goroutines dashboard.current_memory_usage = Current Memory Usage diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 0904a4b16c22..98eb4a35fa44 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -56,6 +56,10 @@ func autoSignIn(ctx *context.Context) (bool, error) { } }() + if err := auth.DeleteExpiredAuthTokens(ctx); err != nil { + log.Error("Failed to delete expired auth tokens: %v", err) + } + t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName)) if err != nil { switch err { diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go index b95104cfae04..6b59238c984c 100644 --- a/services/auth/auth_token.go +++ b/services/auth/auth_token.go @@ -20,6 +20,10 @@ import ( // Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies +// The auth token consists of two parts: ID and token hash +// Every device login creates a new auth token with an individual id and hash. +// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash. + var ( ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format") ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired") @@ -51,6 +55,8 @@ func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, e hashedToken := sha256.Sum256([]byte(parts[1])) if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 { + // If an attacker steals a token and uses the token to create a new session the hash gets updated. + // When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token. return nil, ErrAuthTokenInvalidHash } diff --git a/services/cron/tasks_basic.go b/services/cron/tasks_basic.go index 5e22af22fe47..45e3e35bf50c 100644 --- a/services/cron/tasks_basic.go +++ b/services/cron/tasks_basic.go @@ -157,16 +157,6 @@ func registerCleanupPackages() { }) } -func registerCleanupAuthTokens() { - RegisterTaskFatal("cleanup_auth_tokens", &BaseConfig{ - Enabled: true, - RunAtStart: true, - Schedule: "@midnight", - }, func(ctx context.Context, _ *user_model.User, _ Config) error { - return auth_model.DeleteExpiredAuthTokens(ctx) - }) -} - func initBasicTasks() { if setting.Mirror.Enabled { registerUpdateMirrorTask() @@ -183,5 +173,4 @@ func initBasicTasks() { if setting.Packages.Enabled { registerCleanupPackages() } - registerCleanupAuthTokens() } From 73ad0f151c98ba2b5419222825a009325ac0fed0 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Fri, 13 Oct 2023 14:57:58 +0000 Subject: [PATCH 6/6] Add signin test. --- tests/integration/signin_test.go | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go index 9ae45d32424f..2584b88f6511 100644 --- a/tests/integration/signin_test.go +++ b/tests/integration/signin_test.go @@ -5,11 +5,13 @@ package integration import ( "net/http" + "net/url" "strings" "testing" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/tests" @@ -57,3 +59,37 @@ func TestSignin(t *testing.T) { testLoginFailed(t, s.username, s.password, s.message) } } + +func TestSigninWithRememberMe(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + baseURL, _ := url.Parse(setting.AppURL) + + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": GetCSRF(t, session, "/user/login"), + "user_name": user.Name, + "password": userPassword, + "remember": "on", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + c := session.GetCookie(setting.CookieRememberName) + assert.NotNil(t, c) + + session = emptyTestSession(t) + + // Without session the settings page should not be reachable + req = NewRequest(t, "GET", "/user/settings") + session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequest(t, "GET", "/user/login") + // Set the remember me cookie for the login GET request + session.jar.SetCookies(baseURL, []*http.Cookie{c}) + session.MakeRequest(t, req, http.StatusSeeOther) + + // With session the settings page should be reachable + req = NewRequest(t, "GET", "/user/settings") + session.MakeRequest(t, req, http.StatusOK) +}