summaryrefslogtreecommitdiffstats
path: root/services
diff options
context:
space:
mode:
Diffstat (limited to 'services')
-rw-r--r--services/auth/auth_token.go123
-rw-r--r--services/auth/auth_token_test.go107
-rw-r--r--services/auth/main_test.go14
3 files changed, 244 insertions, 0 deletions
diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go
new file mode 100644
index 0000000000..6b59238c98
--- /dev/null
+++ b/services/auth/auth_token.go
@@ -0,0 +1,123 @@
+// 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
+}
diff --git a/services/auth/auth_token_test.go b/services/auth/auth_token_test.go
new file mode 100644
index 0000000000..654275df17
--- /dev/null
+++ b/services/auth/auth_token_test.go
@@ -0,0 +1,107 @@
+// 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))
+}
diff --git a/services/auth/main_test.go b/services/auth/main_test.go
new file mode 100644
index 0000000000..b81c39a1f2
--- /dev/null
+++ b/services/auth/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}