[SECURITY] Rework long-term authentication

- This is a 'front-port' of the already existing patch on v1.21 and
v1.20, but applied on top of what Gitea has done to rework the LTA
mechanism. Forgejo will stick with the reworked mechanism by the Forgejo
Security team for the time being. The removal of legacy code (AES-GCM) has been
left out.
- 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 e3d6622a63)
(cherry picked from commit fef1a6dac5)
(cherry picked from commit b0c5165145)
(cherry picked from commit 7ad51b9f8d)
(cherry picked from commit 64f053f383)
(cherry picked from commit f5e78e4c20)

Conflicts:
	services/auth/auth_token_test.go
	https://codeberg.org/forgejo/forgejo/pulls/2069
(cherry picked from commit f69fc23d4b)
This commit is contained in:
Gusted 2023-11-22 17:26:21 +01:00 committed by Earl Warren
parent 7b254f84cf
commit d955ab3ab0
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
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 }
// 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
} }
return at, nil
}
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error { _, err := db.DeleteByBean(ctx, &AuthorizationToken{UID: userID})
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
return err return err
} }
func DeleteAuthTokenByID(ctx context.Context, id string) error { // HashValidator will return a hexified hashed version of the validator.
_, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{}) func HashValidator(validator []byte) string {
return err h := sha256.New()
} h.Write(validator)
return hex.EncodeToString(h.Sum(nil))
func DeleteExpiredAuthTokens(ctx context.Context) error {
_, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{})
return err
} }

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

@ -385,6 +385,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"
@ -52,23 +54,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)
@ -78,17 +96,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)
} }
@ -124,10 +134,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{
@ -321,8 +324,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
"twofaRemember", "twofaRemember",
"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 + "/"
@ -739,8 +741,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
log.Trace("User activated: %s", user.Name) log.Trace("User activated: %s", user.Name)
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

@ -1123,8 +1123,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. // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
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.MockSet(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.MockUnset()
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.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
defer timeutil.MockUnset()
at, token, err := CreateAuthTokenForUserID(db.DefaultContext, 2)
assert.NoError(t, err)
assert.NotNil(t, at)
assert.NotEmpty(t, token)
timeutil.MockSet(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"
@ -302,6 +303,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)
@ -310,6 +317,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)