From a14be197eb0b6eddef0162aea2ba639873463f19 Mon Sep 17 00:00:00 2001 From: Gusted Date: Sat, 3 Jun 2023 15:21:40 +0200 Subject: [PATCH] [GITEA] rework long-term authentication - The current architecture is inherently insecure, because you can construct the 'secret' cookie value with values that are available in the database. Thus provides zero protection when a database is dumped/leaked. - This patch implements a new architecture that's inspired from: [Paragonie Initiative](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies). - Integration testing is added to ensure the new mechanism works. - Removes a setting, because it's not used anymore. (cherry picked from commit eff097448b1ebd2a280fcdd55d10b1f6081e9ccd) [GITEA] rework long-term authentication (squash) add migration Reminder: the migration is run via integration tests as explained in the commit "[DB] run all Forgejo migrations in integration tests" (cherry picked from commit 4accf7443c1c59b4d2e7787d6a6c602d725da403) --- models/auth/auth_token.go | 96 +++++++++++++++ models/forgejo_migrations/migrate.go | 2 + models/forgejo_migrations/v1_20/v3.go | 25 ++++ models/user/user.go | 5 + modules/context/context_cookie.go | 50 ++------ modules/setting/security.go | 2 - modules/util/legacy.go | 53 --------- modules/util/legacy_test.go | 20 ---- routers/install/install.go | 13 +- routers/web/auth/auth.go | 61 ++++++---- routers/web/auth/oauth.go | 3 +- routers/web/home.go | 3 +- routers/web/user/setting/account.go | 9 ++ routers/web/web.go | 2 +- services/auth/auth.go | 4 - tests/integration/auth_token_test.go | 163 ++++++++++++++++++++++++++ tests/integration/integration_test.go | 8 ++ 17 files changed, 365 insertions(+), 154 deletions(-) create mode 100644 models/auth/auth_token.go create mode 100644 models/forgejo_migrations/v1_20/v3.go create mode 100644 tests/integration/auth_token_test.go diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go new file mode 100644 index 0000000000..2c3ca90734 --- /dev/null +++ b/models/auth/auth_token.go @@ -0,0 +1,96 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +// AuthorizationToken represents a authorization token to a user. +type AuthorizationToken struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX"` + LookupKey string `xorm:"INDEX UNIQUE"` + HashedValidator string + Expiry timeutil.TimeStamp +} + +// TableName provides the real table name. +func (AuthorizationToken) TableName() string { + return "forgejo_auth_token" +} + +func init() { + db.RegisterModel(new(AuthorizationToken)) +} + +// IsExpired returns if the authorization token is expired. +func (authToken *AuthorizationToken) IsExpired() bool { + return authToken.Expiry.AsLocalTime().Before(time.Now()) +} + +// GenerateAuthToken generates a new authentication token for the given user. +// It returns the lookup key and validator values that should be passed to the +// user via a long-term cookie. +func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp) (lookupKey, validator string, err error) { + // Request 64 random bytes. The first 32 bytes will be used for the lookupKey + // and the other 32 bytes will be used for the validator. + rBytes, err := util.CryptoRandomBytes(64) + if err != nil { + return "", "", err + } + hexEncoded := hex.EncodeToString(rBytes) + validator, lookupKey = hexEncoded[64:], hexEncoded[:64] + + _, err = db.GetEngine(ctx).Insert(&AuthorizationToken{ + UID: userID, + Expiry: expiry, + LookupKey: lookupKey, + HashedValidator: HashValidator(rBytes[32:]), + }) + return lookupKey, validator, err +} + +// FindAuthToken will find a authorization token via the lookup key. +func FindAuthToken(ctx context.Context, lookupKey string) (*AuthorizationToken, error) { + var authToken AuthorizationToken + has, err := db.GetEngine(ctx).Where("lookup_key = ?", lookupKey).Get(&authToken) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("lookup key %q: %w", lookupKey, util.ErrNotExist) + } + return &authToken, nil +} + +// DeleteAuthToken will delete the authorization token. +func DeleteAuthToken(ctx context.Context, authToken *AuthorizationToken) error { + _, err := db.DeleteByBean(ctx, authToken) + return err +} + +// DeleteAuthTokenByUser will delete all authorization tokens for the user. +func DeleteAuthTokenByUser(ctx context.Context, userID int64) error { + if userID == 0 { + return nil + } + + _, err := db.DeleteByBean(ctx, &AuthorizationToken{UID: userID}) + return err +} + +// HashValidator will return a hexified hashed version of the validator. +func HashValidator(validator []byte) string { + h := sha256.New() + h.Write(validator) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 2becf1b713..58f158bd17 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -41,6 +41,8 @@ var migrations = []*Migration{ NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser), // v1 -> v2 NewMigration("create the forgejo_sem_ver table", forgejo_v1_20.CreateSemVerTable), + // v2 -> v3 + NewMigration("create the forgejo_auth_token table", forgejo_v1_20.CreateAuthorizationTokenTable), } // GetCurrentDBVersion returns the current Forgejo database version. diff --git a/models/forgejo_migrations/v1_20/v3.go b/models/forgejo_migrations/v1_20/v3.go new file mode 100644 index 0000000000..38c29bed03 --- /dev/null +++ b/models/forgejo_migrations/v1_20/v3.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +package forgejo_v1_20 //nolint:revive + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type AuthorizationToken struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX"` + LookupKey string `xorm:"INDEX UNIQUE"` + HashedValidator string + Expiry timeutil.TimeStamp +} + +func (AuthorizationToken) TableName() string { + return "forgejo_auth_token" +} + +func CreateAuthorizationTokenTable(x *xorm.Engine) error { + return x.Sync(new(AuthorizationToken)) +} diff --git a/models/user/user.go b/models/user/user.go index 95786a5a63..abf9f213f7 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -386,6 +386,11 @@ func (u *User) SetPassword(passwd string) (err error) { return nil } + // Invalidate all authentication tokens for this user. + if err := auth.DeleteAuthTokenByUser(db.DefaultContext, u.ID); err != nil { + return err + } + if u.Salt, err = GetUserSalt(); err != nil { return err } diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go index 1fd9719d31..39e3218d1b 100644 --- a/modules/context/context_cookie.go +++ b/modules/context/context_cookie.go @@ -4,16 +4,14 @@ package context import ( - "crypto/sha256" - "encoding/hex" "net/http" "strings" + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web/middleware" - - "golang.org/x/crypto/pbkdf2" ) const CookieNameFlash = "gitea_flash" @@ -46,41 +44,13 @@ 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) +// SetLTACookie will generate a LTA token and add it as an cookie. +func (ctx *Context) SetLTACookie(u *user_model.User) error { + days := 86400 * setting.LogInRememberDays + lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days))) if err != nil { - return "", false + return err } - - 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) + ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days) + return nil } 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/modules/util/legacy.go b/modules/util/legacy.go index 2ea293a2be..2d4de01949 100644 --- a/modules/util/legacy.go +++ b/modules/util/legacy.go @@ -4,10 +4,6 @@ package util import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "errors" "io" "os" ) @@ -40,52 +36,3 @@ func CopyFile(src, dest string) error { } return os.Chmod(dest, si.Mode()) } - -// AESGCMEncrypt (from legacy package): encrypts plaintext with the given key using AES in GCM mode. should be replaced. -func AESGCMEncrypt(key, plaintext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return nil, err - } - - ciphertext := gcm.Seal(nil, nonce, plaintext, nil) - return append(nonce, ciphertext...), nil -} - -// AESGCMDecrypt (from legacy package): decrypts ciphertext with the given key using AES in GCM mode. should be replaced. -func AESGCMDecrypt(key, ciphertext []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - size := gcm.NonceSize() - if len(ciphertext)-size <= 0 { - return nil, errors.New("ciphertext is empty") - } - - nonce := ciphertext[:size] - ciphertext = ciphertext[size:] - - plainText, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - - return plainText, nil -} diff --git a/modules/util/legacy_test.go b/modules/util/legacy_test.go index e732094c29..b7991bd365 100644 --- a/modules/util/legacy_test.go +++ b/modules/util/legacy_test.go @@ -4,8 +4,6 @@ package util import ( - "crypto/aes" - "crypto/rand" "fmt" "os" "testing" @@ -37,21 +35,3 @@ func TestCopyFile(t *testing.T) { assert.NoError(t, err) assert.Equal(t, testContent, dstContent) } - -func TestAESGCM(t *testing.T) { - t.Parallel() - - key := make([]byte, aes.BlockSize) - _, err := rand.Read(key) - assert.NoError(t, err) - - plaintext := []byte("this will be encrypted") - - ciphertext, err := AESGCMEncrypt(key, plaintext) - assert.NoError(t, err) - - decrypted, err := AESGCMDecrypt(key, ciphertext) - assert.NoError(t, err) - - assert.Equal(t, plaintext, decrypted) -} diff --git a/routers/install/install.go b/routers/install/install.go index 1c44e1f5fd..cb7818bd33 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -553,18 +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) - - ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), - setting.CookieRememberName, u.Name, days) - - // Auto-login for admin - if err = ctx.Session.Set("uid", u.ID); err != nil { + if err := ctx.SetLTACookie(u); err != nil { ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } - if err = ctx.Session.Set("uname", u.Name); err != nil { + + // Auto-login for admin + if err = ctx.Session.Set("uid", u.ID); err != nil { ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) return } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 465077a909..884c6fe266 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -5,6 +5,8 @@ package auth import ( + "crypto/subtle" + "encoding/hex" "errors" "fmt" "net/http" @@ -50,21 +52,47 @@ func AutoSignIn(ctx *context.Context) (bool, error) { return false, nil } - uname := ctx.GetSiteCookie(setting.CookieUserName) - if len(uname) == 0 { + authCookie := ctx.GetSiteCookie(setting.CookieRememberName) + if len(authCookie) == 0 { return false, nil } isSucceed := false defer func() { if !isSucceed { - log.Trace("auto-login cookie cleared: %s", uname) - ctx.DeleteSiteCookie(setting.CookieUserName) + log.Trace("Auto login cookie is cleared: %s", authCookie) ctx.DeleteSiteCookie(setting.CookieRememberName) } }() - u, err := user_model.GetUserByName(ctx, uname) + lookupKey, validator, found := strings.Cut(authCookie, ":") + if !found { + return false, nil + } + + authToken, err := auth.FindAuthToken(ctx, lookupKey) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + return false, nil + } + return false, err + } + + if authToken.IsExpired() { + err = auth.DeleteAuthToken(ctx, authToken) + return false, err + } + + rawValidator, err := hex.DecodeString(validator) + if err != nil { + return false, err + } + + if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 { + return false, nil + } + + u, err := user_model.GetUserByID(ctx, authToken.UID) if err != nil { if !user_model.IsErrUserNotExist(err) { return false, fmt.Errorf("GetUserByName: %w", err) @@ -72,17 +100,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) { 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 if err := updateSession(ctx, nil, map[string]any{ // Set session IDs - "uid": u.ID, - "uname": u.Name, + "uid": authToken.UID, }); err != nil { return false, fmt.Errorf("unable to updateSession: %w", err) } @@ -291,10 +313,10 @@ 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) + if err := ctx.SetLTACookie(u); err != nil { + ctx.ServerError("GenerateAuthToken", err) + return setting.AppSubURL + "/" + } } if err := updateSession(ctx, []string{ @@ -307,8 +329,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe "twofaRemember", "linkAccount", }, map[string]any{ - "uid": u.ID, - "uname": u.Name, + "uid": u.ID, }); err != nil { ctx.ServerError("RegenerateSession", err) return setting.AppSubURL + "/" @@ -369,7 +390,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) @@ -732,8 +752,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { log.Trace("User activated: %s", user.Name) if err := updateSession(ctx, nil, map[string]any{ - "uid": user.ID, - "uname": user.Name, + "uid": user.ID, }); err != nil { log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) ctx.ServerError("ActivateUserEmail", err) diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index ce58cbdef9..2aba6e1224 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -1118,8 +1118,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. if !needs2FA { if err := updateSession(ctx, nil, map[string]any{ - "uid": u.ID, - "uname": u.Name, + "uid": u.ID, }); err != nil { ctx.ServerError("updateSession", err) return diff --git a/routers/web/home.go b/routers/web/home.go index ab3fbde2c9..4bcc4adcbf 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 len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 { ctx.Redirect(setting.AppSubURL + "/user/login") return } diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 5c14f3ad4b..f50c19a923 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -78,6 +78,15 @@ func AccountPost(ctx *context.Context) { ctx.ServerError("UpdateUser", err) return } + + // Re-generate LTA cookie. + if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 { + if err := ctx.SetLTACookie(ctx.Doer); err != nil { + ctx.ServerError("SetLTACookie", err) + return + } + } + log.Trace("User password updated: %s", ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.change_password_success")) } diff --git a/routers/web/web.go b/routers/web/web.go index bb10d86be7..593e56f586 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -181,7 +181,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 { + len(ctx.GetSiteCookie(setting.CookieRememberName)) > 0 { if ctx.Req.URL.Path != "/user/events" { middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) } diff --git a/services/auth/auth.go b/services/auth/auth.go index 0c8acac61f..338e07005d 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -72,10 +72,6 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore if err != nil { log.Error(fmt.Sprintf("Error setting session: %v", err)) } - err = sess.Set("uname", user.Name) - if err != nil { - log.Error(fmt.Sprintf("Error setting session: %v", err)) - } // Language setting of the user overwrites the one previously set // If the user does not have a locale set, we save the current one. diff --git a/tests/integration/auth_token_test.go b/tests/integration/auth_token_test.go new file mode 100644 index 0000000000..24c66ee261 --- /dev/null +++ b/tests/integration/auth_token_test.go @@ -0,0 +1,163 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/hex" + "net/http" + "net/url" + "strings" + "testing" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + "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/timeutil" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +// GetSessionForLTACookie returns a new session with only the LTA cookie being set. +func GetSessionForLTACookie(t *testing.T, ltaCookie *http.Cookie) *TestSession { + t.Helper() + + ch := http.Header{} + ch.Add("Cookie", ltaCookie.String()) + cr := http.Request{Header: ch} + + session := emptyTestSession(t) + baseURL, err := url.Parse(setting.AppURL) + assert.NoError(t, err) + session.jar.SetCookies(baseURL, cr.Cookies()) + + return session +} + +// GetLTACookieValue returns the value of the LTA cookie. +func GetLTACookieValue(t *testing.T, sess *TestSession) string { + t.Helper() + + rememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, rememberCookie) + + cookieValue, err := url.QueryUnescape(rememberCookie.Value) + assert.NoError(t, err) + + return cookieValue +} + +// TestSessionCookie checks if the session cookie provides authentication. +func TestSessionCookie(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + sess := loginUser(t, "user1") + assert.NotNil(t, sess.GetCookie(setting.SessionConfig.CookieName)) + + req := NewRequest(t, "GET", "/user/settings") + sess.MakeRequest(t, req, http.StatusOK) +} + +// TestLTACookie checks if the LTA cookie that's returned is valid, exists in the database +// and provides authentication of no session cookie is present. +func TestLTACookie(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + sess := emptyTestSession(t) + + req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{ + "_csrf": GetCSRF(t, sess, "/user/login"), + "user_name": user.Name, + "password": userPassword, + "remember": "true", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + + // Checks if the database entry exist for the user. + ltaCookieValue := GetLTACookieValue(t, sess) + lookupKey, validator, found := strings.Cut(ltaCookieValue, ":") + assert.True(t, found) + rawValidator, err := hex.DecodeString(validator) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID}) + + // Check if the LTA cookie it provides authentication. + // If LTA cookie provides authentication /user/login shouldn't return status 200. + session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName)) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusSeeOther) +} + +// TestLTAPasswordChange checks that LTA doesn't provide authentication when a +// password change has happened and that the new LTA does provide authentication. +func TestLTAPasswordChange(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true) + oldRememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, oldRememberCookie) + + // Make a simple password change. + req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{ + "_csrf": GetCSRF(t, sess, "/user/settings/account"), + "old_password": userPassword, + "password": "password2", + "retype": "password2", + }) + sess.MakeRequest(t, req, http.StatusSeeOther) + rememberCookie := sess.GetCookie(setting.CookieRememberName) + assert.NotNil(t, rememberCookie) + + // Check if the password really changed. + assert.NotEqualValues(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).Passwd, user.Passwd) + + // /user/settings/account should provide with a new LTA cookie, so check for that. + // If LTA cookie provides authentication /user/login shouldn't return status 200. + session := GetSessionForLTACookie(t, rememberCookie) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusSeeOther) + + // Check if the old LTA token is invalidated. + session = GetSessionForLTACookie(t, oldRememberCookie) + req = NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusOK) +} + +// TestLTAExpiry tests that the LTA expiry works. +func TestLTAExpiry(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true) + + ltaCookieValie := GetLTACookieValue(t, sess) + lookupKey, _, found := strings.Cut(ltaCookieValie, ":") + assert.True(t, found) + + // Ensure it's not expired. + lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) + assert.False(t, lta.IsExpired()) + + // Manually stub LTA's expiry. + _, err := db.GetEngine(db.DefaultContext).ID(lta.ID).Table("forgejo_auth_token").Cols("expiry").Update(&auth.AuthorizationToken{Expiry: timeutil.TimeStampNow()}) + assert.NoError(t, err) + + // Ensure it's expired. + lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) + assert.True(t, lta.IsExpired()) + + // Should return 200 OK, because LTA doesn't provide authorization anymore. + session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName)) + req := NewRequest(t, "GET", "/user/login") + session.MakeRequest(t, req, http.StatusOK) + + // Ensure it's deleted. + unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey}) +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 6b00f3575b..cb6269cea9 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -17,6 +17,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync/atomic" "testing" @@ -299,6 +300,12 @@ func loginUser(t testing.TB, userName string) *TestSession { func loginUserWithPassword(t testing.TB, userName, password string) *TestSession { t.Helper() + + return loginUserWithPasswordRemember(t, userName, password, false) +} + +func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession { + t.Helper() req := NewRequest(t, "GET", "/user/login") resp := MakeRequest(t, req, http.StatusOK) @@ -307,6 +314,7 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession "_csrf": doc.GetCSRF(), "user_name": userName, "password": password, + "remember": strconv.FormatBool(rememberMe), }) resp = MakeRequest(t, req, http.StatusSeeOther)