mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-13 04:58:46 +00:00
feat: persist OAuth2/OIDC sign-in via IdP re-validation (#12321)
The session cookie has no Max-Age, so it is lost when the browser closes. The password flow compensates via a "Remember me" checkbox issuing an LTA cookie; OAuth2/OIDC sign-in had no such UI. Issuing a regular LTA cookie after an OAuth callback would skip the IdP for LOGIN_REMEMBER_DAYS. Instead, this introduces a separate LongTermAuthorizationSSO purpose: the cookie is opt-in via the existing "Remember me" checkbox, and when presented without a session, autoSignIn redirects through the IdP with OIDC prompt=none for silent re-auth. On login_required / interaction_required / consent_required / account_selection_required we transparently fall back to interactive sign-in. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12321 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
parent
e41d7b45f9
commit
0211c1eace
11 changed files with 392 additions and 56 deletions
|
|
@ -11,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/util"
|
||||
)
|
||||
|
|
@ -21,6 +22,9 @@ var (
|
|||
// Used to store long term authorization tokens.
|
||||
LongTermAuthorization AuthorizationPurpose = "long_term_authorization"
|
||||
|
||||
// Long-term token for OAuth2/OIDC sign-ins; bounces through the IdP rather than auto-logging in.
|
||||
LongTermAuthorizationSSO AuthorizationPurpose = "long_term_authorization_sso"
|
||||
|
||||
// Used to activate a user account.
|
||||
UserActivation AuthorizationPurpose = "user_activation"
|
||||
|
||||
|
|
@ -40,6 +44,7 @@ type AuthorizationToken struct {
|
|||
LookupKey string `xorm:"INDEX UNIQUE"`
|
||||
HashedValidator string
|
||||
Purpose AuthorizationPurpose `xorm:"NOT NULL DEFAULT 'long_term_authorization'"`
|
||||
LoginSourceID optional.Option[int64] `xorm:"INDEX REFERENCES(login_source, id)"`
|
||||
Expiry timeutil.TimeStamp
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +65,7 @@ func (authToken *AuthorizationToken) IsExpired() bool {
|
|||
// 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, purpose AuthorizationPurpose) (lookupKey, validator string, err error) {
|
||||
func GenerateAuthToken(ctx context.Context, userID int64, loginSourceID optional.Option[int64], expiry timeutil.TimeStamp, purpose AuthorizationPurpose) (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 := util.CryptoRandomBytes(64)
|
||||
|
|
@ -73,6 +78,7 @@ func GenerateAuthToken(ctx context.Context, userID int64, expiry timeutil.TimeSt
|
|||
LookupKey: lookupKey,
|
||||
HashedValidator: HashValidator(rBytes[32:]),
|
||||
Purpose: purpose,
|
||||
LoginSourceID: loginSourceID,
|
||||
})
|
||||
return lookupKey, validator, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package forgejo_migrations
|
||||
|
||||
import (
|
||||
"forgejo.org/modules/optional"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(&Migration{
|
||||
Description: "add login_source_id column to forgejo_auth_token",
|
||||
Upgrade: addLoginSourceIDToForgejoAuthToken,
|
||||
})
|
||||
}
|
||||
|
||||
func addLoginSourceIDToForgejoAuthToken(x *xorm.Engine) error {
|
||||
type ForgejoAuthToken struct {
|
||||
LoginSourceID optional.Option[int64] `xorm:"INDEX REFERENCES(login_source, id)"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(ForgejoAuthToken))
|
||||
return err
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ func (u *User) OrganisationLink() string {
|
|||
// GenerateEmailAuthorizationCode generates an activation code based for the user for the specified purpose.
|
||||
// The standard expiry is ActiveCodeLives minutes.
|
||||
func (u *User) GenerateEmailAuthorizationCode(ctx context.Context, purpose auth.AuthorizationPurpose) (string, error) {
|
||||
lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose)
|
||||
lookup, validator, err := auth.GenerateAuthToken(ctx, u.ID, optional.None[int64](), timeutil.TimeStampNow().Add(int64(setting.Service.ActiveCodeLives)*60), purpose)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -923,46 +923,46 @@ func countUsers(ctx context.Context, opts *CountUserFilter) int64 {
|
|||
|
||||
// VerifyUserActiveCode verifies that the code is valid for the given purpose for this user.
|
||||
// If delete is specified, the token will be deleted.
|
||||
func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose) (user *User, deleteToken func() error, err error) {
|
||||
func VerifyUserAuthorizationToken(ctx context.Context, code string, purpose auth.AuthorizationPurpose) (user *User, authToken *auth.AuthorizationToken, deleteToken func() error, err error) {
|
||||
lookupKey, validator, found := strings.Cut(code, ":")
|
||||
if !found {
|
||||
return nil, nil, nil
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
|
||||
authToken, err := auth.FindAuthToken(ctx, lookupKey, purpose)
|
||||
authToken, err = auth.FindAuthToken(ctx, lookupKey, purpose)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return nil, nil, nil
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if authToken.IsExpired() {
|
||||
return nil, nil, auth.DeleteAuthToken(ctx, authToken)
|
||||
return nil, nil, nil, auth.DeleteAuthToken(ctx, authToken)
|
||||
}
|
||||
|
||||
rawValidator, err := hex.DecodeString(validator)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(authToken.HashedValidator), []byte(auth.HashValidator(rawValidator))) == 0 {
|
||||
return nil, nil, errors.New("validator doesn't match")
|
||||
return nil, nil, nil, errors.New("validator doesn't match")
|
||||
}
|
||||
|
||||
u, err := GetUserByID(ctx, authToken.UID)
|
||||
if err != nil {
|
||||
if IsErrUserNotExist(err) {
|
||||
return nil, nil, nil
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
deleteToken = func() error {
|
||||
return auth.DeleteAuthToken(ctx, authToken)
|
||||
}
|
||||
|
||||
return u, deleteToken, nil
|
||||
return u, authToken, deleteToken, nil
|
||||
}
|
||||
|
||||
// ValidateUser check if user is valid to insert / update into database
|
||||
|
|
|
|||
|
|
@ -982,23 +982,25 @@ func TestVerifyUserAuthorizationToken(t *testing.T) {
|
|||
assert.True(t, ok)
|
||||
|
||||
t.Run("Wrong purpose", func(t *testing.T) {
|
||||
u, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset)
|
||||
u, _, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.PasswordReset)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, u)
|
||||
})
|
||||
|
||||
t.Run("No delete", func(t *testing.T) {
|
||||
u, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation)
|
||||
u, authToken, _, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, u.ID)
|
||||
require.NotNil(t, authToken)
|
||||
assert.False(t, authToken.LoginSourceID.Has())
|
||||
|
||||
authToken, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
|
||||
stored, err := auth.FindAuthToken(db.DefaultContext, lookupKey, auth.UserActivation)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authToken)
|
||||
assert.NotNil(t, stored)
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
u, deleteToken, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation)
|
||||
u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(db.DefaultContext, code, auth.UserActivation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, u.ID)
|
||||
require.NoError(t, deleteToken())
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -49,7 +50,6 @@ const (
|
|||
TplActivate base.TplName = "user/auth/activate"
|
||||
)
|
||||
|
||||
// autoSignIn reads cookie and try to auto-login.
|
||||
func autoSignIn(ctx *context.Context) (bool, error) {
|
||||
isSucceed := false
|
||||
defer func() {
|
||||
|
|
@ -63,20 +63,14 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||
return false, nil
|
||||
}
|
||||
|
||||
u, _, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization)
|
||||
u, _, _, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorization)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("VerifyUserAuthorizationToken: %w", err)
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if u != nil {
|
||||
isSucceed = true
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
// Set session IDs
|
||||
"uid": u.ID,
|
||||
}); err != nil {
|
||||
if err := updateSession(ctx, nil, map[string]any{"uid": u.ID}); err != nil {
|
||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +81,36 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
|||
return true, nil
|
||||
}
|
||||
|
||||
u, authToken, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, authCookie, auth.LongTermAuthorizationSSO)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("VerifyUserAuthorizationToken (SSO): %w", err)
|
||||
}
|
||||
if u == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hasLoginSource, loginSourceID := authToken.LoginSourceID.Get()
|
||||
if !hasLoginSource {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
source, err := auth.GetSourceByID(ctx, loginSourceID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetSourceByID: %w", err)
|
||||
}
|
||||
if !source.IsActive || !source.IsOAuth2() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := deleteToken(); err != nil {
|
||||
return false, fmt.Errorf("deleteToken: %w", err)
|
||||
}
|
||||
isSucceed = true
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("%s/user/oauth2/%s?prompt=none", setting.AppSubURL, url.PathEscape(source.Name)))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func resetLocale(ctx *context.Context, u *user_model.User) error {
|
||||
// Language setting of the user overwrites the one previously set
|
||||
// If the user does not have a locale set, we save the current one.
|
||||
|
|
@ -122,15 +146,20 @@ func RedirectAfterLogin(ctx *context.Context) {
|
|||
}
|
||||
|
||||
func CheckAutoLogin(ctx *context.Context) bool {
|
||||
isSucceed, err := autoSignIn(ctx) // try to auto-login
|
||||
// redirect_to must be set before autoSignIn so it survives the SSO IdP round-trip.
|
||||
redirectTo := ctx.FormString("redirect_to")
|
||||
if len(redirectTo) > 0 {
|
||||
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
||||
}
|
||||
|
||||
isSucceed, err := autoSignIn(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("autoSignIn", err)
|
||||
return true
|
||||
}
|
||||
|
||||
redirectTo := ctx.FormString("redirect_to")
|
||||
if len(redirectTo) > 0 {
|
||||
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
||||
if ctx.Written() {
|
||||
return true
|
||||
}
|
||||
|
||||
if isSucceed {
|
||||
|
|
@ -296,7 +325,19 @@ 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 {
|
||||
if err := ctx.SetLTACookie(u); err != nil {
|
||||
var err error
|
||||
if ssoLTA, _ := ctx.Session.Get("twofaSSOLTA").(bool); ssoLTA {
|
||||
sourceID, _ := ctx.Session.Get("twofaSSOLTASourceID").(int64)
|
||||
if sourceID == 0 {
|
||||
// twofaSSOLTASourceID must have been set alongside twofaSSOLTA in handleOAuth2SignIn.
|
||||
log.Warn("2FA SSO LTA requested for user %d without a source ID; skipping remember-me cookie", u.ID)
|
||||
} else {
|
||||
err = ctx.SetSSOLTACookie(u, sourceID)
|
||||
}
|
||||
} else {
|
||||
err = ctx.SetLTACookie(u)
|
||||
}
|
||||
if err != nil {
|
||||
ctx.ServerError("GenerateAuthToken", err)
|
||||
return setting.AppSubURL + "/"
|
||||
}
|
||||
|
|
@ -310,6 +351,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
|
|||
"openid_determined_username",
|
||||
"twofaUid",
|
||||
"twofaRemember",
|
||||
"twofaSSOLTA",
|
||||
"twofaSSOLTASourceID",
|
||||
"twofaOpenID",
|
||||
"linkAccount",
|
||||
}, map[string]any{
|
||||
|
|
@ -676,7 +719,7 @@ func Activate(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
user, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation)
|
||||
user, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation)
|
||||
if err != nil {
|
||||
ctx.ServerError("VerifyUserAuthorizationToken", err)
|
||||
return
|
||||
|
|
@ -750,7 +793,7 @@ func ActivatePost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
user, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation)
|
||||
user, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.UserActivation)
|
||||
if err != nil {
|
||||
ctx.ServerError("VerifyUserAuthorizationToken", err)
|
||||
return
|
||||
|
|
@ -840,7 +883,7 @@ func ActivateEmail(ctx *context.Context) {
|
|||
code := ctx.FormString("code")
|
||||
emailStr := ctx.FormString("email")
|
||||
|
||||
u, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr))
|
||||
u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.EmailActivation(emailStr))
|
||||
if err != nil {
|
||||
ctx.ServerError("VerifyUserAuthorizationToken", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -128,6 +128,15 @@ func (err errCallback) Error() string {
|
|||
return err.Description
|
||||
}
|
||||
|
||||
func isOIDCSilentAuthFailure(code string) bool {
|
||||
switch code {
|
||||
// access_denied is non-standard for prompt=none but emitted by Keycloak and some Azure AD configurations.
|
||||
case "login_required", "interaction_required", "account_selection_required", "consent_required", "access_denied":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TokenType specifies the kind of token
|
||||
type TokenType string
|
||||
|
||||
|
|
@ -955,6 +964,17 @@ func SignInOAuth(ctx *context.Context) {
|
|||
middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
|
||||
}
|
||||
|
||||
// Overwrite to avoid leaking the value from a prior attempt.
|
||||
promptParam := ctx.FormString("prompt")
|
||||
if err := ctx.Session.Set("oauth_signin_silent", promptParam == "none"); err != nil {
|
||||
ctx.ServerError("Session.Set", err)
|
||||
return
|
||||
}
|
||||
if err := ctx.Session.Release(); err != nil {
|
||||
ctx.ServerError("Session.Release", err)
|
||||
return
|
||||
}
|
||||
|
||||
// try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
|
||||
user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
|
||||
if err == nil && user != nil {
|
||||
|
|
@ -969,13 +989,13 @@ func SignInOAuth(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
|
||||
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge, promptParam); err != nil {
|
||||
if strings.Contains(err.Error(), "no provider for ") {
|
||||
if err = oauth2.ResetOAuth2(ctx); err != nil {
|
||||
ctx.ServerError("SignIn", err)
|
||||
return
|
||||
}
|
||||
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
|
||||
if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge, promptParam); err != nil {
|
||||
ctx.ServerError("SignIn", err)
|
||||
}
|
||||
return
|
||||
|
|
@ -989,6 +1009,22 @@ func SignInOAuth(ctx *context.Context) {
|
|||
func SignInOAuthCallback(ctx *context.Context) {
|
||||
provider := ctx.Params(":provider")
|
||||
|
||||
// If the IdP refused our prompt=none silent re-auth, retry interactively rather than surfacing the error.
|
||||
if isOIDCSilentAuthFailure(ctx.Req.FormValue("error")) {
|
||||
if silent, _ := ctx.Session.Get("oauth_signin_silent").(bool); silent {
|
||||
if err := ctx.Session.Delete("oauth_signin_silent"); err != nil {
|
||||
ctx.ServerError("Session.Delete", err)
|
||||
return
|
||||
}
|
||||
if err := ctx.Session.Release(); err != nil {
|
||||
ctx.ServerError("Session.Release", err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(fmt.Sprintf("%s/user/oauth2/%s", setting.AppSubURL, url.PathEscape(provider)))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Req.FormValue("error") != "" {
|
||||
var errorKeyValues []string
|
||||
for k, vv := range ctx.Req.Form {
|
||||
|
|
@ -1331,7 +1367,14 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
|||
// If this user is enrolled in 2FA and this source doesn't override it,
|
||||
// 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{
|
||||
if err := ctx.SetSSOLTACookie(u, source.ID); err != nil {
|
||||
ctx.ServerError("SetSSOLTACookie", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateSession(ctx,
|
||||
[]string{"oauth_signin_silent"},
|
||||
map[string]any{
|
||||
"uid": u.ID,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
|
|
@ -1406,10 +1449,13 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
|
|||
}
|
||||
}
|
||||
|
||||
if err := updateSession(ctx, nil, map[string]any{
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
if err := updateSession(ctx,
|
||||
[]string{"oauth_signin_silent"},
|
||||
map[string]any{
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": false,
|
||||
"twofaRemember": true, // OAuth implies remember
|
||||
"twofaSSOLTA": true, // honored by handleSignInFull to issue the SSO variant
|
||||
"twofaSSOLTASourceID": source.ID,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func commonResetPassword(ctx *context.Context, shouldDeleteToken bool) (*user_mo
|
|||
}
|
||||
|
||||
// Fail early, don't frustrate the user
|
||||
u, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset)
|
||||
u, _, deleteToken, err := user_model.VerifyUserAuthorizationToken(ctx, code, auth.PasswordReset)
|
||||
if err != nil {
|
||||
ctx.ServerError("VerifyUserAuthorizationToken", err)
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ func DeleteSource(ctx context.Context, source *auth.Source) error {
|
|||
}
|
||||
}
|
||||
|
||||
if _, err := db.GetEngine(ctx).Where("login_source_id = ?", source.ID).Delete(new(auth.AuthorizationToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,23 @@ import (
|
|||
"github.com/markbates/goth/gothic"
|
||||
)
|
||||
|
||||
// Callout redirects request/response pair to authenticate against the provider
|
||||
func (source *Source) Callout(request *http.Request, response http.ResponseWriter, codeChallengeS256 string) error {
|
||||
// Callout redirects request/response pair to authenticate against the provider.
|
||||
// prompt, if non-empty, is appended as the OIDC `prompt` parameter.
|
||||
func (source *Source) Callout(request *http.Request, response http.ResponseWriter, codeChallengeS256, prompt string) error {
|
||||
// not sure if goth is thread safe (?) when using multiple providers
|
||||
request.Header.Set(ProviderHeaderKey, source.authSource.Name)
|
||||
|
||||
var querySuffix string
|
||||
extras := url.Values{}
|
||||
if codeChallengeS256 != "" {
|
||||
querySuffix = "&" + url.Values{
|
||||
"code_challenge_method": []string{"S256"},
|
||||
"code_challenge": []string{codeChallengeS256},
|
||||
}.Encode()
|
||||
extras.Set("code_challenge_method", "S256")
|
||||
extras.Set("code_challenge", codeChallengeS256)
|
||||
}
|
||||
if prompt != "" {
|
||||
extras.Set("prompt", prompt)
|
||||
}
|
||||
var querySuffix string
|
||||
if len(extras) > 0 {
|
||||
querySuffix = "&" + extras.Encode()
|
||||
}
|
||||
|
||||
// don't use the default gothic begin handler to prevent issues when some error occurs
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/timeutil"
|
||||
"forgejo.org/modules/web/middleware"
|
||||
|
|
@ -47,7 +48,18 @@ func (ctx *Context) GetSiteCookie(name string) string {
|
|||
// 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)), auth_model.LongTermAuthorization)
|
||||
lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, optional.None[int64](), timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, lookup+":"+validator, days)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSSOLTACookie sets a long-term-auth cookie bound to the given OAuth2/OIDC source.
|
||||
func (ctx *Context) SetSSOLTACookie(u *user_model.User, loginSourceID int64) error {
|
||||
days := 86400 * setting.LogInRememberDays
|
||||
lookup, validator, err := auth_model.GenerateAuthToken(ctx, u.ID, optional.Some(loginSourceID), timeutil.TimeStampNow().Add(int64(days)), auth_model.LongTermAuthorizationSSO)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -22,6 +23,7 @@ import (
|
|||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/optional"
|
||||
"forgejo.org/modules/setting"
|
||||
api "forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/test"
|
||||
|
|
@ -30,6 +32,7 @@ import (
|
|||
"forgejo.org/tests"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
go_oauth2 "golang.org/x/oauth2"
|
||||
|
|
@ -623,6 +626,195 @@ func TestSignInOAuthCallbackSignIn(t *testing.T) {
|
|||
assert.Greater(t, userAfterLogin.LastLoginUnix, userGitLab.LastLoginUnix)
|
||||
}
|
||||
|
||||
func findLTACookie(resp *httptest.ResponseRecorder) *http.Cookie {
|
||||
for _, c := range resp.Result().Cookies() {
|
||||
if c.Name == setting.CookieRememberName && c.Value != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLTAAuthToken(t *testing.T, cookieValue string) auth_model.AuthorizationToken {
|
||||
t.Helper()
|
||||
decoded, err := url.QueryUnescape(cookieValue)
|
||||
require.NoError(t, err)
|
||||
lookup, _, ok := strings.Cut(decoded, ":")
|
||||
require.True(t, ok)
|
||||
var token auth_model.AuthorizationToken
|
||||
has, err := db.GetEngine(t.Context()).Where("lookup_key = ?", lookup).Get(&token)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
return token
|
||||
}
|
||||
|
||||
func TestSignInOAuthSSOLTACookie(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
gitlabName := "gitlab"
|
||||
gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
|
||||
|
||||
userGitLabUserID := "5678"
|
||||
userGitLab := &user_model.User{
|
||||
Name: "gitlabuser",
|
||||
Email: "gitlabuser@example.com",
|
||||
Passwd: "gitlabuserpassword",
|
||||
Type: user_model.UserTypeIndividual,
|
||||
LoginType: auth_model.OAuth2,
|
||||
LoginSource: gitlab.ID,
|
||||
LoginName: userGitLabUserID,
|
||||
}
|
||||
defer createUser(t.Context(), t, userGitLab)()
|
||||
|
||||
mockUser := func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
|
||||
return goth.User{
|
||||
Provider: gitlabName,
|
||||
UserID: userGitLabUserID,
|
||||
Email: userGitLab.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
t.Run("OAuth sign-in issues an SSO LTA cookie bound to the source", func(t *testing.T) {
|
||||
defer mockCompleteUserAuth(mockUser)()
|
||||
|
||||
session := emptyTestSession(t)
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName))
|
||||
resp := session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
c := findLTACookie(resp)
|
||||
require.NotNil(t, c)
|
||||
token := loadLTAAuthToken(t, c.Value)
|
||||
assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose)
|
||||
assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID)
|
||||
})
|
||||
|
||||
t.Run("SSO LTA cookie alone bounces user to IdP with prompt=none", func(t *testing.T) {
|
||||
defer mockCompleteUserAuth(mockUser)()
|
||||
session := emptyTestSession(t)
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName))
|
||||
session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
require.NotNil(t, session.GetCookie(setting.CookieRememberName))
|
||||
|
||||
session.SetCookie(&http.Cookie{Name: setting.SessionConfig.CookieName, MaxAge: -1})
|
||||
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusSeeOther)
|
||||
loc, err := resp.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path)
|
||||
assert.Equal(t, "none", loc.Query().Get("prompt"))
|
||||
})
|
||||
|
||||
t.Run("silent re-auth failure falls back to interactive", func(t *testing.T) {
|
||||
defer mockCompleteUserAuth(func(http.ResponseWriter, *http.Request) (goth.User, error) {
|
||||
return goth.User{}, errors.New("not authenticated")
|
||||
})()
|
||||
session := emptyTestSession(t)
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s?prompt=none", gitlabName))
|
||||
session.MakeRequest(t, req, http.StatusTemporaryRedirect)
|
||||
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?error=login_required", gitlabName))
|
||||
resp := session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
loc, err := resp.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path)
|
||||
assert.Empty(t, loc.Query().Get("prompt"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignInOAuthSSOLTACookie_MultipleLinkedSources(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
gitlabName := "gitlab-multi"
|
||||
gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
|
||||
|
||||
externalID := "9999"
|
||||
user := &user_model.User{
|
||||
Name: "internal-then-linked",
|
||||
Email: "internal-then-linked@example.com",
|
||||
Passwd: "passwd",
|
||||
Type: user_model.UserTypeIndividual,
|
||||
LoginType: auth_model.Plain,
|
||||
}
|
||||
defer createUser(t.Context(), t, user)()
|
||||
|
||||
require.NoError(t, user_model.LinkExternalToUser(db.DefaultContext, user, &user_model.ExternalLoginUser{
|
||||
ExternalID: externalID,
|
||||
UserID: user.ID,
|
||||
LoginSourceID: gitlab.ID,
|
||||
}))
|
||||
|
||||
mockUser := func(http.ResponseWriter, *http.Request) (goth.User, error) {
|
||||
return goth.User{Provider: gitlabName, UserID: externalID, Email: user.Email}, nil
|
||||
}
|
||||
|
||||
t.Run("LTA bound to the OAuth source", func(t *testing.T) {
|
||||
defer mockCompleteUserAuth(mockUser)()
|
||||
|
||||
session := emptyTestSession(t)
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName))
|
||||
resp := session.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
c := findLTACookie(resp)
|
||||
require.NotNil(t, c)
|
||||
token := loadLTAAuthToken(t, c.Value)
|
||||
assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose)
|
||||
assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID)
|
||||
|
||||
session.SetCookie(&http.Cookie{Name: setting.SessionConfig.CookieName, MaxAge: -1})
|
||||
|
||||
resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusSeeOther)
|
||||
loc, err := resp.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, setting.AppSubURL+"/user/oauth2/"+gitlabName, loc.Path)
|
||||
assert.Equal(t, "none", loc.Query().Get("prompt"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignInOAuthSSOLTACookie_With2FA(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
gitlabName := "gitlab-2fa"
|
||||
gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
|
||||
|
||||
gitlabUserID := "2fa-user"
|
||||
user := &user_model.User{
|
||||
Name: "oauth-2fa-user",
|
||||
Email: "oauth-2fa-user@example.com",
|
||||
Passwd: "passwd",
|
||||
Type: user_model.UserTypeIndividual,
|
||||
LoginType: auth_model.OAuth2,
|
||||
LoginSource: gitlab.ID,
|
||||
LoginName: gitlabUserID,
|
||||
}
|
||||
defer createUser(t.Context(), t, user)()
|
||||
|
||||
otpKey, err := totp.Generate(totp.GenerateOpts{SecretSize: 40, Issuer: "forgejo-test", AccountName: user.Name})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, auth_model.NewTwoFactor(db.DefaultContext, &auth_model.TwoFactor{UID: user.ID}, otpKey.Secret()))
|
||||
defer unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
|
||||
|
||||
defer mockCompleteUserAuth(func(http.ResponseWriter, *http.Request) (goth.User, error) {
|
||||
return goth.User{Provider: gitlabName, UserID: gitlabUserID, Email: user.Email}, nil
|
||||
})()
|
||||
|
||||
session := emptyTestSession(t)
|
||||
resp := session.MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName)), http.StatusSeeOther)
|
||||
loc, err := resp.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, setting.AppSubURL+"/user/two_factor", loc.Path)
|
||||
require.Nil(t, session.GetCookie(setting.CookieRememberName))
|
||||
|
||||
passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/two_factor", map[string]string{"passcode": passcode}), http.StatusSeeOther)
|
||||
|
||||
c := session.GetCookie(setting.CookieRememberName)
|
||||
require.NotNil(t, c)
|
||||
require.NotEmpty(t, c.Value)
|
||||
token := loadLTAAuthToken(t, c.Value)
|
||||
assert.Equal(t, auth_model.LongTermAuthorizationSSO, token.Purpose)
|
||||
assert.Equal(t, optional.Some(gitlab.ID), token.LoginSourceID)
|
||||
}
|
||||
|
||||
func TestSignInOAuthCallbackWithoutPKCEWhenUnsupported(t *testing.T) {
|
||||
// https://codeberg.org/forgejo/forgejo/issues/4033
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue