mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-10-31 14:31:02 +00:00 
			
		
		
		
	[v1.21] [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 commiteff097448b) [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 commit4accf7443c) (cherry picked from commit 99d06e344ebc3b50bafb2ac4473dd95f057d1ddc) (cherry picked from commitd8bc98a8f0) (cherry picked from commit6404845df9)
This commit is contained in:
		
					parent
					
						
							
								0c409950f1
							
						
					
				
			
			
				commit
				
					
						72bdd4f3b9
					
				
			
		
					 17 changed files with 365 additions and 154 deletions
				
			
		
							
								
								
									
										96
									
								
								models/auth/auth_token.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								models/auth/auth_token.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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)) | ||||
| } | ||||
|  | @ -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. | ||||
|  |  | |||
							
								
								
									
										25
									
								
								models/forgejo_migrations/v1_20/v3.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								models/forgejo_migrations/v1_20/v3.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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)) | ||||
| } | ||||
|  | @ -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 | ||||
| 	} | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| 	} | ||||
|  |  | |||
|  | @ -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")) | ||||
| 	} | ||||
|  |  | |||
|  | @ -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()) | ||||
| 			} | ||||
|  |  | |||
|  | @ -76,10 +76,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. | ||||
|  |  | |||
							
								
								
									
										163
									
								
								tests/integration/auth_token_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								tests/integration/auth_token_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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}) | ||||
| } | ||||
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue