[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
) (cherry picked from commit72bdd4f3b9
) (cherry picked from commit4b01bb0ce8
)
This commit is contained in:
parent
d1e5d9d664
commit
c26ac31816
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
Reference in a new issue