[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 commit e3d6622a63
)
This commit is contained in:
parent
166d769a6e
commit
fef1a6dac5
15 changed files with 328 additions and 309 deletions
|
@ -31,7 +31,6 @@ package "code.gitea.io/gitea/models/asymkey"
|
||||||
func HasDeployKey
|
func HasDeployKey
|
||||||
|
|
||||||
package "code.gitea.io/gitea/models/auth"
|
package "code.gitea.io/gitea/models/auth"
|
||||||
func DeleteAuthTokenByID
|
|
||||||
func GetSourceByName
|
func GetSourceByName
|
||||||
func GetWebAuthnCredentialByID
|
func GetWebAuthnCredentialByID
|
||||||
func WebAuthnCredentials
|
func WebAuthnCredentials
|
||||||
|
|
|
@ -1,60 +1,96 @@
|
||||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrAuthTokenNotExist = util.NewNotExistErrorf("auth token does not exist")
|
// AuthorizationToken represents a authorization token to a user.
|
||||||
|
type AuthorizationToken struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UID int64 `xorm:"INDEX"`
|
||||||
|
LookupKey string `xorm:"INDEX UNIQUE"`
|
||||||
|
HashedValidator string
|
||||||
|
Expiry timeutil.TimeStamp
|
||||||
|
}
|
||||||
|
|
||||||
type AuthToken struct { //nolint:revive
|
// TableName provides the real table name.
|
||||||
ID string `xorm:"pk"`
|
func (AuthorizationToken) TableName() string {
|
||||||
TokenHash string
|
return "forgejo_auth_token"
|
||||||
UserID int64 `xorm:"INDEX"`
|
|
||||||
ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
db.RegisterModel(new(AuthToken))
|
db.RegisterModel(new(AuthorizationToken))
|
||||||
}
|
}
|
||||||
|
|
||||||
func InsertAuthToken(ctx context.Context, t *AuthToken) error {
|
// IsExpired returns if the authorization token is expired.
|
||||||
_, err := db.GetEngine(ctx).Insert(t)
|
func (authToken *AuthorizationToken) IsExpired() bool {
|
||||||
return err
|
return authToken.Expiry.AsLocalTime().Before(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
|
// GenerateAuthToken generates a new authentication token for the given user.
|
||||||
at := &AuthToken{}
|
// It returns the lookup key and validator values that should be passed to the
|
||||||
|
// user via a long-term cookie.
|
||||||
|
func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeStamp) (lookupKey, validator string, err error) {
|
||||||
|
// Request 64 random bytes. The first 32 bytes will be used for the lookupKey
|
||||||
|
// and the other 32 bytes will be used for the validator.
|
||||||
|
rBytes, err := util.CryptoRandomBytes(64)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
hexEncoded := hex.EncodeToString(rBytes)
|
||||||
|
validator, lookupKey = hexEncoded[64:], hexEncoded[:64]
|
||||||
|
|
||||||
has, err := db.GetEngine(ctx).ID(id).Get(at)
|
_, err = db.GetEngine(ctx).Insert(&AuthorizationToken{
|
||||||
|
UID: userID,
|
||||||
|
Expiry: expiry,
|
||||||
|
LookupKey: lookupKey,
|
||||||
|
HashedValidator: HashValidator(rBytes[32:]),
|
||||||
|
})
|
||||||
|
return lookupKey, validator, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAuthToken will find a authorization token via the lookup key.
|
||||||
|
func FindAuthToken(ctx context.Context, lookupKey string) (*AuthorizationToken, error) {
|
||||||
|
var authToken AuthorizationToken
|
||||||
|
has, err := db.GetEngine(ctx).Where("lookup_key = ?", lookupKey).Get(&authToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
} else if !has {
|
||||||
|
return nil, fmt.Errorf("lookup key %q: %w", lookupKey, util.ErrNotExist)
|
||||||
}
|
}
|
||||||
if !has {
|
return &authToken, nil
|
||||||
return nil, ErrAuthTokenNotExist
|
}
|
||||||
|
|
||||||
|
// DeleteAuthToken will delete the authorization token.
|
||||||
|
func DeleteAuthToken(ctx context.Context, authToken *AuthorizationToken) error {
|
||||||
|
_, err := db.DeleteByBean(ctx, authToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAuthTokenByUser will delete all authorization tokens for the user.
|
||||||
|
func DeleteAuthTokenByUser(ctx context.Context, userID int64) error {
|
||||||
|
if userID == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return at, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
|
_, err := db.DeleteByBean(ctx, &AuthorizationToken{UID: userID})
|
||||||
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
// HashValidator will return a hexified hashed version of the validator.
|
||||||
_, err := db.GetEngine(ctx).ID(id).Delete(&AuthToken{})
|
func HashValidator(validator []byte) string {
|
||||||
return err
|
h := sha256.New()
|
||||||
}
|
h.Write(validator)
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
func DeleteExpiredAuthTokens(ctx context.Context) error {
|
|
||||||
_, err := db.GetEngine(ctx).Where(builder.Lt{"expires_unix": timeutil.TimeStampNow()}).Delete(&AuthToken{})
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,8 @@ var migrations = []*Migration{
|
||||||
NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser),
|
NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser),
|
||||||
// v1 -> v2
|
// v1 -> v2
|
||||||
NewMigration("create the forgejo_sem_ver table", forgejo_v1_20.CreateSemVerTable),
|
NewMigration("create the forgejo_sem_ver table", forgejo_v1_20.CreateSemVerTable),
|
||||||
|
// v2 -> v3
|
||||||
|
NewMigration("create the forgejo_auth_token table", forgejo_v1_20.CreateAuthorizationTokenTable),
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentDBVersion returns the current Forgejo database version.
|
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||||
|
|
26
models/forgejo_migrations/v1_20/v3.go
Normal file
26
models/forgejo_migrations/v1_20/v3.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package forgejo_v1_20 //nolint:revive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthorizationToken struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
UID int64 `xorm:"INDEX"`
|
||||||
|
LookupKey string `xorm:"INDEX UNIQUE"`
|
||||||
|
HashedValidator string
|
||||||
|
Expiry timeutil.TimeStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AuthorizationToken) TableName() string {
|
||||||
|
return "forgejo_auth_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateAuthorizationTokenTable(x *xorm.Engine) error {
|
||||||
|
return x.Sync(new(AuthorizationToken))
|
||||||
|
}
|
|
@ -380,6 +380,11 @@ func (u *User) SetPassword(passwd string) (err error) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate all authentication tokens for this user.
|
||||||
|
if err := auth.DeleteAuthTokenByUser(db.DefaultContext, u.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if u.Salt, err = GetUserSalt(); err != nil {
|
if u.Salt, err = GetUserSalt(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -40,3 +43,14 @@ func (ctx *Context) DeleteSiteCookie(name string) {
|
||||||
func (ctx *Context) GetSiteCookie(name string) string {
|
func (ctx *Context) GetSiteCookie(name string) string {
|
||||||
return middleware.GetSiteCookie(ctx.Req, name)
|
return middleware.GetSiteCookie(ctx.Req, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLTACookie will generate a LTA token and add it as an cookie.
|
||||||
|
func (ctx *Context) SetLTACookie(u *user_model.User) error {
|
||||||
|
days := 86400 * setting.LogInRememberDays
|
||||||
|
lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(days)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -27,14 +27,12 @@ import (
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/templates"
|
"code.gitea.io/gitea/modules/templates"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
|
||||||
"code.gitea.io/gitea/modules/translation"
|
"code.gitea.io/gitea/modules/translation"
|
||||||
"code.gitea.io/gitea/modules/user"
|
"code.gitea.io/gitea/modules/user"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
"code.gitea.io/gitea/routers/common"
|
"code.gitea.io/gitea/routers/common"
|
||||||
auth_service "code.gitea.io/gitea/services/auth"
|
|
||||||
"code.gitea.io/gitea/services/forms"
|
"code.gitea.io/gitea/services/forms"
|
||||||
|
|
||||||
"gitea.com/go-chi/session"
|
"gitea.com/go-chi/session"
|
||||||
|
@ -549,20 +547,13 @@ func SubmitInstall(ctx *context.Context) {
|
||||||
u, _ = user_model.GetUserByName(ctx, u.Name)
|
u, _ = user_model.GetUserByName(ctx, u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
if err := ctx.SetLTACookie(u); err != nil {
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err = ctx.Session.Set("uname", u.Name); err != nil {
|
|
||||||
|
// Auto-login for admin
|
||||||
|
if err = ctx.Session.Set("uid", u.ID); err != nil {
|
||||||
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -56,23 +58,39 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
|
authCookie := ctx.GetSiteCookie(setting.CookieRememberName)
|
||||||
log.Error("Failed to delete expired auth tokens: %v", err)
|
if len(authCookie) == 0 {
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := auth_service.CheckAuthToken(ctx, ctx.GetSiteCookie(setting.CookieRememberName))
|
lookupKey, validator, found := strings.Cut(authCookie, ":")
|
||||||
|
if !found {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := auth.FindAuthToken(ctx, lookupKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch err {
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if t == nil {
|
|
||||||
|
if authToken.IsExpired() {
|
||||||
|
err = auth.DeleteAuthToken(ctx, authToken)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rawValidator, err := hex.DecodeString(validator)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := user_model.GetUserByID(ctx, t.UserID)
|
u, err := user_model.GetUserByID(ctx, authToken.UID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !user_model.IsErrUserNotExist(err) {
|
if !user_model.IsErrUserNotExist(err) {
|
||||||
return false, fmt.Errorf("GetUserByID: %w", err)
|
return false, fmt.Errorf("GetUserByID: %w", err)
|
||||||
|
@ -82,17 +100,9 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
||||||
|
|
||||||
isSucceed = true
|
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{
|
if err := updateSession(ctx, nil, map[string]any{
|
||||||
// Set session IDs
|
// Set session IDs
|
||||||
"uid": u.ID,
|
"uid": u.ID,
|
||||||
"uname": u.Name,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -128,10 +138,6 @@ func CheckAutoLogin(ctx *context.Context) bool {
|
||||||
// Check auto-login
|
// Check auto-login
|
||||||
isSucceed, err := autoSignIn(ctx)
|
isSucceed, err := autoSignIn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, auth_service.ErrAuthTokenInvalidHash) {
|
|
||||||
ctx.Flash.Error(ctx.Tr("auth.remember_me.compromised"), true)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ctx.ServerError("autoSignIn", err)
|
ctx.ServerError("autoSignIn", err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -302,13 +308,10 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
|
||||||
|
|
||||||
func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
|
func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRedirect bool) string {
|
||||||
if remember {
|
if remember {
|
||||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
if err := ctx.SetLTACookie(u); err != nil {
|
||||||
if err != nil {
|
ctx.ServerError("GenerateAuthToken", err)
|
||||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
|
||||||
return setting.AppSubURL + "/"
|
return setting.AppSubURL + "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := updateSession(ctx, []string{
|
if err := updateSession(ctx, []string{
|
||||||
|
@ -322,7 +325,6 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
|
||||||
"linkAccount",
|
"linkAccount",
|
||||||
}, map[string]any{
|
}, map[string]any{
|
||||||
"uid": u.ID,
|
"uid": u.ID,
|
||||||
"uname": u.Name,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("RegenerateSession", err)
|
ctx.ServerError("RegenerateSession", err)
|
||||||
return setting.AppSubURL + "/"
|
return setting.AppSubURL + "/"
|
||||||
|
@ -744,7 +746,6 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
||||||
|
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := updateSession(ctx, nil, map[string]any{
|
||||||
"uid": user.ID,
|
"uid": user.ID,
|
||||||
"uname": user.Name,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
|
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, err)
|
||||||
ctx.ServerError("ActivateUserEmail", err)
|
ctx.ServerError("ActivateUserEmail", err)
|
||||||
|
|
|
@ -1119,7 +1119,6 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
||||||
if !needs2FA {
|
if !needs2FA {
|
||||||
if err := updateSession(ctx, nil, map[string]any{
|
if err := updateSession(ctx, nil, map[string]any{
|
||||||
"uid": u.ID,
|
"uid": u.ID,
|
||||||
"uname": u.Name,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
ctx.ServerError("updateSession", err)
|
ctx.ServerError("updateSession", err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -78,6 +78,15 @@ func AccountPost(ctx *context.Context) {
|
||||||
ctx.ServerError("UpdateUser", err)
|
ctx.ServerError("UpdateUser", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-generate LTA cookie.
|
||||||
|
if len(ctx.GetSiteCookie(setting.CookieRememberName)) != 0 {
|
||||||
|
if err := ctx.SetLTACookie(ctx.Doer); err != nil {
|
||||||
|
ctx.ServerError("SetLTACookie", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Trace("User password updated: %s", ctx.Doer.Name)
|
log.Trace("User password updated: %s", ctx.Doer.Name)
|
||||||
ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
|
ctx.Flash.Success(ctx.Tr("settings.change_password_success"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,10 +76,6 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
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
|
// Language setting of the user overwrites the one previously set
|
||||||
// If the user does not have a locale set, we save the current one.
|
// 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.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))
|
|
||||||
}
|
|
163
tests/integration/auth_token_test.go
Normal file
163
tests/integration/auth_token_test.go
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/auth"
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"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/timeutil"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSessionForLTACookie returns a new session with only the LTA cookie being set.
|
||||||
|
func GetSessionForLTACookie(t *testing.T, ltaCookie *http.Cookie) *TestSession {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ch := http.Header{}
|
||||||
|
ch.Add("Cookie", ltaCookie.String())
|
||||||
|
cr := http.Request{Header: ch}
|
||||||
|
|
||||||
|
session := emptyTestSession(t)
|
||||||
|
baseURL, err := url.Parse(setting.AppURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
session.jar.SetCookies(baseURL, cr.Cookies())
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLTACookieValue returns the value of the LTA cookie.
|
||||||
|
func GetLTACookieValue(t *testing.T, sess *TestSession) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, rememberCookie)
|
||||||
|
|
||||||
|
cookieValue, err := url.QueryUnescape(rememberCookie.Value)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return cookieValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionCookie checks if the session cookie provides authentication.
|
||||||
|
func TestSessionCookie(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
sess := loginUser(t, "user1")
|
||||||
|
assert.NotNil(t, sess.GetCookie(setting.SessionConfig.CookieName))
|
||||||
|
|
||||||
|
req := NewRequest(t, "GET", "/user/settings")
|
||||||
|
sess.MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTACookie checks if the LTA cookie that's returned is valid, exists in the database
|
||||||
|
// and provides authentication of no session cookie is present.
|
||||||
|
func TestLTACookie(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
sess := emptyTestSession(t)
|
||||||
|
|
||||||
|
req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, sess, "/user/login"),
|
||||||
|
"user_name": user.Name,
|
||||||
|
"password": userPassword,
|
||||||
|
"remember": "true",
|
||||||
|
})
|
||||||
|
sess.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
// Checks if the database entry exist for the user.
|
||||||
|
ltaCookieValue := GetLTACookieValue(t, sess)
|
||||||
|
lookupKey, validator, found := strings.Cut(ltaCookieValue, ":")
|
||||||
|
assert.True(t, found)
|
||||||
|
rawValidator, err := hex.DecodeString(validator)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID})
|
||||||
|
|
||||||
|
// Check if the LTA cookie it provides authentication.
|
||||||
|
// If LTA cookie provides authentication /user/login shouldn't return status 200.
|
||||||
|
session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTAPasswordChange checks that LTA doesn't provide authentication when a
|
||||||
|
// password change has happened and that the new LTA does provide authentication.
|
||||||
|
func TestLTAPasswordChange(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|
||||||
|
sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
|
||||||
|
oldRememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, oldRememberCookie)
|
||||||
|
|
||||||
|
// Make a simple password change.
|
||||||
|
req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{
|
||||||
|
"_csrf": GetCSRF(t, sess, "/user/settings/account"),
|
||||||
|
"old_password": userPassword,
|
||||||
|
"password": "password2",
|
||||||
|
"retype": "password2",
|
||||||
|
})
|
||||||
|
sess.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
rememberCookie := sess.GetCookie(setting.CookieRememberName)
|
||||||
|
assert.NotNil(t, rememberCookie)
|
||||||
|
|
||||||
|
// Check if the password really changed.
|
||||||
|
assert.NotEqualValues(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).Passwd, user.Passwd)
|
||||||
|
|
||||||
|
// /user/settings/account should provide with a new LTA cookie, so check for that.
|
||||||
|
// If LTA cookie provides authentication /user/login shouldn't return status 200.
|
||||||
|
session := GetSessionForLTACookie(t, rememberCookie)
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
// Check if the old LTA token is invalidated.
|
||||||
|
session = GetSessionForLTACookie(t, oldRememberCookie)
|
||||||
|
req = NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLTAExpiry tests that the LTA expiry works.
|
||||||
|
func TestLTAExpiry(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|
||||||
|
sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
|
||||||
|
|
||||||
|
ltaCookieValie := GetLTACookieValue(t, sess)
|
||||||
|
lookupKey, _, found := strings.Cut(ltaCookieValie, ":")
|
||||||
|
assert.True(t, found)
|
||||||
|
|
||||||
|
// Ensure it's not expired.
|
||||||
|
lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
assert.False(t, lta.IsExpired())
|
||||||
|
|
||||||
|
// Manually stub LTA's expiry.
|
||||||
|
_, err := db.GetEngine(db.DefaultContext).ID(lta.ID).Table("forgejo_auth_token").Cols("expiry").Update(&auth.AuthorizationToken{Expiry: timeutil.TimeStampNow()})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Ensure it's expired.
|
||||||
|
lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
assert.True(t, lta.IsExpired())
|
||||||
|
|
||||||
|
// Should return 200 OK, because LTA doesn't provide authorization anymore.
|
||||||
|
session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
|
||||||
|
req := NewRequest(t, "GET", "/user/login")
|
||||||
|
session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
// Ensure it's deleted.
|
||||||
|
unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -299,6 +300,12 @@ func loginUser(t testing.TB, userName string) *TestSession {
|
||||||
|
|
||||||
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return loginUserWithPasswordRemember(t, userName, password, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
|
||||||
|
t.Helper()
|
||||||
req := NewRequest(t, "GET", "/user/login")
|
req := NewRequest(t, "GET", "/user/login")
|
||||||
resp := MakeRequest(t, req, http.StatusOK)
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
@ -307,6 +314,7 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession
|
||||||
"_csrf": doc.GetCSRF(),
|
"_csrf": doc.GetCSRF(),
|
||||||
"user_name": userName,
|
"user_name": userName,
|
||||||
"password": password,
|
"password": password,
|
||||||
|
"remember": strconv.FormatBool(rememberMe),
|
||||||
})
|
})
|
||||||
resp = MakeRequest(t, req, http.StatusSeeOther)
|
resp = MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue