summaryrefslogtreecommitdiffstats
path: root/services/auth
diff options
context:
space:
mode:
Diffstat (limited to 'services/auth')
-rw-r--r--services/auth/additional_scopes_test.go32
-rw-r--r--services/auth/auth.go106
-rw-r--r--services/auth/auth_test.go134
-rw-r--r--services/auth/basic.go169
-rw-r--r--services/auth/group.go72
-rw-r--r--services/auth/httpsign.go218
-rw-r--r--services/auth/interface.go47
-rw-r--r--services/auth/main_test.go14
-rw-r--r--services/auth/oauth2.go219
-rw-r--r--services/auth/reverseproxy.go179
-rw-r--r--services/auth/reverseproxy_test.go67
-rw-r--r--services/auth/session.go60
-rw-r--r--services/auth/signin.go128
-rw-r--r--services/auth/source.go42
-rw-r--r--services/auth/source/db/assert_interface_test.go20
-rw-r--r--services/auth/source/db/authenticate.go87
-rw-r--r--services/auth/source/db/source.go35
-rw-r--r--services/auth/source/ldap/README.md131
-rw-r--r--services/auth/source/ldap/assert_interface_test.go27
-rw-r--r--services/auth/source/ldap/security_protocol.go31
-rw-r--r--services/auth/source/ldap/source.go122
-rw-r--r--services/auth/source/ldap/source_authenticate.go124
-rw-r--r--services/auth/source/ldap/source_search.go516
-rw-r--r--services/auth/source/ldap/source_sync.go232
-rw-r--r--services/auth/source/ldap/util.go18
-rw-r--r--services/auth/source/oauth2/assert_interface_test.go22
-rw-r--r--services/auth/source/oauth2/init.go86
-rw-r--r--services/auth/source/oauth2/jwtsigningkey.go404
-rw-r--r--services/auth/source/oauth2/main_test.go14
-rw-r--r--services/auth/source/oauth2/providers.go190
-rw-r--r--services/auth/source/oauth2/providers_base.go51
-rw-r--r--services/auth/source/oauth2/providers_custom.go123
-rw-r--r--services/auth/source/oauth2/providers_openid.go58
-rw-r--r--services/auth/source/oauth2/providers_simple.go109
-rw-r--r--services/auth/source/oauth2/providers_test.go62
-rw-r--r--services/auth/source/oauth2/source.go51
-rw-r--r--services/auth/source/oauth2/source_authenticate.go19
-rw-r--r--services/auth/source/oauth2/source_callout.go68
-rw-r--r--services/auth/source/oauth2/source_name.go18
-rw-r--r--services/auth/source/oauth2/source_register.go50
-rw-r--r--services/auth/source/oauth2/source_sync.go114
-rw-r--r--services/auth/source/oauth2/source_sync_test.go101
-rw-r--r--services/auth/source/oauth2/store.go98
-rw-r--r--services/auth/source/oauth2/token.go100
-rw-r--r--services/auth/source/oauth2/urlmapping.go77
-rw-r--r--services/auth/source/pam/assert_interface_test.go21
-rw-r--r--services/auth/source/pam/source.go45
-rw-r--r--services/auth/source/pam/source_authenticate.go76
-rw-r--r--services/auth/source/remote/source.go33
-rw-r--r--services/auth/source/smtp/assert_interface_test.go24
-rw-r--r--services/auth/source/smtp/auth.go106
-rw-r--r--services/auth/source/smtp/source.go66
-rw-r--r--services/auth/source/smtp/source_authenticate.go92
-rw-r--r--services/auth/source/source_group_sync.go116
-rw-r--r--services/auth/source/sspi/assert_interface_test.go18
-rw-r--r--services/auth/source/sspi/source.go39
-rw-r--r--services/auth/sspi.go223
-rw-r--r--services/auth/sspiauth_posix.go30
-rw-r--r--services/auth/sspiauth_windows.go19
-rw-r--r--services/auth/sync.go43
60 files changed, 5596 insertions, 0 deletions
diff --git a/services/auth/additional_scopes_test.go b/services/auth/additional_scopes_test.go
new file mode 100644
index 0000000..9ab4e6e
--- /dev/null
+++ b/services/auth/additional_scopes_test.go
@@ -0,0 +1,32 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGrantAdditionalScopes(t *testing.T) {
+ tests := []struct {
+ grantScopes string
+ expectedScopes string
+ }{
+ {"openid profile email", ""},
+ {"openid profile email groups", ""},
+ {"openid profile email all", "all"},
+ {"openid profile email read:user all", "read:user,all"},
+ {"openid profile email groups read:user", "read:user"},
+ {"read:user read:repository", "read:user,read:repository"},
+ {"read:user write:issue public-only", "read:user,write:issue,public-only"},
+ {"openid profile email read:user", "read:user"},
+ {"read:invalid_scope", ""},
+ {"read:invalid_scope,write:scope_invalid,just-plain-wrong", ""},
+ }
+
+ for _, test := range tests {
+ t.Run(test.grantScopes, func(t *testing.T) {
+ result := grantAdditionalScopes(test.grantScopes)
+ assert.Equal(t, test.expectedScopes, result)
+ })
+ }
+}
diff --git a/services/auth/auth.go b/services/auth/auth.go
new file mode 100644
index 0000000..c108723
--- /dev/null
+++ b/services/auth/auth.go
@@ -0,0 +1,106 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/webauthn"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/session"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web/middleware"
+ gitea_context "code.gitea.io/gitea/services/context"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+// Init should be called exactly once when the application starts to allow plugins
+// to allocate necessary resources
+func Init() {
+ webauthn.Init()
+}
+
+// isAttachmentDownload check if request is a file download (GET) with URL to an attachment
+func isAttachmentDownload(req *http.Request) bool {
+ return strings.HasPrefix(req.URL.Path, "/attachments/") && req.Method == "GET"
+}
+
+// isContainerPath checks if the request targets the container endpoint
+func isContainerPath(req *http.Request) bool {
+ return strings.HasPrefix(req.URL.Path, "/v2/")
+}
+
+var (
+ gitRawOrAttachPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/(?:(?:git-(?:(?:upload)|(?:receive))-pack$)|(?:info/refs$)|(?:HEAD$)|(?:objects/)|(?:raw/)|(?:releases/download/)|(?:attachments/))`)
+ lfsPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/info/lfs/`)
+ archivePathRe = regexp.MustCompile(`^/[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+/archive/`)
+)
+
+func isGitRawOrAttachPath(req *http.Request) bool {
+ return gitRawOrAttachPathRe.MatchString(req.URL.Path)
+}
+
+func isGitRawOrAttachOrLFSPath(req *http.Request) bool {
+ if isGitRawOrAttachPath(req) {
+ return true
+ }
+ if setting.LFS.StartServer {
+ return lfsPathRe.MatchString(req.URL.Path)
+ }
+ return false
+}
+
+func isArchivePath(req *http.Request) bool {
+ return archivePathRe.MatchString(req.URL.Path)
+}
+
+// handleSignIn clears existing session variables and stores new ones for the specified user object
+func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) {
+ // We need to regenerate the session...
+ newSess, err := session.RegenerateSession(resp, req)
+ if err != nil {
+ log.Error(fmt.Sprintf("Error regenerating session: %v", err))
+ } else {
+ sess = newSess
+ }
+
+ _ = sess.Delete("openid_verified_uri")
+ _ = sess.Delete("openid_signin_remember")
+ _ = sess.Delete("openid_determined_email")
+ _ = sess.Delete("openid_determined_username")
+ _ = sess.Delete("twofaUid")
+ _ = sess.Delete("twofaRemember")
+ _ = sess.Delete("webauthnAssertion")
+ _ = sess.Delete("linkAccount")
+ err = sess.Set("uid", user.ID)
+ 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.
+ if len(user.Language) == 0 {
+ lc := middleware.Locale(resp, req)
+ opts := &user_service.UpdateOptions{
+ Language: optional.Some(lc.Language()),
+ }
+ if err := user_service.UpdateUser(req.Context(), user, opts); err != nil {
+ log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language))
+ return
+ }
+ }
+
+ middleware.SetLocaleCookie(resp, user.Language, 0)
+
+ // Clear whatever CSRF has right now, force to generate a new one
+ if ctx := gitea_context.GetWebContext(req); ctx != nil {
+ ctx.Csrf.DeleteCookie(ctx)
+ }
+}
diff --git a/services/auth/auth_test.go b/services/auth/auth_test.go
new file mode 100644
index 0000000..3adaa28
--- /dev/null
+++ b/services/auth/auth_test.go
@@ -0,0 +1,134 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func Test_isGitRawOrLFSPath(t *testing.T) {
+ tests := []struct {
+ path string
+
+ want bool
+ }{
+ {
+ "/owner/repo/git-upload-pack",
+ true,
+ },
+ {
+ "/owner/repo/git-receive-pack",
+ true,
+ },
+ {
+ "/owner/repo/info/refs",
+ true,
+ },
+ {
+ "/owner/repo/HEAD",
+ true,
+ },
+ {
+ "/owner/repo/objects/info/alternates",
+ true,
+ },
+ {
+ "/owner/repo/objects/info/http-alternates",
+ true,
+ },
+ {
+ "/owner/repo/objects/info/packs",
+ true,
+ },
+ {
+ "/owner/repo/objects/info/blahahsdhsdkla",
+ true,
+ },
+ {
+ "/owner/repo/objects/01/23456789abcdef0123456789abcdef01234567",
+ true,
+ },
+ {
+ "/owner/repo/objects/pack/pack-123456789012345678921234567893124567894.pack",
+ true,
+ },
+ {
+ "/owner/repo/objects/pack/pack-0123456789abcdef0123456789abcdef0123456.idx",
+ true,
+ },
+ {
+ "/owner/repo/raw/branch/foo/fanaso",
+ true,
+ },
+ {
+ "/owner/repo/stars",
+ false,
+ },
+ {
+ "/notowner",
+ false,
+ },
+ {
+ "/owner/repo",
+ false,
+ },
+ {
+ "/owner/repo/commit/123456789012345678921234567893124567894",
+ false,
+ },
+ {
+ "/owner/repo/releases/download/tag/repo.tar.gz",
+ true,
+ },
+ {
+ "/owner/repo/attachments/6d92a9ee-5d8b-4993-97c9-6181bdaa8955",
+ true,
+ },
+ }
+ lfsTests := []string{
+ "/owner/repo/info/lfs/",
+ "/owner/repo/info/lfs/objects/batch",
+ "/owner/repo/info/lfs/objects/oid/filename",
+ "/owner/repo/info/lfs/objects/oid",
+ "/owner/repo/info/lfs/objects",
+ "/owner/repo/info/lfs/verify",
+ "/owner/repo/info/lfs/locks",
+ "/owner/repo/info/lfs/locks/verify",
+ "/owner/repo/info/lfs/locks/123/unlock",
+ }
+
+ origLFSStartServer := setting.LFS.StartServer
+
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ req, _ := http.NewRequest("POST", "http://localhost"+tt.path, nil)
+ setting.LFS.StartServer = false
+ if got := isGitRawOrAttachOrLFSPath(req); got != tt.want {
+ t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want)
+ }
+ setting.LFS.StartServer = true
+ if got := isGitRawOrAttachOrLFSPath(req); got != tt.want {
+ t.Errorf("isGitOrLFSPath() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+ for _, tt := range lfsTests {
+ t.Run(tt, func(t *testing.T) {
+ req, _ := http.NewRequest("POST", tt, nil)
+ setting.LFS.StartServer = false
+ if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer {
+ t.Errorf("isGitOrLFSPath(%q) = %v, want %v, %v", tt, got, setting.LFS.StartServer, gitRawOrAttachPathRe.MatchString(tt))
+ }
+ setting.LFS.StartServer = true
+ if got := isGitRawOrAttachOrLFSPath(req); got != setting.LFS.StartServer {
+ t.Errorf("isGitOrLFSPath(%q) = %v, want %v", tt, got, setting.LFS.StartServer)
+ }
+ })
+ }
+ setting.LFS.StartServer = origLFSStartServer
+}
diff --git a/services/auth/basic.go b/services/auth/basic.go
new file mode 100644
index 0000000..382c8bc
--- /dev/null
+++ b/services/auth/basic.go
@@ -0,0 +1,169 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web/middleware"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &Basic{}
+)
+
+// BasicMethodName is the constant name of the basic authentication method
+const BasicMethodName = "basic"
+
+// Basic implements the Auth interface and authenticates requests (API requests
+// only) by looking for Basic authentication data or "x-oauth-basic" token in the "Authorization"
+// header.
+type Basic struct{}
+
+// Name represents the name of auth method
+func (b *Basic) Name() string {
+ return BasicMethodName
+}
+
+// Verify extracts and validates Basic data (username and password/token) from the
+// "Authorization" header of the request and returns the corresponding user object for that
+// name/token on successful validation.
+// Returns nil if header is empty or validation fails.
+func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ // Basic authentication should only fire on API, Download or on Git or LFSPaths
+ if !middleware.IsAPIPath(req) && !isContainerPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) {
+ return nil, nil
+ }
+
+ baHead := req.Header.Get("Authorization")
+ if len(baHead) == 0 {
+ return nil, nil
+ }
+
+ auths := strings.SplitN(baHead, " ", 2)
+ if len(auths) != 2 || (strings.ToLower(auths[0]) != "basic") {
+ return nil, nil
+ }
+
+ uname, passwd, _ := base.BasicAuthDecode(auths[1])
+
+ // Check if username or password is a token
+ isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
+ // Assume username is token
+ authToken := uname
+ if !isUsernameToken {
+ log.Trace("Basic Authorization: Attempting login for: %s", uname)
+ // Assume password is token
+ authToken = passwd
+ } else {
+ log.Trace("Basic Authorization: Attempting login with username as token")
+ }
+
+ // check oauth2 token
+ uid, _ := CheckOAuthAccessToken(req.Context(), authToken)
+ if uid != 0 {
+ log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
+
+ u, err := user_model.GetUserByID(req.Context(), uid)
+ if err != nil {
+ log.Error("GetUserByID: %v", err)
+ return nil, err
+ }
+
+ store.GetData()["IsApiToken"] = true
+ return u, nil
+ }
+
+ // check personal access token
+ token, err := auth_model.GetAccessTokenBySHA(req.Context(), authToken)
+ if err == nil {
+ log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid)
+ u, err := user_model.GetUserByID(req.Context(), token.UID)
+ if err != nil {
+ log.Error("GetUserByID: %v", err)
+ return nil, err
+ }
+
+ token.UpdatedUnix = timeutil.TimeStampNow()
+ if err = auth_model.UpdateAccessToken(req.Context(), token); err != nil {
+ log.Error("UpdateAccessToken: %v", err)
+ }
+
+ store.GetData()["IsApiToken"] = true
+ store.GetData()["ApiTokenScope"] = token.Scope
+ return u, nil
+ } else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
+ log.Error("GetAccessTokenBySha: %v", err)
+ }
+
+ // check task token
+ task, err := actions_model.GetRunningTaskByToken(req.Context(), authToken)
+ if err == nil && task != nil {
+ log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
+
+ store.GetData()["IsActionsToken"] = true
+ store.GetData()["ActionsTaskID"] = task.ID
+
+ return user_model.NewActionsUser(), nil
+ }
+
+ if !setting.Service.EnableBasicAuth {
+ return nil, nil
+ }
+
+ log.Trace("Basic Authorization: Attempting SignIn for %s", uname)
+ u, source, err := UserSignIn(req.Context(), uname, passwd)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("UserSignIn: %v", err)
+ }
+ return nil, err
+ }
+
+ if skipper, ok := source.Cfg.(LocalTwoFASkipper); !ok || !skipper.IsSkipLocalTwoFA() {
+ if err := validateTOTP(req, u); err != nil {
+ return nil, err
+ }
+ }
+
+ log.Trace("Basic Authorization: Logged in user %-v", u)
+
+ return u, nil
+}
+
+func getOtpHeader(header http.Header) string {
+ otpHeader := header.Get("X-Gitea-OTP")
+ if forgejoHeader := header.Get("X-Forgejo-OTP"); forgejoHeader != "" {
+ otpHeader = forgejoHeader
+ }
+ return otpHeader
+}
+
+func validateTOTP(req *http.Request, u *user_model.User) error {
+ twofa, err := auth_model.GetTwoFactorByUID(req.Context(), u.ID)
+ if err != nil {
+ if auth_model.IsErrTwoFactorNotEnrolled(err) {
+ // No 2FA enrollment for this user
+ return nil
+ }
+ return err
+ }
+ if ok, err := twofa.ValidateTOTP(getOtpHeader(req.Header)); err != nil {
+ return err
+ } else if !ok {
+ return util.NewInvalidArgumentErrorf("invalid provided OTP")
+ }
+ return nil
+}
diff --git a/services/auth/group.go b/services/auth/group.go
new file mode 100644
index 0000000..aecf43c
--- /dev/null
+++ b/services/auth/group.go
@@ -0,0 +1,72 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &Group{}
+)
+
+// Group implements the Auth interface with serval Auth.
+type Group struct {
+ methods []Method
+}
+
+// NewGroup creates a new auth group
+func NewGroup(methods ...Method) *Group {
+ return &Group{
+ methods: methods,
+ }
+}
+
+// Add adds a new method to group
+func (b *Group) Add(method Method) {
+ b.methods = append(b.methods, method)
+}
+
+// Name returns group's methods name
+func (b *Group) Name() string {
+ names := make([]string, 0, len(b.methods))
+ for _, m := range b.methods {
+ names = append(names, m.Name())
+ }
+ return strings.Join(names, ",")
+}
+
+func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ // Try to sign in with each of the enabled plugins
+ var retErr error
+ for _, m := range b.methods {
+ user, err := m.Verify(req, w, store, sess)
+ if err != nil {
+ if retErr == nil {
+ retErr = err
+ }
+ // Try other methods if this one failed.
+ // Some methods may share the same protocol to detect if they are matched.
+ // For example, OAuth2 and conan.Auth both read token from "Authorization: Bearer <token>" header,
+ // If OAuth2 returns error, we should give conan.Auth a chance to try.
+ continue
+ }
+
+ // If any method returns a user, we can stop trying.
+ // Return the user and ignore any error returned by previous methods.
+ if user != nil {
+ if store.GetData()["AuthedMethod"] == nil {
+ store.GetData()["AuthedMethod"] = m.Name()
+ }
+ return user, nil
+ }
+ }
+
+ // If no method returns a user, return the error returned by the first method.
+ return nil, retErr
+}
diff --git a/services/auth/httpsign.go b/services/auth/httpsign.go
new file mode 100644
index 0000000..b604349
--- /dev/null
+++ b/services/auth/httpsign.go
@@ -0,0 +1,218 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "bytes"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/go-fed/httpsig"
+ "golang.org/x/crypto/ssh"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &HTTPSign{}
+)
+
+// HTTPSign implements the Auth interface and authenticates requests (API requests
+// only) by looking for http signature data in the "Signature" header.
+// more information can be found on https://github.com/go-fed/httpsig
+type HTTPSign struct{}
+
+// Name represents the name of auth method
+func (h *HTTPSign) Name() string {
+ return "httpsign"
+}
+
+// Verify extracts and validates HTTPsign from the Signature header of the request and returns
+// the corresponding user object on successful validation.
+// Returns nil if header is empty or validation fails.
+func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ sigHead := req.Header.Get("Signature")
+ if len(sigHead) == 0 {
+ return nil, nil
+ }
+
+ var (
+ publicKey *asymkey_model.PublicKey
+ err error
+ )
+
+ if len(req.Header.Get("X-Ssh-Certificate")) != 0 {
+ // Handle Signature signed by SSH certificates
+ if len(setting.SSH.TrustedUserCAKeys) == 0 {
+ return nil, nil
+ }
+
+ publicKey, err = VerifyCert(req)
+ if err != nil {
+ log.Debug("VerifyCert on request from %s: failed: %v", req.RemoteAddr, err)
+ log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
+ return nil, nil
+ }
+ } else {
+ // Handle Signature signed by Public Key
+ publicKey, err = VerifyPubKey(req)
+ if err != nil {
+ log.Debug("VerifyPubKey on request from %s: failed: %v", req.RemoteAddr, err)
+ log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
+ return nil, nil
+ }
+ }
+
+ u, err := user_model.GetUserByID(req.Context(), publicKey.OwnerID)
+ if err != nil {
+ log.Error("GetUserByID: %v", err)
+ return nil, err
+ }
+
+ store.GetData()["IsApiToken"] = true
+
+ log.Trace("HTTP Sign: Logged in user %-v", u)
+
+ return u, nil
+}
+
+func VerifyPubKey(r *http.Request) (*asymkey_model.PublicKey, error) {
+ verifier, err := httpsig.NewVerifier(r)
+ if err != nil {
+ return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
+ }
+
+ keyID := verifier.KeyId()
+
+ publicKeys, err := db.Find[asymkey_model.PublicKey](r.Context(), asymkey_model.FindPublicKeyOptions{
+ Fingerprint: keyID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if len(publicKeys) == 0 {
+ return nil, fmt.Errorf("no public key found for keyid %s", keyID)
+ }
+
+ sshPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeys[0].Content))
+ if err != nil {
+ return nil, err
+ }
+
+ if err := doVerify(verifier, []ssh.PublicKey{sshPublicKey}); err != nil {
+ return nil, err
+ }
+
+ return publicKeys[0], nil
+}
+
+// VerifyCert verifies the validity of the ssh certificate and returns the publickey of the signer
+// We verify that the certificate is signed with the correct CA
+// We verify that the http request is signed with the private key (of the public key mentioned in the certificate)
+func VerifyCert(r *http.Request) (*asymkey_model.PublicKey, error) {
+ // Get our certificate from the header
+ bcert, err := base64.RawStdEncoding.DecodeString(r.Header.Get("x-ssh-certificate"))
+ if err != nil {
+ return nil, err
+ }
+
+ pk, err := ssh.ParsePublicKey(bcert)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if it's really a ssh certificate
+ cert, ok := pk.(*ssh.Certificate)
+ if !ok {
+ return nil, fmt.Errorf("no certificate found")
+ }
+
+ c := &ssh.CertChecker{
+ IsUserAuthority: func(auth ssh.PublicKey) bool {
+ marshaled := auth.Marshal()
+
+ for _, k := range setting.SSH.TrustedUserCAKeysParsed {
+ if bytes.Equal(marshaled, k.Marshal()) {
+ return true
+ }
+ }
+
+ return false
+ },
+ }
+
+ // check the CA of the cert
+ if !c.IsUserAuthority(cert.SignatureKey) {
+ return nil, fmt.Errorf("CA check failed")
+ }
+
+ // Create a verifier
+ verifier, err := httpsig.NewVerifier(r)
+ if err != nil {
+ return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
+ }
+
+ // now verify that this request was signed with the private key that matches the certificate public key
+ if err := doVerify(verifier, []ssh.PublicKey{cert.Key}); err != nil {
+ return nil, err
+ }
+
+ // Now for each of the certificate valid principals
+ for _, principal := range cert.ValidPrincipals {
+ // Look in the db for the public key
+ publicKey, err := asymkey_model.SearchPublicKeyByContentExact(r.Context(), principal)
+ if asymkey_model.IsErrKeyNotExist(err) {
+ // No public key matches this principal - try the next principal
+ continue
+ } else if err != nil {
+ // this error will be a db error therefore we can't solve this and we should abort
+ log.Error("SearchPublicKeyByContentExact: %v", err)
+ return nil, err
+ }
+
+ // Validate the cert for this principal
+ if err := c.CheckCert(principal, cert); err != nil {
+ // however, because principal is a member of ValidPrincipals - if this fails then the certificate itself is invalid
+ return nil, err
+ }
+
+ // OK we have a public key for a principal matching a valid certificate whose key has signed this request.
+ return publicKey, nil
+ }
+
+ // No public key matching a principal in the certificate is registered in gitea
+ return nil, fmt.Errorf("no valid principal found")
+}
+
+// doVerify iterates across the provided public keys attempting the verify the current request against each key in turn
+func doVerify(verifier httpsig.Verifier, sshPublicKeys []ssh.PublicKey) error {
+ for _, publicKey := range sshPublicKeys {
+ cryptoPubkey := publicKey.(ssh.CryptoPublicKey).CryptoPublicKey()
+
+ var algos []httpsig.Algorithm
+
+ switch {
+ case strings.HasPrefix(publicKey.Type(), "ssh-ed25519"):
+ algos = []httpsig.Algorithm{httpsig.ED25519}
+ case strings.HasPrefix(publicKey.Type(), "ssh-rsa"):
+ algos = []httpsig.Algorithm{httpsig.RSA_SHA1, httpsig.RSA_SHA256, httpsig.RSA_SHA512}
+ }
+ for _, algo := range algos {
+ if err := verifier.Verify(cryptoPubkey, algo); err == nil {
+ return nil
+ }
+ }
+ }
+
+ return errors.New("verification failed")
+}
diff --git a/services/auth/interface.go b/services/auth/interface.go
new file mode 100644
index 0000000..ece28af
--- /dev/null
+++ b/services/auth/interface.go
@@ -0,0 +1,47 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/session"
+ "code.gitea.io/gitea/modules/web/middleware"
+)
+
+// DataStore represents a data store
+type DataStore middleware.ContextDataStore
+
+// SessionStore represents a session store
+type SessionStore session.Store
+
+// Method represents an authentication method (plugin) for HTTP requests.
+type Method interface {
+ // Verify tries to verify the authentication data contained in the request.
+ // If verification is successful returns either an existing user object (with id > 0)
+ // or a new user object (with id = 0) populated with the information that was found
+ // in the authentication data (username or email).
+ // Second argument returns err if verification fails, otherwise
+ // First return argument returns nil if no matched verification condition
+ Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error)
+
+ Name() string
+}
+
+// PasswordAuthenticator represents a source of authentication
+type PasswordAuthenticator interface {
+ Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error)
+}
+
+// LocalTwoFASkipper represents a source of authentication that can skip local 2fa
+type LocalTwoFASkipper interface {
+ IsSkipLocalTwoFA() bool
+}
+
+// SynchronizableSource represents a source that can synchronize users
+type SynchronizableSource interface {
+ Sync(ctx context.Context, updateExisting bool) error
+}
diff --git a/services/auth/main_test.go b/services/auth/main_test.go
new file mode 100644
index 0000000..b81c39a
--- /dev/null
+++ b/services/auth/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go
new file mode 100644
index 0000000..6a63c62
--- /dev/null
+++ b/services/auth/oauth2.go
@@ -0,0 +1,219 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &OAuth2{}
+)
+
+// grantAdditionalScopes returns valid scopes coming from grant
+func grantAdditionalScopes(grantScopes string) string {
+ // scopes_supported from templates/user/auth/oidc_wellknown.tmpl
+ scopesSupported := []string{
+ "openid",
+ "profile",
+ "email",
+ "groups",
+ }
+
+ var apiTokenScopes []string
+ for _, apiTokenScope := range strings.Split(grantScopes, " ") {
+ if slices.Index(scopesSupported, apiTokenScope) == -1 {
+ apiTokenScopes = append(apiTokenScopes, apiTokenScope)
+ }
+ }
+
+ if len(apiTokenScopes) == 0 {
+ return ""
+ }
+
+ var additionalGrantScopes []string
+ allScopes := auth_model.AccessTokenScope("all")
+
+ for _, apiTokenScope := range apiTokenScopes {
+ grantScope := auth_model.AccessTokenScope(apiTokenScope)
+ if ok, _ := allScopes.HasScope(grantScope); ok {
+ additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
+ } else if apiTokenScope == "public-only" {
+ additionalGrantScopes = append(additionalGrantScopes, apiTokenScope)
+ }
+ }
+ if len(additionalGrantScopes) > 0 {
+ return strings.Join(additionalGrantScopes, ",")
+ }
+
+ return ""
+}
+
+// CheckOAuthAccessToken returns uid of user from oauth token
+// + non default openid scopes requested
+func CheckOAuthAccessToken(ctx context.Context, accessToken string) (int64, string) {
+ // JWT tokens require a "."
+ if !strings.Contains(accessToken, ".") {
+ return 0, ""
+ }
+ token, err := oauth2.ParseToken(accessToken, oauth2.DefaultSigningKey)
+ if err != nil {
+ log.Trace("oauth2.ParseToken: %v", err)
+ return 0, ""
+ }
+ var grant *auth_model.OAuth2Grant
+ if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
+ return 0, ""
+ }
+ if token.Type != oauth2.TypeAccessToken {
+ return 0, ""
+ }
+ if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
+ return 0, ""
+ }
+ grantScopes := grantAdditionalScopes(grant.Scope)
+ return grant.UserID, grantScopes
+}
+
+// OAuth2 implements the Auth interface and authenticates requests
+// (API requests only) by looking for an OAuth token in query parameters or the
+// "Authorization" header.
+type OAuth2 struct{}
+
+// Name represents the name of auth method
+func (o *OAuth2) Name() string {
+ return "oauth2"
+}
+
+// parseToken returns the token from request, and a boolean value
+// representing whether the token exists or not
+func parseToken(req *http.Request) (string, bool) {
+ _ = req.ParseForm()
+ if !setting.DisableQueryAuthToken {
+ // Check token.
+ if token := req.Form.Get("token"); token != "" {
+ return token, true
+ }
+ // Check access token.
+ if token := req.Form.Get("access_token"); token != "" {
+ return token, true
+ }
+ } else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" {
+ log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true")
+ }
+
+ // check header token
+ if auHead := req.Header.Get("Authorization"); auHead != "" {
+ auths := strings.Fields(auHead)
+ if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+ return auths[1], true
+ }
+ }
+ return "", false
+}
+
+// userIDFromToken returns the user id corresponding to the OAuth token.
+// It will set 'IsApiToken' to true if the token is an API token and
+// set 'ApiTokenScope' to the scope of the access token
+func (o *OAuth2) userIDFromToken(ctx context.Context, tokenSHA string, store DataStore) int64 {
+ // Let's see if token is valid.
+ if strings.Contains(tokenSHA, ".") {
+ uid, grantScopes := CheckOAuthAccessToken(ctx, tokenSHA)
+
+ if uid != 0 {
+ store.GetData()["IsApiToken"] = true
+ if grantScopes != "" {
+ store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScope(grantScopes)
+ } else {
+ store.GetData()["ApiTokenScope"] = auth_model.AccessTokenScopeAll // fallback to all
+ }
+ }
+ return uid
+ }
+ t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
+ if err != nil {
+ if auth_model.IsErrAccessTokenNotExist(err) {
+ // check task token
+ task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA)
+ if err == nil && task != nil {
+ log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
+
+ store.GetData()["IsActionsToken"] = true
+ store.GetData()["ActionsTaskID"] = task.ID
+
+ return user_model.ActionsUserID
+ }
+ } else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
+ log.Error("GetAccessTokenBySHA: %v", err)
+ }
+ return 0
+ }
+ t.UpdatedUnix = timeutil.TimeStampNow()
+ if err = auth_model.UpdateAccessToken(ctx, t); err != nil {
+ log.Error("UpdateAccessToken: %v", err)
+ }
+ store.GetData()["IsApiToken"] = true
+ store.GetData()["ApiTokenScope"] = t.Scope
+ return t.UID
+}
+
+// Verify extracts the user ID from the OAuth token in the query parameters
+// or the "Authorization" header and returns the corresponding user object for that ID.
+// If verification is successful returns an existing user object.
+// Returns nil if verification fails.
+func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ // These paths are not API paths, but we still want to check for tokens because they maybe in the API returned URLs
+ if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isAuthenticatedTokenRequest(req) &&
+ !isGitRawOrAttachPath(req) && !isArchivePath(req) {
+ return nil, nil
+ }
+
+ token, ok := parseToken(req)
+ if !ok {
+ return nil, nil
+ }
+
+ id := o.userIDFromToken(req.Context(), token, store)
+
+ if id <= 0 && id != -2 { // -2 means actions, so we need to allow it.
+ return nil, user_model.ErrUserNotExist{}
+ }
+ log.Trace("OAuth2 Authorization: Found token for user[%d]", id)
+
+ user, err := user_model.GetPossibleUserByID(req.Context(), id)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByName: %v", err)
+ }
+ return nil, err
+ }
+
+ log.Trace("OAuth2 Authorization: Logged in user %-v", user)
+ return user, nil
+}
+
+func isAuthenticatedTokenRequest(req *http.Request) bool {
+ switch req.URL.Path {
+ case "/login/oauth/userinfo":
+ fallthrough
+ case "/login/oauth/introspect":
+ return true
+ }
+ return false
+}
diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go
new file mode 100644
index 0000000..8a5a5dc
--- /dev/null
+++ b/services/auth/reverseproxy.go
@@ -0,0 +1,179 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "strings"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web/middleware"
+
+ gouuid "github.com/google/uuid"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &ReverseProxy{}
+)
+
+// ReverseProxyMethodName is the constant name of the ReverseProxy authentication method
+const ReverseProxyMethodName = "reverse_proxy"
+
+// ReverseProxy implements the Auth interface, but actually relies on
+// a reverse proxy for authentication of users.
+// On successful authentication the proxy is expected to populate the username in the
+// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the
+// user in the "setting.ReverseProxyAuthEmail" header.
+type ReverseProxy struct{}
+
+// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header
+func (r *ReverseProxy) getUserName(req *http.Request) string {
+ return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthUser))
+}
+
+// Name represents the name of auth method
+func (r *ReverseProxy) Name() string {
+ return ReverseProxyMethodName
+}
+
+// getUserFromAuthUser extracts the username from the "setting.ReverseProxyAuthUser" header
+// of the request and returns the corresponding user object for that name.
+// Verification of header data is not performed as it should have already been done by
+// the reverse proxy.
+// If a username is available in the "setting.ReverseProxyAuthUser" header an existing
+// user object is returned (populated with username or email found in header).
+// Returns nil if header is empty.
+func (r *ReverseProxy) getUserFromAuthUser(req *http.Request) (*user_model.User, error) {
+ username := r.getUserName(req)
+ if len(username) == 0 {
+ return nil, nil
+ }
+ log.Trace("ReverseProxy Authorization: Found username: %s", username)
+
+ user, err := user_model.GetUserByName(req.Context(), username)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) || !r.isAutoRegisterAllowed() {
+ log.Error("GetUserByName: %v", err)
+ return nil, err
+ }
+ user = r.newUser(req)
+ }
+ return user, nil
+}
+
+// getEmail extracts the email from the "setting.ReverseProxyAuthEmail" header
+func (r *ReverseProxy) getEmail(req *http.Request) string {
+ return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthEmail))
+}
+
+// getUserFromAuthEmail extracts the username from the "setting.ReverseProxyAuthEmail" header
+// of the request and returns the corresponding user object for that email.
+// Verification of header data is not performed as it should have already been done by
+// the reverse proxy.
+// If an email is available in the "setting.ReverseProxyAuthEmail" header an existing
+// user object is returned (populated with the email found in header).
+// Returns nil if header is empty or if "setting.EnableReverseProxyEmail" is disabled.
+func (r *ReverseProxy) getUserFromAuthEmail(req *http.Request) *user_model.User {
+ if !setting.Service.EnableReverseProxyEmail {
+ return nil
+ }
+ email := r.getEmail(req)
+ if len(email) == 0 {
+ return nil
+ }
+ log.Trace("ReverseProxy Authorization: Found email: %s", email)
+
+ user, err := user_model.GetUserByEmail(req.Context(), email)
+ if err != nil {
+ // Do not allow auto-registration, we don't have a username here
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByEmail: %v", err)
+ }
+ return nil
+ }
+ return user
+}
+
+// Verify attempts to load a user object based on headers sent by the reverse proxy.
+// First it will attempt to load it based on the username (see docs for getUserFromAuthUser),
+// and failing that it will attempt to load it based on the email (see docs for getUserFromAuthEmail).
+// Returns nil if the headers are empty or the user is not found.
+func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ user, err := r.getUserFromAuthUser(req)
+ if err != nil {
+ return nil, err
+ }
+ if user == nil {
+ user = r.getUserFromAuthEmail(req)
+ if user == nil {
+ return nil, nil
+ }
+ }
+
+ // Make sure requests to API paths, attachment downloads, git and LFS do not create a new session
+ if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) && !isGitRawOrAttachOrLFSPath(req) {
+ if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
+ handleSignIn(w, req, sess, user)
+ }
+ }
+ store.GetData()["IsReverseProxy"] = true
+
+ log.Trace("ReverseProxy Authorization: Logged in user %-v", user)
+ return user, nil
+}
+
+// isAutoRegisterAllowed checks if EnableReverseProxyAutoRegister setting is true
+func (r *ReverseProxy) isAutoRegisterAllowed() bool {
+ return setting.Service.EnableReverseProxyAutoRegister
+}
+
+// newUser creates a new user object for the purpose of automatic registration
+// and populates its name and email with the information present in request headers.
+func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
+ username := r.getUserName(req)
+ if len(username) == 0 {
+ return nil
+ }
+
+ email := gouuid.New().String() + "@localhost"
+ if setting.Service.EnableReverseProxyEmail {
+ webAuthEmail := req.Header.Get(setting.ReverseProxyAuthEmail)
+ if len(webAuthEmail) > 0 {
+ email = webAuthEmail
+ }
+ }
+
+ var fullname string
+ if setting.Service.EnableReverseProxyFullName {
+ fullname = req.Header.Get(setting.ReverseProxyAuthFullName)
+ }
+
+ user := &user_model.User{
+ Name: username,
+ Email: email,
+ FullName: fullname,
+ }
+
+ overwriteDefault := user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(true),
+ }
+
+ // The first user created should be an admin.
+ if user_model.CountUsers(req.Context(), nil) == 0 {
+ user.IsAdmin = true
+ }
+
+ if err := user_model.CreateUser(req.Context(), user, &overwriteDefault); err != nil {
+ // FIXME: should I create a system notice?
+ log.Error("CreateUser: %v", err)
+ return nil
+ }
+
+ return user
+}
diff --git a/services/auth/reverseproxy_test.go b/services/auth/reverseproxy_test.go
new file mode 100644
index 0000000..7f1b2a7
--- /dev/null
+++ b/services/auth/reverseproxy_test.go
@@ -0,0 +1,67 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+ "testing"
+
+ "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/test"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestReverseProxyAuth(t *testing.T) {
+ defer test.MockVariableValue(&setting.Service.EnableReverseProxyEmail, true)()
+ defer test.MockVariableValue(&setting.Service.EnableReverseProxyFullName, true)()
+ defer test.MockVariableValue(&setting.Service.EnableReverseProxyFullName, true)()
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{}))
+ require.EqualValues(t, 0, user_model.CountUsers(db.DefaultContext, nil))
+
+ t.Run("First user should be admin", func(t *testing.T) {
+ req, err := http.NewRequest("GET", "/", nil)
+ require.NoError(t, err)
+
+ req.Header.Add(setting.ReverseProxyAuthUser, "Edgar")
+ req.Header.Add(setting.ReverseProxyAuthFullName, "Edgar Allan Poe")
+ req.Header.Add(setting.ReverseProxyAuthEmail, "edgar@example.org")
+
+ rp := &ReverseProxy{}
+ user := rp.newUser(req)
+
+ require.EqualValues(t, 1, user_model.CountUsers(db.DefaultContext, nil))
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{Email: "edgar@example.org", Name: "Edgar", LowerName: "edgar", FullName: "Edgar Allan Poe", IsAdmin: true})
+ require.EqualValues(t, "edgar@example.org", user.Email)
+ require.EqualValues(t, "Edgar", user.Name)
+ require.EqualValues(t, "edgar", user.LowerName)
+ require.EqualValues(t, "Edgar Allan Poe", user.FullName)
+ require.True(t, user.IsAdmin)
+ })
+
+ t.Run("Second user shouldn't be admin", func(t *testing.T) {
+ req, err := http.NewRequest("GET", "/", nil)
+ require.NoError(t, err)
+
+ req.Header.Add(setting.ReverseProxyAuthUser, " Gusted ")
+ req.Header.Add(setting.ReverseProxyAuthFullName, "❤‿❤")
+ req.Header.Add(setting.ReverseProxyAuthEmail, "gusted@example.org")
+
+ rp := &ReverseProxy{}
+ user := rp.newUser(req)
+
+ require.EqualValues(t, 2, user_model.CountUsers(db.DefaultContext, nil))
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{Email: "gusted@example.org", Name: "Gusted", LowerName: "gusted", FullName: "❤‿❤"}, "is_admin = false")
+ require.EqualValues(t, "gusted@example.org", user.Email)
+ require.EqualValues(t, "Gusted", user.Name)
+ require.EqualValues(t, "gusted", user.LowerName)
+ require.EqualValues(t, "❤‿❤", user.FullName)
+ require.False(t, user.IsAdmin)
+ })
+}
diff --git a/services/auth/session.go b/services/auth/session.go
new file mode 100644
index 0000000..35d97e4
--- /dev/null
+++ b/services/auth/session.go
@@ -0,0 +1,60 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "net/http"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// Ensure the struct implements the interface.
+var (
+ _ Method = &Session{}
+)
+
+// Session checks if there is a user uid stored in the session and returns the user
+// object for that uid.
+type Session struct{}
+
+// Name represents the name of auth method
+func (s *Session) Name() string {
+ return "session"
+}
+
+// Verify checks if there is a user uid stored in the session and returns the user
+// object for that uid.
+// Returns nil if there is no user uid stored in the session.
+func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ if sess == nil {
+ return nil, nil
+ }
+
+ // Get user ID
+ uid := sess.Get("uid")
+ if uid == nil {
+ return nil, nil
+ }
+ log.Trace("Session Authorization: Found user[%d]", uid)
+
+ id, ok := uid.(int64)
+ if !ok {
+ return nil, nil
+ }
+
+ // Get user object
+ user, err := user_model.GetUserByID(req.Context(), id)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByID: %v", err)
+ // Return the err as-is to keep current signed-in session, in case the err is something like context.Canceled. Otherwise non-existing user (nil, nil) will make the caller clear the signed-in session.
+ return nil, err
+ }
+ return nil, nil
+ }
+
+ log.Trace("Session Authorization: Logged in user %-v", user)
+ return user, nil
+}
diff --git a/services/auth/signin.go b/services/auth/signin.go
new file mode 100644
index 0000000..e116a08
--- /dev/null
+++ b/services/auth/signin.go
@@ -0,0 +1,128 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+ "code.gitea.io/gitea/services/auth/source/smtp"
+
+ _ "code.gitea.io/gitea/services/auth/source/db" // register the sources (and below)
+ _ "code.gitea.io/gitea/services/auth/source/ldap" // register the ldap source
+ _ "code.gitea.io/gitea/services/auth/source/pam" // register the pam source
+ _ "code.gitea.io/gitea/services/auth/source/sspi" // register the sspi source
+)
+
+// UserSignIn validates user name and password.
+func UserSignIn(ctx context.Context, username, password string) (*user_model.User, *auth.Source, error) {
+ var user *user_model.User
+ isEmail := false
+ if strings.Contains(username, "@") {
+ isEmail = true
+ emailAddress := user_model.EmailAddress{LowerEmail: strings.ToLower(strings.TrimSpace(username))}
+ // check same email
+ has, err := db.GetEngine(ctx).Get(&emailAddress)
+ if err != nil {
+ return nil, nil, err
+ }
+ if has {
+ if !emailAddress.IsActivated {
+ return nil, nil, user_model.ErrEmailAddressNotExist{
+ Email: username,
+ }
+ }
+ user = &user_model.User{ID: emailAddress.UID}
+ }
+ } else {
+ trimmedUsername := strings.TrimSpace(username)
+ if len(trimmedUsername) == 0 {
+ return nil, nil, user_model.ErrUserNotExist{Name: username}
+ }
+
+ user = &user_model.User{LowerName: strings.ToLower(trimmedUsername)}
+ }
+
+ if user != nil {
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if hasUser {
+ source, err := auth.GetSourceByID(ctx, user.LoginSource)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if !source.IsActive {
+ return nil, nil, oauth2.ErrAuthSourceNotActivated
+ }
+
+ authenticator, ok := source.Cfg.(PasswordAuthenticator)
+ if !ok {
+ return nil, nil, smtp.ErrUnsupportedLoginType
+ }
+
+ user, err := authenticator.Authenticate(ctx, user, user.LoginName, password)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // WARN: DON'T check user.IsActive, that will be checked on reqSign so that
+ // user could be hint to resend confirm email.
+ if user.ProhibitLogin {
+ return nil, nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name}
+ }
+
+ return user, source, nil
+ }
+ }
+
+ sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: optional.Some(true),
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+
+ for _, source := range sources {
+ if !source.IsActive {
+ // don't try to authenticate non-active sources
+ continue
+ }
+
+ authenticator, ok := source.Cfg.(PasswordAuthenticator)
+ if !ok {
+ continue
+ }
+
+ authUser, err := authenticator.Authenticate(ctx, nil, username, password)
+
+ if err == nil {
+ if !authUser.ProhibitLogin {
+ return authUser, source, nil
+ }
+ err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name}
+ }
+
+ if user_model.IsErrUserNotExist(err) {
+ log.Debug("Failed to login '%s' via '%s': %v", username, source.Name, err)
+ } else {
+ log.Warn("Failed to login '%s' via '%s': %v", username, source.Name, err)
+ }
+ }
+
+ if isEmail {
+ return nil, nil, user_model.ErrEmailAddressNotExist{Email: username}
+ }
+
+ return nil, nil, user_model.ErrUserNotExist{Name: username}
+}
diff --git a/services/auth/source.go b/services/auth/source.go
new file mode 100644
index 0000000..69b71a6
--- /dev/null
+++ b/services/auth/source.go
@@ -0,0 +1,42 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// DeleteSource deletes a AuthSource record in DB.
+func DeleteSource(ctx context.Context, source *auth.Source) error {
+ count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
+ if err != nil {
+ return err
+ } else if count > 0 {
+ return auth.ErrSourceInUse{
+ ID: source.ID,
+ }
+ }
+
+ count, err = db.GetEngine(ctx).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID})
+ if err != nil {
+ return err
+ } else if count > 0 {
+ return auth.ErrSourceInUse{
+ ID: source.ID,
+ }
+ }
+
+ if registerableSource, ok := source.Cfg.(auth.RegisterableSource); ok {
+ if err := registerableSource.UnregisterSource(); err != nil {
+ return err
+ }
+ }
+
+ _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
+ return err
+}
diff --git a/services/auth/source/db/assert_interface_test.go b/services/auth/source/db/assert_interface_test.go
new file mode 100644
index 0000000..62387c7
--- /dev/null
+++ b/services/auth/source/db/assert_interface_test.go
@@ -0,0 +1,20 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/db"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth.PasswordAuthenticator
+ auth_model.Config
+}
+
+var _ (sourceInterface) = &db.Source{}
diff --git a/services/auth/source/db/authenticate.go b/services/auth/source/db/authenticate.go
new file mode 100644
index 0000000..8160141
--- /dev/null
+++ b/services/auth/source/db/authenticate.go
@@ -0,0 +1,87 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db
+
+import (
+ "context"
+ "fmt"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// ErrUserPasswordNotSet represents a "ErrUserPasswordNotSet" kind of error.
+type ErrUserPasswordNotSet struct {
+ UID int64
+ Name string
+}
+
+func (err ErrUserPasswordNotSet) Error() string {
+ return fmt.Sprintf("user's password isn't set [uid: %d, name: %s]", err.UID, err.Name)
+}
+
+// Unwrap unwraps this error as a ErrInvalidArgument error
+func (err ErrUserPasswordNotSet) Unwrap() error {
+ return util.ErrInvalidArgument
+}
+
+// ErrUserPasswordInvalid represents a "ErrUserPasswordInvalid" kind of error.
+type ErrUserPasswordInvalid struct {
+ UID int64
+ Name string
+}
+
+func (err ErrUserPasswordInvalid) Error() string {
+ return fmt.Sprintf("user's password is invalid [uid: %d, name: %s]", err.UID, err.Name)
+}
+
+// Unwrap unwraps this error as a ErrInvalidArgument error
+func (err ErrUserPasswordInvalid) Unwrap() error {
+ return util.ErrInvalidArgument
+}
+
+// Authenticate authenticates the provided user against the DB
+func Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
+ if user == nil {
+ return nil, user_model.ErrUserNotExist{Name: login}
+ }
+
+ if !user.IsPasswordSet() {
+ return nil, ErrUserPasswordNotSet{UID: user.ID, Name: user.Name}
+ } else if !user.ValidatePassword(password) {
+ return nil, ErrUserPasswordInvalid{UID: user.ID, Name: user.Name}
+ }
+
+ // Update password hash if server password hash algorithm have changed
+ // Or update the password when the salt length doesn't match the current
+ // recommended salt length, this in order to migrate user's salts to a more secure salt.
+ if user.PasswdHashAlgo != setting.PasswordHashAlgo || len(user.Salt) != user_model.SaltByteLength*2 {
+ if err := user.SetPassword(password); err != nil {
+ return nil, err
+ }
+ if err := user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
+ return nil, err
+ }
+ }
+
+ // WARN: DON'T check user.IsActive, that will be checked on reqSign so that
+ // user could be hinted to resend confirm email.
+ if user.ProhibitLogin {
+ return nil, user_model.ErrUserProhibitLogin{
+ UID: user.ID,
+ Name: user.Name,
+ }
+ }
+
+ // attempting to login as a non-user account
+ if user.Type != user_model.UserTypeIndividual {
+ return nil, user_model.ErrUserProhibitLogin{
+ UID: user.ID,
+ Name: user.Name,
+ }
+ }
+
+ return user, nil
+}
diff --git a/services/auth/source/db/source.go b/services/auth/source/db/source.go
new file mode 100644
index 0000000..bb2270c
--- /dev/null
+++ b/services/auth/source/db/source.go
@@ -0,0 +1,35 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package db
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+)
+
+// Source is a password authentication service
+type Source struct{}
+
+// FromDB fills up an OAuth2Config from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return nil
+}
+
+// ToDB exports the config to a byte slice to be saved into database (this method is just dummy and does nothing for DB source)
+func (source *Source) ToDB() ([]byte, error) {
+ return nil, nil
+}
+
+// Authenticate queries if login/password is valid against the PAM,
+// and create a local user if success when enabled.
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
+ return Authenticate(ctx, user, login, password)
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.NoType, &Source{})
+ auth.RegisterTypeConfig(auth.Plain, &Source{})
+}
diff --git a/services/auth/source/ldap/README.md b/services/auth/source/ldap/README.md
new file mode 100644
index 0000000..34c8117
--- /dev/null
+++ b/services/auth/source/ldap/README.md
@@ -0,0 +1,131 @@
+# Gitea LDAP Authentication Module
+
+## About
+
+This authentication module attempts to authorize and authenticate a user
+against an LDAP server. It provides two methods of authentication: LDAP via
+BindDN, and LDAP simple authentication.
+
+LDAP via BindDN functions like most LDAP authentication systems. First, it
+queries the LDAP server using a Bind DN and searches for the user that is
+attempting to sign in. If the user is found, the module attempts to bind to the
+server using the user's supplied credentials. If this succeeds, the user has
+been authenticated, and his account information is retrieved and passed to the
+Gogs login infrastructure.
+
+LDAP simple authentication does not utilize a Bind DN. Instead, it binds
+directly with the LDAP server using the user's supplied credentials. If the bind
+succeeds and no filter rules out the user, the user is authenticated.
+
+LDAP via BindDN is recommended for most users. By using a Bind DN, the server
+can perform authorization by restricting which entries the Bind DN account can
+read. Further, using a Bind DN with reduced permissions can reduce security risk
+in the face of application bugs.
+
+## Usage
+
+To use this module, add an LDAP authentication source via the Authentications
+section in the admin panel. Both the LDAP via BindDN and the simple auth LDAP
+share the following fields:
+
+* Authorization Name **(required)**
+ * A name to assign to the new method of authorization.
+
+* Host **(required)**
+ * The address where the LDAP server can be reached.
+ * Example: mydomain.com
+
+* Port **(required)**
+ * The port to use when connecting to the server.
+ * Example: 636
+
+* Enable TLS Encryption (optional)
+ * Whether to use TLS when connecting to the LDAP server.
+
+* Admin Filter (optional)
+ * An LDAP filter specifying if a user should be given administrator
+ privileges. If a user accounts passes the filter, the user will be
+ privileged as an administrator.
+ * Example: (objectClass=adminAccount)
+
+* First name attribute (optional)
+ * The attribute of the user's LDAP record containing the user's first name.
+ This will be used to populate their account information.
+ * Example: givenName
+
+* Surname attribute (optional)
+ * The attribute of the user's LDAP record containing the user's surname This
+ will be used to populate their account information.
+ * Example: sn
+
+* E-mail attribute **(required)**
+ * The attribute of the user's LDAP record containing the user's email
+ address. This will be used to populate their account information.
+ * Example: mail
+
+**LDAP via BindDN** adds the following fields:
+
+* Bind DN (optional)
+ * The DN to bind to the LDAP server with when searching for the user. This
+ may be left blank to perform an anonymous search.
+ * Example: cn=Search,dc=mydomain,dc=com
+
+* Bind Password (optional)
+ * The password for the Bind DN specified above, if any. _Note: The password
+ is stored in plaintext at the server. As such, ensure that your Bind DN
+ has as few privileges as possible._
+
+* User Search Base **(required)**
+ * The LDAP base at which user accounts will be searched for.
+ * Example: ou=Users,dc=mydomain,dc=com
+
+* User Filter **(required)**
+ * An LDAP filter declaring how to find the user record that is attempting to
+ authenticate. The '%[1]s' matching parameter will be substituted with the
+ user's username.
+ * Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
+
+**LDAP using simple auth** adds the following fields:
+
+* User DN **(required)**
+ * A template to use as the user's DN. The `%s` matching parameter will be
+ substituted with the user's username.
+ * Example: cn=%s,ou=Users,dc=mydomain,dc=com
+ * Example: uid=%s,ou=Users,dc=mydomain,dc=com
+
+* User Search Base (optional)
+ * The LDAP base at which user accounts will be searched for.
+ * Example: ou=Users,dc=mydomain,dc=com
+
+* User Filter **(required)**
+ * An LDAP filter declaring when a user should be allowed to log in. The `%[1]s`
+ matching parameter will be substituted with the user's username.
+ * Example: (&(objectClass=posixAccount)(|(cn=%[1]s)(mail=%[1]s)))
+ * Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
+
+**Verify group membership in LDAP** uses the following fields:
+
+* Group Search Base (optional)
+ * The LDAP DN used for groups.
+ * Example: ou=group,dc=mydomain,dc=com
+
+* Group Name Filter (optional)
+ * An LDAP filter declaring how to find valid groups in the above DN.
+ * Example: (|(cn=gitea_users)(cn=admins))
+
+* User Attribute in Group (optional)
+ * The user attribute that is used to reference a user in the group object.
+ * Example: uid if the group objects contains a member: bender and the user object contains a uid: bender.
+ * Example: dn if the group object contains a member: uid=bender,ou=users,dc=planetexpress,dc=com.
+
+* Group Attribute for User (optional)
+ * The attribute of the group object that lists/contains the group members.
+ * Example: memberUid or member
+
+* Team group map (optional)
+ * Automatically add users to Organization teams, depending on LDAP group memberships.
+ * Note: this function only adds users to teams, it never removes users.
+ * Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...}
+
+* Team group map removal (optional)
+ * If set to true, users will be removed from teams if they are not members of the corresponding group.
diff --git a/services/auth/source/ldap/assert_interface_test.go b/services/auth/source/ldap/assert_interface_test.go
new file mode 100644
index 0000000..3334768
--- /dev/null
+++ b/services/auth/source/ldap/assert_interface_test.go
@@ -0,0 +1,27 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/ldap"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth.PasswordAuthenticator
+ auth.SynchronizableSource
+ auth.LocalTwoFASkipper
+ auth_model.SSHKeyProvider
+ auth_model.Config
+ auth_model.SkipVerifiable
+ auth_model.HasTLSer
+ auth_model.UseTLSer
+ auth_model.SourceSettable
+}
+
+var _ (sourceInterface) = &ldap.Source{}
diff --git a/services/auth/source/ldap/security_protocol.go b/services/auth/source/ldap/security_protocol.go
new file mode 100644
index 0000000..af83ce1
--- /dev/null
+++ b/services/auth/source/ldap/security_protocol.go
@@ -0,0 +1,31 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+// SecurityProtocol protocol type
+type SecurityProtocol int
+
+// Note: new type must be added at the end of list to maintain compatibility.
+const (
+ SecurityProtocolUnencrypted SecurityProtocol = iota
+ SecurityProtocolLDAPS
+ SecurityProtocolStartTLS
+)
+
+// String returns the name of the SecurityProtocol
+func (s SecurityProtocol) String() string {
+ return SecurityProtocolNames[s]
+}
+
+// Int returns the int value of the SecurityProtocol
+func (s SecurityProtocol) Int() int {
+ return int(s)
+}
+
+// SecurityProtocolNames contains the name of SecurityProtocol values.
+var SecurityProtocolNames = map[SecurityProtocol]string{
+ SecurityProtocolUnencrypted: "Unencrypted",
+ SecurityProtocolLDAPS: "LDAPS",
+ SecurityProtocolStartTLS: "StartTLS",
+}
diff --git a/services/auth/source/ldap/source.go b/services/auth/source/ldap/source.go
new file mode 100644
index 0000000..ba407b3
--- /dev/null
+++ b/services/auth/source/ldap/source.go
@@ -0,0 +1,122 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+import (
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/secret"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+// .____ ________ _____ __________
+// | | \______ \ / _ \\______ \
+// | | | | \ / /_\ \| ___/
+// | |___ | ` \/ | \ |
+// |_______ \/_______ /\____|__ /____|
+// \/ \/ \/
+
+// Package ldap provide functions & structure to query a LDAP ldap directory
+// For now, it's mainly tested again an MS Active Directory service, see README.md for more information
+
+// Source Basic LDAP authentication service
+type Source struct {
+ Name string // canonical name (ie. corporate.ad)
+ Host string // LDAP host
+ Port int // port number
+ SecurityProtocol SecurityProtocol
+ SkipVerify bool
+ BindDN string // DN to bind with
+ BindPasswordEncrypt string // Encrypted Bind BN password
+ BindPassword string // Bind DN password
+ UserBase string // Base search path for users
+ UserDN string // Template for the DN of the user for simple auth
+ DefaultDomainName string // DomainName used if none are in the field, default "localhost.local"
+ AttributeUsername string // Username attribute
+ AttributeName string // First name attribute
+ AttributeSurname string // Surname attribute
+ AttributeMail string // E-mail attribute
+ AttributesInBind bool // fetch attributes in bind context (not user)
+ AttributeSSHPublicKey string // LDAP SSH Public Key attribute
+ AttributeAvatar string
+ SearchPageSize uint32 // Search with paging page size
+ Filter string // Query filter to validate entry
+ AdminFilter string // Query filter to check if user is admin
+ RestrictedFilter string // Query filter to check if user is restricted
+ Enabled bool // if this source is disabled
+ AllowDeactivateAll bool // Allow an empty search response to deactivate all users from this source
+ GroupsEnabled bool // if the group checking is enabled
+ GroupDN string // Group Search Base
+ GroupFilter string // Group Name Filter
+ GroupMemberUID string // Group Attribute containing array of UserUID
+ GroupTeamMap string // Map LDAP groups to teams
+ GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
+ UserUID string // User Attribute listed in Group
+ SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up a LDAPConfig from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ err := json.UnmarshalHandleDoubleEncode(bs, &source)
+ if err != nil {
+ return err
+ }
+ if source.BindPasswordEncrypt != "" {
+ source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt)
+ source.BindPasswordEncrypt = ""
+ }
+ return err
+}
+
+// ToDB exports a LDAPConfig to a serialized format.
+func (source *Source) ToDB() ([]byte, error) {
+ var err error
+ source.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, source.BindPassword)
+ if err != nil {
+ return nil, err
+ }
+ source.BindPassword = ""
+ return json.Marshal(source)
+}
+
+// SecurityProtocolName returns the name of configured security
+// protocol.
+func (source *Source) SecurityProtocolName() string {
+ return SecurityProtocolNames[source.SecurityProtocol]
+}
+
+// IsSkipVerify returns if SkipVerify is set
+func (source *Source) IsSkipVerify() bool {
+ return source.SkipVerify
+}
+
+// HasTLS returns if HasTLS
+func (source *Source) HasTLS() bool {
+ return source.SecurityProtocol > SecurityProtocolUnencrypted
+}
+
+// UseTLS returns if UseTLS
+func (source *Source) UseTLS() bool {
+ return source.SecurityProtocol != SecurityProtocolUnencrypted
+}
+
+// ProvidesSSHKeys returns if this source provides SSH Keys
+func (source *Source) ProvidesSSHKeys() bool {
+ return len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
+}
+
+// SetAuthSource sets the related AuthSource
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.LDAP, &Source{})
+ auth.RegisterTypeConfig(auth.DLDAP, &Source{})
+}
diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go
new file mode 100644
index 0000000..68ecd16
--- /dev/null
+++ b/services/auth/source/ldap/source_authenticate.go
@@ -0,0 +1,124 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ auth_module "code.gitea.io/gitea/modules/auth"
+ "code.gitea.io/gitea/modules/optional"
+ source_service "code.gitea.io/gitea/services/auth/source"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+// Authenticate queries if login/password is valid against the LDAP directory pool,
+// and create a local user if success when enabled.
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
+ loginName := userName
+ if user != nil {
+ loginName = user.LoginName
+ }
+ sr := source.SearchEntry(loginName, password, source.authSource.Type == auth.DLDAP)
+ if sr == nil {
+ // User not in LDAP, do nothing
+ return nil, user_model.ErrUserNotExist{Name: loginName}
+ }
+ // Fallback.
+ if len(sr.Username) == 0 {
+ sr.Username = userName
+ }
+ if len(sr.Mail) == 0 {
+ sr.Mail = fmt.Sprintf("%s@localhost.local", sr.Username)
+ }
+ isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
+
+ // Update User admin flag if exist
+ if isExist, err := user_model.IsUserExist(ctx, 0, sr.Username); err != nil {
+ return nil, err
+ } else if isExist {
+ if user == nil {
+ user, err = user_model.GetUserByName(ctx, sr.Username)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if user != nil && !user.ProhibitLogin {
+ opts := &user_service.UpdateOptions{}
+ if len(source.AdminFilter) > 0 && user.IsAdmin != sr.IsAdmin {
+ // Change existing admin flag only if AdminFilter option is set
+ opts.IsAdmin = optional.Some(sr.IsAdmin)
+ }
+ if !sr.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted {
+ // Change existing restricted flag only if RestrictedFilter option is set
+ opts.IsRestricted = optional.Some(sr.IsRestricted)
+ }
+ if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
+ if err := user_service.UpdateUser(ctx, user, opts); err != nil {
+ return nil, err
+ }
+ }
+ }
+ }
+
+ if user != nil {
+ if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey) {
+ if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil {
+ return user, err
+ }
+ }
+ } else {
+ user = &user_model.User{
+ LowerName: strings.ToLower(sr.Username),
+ Name: sr.Username,
+ FullName: composeFullName(sr.Name, sr.Surname, sr.Username),
+ Email: sr.Mail,
+ LoginType: source.authSource.Type,
+ LoginSource: source.authSource.ID,
+ LoginName: userName,
+ IsAdmin: sr.IsAdmin,
+ }
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsRestricted: optional.Some(sr.IsRestricted),
+ IsActive: optional.Some(true),
+ }
+
+ err := user_model.CreateUser(ctx, user, overwriteDefault)
+ if err != nil {
+ return user, err
+ }
+
+ if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey) {
+ if err := asymkey_model.RewriteAllPublicKeys(ctx); err != nil {
+ return user, err
+ }
+ }
+ if len(source.AttributeAvatar) > 0 {
+ if err := user_service.UploadAvatar(ctx, user, sr.Avatar); err != nil {
+ return user, err
+ }
+ }
+ }
+
+ if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
+ if err != nil {
+ return user, err
+ }
+ if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
+ return user, err
+ }
+ }
+
+ return user, nil
+}
+
+// IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication
+func (source *Source) IsSkipLocalTwoFA() bool {
+ return source.SkipLocalTwoFA
+}
diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go
new file mode 100644
index 0000000..2a61386
--- /dev/null
+++ b/services/auth/source/ldap/source_search.go
@@ -0,0 +1,516 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/go-ldap/ldap/v3"
+)
+
+// SearchResult : user data
+type SearchResult struct {
+ Username string // Username
+ Name string // Name
+ Surname string // Surname
+ Mail string // E-mail address
+ SSHPublicKey []string // SSH Public Key
+ IsAdmin bool // if user is administrator
+ IsRestricted bool // if user is restricted
+ LowerName string // LowerName
+ Avatar []byte
+ Groups container.Set[string]
+}
+
+func (source *Source) sanitizedUserQuery(username string) (string, bool) {
+ // See http://tools.ietf.org/search/rfc4515
+ badCharacters := "\x00()*\\"
+ if strings.ContainsAny(username, badCharacters) {
+ log.Debug("'%s' contains invalid query characters. Aborting.", username)
+ return "", false
+ }
+
+ return fmt.Sprintf(source.Filter, username), true
+}
+
+func (source *Source) sanitizedUserDN(username string) (string, bool) {
+ // See http://tools.ietf.org/search/rfc4514: "special characters"
+ badCharacters := "\x00()*\\,='\"#+;<>"
+ if strings.ContainsAny(username, badCharacters) {
+ log.Debug("'%s' contains invalid DN characters. Aborting.", username)
+ return "", false
+ }
+
+ return fmt.Sprintf(source.UserDN, username), true
+}
+
+func (source *Source) sanitizedGroupFilter(group string) (string, bool) {
+ // See http://tools.ietf.org/search/rfc4515
+ badCharacters := "\x00*\\"
+ if strings.ContainsAny(group, badCharacters) {
+ log.Trace("Group filter invalid query characters: %s", group)
+ return "", false
+ }
+
+ return group, true
+}
+
+func (source *Source) sanitizedGroupDN(groupDn string) (string, bool) {
+ // See http://tools.ietf.org/search/rfc4514: "special characters"
+ badCharacters := "\x00()*\\'\"#+;<>"
+ if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") {
+ log.Trace("Group DN contains invalid query characters: %s", groupDn)
+ return "", false
+ }
+
+ return groupDn, true
+}
+
+func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) {
+ log.Trace("Search for LDAP user: %s", name)
+
+ // A search for the user.
+ userFilter, ok := source.sanitizedUserQuery(name)
+ if !ok {
+ return "", false
+ }
+
+ log.Trace("Searching for DN using filter %s and base %s", userFilter, source.UserBase)
+ search := ldap.NewSearchRequest(
+ source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0,
+ false, userFilter, []string{}, nil)
+
+ // Ensure we found a user
+ sr, err := l.Search(search)
+ if err != nil || len(sr.Entries) < 1 {
+ log.Debug("Failed search using filter[%s]: %v", userFilter, err)
+ return "", false
+ } else if len(sr.Entries) > 1 {
+ log.Debug("Filter '%s' returned more than one user.", userFilter)
+ return "", false
+ }
+
+ userDN := sr.Entries[0].DN
+ if userDN == "" {
+ log.Error("LDAP search was successful, but found no DN!")
+ return "", false
+ }
+
+ return userDN, true
+}
+
+func dial(source *Source) (*ldap.Conn, error) {
+ log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify)
+
+ tlsConfig := &tls.Config{
+ ServerName: source.Host,
+ InsecureSkipVerify: source.SkipVerify,
+ }
+
+ if source.SecurityProtocol == SecurityProtocolLDAPS {
+ return ldap.DialTLS("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)), tlsConfig)
+ }
+
+ conn, err := ldap.Dial("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)))
+ if err != nil {
+ return nil, fmt.Errorf("error during Dial: %w", err)
+ }
+
+ if source.SecurityProtocol == SecurityProtocolStartTLS {
+ if err = conn.StartTLS(tlsConfig); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("error during StartTLS: %w", err)
+ }
+ }
+
+ return conn, nil
+}
+
+func bindUser(l *ldap.Conn, userDN, passwd string) error {
+ log.Trace("Binding with userDN: %s", userDN)
+ err := l.Bind(userDN, passwd)
+ if err != nil {
+ log.Debug("LDAP auth. failed for %s, reason: %v", userDN, err)
+ return err
+ }
+ log.Trace("Bound successfully with userDN: %s", userDN)
+ return err
+}
+
+func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool {
+ if len(ls.AdminFilter) == 0 {
+ return false
+ }
+ log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
+ search := ldap.NewSearchRequest(
+ userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
+ []string{ls.AttributeName},
+ nil)
+
+ sr, err := l.Search(search)
+
+ if err != nil {
+ log.Error("LDAP Admin Search with filter %s for %s failed unexpectedly! (%v)", ls.AdminFilter, userDN, err)
+ } else if len(sr.Entries) < 1 {
+ log.Trace("LDAP Admin Search found no matching entries.")
+ } else {
+ return true
+ }
+ return false
+}
+
+func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool {
+ if len(ls.RestrictedFilter) == 0 {
+ return false
+ }
+ if ls.RestrictedFilter == "*" {
+ return true
+ }
+ log.Trace("Checking restricted with filter %s and base %s", ls.RestrictedFilter, userDN)
+ search := ldap.NewSearchRequest(
+ userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.RestrictedFilter,
+ []string{ls.AttributeName},
+ nil)
+
+ sr, err := l.Search(search)
+
+ if err != nil {
+ log.Error("LDAP Restrictred Search with filter %s for %s failed unexpectedly! (%v)", ls.RestrictedFilter, userDN, err)
+ } else if len(sr.Entries) < 1 {
+ log.Trace("LDAP Restricted Search found no matching entries.")
+ } else {
+ return true
+ }
+ return false
+}
+
+// List all group memberships of a user
+func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] {
+ ldapGroups := make(container.Set[string])
+
+ groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter)
+ if !ok {
+ return ldapGroups
+ }
+
+ groupDN, ok := source.sanitizedGroupDN(source.GroupDN)
+ if !ok {
+ return ldapGroups
+ }
+
+ var searchFilter string
+ if applyGroupFilter && groupFilter != "" {
+ searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid))
+ } else {
+ searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid))
+ }
+ result, err := l.Search(ldap.NewSearchRequest(
+ groupDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0,
+ 0,
+ false,
+ searchFilter,
+ []string{},
+ nil,
+ ))
+ if err != nil {
+ log.Error("Failed group search in LDAP with filter [%s]: %v", searchFilter, err)
+ return ldapGroups
+ }
+
+ for _, entry := range result.Entries {
+ if entry.DN == "" {
+ log.Error("LDAP search was successful, but found no DN!")
+ continue
+ }
+ ldapGroups.Add(entry.DN)
+ }
+
+ return ldapGroups
+}
+
+func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string {
+ if strings.ToLower(source.UserUID) == "dn" {
+ return entry.DN
+ }
+
+ return entry.GetAttributeValue(source.UserUID)
+}
+
+// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
+func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
+ // See https://tools.ietf.org/search/rfc4513#section-5.1.2
+ if len(passwd) == 0 {
+ log.Debug("Auth. failed for %s, password cannot be empty", name)
+ return nil
+ }
+ l, err := dial(source)
+ if err != nil {
+ log.Error("LDAP Connect error, %s:%v", source.Host, err)
+ source.Enabled = false
+ return nil
+ }
+ defer l.Close()
+
+ var userDN string
+ if directBind {
+ log.Trace("LDAP will bind directly via UserDN template: %s", source.UserDN)
+
+ var ok bool
+ userDN, ok = source.sanitizedUserDN(name)
+
+ if !ok {
+ return nil
+ }
+
+ err = bindUser(l, userDN, passwd)
+ if err != nil {
+ return nil
+ }
+
+ if source.UserBase != "" {
+ // not everyone has a CN compatible with input name so we need to find
+ // the real userDN in that case
+
+ userDN, ok = source.findUserDN(l, name)
+ if !ok {
+ return nil
+ }
+ }
+ } else {
+ log.Trace("LDAP will use BindDN.")
+
+ var found bool
+
+ if source.BindDN != "" && source.BindPassword != "" {
+ err := l.Bind(source.BindDN, source.BindPassword)
+ if err != nil {
+ log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
+ return nil
+ }
+ log.Trace("Bound as BindDN %s", source.BindDN)
+ } else {
+ log.Trace("Proceeding with anonymous LDAP search.")
+ }
+
+ userDN, found = source.findUserDN(l, name)
+ if !found {
+ return nil
+ }
+ }
+
+ if !source.AttributesInBind {
+ // binds user (checking password) before looking-up attributes in user context
+ err = bindUser(l, userDN, passwd)
+ if err != nil {
+ return nil
+ }
+ }
+
+ userFilter, ok := source.sanitizedUserQuery(name)
+ if !ok {
+ return nil
+ }
+
+ isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
+ isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0
+
+ attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail}
+ if len(strings.TrimSpace(source.UserUID)) > 0 {
+ attribs = append(attribs, source.UserUID)
+ }
+ if isAttributeSSHPublicKeySet {
+ attribs = append(attribs, source.AttributeSSHPublicKey)
+ }
+ if isAtributeAvatarSet {
+ attribs = append(attribs, source.AttributeAvatar)
+ }
+
+ log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v', '%v' with filter '%s' and base '%s'", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, source.UserUID, userFilter, userDN)
+ search := ldap.NewSearchRequest(
+ userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
+ attribs, nil)
+
+ sr, err := l.Search(search)
+ if err != nil {
+ log.Error("LDAP Search failed unexpectedly! (%v)", err)
+ return nil
+ } else if len(sr.Entries) < 1 {
+ if directBind {
+ log.Trace("User filter inhibited user login.")
+ } else {
+ log.Trace("LDAP Search found no matching entries.")
+ }
+
+ return nil
+ }
+
+ var sshPublicKey []string
+ var Avatar []byte
+
+ username := sr.Entries[0].GetAttributeValue(source.AttributeUsername)
+ firstname := sr.Entries[0].GetAttributeValue(source.AttributeName)
+ surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname)
+ mail := sr.Entries[0].GetAttributeValue(source.AttributeMail)
+
+ if isAttributeSSHPublicKeySet {
+ sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey)
+ }
+
+ isAdmin := checkAdmin(l, source, userDN)
+
+ var isRestricted bool
+ if !isAdmin {
+ isRestricted = checkRestricted(l, source, userDN)
+ }
+
+ if isAtributeAvatarSet {
+ Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar)
+ }
+
+ // Check group membership
+ var usersLdapGroups container.Set[string]
+ if source.GroupsEnabled {
+ userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0])
+ usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
+
+ if source.GroupFilter != "" && len(usersLdapGroups) == 0 {
+ return nil
+ }
+ }
+
+ if !directBind && source.AttributesInBind {
+ // binds user (checking password) after looking-up attributes in BindDN context
+ err = bindUser(l, userDN, passwd)
+ if err != nil {
+ return nil
+ }
+ }
+
+ return &SearchResult{
+ LowerName: strings.ToLower(username),
+ Username: username,
+ Name: firstname,
+ Surname: surname,
+ Mail: mail,
+ SSHPublicKey: sshPublicKey,
+ IsAdmin: isAdmin,
+ IsRestricted: isRestricted,
+ Avatar: Avatar,
+ Groups: usersLdapGroups,
+ }
+}
+
+// UsePagedSearch returns if need to use paged search
+func (source *Source) UsePagedSearch() bool {
+ return source.SearchPageSize > 0
+}
+
+// SearchEntries : search an LDAP source for all users matching userFilter
+func (source *Source) SearchEntries() ([]*SearchResult, error) {
+ l, err := dial(source)
+ if err != nil {
+ log.Error("LDAP Connect error, %s:%v", source.Host, err)
+ source.Enabled = false
+ return nil, err
+ }
+ defer l.Close()
+
+ if source.BindDN != "" && source.BindPassword != "" {
+ err := l.Bind(source.BindDN, source.BindPassword)
+ if err != nil {
+ log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
+ return nil, err
+ }
+ log.Trace("Bound as BindDN %s", source.BindDN)
+ } else {
+ log.Trace("Proceeding with anonymous LDAP search.")
+ }
+
+ userFilter := fmt.Sprintf(source.Filter, "*")
+
+ isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
+ isAtributeAvatarSet := len(strings.TrimSpace(source.AttributeAvatar)) > 0
+
+ attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.UserUID}
+ if isAttributeSSHPublicKeySet {
+ attribs = append(attribs, source.AttributeSSHPublicKey)
+ }
+ if isAtributeAvatarSet {
+ attribs = append(attribs, source.AttributeAvatar)
+ }
+
+ log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v' with filter %s and base %s", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, userFilter, source.UserBase)
+ search := ldap.NewSearchRequest(
+ source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
+ attribs, nil)
+
+ var sr *ldap.SearchResult
+ if source.UsePagedSearch() {
+ sr, err = l.SearchWithPaging(search, source.SearchPageSize)
+ } else {
+ sr, err = l.Search(search)
+ }
+ if err != nil {
+ log.Error("LDAP Search failed unexpectedly! (%v)", err)
+ return nil, err
+ }
+
+ result := make([]*SearchResult, 0, len(sr.Entries))
+
+ for _, v := range sr.Entries {
+ var usersLdapGroups container.Set[string]
+ if source.GroupsEnabled {
+ userAttributeListedInGroup := source.getUserAttributeListedInGroup(v)
+
+ if source.GroupFilter != "" {
+ usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
+ if len(usersLdapGroups) == 0 {
+ continue
+ }
+ }
+
+ if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
+ usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false)
+ }
+ }
+
+ user := &SearchResult{
+ Username: v.GetAttributeValue(source.AttributeUsername),
+ Name: v.GetAttributeValue(source.AttributeName),
+ Surname: v.GetAttributeValue(source.AttributeSurname),
+ Mail: v.GetAttributeValue(source.AttributeMail),
+ IsAdmin: checkAdmin(l, source, v.DN),
+ Groups: usersLdapGroups,
+ }
+
+ if !user.IsAdmin {
+ user.IsRestricted = checkRestricted(l, source, v.DN)
+ }
+
+ if isAttributeSSHPublicKeySet {
+ user.SSHPublicKey = v.GetAttributeValues(source.AttributeSSHPublicKey)
+ }
+
+ if isAtributeAvatarSet {
+ user.Avatar = v.GetRawAttributeValue(source.AttributeAvatar)
+ }
+
+ user.LowerName = strings.ToLower(user.Username)
+
+ result = append(result, user)
+ }
+
+ return result, nil
+}
diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go
new file mode 100644
index 0000000..1f70eda
--- /dev/null
+++ b/services/auth/source/ldap/source_sync.go
@@ -0,0 +1,232 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ user_model "code.gitea.io/gitea/models/user"
+ auth_module "code.gitea.io/gitea/modules/auth"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ source_service "code.gitea.io/gitea/services/auth/source"
+ user_service "code.gitea.io/gitea/services/user"
+)
+
+// Sync causes this ldap source to synchronize its users with the db
+func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
+ log.Trace("Doing: SyncExternalUsers[%s]", source.authSource.Name)
+
+ isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0
+ var sshKeysNeedUpdate bool
+
+ // Find all users with this login type - FIXME: Should this be an iterator?
+ users, err := user_model.GetUsersBySource(ctx, source.authSource)
+ if err != nil {
+ log.Error("SyncExternalUsers: %v", err)
+ return err
+ }
+ select {
+ case <-ctx.Done():
+ log.Warn("SyncExternalUsers: Cancelled before update of %s", source.authSource.Name)
+ return db.ErrCancelledf("Before update of %s", source.authSource.Name)
+ default:
+ }
+
+ usernameUsers := make(map[string]*user_model.User, len(users))
+ mailUsers := make(map[string]*user_model.User, len(users))
+ keepActiveUsers := make(container.Set[int64])
+
+ for _, u := range users {
+ usernameUsers[u.LowerName] = u
+ mailUsers[strings.ToLower(u.Email)] = u
+ }
+
+ sr, err := source.SearchEntries()
+ if err != nil {
+ log.Error("SyncExternalUsers LDAP source failure [%s], skipped", source.authSource.Name)
+ return nil
+ }
+
+ if len(sr) == 0 {
+ if !source.AllowDeactivateAll {
+ log.Error("LDAP search found no entries but did not report an error. Refusing to deactivate all users")
+ return nil
+ }
+ log.Warn("LDAP search found no entries but did not report an error. All users will be deactivated as per settings")
+ }
+
+ orgCache := make(map[string]*organization.Organization)
+ teamCache := make(map[string]*organization.Team)
+
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
+ if err != nil {
+ return err
+ }
+
+ for _, su := range sr {
+ select {
+ case <-ctx.Done():
+ log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.authSource.Name)
+ // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
+ if sshKeysNeedUpdate {
+ err = asymkey_model.RewriteAllPublicKeys(ctx)
+ if err != nil {
+ log.Error("RewriteAllPublicKeys: %v", err)
+ }
+ }
+ return db.ErrCancelledf("During update of %s before completed update of users", source.authSource.Name)
+ default:
+ }
+ if len(su.Username) == 0 && len(su.Mail) == 0 {
+ continue
+ }
+
+ var usr *user_model.User
+ if len(su.Username) > 0 {
+ usr = usernameUsers[su.LowerName]
+ }
+ if usr == nil && len(su.Mail) > 0 {
+ usr = mailUsers[strings.ToLower(su.Mail)]
+ }
+
+ if usr != nil {
+ keepActiveUsers.Add(usr.ID)
+ } else if len(su.Username) == 0 {
+ // we cannot create the user if su.Username is empty
+ continue
+ }
+
+ if len(su.Mail) == 0 {
+ domainName := source.DefaultDomainName
+ if len(domainName) == 0 {
+ domainName = "localhost.local"
+ }
+ su.Mail = fmt.Sprintf("%s@%s", su.Username, domainName)
+ }
+
+ fullName := composeFullName(su.Name, su.Surname, su.Username)
+ // If no existing user found, create one
+ if usr == nil {
+ log.Trace("SyncExternalUsers[%s]: Creating user %s", source.authSource.Name, su.Username)
+
+ usr = &user_model.User{
+ LowerName: su.LowerName,
+ Name: su.Username,
+ FullName: fullName,
+ LoginType: source.authSource.Type,
+ LoginSource: source.authSource.ID,
+ LoginName: su.Username,
+ Email: su.Mail,
+ IsAdmin: su.IsAdmin,
+ }
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsRestricted: optional.Some(su.IsRestricted),
+ IsActive: optional.Some(true),
+ }
+
+ err = user_model.CreateUser(ctx, usr, overwriteDefault)
+ if err != nil {
+ log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.authSource.Name, su.Username, err)
+ }
+
+ if err == nil && isAttributeSSHPublicKeySet {
+ log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.authSource.Name, usr.Name)
+ if asymkey_model.AddPublicKeysBySource(ctx, usr, source.authSource, su.SSHPublicKey) {
+ sshKeysNeedUpdate = true
+ }
+ }
+
+ if err == nil && len(source.AttributeAvatar) > 0 {
+ _ = user_service.UploadAvatar(ctx, usr, su.Avatar)
+ }
+ } else if updateExisting {
+ // Synchronize SSH Public Key if that attribute is set
+ if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.authSource, su.SSHPublicKey) {
+ sshKeysNeedUpdate = true
+ }
+
+ // Check if user data has changed
+ if (len(source.AdminFilter) > 0 && usr.IsAdmin != su.IsAdmin) ||
+ (len(source.RestrictedFilter) > 0 && usr.IsRestricted != su.IsRestricted) ||
+ !strings.EqualFold(usr.Email, su.Mail) ||
+ usr.FullName != fullName ||
+ !usr.IsActive {
+ log.Trace("SyncExternalUsers[%s]: Updating user %s", source.authSource.Name, usr.Name)
+
+ opts := &user_service.UpdateOptions{
+ FullName: optional.Some(fullName),
+ IsActive: optional.Some(true),
+ }
+ if source.AdminFilter != "" {
+ opts.IsAdmin = optional.Some(su.IsAdmin)
+ }
+ // Change existing restricted flag only if RestrictedFilter option is set
+ if !su.IsAdmin && source.RestrictedFilter != "" {
+ opts.IsRestricted = optional.Some(su.IsRestricted)
+ }
+
+ if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
+ log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.authSource.Name, usr.Name, err)
+ }
+
+ if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil {
+ log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.authSource.Name, usr.Name, su.Mail, err)
+ }
+ }
+
+ if usr.IsUploadAvatarChanged(su.Avatar) {
+ if err == nil && len(source.AttributeAvatar) > 0 {
+ _ = user_service.UploadAvatar(ctx, usr, su.Avatar)
+ }
+ }
+ }
+ // Synchronize LDAP groups with organization and team memberships
+ if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
+ if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil {
+ log.Error("SyncGroupsToTeamsCached: %v", err)
+ }
+ }
+ }
+
+ // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
+ if sshKeysNeedUpdate {
+ err = asymkey_model.RewriteAllPublicKeys(ctx)
+ if err != nil {
+ log.Error("RewriteAllPublicKeys: %v", err)
+ }
+ }
+
+ select {
+ case <-ctx.Done():
+ log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", source.authSource.Name)
+ return db.ErrCancelledf("During update of %s before delete users", source.authSource.Name)
+ default:
+ }
+
+ // Deactivate users not present in LDAP
+ if updateExisting {
+ for _, usr := range users {
+ if keepActiveUsers.Contains(usr.ID) {
+ continue
+ }
+
+ log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.authSource.Name, usr.Name)
+
+ opts := &user_service.UpdateOptions{
+ IsActive: optional.Some(false),
+ }
+ if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
+ log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.authSource.Name, usr.Name, err)
+ }
+ }
+ }
+ return nil
+}
diff --git a/services/auth/source/ldap/util.go b/services/auth/source/ldap/util.go
new file mode 100644
index 0000000..bd11e2d
--- /dev/null
+++ b/services/auth/source/ldap/util.go
@@ -0,0 +1,18 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package ldap
+
+// composeFullName composes a firstname surname or username
+func composeFullName(firstname, surname, username string) string {
+ switch {
+ case len(firstname) == 0 && len(surname) == 0:
+ return username
+ case len(firstname) == 0:
+ return surname
+ case len(surname) == 0:
+ return firstname
+ default:
+ return firstname + " " + surname
+ }
+}
diff --git a/services/auth/source/oauth2/assert_interface_test.go b/services/auth/source/oauth2/assert_interface_test.go
new file mode 100644
index 0000000..56fe0e4
--- /dev/null
+++ b/services/auth/source/oauth2/assert_interface_test.go
@@ -0,0 +1,22 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/oauth2"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth_model.Config
+ auth_model.SourceSettable
+ auth_model.RegisterableSource
+ auth.PasswordAuthenticator
+}
+
+var _ (sourceInterface) = &oauth2.Source{}
diff --git a/services/auth/source/oauth2/init.go b/services/auth/source/oauth2/init.go
new file mode 100644
index 0000000..5c25681
--- /dev/null
+++ b/services/auth/source/oauth2/init.go
@@ -0,0 +1,86 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "encoding/gob"
+ "net/http"
+ "sync"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/google/uuid"
+ "github.com/gorilla/sessions"
+ "github.com/markbates/goth/gothic"
+)
+
+var gothRWMutex = sync.RWMutex{}
+
+// UsersStoreKey is the key for the store
+const UsersStoreKey = "gitea-oauth2-sessions"
+
+// ProviderHeaderKey is the HTTP header key
+const ProviderHeaderKey = "gitea-oauth2-provider"
+
+// Init initializes the oauth source
+func Init(ctx context.Context) error {
+ if err := InitSigningKey(); err != nil {
+ return err
+ }
+
+ // Lock our mutex
+ gothRWMutex.Lock()
+
+ gob.Register(&sessions.Session{})
+
+ gothic.Store = &SessionsStore{
+ maxLength: int64(setting.OAuth2.MaxTokenLength),
+ }
+
+ gothic.SetState = func(req *http.Request) string {
+ return uuid.New().String()
+ }
+
+ gothic.GetProviderName = func(req *http.Request) (string, error) {
+ return req.Header.Get(ProviderHeaderKey), nil
+ }
+
+ // Unlock our mutex
+ gothRWMutex.Unlock()
+
+ return initOAuth2Sources(ctx)
+}
+
+// ResetOAuth2 clears existing OAuth2 providers and loads them from DB
+func ResetOAuth2(ctx context.Context) error {
+ ClearProviders()
+ return initOAuth2Sources(ctx)
+}
+
+// initOAuth2Sources is used to load and register all active OAuth2 providers
+func initOAuth2Sources(ctx context.Context) error {
+ authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: optional.Some(true),
+ LoginType: auth.OAuth2,
+ })
+ if err != nil {
+ return err
+ }
+ for _, source := range authSources {
+ oauth2Source, ok := source.Cfg.(*Source)
+ if !ok {
+ continue
+ }
+ err := oauth2Source.RegisterSource()
+ if err != nil {
+ log.Critical("Unable to register source: %s due to Error: %v.", source.Name, err)
+ }
+ }
+ return nil
+}
diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go
new file mode 100644
index 0000000..070fffe
--- /dev/null
+++ b/services/auth/source/oauth2/jwtsigningkey.go
@@ -0,0 +1,404 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// ErrInvalidAlgorithmType represents an invalid algorithm error.
+type ErrInvalidAlgorithmType struct {
+ Algorithm string
+}
+
+func (err ErrInvalidAlgorithmType) Error() string {
+ return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorithm)
+}
+
+// JWTSigningKey represents a algorithm/key pair to sign JWTs
+type JWTSigningKey interface {
+ IsSymmetric() bool
+ SigningMethod() jwt.SigningMethod
+ SignKey() any
+ VerifyKey() any
+ ToJWK() (map[string]string, error)
+ PreProcessToken(*jwt.Token)
+}
+
+type hmacSigningKey struct {
+ signingMethod jwt.SigningMethod
+ secret []byte
+}
+
+func (key hmacSigningKey) IsSymmetric() bool {
+ return true
+}
+
+func (key hmacSigningKey) SigningMethod() jwt.SigningMethod {
+ return key.signingMethod
+}
+
+func (key hmacSigningKey) SignKey() any {
+ return key.secret
+}
+
+func (key hmacSigningKey) VerifyKey() any {
+ return key.secret
+}
+
+func (key hmacSigningKey) ToJWK() (map[string]string, error) {
+ return map[string]string{
+ "kty": "oct",
+ "alg": key.SigningMethod().Alg(),
+ }, nil
+}
+
+func (key hmacSigningKey) PreProcessToken(*jwt.Token) {}
+
+type rsaSingingKey struct {
+ signingMethod jwt.SigningMethod
+ key *rsa.PrivateKey
+ id string
+}
+
+func newRSASingingKey(signingMethod jwt.SigningMethod, key *rsa.PrivateKey) (rsaSingingKey, error) {
+ kid, err := util.CreatePublicKeyFingerprint(key.Public().(*rsa.PublicKey))
+ if err != nil {
+ return rsaSingingKey{}, err
+ }
+
+ return rsaSingingKey{
+ signingMethod,
+ key,
+ base64.RawURLEncoding.EncodeToString(kid),
+ }, nil
+}
+
+func (key rsaSingingKey) IsSymmetric() bool {
+ return false
+}
+
+func (key rsaSingingKey) SigningMethod() jwt.SigningMethod {
+ return key.signingMethod
+}
+
+func (key rsaSingingKey) SignKey() any {
+ return key.key
+}
+
+func (key rsaSingingKey) VerifyKey() any {
+ return key.key.Public()
+}
+
+func (key rsaSingingKey) ToJWK() (map[string]string, error) {
+ pubKey := key.key.Public().(*rsa.PublicKey)
+
+ return map[string]string{
+ "kty": "RSA",
+ "alg": key.SigningMethod().Alg(),
+ "kid": key.id,
+ "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes()),
+ "n": base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes()),
+ }, nil
+}
+
+func (key rsaSingingKey) PreProcessToken(token *jwt.Token) {
+ token.Header["kid"] = key.id
+}
+
+type eddsaSigningKey struct {
+ signingMethod jwt.SigningMethod
+ key ed25519.PrivateKey
+ id string
+}
+
+func newEdDSASingingKey(signingMethod jwt.SigningMethod, key ed25519.PrivateKey) (eddsaSigningKey, error) {
+ kid, err := util.CreatePublicKeyFingerprint(key.Public().(ed25519.PublicKey))
+ if err != nil {
+ return eddsaSigningKey{}, err
+ }
+
+ return eddsaSigningKey{
+ signingMethod,
+ key,
+ base64.RawURLEncoding.EncodeToString(kid),
+ }, nil
+}
+
+func (key eddsaSigningKey) IsSymmetric() bool {
+ return false
+}
+
+func (key eddsaSigningKey) SigningMethod() jwt.SigningMethod {
+ return key.signingMethod
+}
+
+func (key eddsaSigningKey) SignKey() any {
+ return key.key
+}
+
+func (key eddsaSigningKey) VerifyKey() any {
+ return key.key.Public()
+}
+
+func (key eddsaSigningKey) ToJWK() (map[string]string, error) {
+ pubKey := key.key.Public().(ed25519.PublicKey)
+
+ return map[string]string{
+ "alg": key.SigningMethod().Alg(),
+ "kid": key.id,
+ "kty": "OKP",
+ "crv": "Ed25519",
+ "x": base64.RawURLEncoding.EncodeToString(pubKey),
+ }, nil
+}
+
+func (key eddsaSigningKey) PreProcessToken(token *jwt.Token) {
+ token.Header["kid"] = key.id
+}
+
+type ecdsaSingingKey struct {
+ signingMethod jwt.SigningMethod
+ key *ecdsa.PrivateKey
+ id string
+}
+
+func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) {
+ kid, err := util.CreatePublicKeyFingerprint(key.Public().(*ecdsa.PublicKey))
+ if err != nil {
+ return ecdsaSingingKey{}, err
+ }
+
+ return ecdsaSingingKey{
+ signingMethod,
+ key,
+ base64.RawURLEncoding.EncodeToString(kid),
+ }, nil
+}
+
+func (key ecdsaSingingKey) IsSymmetric() bool {
+ return false
+}
+
+func (key ecdsaSingingKey) SigningMethod() jwt.SigningMethod {
+ return key.signingMethod
+}
+
+func (key ecdsaSingingKey) SignKey() any {
+ return key.key
+}
+
+func (key ecdsaSingingKey) VerifyKey() any {
+ return key.key.Public()
+}
+
+func (key ecdsaSingingKey) ToJWK() (map[string]string, error) {
+ pubKey := key.key.Public().(*ecdsa.PublicKey)
+
+ return map[string]string{
+ "kty": "EC",
+ "alg": key.SigningMethod().Alg(),
+ "kid": key.id,
+ "crv": pubKey.Params().Name,
+ "x": base64.RawURLEncoding.EncodeToString(pubKey.X.Bytes()),
+ "y": base64.RawURLEncoding.EncodeToString(pubKey.Y.Bytes()),
+ }, nil
+}
+
+func (key ecdsaSingingKey) PreProcessToken(token *jwt.Token) {
+ token.Header["kid"] = key.id
+}
+
+// CreateJWTSigningKey creates a signing key from an algorithm / key pair.
+func CreateJWTSigningKey(algorithm string, key any) (JWTSigningKey, error) {
+ var signingMethod jwt.SigningMethod
+ switch algorithm {
+ case "HS256":
+ signingMethod = jwt.SigningMethodHS256
+ case "HS384":
+ signingMethod = jwt.SigningMethodHS384
+ case "HS512":
+ signingMethod = jwt.SigningMethodHS512
+
+ case "RS256":
+ signingMethod = jwt.SigningMethodRS256
+ case "RS384":
+ signingMethod = jwt.SigningMethodRS384
+ case "RS512":
+ signingMethod = jwt.SigningMethodRS512
+
+ case "ES256":
+ signingMethod = jwt.SigningMethodES256
+ case "ES384":
+ signingMethod = jwt.SigningMethodES384
+ case "ES512":
+ signingMethod = jwt.SigningMethodES512
+ case "EdDSA":
+ signingMethod = jwt.SigningMethodEdDSA
+ default:
+ return nil, ErrInvalidAlgorithmType{algorithm}
+ }
+
+ switch signingMethod.(type) {
+ case *jwt.SigningMethodEd25519:
+ privateKey, ok := key.(ed25519.PrivateKey)
+ if !ok {
+ return nil, jwt.ErrInvalidKeyType
+ }
+ return newEdDSASingingKey(signingMethod, privateKey)
+ case *jwt.SigningMethodECDSA:
+ privateKey, ok := key.(*ecdsa.PrivateKey)
+ if !ok {
+ return nil, jwt.ErrInvalidKeyType
+ }
+ return newECDSASingingKey(signingMethod, privateKey)
+ case *jwt.SigningMethodRSA:
+ privateKey, ok := key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, jwt.ErrInvalidKeyType
+ }
+ return newRSASingingKey(signingMethod, privateKey)
+ default:
+ secret, ok := key.([]byte)
+ if !ok {
+ return nil, jwt.ErrInvalidKeyType
+ }
+ return hmacSigningKey{signingMethod, secret}, nil
+ }
+}
+
+// DefaultSigningKey is the default signing key for JWTs.
+var DefaultSigningKey JWTSigningKey
+
+// InitSigningKey creates the default signing key from settings or creates a random key.
+func InitSigningKey() error {
+ var err error
+ var key any
+
+ switch setting.OAuth2.JWTSigningAlgorithm {
+ case "HS256":
+ fallthrough
+ case "HS384":
+ fallthrough
+ case "HS512":
+ key = setting.GetGeneralTokenSigningSecret()
+ case "RS256":
+ fallthrough
+ case "RS384":
+ fallthrough
+ case "RS512":
+ fallthrough
+ case "ES256":
+ fallthrough
+ case "ES384":
+ fallthrough
+ case "ES512":
+ fallthrough
+ case "EdDSA":
+ key, err = loadOrCreateAsymmetricKey()
+ default:
+ return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm}
+ }
+
+ if err != nil {
+ return fmt.Errorf("Error while loading or creating JWT key: %w", err)
+ }
+
+ signingKey, err := CreateJWTSigningKey(setting.OAuth2.JWTSigningAlgorithm, key)
+ if err != nil {
+ return err
+ }
+
+ DefaultSigningKey = signingKey
+
+ return nil
+}
+
+// loadOrCreateAsymmetricKey checks if the configured private key exists.
+// If it does not exist a new random key gets generated and saved on the configured path.
+func loadOrCreateAsymmetricKey() (any, error) {
+ keyPath := setting.OAuth2.JWTSigningPrivateKeyFile
+
+ isExist, err := util.IsExist(keyPath)
+ if err != nil {
+ log.Fatal("Unable to check if %s exists. Error: %v", keyPath, err)
+ }
+ if !isExist {
+ err := func() error {
+ key, err := func() (any, error) {
+ switch {
+ case strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS"):
+ return rsa.GenerateKey(rand.Reader, 4096)
+ case setting.OAuth2.JWTSigningAlgorithm == "EdDSA":
+ _, pk, err := ed25519.GenerateKey(rand.Reader)
+ return pk, err
+ default:
+ return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ }
+ }()
+ if err != nil {
+ return err
+ }
+
+ bytes, err := x509.MarshalPKCS8PrivateKey(key)
+ if err != nil {
+ return err
+ }
+
+ privateKeyPEM := &pem.Block{Type: "PRIVATE KEY", Bytes: bytes}
+
+ if err := os.MkdirAll(filepath.Dir(keyPath), os.ModePerm); err != nil {
+ return err
+ }
+
+ f, err := os.OpenFile(keyPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err = f.Close(); err != nil {
+ log.Error("Close: %v", err)
+ }
+ }()
+
+ return pem.Encode(f, privateKeyPEM)
+ }()
+ if err != nil {
+ log.Fatal("Error generating private key: %v", err)
+ return nil, err
+ }
+ }
+
+ bytes, err := os.ReadFile(keyPath)
+ if err != nil {
+ return nil, err
+ }
+
+ block, _ := pem.Decode(bytes)
+ if block == nil {
+ return nil, fmt.Errorf("no valid PEM data found in %s", keyPath)
+ } else if block.Type != "PRIVATE KEY" {
+ return nil, fmt.Errorf("expected PRIVATE KEY, got %s in %s", block.Type, keyPath)
+ }
+
+ return x509.ParsePKCS8PrivateKey(block.Bytes)
+}
diff --git a/services/auth/source/oauth2/main_test.go b/services/auth/source/oauth2/main_test.go
new file mode 100644
index 0000000..57c74fd
--- /dev/null
+++ b/services/auth/source/oauth2/main_test.go
@@ -0,0 +1,14 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m, &unittest.TestOptions{})
+}
diff --git a/services/auth/source/oauth2/providers.go b/services/auth/source/oauth2/providers.go
new file mode 100644
index 0000000..f2c1bb4
--- /dev/null
+++ b/services/auth/source/oauth2/providers.go
@@ -0,0 +1,190 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "net/url"
+ "sort"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+)
+
+// Provider is an interface for describing a single OAuth2 provider
+type Provider interface {
+ Name() string
+ DisplayName() string
+ IconHTML(size int) template.HTML
+ CustomURLSettings() *CustomURLSettings
+}
+
+// GothProviderCreator provides a function to create a goth.Provider
+type GothProviderCreator interface {
+ CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error)
+}
+
+// GothProvider is an interface for describing a single OAuth2 provider
+type GothProvider interface {
+ Provider
+ GothProviderCreator
+}
+
+// AuthSourceProvider provides a provider for an AuthSource. Multiple auth sources could use the same registered GothProvider
+// So each auth source should have its own DisplayName and IconHTML for display.
+// The Name is the GothProvider's name, to help to find the GothProvider to sign in.
+// The DisplayName is the auth source config's name, site admin set it on the admin page, the IconURL can also be set there.
+type AuthSourceProvider struct {
+ GothProvider
+ sourceName, iconURL string
+}
+
+func (p *AuthSourceProvider) Name() string {
+ return p.GothProvider.Name()
+}
+
+func (p *AuthSourceProvider) DisplayName() string {
+ return p.sourceName
+}
+
+func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
+ if p.iconURL != "" {
+ img := fmt.Sprintf(`<img class="tw-object-contain tw-mr-2" width="%d" height="%d" src="%s" alt="%s">`,
+ size,
+ size,
+ html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
+ )
+ return template.HTML(img)
+ }
+ return p.GothProvider.IconHTML(size)
+}
+
+// Providers contains the map of registered OAuth2 providers in Gitea (based on goth)
+// key is used to map the OAuth2Provider with the goth provider type (also in AuthSource.OAuth2Config.Provider)
+// value is used to store display data
+var gothProviders = map[string]GothProvider{}
+
+// RegisterGothProvider registers a GothProvider
+func RegisterGothProvider(provider GothProvider) {
+ if _, has := gothProviders[provider.Name()]; has {
+ log.Fatal("Duplicate oauth2provider type provided: %s", provider.Name())
+ }
+ gothProviders[provider.Name()] = provider
+}
+
+// GetSupportedOAuth2Providers returns the map of unconfigured OAuth2 providers
+// key is used as technical name (like in the callbackURL)
+// values to display
+func GetSupportedOAuth2Providers() []Provider {
+ providers := make([]Provider, 0, len(gothProviders))
+
+ for _, provider := range gothProviders {
+ providers = append(providers, provider)
+ }
+ sort.Slice(providers, func(i, j int) bool {
+ return providers[i].Name() < providers[j].Name()
+ })
+ return providers
+}
+
+func CreateProviderFromSource(source *auth.Source) (Provider, error) {
+ oauth2Cfg, ok := source.Cfg.(*Source)
+ if !ok {
+ return nil, fmt.Errorf("invalid OAuth2 source config: %v", oauth2Cfg)
+ }
+ gothProv := gothProviders[oauth2Cfg.Provider]
+ return &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL}, nil
+}
+
+// GetOAuth2Providers returns the list of configured OAuth2 providers
+func GetOAuth2Providers(ctx context.Context, isActive optional.Option[bool]) ([]Provider, error) {
+ authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: isActive,
+ LoginType: auth.OAuth2,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ providers := make([]Provider, 0, len(authSources))
+ for _, source := range authSources {
+ provider, err := CreateProviderFromSource(source)
+ if err != nil {
+ return nil, err
+ }
+ providers = append(providers, provider)
+ }
+
+ sort.Slice(providers, func(i, j int) bool {
+ return providers[i].Name() < providers[j].Name()
+ })
+
+ return providers, nil
+}
+
+// RegisterProviderWithGothic register a OAuth2 provider in goth lib
+func RegisterProviderWithGothic(providerName string, source *Source) error {
+ provider, err := createProvider(providerName, source)
+
+ if err == nil && provider != nil {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ goth.UseProviders(provider)
+ }
+
+ return err
+}
+
+// RemoveProviderFromGothic removes the given OAuth2 provider from the goth lib
+func RemoveProviderFromGothic(providerName string) {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ delete(goth.GetProviders(), providerName)
+}
+
+// ClearProviders clears all OAuth2 providers from the goth lib
+func ClearProviders() {
+ gothRWMutex.Lock()
+ defer gothRWMutex.Unlock()
+
+ goth.ClearProviders()
+}
+
+var ErrAuthSourceNotActivated = errors.New("auth source is not activated")
+
+// used to create different types of goth providers
+func createProvider(providerName string, source *Source) (goth.Provider, error) {
+ callbackURL := setting.AppURL + "user/oauth2/" + url.PathEscape(providerName) + "/callback"
+
+ var provider goth.Provider
+ var err error
+
+ p, ok := gothProviders[source.Provider]
+ if !ok {
+ return nil, ErrAuthSourceNotActivated
+ }
+
+ provider, err = p.CreateGothProvider(providerName, callbackURL, source)
+ if err != nil {
+ return provider, err
+ }
+
+ // always set the name if provider is created so we can support multiple setups of 1 provider
+ if provider != nil {
+ provider.SetName(providerName)
+ }
+
+ return provider, err
+}
diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go
new file mode 100644
index 0000000..9d4ab10
--- /dev/null
+++ b/services/auth/source/oauth2/providers_base.go
@@ -0,0 +1,51 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "html/template"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/svg"
+)
+
+// BaseProvider represents a common base for Provider
+type BaseProvider struct {
+ name string
+ displayName string
+}
+
+// Name provides the technical name for this provider
+func (b *BaseProvider) Name() string {
+ return b.name
+}
+
+// DisplayName returns the friendly name for this provider
+func (b *BaseProvider) DisplayName() string {
+ return b.displayName
+}
+
+// IconHTML returns icon HTML for this provider
+func (b *BaseProvider) IconHTML(size int) template.HTML {
+ svgName := "gitea-" + b.name
+ switch b.name {
+ case "gplus":
+ svgName = "gitea-google"
+ case "github":
+ svgName = "octicon-mark-github"
+ }
+ svgHTML := svg.RenderHTML(svgName, size, "tw-mr-2")
+ if svgHTML == "" {
+ log.Error("No SVG icon for oauth2 provider %q", b.name)
+ svgHTML = svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+ }
+ return svgHTML
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (b *BaseProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+var _ Provider = &BaseProvider{}
diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go
new file mode 100644
index 0000000..65cf538
--- /dev/null
+++ b/services/auth/source/oauth2/providers_custom.go
@@ -0,0 +1,123 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/azureadv2"
+ "github.com/markbates/goth/providers/gitea"
+ "github.com/markbates/goth/providers/github"
+ "github.com/markbates/goth/providers/gitlab"
+ "github.com/markbates/goth/providers/mastodon"
+ "github.com/markbates/goth/providers/nextcloud"
+)
+
+// CustomProviderNewFn creates a goth.Provider using a custom url mapping
+type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
+
+// CustomProvider is a GothProvider that has CustomURL features
+type CustomProvider struct {
+ BaseProvider
+ customURLSettings *CustomURLSettings
+ newFn CustomProviderNewFn
+}
+
+// CustomURLSettings returns the CustomURLSettings for this provider
+func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
+ return c.customURLSettings
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
+
+ return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
+}
+
+// NewCustomProvider is a constructor function for custom providers
+func NewCustomProvider(name, displayName string, customURLSetting *CustomURLSettings, newFn CustomProviderNewFn) *CustomProvider {
+ return &CustomProvider{
+ BaseProvider: BaseProvider{
+ name: name,
+ displayName: displayName,
+ },
+ customURLSettings: customURLSetting,
+ newFn: newFn,
+ }
+}
+
+var _ GothProvider = &CustomProvider{}
+
+func init() {
+ RegisterGothProvider(NewCustomProvider(
+ "github", "GitHub", &CustomURLSettings{
+ TokenURL: availableAttribute(github.TokenURL),
+ AuthURL: availableAttribute(github.AuthURL),
+ ProfileURL: availableAttribute(github.ProfileURL),
+ EmailURL: availableAttribute(github.EmailURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ if setting.OAuth2Client.EnableAutoRegistration {
+ scopes = append(scopes, "user:email")
+ }
+ return github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "gitlab", "GitLab", &CustomURLSettings{
+ AuthURL: availableAttribute(gitlab.AuthURL),
+ TokenURL: availableAttribute(gitlab.TokenURL),
+ ProfileURL: availableAttribute(gitlab.ProfileURL),
+ }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ scopes = append(scopes, "read_user")
+ return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "gitea", "Gitea", &CustomURLSettings{
+ TokenURL: requiredAttribute(gitea.TokenURL),
+ AuthURL: requiredAttribute(gitea.AuthURL),
+ ProfileURL: requiredAttribute(gitea.ProfileURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "nextcloud", "Nextcloud", &CustomURLSettings{
+ TokenURL: requiredAttribute(nextcloud.TokenURL),
+ AuthURL: requiredAttribute(nextcloud.AuthURL),
+ ProfileURL: requiredAttribute(nextcloud.ProfileURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "mastodon", "Mastodon", &CustomURLSettings{
+ AuthURL: requiredAttribute(mastodon.InstanceURL),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
+ }))
+
+ RegisterGothProvider(NewCustomProvider(
+ "azureadv2", "Azure AD v2", &CustomURLSettings{
+ Tenant: requiredAttribute("organizations"),
+ },
+ func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
+ azureScopes := make([]azureadv2.ScopeType, len(scopes))
+ for i, scope := range scopes {
+ azureScopes[i] = azureadv2.ScopeType(scope)
+ }
+
+ return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
+ Tenant: azureadv2.TenantType(custom.Tenant),
+ Scopes: azureScopes,
+ }), nil
+ },
+ ))
+}
diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go
new file mode 100644
index 0000000..285876d
--- /dev/null
+++ b/services/auth/source/oauth2/providers_openid.go
@@ -0,0 +1,58 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "html/template"
+
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/svg"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/openidConnect"
+)
+
+// OpenIDProvider is a GothProvider for OpenID
+type OpenIDProvider struct{}
+
+// Name provides the technical name for this provider
+func (o *OpenIDProvider) Name() string {
+ return "openidConnect"
+}
+
+// DisplayName returns the friendly name for this provider
+func (o *OpenIDProvider) DisplayName() string {
+ return "OpenID Connect"
+}
+
+// IconHTML returns icon HTML for this provider
+func (o *OpenIDProvider) IconHTML(size int) template.HTML {
+ return svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := setting.OAuth2Client.OpenIDConnectScopes
+ if len(scopes) == 0 {
+ scopes = append(scopes, source.Scopes...)
+ }
+
+ provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
+ return provider, err
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+var _ GothProvider = &OpenIDProvider{}
+
+func init() {
+ RegisterGothProvider(&OpenIDProvider{})
+}
diff --git a/services/auth/source/oauth2/providers_simple.go b/services/auth/source/oauth2/providers_simple.go
new file mode 100644
index 0000000..e95323a
--- /dev/null
+++ b/services/auth/source/oauth2/providers_simple.go
@@ -0,0 +1,109 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/azuread"
+ "github.com/markbates/goth/providers/bitbucket"
+ "github.com/markbates/goth/providers/discord"
+ "github.com/markbates/goth/providers/dropbox"
+ "github.com/markbates/goth/providers/facebook"
+ "github.com/markbates/goth/providers/google"
+ "github.com/markbates/goth/providers/microsoftonline"
+ "github.com/markbates/goth/providers/twitter"
+ "github.com/markbates/goth/providers/yandex"
+)
+
+// SimpleProviderNewFn create goth.Providers without custom url features
+type SimpleProviderNewFn func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider
+
+// SimpleProvider is a GothProvider which does not have custom url features
+type SimpleProvider struct {
+ BaseProvider
+ scopes []string
+ newFn SimpleProviderNewFn
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := make([]string, len(c.scopes)+len(source.Scopes))
+ copy(scopes, c.scopes)
+ copy(scopes[len(c.scopes):], source.Scopes)
+ return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
+}
+
+// NewSimpleProvider is a constructor function for simple providers
+func NewSimpleProvider(name, displayName string, scopes []string, newFn SimpleProviderNewFn) *SimpleProvider {
+ return &SimpleProvider{
+ BaseProvider: BaseProvider{
+ name: name,
+ displayName: displayName,
+ },
+ scopes: scopes,
+ newFn: newFn,
+ }
+}
+
+var _ GothProvider = &SimpleProvider{}
+
+func init() {
+ RegisterGothProvider(
+ NewSimpleProvider("bitbucket", "Bitbucket", []string{"account"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return bitbucket.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(
+ NewSimpleProvider("dropbox", "Dropbox", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return dropbox.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("facebook", "Facebook", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return facebook.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ // named gplus due to legacy gplus -> google migration (Google killed Google+). This ensures old connections still work
+ RegisterGothProvider(NewSimpleProvider("gplus", "Google", []string{"email"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ if setting.OAuth2Client.UpdateAvatar || setting.OAuth2Client.EnableAutoRegistration {
+ scopes = append(scopes, "profile")
+ }
+ return google.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("twitter", "Twitter", nil,
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return twitter.New(clientKey, secret, callbackURL)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider("discord", "Discord", []string{discord.ScopeIdentify, discord.ScopeEmail},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return discord.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ // See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/
+ RegisterGothProvider(NewSimpleProvider("yandex", "Yandex", []string{"login:email", "login:info", "login:avatar"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return yandex.New(clientKey, secret, callbackURL, scopes...)
+ }))
+
+ RegisterGothProvider(NewSimpleProvider(
+ "azuread", "Azure AD", nil,
+ func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
+ return azuread.New(clientID, secret, callbackURL, nil, scopes...)
+ },
+ ))
+
+ RegisterGothProvider(NewSimpleProvider(
+ "microsoftonline", "Microsoft Online", nil,
+ func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
+ return microsoftonline.New(clientID, secret, callbackURL, scopes...)
+ },
+ ))
+}
diff --git a/services/auth/source/oauth2/providers_test.go b/services/auth/source/oauth2/providers_test.go
new file mode 100644
index 0000000..353816c
--- /dev/null
+++ b/services/auth/source/oauth2/providers_test.go
@@ -0,0 +1,62 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "time"
+
+ "github.com/markbates/goth"
+ "golang.org/x/oauth2"
+)
+
+type fakeProvider struct{}
+
+func (p *fakeProvider) Name() string {
+ return "fake"
+}
+
+func (p *fakeProvider) SetName(name string) {}
+
+func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) {
+ return nil, nil
+}
+
+func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
+ return nil, nil
+}
+
+func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) {
+ return goth.User{}, nil
+}
+
+func (p *fakeProvider) Debug(bool) {
+}
+
+func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
+ switch refreshToken {
+ case "expired":
+ return nil, &oauth2.RetrieveError{
+ ErrorCode: "invalid_grant",
+ }
+ default:
+ return &oauth2.Token{
+ AccessToken: "token",
+ TokenType: "Bearer",
+ RefreshToken: "refresh",
+ Expiry: time.Now().Add(time.Hour),
+ }, nil
+ }
+}
+
+func (p *fakeProvider) RefreshTokenAvailable() bool {
+ return true
+}
+
+func init() {
+ RegisterGothProvider(
+ NewSimpleProvider("fake", "Fake", []string{"account"},
+ func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
+ return &fakeProvider{}
+ }))
+}
diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go
new file mode 100644
index 0000000..3454c9a
--- /dev/null
+++ b/services/auth/source/oauth2/source.go
@@ -0,0 +1,51 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// Source holds configuration for the OAuth2 login source.
+type Source struct {
+ Provider string
+ ClientID string
+ ClientSecret string
+ OpenIDConnectAutoDiscoveryURL string
+ CustomURLMapping *CustomURLMapping
+ IconURL string
+
+ Scopes []string
+ RequiredClaimName string
+ RequiredClaimValue string
+ GroupClaimName string
+ AdminGroup string
+ GroupTeamMap string
+ GroupTeamMapRemoval bool
+ RestrictedGroup string
+ SkipLocalTwoFA bool `json:",omitempty"`
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up an OAuth2Config from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports an OAuth2Config to a serialized format.
+func (source *Source) ToDB() ([]byte, error) {
+ return json.Marshal(source)
+}
+
+// SetAuthSource sets the related AuthSource
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.OAuth2, &Source{})
+}
diff --git a/services/auth/source/oauth2/source_authenticate.go b/services/auth/source/oauth2/source_authenticate.go
new file mode 100644
index 0000000..bbda35d
--- /dev/null
+++ b/services/auth/source/oauth2/source_authenticate.go
@@ -0,0 +1,19 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/services/auth/source/db"
+)
+
+// Authenticate falls back to the db authenticator
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
+ return db.Authenticate(ctx, user, login, password)
+}
+
+// NB: Oauth2 does not implement LocalTwoFASkipper for password authentication
+// as its password authentication drops to db authentication
diff --git a/services/auth/source/oauth2/source_callout.go b/services/auth/source/oauth2/source_callout.go
new file mode 100644
index 0000000..f95a80f
--- /dev/null
+++ b/services/auth/source/oauth2/source_callout.go
@@ -0,0 +1,68 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "net/http"
+ "net/url"
+
+ "github.com/markbates/goth"
+ "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 {
+ // not sure if goth is thread safe (?) when using multiple providers
+ request.Header.Set(ProviderHeaderKey, source.authSource.Name)
+
+ var querySuffix string
+ if codeChallengeS256 != "" {
+ querySuffix = "&" + url.Values{
+ "code_challenge_method": []string{"S256"},
+ "code_challenge": []string{codeChallengeS256},
+ }.Encode()
+ }
+
+ // don't use the default gothic begin handler to prevent issues when some error occurs
+ // normally the gothic library will write some custom stuff to the response instead of our own nice error page
+ // gothic.BeginAuthHandler(response, request)
+
+ gothRWMutex.RLock()
+ defer gothRWMutex.RUnlock()
+
+ url, err := gothic.GetAuthURL(response, request)
+ if err == nil {
+ // hacky way to set the code_challenge, but no better way until
+ // https://github.com/markbates/goth/issues/516 is resolved
+ http.Redirect(response, request, url+querySuffix, http.StatusTemporaryRedirect)
+ }
+ return err
+}
+
+// Callback handles OAuth callback, resolve to a goth user and send back to original url
+// this will trigger a new authentication request, but because we save it in the session we can use that
+func (source *Source) Callback(request *http.Request, response http.ResponseWriter, codeVerifier string) (goth.User, error) {
+ // not sure if goth is thread safe (?) when using multiple providers
+ request.Header.Set(ProviderHeaderKey, source.authSource.Name)
+
+ if codeVerifier != "" {
+ // hacky way to set the code_verifier...
+ // Will be picked up inside CompleteUserAuth: params := req.URL.Query()
+ // https://github.com/markbates/goth/pull/474/files
+ request = request.Clone(request.Context())
+ q := request.URL.Query()
+ q.Add("code_verifier", codeVerifier)
+ request.URL.RawQuery = q.Encode()
+ }
+
+ gothRWMutex.RLock()
+ defer gothRWMutex.RUnlock()
+
+ user, err := gothic.CompleteUserAuth(response, request)
+ if err != nil {
+ return user, err
+ }
+
+ return user, nil
+}
diff --git a/services/auth/source/oauth2/source_name.go b/services/auth/source/oauth2/source_name.go
new file mode 100644
index 0000000..eee789e
--- /dev/null
+++ b/services/auth/source/oauth2/source_name.go
@@ -0,0 +1,18 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+// Name returns the provider name of this source
+func (source *Source) Name() string {
+ return source.Provider
+}
+
+// DisplayName returns the display name of this source
+func (source *Source) DisplayName() string {
+ provider, has := gothProviders[source.Provider]
+ if !has {
+ return source.Provider
+ }
+ return provider.DisplayName()
+}
diff --git a/services/auth/source/oauth2/source_register.go b/services/auth/source/oauth2/source_register.go
new file mode 100644
index 0000000..82a36ac
--- /dev/null
+++ b/services/auth/source/oauth2/source_register.go
@@ -0,0 +1,50 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "fmt"
+)
+
+// RegisterSource causes an OAuth2 configuration to be registered
+func (source *Source) RegisterSource() error {
+ err := RegisterProviderWithGothic(source.authSource.Name, source)
+ return wrapOpenIDConnectInitializeError(err, source.authSource.Name, source)
+}
+
+// UnregisterSource causes an OAuth2 configuration to be unregistered
+func (source *Source) UnregisterSource() error {
+ RemoveProviderFromGothic(source.authSource.Name)
+ return nil
+}
+
+// ErrOpenIDConnectInitialize represents a "OpenIDConnectInitialize" kind of error.
+type ErrOpenIDConnectInitialize struct {
+ OpenIDConnectAutoDiscoveryURL string
+ ProviderName string
+ Cause error
+}
+
+// IsErrOpenIDConnectInitialize checks if an error is a ExternalLoginUserAlreadyExist.
+func IsErrOpenIDConnectInitialize(err error) bool {
+ _, ok := err.(ErrOpenIDConnectInitialize)
+ return ok
+}
+
+func (err ErrOpenIDConnectInitialize) Error() string {
+ return fmt.Sprintf("Failed to initialize OpenID Connect Provider with name '%s' with url '%s': %v", err.ProviderName, err.OpenIDConnectAutoDiscoveryURL, err.Cause)
+}
+
+func (err ErrOpenIDConnectInitialize) Unwrap() error {
+ return err.Cause
+}
+
+// wrapOpenIDConnectInitializeError is used to wrap the error but this cannot be done in modules/auth/oauth2
+// inside oauth2: import cycle not allowed models -> modules/auth/oauth2 -> models
+func wrapOpenIDConnectInitializeError(err error, providerName string, source *Source) error {
+ if err != nil && source.Provider == "openidConnect" {
+ err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err}
+ }
+ return err
+}
diff --git a/services/auth/source/oauth2/source_sync.go b/services/auth/source/oauth2/source_sync.go
new file mode 100644
index 0000000..5e30313
--- /dev/null
+++ b/services/auth/source/oauth2/source_sync.go
@@ -0,0 +1,114 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/markbates/goth"
+ "golang.org/x/oauth2"
+)
+
+// Sync causes this OAuth2 source to synchronize its users with the db.
+func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
+ log.Trace("Doing: SyncExternalUsers[%s] %d", source.authSource.Name, source.authSource.ID)
+
+ if !updateExisting {
+ log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.authSource.Name)
+ return nil
+ }
+
+ provider, err := createProvider(source.authSource.Name, source)
+ if err != nil {
+ return err
+ }
+
+ if !provider.RefreshTokenAvailable() {
+ log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.authSource.Name)
+ return nil
+ }
+
+ opts := user_model.FindExternalUserOptions{
+ HasRefreshToken: true,
+ Expired: true,
+ LoginSourceID: source.authSource.ID,
+ }
+
+ return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error {
+ return source.refresh(ctx, provider, u)
+ })
+}
+
+func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error {
+ log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt)
+
+ shouldDisable := false
+
+ token, err := provider.RefreshToken(u.RefreshToken)
+ if err != nil {
+ if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" {
+ // this signals that the token is not valid and the user should be disabled
+ shouldDisable = true
+ } else {
+ return err
+ }
+ }
+
+ user := &user_model.User{
+ LoginName: u.ExternalID,
+ LoginType: auth.OAuth2,
+ LoginSource: u.LoginSourceID,
+ }
+
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return err
+ }
+
+ // If the grant is no longer valid, disable the user and
+ // delete local tokens. If the OAuth2 provider still
+ // recognizes them as a valid user, they will be able to login
+ // via their provider and reactivate their account.
+ if shouldDisable {
+ log.Info("SyncExternalUsers[%s] disabling user %d", source.authSource.Name, user.ID)
+
+ return db.WithTx(ctx, func(ctx context.Context) error {
+ if hasUser {
+ user.IsActive = false
+ err := user_model.UpdateUserCols(ctx, user, "is_active")
+ if err != nil {
+ return err
+ }
+ }
+
+ // Delete stored tokens, since they are invalid. This
+ // also provents us from checking this in subsequent runs.
+ u.AccessToken = ""
+ u.RefreshToken = ""
+ u.ExpiresAt = time.Time{}
+
+ return user_model.UpdateExternalUserByExternalID(ctx, u)
+ })
+ }
+
+ // Otherwise, update the tokens
+ u.AccessToken = token.AccessToken
+ u.ExpiresAt = token.Expiry
+
+ // Some providers only update access tokens provide a new
+ // refresh token, so avoid updating it if it's empty
+ if token.RefreshToken != "" {
+ u.RefreshToken = token.RefreshToken
+ }
+
+ err = user_model.UpdateExternalUserByExternalID(ctx, u)
+
+ return err
+}
diff --git a/services/auth/source/oauth2/source_sync_test.go b/services/auth/source/oauth2/source_sync_test.go
new file mode 100644
index 0000000..746df82
--- /dev/null
+++ b/services/auth/source/oauth2/source_sync_test.go
@@ -0,0 +1,101 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSource(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ source := &Source{
+ Provider: "fake",
+ authSource: &auth.Source{
+ ID: 12,
+ Type: auth.OAuth2,
+ Name: "fake",
+ IsActive: true,
+ IsSyncEnabled: true,
+ },
+ }
+
+ user := &user_model.User{
+ LoginName: "external",
+ LoginType: auth.OAuth2,
+ LoginSource: source.authSource.ID,
+ Name: "test",
+ Email: "external@example.com",
+ }
+
+ err := user_model.CreateUser(context.Background(), user, &user_model.CreateUserOverwriteOptions{})
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: "external",
+ UserID: user.ID,
+ LoginSourceID: user.LoginSource,
+ RefreshToken: "valid",
+ }
+ err = user_model.LinkExternalToUser(context.Background(), user, e)
+ require.NoError(t, err)
+
+ provider, err := createProvider(source.authSource.Name, source)
+ require.NoError(t, err)
+
+ t.Run("refresh", func(t *testing.T) {
+ t.Run("valid", func(t *testing.T) {
+ err := source.refresh(context.Background(), provider, e)
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: e.ExternalID,
+ LoginSourceID: e.LoginSourceID,
+ }
+
+ ok, err := user_model.GetExternalLogin(context.Background(), e)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ assert.Equal(t, "refresh", e.RefreshToken)
+ assert.Equal(t, "token", e.AccessToken)
+
+ u, err := user_model.GetUserByID(context.Background(), user.ID)
+ require.NoError(t, err)
+ assert.True(t, u.IsActive)
+ })
+
+ t.Run("expired", func(t *testing.T) {
+ err := source.refresh(context.Background(), provider, &user_model.ExternalLoginUser{
+ ExternalID: "external",
+ UserID: user.ID,
+ LoginSourceID: user.LoginSource,
+ RefreshToken: "expired",
+ })
+ require.NoError(t, err)
+
+ e := &user_model.ExternalLoginUser{
+ ExternalID: e.ExternalID,
+ LoginSourceID: e.LoginSourceID,
+ }
+
+ ok, err := user_model.GetExternalLogin(context.Background(), e)
+ require.NoError(t, err)
+ assert.True(t, ok)
+ assert.Equal(t, "", e.RefreshToken)
+ assert.Equal(t, "", e.AccessToken)
+
+ u, err := user_model.GetUserByID(context.Background(), user.ID)
+ require.NoError(t, err)
+ assert.False(t, u.IsActive)
+ })
+ })
+}
diff --git a/services/auth/source/oauth2/store.go b/services/auth/source/oauth2/store.go
new file mode 100644
index 0000000..e031653
--- /dev/null
+++ b/services/auth/source/oauth2/store.go
@@ -0,0 +1,98 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "encoding/gob"
+ "fmt"
+ "net/http"
+
+ "code.gitea.io/gitea/modules/log"
+ session_module "code.gitea.io/gitea/modules/session"
+
+ chiSession "code.forgejo.org/go-chi/session"
+ "github.com/gorilla/sessions"
+)
+
+// SessionsStore creates a gothic store from our session
+type SessionsStore struct {
+ maxLength int64
+}
+
+// Get should return a cached session.
+func (st *SessionsStore) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return st.getOrNew(r, name, false)
+}
+
+// New should create and return a new session.
+//
+// Note that New should never return a nil session, even in the case of
+// an error if using the Registry infrastructure to cache the session.
+func (st *SessionsStore) New(r *http.Request, name string) (*sessions.Session, error) {
+ return st.getOrNew(r, name, true)
+}
+
+// getOrNew gets the session from the chi-session if it exists. Override permits the overriding of an unexpected object.
+func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) (*sessions.Session, error) {
+ chiStore := chiSession.GetSession(r)
+
+ session := sessions.NewSession(st, name)
+
+ rawData := chiStore.Get(name)
+ if rawData != nil {
+ oldSession, ok := rawData.(*sessions.Session)
+ if ok {
+ session.ID = oldSession.ID
+ session.IsNew = oldSession.IsNew
+ session.Options = oldSession.Options
+ session.Values = oldSession.Values
+
+ return session, nil
+ } else if !override {
+ log.Error("Unexpected object in session at name: %s: %v", name, rawData)
+ return nil, fmt.Errorf("unexpected object in session at name: %s", name)
+ }
+ }
+
+ session.IsNew = override
+ session.ID = chiStore.ID() // Simply copy the session id from the chi store
+
+ return session, chiStore.Set(name, session)
+}
+
+// Save should persist session to the underlying store implementation.
+func (st *SessionsStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ chiStore := chiSession.GetSession(r)
+
+ if session.IsNew {
+ _, _ = session_module.RegenerateSession(w, r)
+ session.IsNew = false
+ }
+
+ if err := chiStore.Set(session.Name(), session); err != nil {
+ return err
+ }
+
+ if st.maxLength > 0 {
+ sizeWriter := &sizeWriter{}
+
+ _ = gob.NewEncoder(sizeWriter).Encode(session)
+ if sizeWriter.size > st.maxLength {
+ return fmt.Errorf("encode session: Data too long: %d > %d", sizeWriter.size, st.maxLength)
+ }
+ }
+
+ return chiStore.Release()
+}
+
+type sizeWriter struct {
+ size int64
+}
+
+func (s *sizeWriter) Write(data []byte) (int, error) {
+ s.size += int64(len(data))
+ return len(data), nil
+}
+
+var _ (sessions.Store) = &SessionsStore{}
diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go
new file mode 100644
index 0000000..3405619
--- /dev/null
+++ b/services/auth/source/oauth2/token.go
@@ -0,0 +1,100 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "fmt"
+ "time"
+
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// ___________ __
+// \__ ___/___ | | __ ____ ____
+// | | / _ \| |/ // __ \ / \
+// | |( <_> ) <\ ___/| | \
+// |____| \____/|__|_ \\___ >___| /
+// \/ \/ \/
+
+// Token represents an Oauth grant
+
+// TokenType represents the type of token for an oauth application
+type TokenType int
+
+const (
+ // TypeAccessToken is a token with short lifetime to access the api
+ TypeAccessToken TokenType = 0
+ // TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client
+ TypeRefreshToken = iota
+)
+
+// Token represents a JWT token used to authenticate a client
+type Token struct {
+ GrantID int64 `json:"gnt"`
+ Type TokenType `json:"tt"`
+ Counter int64 `json:"cnt,omitempty"`
+ jwt.RegisteredClaims
+}
+
+// ParseToken parses a signed jwt string
+func ParseToken(jwtToken string, signingKey JWTSigningKey) (*Token, error) {
+ parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (any, error) {
+ if token.Method == nil || token.Method.Alg() != signingKey.SigningMethod().Alg() {
+ return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"])
+ }
+ return signingKey.VerifyKey(), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if !parsedToken.Valid {
+ return nil, fmt.Errorf("invalid token")
+ }
+ var token *Token
+ var ok bool
+ if token, ok = parsedToken.Claims.(*Token); !ok || !parsedToken.Valid {
+ return nil, fmt.Errorf("invalid token")
+ }
+ return token, nil
+}
+
+// SignToken signs the token with the JWT secret
+func (token *Token) SignToken(signingKey JWTSigningKey) (string, error) {
+ token.IssuedAt = jwt.NewNumericDate(time.Now())
+ jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+ signingKey.PreProcessToken(jwtToken)
+ return jwtToken.SignedString(signingKey.SignKey())
+}
+
+// OIDCToken represents an OpenID Connect id_token
+type OIDCToken struct {
+ jwt.RegisteredClaims
+ Nonce string `json:"nonce,omitempty"`
+
+ // Scope profile
+ Name string `json:"name,omitempty"`
+ PreferredUsername string `json:"preferred_username,omitempty"`
+ Profile string `json:"profile,omitempty"`
+ Picture string `json:"picture,omitempty"`
+ Website string `json:"website,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ UpdatedAt timeutil.TimeStamp `json:"updated_at,omitempty"`
+
+ // Scope email
+ Email string `json:"email,omitempty"`
+ EmailVerified bool `json:"email_verified,omitempty"`
+
+ // Groups are generated by organization and team names
+ Groups []string `json:"groups,omitempty"`
+}
+
+// SignToken signs an id_token with the (symmetric) client secret key
+func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) {
+ token.IssuedAt = jwt.NewNumericDate(time.Now())
+ jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token)
+ signingKey.PreProcessToken(jwtToken)
+ return jwtToken.SignedString(signingKey.SignKey())
+}
diff --git a/services/auth/source/oauth2/urlmapping.go b/services/auth/source/oauth2/urlmapping.go
new file mode 100644
index 0000000..d0442d5
--- /dev/null
+++ b/services/auth/source/oauth2/urlmapping.go
@@ -0,0 +1,77 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs
+type CustomURLMapping struct {
+ AuthURL string `json:",omitempty"`
+ TokenURL string `json:",omitempty"`
+ ProfileURL string `json:",omitempty"`
+ EmailURL string `json:",omitempty"`
+ Tenant string `json:",omitempty"`
+}
+
+// CustomURLSettings describes the urls values and availability to use when customizing OAuth2 provider URLs
+type CustomURLSettings struct {
+ AuthURL Attribute `json:",omitempty"`
+ TokenURL Attribute `json:",omitempty"`
+ ProfileURL Attribute `json:",omitempty"`
+ EmailURL Attribute `json:",omitempty"`
+ Tenant Attribute `json:",omitempty"`
+}
+
+// Attribute describes the availability, and required status for a custom url configuration
+type Attribute struct {
+ Value string
+ Available bool
+ Required bool
+}
+
+func availableAttribute(value string) Attribute {
+ return Attribute{Value: value, Available: true}
+}
+
+func requiredAttribute(value string) Attribute {
+ return Attribute{Value: value, Available: true, Required: true}
+}
+
+// Required is true if any attribute is required
+func (c *CustomURLSettings) Required() bool {
+ if c == nil {
+ return false
+ }
+ if c.AuthURL.Required || c.EmailURL.Required || c.ProfileURL.Required || c.TokenURL.Required || c.Tenant.Required {
+ return true
+ }
+ return false
+}
+
+// OverrideWith copies the current customURLMapping and overrides it with values from the provided mapping
+func (c *CustomURLSettings) OverrideWith(override *CustomURLMapping) *CustomURLMapping {
+ custom := &CustomURLMapping{
+ AuthURL: c.AuthURL.Value,
+ TokenURL: c.TokenURL.Value,
+ ProfileURL: c.ProfileURL.Value,
+ EmailURL: c.EmailURL.Value,
+ Tenant: c.Tenant.Value,
+ }
+ if override != nil {
+ if len(override.AuthURL) > 0 && c.AuthURL.Available {
+ custom.AuthURL = override.AuthURL
+ }
+ if len(override.TokenURL) > 0 && c.TokenURL.Available {
+ custom.TokenURL = override.TokenURL
+ }
+ if len(override.ProfileURL) > 0 && c.ProfileURL.Available {
+ custom.ProfileURL = override.ProfileURL
+ }
+ if len(override.EmailURL) > 0 && c.EmailURL.Available {
+ custom.EmailURL = override.EmailURL
+ }
+ if len(override.Tenant) > 0 && c.Tenant.Available {
+ custom.Tenant = override.Tenant
+ }
+ }
+ return custom
+}
diff --git a/services/auth/source/pam/assert_interface_test.go b/services/auth/source/pam/assert_interface_test.go
new file mode 100644
index 0000000..8e7648b
--- /dev/null
+++ b/services/auth/source/pam/assert_interface_test.go
@@ -0,0 +1,21 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pam_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/pam"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth.PasswordAuthenticator
+ auth_model.Config
+ auth_model.SourceSettable
+}
+
+var _ (sourceInterface) = &pam.Source{}
diff --git a/services/auth/source/pam/source.go b/services/auth/source/pam/source.go
new file mode 100644
index 0000000..96b182e
--- /dev/null
+++ b/services/auth/source/pam/source.go
@@ -0,0 +1,45 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pam
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// __________ _____ _____
+// \______ \/ _ \ / \
+// | ___/ /_\ \ / \ / \
+// | | / | \/ Y \
+// |____| \____|__ /\____|__ /
+// \/ \/
+
+// Source holds configuration for the PAM login source.
+type Source struct {
+ ServiceName string // pam service (e.g. system-auth)
+ EmailDomain string
+ SkipLocalTwoFA bool `json:",omitempty"` // Skip Local 2fa for users authenticated with this source
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up a PAMConfig from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports a PAMConfig to a serialized format.
+func (source *Source) ToDB() ([]byte, error) {
+ return json.Marshal(source)
+}
+
+// SetAuthSource sets the related AuthSource
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.PAM, &Source{})
+}
diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go
new file mode 100644
index 0000000..addd1bd
--- /dev/null
+++ b/services/auth/source/pam/source_authenticate.go
@@ -0,0 +1,76 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package pam
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/auth/pam"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/google/uuid"
+)
+
+// Authenticate queries if login/password is valid against the PAM,
+// and create a local user if success when enabled.
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
+ pamLogin, err := pam.Auth(source.ServiceName, userName, password)
+ if err != nil {
+ if strings.Contains(err.Error(), "Authentication failure") {
+ return nil, user_model.ErrUserNotExist{Name: userName}
+ }
+ return nil, err
+ }
+
+ if user != nil {
+ return user, nil
+ }
+
+ // Allow PAM sources with `@` in their name, like from Active Directory
+ username := pamLogin
+ email := pamLogin
+ idx := strings.Index(pamLogin, "@")
+ if idx > -1 {
+ username = pamLogin[:idx]
+ }
+ if user_model.ValidateEmail(email) != nil {
+ if source.EmailDomain != "" {
+ email = fmt.Sprintf("%s@%s", username, source.EmailDomain)
+ } else {
+ email = fmt.Sprintf("%s@%s", username, setting.Service.NoReplyAddress)
+ }
+ if user_model.ValidateEmail(email) != nil {
+ email = uuid.New().String() + "@localhost"
+ }
+ }
+
+ user = &user_model.User{
+ LowerName: strings.ToLower(username),
+ Name: username,
+ Email: email,
+ Passwd: password,
+ LoginType: auth.PAM,
+ LoginSource: source.authSource.ID,
+ LoginName: userName, // This is what the user typed in
+ }
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(true),
+ }
+
+ if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil {
+ return user, err
+ }
+
+ return user, nil
+}
+
+// IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication
+func (source *Source) IsSkipLocalTwoFA() bool {
+ return source.SkipLocalTwoFA
+}
diff --git a/services/auth/source/remote/source.go b/services/auth/source/remote/source.go
new file mode 100644
index 0000000..4165858
--- /dev/null
+++ b/services/auth/source/remote/source.go
@@ -0,0 +1,33 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// SPDX-License-Identifier: MIT
+
+package remote
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+type Source struct {
+ URL string
+ MatchingSource string
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+func (source *Source) ToDB() ([]byte, error) {
+ return json.Marshal(source)
+}
+
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.Remote, &Source{})
+}
diff --git a/services/auth/source/smtp/assert_interface_test.go b/services/auth/source/smtp/assert_interface_test.go
new file mode 100644
index 0000000..6c9cde6
--- /dev/null
+++ b/services/auth/source/smtp/assert_interface_test.go
@@ -0,0 +1,24 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package smtp_test
+
+import (
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/smtp"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth.PasswordAuthenticator
+ auth_model.Config
+ auth_model.SkipVerifiable
+ auth_model.HasTLSer
+ auth_model.UseTLSer
+ auth_model.SourceSettable
+}
+
+var _ (sourceInterface) = &smtp.Source{}
diff --git a/services/auth/source/smtp/auth.go b/services/auth/source/smtp/auth.go
new file mode 100644
index 0000000..6446fcd
--- /dev/null
+++ b/services/auth/source/smtp/auth.go
@@ -0,0 +1,106 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package smtp
+
+import (
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net"
+ "net/smtp"
+ "os"
+ "strconv"
+)
+
+// _________ __________________________
+// / _____/ / \__ ___/\______ \
+// \_____ \ / \ / \| | | ___/
+// / \/ Y \ | | |
+// /_______ /\____|__ /____| |____|
+// \/ \/
+
+type loginAuthenticator struct {
+ username, password string
+}
+
+func (auth *loginAuthenticator) Start(server *smtp.ServerInfo) (string, []byte, error) {
+ return "LOGIN", []byte(auth.username), nil
+}
+
+func (auth *loginAuthenticator) Next(fromServer []byte, more bool) ([]byte, error) {
+ if more {
+ switch string(fromServer) {
+ case "Username:":
+ return []byte(auth.username), nil
+ case "Password:":
+ return []byte(auth.password), nil
+ }
+ }
+ return nil, nil
+}
+
+// SMTP authentication type names.
+const (
+ PlainAuthentication = "PLAIN"
+ LoginAuthentication = "LOGIN"
+ CRAMMD5Authentication = "CRAM-MD5"
+)
+
+// Authenticators contains available SMTP authentication type names.
+var Authenticators = []string{PlainAuthentication, LoginAuthentication, CRAMMD5Authentication}
+
+// ErrUnsupportedLoginType login source is unknown error
+var ErrUnsupportedLoginType = errors.New("Login source is unknown")
+
+// Authenticate performs an SMTP authentication.
+func Authenticate(a smtp.Auth, source *Source) error {
+ tlsConfig := &tls.Config{
+ InsecureSkipVerify: source.SkipVerify,
+ ServerName: source.Host,
+ }
+
+ conn, err := net.Dial("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)))
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ if source.UseTLS() {
+ conn = tls.Client(conn, tlsConfig)
+ }
+
+ client, err := smtp.NewClient(conn, source.Host)
+ if err != nil {
+ return fmt.Errorf("failed to create NewClient: %w", err)
+ }
+ defer client.Close()
+
+ if !source.DisableHelo {
+ hostname := source.HeloHostname
+ if len(hostname) == 0 {
+ hostname, err = os.Hostname()
+ if err != nil {
+ return fmt.Errorf("failed to find Hostname: %w", err)
+ }
+ }
+
+ if err = client.Hello(hostname); err != nil {
+ return fmt.Errorf("failed to send Helo: %w", err)
+ }
+ }
+
+ // If not using SMTPS, always use STARTTLS if available
+ hasStartTLS, _ := client.Extension("STARTTLS")
+ if !source.UseTLS() && hasStartTLS {
+ if err = client.StartTLS(tlsConfig); err != nil {
+ return fmt.Errorf("failed to start StartTLS: %w", err)
+ }
+ }
+
+ if ok, _ := client.Extension("AUTH"); ok {
+ return client.Auth(a)
+ }
+
+ return ErrUnsupportedLoginType
+}
diff --git a/services/auth/source/smtp/source.go b/services/auth/source/smtp/source.go
new file mode 100644
index 0000000..2a648e4
--- /dev/null
+++ b/services/auth/source/smtp/source.go
@@ -0,0 +1,66 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package smtp
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// _________ __________________________
+// / _____/ / \__ ___/\______ \
+// \_____ \ / \ / \| | | ___/
+// / \/ Y \ | | |
+// /_______ /\____|__ /____| |____|
+// \/ \/
+
+// Source holds configuration for the SMTP login source.
+type Source struct {
+ Auth string
+ Host string
+ Port int
+ AllowedDomains string `xorm:"TEXT"`
+ ForceSMTPS bool
+ SkipVerify bool
+ HeloHostname string
+ DisableHelo bool
+ SkipLocalTwoFA bool `json:",omitempty"`
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up an SMTPConfig from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports an SMTPConfig to a serialized format.
+func (source *Source) ToDB() ([]byte, error) {
+ return json.Marshal(source)
+}
+
+// IsSkipVerify returns if SkipVerify is set
+func (source *Source) IsSkipVerify() bool {
+ return source.SkipVerify
+}
+
+// HasTLS returns true for SMTP
+func (source *Source) HasTLS() bool {
+ return true
+}
+
+// UseTLS returns if TLS is set
+func (source *Source) UseTLS() bool {
+ return source.ForceSMTPS || source.Port == 465
+}
+
+// SetAuthSource sets the related AuthSource
+func (source *Source) SetAuthSource(authSource *auth.Source) {
+ source.authSource = authSource
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.SMTP, &Source{})
+}
diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go
new file mode 100644
index 0000000..1f0a61c
--- /dev/null
+++ b/services/auth/source/smtp/source_authenticate.go
@@ -0,0 +1,92 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package smtp
+
+import (
+ "context"
+ "errors"
+ "net/smtp"
+ "net/textproto"
+ "strings"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// Authenticate queries if the provided login/password is authenticates against the SMTP server
+// Users will be autoregistered as required
+func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
+ // Verify allowed domains.
+ if len(source.AllowedDomains) > 0 {
+ idx := strings.Index(userName, "@")
+ if idx == -1 {
+ return nil, user_model.ErrUserNotExist{Name: userName}
+ } else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), userName[idx+1:], true) {
+ return nil, user_model.ErrUserNotExist{Name: userName}
+ }
+ }
+
+ var auth smtp.Auth
+ switch source.Auth {
+ case PlainAuthentication:
+ auth = smtp.PlainAuth("", userName, password, source.Host)
+ case LoginAuthentication:
+ auth = &loginAuthenticator{userName, password}
+ case CRAMMD5Authentication:
+ auth = smtp.CRAMMD5Auth(userName, password)
+ default:
+ return nil, errors.New("unsupported SMTP auth type")
+ }
+
+ if err := Authenticate(auth, source); err != nil {
+ // Check standard error format first,
+ // then fallback to worse case.
+ tperr, ok := err.(*textproto.Error)
+ if (ok && tperr.Code == 535) ||
+ strings.Contains(err.Error(), "Username and Password not accepted") {
+ return nil, user_model.ErrUserNotExist{Name: userName}
+ }
+ if (ok && tperr.Code == 534) ||
+ strings.Contains(err.Error(), "Application-specific password required") {
+ return nil, user_model.ErrUserNotExist{Name: userName}
+ }
+ return nil, err
+ }
+
+ if user != nil {
+ return user, nil
+ }
+
+ username := userName
+ idx := strings.Index(userName, "@")
+ if idx > -1 {
+ username = userName[:idx]
+ }
+
+ user = &user_model.User{
+ LowerName: strings.ToLower(username),
+ Name: strings.ToLower(username),
+ Email: userName,
+ Passwd: password,
+ LoginType: auth_model.SMTP,
+ LoginSource: source.authSource.ID,
+ LoginName: userName,
+ }
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(true),
+ }
+
+ if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil {
+ return user, err
+ }
+
+ return user, nil
+}
+
+// IsSkipLocalTwoFA returns if this source should skip local 2fa for password authentication
+func (source *Source) IsSkipLocalTwoFA() bool {
+ return source.SkipLocalTwoFA
+}
diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go
new file mode 100644
index 0000000..3a2411e
--- /dev/null
+++ b/services/auth/source/source_group_sync.go
@@ -0,0 +1,116 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package source
+
+import (
+ "context"
+ "fmt"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/organization"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/container"
+ "code.gitea.io/gitea/modules/log"
+)
+
+type syncType int
+
+const (
+ syncAdd syncType = iota
+ syncRemove
+)
+
+// SyncGroupsToTeams maps authentication source groups to organization and team memberships
+func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool) error {
+ orgCache := make(map[string]*organization.Organization)
+ teamCache := make(map[string]*organization.Team)
+ return SyncGroupsToTeamsCached(ctx, user, sourceUserGroups, sourceGroupTeamMapping, performRemoval, orgCache, teamCache)
+}
+
+// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships
+func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
+ membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping)
+
+ if performRemoval {
+ if err := syncGroupsToTeamsCached(ctx, user, membershipsToRemove, syncRemove, orgCache, teamCache); err != nil {
+ return fmt.Errorf("could not sync[remove] user groups: %w", err)
+ }
+ }
+
+ if err := syncGroupsToTeamsCached(ctx, user, membershipsToAdd, syncAdd, orgCache, teamCache); err != nil {
+ return fmt.Errorf("could not sync[add] user groups: %w", err)
+ }
+
+ return nil
+}
+
+func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string) (map[string][]string, map[string][]string) {
+ membershipsToAdd := map[string][]string{}
+ membershipsToRemove := map[string][]string{}
+ for group, memberships := range sourceGroupTeamMapping {
+ isUserInGroup := sourceUserGroups.Contains(group)
+ if isUserInGroup {
+ for org, teams := range memberships {
+ membershipsToAdd[org] = append(membershipsToAdd[org], teams...)
+ }
+ } else {
+ for org, teams := range memberships {
+ membershipsToRemove[org] = append(membershipsToRemove[org], teams...)
+ }
+ }
+ }
+ return membershipsToAdd, membershipsToRemove
+}
+
+func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeamMap map[string][]string, action syncType, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
+ for orgName, teamNames := range orgTeamMap {
+ var err error
+ org, ok := orgCache[orgName]
+ if !ok {
+ org, err = organization.GetOrgByName(ctx, orgName)
+ if err != nil {
+ if organization.IsErrOrgNotExist(err) {
+ // organization must be created before group sync
+ log.Warn("group sync: Could not find organisation %s: %v", orgName, err)
+ continue
+ }
+ return err
+ }
+ orgCache[orgName] = org
+ }
+ for _, teamName := range teamNames {
+ team, ok := teamCache[orgName+teamName]
+ if !ok {
+ team, err = org.GetTeam(ctx, teamName)
+ if err != nil {
+ if organization.IsErrTeamNotExist(err) {
+ // team must be created before group sync
+ log.Warn("group sync: Could not find team %s: %v", teamName, err)
+ continue
+ }
+ return err
+ }
+ teamCache[orgName+teamName] = team
+ }
+
+ isMember, err := organization.IsTeamMember(ctx, org.ID, team.ID, user.ID)
+ if err != nil {
+ return err
+ }
+
+ if action == syncAdd && !isMember {
+ if err := models.AddTeamMember(ctx, team, user.ID); err != nil {
+ log.Error("group sync: Could not add user to team: %v", err)
+ return err
+ }
+ } else if action == syncRemove && isMember {
+ if err := models.RemoveTeamMember(ctx, team, user.ID); err != nil {
+ log.Error("group sync: Could not remove user from team: %v", err)
+ return err
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/services/auth/source/sspi/assert_interface_test.go b/services/auth/source/sspi/assert_interface_test.go
new file mode 100644
index 0000000..03d836d
--- /dev/null
+++ b/services/auth/source/sspi/assert_interface_test.go
@@ -0,0 +1,18 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sspi_test
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/services/auth/source/sspi"
+)
+
+// This test file exists to assert that our Source exposes the interfaces that we expect
+// It tightly binds the interfaces and implementation without breaking go import cycles
+
+type sourceInterface interface {
+ auth.Config
+}
+
+var _ (sourceInterface) = &sspi.Source{}
diff --git a/services/auth/source/sspi/source.go b/services/auth/source/sspi/source.go
new file mode 100644
index 0000000..bdd6ef4
--- /dev/null
+++ b/services/auth/source/sspi/source.go
@@ -0,0 +1,39 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package sspi
+
+import (
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+)
+
+// _________ ___________________.___
+// / _____// _____/\______ \ |
+// \_____ \ \_____ \ | ___/ |
+// / \/ \ | | | |
+// /_______ /_______ / |____| |___|
+// \/ \/
+
+// Source holds configuration for SSPI single sign-on.
+type Source struct {
+ AutoCreateUsers bool
+ AutoActivateUsers bool
+ StripDomainNames bool
+ SeparatorReplacement string
+ DefaultLanguage string
+}
+
+// FromDB fills up an SSPIConfig from serialized format.
+func (cfg *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &cfg)
+}
+
+// ToDB exports an SSPIConfig to a serialized format.
+func (cfg *Source) ToDB() ([]byte, error) {
+ return json.Marshal(cfg)
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.SSPI, &Source{})
+}
diff --git a/services/auth/sspi.go b/services/auth/sspi.go
new file mode 100644
index 0000000..64a127e
--- /dev/null
+++ b/services/auth/sspi.go
@@ -0,0 +1,223 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "sync"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/web/middleware"
+ "code.gitea.io/gitea/services/auth/source/sspi"
+ gitea_context "code.gitea.io/gitea/services/context"
+
+ gouuid "github.com/google/uuid"
+)
+
+const (
+ tplSignIn base.TplName = "user/auth/signin"
+)
+
+type SSPIAuth interface {
+ AppendAuthenticateHeader(w http.ResponseWriter, data string)
+ Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error)
+}
+
+var (
+ sspiAuth SSPIAuth // a global instance of the websspi authenticator to avoid acquiring the server credential handle on every request
+ sspiAuthOnce sync.Once
+ sspiAuthErrInit error
+
+ // Ensure the struct implements the interface.
+ _ Method = &SSPI{}
+)
+
+// SSPI implements the SingleSignOn interface and authenticates requests
+// via the built-in SSPI module in Windows for SPNEGO authentication.
+// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation
+// fails (or if negotiation should continue), which would prevent other authentication methods
+// to execute at all.
+type SSPI struct{}
+
+// Name represents the name of auth method
+func (s *SSPI) Name() string {
+ return "sspi"
+}
+
+// Verify uses SSPI (Windows implementation of SPNEGO) to authenticate the request.
+// If authentication is successful, returns the corresponding user object.
+// If negotiation should continue or authentication fails, immediately returns a 401 HTTP
+// response code, as required by the SPNEGO protocol.
+func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
+ sspiAuthOnce.Do(func() { sspiAuthErrInit = sspiAuthInit() })
+ if sspiAuthErrInit != nil {
+ return nil, sspiAuthErrInit
+ }
+ if !s.shouldAuthenticate(req) {
+ return nil, nil
+ }
+
+ cfg, err := s.getConfig(req.Context())
+ if err != nil {
+ log.Error("could not get SSPI config: %v", err)
+ return nil, err
+ }
+
+ log.Trace("SSPI Authorization: Attempting to authenticate")
+ userInfo, outToken, err := sspiAuth.Authenticate(req, w)
+ if err != nil {
+ log.Warn("Authentication failed with error: %v\n", err)
+ sspiAuth.AppendAuthenticateHeader(w, outToken)
+
+ // Include the user login page in the 401 response to allow the user
+ // to login with another authentication method if SSPI authentication
+ // fails
+ store.GetData()["Flash"] = map[string]string{
+ "ErrorMsg": err.Error(),
+ }
+ store.GetData()["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn
+ store.GetData()["EnableSSPI"] = true
+ // in this case, the Verify function is called in Gitea's web context
+ // FIXME: it doesn't look good to render the page here, why not redirect?
+ gitea_context.GetWebContext(req).HTML(http.StatusUnauthorized, tplSignIn)
+ return nil, err
+ }
+ if outToken != "" {
+ sspiAuth.AppendAuthenticateHeader(w, outToken)
+ }
+
+ username := sanitizeUsername(userInfo.Username, cfg)
+ if len(username) == 0 {
+ return nil, nil
+ }
+ log.Info("Authenticated as %s\n", username)
+
+ user, err := user_model.GetUserByName(req.Context(), username)
+ if err != nil {
+ if !user_model.IsErrUserNotExist(err) {
+ log.Error("GetUserByName: %v", err)
+ return nil, err
+ }
+ if !cfg.AutoCreateUsers {
+ log.Error("User '%s' not found", username)
+ return nil, nil
+ }
+ user, err = s.newUser(req.Context(), username, cfg)
+ if err != nil {
+ log.Error("CreateUser: %v", err)
+ return nil, err
+ }
+ }
+
+ // Make sure requests to API paths and PWA resources do not create a new session
+ if !middleware.IsAPIPath(req) && !isAttachmentDownload(req) {
+ handleSignIn(w, req, sess, user)
+ }
+
+ log.Trace("SSPI Authorization: Logged in user %-v", user)
+ return user, nil
+}
+
+// getConfig retrieves the SSPI configuration from login sources
+func (s *SSPI) getConfig(ctx context.Context) (*sspi.Source, error) {
+ sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
+ IsActive: optional.Some(true),
+ LoginType: auth.SSPI,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(sources) == 0 {
+ return nil, errors.New("no active login sources of type SSPI found")
+ }
+ if len(sources) > 1 {
+ return nil, errors.New("more than one active login source of type SSPI found")
+ }
+ return sources[0].Cfg.(*sspi.Source), nil
+}
+
+func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) {
+ shouldAuth = false
+ path := strings.TrimSuffix(req.URL.Path, "/")
+ if path == "/user/login" {
+ if req.FormValue("user_name") != "" && req.FormValue("password") != "" {
+ shouldAuth = false
+ } else if req.FormValue("auth_with_sspi") == "1" {
+ shouldAuth = true
+ }
+ } else if middleware.IsAPIPath(req) || isAttachmentDownload(req) {
+ shouldAuth = true
+ }
+ return shouldAuth
+}
+
+// newUser creates a new user object for the purpose of automatic registration
+// and populates its name and email with the information present in request headers.
+func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (*user_model.User, error) {
+ email := gouuid.New().String() + "@localhost.localdomain"
+ user := &user_model.User{
+ Name: username,
+ Email: email,
+ Language: cfg.DefaultLanguage,
+ }
+ emailNotificationPreference := user_model.EmailNotificationsDisabled
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(cfg.AutoActivateUsers),
+ KeepEmailPrivate: optional.Some(true),
+ EmailNotificationsPreference: &emailNotificationPreference,
+ }
+ if err := user_model.CreateUser(ctx, user, overwriteDefault); err != nil {
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// stripDomainNames removes NETBIOS domain name and separator from down-level logon names
+// (eg. "DOMAIN\user" becomes "user"), and removes the UPN suffix (domain name) and separator
+// from UPNs (eg. "user@domain.local" becomes "user")
+func stripDomainNames(username string) string {
+ if strings.Contains(username, "\\") {
+ parts := strings.SplitN(username, "\\", 2)
+ if len(parts) > 1 {
+ username = parts[1]
+ }
+ } else if strings.Contains(username, "@") {
+ parts := strings.Split(username, "@")
+ if len(parts) > 1 {
+ username = parts[0]
+ }
+ }
+ return username
+}
+
+func replaceSeparators(username string, cfg *sspi.Source) string {
+ newSep := cfg.SeparatorReplacement
+ username = strings.ReplaceAll(username, "\\", newSep)
+ username = strings.ReplaceAll(username, "/", newSep)
+ username = strings.ReplaceAll(username, "@", newSep)
+ return username
+}
+
+func sanitizeUsername(username string, cfg *sspi.Source) string {
+ if len(username) == 0 {
+ return ""
+ }
+ if cfg.StripDomainNames {
+ username = stripDomainNames(username)
+ }
+ // Replace separators even if we have already stripped the domain name part,
+ // as the username can contain several separators: eg. "MICROSOFT\useremail@live.com"
+ username = replaceSeparators(username, cfg)
+ return username
+}
diff --git a/services/auth/sspiauth_posix.go b/services/auth/sspiauth_posix.go
new file mode 100644
index 0000000..49b0ed4
--- /dev/null
+++ b/services/auth/sspiauth_posix.go
@@ -0,0 +1,30 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build !windows
+
+package auth
+
+import (
+ "errors"
+ "net/http"
+)
+
+type SSPIUserInfo struct {
+ Username string // Name of user, usually in the form DOMAIN\User
+ Groups []string // The global groups the user is a member of
+}
+
+type sspiAuthMock struct{}
+
+func (s sspiAuthMock) AppendAuthenticateHeader(w http.ResponseWriter, data string) {
+}
+
+func (s sspiAuthMock) Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error) {
+ return nil, "", errors.New("not implemented")
+}
+
+func sspiAuthInit() error {
+ sspiAuth = &sspiAuthMock{} // TODO: we can mock the SSPI auth in tests
+ return nil
+}
diff --git a/services/auth/sspiauth_windows.go b/services/auth/sspiauth_windows.go
new file mode 100644
index 0000000..093caae
--- /dev/null
+++ b/services/auth/sspiauth_windows.go
@@ -0,0 +1,19 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//go:build windows
+
+package auth
+
+import (
+ "github.com/quasoft/websspi"
+)
+
+type SSPIUserInfo = websspi.UserInfo
+
+func sspiAuthInit() error {
+ var err error
+ config := websspi.NewConfig()
+ sspiAuth, err = websspi.New(config)
+ return err
+}
diff --git a/services/auth/sync.go b/services/auth/sync.go
new file mode 100644
index 0000000..7562ac8
--- /dev/null
+++ b/services/auth/sync.go
@@ -0,0 +1,43 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/log"
+)
+
+// SyncExternalUsers is used to synchronize users with external authorization source
+func SyncExternalUsers(ctx context.Context, updateExisting bool) error {
+ log.Trace("Doing: SyncExternalUsers")
+
+ ls, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
+ if err != nil {
+ log.Error("SyncExternalUsers: %v", err)
+ return err
+ }
+
+ for _, s := range ls {
+ if !s.IsActive || !s.IsSyncEnabled {
+ continue
+ }
+ select {
+ case <-ctx.Done():
+ log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name)
+ return db.ErrCancelledf("Before update of %s", s.Name)
+ default:
+ }
+
+ if syncable, ok := s.Cfg.(SynchronizableSource); ok {
+ err := syncable.Sync(ctx, updateExisting)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}