forked from kevadesu/forgejo
[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 commite3d6622a63
) (cherry picked from commitfef1a6dac5
) (cherry picked from commitb0c5165145
) (cherry picked from commit7ad51b9f8d
) (cherry picked from commit64f053f383
) (cherry picked from commitf5e78e4c20
) Conflicts: services/auth/auth_token_test.go https://codeberg.org/forgejo/forgejo/pulls/2069
This commit is contained in:
parent
40c1130c41
commit
f69fc23d4b
15 changed files with 328 additions and 309 deletions
|
@ -76,10 +76,6 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
|
|||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
err = sess.Set("uname", user.Name)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
|
||||
// Language setting of the user overwrites the one previously set
|
||||
// If the user does not have a locale set, we save the current one.
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue