diff options
author | zeripath <art27@cantab.net> | 2021-07-24 11:16:34 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-07-24 11:16:34 +0100 |
commit | 5d2e11eedb837f26d13e3b904583730cd8492fbd (patch) | |
tree | d323dc6c910809f87c29cb6511b3a10fc3605818 /services/auth | |
parent | f135a818f53d82a61f3d99d80e2a2384f00c51d2 (diff) | |
download | gitea-5d2e11eedb837f26d13e3b904583730cd8492fbd.tar.gz gitea-5d2e11eedb837f26d13e3b904583730cd8492fbd.zip |
Refactor: Move login out of models (#16199)
`models` does far too much. In particular it handles all `UserSignin`.
It shouldn't be responsible for calling LDAP, SMTP or PAM for signing in.
Therefore we should move this code out of `models`.
This code has to depend on `models` - therefore it belongs in `services`.
There is a package in `services` called `auth` and clearly this functionality belongs in there.
Plan:
- [x] Change `auth.Auth` to `auth.Method` - as they represent methods of authentication.
- [x] Move `models.UserSignIn` into `auth`
- [x] Move `models.ExternalUserLogin`
- [x] Move most of the `LoginVia*` methods to `auth` or subpackages
- [x] Move Resynchronize functionality to `auth`
- Involved some restructuring of `models/ssh_key.go` to reduce the size of this massive file and simplify its files.
- [x] Move the rest of the LDAP functionality in to the ldap subpackage
- [x] Re-factor the login sources to express an interfaces `auth.Source`?
- I've done this through some smaller interfaces Authenticator and Synchronizable - which would allow us to extend things in future
- [x] Now LDAP is out of models - need to think about modules/auth/ldap and I think all of that functionality might just be moveable
- [x] Similarly a lot Oauth2 functionality need not be in models too and should be moved to services/auth/source/oauth2
- [x] modules/auth/oauth2/oauth2.go uses xorm... This is naughty - probably need to move this into models.
- [x] models/oauth2.go - mostly should be in modules/auth/oauth2 or services/auth/source/oauth2
- [x] More simplifications of login_source.go may need to be done
- Allow wiring in of notify registration - *this can now easily be done - but I think we should do it in another PR* - see #16178
- More refactors...?
- OpenID should probably become an auth Method but I think that can be left for another PR
- Methods should also probably be cleaned up - again another PR I think.
- SSPI still needs more refactors.* Rename auth.Auth auth.Method
* Restructure ssh_key.go
- move functions from models/user.go that relate to ssh_key to ssh_key
- split ssh_key.go to try create clearer function domains for allow for
future refactors here.
Signed-off-by: Andrew Thornton <art27@cantab.net>
Diffstat (limited to 'services/auth')
40 files changed, 2804 insertions, 80 deletions
diff --git a/services/auth/auth.go b/services/auth/auth.go index 5492a8b74e..11a8c6ed1c 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -27,7 +27,7 @@ import ( // // The Session plugin is expected to be executed second, in order to skip authentication // for users that have already signed in. -var authMethods = []Auth{ +var authMethods = []Method{ &OAuth2{}, &Basic{}, &Session{}, @@ -40,12 +40,12 @@ var ( ) // Methods returns the instances of all registered methods -func Methods() []Auth { +func Methods() []Method { return authMethods } // Register adds the specified instance to the list of available methods -func Register(method Auth) { +func Register(method Method) { authMethods = append(authMethods, method) } @@ -57,7 +57,12 @@ func Init() { } specialInit() for _, method := range Methods() { - err := method.Init() + initializable, ok := method.(Initializable) + if !ok { + continue + } + + err := initializable.Init() if err != nil { log.Error("Could not initialize '%s' auth method, error: %s", reflect.TypeOf(method).String(), err) } @@ -68,7 +73,12 @@ func Init() { // to release necessary resources func Free() { for _, method := range Methods() { - err := method.Free() + freeable, ok := method.(Freeable) + if !ok { + continue + } + + err := freeable.Free() if err != nil { log.Error("Could not free '%s' auth method, error: %s", reflect.TypeOf(method).String(), err) } diff --git a/services/auth/basic.go b/services/auth/basic.go index 0bce4f1d06..d492a52a66 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -19,7 +19,8 @@ import ( // Ensure the struct implements the interface. var ( - _ Auth = &Basic{} + _ Method = &Basic{} + _ Named = &Basic{} ) // Basic implements the Auth interface and authenticates requests (API requests @@ -33,16 +34,6 @@ func (b *Basic) Name() string { return "basic" } -// Init does nothing as the Basic implementation does not need to allocate any resources -func (b *Basic) Init() error { - return nil -} - -// Free does nothing as the Basic implementation does not have to release any resources -func (b *Basic) Free() error { - return nil -} - // 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. @@ -116,7 +107,7 @@ func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore } log.Trace("Basic Authorization: Attempting SignIn for %s", uname) - u, err := models.UserSignIn(uname, passwd) + u, err := UserSignIn(uname, passwd) if err != nil { if !models.IsErrUserNotExist(err) { log.Error("UserSignIn: %v", err) diff --git a/services/auth/group.go b/services/auth/group.go index b61949de7d..fb885b818a 100644 --- a/services/auth/group.go +++ b/services/auth/group.go @@ -12,30 +12,32 @@ import ( // Ensure the struct implements the interface. var ( - _ Auth = &Group{} + _ Method = &Group{} + _ Initializable = &Group{} + _ Freeable = &Group{} ) // Group implements the Auth interface with serval Auth. type Group struct { - methods []Auth + methods []Method } // NewGroup creates a new auth group -func NewGroup(methods ...Auth) *Group { +func NewGroup(methods ...Method) *Group { return &Group{ methods: methods, } } -// Name represents the name of auth method -func (b *Group) Name() string { - return "group" -} - // Init does nothing as the Basic implementation does not need to allocate any resources func (b *Group) Init() error { - for _, m := range b.methods { - if err := m.Init(); err != nil { + for _, method := range b.methods { + initializable, ok := method.(Initializable) + if !ok { + continue + } + + if err := initializable.Init(); err != nil { return err } } @@ -44,8 +46,12 @@ func (b *Group) Init() error { // Free does nothing as the Basic implementation does not have to release any resources func (b *Group) Free() error { - for _, m := range b.methods { - if err := m.Free(); err != nil { + for _, method := range b.methods { + freeable, ok := method.(Freeable) + if !ok { + continue + } + if err := freeable.Free(); err != nil { return err } } @@ -63,7 +69,9 @@ func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore user := ssoMethod.Verify(req, w, store, sess) if user != nil { if store.GetData()["AuthedMethod"] == nil { - store.GetData()["AuthedMethod"] = ssoMethod.Name() + if named, ok := ssoMethod.(Named); ok { + store.GetData()["AuthedMethod"] = named.Name() + } } return user } diff --git a/services/auth/interface.go b/services/auth/interface.go index a305bdfc22..51c7043370 100644 --- a/services/auth/interface.go +++ b/services/auth/interface.go @@ -5,6 +5,7 @@ package auth import ( + "context" "net/http" "code.gitea.io/gitea/models" @@ -18,22 +19,42 @@ type DataStore middleware.DataStore // SessionStore represents a session store type SessionStore session.Store -// Auth represents an authentication method (plugin) for HTTP requests. -type Auth interface { - Name() string +// 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). + // Returns nil if verification fails. + Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User +} +// Initializable represents a structure that requires initialization +// It usually should only be called once before anything else is called +type Initializable interface { // Init should be called exactly once before using any of the other methods, // in order to allow the plugin to allocate necessary resources Init() error +} +// Named represents a named thing +type Named interface { + Name() string +} + +// Freeable represents a structure that is required to be freed +type Freeable interface { // Free should be called exactly once before application closes, in order to // give chance to the plugin to free any allocated resources Free() error +} - // 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). - // Returns nil if verification fails. - Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) *models.User +// PasswordAuthenticator represents a source of authentication +type PasswordAuthenticator interface { + Authenticate(user *models.User, login, password string) (*models.User, error) +} + +// SynchronizableSource represents a source that can synchronize users +type SynchronizableSource interface { + Sync(ctx context.Context, updateExisting bool) error } diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index c6b98c144f..93806c7072 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -14,11 +14,13 @@ import ( "code.gitea.io/gitea/modules/log" "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 ( - _ Auth = &OAuth2{} + _ Method = &OAuth2{} + _ Named = &OAuth2{} ) // CheckOAuthAccessToken returns uid of user from oauth token @@ -27,7 +29,7 @@ func CheckOAuthAccessToken(accessToken string) int64 { if !strings.Contains(accessToken, ".") { return 0 } - token, err := models.ParseOAuth2Token(accessToken) + token, err := oauth2.ParseToken(accessToken) if err != nil { log.Trace("ParseOAuth2Token: %v", err) return 0 @@ -36,7 +38,7 @@ func CheckOAuthAccessToken(accessToken string) int64 { if grant, err = models.GetOAuth2GrantByID(token.GrantID); err != nil || grant == nil { return 0 } - if token.Type != models.TypeAccessToken { + if token.Type != oauth2.TypeAccessToken { return 0 } if token.ExpiresAt < time.Now().Unix() || token.IssuedAt > time.Now().Unix() { @@ -51,21 +53,11 @@ func CheckOAuthAccessToken(accessToken string) int64 { type OAuth2 struct { } -// Init does nothing as the OAuth2 implementation does not need to allocate any resources -func (o *OAuth2) Init() error { - return nil -} - // Name represents the name of auth method func (o *OAuth2) Name() string { return "oauth2" } -// Free does nothing as the OAuth2 implementation does not have to release any resources -func (o *OAuth2) Free() error { - return nil -} - // userIDFromToken returns the user id corresponding to the OAuth token. func (o *OAuth2) userIDFromToken(req *http.Request, store DataStore) int64 { _ = req.ParseForm() diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index f958d28c9a..46d8d3fa63 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -19,7 +19,8 @@ import ( // Ensure the struct implements the interface. var ( - _ Auth = &ReverseProxy{} + _ Method = &ReverseProxy{} + _ Named = &ReverseProxy{} ) // ReverseProxy implements the Auth interface, but actually relies on @@ -44,16 +45,6 @@ func (r *ReverseProxy) Name() string { return "reverse_proxy" } -// Init does nothing as the ReverseProxy implementation does not need initialization -func (r *ReverseProxy) Init() error { - return nil -} - -// Free does nothing as the ReverseProxy implementation does not have to release resources -func (r *ReverseProxy) Free() error { - return nil -} - // Verify 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 diff --git a/services/auth/session.go b/services/auth/session.go index 9f08f43363..9a6e2d95d0 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -13,7 +13,8 @@ import ( // Ensure the struct implements the interface. var ( - _ Auth = &Session{} + _ Method = &Session{} + _ Named = &Session{} ) // Session checks if there is a user uid stored in the session and returns the user @@ -21,21 +22,11 @@ var ( type Session struct { } -// Init does nothing as the Session implementation does not need to allocate any resources -func (s *Session) Init() error { - return nil -} - // Name represents the name of auth method func (s *Session) Name() string { return "session" } -// Free does nothing as the Session implementation does not have to release any resources -func (s *Session) Free() error { - return nil -} - // 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. diff --git a/services/auth/signin.go b/services/auth/signin.go new file mode 100644 index 0000000000..2c4bf9b35b --- /dev/null +++ b/services/auth/signin.go @@ -0,0 +1,113 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + + // Register the sources + _ "code.gitea.io/gitea/services/auth/source/db" + _ "code.gitea.io/gitea/services/auth/source/ldap" + _ "code.gitea.io/gitea/services/auth/source/oauth2" + _ "code.gitea.io/gitea/services/auth/source/pam" + _ "code.gitea.io/gitea/services/auth/source/smtp" + _ "code.gitea.io/gitea/services/auth/source/sspi" +) + +// UserSignIn validates user name and password. +func UserSignIn(username, password string) (*models.User, error) { + var user *models.User + if strings.Contains(username, "@") { + user = &models.User{Email: strings.ToLower(strings.TrimSpace(username))} + // check same email + cnt, err := models.Count(user) + if err != nil { + return nil, err + } + if cnt > 1 { + return nil, models.ErrEmailAlreadyUsed{ + Email: user.Email, + } + } + } else { + trimmedUsername := strings.TrimSpace(username) + if len(trimmedUsername) == 0 { + return nil, models.ErrUserNotExist{Name: username} + } + + user = &models.User{LowerName: strings.ToLower(trimmedUsername)} + } + + hasUser, err := models.GetUser(user) + if err != nil { + return nil, err + } + + if hasUser { + source, err := models.GetLoginSourceByID(user.LoginSource) + if err != nil { + return nil, err + } + + if !source.IsActive { + return nil, models.ErrLoginSourceNotActived + } + + authenticator, ok := source.Cfg.(PasswordAuthenticator) + if !ok { + return nil, models.ErrUnsupportedLoginType + } + + user, err := authenticator.Authenticate(user, username, password) + if err != nil { + return 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, models.ErrUserProhibitLogin{UID: user.ID, Name: user.Name} + } + + return user, nil + } + + sources, err := models.AllActiveLoginSources() + if err != nil { + return 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(nil, username, password) + + if err == nil { + if !authUser.ProhibitLogin { + return authUser, nil + } + err = models.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name} + } + + if models.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) + } + } + + return nil, models.ErrUserNotExist{Name: username} +} 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 0000000000..2e0fa9ba22 --- /dev/null +++ b/services/auth/source/db/assert_interface_test.go @@ -0,0 +1,21 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db_test + +import ( + "code.gitea.io/gitea/models" + "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 + models.LoginConfig +} + +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 0000000000..e73ab15d28 --- /dev/null +++ b/services/auth/source/db/authenticate.go @@ -0,0 +1,42 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" +) + +// Authenticate authenticates the provided user against the DB +func Authenticate(user *models.User, login, password string) (*models.User, error) { + if user == nil { + return nil, models.ErrUserNotExist{Name: login} + } + + if !user.IsPasswordSet() || !user.ValidatePassword(password) { + return nil, models.ErrUserNotExist{UID: user.ID, Name: user.Name} + } + + // Update password hash if server password hash algorithm have changed + if user.PasswdHashAlgo != setting.PasswordHashAlgo { + if err := user.SetPassword(password); err != nil { + return nil, err + } + if err := models.UpdateUserCols(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 hint to resend confirm email. + if user.ProhibitLogin { + return nil, models.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 0000000000..182c05f0df --- /dev/null +++ b/services/auth/source/db/source.go @@ -0,0 +1,31 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package db + +import "code.gitea.io/gitea/models" + +// 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 an SMTPConfig to a serialized format. +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(user *models.User, login, password string) (*models.User, error) { + return Authenticate(user, login, password) +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginNoType, &Source{}) + models.RegisterLoginTypeConfig(models.LoginPlain, &Source{}) +} diff --git a/services/auth/source/ldap/README.md b/services/auth/source/ldap/README.md new file mode 100644 index 0000000000..3a839fa314 --- /dev/null +++ b/services/auth/source/ldap/README.md @@ -0,0 +1,122 @@ +# 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 '%s' matching parameter will be substituted with the + user's username. + * Example: (&(objectClass=posixAccount)(uid=%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 `%s` + matching parameter will be substituted with the user's username. + * Example: (&(objectClass=posixAccount)(cn=%s)) + * Example: (&(objectClass=posixAccount)(uid=%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) + * Which user LDAP attribute is listed in the group. + * Example: uid + +* Group Attribute for User (optional) + * Which group LDAP attribute contains an array above user attribute names. + * Example: memberUid 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 0000000000..4cf3eafe76 --- /dev/null +++ b/services/auth/source/ldap/assert_interface_test.go @@ -0,0 +1,27 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ldap_test + +import ( + "code.gitea.io/gitea/models" + "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 + models.SSHKeyProvider + models.LoginConfig + models.SkipVerifiable + models.HasTLSer + models.UseTLSer + models.LoginSourceSettable +} + +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 0000000000..47c9d30e5c --- /dev/null +++ b/services/auth/source/ldap/security_protocol.go @@ -0,0 +1,27 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +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] +} + +// 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 0000000000..87be0117ee --- /dev/null +++ b/services/auth/source/ldap/source.go @@ -0,0 +1,120 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ldap + +import ( + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/secret" + "code.gitea.io/gitea/modules/setting" + + jsoniter "github.com/json-iterator/go" +) + +// .____ ________ _____ __________ +// | | \______ \ / _ \\______ \ +// | | | | \ / /_\ \| ___/ +// | |___ | ` \/ | \ | +// |_______ \/_______ /\____|__ /____| +// \/ \/ \/ + +// 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 + 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 + 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 + UserUID string // User Attribute listed in Group + + // reference to the loginSource + loginSource *models.LoginSource +} + +// FromDB fills up a LDAPConfig from serialized format. +func (source *Source) FromDB(bs []byte) error { + err := models.JSONUnmarshalHandleDoubleEncode(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 = "" + json := jsoniter.ConfigCompatibleWithStandardLibrary + 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 +} + +// SetLoginSource sets the related LoginSource +func (source *Source) SetLoginSource(loginSource *models.LoginSource) { + source.loginSource = loginSource +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginLDAP, &Source{}) + models.RegisterLoginTypeConfig(models.LoginDLDAP, &Source{}) +} diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go new file mode 100644 index 0000000000..1d5e69539b --- /dev/null +++ b/services/auth/source/ldap/source_authenticate.go @@ -0,0 +1,93 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ldap + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/models" +) + +// 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(user *models.User, login, password string) (*models.User, error) { + sr := source.SearchEntry(login, password, source.loginSource.Type == models.LoginDLDAP) + if sr == nil { + // User not in LDAP, do nothing + return nil, models.ErrUserNotExist{Name: login} + } + + isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 + + // Update User admin flag if exist + if isExist, err := models.IsUserExist(0, sr.Username); err != nil { + return nil, err + } else if isExist { + if user == nil { + user, err = models.GetUserByName(sr.Username) + if err != nil { + return nil, err + } + } + if user != nil && !user.ProhibitLogin { + cols := make([]string, 0) + if len(source.AdminFilter) > 0 && user.IsAdmin != sr.IsAdmin { + // Change existing admin flag only if AdminFilter option is set + user.IsAdmin = sr.IsAdmin + cols = append(cols, "is_admin") + } + if !user.IsAdmin && len(source.RestrictedFilter) > 0 && user.IsRestricted != sr.IsRestricted { + // Change existing restricted flag only if RestrictedFilter option is set + user.IsRestricted = sr.IsRestricted + cols = append(cols, "is_restricted") + } + if len(cols) > 0 { + err = models.UpdateUserCols(user, cols...) + if err != nil { + return nil, err + } + } + } + } + + if user != nil { + if isAttributeSSHPublicKeySet && models.SynchronizePublicKeys(user, source.loginSource, sr.SSHPublicKey) { + return user, models.RewriteAllPublicKeys() + } + + return user, nil + } + + // Fallback. + if len(sr.Username) == 0 { + sr.Username = login + } + + if len(sr.Mail) == 0 { + sr.Mail = fmt.Sprintf("%s@localhost", sr.Username) + } + + user = &models.User{ + LowerName: strings.ToLower(sr.Username), + Name: sr.Username, + FullName: composeFullName(sr.Name, sr.Surname, sr.Username), + Email: sr.Mail, + LoginType: source.loginSource.Type, + LoginSource: source.loginSource.ID, + LoginName: login, + IsActive: true, + IsAdmin: sr.IsAdmin, + IsRestricted: sr.IsRestricted, + } + + err := models.CreateUser(user) + + if err == nil && isAttributeSSHPublicKeySet && models.AddPublicKeysBySource(user, source.loginSource, sr.SSHPublicKey) { + err = models.RewriteAllPublicKeys() + } + + return user, err +} diff --git a/services/auth/source/ldap/source_search.go b/services/auth/source/ldap/source_search.go new file mode 100644 index 0000000000..e99fc67901 --- /dev/null +++ b/services/auth/source/ldap/source_search.go @@ -0,0 +1,443 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ldap + +import ( + "crypto/tls" + "fmt" + "strings" + + "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 +} + +func (ls *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(ls.Filter, username), true +} + +func (ls *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(ls.UserDN, username), true +} + +func (ls *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 (ls *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 (ls *Source) findUserDN(l *ldap.Conn, name string) (string, bool) { + log.Trace("Search for LDAP user: %s", name) + + // A search for the user. + userFilter, ok := ls.sanitizedUserQuery(name) + if !ok { + return "", false + } + + log.Trace("Searching for DN using filter %s and base %s", userFilter, ls.UserBase) + search := ldap.NewSearchRequest( + ls.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(ls *Source) (*ldap.Conn, error) { + log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", ls.SecurityProtocol, ls.SkipVerify) + + tlsCfg := &tls.Config{ + ServerName: ls.Host, + InsecureSkipVerify: ls.SkipVerify, + } + if ls.SecurityProtocol == SecurityProtocolLDAPS { + return ldap.DialTLS("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port), tlsCfg) + } + + conn, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", ls.Host, ls.Port)) + if err != nil { + return nil, fmt.Errorf("Dial: %v", err) + } + + if ls.SecurityProtocol == SecurityProtocolStartTLS { + if err = conn.StartTLS(tlsCfg); err != nil { + conn.Close() + return nil, fmt.Errorf("StartTLS: %v", 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 failed unexpectedly! (%v)", 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 failed unexpectedly! (%v)", err) + } else if len(sr.Entries) < 1 { + log.Trace("LDAP Restricted Search found no matching entries.") + } else { + return true + } + return false +} + +// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter +func (ls *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(ls) + if err != nil { + log.Error("LDAP Connect error, %s:%v", ls.Host, err) + ls.Enabled = false + return nil + } + defer l.Close() + + var userDN string + if directBind { + log.Trace("LDAP will bind directly via UserDN template: %s", ls.UserDN) + + var ok bool + userDN, ok = ls.sanitizedUserDN(name) + + if !ok { + return nil + } + + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + + if ls.UserBase != "" { + // not everyone has a CN compatible with input name so we need to find + // the real userDN in that case + + userDN, ok = ls.findUserDN(l, name) + if !ok { + return nil + } + } + } else { + log.Trace("LDAP will use BindDN.") + + var found bool + + if ls.BindDN != "" && ls.BindPassword != "" { + err := l.Bind(ls.BindDN, ls.BindPassword) + if err != nil { + log.Debug("Failed to bind as BindDN[%s]: %v", ls.BindDN, err) + return nil + } + log.Trace("Bound as BindDN %s", ls.BindDN) + } else { + log.Trace("Proceeding with anonymous LDAP search.") + } + + userDN, found = ls.findUserDN(l, name) + if !found { + return nil + } + } + + if !ls.AttributesInBind { + // binds user (checking password) before looking-up attributes in user context + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + } + + userFilter, ok := ls.sanitizedUserQuery(name) + if !ok { + return nil + } + + var isAttributeSSHPublicKeySet = len(strings.TrimSpace(ls.AttributeSSHPublicKey)) > 0 + + attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail} + if len(strings.TrimSpace(ls.UserUID)) > 0 { + attribs = append(attribs, ls.UserUID) + } + if isAttributeSSHPublicKeySet { + attribs = append(attribs, ls.AttributeSSHPublicKey) + } + + log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v' with filter '%s' and base '%s'", ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, ls.AttributeSSHPublicKey, ls.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 + + username := sr.Entries[0].GetAttributeValue(ls.AttributeUsername) + firstname := sr.Entries[0].GetAttributeValue(ls.AttributeName) + surname := sr.Entries[0].GetAttributeValue(ls.AttributeSurname) + mail := sr.Entries[0].GetAttributeValue(ls.AttributeMail) + uid := sr.Entries[0].GetAttributeValue(ls.UserUID) + + // Check group membership + if ls.GroupsEnabled { + groupFilter, ok := ls.sanitizedGroupFilter(ls.GroupFilter) + if !ok { + return nil + } + groupDN, ok := ls.sanitizedGroupDN(ls.GroupDN) + if !ok { + return nil + } + + log.Trace("Fetching groups '%v' with filter '%s' and base '%s'", ls.GroupMemberUID, groupFilter, groupDN) + groupSearch := ldap.NewSearchRequest( + groupDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, groupFilter, + []string{ls.GroupMemberUID}, + nil) + + srg, err := l.Search(groupSearch) + if err != nil { + log.Error("LDAP group search failed: %v", err) + return nil + } else if len(srg.Entries) < 1 { + log.Error("LDAP group search failed: 0 entries") + return nil + } + + isMember := false + Entries: + for _, group := range srg.Entries { + for _, member := range group.GetAttributeValues(ls.GroupMemberUID) { + if (ls.UserUID == "dn" && member == sr.Entries[0].DN) || member == uid { + isMember = true + break Entries + } + } + } + + if !isMember { + log.Error("LDAP group membership test failed") + return nil + } + } + + if isAttributeSSHPublicKeySet { + sshPublicKey = sr.Entries[0].GetAttributeValues(ls.AttributeSSHPublicKey) + } + isAdmin := checkAdmin(l, ls, userDN) + var isRestricted bool + if !isAdmin { + isRestricted = checkRestricted(l, ls, userDN) + } + + if !directBind && ls.AttributesInBind { + // binds user (checking password) after looking-up attributes in BindDN context + err = bindUser(l, userDN, passwd) + if err != nil { + return nil + } + } + + return &SearchResult{ + Username: username, + Name: firstname, + Surname: surname, + Mail: mail, + SSHPublicKey: sshPublicKey, + IsAdmin: isAdmin, + IsRestricted: isRestricted, + } +} + +// UsePagedSearch returns if need to use paged search +func (ls *Source) UsePagedSearch() bool { + return ls.SearchPageSize > 0 +} + +// SearchEntries : search an LDAP source for all users matching userFilter +func (ls *Source) SearchEntries() ([]*SearchResult, error) { + l, err := dial(ls) + if err != nil { + log.Error("LDAP Connect error, %s:%v", ls.Host, err) + ls.Enabled = false + return nil, err + } + defer l.Close() + + if ls.BindDN != "" && ls.BindPassword != "" { + err := l.Bind(ls.BindDN, ls.BindPassword) + if err != nil { + log.Debug("Failed to bind as BindDN[%s]: %v", ls.BindDN, err) + return nil, err + } + log.Trace("Bound as BindDN %s", ls.BindDN) + } else { + log.Trace("Proceeding with anonymous LDAP search.") + } + + userFilter := fmt.Sprintf(ls.Filter, "*") + + var isAttributeSSHPublicKeySet = len(strings.TrimSpace(ls.AttributeSSHPublicKey)) > 0 + + attribs := []string{ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail} + if isAttributeSSHPublicKeySet { + attribs = append(attribs, ls.AttributeSSHPublicKey) + } + + log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v' with filter %s and base %s", ls.AttributeUsername, ls.AttributeName, ls.AttributeSurname, ls.AttributeMail, ls.AttributeSSHPublicKey, userFilter, ls.UserBase) + search := ldap.NewSearchRequest( + ls.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter, + attribs, nil) + + var sr *ldap.SearchResult + if ls.UsePagedSearch() { + sr, err = l.SearchWithPaging(search, ls.SearchPageSize) + } else { + sr, err = l.Search(search) + } + if err != nil { + log.Error("LDAP Search failed unexpectedly! (%v)", err) + return nil, err + } + + result := make([]*SearchResult, len(sr.Entries)) + + for i, v := range sr.Entries { + result[i] = &SearchResult{ + Username: v.GetAttributeValue(ls.AttributeUsername), + Name: v.GetAttributeValue(ls.AttributeName), + Surname: v.GetAttributeValue(ls.AttributeSurname), + Mail: v.GetAttributeValue(ls.AttributeMail), + IsAdmin: checkAdmin(l, ls, v.DN), + } + if !result[i].IsAdmin { + result[i].IsRestricted = checkRestricted(l, ls, v.DN) + } + if isAttributeSSHPublicKeySet { + result[i].SSHPublicKey = v.GetAttributeValues(ls.AttributeSSHPublicKey) + } + } + + 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 0000000000..7e4088e571 --- /dev/null +++ b/services/auth/source/ldap/source_sync.go @@ -0,0 +1,184 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package ldap + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" +) + +// 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.loginSource.Name) + + var existingUsers []int64 + 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 := models.GetUsersBySource(source.loginSource) + if err != nil { + log.Error("SyncExternalUsers: %v", err) + return err + } + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled before update of %s", source.loginSource.Name) + return models.ErrCancelledf("Before update of %s", source.loginSource.Name) + default: + } + + sr, err := source.SearchEntries() + if err != nil { + log.Error("SyncExternalUsers LDAP source failure [%s], skipped", source.loginSource.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") + } + + for _, su := range sr { + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.loginSource.Name) + // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed + if sshKeysNeedUpdate { + err = models.RewriteAllPublicKeys() + if err != nil { + log.Error("RewriteAllPublicKeys: %v", err) + } + } + return models.ErrCancelledf("During update of %s before completed update of users", source.loginSource.Name) + default: + } + if len(su.Username) == 0 { + continue + } + + if len(su.Mail) == 0 { + su.Mail = fmt.Sprintf("%s@localhost", su.Username) + } + + var usr *models.User + // Search for existing user + for _, du := range users { + if du.LowerName == strings.ToLower(su.Username) { + usr = du + break + } + } + + 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.loginSource.Name, su.Username) + + usr = &models.User{ + LowerName: strings.ToLower(su.Username), + Name: su.Username, + FullName: fullName, + LoginType: source.loginSource.Type, + LoginSource: source.loginSource.ID, + LoginName: su.Username, + Email: su.Mail, + IsAdmin: su.IsAdmin, + IsRestricted: su.IsRestricted, + IsActive: true, + } + + err = models.CreateUser(usr) + + if err != nil { + log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.loginSource.Name, su.Username, err) + } else if isAttributeSSHPublicKeySet { + log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.loginSource.Name, usr.Name) + if models.AddPublicKeysBySource(usr, source.loginSource, su.SSHPublicKey) { + sshKeysNeedUpdate = true + } + } + } else if updateExisting { + existingUsers = append(existingUsers, usr.ID) + + // Synchronize SSH Public Key if that attribute is set + if isAttributeSSHPublicKeySet && models.SynchronizePublicKeys(usr, source.loginSource, 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.loginSource.Name, usr.Name) + + usr.FullName = fullName + usr.Email = su.Mail + // Change existing admin flag only if AdminFilter option is set + if len(source.AdminFilter) > 0 { + usr.IsAdmin = su.IsAdmin + } + // Change existing restricted flag only if RestrictedFilter option is set + if !usr.IsAdmin && len(source.RestrictedFilter) > 0 { + usr.IsRestricted = su.IsRestricted + } + usr.IsActive = true + + err = models.UpdateUserCols(usr, "full_name", "email", "is_admin", "is_restricted", "is_active") + if err != nil { + log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.loginSource.Name, usr.Name, err) + } + } + } + } + + // Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed + if sshKeysNeedUpdate { + err = models.RewriteAllPublicKeys() + if err != nil { + log.Error("RewriteAllPublicKeys: %v", err) + } + } + + select { + case <-ctx.Done(): + log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", source.loginSource.Name) + return models.ErrCancelledf("During update of %s before delete users", source.loginSource.Name) + default: + } + + // Deactivate users not present in LDAP + if updateExisting { + for _, usr := range users { + found := false + for _, uid := range existingUsers { + if usr.ID == uid { + found = true + break + } + } + if !found { + log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.loginSource.Name, usr.Name) + + usr.IsActive = false + err = models.UpdateUserCols(usr, "is_active") + if err != nil { + log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.loginSource.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 0000000000..f27de37c87 --- /dev/null +++ b/services/auth/source/ldap/util.go @@ -0,0 +1,19 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +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 0000000000..4157427ff2 --- /dev/null +++ b/services/auth/source/oauth2/assert_interface_test.go @@ -0,0 +1,23 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2_test + +import ( + "code.gitea.io/gitea/models" + "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 { + models.LoginConfig + models.LoginSourceSettable + models.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 0000000000..f797fd7fd4 --- /dev/null +++ b/services/auth/source/oauth2/init.go @@ -0,0 +1,83 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/google/uuid" + "github.com/markbates/goth/gothic" +) + +// SessionTableName is the table name that OAuth2 will use to store things +const SessionTableName = "oauth2_session" + +// 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() error { + if err := InitSigningKey(); err != nil { + return err + } + + store, err := models.CreateStore(SessionTableName, UsersStoreKey) + if err != nil { + return err + } + + // according to the Goth lib: + // set the maxLength of the cookies stored on the disk to a larger number to prevent issues with: + // securecookie: the value is too long + // when using OpenID Connect , since this can contain a large amount of extra information in the id_token + + // Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk + store.MaxLength(setting.OAuth2.MaxTokenLength) + gothic.Store = store + + 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 + } + + return initOAuth2LoginSources() +} + +// ResetOAuth2 clears existing OAuth2 providers and loads them from DB +func ResetOAuth2() error { + ClearProviders() + return initOAuth2LoginSources() +} + +// initOAuth2LoginSources is used to load and register all active OAuth2 providers +func initOAuth2LoginSources() error { + loginSources, _ := models.GetActiveOAuth2ProviderLoginSources() + for _, source := range loginSources { + 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. This source will be disabled.", source.Name, err) + source.IsActive = false + if err = models.UpdateSource(source); err != nil { + log.Critical("Unable to update source %s to disable it. Error: %v", err) + return err + } + } + } + return nil +} diff --git a/services/auth/source/oauth2/jwtsigningkey.go b/services/auth/source/oauth2/jwtsigningkey.go new file mode 100644 index 0000000000..75e62a7c43 --- /dev/null +++ b/services/auth/source/oauth2/jwtsigningkey.go @@ -0,0 +1,378 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io/ioutil" + "math/big" + "os" + "path/filepath" + "strings" + + "code.gitea.io/gitea/modules/generate" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/dgrijalva/jwt-go" + ini "gopkg.in/ini.v1" +) + +// ErrInvalidAlgorithmType represents an invalid algorithm error. +type ErrInvalidAlgorithmType struct { + Algorightm string +} + +func (err ErrInvalidAlgorithmType) Error() string { + return fmt.Sprintf("JWT signing algorithm is not supported: %s", err.Algorightm) +} + +// JWTSigningKey represents a algorithm/key pair to sign JWTs +type JWTSigningKey interface { + IsSymmetric() bool + SigningMethod() jwt.SigningMethod + SignKey() interface{} + VerifyKey() interface{} + 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() interface{} { + return key.secret +} + +func (key hmacSigningKey) VerifyKey() interface{} { + 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 := 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() interface{} { + return key.key +} + +func (key rsaSingingKey) VerifyKey() interface{} { + 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 ecdsaSingingKey struct { + signingMethod jwt.SigningMethod + key *ecdsa.PrivateKey + id string +} + +func newECDSASingingKey(signingMethod jwt.SigningMethod, key *ecdsa.PrivateKey) (ecdsaSingingKey, error) { + kid, err := 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() interface{} { + return key.key +} + +func (key ecdsaSingingKey) VerifyKey() interface{} { + 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 +} + +// createPublicKeyFingerprint creates a fingerprint of the given key. +// The fingerprint is the sha256 sum of the PKIX structure of the key. +func createPublicKeyFingerprint(key interface{}) ([]byte, error) { + bytes, err := x509.MarshalPKIXPublicKey(key) + if err != nil { + return nil, err + } + + checksum := sha256.Sum256(bytes) + + return checksum[:], nil +} + +// CreateJWTSingingKey creates a signing key from an algorithm / key pair. +func CreateJWTSingingKey(algorithm string, key interface{}) (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 + default: + return nil, ErrInvalidAlgorithmType{algorithm} + } + + switch signingMethod.(type) { + 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 interface{} + + switch setting.OAuth2.JWTSigningAlgorithm { + case "HS256": + fallthrough + case "HS384": + fallthrough + case "HS512": + key, err = loadOrCreateSymmetricKey() + + case "RS256": + fallthrough + case "RS384": + fallthrough + case "RS512": + fallthrough + case "ES256": + fallthrough + case "ES384": + fallthrough + case "ES512": + key, err = loadOrCreateAsymmetricKey() + + default: + return ErrInvalidAlgorithmType{setting.OAuth2.JWTSigningAlgorithm} + } + + if err != nil { + return fmt.Errorf("Error while loading or creating symmetric key: %v", err) + } + + signingKey, err := CreateJWTSingingKey(setting.OAuth2.JWTSigningAlgorithm, key) + if err != nil { + return err + } + + DefaultSigningKey = signingKey + + return nil +} + +// loadOrCreateSymmetricKey checks if the configured secret is valid. +// If it is not valid a new secret is created and saved in the configuration file. +func loadOrCreateSymmetricKey() (interface{}, error) { + key := make([]byte, 32) + n, err := base64.RawURLEncoding.Decode(key, []byte(setting.OAuth2.JWTSecretBase64)) + if err != nil || n != 32 { + key, err = generate.NewJwtSecret() + if err != nil { + log.Fatal("error generating JWT secret: %v", err) + return nil, err + } + + setting.CreateOrAppendToCustomConf(func(cfg *ini.File) { + secretBase64 := base64.RawURLEncoding.EncodeToString(key) + cfg.Section("oauth2").Key("JWT_SECRET").SetValue(secretBase64) + }) + } + + return key, 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() (interface{}, 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() (interface{}, error) { + if strings.HasPrefix(setting.OAuth2.JWTSigningAlgorithm, "RS") { + return rsa.GenerateKey(rand.Reader, 4096) + } + 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, 0600) + 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 := ioutil.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/providers.go b/services/auth/source/oauth2/providers.go new file mode 100644 index 0000000000..bf97f8002a --- /dev/null +++ b/services/auth/source/oauth2/providers.go @@ -0,0 +1,257 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "net/url" + "sort" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + "github.com/markbates/goth" + "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/gitea" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/gitlab" + "github.com/markbates/goth/providers/google" + "github.com/markbates/goth/providers/mastodon" + "github.com/markbates/goth/providers/nextcloud" + "github.com/markbates/goth/providers/openidConnect" + "github.com/markbates/goth/providers/twitter" + "github.com/markbates/goth/providers/yandex" +) + +// Provider describes the display values of a single OAuth2 provider +type Provider struct { + Name string + DisplayName string + Image string + CustomURLMapping *CustomURLMapping +} + +// 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 LoginSource.OAuth2Config.Provider) +// value is used to store display data +var Providers = map[string]Provider{ + "bitbucket": {Name: "bitbucket", DisplayName: "Bitbucket", Image: "/assets/img/auth/bitbucket.png"}, + "dropbox": {Name: "dropbox", DisplayName: "Dropbox", Image: "/assets/img/auth/dropbox.png"}, + "facebook": {Name: "facebook", DisplayName: "Facebook", Image: "/assets/img/auth/facebook.png"}, + "github": { + Name: "github", DisplayName: "GitHub", Image: "/assets/img/auth/github.png", + CustomURLMapping: &CustomURLMapping{ + TokenURL: github.TokenURL, + AuthURL: github.AuthURL, + ProfileURL: github.ProfileURL, + EmailURL: github.EmailURL, + }, + }, + "gitlab": { + Name: "gitlab", DisplayName: "GitLab", Image: "/assets/img/auth/gitlab.png", + CustomURLMapping: &CustomURLMapping{ + TokenURL: gitlab.TokenURL, + AuthURL: gitlab.AuthURL, + ProfileURL: gitlab.ProfileURL, + }, + }, + "gplus": {Name: "gplus", DisplayName: "Google", Image: "/assets/img/auth/google.png"}, + "openidConnect": {Name: "openidConnect", DisplayName: "OpenID Connect", Image: "/assets/img/auth/openid_connect.svg"}, + "twitter": {Name: "twitter", DisplayName: "Twitter", Image: "/assets/img/auth/twitter.png"}, + "discord": {Name: "discord", DisplayName: "Discord", Image: "/assets/img/auth/discord.png"}, + "gitea": { + Name: "gitea", DisplayName: "Gitea", Image: "/assets/img/auth/gitea.png", + CustomURLMapping: &CustomURLMapping{ + TokenURL: gitea.TokenURL, + AuthURL: gitea.AuthURL, + ProfileURL: gitea.ProfileURL, + }, + }, + "nextcloud": { + Name: "nextcloud", DisplayName: "Nextcloud", Image: "/assets/img/auth/nextcloud.png", + CustomURLMapping: &CustomURLMapping{ + TokenURL: nextcloud.TokenURL, + AuthURL: nextcloud.AuthURL, + ProfileURL: nextcloud.ProfileURL, + }, + }, + "yandex": {Name: "yandex", DisplayName: "Yandex", Image: "/assets/img/auth/yandex.png"}, + "mastodon": { + Name: "mastodon", DisplayName: "Mastodon", Image: "/assets/img/auth/mastodon.png", + CustomURLMapping: &CustomURLMapping{ + AuthURL: mastodon.InstanceURL, + }, + }, +} + +// GetActiveOAuth2Providers returns the map of configured active OAuth2 providers +// key is used as technical name (like in the callbackURL) +// values to display +func GetActiveOAuth2Providers() ([]string, map[string]Provider, error) { + // Maybe also separate used and unused providers so we can force the registration of only 1 active provider for each type + + loginSources, err := models.GetActiveOAuth2ProviderLoginSources() + if err != nil { + return nil, nil, err + } + + var orderedKeys []string + providers := make(map[string]Provider) + for _, source := range loginSources { + prov := Providers[source.Cfg.(*Source).Provider] + if source.Cfg.(*Source).IconURL != "" { + prov.Image = source.Cfg.(*Source).IconURL + } + providers[source.Name] = prov + orderedKeys = append(orderedKeys, source.Name) + } + + sort.Strings(orderedKeys) + + return orderedKeys, providers, nil +} + +// RegisterProvider register a OAuth2 provider in goth lib +func RegisterProvider(providerName, providerType, clientID, clientSecret, openIDConnectAutoDiscoveryURL string, customURLMapping *CustomURLMapping) error { + provider, err := createProvider(providerName, providerType, clientID, clientSecret, openIDConnectAutoDiscoveryURL, customURLMapping) + + if err == nil && provider != nil { + goth.UseProviders(provider) + } + + return err +} + +// RemoveProvider removes the given OAuth2 provider from the goth lib +func RemoveProvider(providerName string) { + delete(goth.GetProviders(), providerName) +} + +// ClearProviders clears all OAuth2 providers from the goth lib +func ClearProviders() { + goth.ClearProviders() +} + +// used to create different types of goth providers +func createProvider(providerName, providerType, clientID, clientSecret, openIDConnectAutoDiscoveryURL string, customURLMapping *CustomURLMapping) (goth.Provider, error) { + callbackURL := setting.AppURL + "user/oauth2/" + url.PathEscape(providerName) + "/callback" + + var provider goth.Provider + var err error + + switch providerType { + case "bitbucket": + provider = bitbucket.New(clientID, clientSecret, callbackURL, "account") + case "dropbox": + provider = dropbox.New(clientID, clientSecret, callbackURL) + case "facebook": + provider = facebook.New(clientID, clientSecret, callbackURL, "email") + case "github": + authURL := github.AuthURL + tokenURL := github.TokenURL + profileURL := github.ProfileURL + emailURL := github.EmailURL + if customURLMapping != nil { + if len(customURLMapping.AuthURL) > 0 { + authURL = customURLMapping.AuthURL + } + if len(customURLMapping.TokenURL) > 0 { + tokenURL = customURLMapping.TokenURL + } + if len(customURLMapping.ProfileURL) > 0 { + profileURL = customURLMapping.ProfileURL + } + if len(customURLMapping.EmailURL) > 0 { + emailURL = customURLMapping.EmailURL + } + } + scopes := []string{} + if setting.OAuth2Client.EnableAutoRegistration { + scopes = append(scopes, "user:email") + } + provider = github.NewCustomisedURL(clientID, clientSecret, callbackURL, authURL, tokenURL, profileURL, emailURL, scopes...) + case "gitlab": + authURL := gitlab.AuthURL + tokenURL := gitlab.TokenURL + profileURL := gitlab.ProfileURL + if customURLMapping != nil { + if len(customURLMapping.AuthURL) > 0 { + authURL = customURLMapping.AuthURL + } + if len(customURLMapping.TokenURL) > 0 { + tokenURL = customURLMapping.TokenURL + } + if len(customURLMapping.ProfileURL) > 0 { + profileURL = customURLMapping.ProfileURL + } + } + provider = gitlab.NewCustomisedURL(clientID, clientSecret, callbackURL, authURL, tokenURL, profileURL, "read_user") + case "gplus": // named gplus due to legacy gplus -> google migration (Google killed Google+). This ensures old connections still work + scopes := []string{"email"} + if setting.OAuth2Client.UpdateAvatar || setting.OAuth2Client.EnableAutoRegistration { + scopes = append(scopes, "profile") + } + provider = google.New(clientID, clientSecret, callbackURL, scopes...) + case "openidConnect": + if provider, err = openidConnect.New(clientID, clientSecret, callbackURL, openIDConnectAutoDiscoveryURL, setting.OAuth2Client.OpenIDConnectScopes...); err != nil { + log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, openIDConnectAutoDiscoveryURL, err) + } + case "twitter": + provider = twitter.NewAuthenticate(clientID, clientSecret, callbackURL) + case "discord": + provider = discord.New(clientID, clientSecret, callbackURL, discord.ScopeIdentify, discord.ScopeEmail) + case "gitea": + authURL := gitea.AuthURL + tokenURL := gitea.TokenURL + profileURL := gitea.ProfileURL + if customURLMapping != nil { + if len(customURLMapping.AuthURL) > 0 { + authURL = customURLMapping.AuthURL + } + if len(customURLMapping.TokenURL) > 0 { + tokenURL = customURLMapping.TokenURL + } + if len(customURLMapping.ProfileURL) > 0 { + profileURL = customURLMapping.ProfileURL + } + } + provider = gitea.NewCustomisedURL(clientID, clientSecret, callbackURL, authURL, tokenURL, profileURL) + case "nextcloud": + authURL := nextcloud.AuthURL + tokenURL := nextcloud.TokenURL + profileURL := nextcloud.ProfileURL + if customURLMapping != nil { + if len(customURLMapping.AuthURL) > 0 { + authURL = customURLMapping.AuthURL + } + if len(customURLMapping.TokenURL) > 0 { + tokenURL = customURLMapping.TokenURL + } + if len(customURLMapping.ProfileURL) > 0 { + profileURL = customURLMapping.ProfileURL + } + } + provider = nextcloud.NewCustomisedURL(clientID, clientSecret, callbackURL, authURL, tokenURL, profileURL) + case "yandex": + // See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/ + provider = yandex.New(clientID, clientSecret, callbackURL, "login:email", "login:info", "login:avatar") + case "mastodon": + instanceURL := mastodon.InstanceURL + if customURLMapping != nil && len(customURLMapping.AuthURL) > 0 { + instanceURL = customURLMapping.AuthURL + } + provider = mastodon.NewCustomisedURL(clientID, clientSecret, callbackURL, instanceURL) + } + + // always set the name if provider is created so we can support multiple setups of 1 provider + if err == nil && provider != nil { + provider.SetName(providerName) + } + + return provider, err +} diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go new file mode 100644 index 0000000000..e9c49ef90b --- /dev/null +++ b/services/auth/source/oauth2/source.go @@ -0,0 +1,51 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "code.gitea.io/gitea/models" + + jsoniter "github.com/json-iterator/go" +) + +// ________ _____ __ .__ ________ +// \_____ \ / _ \ __ ___/ |_| |__ \_____ \ +// / | \ / /_\ \| | \ __\ | \ / ____/ +// / | \/ | \ | /| | | Y \/ \ +// \_______ /\____|__ /____/ |__| |___| /\_______ \ +// \/ \/ \/ \/ + +// Source holds configuration for the OAuth2 login source. +type Source struct { + Provider string + ClientID string + ClientSecret string + OpenIDConnectAutoDiscoveryURL string + CustomURLMapping *CustomURLMapping + IconURL string + + // reference to the loginSource + loginSource *models.LoginSource +} + +// FromDB fills up an OAuth2Config from serialized format. +func (source *Source) FromDB(bs []byte) error { + return models.JSONUnmarshalHandleDoubleEncode(bs, &source) +} + +// ToDB exports an SMTPConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + json := jsoniter.ConfigCompatibleWithStandardLibrary + return json.Marshal(source) +} + +// SetLoginSource sets the related LoginSource +func (source *Source) SetLoginSource(loginSource *models.LoginSource) { + source.loginSource = loginSource +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginOAuth2, &Source{}) +} diff --git a/services/auth/source/oauth2/source_authenticate.go b/services/auth/source/oauth2/source_authenticate.go new file mode 100644 index 0000000000..2e39f245df --- /dev/null +++ b/services/auth/source/oauth2/source_authenticate.go @@ -0,0 +1,15 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/services/auth/source/db" +) + +// Authenticate falls back to the db authenticator +func (source *Source) Authenticate(user *models.User, login, password string) (*models.User, error) { + return db.Authenticate(user, login, password) +} diff --git a/services/auth/source/oauth2/source_callout.go b/services/auth/source/oauth2/source_callout.go new file mode 100644 index 0000000000..8f4663f3be --- /dev/null +++ b/services/auth/source/oauth2/source_callout.go @@ -0,0 +1,42 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "net/http" + + "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) error { + // not sure if goth is thread safe (?) when using multiple providers + request.Header.Set(ProviderHeaderKey, source.loginSource.Name) + + // 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) + + url, err := gothic.GetAuthURL(response, request) + if err == nil { + http.Redirect(response, request, url, 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) (goth.User, error) { + // not sure if goth is thread safe (?) when using multiple providers + request.Header.Set(ProviderHeaderKey, source.loginSource.Name) + + user, err := gothic.CompleteUserAuth(response, request) + if err != nil { + return user, err + } + + return user, nil +} diff --git a/services/auth/source/oauth2/source_register.go b/services/auth/source/oauth2/source_register.go new file mode 100644 index 0000000000..b61cc3fe79 --- /dev/null +++ b/services/auth/source/oauth2/source_register.go @@ -0,0 +1,30 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "code.gitea.io/gitea/models" +) + +// RegisterSource causes an OAuth2 configuration to be registered +func (source *Source) RegisterSource() error { + err := RegisterProvider(source.loginSource.Name, source.Provider, source.ClientID, source.ClientSecret, source.OpenIDConnectAutoDiscoveryURL, source.CustomURLMapping) + return wrapOpenIDConnectInitializeError(err, source.loginSource.Name, source) +} + +// UnregisterSource causes an OAuth2 configuration to be unregistered +func (source *Source) UnregisterSource() error { + RemoveProvider(source.loginSource.Name) + return nil +} + +// 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 = models.ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err} + } + return err +} diff --git a/services/auth/source/oauth2/token.go b/services/auth/source/oauth2/token.go new file mode 100644 index 0000000000..0573a47e3b --- /dev/null +++ b/services/auth/source/oauth2/token.go @@ -0,0 +1,94 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +import ( + "fmt" + "time" + + "code.gitea.io/gitea/modules/timeutil" + "github.com/dgrijalva/jwt-go" +) + +// ___________ __ +// \__ ___/___ | | __ ____ ____ +// | | / _ \| |/ // __ \ / \ +// | |( <_> ) <\ ___/| | \ +// |____| \____/|__|_ \\___ >___| / +// \/ \/ \/ + +// 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.StandardClaims +} + +// ParseToken parses a signed jwt string +func ParseToken(jwtToken string) (*Token, error) { + parsedToken, err := jwt.ParseWithClaims(jwtToken, &Token{}, func(token *jwt.Token) (interface{}, error) { + if token.Method == nil || token.Method.Alg() != DefaultSigningKey.SigningMethod().Alg() { + return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"]) + } + return DefaultSigningKey.VerifyKey(), nil + }) + if err != nil { + return nil, err + } + 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() (string, error) { + token.IssuedAt = time.Now().Unix() + jwtToken := jwt.NewWithClaims(DefaultSigningKey.SigningMethod(), token) + DefaultSigningKey.PreProcessToken(jwtToken) + return jwtToken.SignedString(DefaultSigningKey.SignKey()) +} + +// OIDCToken represents an OpenID Connect id_token +type OIDCToken struct { + jwt.StandardClaims + 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"` +} + +// SignToken signs an id_token with the (symmetric) client secret key +func (token *OIDCToken) SignToken(signingKey JWTSigningKey) (string, error) { + token.IssuedAt = time.Now().Unix() + 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 0000000000..68829fba21 --- /dev/null +++ b/services/auth/source/oauth2/urlmapping.go @@ -0,0 +1,24 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package oauth2 + +// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs +type CustomURLMapping struct { + AuthURL string + TokenURL string + ProfileURL string + EmailURL string +} + +// DefaultCustomURLMappings contains the map of default URL's for OAuth2 providers that are allowed to have custom urls +// key is used to map the OAuth2Provider +// value is the mapping as defined for the OAuth2Provider +var DefaultCustomURLMappings = map[string]*CustomURLMapping{ + "github": Providers["github"].CustomURLMapping, + "gitlab": Providers["gitlab"].CustomURLMapping, + "gitea": Providers["gitea"].CustomURLMapping, + "nextcloud": Providers["nextcloud"].CustomURLMapping, + "mastodon": Providers["mastodon"].CustomURLMapping, +} 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 0000000000..a0bebdf9c6 --- /dev/null +++ b/services/auth/source/pam/assert_interface_test.go @@ -0,0 +1,22 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package pam_test + +import ( + "code.gitea.io/gitea/models" + "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 + models.LoginConfig + models.LoginSourceSettable +} + +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 0000000000..b717ee6fe8 --- /dev/null +++ b/services/auth/source/pam/source.go @@ -0,0 +1,47 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package pam + +import ( + "code.gitea.io/gitea/models" + + jsoniter "github.com/json-iterator/go" +) + +// __________ _____ _____ +// \______ \/ _ \ / \ +// | ___/ /_\ \ / \ / \ +// | | / | \/ Y \ +// |____| \____|__ /\____|__ / +// \/ \/ + +// Source holds configuration for the PAM login source. +type Source struct { + ServiceName string // pam service (e.g. system-auth) + EmailDomain string + + // reference to the loginSource + loginSource *models.LoginSource +} + +// FromDB fills up a PAMConfig from serialized format. +func (source *Source) FromDB(bs []byte) error { + return models.JSONUnmarshalHandleDoubleEncode(bs, &source) +} + +// ToDB exports a PAMConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + json := jsoniter.ConfigCompatibleWithStandardLibrary + return json.Marshal(source) +} + +// SetLoginSource sets the related LoginSource +func (source *Source) SetLoginSource(loginSource *models.LoginSource) { + source.loginSource = loginSource +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginPAM, &Source{}) +} diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go new file mode 100644 index 0000000000..6ca0642904 --- /dev/null +++ b/services/auth/source/pam/source_authenticate.go @@ -0,0 +1,62 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package pam + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/auth/pam" + "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(user *models.User, login, password string) (*models.User, error) { + pamLogin, err := pam.Auth(source.ServiceName, login, password) + if err != nil { + if strings.Contains(err.Error(), "Authentication failure") { + return nil, models.ErrUserNotExist{Name: login} + } + 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 models.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 models.ValidateEmail(email) != nil { + email = uuid.New().String() + "@localhost" + } + } + + user = &models.User{ + LowerName: strings.ToLower(username), + Name: username, + Email: email, + Passwd: password, + LoginType: models.LoginPAM, + LoginSource: source.loginSource.ID, + LoginName: login, // This is what the user typed in + IsActive: true, + } + return user, models.CreateUser(user) +} 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 0000000000..bc2042e069 --- /dev/null +++ b/services/auth/source/smtp/assert_interface_test.go @@ -0,0 +1,25 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package smtp_test + +import ( + "code.gitea.io/gitea/models" + "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 + models.LoginConfig + models.SkipVerifiable + models.HasTLSer + models.UseTLSer + models.LoginSourceSettable +} + +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 0000000000..8edf4fca15 --- /dev/null +++ b/services/auth/source/smtp/auth.go @@ -0,0 +1,81 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package smtp + +import ( + "crypto/tls" + "errors" + "fmt" + "net/smtp" + + "code.gitea.io/gitea/models" +) + +// _________ __________________________ +// / _____/ / \__ ___/\______ \ +// \_____ \ / \ / \| | | ___/ +// / \/ 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" +) + +// Authenticators contains available SMTP authentication type names. +var Authenticators = []string{PlainAuthentication, LoginAuthentication} + +// Authenticate performs an SMTP authentication. +func Authenticate(a smtp.Auth, source *Source) error { + c, err := smtp.Dial(fmt.Sprintf("%s:%d", source.Host, source.Port)) + if err != nil { + return err + } + defer c.Close() + + if err = c.Hello("gogs"); err != nil { + return err + } + + if source.TLS { + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(&tls.Config{ + InsecureSkipVerify: source.SkipVerify, + ServerName: source.Host, + }); err != nil { + return err + } + } else { + return errors.New("SMTP server unsupports TLS") + } + } + + if ok, _ := c.Extension("AUTH"); ok { + return c.Auth(a) + } + return models.ErrUnsupportedLoginType +} diff --git a/services/auth/source/smtp/source.go b/services/auth/source/smtp/source.go new file mode 100644 index 0000000000..0f948d5381 --- /dev/null +++ b/services/auth/source/smtp/source.go @@ -0,0 +1,66 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package smtp + +import ( + "code.gitea.io/gitea/models" + + jsoniter "github.com/json-iterator/go" +) + +// _________ __________________________ +// / _____/ / \__ ___/\______ \ +// \_____ \ / \ / \| | | ___/ +// / \/ Y \ | | | +// /_______ /\____|__ /____| |____| +// \/ \/ + +// Source holds configuration for the SMTP login source. +type Source struct { + Auth string + Host string + Port int + AllowedDomains string `xorm:"TEXT"` + TLS bool + SkipVerify bool + + // reference to the loginSource + loginSource *models.LoginSource +} + +// FromDB fills up an SMTPConfig from serialized format. +func (source *Source) FromDB(bs []byte) error { + return models.JSONUnmarshalHandleDoubleEncode(bs, &source) +} + +// ToDB exports an SMTPConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + json := jsoniter.ConfigCompatibleWithStandardLibrary + 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.TLS +} + +// SetLoginSource sets the related LoginSource +func (source *Source) SetLoginSource(loginSource *models.LoginSource) { + source.loginSource = loginSource +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginSMTP, &Source{}) +} diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go new file mode 100644 index 0000000000..9bab86604b --- /dev/null +++ b/services/auth/source/smtp/source_authenticate.go @@ -0,0 +1,71 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package smtp + +import ( + "errors" + "net/smtp" + "net/textproto" + "strings" + + "code.gitea.io/gitea/models" + "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(user *models.User, login, password string) (*models.User, error) { + // Verify allowed domains. + if len(source.AllowedDomains) > 0 { + idx := strings.Index(login, "@") + if idx == -1 { + return nil, models.ErrUserNotExist{Name: login} + } else if !util.IsStringInSlice(login[idx+1:], strings.Split(source.AllowedDomains, ","), true) { + return nil, models.ErrUserNotExist{Name: login} + } + } + + var auth smtp.Auth + if source.Auth == PlainAuthentication { + auth = smtp.PlainAuth("", login, password, source.Host) + } else if source.Auth == LoginAuthentication { + auth = &loginAuthenticator{login, password} + } else { + 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, models.ErrUserNotExist{Name: login} + } + return nil, err + } + + if user != nil { + return user, nil + } + + username := login + idx := strings.Index(login, "@") + if idx > -1 { + username = login[:idx] + } + + user = &models.User{ + LowerName: strings.ToLower(username), + Name: strings.ToLower(username), + Email: login, + Passwd: password, + LoginType: models.LoginSMTP, + LoginSource: source.loginSource.ID, + LoginName: login, + IsActive: true, + } + return user, models.CreateUser(user) +} 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 0000000000..605a6ec6c5 --- /dev/null +++ b/services/auth/source/sspi/assert_interface_test.go @@ -0,0 +1,19 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sspi_test + +import ( + "code.gitea.io/gitea/models" + "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 { + models.LoginConfig +} + +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 0000000000..e4be446f30 --- /dev/null +++ b/services/auth/source/sspi/source.go @@ -0,0 +1,41 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package sspi + +import ( + "code.gitea.io/gitea/models" + jsoniter "github.com/json-iterator/go" +) + +// _________ ___________________.___ +// / _____// _____/\______ \ | +// \_____ \ \_____ \ | ___/ | +// / \/ \ | | | | +// /_______ /_______ / |____| |___| +// \/ \/ + +// 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 models.JSONUnmarshalHandleDoubleEncode(bs, &cfg) +} + +// ToDB exports an SSPIConfig to a serialized format. +func (cfg *Source) ToDB() ([]byte, error) { + json := jsoniter.ConfigCompatibleWithStandardLibrary + return json.Marshal(cfg) +} + +func init() { + models.RegisterLoginTypeConfig(models.LoginSSPI, &Source{}) +} diff --git a/services/auth/sspi_windows.go b/services/auth/sspi_windows.go index bb0291d2c9..8420d43071 100644 --- a/services/auth/sspi_windows.go +++ b/services/auth/sspi_windows.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/auth/source/sspi" gouuid "github.com/google/uuid" "github.com/quasoft/websspi" @@ -32,7 +33,10 @@ var ( sspiAuth *websspi.Authenticator // Ensure the struct implements the interface. - _ Auth = &SSPI{} + _ Method = &SSPI{} + _ Named = &SSPI{} + _ Initializable = &SSPI{} + _ Freeable = &SSPI{} ) // SSPI implements the SingleSignOn interface and authenticates requests @@ -146,7 +150,7 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, } // getConfig retrieves the SSPI configuration from login sources -func (s *SSPI) getConfig() (*models.SSPIConfig, error) { +func (s *SSPI) getConfig() (*sspi.Source, error) { sources, err := models.ActiveLoginSources(models.LoginSSPI) if err != nil { return nil, err @@ -157,7 +161,7 @@ func (s *SSPI) getConfig() (*models.SSPIConfig, error) { if len(sources) > 1 { return nil, errors.New("more than one active login source of type SSPI found") } - return sources[0].SSPI(), nil + return sources[0].Cfg.(*sspi.Source), nil } func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) { @@ -177,7 +181,7 @@ func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) { // 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(username string, cfg *models.SSPIConfig) (*models.User, error) { +func (s *SSPI) newUser(username string, cfg *sspi.Source) (*models.User, error) { email := gouuid.New().String() + "@localhost.localdomain" user := &models.User{ Name: username, @@ -214,7 +218,7 @@ func stripDomainNames(username string) string { return username } -func replaceSeparators(username string, cfg *models.SSPIConfig) string { +func replaceSeparators(username string, cfg *sspi.Source) string { newSep := cfg.SeparatorReplacement username = strings.ReplaceAll(username, "\\", newSep) username = strings.ReplaceAll(username, "/", newSep) @@ -222,7 +226,7 @@ func replaceSeparators(username string, cfg *models.SSPIConfig) string { return username } -func sanitizeUsername(username string, cfg *models.SSPIConfig) string { +func sanitizeUsername(username string, cfg *sspi.Source) string { if len(username) == 0 { return "" } diff --git a/services/auth/sync.go b/services/auth/sync.go new file mode 100644 index 0000000000..a34b4d1d26 --- /dev/null +++ b/services/auth/sync.go @@ -0,0 +1,43 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "context" + + "code.gitea.io/gitea/models" + "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 := models.LoginSources() + 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 models.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 +} |