From c6c829fe3fde5d55b2115eb006b427288e381158 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sat, 14 Oct 2023 02:56:41 +0200 Subject: [PATCH] Enhanced auth token / remember me (#27606) Closes #27455 > The mechanism responsible for long-term authentication (the 'remember me' cookie) uses a weak construction technique. It will hash the user's hashed password and the rands value; it will then call the secure cookie code, which will encrypt the user's name with the computed hash. If one were able to dump the database, they could extract those two values to rebuild that cookie and impersonate a user. That vulnerability exists from the date the dump was obtained until a user changed their password. > > To fix this security issue, the cookie could be created and verified using a different technique such as the one explained at https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies. The PR removes the now obsolete setting `COOKIE_USERNAME`. --- .../config-cheat-sheet.en-us.md | 1 - .../config-cheat-sheet.zh-cn.md | 1 - models/auth/{token.go => access_token.go} | 0 .../{token_scope.go => access_token_scope.go} | 0 ...ope_test.go => access_token_scope_test.go} | 0 .../{token_test.go => access_token_test.go} | 0 models/auth/auth_token.go | 60 +++++++++ models/migrations/migrations.go | 2 + models/migrations/v1_22/v281.go | 21 +++ modules/context/context_cookie.go | 44 ------- modules/setting/security.go | 2 - options/locale/locale_en-US.ini | 1 + routers/install/install.go | 12 +- routers/web/auth/2fa.go | 6 +- routers/web/auth/auth.go | 66 ++++++---- routers/web/auth/openid.go | 19 +-- routers/web/auth/webauthn.go | 3 +- routers/web/home.go | 3 +- routers/web/web.go | 2 +- services/auth/auth_token.go | 123 ++++++++++++++++++ services/auth/auth_token_test.go | 107 +++++++++++++++ services/auth/main_test.go | 14 ++ tests/integration/signin_test.go | 36 +++++ 23 files changed, 419 insertions(+), 104 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%) create mode 100644 models/auth/auth_token.go create mode 100644 models/migrations/v1_22/v281.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/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 3fd78d483e..16f8caf22f 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -517,7 +517,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`: **_empty_**: 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 diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md index 27f46cd8be..d541013159 100644 --- a/docs/content/administration/config-cheat-sheet.zh-cn.md +++ b/docs/content/administration/config-cheat-sheet.zh-cn.md @@ -506,7 +506,6 @@ Gitea 创建以下非唯一队列: - `SECRET_KEY`: **\<每次安装时随机生成\>**:全局服务器安全密钥。这个密钥非常重要,如果丢失将无法解密加密的数据(例如 2FA)。 - `SECRET_KEY_URI`: **_empty_**:与定义 `SECRET_KEY` 不同,此选项可用于使用存储在文件中的密钥(示例值:`file:/etc/gitea/secret_key`)。它不应该像 `SECRET_KEY` 一样容易丢失。 - `LOGIN_REMEMBER_DAYS`: **7**:Cookie 保存时间,单位为天。 -- `COOKIE_USERNAME`: **gitea\_awesome**:保存用户名的 Cookie 名称。 - `COOKIE_REMEMBER_NAME`: **gitea\_incredible**:保存自动登录信息的 Cookie 名称。 - `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**:反向代理认证的 HTTP 头部名称,用于提供用户信息。 - `REVERSE_PROXY_AUTHENTICATION_EMAIL`: **X-WEBAUTH-EMAIL**:反向代理认证的 HTTP 头部名称,用于提供邮箱信息。 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 diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go new file mode 100644 index 0000000000..65f1b169eb --- /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 { //nolint:revive + ID string `xorm:"pk"` + TokenHash string + UserID int64 `xorm:"INDEX"` + ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"` +} + +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/models/migrations/migrations.go b/models/migrations/migrations.go index a8037fa67e..4a06cdc73a 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -546,6 +546,8 @@ var migrations = []Migration{ // v280 -> v281 NewMigration("Rename user themes", v1_22.RenameUserThemes), + // v281 -> v282 + NewMigration("Add auth_token table", v1_22.CreateAuthTokenTable), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_22/v281.go b/models/migrations/v1_22/v281.go new file mode 100644 index 0000000000..fc1866aa83 --- /dev/null +++ b/models/migrations/v1_22/v281.go @@ -0,0 +1,21 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //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 `xorm:"INDEX"` + } + + return x.Sync(new(AuthToken)) +} diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go index 9ce67a5298..b6f8dadb56 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 90f614d4cd..92caa05fad 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 b7b99bd7a5..8677461322 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -363,6 +363,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. diff --git a/routers/install/install.go b/routers/install/install.go index 185e4bf6bf..5c0290d2cc 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -27,12 +27,14 @@ 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" "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" @@ -547,11 +549,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, setting.LogInRememberDays*timeutil.Day) // 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 bc3cb4907c..dc0062ebaa 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 df835a2fa1..1238e52755 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -43,40 +43,51 @@ 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) + 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 { + case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired: + return false, nil + } + return false, err + } + if t == nil { + return false, nil + } + + u, err := user_model.GetUserByID(ctx, t.UserID) if err != nil { if !user_model.IsErrUserNotExist(err) { - return false, fmt.Errorf("GetUserByName: %w", err) + return false, fmt.Errorf("GetUserByID: %w", err) } return false, nil } - if val, ok := ctx.GetSuperSecureCookie( - base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name { - return false, nil + isSucceed = true + + nt, token, err := auth_service.RegenerateAuthToken(ctx, t) + if err != nil { + return false, err } - isSucceed = true + ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day) if err := updateSession(ctx, nil, map[string]any{ // Set session IDs @@ -113,11 +124,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 +156,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 +304,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, setting.LogInRememberDays*timeutil.Day) } if err := updateSession(ctx, []string{ @@ -368,7 +385,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 aa07129632..29ef772b1c 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 9b516ce396..95c8d262a5 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 ab3fbde2c9..2321b00efe 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/routers/web/web.go b/routers/web/web.go index d2179a0008..6449f7716c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -187,7 +187,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont // 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/auth/auth_token.go b/services/auth/auth_token.go new file mode 100644 index 0000000000..6b59238c98 --- /dev/null +++ b/services/auth/auth_token.go @@ -0,0 +1,123 @@ +// 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" +) + +// 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") + 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 { + // 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 + } + + 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 0000000000..654275df17 --- /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 0000000000..b81c39a1f2 --- /dev/null +++ b/services/auth/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go index 9ae45d3242..2584b88f65 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) +}