[GITEA] rework long-term authentication

- The current architecture is inherently insecure, because you can
construct the 'secret' cookie value with values that are available in
the database. Thus provides zero protection when a database is
dumped/leaked.
- This patch implements a new architecture that's inspired from: [Paragonie Initiative](https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies).
- Integration testing is added to ensure the new mechanism works.
- Removes a setting, because it's not used anymore.

(cherry picked from commit eff097448b1ebd2a280fcdd55d10b1f6081e9ccd)

[GITEA] rework long-term authentication (squash) add migration

Reminder: the migration is run via integration tests as explained
in the commit "[DB] run all Forgejo migrations in integration tests"

(cherry picked from commit 4accf7443c1c59b4d2e7787d6a6c602d725da403)
(cherry picked from commit 99d06e344ebc3b50bafb2ac4473dd95f057d1ddc)
(cherry picked from commit d8bc98a8f021d381bf72790ad246f923ac983ad4)
(cherry picked from commit 6404845df9a63802fff4c5bd6cfe1e390076e7f0)
(cherry picked from commit 72bdd4f3b9f6509d1ff3f10ecb12c621a932ed30)
(cherry picked from commit 4b01bb0ce812b6c59414ff53fed728563d8bc9cc)
(cherry picked from commit c26ac31816)
This commit is contained in:
Gusted 2023-06-03 15:21:40 +02:00 committed by Earl Warren
parent d7268d7266
commit 8d2dab94a6
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
17 changed files with 365 additions and 154 deletions

96
models/auth/auth_token.go Normal file
View 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))
}

View file

@ -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.

View 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))
}

View file

@ -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
} }

View file

@ -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)
} }

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
} }

View file

@ -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)

View file

@ -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

View file

@ -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
} }

View file

@ -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"))
} }

View file

@ -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())
} }

View file

@ -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.

View 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})
}

View file

@ -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)