mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-24 01:22:21 +00:00
Merge pull request '[SECURITY] Rework long-term authentication' (#1802) from Gusted/forgejo:forgejo-rework-lta into forgejo-development
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/1802
This commit is contained in:
commit
dd879dac00
|
@ -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{
|
||||||
|
@ -321,8 +324,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
|
||||||
"twofaRemember",
|
"twofaRemember",
|
||||||
"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 + "/"
|
||||||
|
@ -743,8 +745,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
||||||
log.Trace("User activated: %s", user.Name)
|
log.Trace("User activated: %s", user.Name)
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
@ -1118,8 +1118,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
||||||
// we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
|
// we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
|
||||||
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