summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKN4CK3R <admin@oldschoolhack.me>2023-10-14 02:56:41 +0200
committerGitHub <noreply@github.com>2023-10-14 00:56:41 +0000
commitc6c829fe3fde5d55b2115eb006b427288e381158 (patch)
treed2429a0bfd72836375262137e0709995889c6924
parentee6a390675638b9c0f587d861e7063b9e633540a (diff)
downloadgitea-c6c829fe3fde5d55b2115eb006b427288e381158.tar.gz
gitea-c6c829fe3fde5d55b2115eb006b427288e381158.zip
Enhanced auth token / remember me (#27606)
Closes #27455 > The mechanism responsible for long-term authentication (the 'remember me' cookie) uses a weak construction technique. It will hash the user's hashed password and the rands value; it will then call the secure cookie code, which will encrypt the user's name with the computed hash. If one were able to dump the database, they could extract those two values to rebuild that cookie and impersonate a user. That vulnerability exists from the date the dump was obtained until a user changed their password. > > To fix this security issue, the cookie could be created and verified using a different technique such as the one explained at https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies. The PR removes the now obsolete setting `COOKIE_USERNAME`.
-rw-r--r--docs/content/administration/config-cheat-sheet.en-us.md1
-rw-r--r--docs/content/administration/config-cheat-sheet.zh-cn.md1
-rw-r--r--models/auth/access_token.go (renamed from models/auth/token.go)0
-rw-r--r--models/auth/access_token_scope.go (renamed from models/auth/token_scope.go)0
-rw-r--r--models/auth/access_token_scope_test.go (renamed from models/auth/token_scope_test.go)0
-rw-r--r--models/auth/access_token_test.go (renamed from models/auth/token_test.go)0
-rw-r--r--models/auth/auth_token.go60
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v1_22/v281.go21
-rw-r--r--modules/context/context_cookie.go44
-rw-r--r--modules/setting/security.go2
-rw-r--r--options/locale/locale_en-US.ini1
-rw-r--r--routers/install/install.go12
-rw-r--r--routers/web/auth/2fa.go6
-rw-r--r--routers/web/auth/auth.go64
-rw-r--r--routers/web/auth/openid.go19
-rw-r--r--routers/web/auth/webauthn.go3
-rw-r--r--routers/web/home.go3
-rw-r--r--routers/web/web.go2
-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
-rw-r--r--tests/integration/signin_test.go36
23 files changed, 418 insertions, 103 deletions
diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md
index 3fd78d483e..16f8caf22f 100644
--- a/docs/content/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/administration/config-cheat-sheet.en-us.md
@@ -517,7 +517,6 @@ And the following unique queues:
- `SECRET_KEY`: **\<random at every install\>**: Global secret key. This key is VERY IMPORTANT, if you lost it, the data encrypted by it (like 2FA secret) can't be decrypted anymore.
- `SECRET_KEY_URI`: **_empty_**: Instead of defining SECRET_KEY, this option can be used to use the key stored in a file (example value: `file:/etc/gitea/secret_key`). It shouldn't be lost like SECRET_KEY.
- `LOGIN_REMEMBER_DAYS`: **7**: Cookie lifetime, in days.
-- `COOKIE_USERNAME`: **gitea\_awesome**: Name of the cookie used to store the current username.
- `COOKIE_REMEMBER_NAME`: **gitea\_incredible**: Name of cookie used to store authentication
information.
- `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**: Header name for reverse proxy
diff --git a/docs/content/administration/config-cheat-sheet.zh-cn.md b/docs/content/administration/config-cheat-sheet.zh-cn.md
index 27f46cd8be..d541013159 100644
--- a/docs/content/administration/config-cheat-sheet.zh-cn.md
+++ b/docs/content/administration/config-cheat-sheet.zh-cn.md
@@ -506,7 +506,6 @@ Gitea 创建以下非唯一队列:
- `SECRET_KEY`: **\<每次安装时随机生成\>**:全局服务器安全密钥。这个密钥非常重要,如果丢失将无法解密加密的数据(例如 2FA)。
- `SECRET_KEY_URI`: **_empty_**:与定义 `SECRET_KEY` 不同,此选项可用于使用存储在文件中的密钥(示例值:`file:/etc/gitea/secret_key`)。它不应该像 `SECRET_KEY` 一样容易丢失。
- `LOGIN_REMEMBER_DAYS`: **7**:Cookie 保存时间,单位为天。
-- `COOKIE_USERNAME`: **gitea\_awesome**:保存用户名的 Cookie 名称。
- `COOKIE_REMEMBER_NAME`: **gitea\_incredible**:保存自动登录信息的 Cookie 名称。
- `REVERSE_PROXY_AUTHENTICATION_USER`: **X-WEBAUTH-USER**:反向代理认证的 HTTP 头部名称,用于提供用户信息。
- `REVERSE_PROXY_AUTHENTICATION_EMAIL`: **X-WEBAUTH-EMAIL**:反向代理认证的 HTTP 头部名称,用于提供邮箱信息。
diff --git a/models/auth/token.go b/models/auth/access_token.go
index 8abcc622bc..8abcc622bc 100644
--- a/models/auth/token.go
+++ b/models/auth/access_token.go
diff --git a/models/auth/token_scope.go b/models/auth/access_token_scope.go
index fe57276700..fe57276700 100644
--- a/models/auth/token_scope.go
+++ b/models/auth/access_token_scope.go
diff --git a/models/auth/token_scope_test.go b/models/auth/access_token_scope_test.go
index a6097e45d7..a6097e45d7 100644
--- a/models/auth/token_scope_test.go
+++ b/models/auth/access_token_scope_test.go
diff --git a/models/auth/token_test.go b/models/auth/access_token_test.go
index 72c937ffd6..72c937ffd6 100644
--- a/models/auth/token_test.go
+++ b/models/auth/access_token_test.go
diff --git a/models/auth/auth_token.go b/models/auth/auth_token.go
new file mode 100644
index 0000000000..65f1b169eb
--- /dev/null
+++ b/models/auth/auth_token.go
@@ -0,0 +1,60 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+
+ "xorm.io/builder"
+)
+
+var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist")
+
+type AuthToken struct { //nolint:revive
+ ID string `xorm:"pk"`
+ TokenHash string
+ UserID int64 `xorm:"INDEX"`
+ ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
+}
+
+func init() {
+ db.RegisterModel(new(AuthToken))
+}
+
+func InsertAuthToken(ctx context.Context, t *AuthToken) error {
+ _, err := db.GetEngine(ctx).Insert(t)
+ return err
+}
+
+func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
+ at := &AuthToken{}
+
+ has, err := db.GetEngine(ctx).ID(id).Get(at)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, ErrAuthTokenNotExist
+ }
+ return at, nil
+}
+
+func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
+ _, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
+ return err
+}
+
+func DeleteAuthTokenByID(ctx context.Context, id string) error {
+ _, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{})
+ return err
+}
+
+func DeleteExpiredAuthTokens(ctx context.Context) error {
+ _, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{})
+ return err
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index a8037fa67e..4a06cdc73a 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -546,6 +546,8 @@ var migrations = []Migration{
// v280 -> v281
NewMigration("Rename user themes", v1_22.RenameUserThemes),
+ // v281 -> v282
+ NewMigration("Add auth_token table", v1_22.CreateAuthTokenTable),
}
// GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_22/v281.go b/models/migrations/v1_22/v281.go
new file mode 100644
index 0000000000..fc1866aa83
--- /dev/null
+++ b/models/migrations/v1_22/v281.go
@@ -0,0 +1,21 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_22 //nolint
+
+import (
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+func CreateAuthTokenTable(x *xorm.Engine) error {
+ type AuthToken struct {
+ ID string `xorm:"pk"`
+ TokenHash string
+ UserID int64 `xorm:"INDEX"`
+ ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
+ }
+
+ return x.Sync(new(AuthToken))
+}
diff --git a/modules/context/context_cookie.go b/modules/context/context_cookie.go
index 9ce67a5298..b6f8dadb56 100644
--- a/modules/context/context_cookie.go
+++ b/modules/context/context_cookie.go
@@ -4,16 +4,11 @@
package context
import (
- "encoding/hex"
"net/http"
"strings"
"code.gitea.io/gitea/modules/setting"
- "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web/middleware"
-
- "github.com/minio/sha256-simd"
- "golang.org/x/crypto/pbkdf2"
)
const CookieNameFlash = "gitea_flash"
@@ -45,42 +40,3 @@ func (ctx *Context) DeleteSiteCookie(name string) {
func (ctx *Context) GetSiteCookie(name string) string {
return middleware.GetSiteCookie(ctx.Req, name)
}
-
-// GetSuperSecureCookie returns given cookie value from request header with secret string.
-func (ctx *Context) GetSuperSecureCookie(secret, name string) (string, bool) {
- val := ctx.GetSiteCookie(name)
- return ctx.CookieDecrypt(secret, val)
-}
-
-// 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 {
- return "", false
- }
-
- key := pbkdf2.Key([]byte(secret), []byte(secret), 1000, 16, sha256.New)
- 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)
-}
diff --git a/modules/setting/security.go b/modules/setting/security.go
index 90f614d4cd..92caa05fad 100644
--- a/modules/setting/security.go
+++ b/modules/setting/security.go
@@ -19,7 +19,6 @@ var (
SecretKey string
InternalToken string // internal access token
LogInRememberDays int
- CookieUserName string
CookieRememberName string
ReverseProxyAuthUser string
ReverseProxyAuthEmail string
@@ -104,7 +103,6 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("security")
InstallLock = HasInstallLock(rootCfg)
LogInRememberDays = sec.Key("LOGIN_REMEMBER_DAYS").MustInt(7)
- CookieUserName = sec.Key("COOKIE_USERNAME").MustString("gitea_awesome")
SecretKey = loadSecret(sec, "SECRET_KEY_URI", "SECRET_KEY")
if SecretKey == "" {
// FIXME: https://github.com/go-gitea/gitea/issues/16832
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b7b99bd7a5..8677461322 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -363,6 +363,7 @@ disable_register_prompt = Registration is disabled. Please contact your site adm
disable_register_mail = Email confirmation for registration is disabled.
manual_activation_only = Contact your site administrator to complete activation.
remember_me = Remember This Device
+remember_me.compromised = The login token is not valid anymore which may indicate a compromised account. Please check your account for unusual activities.
forgot_password_title= Forgot Password
forgot_password = Forgot password?
sign_up_now = Need an account? Register now.
diff --git a/routers/install/install.go b/routers/install/install.go
index 185e4bf6bf..5c0290d2cc 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -27,12 +27,14 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
+ "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/user"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/routers/common"
+ auth_service "code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/forms"
"gitea.com/go-chi/session"
@@ -547,11 +549,13 @@ func SubmitInstall(ctx *context.Context) {
u, _ = user_model.GetUserByName(ctx, u.Name)
}
- days := 86400 * setting.LogInRememberDays
- ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
+ nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("CreateAuthTokenForUserID", err)
+ return
+ }
- ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
- setting.CookieRememberName, u.Name, days)
+ 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 {
diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go
index bc3cb4907c..dc0062ebaa 100644
--- a/routers/web/auth/2fa.go
+++ b/routers/web/auth/2fa.go
@@ -26,8 +26,7 @@ var (
func TwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
- // Check auto-login.
- if checkAutoLogin(ctx) {
+ if CheckAutoLogin(ctx) {
return
}
@@ -99,8 +98,7 @@ func TwoFactorPost(ctx *context.Context) {
func TwoFactorScratch(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa_scratch")
- // Check auto-login.
- if checkAutoLogin(ctx) {
+ if CheckAutoLogin(ctx) {
return
}
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index df835a2fa1..1238e52755 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -43,41 +43,52 @@ const (
TplActivate base.TplName = "user/auth/activate"
)
-// AutoSignIn reads cookie and try to auto-login.
-func AutoSignIn(ctx *context.Context) (bool, error) {
+// autoSignIn reads cookie and try to auto-login.
+func autoSignIn(ctx *context.Context) (bool, error) {
if !db.HasEngine {
return false, nil
}
- uname := ctx.GetSiteCookie(setting.CookieUserName)
- if len(uname) == 0 {
- return false, nil
- }
-
isSucceed := false
defer func() {
if !isSucceed {
- log.Trace("auto-login cookie cleared: %s", uname)
- ctx.DeleteSiteCookie(setting.CookieUserName)
ctx.DeleteSiteCookie(setting.CookieRememberName)
}
}()
- u, err := user_model.GetUserByName(ctx, uname)
+ if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
+ log.Error("Failed to delete expired auth tokens: %v", err)
+ }
+
+ t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
if err != nil {
- if !user_model.IsErrUserNotExist(err) {
- return false, fmt.Errorf("GetUserByName: %w", err)
+ switch err {
+ case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
+ return false, nil
}
+ return false, err
+ }
+ if t == nil {
return false, nil
}
- if val, ok := ctx.GetSuperSecureCookie(
- base.EncodeMD5(u.Rands+u.Passwd), setting.CookieRememberName); !ok || val != u.Name {
+ u, err := user_model.GetUserByID(ctx, t.UserID)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ return false, fmt.Errorf("GetUserByID: %w", err)
+ }
return false, nil
}
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{
// Set session IDs
"uid": u.ID,
@@ -113,11 +124,15 @@ func resetLocale(ctx *context.Context, u *user_model.User) error {
return nil
}
-func checkAutoLogin(ctx *context.Context) bool {
+func CheckAutoLogin(ctx *context.Context) bool {
// Check auto-login
- isSucceed, err := AutoSignIn(ctx)
+ isSucceed, err := autoSignIn(ctx)
if err != nil {
- ctx.ServerError("AutoSignIn", err)
+ if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
+ ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
+ return false
+ }
+ ctx.ServerError("autoSignIn", err)
return true
}
@@ -141,8 +156,7 @@ func checkAutoLogin(ctx *context.Context) bool {
func SignIn(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in")
- // Check auto-login
- if checkAutoLogin(ctx) {
+ if CheckAutoLogin(ctx) {
return
}
@@ -290,10 +304,13 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
if remember {
- days := 86400 * setting.LogInRememberDays
- ctx.SetSiteCookie(setting.CookieUserName, u.Name, days)
- ctx.SetSuperSecureCookie(base.EncodeMD5(u.Rands+u.Passwd),
- setting.CookieRememberName, u.Name, days)
+ nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("CreateAuthTokenForUserID", err)
+ return setting.AppSubURL + "/"
+ }
+
+ ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
}
if err := updateSession(ctx, []string{
@@ -368,7 +385,6 @@ func getUserName(gothUser *goth.User) string {
func HandleSignOut(ctx *context.Context) {
_ = ctx.Session.Flush()
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
- ctx.DeleteSiteCookie(setting.CookieUserName)
ctx.DeleteSiteCookie(setting.CookieRememberName)
ctx.Csrf.DeleteCookie(ctx)
middleware.DeleteRedirectToCookie(ctx.Resp)
diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go
index aa07129632..29ef772b1c 100644
--- a/routers/web/auth/openid.go
+++ b/routers/web/auth/openid.go
@@ -16,7 +16,6 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
- "code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/forms"
)
@@ -36,23 +35,7 @@ func SignInOpenID(ctx *context.Context) {
return
}
- // Check auto-login.
- isSucceed, err := AutoSignIn(ctx)
- if err != nil {
- ctx.ServerError("AutoSignIn", err)
- return
- }
-
- redirectTo := ctx.FormString("redirect_to")
- if len(redirectTo) > 0 {
- middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
- } else {
- redirectTo = ctx.GetSiteCookie("redirect_to")
- }
-
- if isSucceed {
- middleware.DeleteRedirectToCookie(ctx.Resp)
- ctx.RedirectToFirst(redirectTo)
+ if CheckAutoLogin(ctx) {
return
}
diff --git a/routers/web/auth/webauthn.go b/routers/web/auth/webauthn.go
index 9b516ce396..95c8d262a5 100644
--- a/routers/web/auth/webauthn.go
+++ b/routers/web/auth/webauthn.go
@@ -26,8 +26,7 @@ var tplWebAuthn base.TplName = "user/auth/webauthn"
func WebAuthn(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("twofa")
- // Check auto-login.
- if checkAutoLogin(ctx) {
+ if CheckAutoLogin(ctx) {
return
}
diff --git a/routers/web/home.go b/routers/web/home.go
index ab3fbde2c9..2321b00efe 100644
--- a/routers/web/home.go
+++ b/routers/web/home.go
@@ -54,8 +54,7 @@ func Home(ctx *context.Context) {
}
// Check auto-login.
- uname := ctx.GetSiteCookie(setting.CookieUserName)
- if len(uname) != 0 {
+ if ctx.GetSiteCookie(setting.CookieRememberName) != "" {
ctx.Redirect(setting.AppSubURL + "/user/login")
return
}
diff --git a/routers/web/web.go b/routers/web/web.go
index d2179a0008..6449f7716c 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -187,7 +187,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.
if !options.SignOutRequired && !ctx.IsSigned &&
- len(ctx.GetSiteCookie(setting.CookieUserName)) > 0 {
+ ctx.GetSiteCookie(setting.CookieRememberName) != "" {
if ctx.Req.URL.Path != "/user/events" {
middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI())
}
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)
+}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 9ae45d3242..2584b88f65 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -5,11 +5,13 @@ package integration
import (
"net/http"
+ "net/url"
"strings"
"testing"
"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/translation"
"code.gitea.io/gitea/tests"
@@ -57,3 +59,37 @@ func TestSignin(t *testing.T) {
testLoginFailed(t, s.username, s.password, s.message)
}
}
+
+func TestSigninWithRememberMe(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ baseURL, _ := url.Parse(setting.AppURL)
+
+ session := emptyTestSession(t)
+ req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/login"),
+ "user_name": user.Name,
+ "password": userPassword,
+ "remember": "on",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ c := session.GetCookie(setting.CookieRememberName)
+ assert.NotNil(t, c)
+
+ session = emptyTestSession(t)
+
+ // Without session the settings page should not be reachable
+ req = NewRequest(t, "GET", "/user/settings")
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", "/user/login")
+ // Set the remember me cookie for the login GET request
+ session.jar.SetCookies(baseURL, []*http.Cookie{c})
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // With session the settings page should be reachable
+ req = NewRequest(t, "GET", "/user/settings")
+ session.MakeRequest(t, req, http.StatusOK)
+}