Skip to content

Commit a517009

Browse files
committed
1 parent 2f6b1c4 commit a517009

File tree

14 files changed

+169
-64
lines changed

14 files changed

+169
-64
lines changed

models/unittest/testdb.go

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"code.gitea.io/gitea/models/system"
1717
"code.gitea.io/gitea/modules/auth/password/hash"
1818
"code.gitea.io/gitea/modules/base"
19+
"code.gitea.io/gitea/modules/cache"
1920
"code.gitea.io/gitea/modules/git"
2021
"code.gitea.io/gitea/modules/setting"
2122
"code.gitea.io/gitea/modules/setting/config"
@@ -106,6 +107,7 @@ func MainTest(m *testing.M, testOpts ...*TestOptions) {
106107
fatalTestError("Error creating test engine: %v\n", err)
107108
}
108109

110+
setting.IsInTesting = true
109111
setting.AppURL = "https://try.gitea.io/"
110112
setting.RunUser = "runuser"
111113
setting.SSH.User = "sshuser"
@@ -148,6 +150,9 @@ func MainTest(m *testing.M, testOpts ...*TestOptions) {
148150

149151
config.SetDynGetter(system.NewDatabaseDynKeyGetter())
150152

153+
if err = cache.Init(); err != nil {
154+
fatalTestError("cache.Init: %v\n", err)
155+
}
151156
if err = storage.Init(); err != nil {
152157
fatalTestError("storage.Init: %v\n", err)
153158
}

models/user/user.go

+9-9
Original file line numberDiff line numberDiff line change
@@ -501,19 +501,19 @@ func GetUserSalt() (string, error) {
501501
// Note: The set of characters here can safely expand without a breaking change,
502502
// but characters removed from this set can cause user account linking to break
503503
var (
504-
customCharsReplacement = strings.NewReplacer("Æ", "AE")
505-
removeCharsRE = regexp.MustCompile(`['´\x60]`)
506-
removeDiacriticsTransform = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
507-
replaceCharsHyphenRE = regexp.MustCompile(`[\s~+]`)
504+
customCharsReplacement = strings.NewReplacer("Æ", "AE")
505+
removeCharsRE = regexp.MustCompile("['`´]")
506+
transformDiacritics = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
507+
replaceCharsHyphenRE = regexp.MustCompile(`[\s~+]`)
508508
)
509509

510-
// normalizeUserName returns a string with single-quotes and diacritics
511-
// removed, and any other non-supported username characters replaced with
512-
// a `-` character
510+
// NormalizeUserName only takes the name part if it is an email address, transforms it diacritics to ASCII characters.
511+
// It returns a string with the single-quotes removed, and any other non-supported username characters are replaced with a `-` character
513512
func NormalizeUserName(s string) (string, error) {
514-
strDiacriticsRemoved, n, err := transform.String(removeDiacriticsTransform, customCharsReplacement.Replace(s))
513+
s, _, _ = strings.Cut(s, "@")
514+
strDiacriticsRemoved, n, err := transform.String(transformDiacritics, customCharsReplacement.Replace(s))
515515
if err != nil {
516-
return "", fmt.Errorf("Failed to normalize character `%v` in provided username `%v`", s[n], s)
516+
return "", fmt.Errorf("failed to normalize the string of provided username %q at position %d", s, n)
517517
}
518518
return replaceCharsHyphenRE.ReplaceAllLiteralString(removeCharsRE.ReplaceAllLiteralString(strDiacriticsRemoved, ""), "-"), nil
519519
}

models/user/user_test.go

+4-3
Original file line numberDiff line numberDiff line change
@@ -506,15 +506,16 @@ func Test_NormalizeUserFromEmail(t *testing.T) {
506506
Expected string
507507
IsNormalizedValid bool
508508
}{
509-
{"test", "test", true},
509+
{"name@example.com", "name", true},
510+
{"test'`´name", "testname", true},
510511
{"Sinéad.O'Connor", "Sinead.OConnor", true},
511512
{"Æsir", "AEsir", true},
512-
// \u00e9\u0065\u0301
513-
{"éé", "ee", true},
513+
{"éé", "ee", true}, // \u00e9\u0065\u0301
514514
{"Awareness Hub", "Awareness-Hub", true},
515515
{"double__underscore", "double__underscore", false}, // We should consider squashing double non-alpha characters
516516
{".bad.", ".bad.", false},
517517
{"new😀user", "new😀user", false}, // No plans to support
518+
{`"quoted"`, `"quoted"`, false}, // No plans to support
518519
}
519520
for _, testCase := range testCases {
520521
normalizedName, err := user_model.NormalizeUserName(testCase.Input)

modules/session/mock.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package session
5+
6+
import (
7+
"net/http"
8+
9+
"gitea.com/go-chi/session"
10+
)
11+
12+
type MockStore struct {
13+
*session.MemStore
14+
}
15+
16+
func (m *MockStore) Destroy(writer http.ResponseWriter, request *http.Request) error {
17+
return nil
18+
}
19+
20+
type mockStoreContextKeyStruct struct{}
21+
22+
var MockStoreContextKey = mockStoreContextKeyStruct{}
23+
24+
func NewMockStore(sid string) *MockStore {
25+
return &MockStore{session.NewMemStore(sid)}
26+
}

modules/session/store.go

+21-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package session
66
import (
77
"net/http"
88

9+
"code.gitea.io/gitea/modules/setting"
10+
911
"gitea.com/go-chi/session"
1012
)
1113

@@ -14,15 +16,32 @@ type Store interface {
1416
Get(any) any
1517
Set(any, any) error
1618
Delete(any) error
19+
ID() string
20+
Release() error
21+
Flush() error
22+
Destroy(http.ResponseWriter, *http.Request) error
1723
}
1824

1925
// RegenerateSession regenerates the underlying session and returns the new store
2026
func RegenerateSession(resp http.ResponseWriter, req *http.Request) (Store, error) {
2127
for _, f := range BeforeRegenerateSession {
2228
f(resp, req)
2329
}
24-
s, err := session.RegenerateSession(resp, req)
25-
return s, err
30+
if setting.IsInTesting {
31+
if store, ok := req.Context().Value(MockStoreContextKey).(*MockStore); ok {
32+
return store, nil
33+
}
34+
}
35+
return session.RegenerateSession(resp, req)
36+
}
37+
38+
func GetContextSession(req *http.Request) Store {
39+
if setting.IsInTesting {
40+
if store, ok := req.Context().Value(MockStoreContextKey).(*MockStore); ok {
41+
return store
42+
}
43+
}
44+
return session.GetSession(req)
2645
}
2746

2847
// BeforeRegenerateSession is a list of functions that are called before a session is regenerated.

modules/setting/oauth2.go

+5-9
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,10 @@ import (
1616
type OAuth2UsernameType string
1717

1818
const (
19-
// OAuth2UsernameUserid oauth2 userid field will be used as gitea name
20-
OAuth2UsernameUserid OAuth2UsernameType = "userid"
21-
// OAuth2UsernameNickname oauth2 nickname field will be used as gitea name
22-
OAuth2UsernameNickname OAuth2UsernameType = "nickname"
23-
// OAuth2UsernameEmail username of oauth2 email field will be used as gitea name
24-
OAuth2UsernameEmail OAuth2UsernameType = "email"
25-
// OAuth2UsernameEmail username of oauth2 preferred_username field will be used as gitea name
26-
OAuth2UsernamePreferredUsername OAuth2UsernameType = "preferred_username"
19+
OAuth2UsernameUserid OAuth2UsernameType = "userid" // use user id (sub) field as gitea's username
20+
OAuth2UsernameNickname OAuth2UsernameType = "nickname" // use nickname field
21+
OAuth2UsernameEmail OAuth2UsernameType = "email" // use email field
22+
OAuth2UsernamePreferredUsername OAuth2UsernameType = "preferred_username" // use preferred_username field
2723
)
2824

2925
func (username OAuth2UsernameType) isValid() bool {
@@ -71,8 +67,8 @@ func loadOAuth2ClientFrom(rootCfg ConfigProvider) {
7167
OAuth2Client.EnableAutoRegistration = sec.Key("ENABLE_AUTO_REGISTRATION").MustBool()
7268
OAuth2Client.Username = OAuth2UsernameType(sec.Key("USERNAME").MustString(string(OAuth2UsernameNickname)))
7369
if !OAuth2Client.Username.isValid() {
74-
log.Warn("Username setting is not valid: '%s', will fallback to '%s'", OAuth2Client.Username, OAuth2UsernameNickname)
7570
OAuth2Client.Username = OAuth2UsernameNickname
71+
log.Warn("[oauth2_client].USERNAME setting is invalid, falls back to %q", OAuth2Client.Username)
7672
}
7773
OAuth2Client.UpdateAvatar = sec.Key("UPDATE_AVATAR").MustBool()
7874
OAuth2Client.AccountLinking = OAuth2AccountLinkingType(sec.Key("ACCOUNT_LINKING").MustString(string(OAuth2AccountLinkingLogin)))

options/locale/locale_en-US.ini

+1
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ oauth_signin_submit = Link Account
436436
oauth.signin.error = There was an error processing the authorization request. If this error persists, please contact the site administrator.
437437
oauth.signin.error.access_denied = The authorization request was denied.
438438
oauth.signin.error.temporarily_unavailable = Authorization failed because the authentication server is temporarily unavailable. Please try again later.
439+
oauth_callback_unable_auto_reg = Auto Registration is enabled, but OAuth2 Provider %[1]s returned missing fields: %[2]s, unable to create an account automatically, please create or link to an account, or contact the site administrator.
439440
openid_connect_submit = Connect
440441
openid_connect_title = Connect to an existing account
441442
openid_connect_desc = The chosen OpenID URI is unknown. Associate it with a new account here.

routers/web/auth/auth.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -382,17 +382,17 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
382382
return setting.AppSubURL + "/"
383383
}
384384

385-
func getUserName(gothUser *goth.User) (string, error) {
385+
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
386+
// It returns ("", nil) if the required field doesn't exist.
387+
func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
386388
switch setting.OAuth2Client.Username {
387389
case setting.OAuth2UsernameEmail:
388-
return user_model.NormalizeUserName(strings.Split(gothUser.Email, "@")[0])
390+
return user_model.NormalizeUserName(gothUser.Email)
389391
case setting.OAuth2UsernamePreferredUsername:
390-
preferredUsername, exists := gothUser.RawData["preferred_username"]
391-
if exists {
392-
return user_model.NormalizeUserName(preferredUsername.(string))
393-
} else {
394-
return "", fmt.Errorf("preferred_username is missing in received user data but configured as username source for user_id %q. Check if OPENID_CONNECT_SCOPES contains profile", gothUser.UserID)
392+
if preferredUsername, ok := gothUser.RawData["preferred_username"].(string); ok {
393+
return user_model.NormalizeUserName(preferredUsername)
395394
}
395+
return "", nil
396396
case setting.OAuth2UsernameNickname:
397397
return user_model.NormalizeUserName(gothUser.NickName)
398398
default: // OAuth2UsernameUserid

routers/web/auth/auth_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,31 @@ import (
88
"net/url"
99
"testing"
1010

11+
auth_model "code.gitea.io/gitea/models/auth"
12+
"code.gitea.io/gitea/models/db"
13+
"code.gitea.io/gitea/modules/session"
14+
"code.gitea.io/gitea/modules/setting"
1115
"code.gitea.io/gitea/modules/test"
16+
"code.gitea.io/gitea/modules/util"
17+
"code.gitea.io/gitea/services/auth/source/oauth2"
1218
"code.gitea.io/gitea/services/contexttest"
1319

20+
"github.com/markbates/goth"
21+
"github.com/markbates/goth/gothic"
1422
"github.com/stretchr/testify/assert"
1523
)
1624

25+
func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) {
26+
cfg.Provider = util.IfZero(cfg.Provider, "gitea")
27+
err := auth_model.CreateSource(db.DefaultContext, &auth_model.Source{
28+
Type: auth_model.OAuth2,
29+
Name: authName,
30+
IsActive: true,
31+
Cfg: &cfg,
32+
})
33+
assert.NoError(t, err)
34+
}
35+
1736
func TestUserLogin(t *testing.T) {
1837
ctx, resp := contexttest.MockContext(t, "/user/login")
1938
SignIn(ctx)
@@ -41,3 +60,24 @@ func TestUserLogin(t *testing.T) {
4160
SignIn(ctx)
4261
assert.Equal(t, "/", test.RedirectURL(resp))
4362
}
63+
64+
func TestSignUpOAuth2ButMissingFields(t *testing.T) {
65+
defer test.MockVariableValue(&setting.OAuth2Client.EnableAutoRegistration, true)()
66+
defer test.MockVariableValue(&gothic.CompleteUserAuth, func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
67+
return goth.User{Provider: "dummy-auth-source", UserID: "dummy-user"}, nil
68+
})()
69+
70+
addOAuth2Source(t, "dummy-auth-source", oauth2.Source{})
71+
72+
mockOpt := contexttest.MockContextOption{SessionStore: session.NewMockStore("dummy-sid")}
73+
ctx, resp := contexttest.MockContext(t, "/user/oauth2/dummy-auth-source/callback?code=dummy-code", mockOpt)
74+
ctx.SetParams("provider", "dummy-auth-source")
75+
SignInOAuthCallback(ctx)
76+
assert.Equal(t, http.StatusSeeOther, resp.Code)
77+
assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
78+
79+
// then the user will be redirected to the link account page, and see a message about the missing fields
80+
ctx, _ = contexttest.MockContext(t, "/user/link_account", mockOpt)
81+
LinkAccount(ctx)
82+
assert.EqualValues(t, "auth.oauth_callback_unable_auto_reg:dummy-auth-source,email", ctx.Data["AutoRegistrationFailedPrompt"])
83+
}

routers/web/auth/linkaccount.go

+12-8
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,27 @@ func LinkAccount(ctx *context.Context) {
4848
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/link_account_signin"
4949
ctx.Data["SignUpLink"] = setting.AppSubURL + "/user/link_account_signup"
5050

51-
gothUser := ctx.Session.Get("linkAccountGothUser")
52-
if gothUser == nil {
53-
ctx.ServerError("UserSignIn", errors.New("not in LinkAccount session"))
51+
gothUser, ok := ctx.Session.Get("linkAccountGothUser").(goth.User)
52+
if !ok {
53+
// no account in session, so just redirect to the login page, then the user could restart the process
54+
ctx.Redirect(setting.AppSubURL + "/user/login")
5455
return
5556
}
5657

57-
gu, _ := gothUser.(goth.User)
58-
uname, err := getUserName(&gu)
58+
if missingFields, ok := gothUser.RawData["__giteaAutoRegMissingFields"].([]string); ok {
59+
ctx.Data["AutoRegistrationFailedPrompt"] = ctx.Tr("auth.oauth_callback_unable_auto_reg", gothUser.Provider, strings.Join(missingFields, ","))
60+
}
61+
62+
uname, err := extractUserNameFromOAuth2(&gothUser)
5963
if err != nil {
6064
ctx.ServerError("UserSignIn", err)
6165
return
6266
}
63-
email := gu.Email
67+
email := gothUser.Email
6468
ctx.Data["user_name"] = uname
6569
ctx.Data["email"] = email
6670

67-
if len(email) != 0 {
71+
if email != "" {
6872
u, err := user_model.GetUserByEmail(ctx, email)
6973
if err != nil && !user_model.IsErrUserNotExist(err) {
7074
ctx.ServerError("UserSignIn", err)
@@ -73,7 +77,7 @@ func LinkAccount(ctx *context.Context) {
7377
if u != nil {
7478
ctx.Data["user_exists"] = true
7579
}
76-
} else if len(uname) != 0 {
80+
} else if uname != "" {
7781
u, err := user_model.GetUserByName(ctx, uname)
7882
if err != nil && !user_model.IsErrUserNotExist(err) {
7983
ctx.ServerError("UserSignIn", err)

routers/web/auth/oauth.go

+22-13
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,7 @@ func SignInOAuthCallback(ctx *context.Context) {
934934

935935
if u == nil {
936936
if ctx.Doer != nil {
937-
// attach user to already logged in user
937+
// attach user to the current signed-in user
938938
err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
939939
if err != nil {
940940
ctx.ServerError("UserLinkAccount", err)
@@ -952,21 +952,30 @@ func SignInOAuthCallback(ctx *context.Context) {
952952
if gothUser.Email == "" {
953953
missingFields = append(missingFields, "email")
954954
}
955-
if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
956-
missingFields = append(missingFields, "nickname")
955+
uname, err := extractUserNameFromOAuth2(&gothUser)
956+
if err != nil {
957+
ctx.ServerError("UserSignIn", err)
958+
return
959+
}
960+
if uname == "" {
961+
if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname {
962+
missingFields = append(missingFields, "nickname")
963+
} else if setting.OAuth2Client.Username == setting.OAuth2UsernamePreferredUsername {
964+
missingFields = append(missingFields, "preferred_username")
965+
} // else: "UserID" and "Email" have been handled above separately
957966
}
958967
if len(missingFields) > 0 {
959-
log.Error("OAuth2 Provider %s returned empty or missing fields: %s", authSource.Name, missingFields)
960-
if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" {
961-
log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
968+
log.Error(`OAuth2 auto registration (ENABLE_AUTO_REGISTRATION) is enabled but OAuth2 provider %q doesn't return required fields: %s. `+
969+
`Suggest to: disable auto registration, or make OPENID_CONNECT_SCOPES (for OpenIDConnect) / Authentication Source Scopes (for Admin panel) to request all required fields.`,
970+
authSource.Name, strings.Join(missingFields, ","))
971+
// The RawData is the only way to pass the missing fields to the another page at the moment, other ways all have various problems:
972+
// by session or cookie: difficult to clean or reset; by URL: could be injected with uncontrollable content; by ctx.Flash: the link_account page is a mess ...
973+
// Since the RawData is for the provider's data, so we need to use our own prefix here to avoid conflict.
974+
if gothUser.RawData == nil {
975+
gothUser.RawData = make(map[string]any)
962976
}
963-
err = fmt.Errorf("OAuth2 Provider %s returned empty or missing fields: %s", authSource.Name, missingFields)
964-
ctx.ServerError("CreateUser", err)
965-
return
966-
}
967-
uname, err := getUserName(&gothUser)
968-
if err != nil {
969-
ctx.ServerError("UserSignIn", err)
977+
gothUser.RawData["__giteaAutoRegMissingFields"] = missingFields
978+
showLinkingLogin(ctx, gothUser)
970979
return
971980
}
972981
u = &user_model.User{

services/context/context.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@ import (
2020
"code.gitea.io/gitea/modules/cache"
2121
"code.gitea.io/gitea/modules/gitrepo"
2222
"code.gitea.io/gitea/modules/httpcache"
23+
"code.gitea.io/gitea/modules/session"
2324
"code.gitea.io/gitea/modules/setting"
2425
"code.gitea.io/gitea/modules/templates"
2526
"code.gitea.io/gitea/modules/translation"
2627
"code.gitea.io/gitea/modules/web"
2728
"code.gitea.io/gitea/modules/web/middleware"
2829
web_types "code.gitea.io/gitea/modules/web/types"
29-
30-
"gitea.com/go-chi/session"
3130
)
3231

3332
// Render represents a template render
@@ -154,7 +153,7 @@ func Contexter() func(next http.Handler) http.Handler {
154153
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
155154
base, baseCleanUp := NewBaseContext(resp, req)
156155
defer baseCleanUp()
157-
ctx := NewWebContext(base, rnd, session.GetSession(req))
156+
ctx := NewWebContext(base, rnd, session.GetContextSession(req))
158157

159158
ctx.Data.MergeFrom(middleware.CommonTemplateContextData())
160159
ctx.Data["Context"] = ctx // TODO: use "ctx" in template and remove this

0 commit comments

Comments
 (0)