aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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)
+}