[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.
This commit is contained in:
parent
905e546549
commit
eff097448b
15 changed files with 338 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))
|
||||||
|
}
|
|
@ -386,6 +386,11 @@ func (u *User) SetPassword(passwd string) (err error) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate all authentication tokens for this user.
|
||||||
|
if err := auth.DeleteAuthTokenByUser(db.DefaultContext, u.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if u.Salt, err = GetUserSalt(); err != nil {
|
if u.Salt, err = GetUserSalt(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,16 +4,14 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
|
|
||||||
"golang.org/x/crypto/pbkdf2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const CookieNameFlash = "gitea_flash"
|
const CookieNameFlash = "gitea_flash"
|
||||||
|
@ -46,41 +44,13 @@ func (ctx *Context) GetSiteCookie(name string) string {
|
||||||
return middleware.GetSiteCookie(ctx.Req, name)
|
return middleware.GetSiteCookie(ctx.Req, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSuperSecureCookie returns given cookie value from request header with secret string.
|
// SetLTACookie will generate a LTA token and add it as an cookie.
|
||||||
func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) {
|
func (ctx *Context) SetLTACookie(u *user_model.User) error {
|
||||||
val := ctx.GetSiteCookie(name)
|
days := 86400 * setting.LogInRememberDays
|
||||||
return ctx.CookieDecrypt(secret, val)
|
lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)))
|
||||||
}
|
|
||||||
|
|
||||||
// CookieDecrypt returns given value from with secret string.
|
|
||||||
func (ctx *Context) CookieDecrypt(secret, val string) (string, bool) {
|
|
||||||
if val == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
text, err := hex.DecodeString(val)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return err
|
||||||
}
|
}
|
||||||
|
ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days)
|
||||||
key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
|
return nil
|
||||||
text, err = util.AESGCMDecrypt(key, text)
|
|
||||||
return string(text), err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSuperSecureCookie sets given cookie value to response header with secret string.
|
|
||||||
func (ctx *Context) SetSuperSecureCookie(secret, name, value string, maxAge int) {
|
|
||||||
text := ctx.CookieEncrypt(secret, value)
|
|
||||||
ctx.SetSiteCookie(name, text, maxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CookieEncrypt encrypts a given value using the provided secret
|
|
||||||
func (ctx *Context) CookieEncrypt(secret, value string) string {
|
|
||||||
key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
|
|
||||||
text, err := util.AESGCMEncrypt(key, []byte(value))
|
|
||||||
if err != nil {
|
|
||||||
panic("error encrypting cookie: " + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return hex.EncodeToString(text)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,6 @@ var (
|
||||||
SecretKey string
|
SecretKey string
|
||||||
InternalToken string // internal access token
|
InternalToken string // internal access token
|
||||||
LogInRememberDays int
|
LogInRememberDays int
|
||||||
CookieUserName string
|
|
||||||
CookieRememberName string
|
CookieRememberName string
|
||||||
ReverseProxyAuthUser string
|
ReverseProxyAuthUser string
|
||||||
ReverseProxyAuthEmail string
|
ReverseProxyAuthEmail string
|
||||||
|
@ -104,7 +103,6 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
|
||||||
sec := rootCfg.Section("security")
|
sec := rootCfg.Section("security")
|
||||||
InstallLock = HasInstallLock(rootCfg)
|
InstallLock = HasInstallLock(rootCfg)
|
||||||
LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7)
|
LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7)
|
||||||
CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome")
|
|
||||||
SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
|
SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
|
||||||
if SecretKey == "" {
|
if SecretKey == "" {
|
||||||
// FIXME: https://github.com/go-gitea/gitea/issues/16832
|
// FIXME: https://github.com/go-gitea/gitea/issues/16832
|
||||||
|
|
|
@ -4,10 +4,6 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
@ -40,52 +36,3 @@ func CopyFile(src, dest string) error {
|
||||||
}
|
}
|
||||||
return os.Chmod(dest, si.Mode())
|
return os.Chmod(dest, si.Mode())
|
||||||
}
|
}
|
||||||
|
|
||||||
// AESGCMEncrypt (from legacy package): encrypts plaintext with the given key using AES in GCM mode. should be replaced.
|
|
||||||
func AESGCMEncrypt(key, plaintext []byte) ([]byte, error) {
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err := rand.Read(nonce); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
|
||||||
return append(nonce, ciphertext...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AESGCMDecrypt (from legacy package): decrypts ciphertext with the given key using AES in GCM mode. should be replaced.
|
|
||||||
func AESGCMDecrypt(key, ciphertext []byte) ([]byte, error) {
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
size := gcm.NonceSize()
|
|
||||||
if len(ciphertext)-size <= 0 {
|
|
||||||
return nil, errors.New("ciphertext is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := ciphertext[:size]
|
|
||||||
ciphertext = ciphertext[size:]
|
|
||||||
|
|
||||||
plainText, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return plainText, nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,8 +4,6 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -37,21 +35,3 @@ func TestCopyFile(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, testContent, dstContent)
|
assert.Equal(t, testContent, dstContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAESGCM(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
key := make([]byte, aes.BlockSize)
|
|
||||||
_, err := rand.Read(key)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
plaintext := []byte("this will be encrypted")
|
|
||||||
|
|
||||||
ciphertext, err := AESGCMEncrypt(key, plaintext)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
decrypted, err := AESGCMDecrypt(key, ciphertext)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, plaintext, decrypted)
|
|
||||||
}
|
|
||||||
|
|
|
@ -554,18 +554,13 @@ func SubmitInstall(ctx *context.Context) {
|
||||||
u, _ = user_model.GetUserByName(ctx, u.Name)
|
u, _ = user_model.GetUserByName(ctx, u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
days := 86400 * setting.LogInRememberDays
|
if err := ctx.SetLTACookie(u); err != nil {
|
||||||
ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
|
|
||||||
|
|
||||||
ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
|
|
||||||
setting.CookieRememberName, u.Name, days)
|
|
||||||
|
|
||||||
// Auto-login for admin
|
|
||||||
if err = ctx.Session.Set("uid", u.ID); err != nil {
|
|
||||||
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = ctx.Session.Set("uname", u.Name); err != nil {
|
|
||||||
|
// Auto-login for admin
|
||||||
|
if err = ctx.Session.Set("uid", u.ID); err != nil {
|
||||||
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -50,21 +52,47 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
uname := ctx.GetSiteCookie(setting.CookieUserName)
|
authCookie := ctx.GetSiteCookie(setting.CookieRememberName)
|
||||||
if len(uname) == 0 {
|
if len(authCookie) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
isSucceed := false
|
isSucceed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if !isSucceed {
|
if !isSucceed {
|
||||||
log.Trace("auto-login cookie cleared: %s", uname)
|
log.Trace("Auto login cookie is cleared: %s", authCookie)
|
||||||
ctx.DeleteSiteCookie(setting.CookieUserName)
|
|
||||||
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
u, err := user_model.GetUserByName(ctx, uname)
|
lookupKey, validator, found := strings.Cut(authCookie, ":")
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := auth.FindAuthToken(ctx, lookupKey)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if authToken.IsExpired() {
|
||||||
|
err = auth.DeleteAuthToken(ctx, authToken)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawValidator, err := hex.DecodeString(validator)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := user_model.GetUserByID(ctx, authToken.UID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !user_model.IsErrUserNotExist(err) {
|
if !user_model.IsErrUserNotExist(err) {
|
||||||
return false, fmt.Errorf("GetUserByName: %w", err)
|
return false, fmt.Errorf("GetUserByName: %w", err)
|
||||||
|
@ -72,17 +100,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if val, ok := ctx.GetSuperSecureCookie(
|
|
||||||
base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isSucceed = true
|
isSucceed = true
|
||||||
|
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := updateSession(ctx, nil, map[string]any{
|
||||||
// Set session IDs
|
// Set session IDs
|
||||||
"uid": u.ID,
|
"uid": authToken.UID,
|
||||||
"uname": u.Name,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -291,10 +313,10 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
|
||||||
|
|
||||||
func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
|
func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
|
||||||
if remember {
|
if remember {
|
||||||
days := 86400 * setting.LogInRememberDays
|
if err := ctx.SetLTACookie(u); err != nil {
|
||||||
ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
|
ctx.ServerError("GenerateAuthToken", err)
|
||||||
ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
|
return setting.AppSubURL + "/"
|
||||||
setting.CookieRememberName, u.Name, days)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateSession(ctx, []string{
|
if err := updateSession(ctx, []string{
|
||||||
|
@ -307,8 +329,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 + "/"
|
||||||
|
@ -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)
|
||||||
|
@ -732,8 +752,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)
|
||||||
|
|
|
@ -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.
|
// 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
|
||||||
|
|
|
@ -54,8 +54,7 @@ func Home(ctx *context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auto-login.
|
// Check auto-login.
|
||||||
uname := ctx.GetSiteCookie(setting.CookieUserName)
|
if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 {
|
||||||
if len(uname) != 0 {
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/login")
|
ctx.Redirect(setting.AppSubURL + "/user/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,15 @@ func AccountPost(ctx *context.Context) {
|
||||||
ctx.ServerError("UpdateUser", err)
|
ctx.ServerError("UpdateUser", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-generate LTA cookie.
|
||||||
|
if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 {
|
||||||
|
if err := ctx.SetLTACookie(ctx.Doer); err != nil {
|
||||||
|
ctx.ServerError("SetLTACookie", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Trace("User password updated: %s", ctx.Doer.Name)
|
log.Trace("User password updated: %s", ctx.Doer.Name)
|
||||||
ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
|
ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,7 +181,7 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont
|
||||||
|
|
||||||
// Redirect to log in page if auto-signin info is provided and has not signed in.
|
// Redirect to log in page if auto-signin info is provided and has not signed in.
|
||||||
if !options.SignOutRequired && !ctx.IsSigned &&
|
if !options.SignOutRequired && !ctx.IsSigned &&
|
||||||
len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 {
|
len(ctx.GetSiteCookie(setting.CookieRememberName)) > 0 {
|
||||||
if ctx.Req.URL.Path != "/user/events" {
|
if ctx.Req.URL.Path != "/user/events" {
|
||||||
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
|
||||||
}
|
}
|
||||||
|
|
|
@ -72,10 +72,6 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||||
}
|
}
|
||||||
err = sess.Set("uname", user.Name)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language setting of the user overwrites the one previously set
|
// Language setting of the user overwrites the one previously set
|
||||||
// If the user does not have a locale set, we save the current one.
|
// If the user does not have a locale set, we save the current one.
|
||||||
|
|
163
tests/integration/auth_token_test.go
Normal file
163
tests/integration/auth_token_test.go
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSessionForLTACookie returns a new session with only the LTA cookie being set.
|
||||||
|
func GetSessionForLTACookie(t *testing.T, ltaCookie *http.Cookie) *TestSession {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ch := http.Header{}
|
||||||
|
ch.Add("Cookie", ltaCookie.String())
|
||||||
|
cr := http.Request{Header: ch}
|
||||||
|
|
||||||
|
session := emptyTestSession(t)
|
||||||
|
baseURL, err := url.Parse(setting.AppURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
session.jar.SetCookies(baseURL, cr.Cookies())
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLTACookieValue returns the value of the LTA cookie.
|
||||||
|
func GetLTACookieValue(t *testing.T, sess *TestSession) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, rememberCookie)
|
||||||
|
|
||||||
|
cookieValue, err := url.QueryUnescape(rememberCookie.Value)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return cookieValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionCookie checks if the session cookie provides authentication.
|
||||||
|
func TestSessionCookie(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
sess := loginUser(t, "user1")
|
||||||
|
assert.NotNil(t, sess.GetCookie(setting.SessionConfig.CookieName))
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", "/user/settings")
|
||||||
|
sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTACookie checks if the LTA cookie that's returned is valid, exists in the database
|
||||||
|
// and provides authentication of no session cookie is present.
|
||||||
|
func TestLTACookie(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
sess := emptyTestSession(t)
|
||||||
|
|
||||||
|
req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, sess, "/user/login"),
|
||||||
|
"user_name": user.Name,
|
||||||
|
"password": userPassword,
|
||||||
|
"remember": "true",
|
||||||
|
})
|
||||||
|
sess.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
// Checks if the database entry exist for the user.
|
||||||
|
ltaCookieValue := GetLTACookieValue(t, sess)
|
||||||
|
lookupKey, validator, found := strings.Cut(ltaCookieValue, ":")
|
||||||
|
assert.True(t, found)
|
||||||
|
rawValidator, err := hex.DecodeString(validator)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID})
|
||||||
|
|
||||||
|
// Check if the LTA cookie it provides authentication.
|
||||||
|
// If LTA cookie provides authentication /user/login shouldn't return status 200.
|
||||||
|
session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTAPasswordChange checks that LTA doesn't provide authentication when a
|
||||||
|
// password change has happened and that the new LTA does provide authentication.
|
||||||
|
func TestLTAPasswordChange(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|
||||||
|
sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
|
||||||
|
oldRememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, oldRememberCookie)
|
||||||
|
|
||||||
|
// Make a simple password change.
|
||||||
|
req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, sess, "/user/settings/account"),
|
||||||
|
"old_password": userPassword,
|
||||||
|
"password": "password2",
|
||||||
|
"retype": "password2",
|
||||||
|
})
|
||||||
|
sess.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
rememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, rememberCookie)
|
||||||
|
|
||||||
|
// Check if the password really changed.
|
||||||
|
assert.NotEqualValues(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).Passwd, user.Passwd)
|
||||||
|
|
||||||
|
// /user/settings/account should provide with a new LTA cookie, so check for that.
|
||||||
|
// If LTA cookie provides authentication /user/login shouldn't return status 200.
|
||||||
|
session := GetSessionForLTACookie(t, rememberCookie)
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
// Check if the old LTA token is invalidated.
|
||||||
|
session = GetSessionForLTACookie(t, oldRememberCookie)
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTAExpiry tests that the LTA expiry works.
|
||||||
|
func TestLTAExpiry(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|
||||||
|
sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
|
||||||
|
|
||||||
|
ltaCookieValie := GetLTACookieValue(t, sess)
|
||||||
|
lookupKey, _, found := strings.Cut(ltaCookieValie, ":")
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
// Ensure it's not expired.
|
||||||
|
lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
assert.False(t, lta.IsExpired())
|
||||||
|
|
||||||
|
// Manually stub LTA's expiry.
|
||||||
|
_, err := db.GetEngine(db.DefaultContext).ID(lta.ID).Table("forgejo_auth_token").Cols("expiry").Update(&auth.AuthorizationToken{Expiry: timeutil.TimeStampNow()})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Ensure it's expired.
|
||||||
|
lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
assert.True(t, lta.IsExpired())
|
||||||
|
|
||||||
|
// Should return 200 OK, because LTA doesn't provide authorization anymore.
|
||||||
|
session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
|
||||||
|
req := NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// Ensure it's deleted.
|
||||||
|
unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -299,6 +300,12 @@ func loginUser(t testing.TB, userName string) *TestSession {
|
||||||
|
|
||||||
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return loginUserWithPasswordRemember(t, userName, password, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
|
||||||
|
t.Helper()
|
||||||
req := NewRequest(t, "GET", "/user/login")
|
req := NewRequest(t, "GET", "/user/login")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
@ -307,6 +314,7 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession
|
||||||
"_csrf": doc.GetCSRF(),
|
"_csrf": doc.GetCSRF(),
|
||||||
"user_name": userName,
|
"user_name": userName,
|
||||||
"password": password,
|
"password": password,
|
||||||
|
"remember": strconv.FormatBool(rememberMe),
|
||||||
})
|
})
|
||||||
resp = MakeRequest(t, req, http.StatusSeeOther)
|
resp = MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue