mirror of
				https://codeberg.org/forgejo/forgejo.git
				synced 2025-10-30 14:01: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), | 	NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser), | ||||||
| 	// v1 -> v2 | 	// v1 -> v2 | ||||||
| 	NewMigration("create the forgejo_sem_ver table", forgejo_v1_20.CreateSemVerTable), | 	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. | // 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 | 		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 { | 	if u.Salt, err = GetUserSalt(); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -4,16 +4,14 @@ | ||||||
| package context | package context | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"crypto/sha256" |  | ||||||
| 	"encoding/hex" |  | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"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/setting" | ||||||
| 	"code.gitea.io/gitea/modules/util" | 	"code.gitea.io/gitea/modules/timeutil" | ||||||
| 	"code.gitea.io/gitea/modules/web/middleware" | 	"code.gitea.io/gitea/modules/web/middleware" | ||||||
| 
 |  | ||||||
| 	"golang.org/x/crypto/pbkdf2" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const CookieNameFlash = "gitea_flash" | const CookieNameFlash = "gitea_flash" | ||||||
|  | @ -46,41 +44,13 @@ func (ctx *Context) GetSiteCookie(name string) string { | ||||||
| 	return middleware.GetSiteCookie(ctx.Req, name) | 	return middleware.GetSiteCookie(ctx.Req, name) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetSuperSecureCookie returns given cookie value from request header with secret string. | // SetLTACookie will generate a LTA token and add it as an cookie. | ||||||
| func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) { | func (ctx *Context) SetLTACookie(u *user_model.User) error { | ||||||
| 	val := ctx.GetSiteCookie(name) | 	days := 86400 * setting.LogInRememberDays | ||||||
| 	return ctx.CookieDecrypt(secret, val) | 	lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days))) | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // 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 { | 	if err != nil { | ||||||
| 		return "", false | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 	ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days) | ||||||
| 	key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New) | 	return nil | ||||||
| 	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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -19,7 +19,6 @@ var ( | ||||||
| 	SecretKey                          string | 	SecretKey                          string | ||||||
| 	InternalToken                      string // internal access token | 	InternalToken                      string // internal access token | ||||||
| 	LogInRememberDays                  int | 	LogInRememberDays                  int | ||||||
| 	CookieUserName                     string |  | ||||||
| 	CookieRememberName                 string | 	CookieRememberName                 string | ||||||
| 	ReverseProxyAuthUser               string | 	ReverseProxyAuthUser               string | ||||||
| 	ReverseProxyAuthEmail              string | 	ReverseProxyAuthEmail              string | ||||||
|  | @ -104,7 +103,6 @@ func loadSecurityFrom(rootCfg ConfigProvider) { | ||||||
| 	sec := rootCfg.Section("security") | 	sec := rootCfg.Section("security") | ||||||
| 	InstallLock = HasInstallLock(rootCfg) | 	InstallLock = HasInstallLock(rootCfg) | ||||||
| 	LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7) | 	LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7) | ||||||
| 	CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome") |  | ||||||
| 	SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY") | 	SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY") | ||||||
| 	if SecretKey == "" { | 	if SecretKey == "" { | ||||||
| 		// FIXME: https://github.com/go-gitea/gitea/issues/16832 | 		// FIXME: https://github.com/go-gitea/gitea/issues/16832 | ||||||
|  |  | ||||||
|  | @ -4,10 +4,6 @@ | ||||||
| package util | package util | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"crypto/aes" |  | ||||||
| 	"crypto/cipher" |  | ||||||
| 	"crypto/rand" |  | ||||||
| 	"errors" |  | ||||||
| 	"io" | 	"io" | ||||||
| 	"os" | 	"os" | ||||||
| ) | ) | ||||||
|  | @ -40,52 +36,3 @@ func CopyFile(src, dest string) error { | ||||||
| 	} | 	} | ||||||
| 	return os.Chmod(dest, si.Mode()) | 	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 | package util | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"crypto/aes" |  | ||||||
| 	"crypto/rand" |  | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"os" | 	"os" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -37,21 +35,3 @@ func TestCopyFile(t *testing.T) { | ||||||
| 	assert.NoError(t, err) | 	assert.NoError(t, err) | ||||||
| 	assert.Equal(t, testContent, dstContent) | 	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) | 			u, _ = user_model.GetUserByName(ctx, u.Name) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		days := 86400 * setting.LogInRememberDays | 		if err := ctx.SetLTACookie(u); err != nil { | ||||||
| 		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 { |  | ||||||
| 			ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) | 			ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) | ||||||
| 			return | 			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) | 			ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form) | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -5,6 +5,8 @@ | ||||||
| package auth | package auth | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"crypto/subtle" | ||||||
|  | 	"encoding/hex" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
|  | @ -50,21 +52,47 @@ func AutoSignIn(ctx *context.Context) (bool, error) { | ||||||
| 		return false, nil | 		return false, nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	uname := ctx.GetSiteCookie(setting.CookieUserName) | 	authCookie := ctx.GetSiteCookie(setting.CookieRememberName) | ||||||
| 	if len(uname) == 0 { | 	if len(authCookie) == 0 { | ||||||
| 		return false, nil | 		return false, nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	isSucceed := false | 	isSucceed := false | ||||||
| 	defer func() { | 	defer func() { | ||||||
| 		if !isSucceed { | 		if !isSucceed { | ||||||
| 			log.Trace("auto-login cookie cleared: %s", uname) | 			log.Trace("Auto login cookie is cleared: %s", authCookie) | ||||||
| 			ctx.DeleteSiteCookie(setting.CookieUserName) |  | ||||||
| 			ctx.DeleteSiteCookie(setting.CookieRememberName) | 			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 err != nil { | ||||||
| 		if !user_model.IsErrUserNotExist(err) { | 		if !user_model.IsErrUserNotExist(err) { | ||||||
| 			return false, fmt.Errorf("GetUserByName: %w", err) | 			return false, fmt.Errorf("GetUserByName: %w", err) | ||||||
|  | @ -72,17 +100,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) { | ||||||
| 		return false, nil | 		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 | 	isSucceed = true | ||||||
| 
 | 
 | ||||||
| 	if err := updateSession(ctx, nil, map[string]any{ | 	if err := updateSession(ctx, nil, map[string]any{ | ||||||
| 		// Set session IDs | 		// Set session IDs | ||||||
| 		"uid":   u.ID, | 		"uid": authToken.UID, | ||||||
| 		"uname": u.Name, |  | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		return false, fmt.Errorf("unable to updateSession: %w", err) | 		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 { | func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string { | ||||||
| 	if remember { | 	if remember { | ||||||
| 		days := 86400 * setting.LogInRememberDays | 		if err := ctx.SetLTACookie(u); err != nil { | ||||||
| 		ctx.SetSiteCookie(setting.CookieUserName, u.Name, days) | 			ctx.ServerError("GenerateAuthToken", err) | ||||||
| 		ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd), | 			return setting.AppSubURL + "/" | ||||||
| 			setting.CookieRememberName, u.Name, days) | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := updateSession(ctx, []string{ | 	if err := updateSession(ctx, []string{ | ||||||
|  | @ -308,7 +330,6 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe | ||||||
| 		"linkAccount", | 		"linkAccount", | ||||||
| 	}, map[string]any{ | 	}, map[string]any{ | ||||||
| 		"uid": u.ID, | 		"uid": u.ID, | ||||||
| 		"uname": u.Name, |  | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		ctx.ServerError("RegenerateSession", err) | 		ctx.ServerError("RegenerateSession", err) | ||||||
| 		return setting.AppSubURL + "/" | 		return setting.AppSubURL + "/" | ||||||
|  | @ -369,7 +390,6 @@ func getUserName(gothUser *goth.User) string { | ||||||
| func HandleSignOut(ctx *context.Context) { | func HandleSignOut(ctx *context.Context) { | ||||||
| 	_ = ctx.Session.Flush() | 	_ = ctx.Session.Flush() | ||||||
| 	_ = ctx.Session.Destroy(ctx.Resp, ctx.Req) | 	_ = ctx.Session.Destroy(ctx.Resp, ctx.Req) | ||||||
| 	ctx.DeleteSiteCookie(setting.CookieUserName) |  | ||||||
| 	ctx.DeleteSiteCookie(setting.CookieRememberName) | 	ctx.DeleteSiteCookie(setting.CookieRememberName) | ||||||
| 	ctx.Csrf.DeleteCookie(ctx) | 	ctx.Csrf.DeleteCookie(ctx) | ||||||
| 	middleware.DeleteRedirectToCookie(ctx.Resp) | 	middleware.DeleteRedirectToCookie(ctx.Resp) | ||||||
|  | @ -733,7 +753,6 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { | ||||||
| 
 | 
 | ||||||
| 	if err := updateSession(ctx, nil, map[string]any{ | 	if err := updateSession(ctx, nil, map[string]any{ | ||||||
| 		"uid": user.ID, | 		"uid": user.ID, | ||||||
| 		"uname": user.Name, |  | ||||||
| 	}); err != nil { | 	}); err != nil { | ||||||
| 		log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) | 		log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err) | ||||||
| 		ctx.ServerError("ActivateUserEmail", err) | 		ctx.ServerError("ActivateUserEmail", err) | ||||||
|  |  | ||||||
|  | @ -1119,7 +1119,6 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model | ||||||
| 	if !needs2FA { | 	if !needs2FA { | ||||||
| 		if err := updateSession(ctx, nil, map[string]any{ | 		if err := updateSession(ctx, nil, map[string]any{ | ||||||
| 			"uid": u.ID, | 			"uid": u.ID, | ||||||
| 			"uname": u.Name, |  | ||||||
| 		}); err != nil { | 		}); err != nil { | ||||||
| 			ctx.ServerError("updateSession", err) | 			ctx.ServerError("updateSession", err) | ||||||
| 			return | 			return | ||||||
|  |  | ||||||
|  | @ -54,8 +54,7 @@ func Home(ctx *context.Context) { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Check auto-login. | 	// Check auto-login. | ||||||
| 	uname := ctx.GetSiteCookie(setting.CookieUserName) | 	if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 { | ||||||
| 	if len(uname) != 0 { |  | ||||||
| 		ctx.Redirect(setting.AppSubURL + "/user/login") | 		ctx.Redirect(setting.AppSubURL + "/user/login") | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -78,6 +78,15 @@ func AccountPost(ctx *context.Context) { | ||||||
| 			ctx.ServerError("UpdateUser", err) | 			ctx.ServerError("UpdateUser", err) | ||||||
| 			return | 			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) | 		log.Trace("User password updated: %s", ctx.Doer.Name) | ||||||
| 		ctx.Flash.Success(ctx.Tr("settings.change_password_success")) | 		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. | 		// Redirect to log in page if auto-signin info is provided and has not signed in. | ||||||
| 		if !options.SignOutRequired && !ctx.IsSigned && | 		if !options.SignOutRequired && !ctx.IsSigned && | ||||||
| 			len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 { | 			len(ctx.GetSiteCookie(setting.CookieRememberName)) > 0 { | ||||||
| 			if ctx.Req.URL.Path != "/user/events" { | 			if ctx.Req.URL.Path != "/user/events" { | ||||||
| 				middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) | 				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 { | 	if err != nil { | ||||||
| 		log.Error(fmt.Sprintf("Error setting session: %v", err)) | 		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 | 	// Language setting of the user overwrites the one previously set | ||||||
| 	// If the user does not have a locale set, we save the current one. | 	// 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" | 	"net/url" | ||||||
| 	"os" | 	"os" | ||||||
| 	"path/filepath" | 	"path/filepath" | ||||||
|  | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"sync/atomic" | 	"sync/atomic" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | @ -299,6 +300,12 @@ func loginUser(t testing.TB, userName string) *TestSession { | ||||||
| 
 | 
 | ||||||
| func loginUserWithPassword(t testing.TB, userName, password string) *TestSession { | func loginUserWithPassword(t testing.TB, userName, password string) *TestSession { | ||||||
| 	t.Helper() | 	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") | 	req := NewRequest(t, "GET", "/user/login") | ||||||
| 	resp := MakeRequest(t, req, http.StatusOK) | 	resp := MakeRequest(t, req, http.StatusOK) | ||||||
| 
 | 
 | ||||||
|  | @ -307,6 +314,7 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession | ||||||
| 		"_csrf":     doc.GetCSRF(), | 		"_csrf":     doc.GetCSRF(), | ||||||
| 		"user_name": userName, | 		"user_name": userName, | ||||||
| 		"password":  password, | 		"password":  password, | ||||||
|  | 		"remember":  strconv.FormatBool(rememberMe), | ||||||
| 	}) | 	}) | ||||||
| 	resp = MakeRequest(t, req, http.StatusSeeOther) | 	resp = MakeRequest(t, req, http.StatusSeeOther) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue