mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-27 12:53:57 +01: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
15 changed files with 328 additions and 309 deletions
|
@ -31,7 +31,6 @@ package "code.gitea.io/gitea/models/asymkey"
|
|||
func HasDeployKey
|
||||
|
||||
package "code.gitea.io/gitea/models/auth"
|
||||
func DeleteAuthTokenByID
|
||||
func GetSourceByName
|
||||
func GetWebAuthnCredentialByID
|
||||
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
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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")
|
||||
// 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
|
||||
ID string `xorm:"pk"`
|
||||
TokenHash string
|
||||
UserID int64 `xorm:"INDEX"`
|
||||
ExpiresUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||
// TableName provides the real table name.
|
||||
func (AuthorizationToken) TableName() string {
|
||||
return "forgejo_auth_token"
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(AuthToken))
|
||||
db.RegisterModel(new(AuthorizationToken))
|
||||
}
|
||||
|
||||
func InsertAuthToken(ctx context.Context, t *AuthToken) error {
|
||||
_, err := db.GetEngine(ctx).Insert(t)
|
||||
return err
|
||||
// IsExpired returns if the authorization token is expired.
|
||||
func (authToken *AuthorizationToken) IsExpired() bool {
|
||||
return authToken.Expiry.AsLocalTime().Before(time.Now())
|
||||
}
|
||||
|
||||
func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
|
||||
at := &AuthToken{}
|
||||
// GenerateAuthToken generates a new authentication token for the given user.
|
||||
// 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 {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, fmt.Errorf("lookup key %q: %w", lookupKey, util.ErrNotExist)
|
||||
}
|
||||
if !has {
|
||||
return nil, ErrAuthTokenNotExist
|
||||
return &authToken, nil
|
||||
}
|
||||
|
||||
// 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.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
|
||||
_, err := db.DeleteByBean(ctx, &AuthorizationToken{UID: userID})
|
||||
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
|
||||
// HashValidator will return a hexified hashed version of the validator.
|
||||
func HashValidator(validator []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write(validator)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
|
|
@ -41,6 +41,8 @@ var migrations = []*Migration{
|
|||
NewMigration("Add Forgejo Blocked Users table", forgejo_v1_20.AddForgejoBlockedUser),
|
||||
// v1 -> v2
|
||||
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.
|
||||
|
|
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -7,7 +7,10 @@ import (
|
|||
"net/http"
|
||||
"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/timeutil"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
)
|
||||
|
||||
|
@ -40,3 +43,14 @@ func (ctx *Context) DeleteSiteCookie(name string) {
|
|||
func (ctx *Context) GetSiteCookie(name string) string {
|
||||
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/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"
|
||||
|
@ -549,20 +547,13 @@ func SubmitInstall(ctx *context.Context) {
|
|||
u, _ = user_model.GetUserByName(ctx, u.Name)
|
||||
}
|
||||
|
||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||
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 {
|
||||
if err := ctx.SetLTACookie(u); err != nil {
|
||||
ctx.RenderWithErr(ctx.Tr("install.save_config_failed", err), tplInstall, &form)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -56,23 +58,39 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||
}
|
||||
}()
|
||||
|
||||
if err := auth.DeleteExpiredAuthTokens(ctx); err != nil {
|
||||
log.Error("Failed to delete expired auth tokens: %v", err)
|
||||
authCookie := ctx.GetSiteCookie(setting.CookieRememberName)
|
||||
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 {
|
||||
switch err {
|
||||
case auth_service.ErrAuthTokenInvalidFormat, auth_service.ErrAuthTokenExpired:
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(ctx, t.UserID)
|
||||
u, err := user_model.GetUserByID(ctx, authToken.UID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return false, fmt.Errorf("GetUserByID: %w", err)
|
||||
|
@ -82,17 +100,9 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||
|
||||
isSucceed = true
|
||||
|
||||
nt, token, err := auth_service.RegenerateAuthToken(ctx, t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
// Set session IDs
|
||||
"uid": u.ID,
|
||||
"uname": u.Name,
|
||||
"uid": u.ID,
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||
}
|
||||
|
@ -128,10 +138,6 @@ func CheckAutoLogin(ctx *context.Context) bool {
|
|||
// Check auto-login
|
||||
isSucceed, err := autoSignIn(ctx)
|
||||
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)
|
||||
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 {
|
||||
if remember {
|
||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
||||
if err := ctx.SetLTACookie(u); err != nil {
|
||||
ctx.ServerError("GenerateAuthToken", err)
|
||||
return setting.AppSubURL + "/"
|
||||
}
|
||||
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, []string{
|
||||
|
@ -321,8 +324,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
|
|||
"twofaRemember",
|
||||
"linkAccount",
|
||||
}, map[string]any{
|
||||
"uid": u.ID,
|
||||
"uname": u.Name,
|
||||
"uid": u.ID,
|
||||
}); err != nil {
|
||||
ctx.ServerError("RegenerateSession", err)
|
||||
return setting.AppSubURL + "/"
|
||||
|
@ -743,8 +745,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) {
|
|||
log.Trace("User activated: %s", user.Name)
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
"uid": user.ID,
|
||||
"uname": user.Name,
|
||||
"uid": user.ID,
|
||||
}); err != nil {
|
||||
log.Error("Unable to regenerate session for user: %-v with email: %s: %v", user, user.Email, 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.
|
||||
if !needs2FA {
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
"uid": u.ID,
|
||||
"uname": u.Name,
|
||||
"uid": u.ID,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
|
|
|
@ -78,6 +78,15 @@ func AccountPost(ctx *context.Context) {
|
|||
ctx.ServerError("UpdateUser", err)
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
@ -299,6 +300,12 @@ func loginUser(t testing.TB, userName string) *TestSession {
|
|||
|
||||
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
||||
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")
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
|
@ -307,6 +314,7 @@ func loginUserWithPassword(t testing.TB, userName, password string) *TestSession
|
|||
"_csrf": doc.GetCSRF(),
|
||||
"user_name": userName,
|
||||
"password": password,
|
||||
"remember": strconv.FormatBool(rememberMe),
|
||||
})
|
||||
resp = MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
|
|
Loading…
Reference in a new issue