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`.
This commit is contained in:
parent
ee6a390675
commit
c6c829fe3f
23 changed files with 419 additions and 104 deletions
|
@ -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
|
||||
|
|
|
@ -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 头部名称,用于提供邮箱信息。
|
||||
|
|
60
models/auth/auth_token.go
Normal file
60
models/auth/auth_token.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
21
models/migrations/v1_22/v281.go
Normal file
21
models/migrations/v1_22/v281.go
Normal file
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -43,40 +43,51 @@ 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 {
|
||||
switch err {
|
||||
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if t == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(ctx, t.UserID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return false, fmt.Errorf("GetUserByName: %w", err)
|
||||
return false, fmt.Errorf("GetUserByID: %w", err)
|
||||
}
|
||||
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
|
||||
|
||||
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
isSucceed = true
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
// Set session IDs
|
||||
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
123
services/auth/auth_token.go
Normal file
123
services/auth/auth_token.go
Normal file
|
@ -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
|
||||
}
|
107
services/auth/auth_token_test.go
Normal file
107
services/auth/auth_token_test.go
Normal file
|
@ -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))
|
||||
}
|
14
services/auth/main_test.go
Normal file
14
services/auth/main_test.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue