Merge pull request '[SECURITY] Rework long-term authentication' (#1802) from Gusted/forgejo:forgejo-rework-lta into forgejo-development

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/1802
This commit is contained in:
Gusted 2023-11-23 18:17:40 +00:00
commit dd879dac00
15 changed files with 328 additions and 309 deletions

View file

@ -31,7 +31,6 @@ package "code.gitea.io/gitea/models/asymkey"
func HasDeployKey func HasDeployKey
package "code.gitea.io/gitea/models/auth" package "code.gitea.io/gitea/models/auth"
func DeleteAuthTokenByID
func GetSourceByName func GetSourceByName
func GetWebAuthnCredentialByID func GetWebAuthnCredentialByID
func WebAuthnCredentials func WebAuthnCredentials

View file

@ -1,60 +1,96 @@
// Copyright 2023 The Gitea Authors. All rights reserved. // Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package auth package auth
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"xorm.io/builder"
) )
var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist") // 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
}
type AuthToken struct { //nolint:revive // TableName provides the real table name.
ID string `xorm:"pk"` func (AuthorizationToken) TableName() string {
TokenHash string return "forgejo_auth_token"
UserID int64 `xorm:"INDEX"`
ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
} }
func init() { func init() {
db.RegisterModel(new(AuthToken)) db.RegisterModel(new(AuthorizationToken))
} }
func InsertAuthToken(ctx context.Context, t *AuthToken) error { // IsExpired returns if the authorization token is expired.
_, err := db.GetEngine(ctx).Insert(t) func (authToken *AuthorizationToken) IsExpired() bool {
return err return authToken.Expiry.AsLocalTime().Before(time.Now())
} }
func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) { // GenerateAuthToken generates a new authentication token for the given user.
at := &AuthToken{} // 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]
has, err := db.GetEngine(ctx).ID(id).Get(at) _, 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 { if err != nil {
return nil, err return nil, err
} else if !has {
return nil, fmt.Errorf("lookup key %q: %w", lookupKey, util.ErrNotExist)
} }
if !has { return &authToken, nil
return nil, ErrAuthTokenNotExist
}
return at, nil
} }
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error { // DeleteAuthToken will delete the authorization token.
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t) func DeleteAuthToken(ctx context.Context, authToken *AuthorizationToken) error {
_, err := db.DeleteByBean(ctx, authToken)
return err return err
} }
func DeleteAuthTokenByID(ctx context.Context, id string) error { // DeleteAuthTokenByUser will delete all authorization tokens for the user.
_, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{}) func DeleteAuthTokenByUser(ctx context.Context, userID int64) error {
if userID == 0 {
return nil
}
_, err := db.DeleteByBean(ctx, &AuthorizationToken{UID: userID})
return err return err
} }
func DeleteExpiredAuthTokens(ctx context.Context) error { // HashValidator will return a hexified hashed version of the validator.
_, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{}) func HashValidator(validator []byte) string {
return err 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,26 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// 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

@ -380,6 +380,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

@ -7,7 +7,10 @@ import (
"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/timeutil"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
) )
@ -40,3 +43,14 @@ func (ctx *Context) DeleteSiteCookie(name string) {
func (ctx *Context) GetSiteCookie(name string) string { func (ctx *Context) GetSiteCookie(name string) string {
return middleware.GetSiteCookie(ctx.Req, name) return middleware.GetSiteCookie(ctx.Req, name)
} }
// 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 err
}
ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days)
return nil
}

View file

@ -27,14 +27,12 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/user" "code.gitea.io/gitea/modules/user"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/common"
auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
"gitea.com/go-chi/session" "gitea.com/go-chi/session"
@ -549,20 +547,13 @@ func SubmitInstall(ctx *context.Context) {
u, _ = user_model.GetUserByName(ctx, u.Name) u, _ = user_model.GetUserByName(ctx, u.Name)
} }
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) if err := ctx.SetLTACookie(u); err != nil {
if err != nil {
ctx.ServerError("CreateAuthTokenForUserID", err)
return
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
// 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"
@ -56,23 +58,39 @@ func autoSignIn(ctx *context.Context) (bool, error) {
} }
}() }()
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil { authCookie := ctx.GetSiteCookie(setting.CookieRememberName)
log.Error("Failed to delete expired auth tokens: %v", err) if len(authCookie) == 0 {
return false, nil
} }
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName)) lookupKey, validator, found := strings.Cut(authCookie, ":")
if !found {
return false, nil
}
authToken, err := auth.FindAuthToken(ctx, lookupKey)
if err != nil { if err != nil {
switch err { if errors.Is(err, util.ErrNotExist) {
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
return false, nil return false, nil
} }
return false, err return false, err
} }
if t == nil {
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 return false, nil
} }
u, err := user_model.GetUserByID(ctx, t.UserID) 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("GetUserByID: %w", err) return false, fmt.Errorf("GetUserByID: %w", err)
@ -82,17 +100,9 @@ func autoSignIn(ctx *context.Context) (bool, error) {
isSucceed = true isSucceed = true
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
if err != nil {
return false, err
}
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
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": u.ID,
"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)
} }
@ -128,10 +138,6 @@ func CheckAutoLogin(ctx *context.Context) bool {
// Check auto-login // Check auto-login
isSucceed, err := autoSignIn(ctx) isSucceed, err := autoSignIn(ctx)
if err != nil { if err != nil {
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
return false
}
ctx.ServerError("autoSignIn", err) ctx.ServerError("autoSignIn", err)
return true return true
} }
@ -302,13 +308,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 {
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) if err := ctx.SetLTACookie(u); err != nil {
if err != nil { ctx.ServerError("GenerateAuthToken", err)
ctx.ServerError("CreateAuthTokenForUserID", err)
return setting.AppSubURL + "/" return setting.AppSubURL + "/"
} }
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
} }
if err := updateSession(ctx, []string{ if err := updateSession(ctx, []string{
@ -322,7 +325,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 + "/"
@ -744,7 +746,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

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

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

@ -1,123 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"strings"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
)
// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies
// The auth token consists of two parts: ID and token hash
// Every device login creates a new auth token with an individual id and hash.
// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash.
var (
ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format")
ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired")
ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid")
)
func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
if len(value) == 0 {
return nil, nil
}
parts := strings.SplitN(value, ":", 2)
if len(parts) != 2 {
return nil, ErrAuthTokenInvalidFormat
}
t, err := auth_model.GetAuthTokenByID(ctx, parts[0])
if err != nil {
if errors.Is(err, util.ErrNotExist) {
return nil, ErrAuthTokenExpired
}
return nil, err
}
if t.ExpiresUnix < timeutil.TimeStampNow() {
return nil, ErrAuthTokenExpired
}
hashedToken := sha256.Sum256([]byte(parts[1]))
if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 {
// If an attacker steals a token and uses the token to create a new session the hash gets updated.
// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
return nil, ErrAuthTokenInvalidHash
}
return t, nil
}
func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) {
token, hash, err := generateTokenAndHash()
if err != nil {
return nil, "", err
}
newToken := &auth_model.AuthToken{
ID: t.ID,
TokenHash: hash,
UserID: t.UserID,
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
}
if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil {
return nil, "", err
}
return newToken, token, nil
}
func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) {
t := &auth_model.AuthToken{
UserID: userID,
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
}
var err error
t.ID, err = util.CryptoRandomString(10)
if err != nil {
return nil, "", err
}
token, hash, err := generateTokenAndHash()
if err != nil {
return nil, "", err
}
t.TokenHash = hash
if err := auth_model.InsertAuthToken(ctx, t); err != nil {
return nil, "", err
}
return t, token, nil
}
func generateTokenAndHash() (string, string, error) {
buf, err := util.CryptoRandomBytes(32)
if err != nil {
return "", "", err
}
token := hex.EncodeToString(buf)
hashedToken := sha256.Sum256([]byte(token))
return token, hex.EncodeToString(hashedToken[:]), nil
}

View file

@ -1,107 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"time"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestCheckAuthToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("Empty", func(t *testing.T) {
token, err := CheckAuthToken(db.DefaultContext, "")
assert.NoError(t, err)
assert.Nil(t, token)
})
t.Run("InvalidFormat", func(t *testing.T) {
token, err := CheckAuthToken(db.DefaultContext, "dummy")
assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat)
assert.Nil(t, token)
})
t.Run("NotFound", func(t *testing.T) {
token, err := CheckAuthToken(db.DefaultContext, "notexists:dummy")
assert.ErrorIs(t, err, ErrAuthTokenExpired)
assert.Nil(t, token)
})
t.Run("Expired", func(t *testing.T) {
timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
timeutil.Unset()
at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token)
assert.ErrorIs(t, err, ErrAuthTokenExpired)
assert.Nil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
})
t.Run("InvalidHash", func(t *testing.T) {
at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token+"dummy")
assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
assert.Nil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
})
t.Run("Valid", func(t *testing.T) {
at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
at2, err := CheckAuthToken(db.DefaultContext, at.ID+":"+token)
assert.NoError(t, err)
assert.NotNil(t, at2)
assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
})
}
func TestRegenerateAuthToken(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
timeutil.Set(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
defer timeutil.Unset()
at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
timeutil.Set(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC))
at2, token2, err := RegenerateAuthToken(db.DefaultContext, at)
assert.NoError(t, err)
assert.NotNil(t, at2)
assert.NotEmpty(t, token2)
assert.Equal(t, at.ID, at2.ID)
assert.Equal(t, at.UserID, at2.UserID)
assert.NotEqual(t, token, token2)
assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix)
assert.NoError(t, auth_model.DeleteAuthTokenByID(db.DefaultContext, at.ID))
}

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)