`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>tags/v1.16.0-rc1
@@ -14,7 +14,6 @@ import ( | |||
"text/tabwriter" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/graceful" | |||
"code.gitea.io/gitea/modules/log" | |||
@@ -22,6 +21,7 @@ import ( | |||
repo_module "code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/storage" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
"github.com/urfave/cli" | |||
) | |||
@@ -597,7 +597,7 @@ func runRegenerateKeys(_ *cli.Context) error { | |||
return models.RewriteAllPublicKeys() | |||
} | |||
func parseOAuth2Config(c *cli.Context) *models.OAuth2Config { | |||
func parseOAuth2Config(c *cli.Context) *oauth2.Source { | |||
var customURLMapping *oauth2.CustomURLMapping | |||
if c.IsSet("use-custom-urls") { | |||
customURLMapping = &oauth2.CustomURLMapping{ | |||
@@ -609,7 +609,7 @@ func parseOAuth2Config(c *cli.Context) *models.OAuth2Config { | |||
} else { | |||
customURLMapping = nil | |||
} | |||
return &models.OAuth2Config{ | |||
return &oauth2.Source{ | |||
Provider: c.String("provider"), | |||
ClientID: c.String("key"), | |||
ClientSecret: c.String("secret"), | |||
@@ -625,10 +625,10 @@ func runAddOauth(c *cli.Context) error { | |||
} | |||
return models.CreateLoginSource(&models.LoginSource{ | |||
Type: models.LoginOAuth2, | |||
Name: c.String("name"), | |||
IsActived: true, | |||
Cfg: parseOAuth2Config(c), | |||
Type: models.LoginOAuth2, | |||
Name: c.String("name"), | |||
IsActive: true, | |||
Cfg: parseOAuth2Config(c), | |||
}) | |||
} | |||
@@ -646,7 +646,7 @@ func runUpdateOauth(c *cli.Context) error { | |||
return err | |||
} | |||
oAuth2Config := source.OAuth2() | |||
oAuth2Config := source.Cfg.(*oauth2.Source) | |||
if c.IsSet("name") { | |||
source.Name = c.String("name") | |||
@@ -728,7 +728,7 @@ func runListAuth(c *cli.Context) error { | |||
w := tabwriter.NewWriter(os.Stdout, c.Int("min-width"), c.Int("tab-width"), c.Int("padding"), padChar, flags) | |||
fmt.Fprintf(w, "ID\tName\tType\tEnabled\n") | |||
for _, source := range loginSources { | |||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", source.ID, source.Name, models.LoginNames[source.Type], source.IsActived) | |||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", source.ID, source.Name, models.LoginNames[source.Type], source.IsActive) | |||
} | |||
w.Flush() | |||
@@ -9,7 +9,7 @@ import ( | |||
"strings" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/ldap" | |||
"code.gitea.io/gitea/services/auth/source/ldap" | |||
"github.com/urfave/cli" | |||
) | |||
@@ -172,7 +172,7 @@ func parseLoginSource(c *cli.Context, loginSource *models.LoginSource) { | |||
loginSource.Name = c.String("name") | |||
} | |||
if c.IsSet("not-active") { | |||
loginSource.IsActived = !c.Bool("not-active") | |||
loginSource.IsActive = !c.Bool("not-active") | |||
} | |||
if c.IsSet("synchronize-users") { | |||
loginSource.IsSyncEnabled = c.Bool("synchronize-users") | |||
@@ -180,70 +180,70 @@ func parseLoginSource(c *cli.Context, loginSource *models.LoginSource) { | |||
} | |||
// parseLdapConfig assigns values on config according to command line flags. | |||
func parseLdapConfig(c *cli.Context, config *models.LDAPConfig) error { | |||
func parseLdapConfig(c *cli.Context, config *ldap.Source) error { | |||
if c.IsSet("name") { | |||
config.Source.Name = c.String("name") | |||
config.Name = c.String("name") | |||
} | |||
if c.IsSet("host") { | |||
config.Source.Host = c.String("host") | |||
config.Host = c.String("host") | |||
} | |||
if c.IsSet("port") { | |||
config.Source.Port = c.Int("port") | |||
config.Port = c.Int("port") | |||
} | |||
if c.IsSet("security-protocol") { | |||
p, ok := findLdapSecurityProtocolByName(c.String("security-protocol")) | |||
if !ok { | |||
return fmt.Errorf("Unknown security protocol name: %s", c.String("security-protocol")) | |||
} | |||
config.Source.SecurityProtocol = p | |||
config.SecurityProtocol = p | |||
} | |||
if c.IsSet("skip-tls-verify") { | |||
config.Source.SkipVerify = c.Bool("skip-tls-verify") | |||
config.SkipVerify = c.Bool("skip-tls-verify") | |||
} | |||
if c.IsSet("bind-dn") { | |||
config.Source.BindDN = c.String("bind-dn") | |||
config.BindDN = c.String("bind-dn") | |||
} | |||
if c.IsSet("user-dn") { | |||
config.Source.UserDN = c.String("user-dn") | |||
config.UserDN = c.String("user-dn") | |||
} | |||
if c.IsSet("bind-password") { | |||
config.Source.BindPassword = c.String("bind-password") | |||
config.BindPassword = c.String("bind-password") | |||
} | |||
if c.IsSet("user-search-base") { | |||
config.Source.UserBase = c.String("user-search-base") | |||
config.UserBase = c.String("user-search-base") | |||
} | |||
if c.IsSet("username-attribute") { | |||
config.Source.AttributeUsername = c.String("username-attribute") | |||
config.AttributeUsername = c.String("username-attribute") | |||
} | |||
if c.IsSet("firstname-attribute") { | |||
config.Source.AttributeName = c.String("firstname-attribute") | |||
config.AttributeName = c.String("firstname-attribute") | |||
} | |||
if c.IsSet("surname-attribute") { | |||
config.Source.AttributeSurname = c.String("surname-attribute") | |||
config.AttributeSurname = c.String("surname-attribute") | |||
} | |||
if c.IsSet("email-attribute") { | |||
config.Source.AttributeMail = c.String("email-attribute") | |||
config.AttributeMail = c.String("email-attribute") | |||
} | |||
if c.IsSet("attributes-in-bind") { | |||
config.Source.AttributesInBind = c.Bool("attributes-in-bind") | |||
config.AttributesInBind = c.Bool("attributes-in-bind") | |||
} | |||
if c.IsSet("public-ssh-key-attribute") { | |||
config.Source.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") | |||
config.AttributeSSHPublicKey = c.String("public-ssh-key-attribute") | |||
} | |||
if c.IsSet("page-size") { | |||
config.Source.SearchPageSize = uint32(c.Uint("page-size")) | |||
config.SearchPageSize = uint32(c.Uint("page-size")) | |||
} | |||
if c.IsSet("user-filter") { | |||
config.Source.Filter = c.String("user-filter") | |||
config.Filter = c.String("user-filter") | |||
} | |||
if c.IsSet("admin-filter") { | |||
config.Source.AdminFilter = c.String("admin-filter") | |||
config.AdminFilter = c.String("admin-filter") | |||
} | |||
if c.IsSet("restricted-filter") { | |||
config.Source.RestrictedFilter = c.String("restricted-filter") | |||
config.RestrictedFilter = c.String("restricted-filter") | |||
} | |||
if c.IsSet("allow-deactivate-all") { | |||
config.Source.AllowDeactivateAll = c.Bool("allow-deactivate-all") | |||
config.AllowDeactivateAll = c.Bool("allow-deactivate-all") | |||
} | |||
return nil | |||
} | |||
@@ -251,7 +251,7 @@ func parseLdapConfig(c *cli.Context, config *models.LDAPConfig) error { | |||
// findLdapSecurityProtocolByName finds security protocol by its name ignoring case. | |||
// It returns the value of the security protocol and if it was found. | |||
func findLdapSecurityProtocolByName(name string) (ldap.SecurityProtocol, bool) { | |||
for i, n := range models.SecurityProtocolNames { | |||
for i, n := range ldap.SecurityProtocolNames { | |||
if strings.EqualFold(name, n) { | |||
return i, true | |||
} | |||
@@ -289,17 +289,15 @@ func (a *authService) addLdapBindDn(c *cli.Context) error { | |||
} | |||
loginSource := &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
IsActived: true, // active by default | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Enabled: true, // always true | |||
}, | |||
Type: models.LoginLDAP, | |||
IsActive: true, // active by default | |||
Cfg: &ldap.Source{ | |||
Enabled: true, // always true | |||
}, | |||
} | |||
parseLoginSource(c, loginSource) | |||
if err := parseLdapConfig(c, loginSource.LDAP()); err != nil { | |||
if err := parseLdapConfig(c, loginSource.Cfg.(*ldap.Source)); err != nil { | |||
return err | |||
} | |||
@@ -318,7 +316,7 @@ func (a *authService) updateLdapBindDn(c *cli.Context) error { | |||
} | |||
parseLoginSource(c, loginSource) | |||
if err := parseLdapConfig(c, loginSource.LDAP()); err != nil { | |||
if err := parseLdapConfig(c, loginSource.Cfg.(*ldap.Source)); err != nil { | |||
return err | |||
} | |||
@@ -336,17 +334,15 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error { | |||
} | |||
loginSource := &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
IsActived: true, // active by default | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Enabled: true, // always true | |||
}, | |||
Type: models.LoginDLDAP, | |||
IsActive: true, // active by default | |||
Cfg: &ldap.Source{ | |||
Enabled: true, // always true | |||
}, | |||
} | |||
parseLoginSource(c, loginSource) | |||
if err := parseLdapConfig(c, loginSource.LDAP()); err != nil { | |||
if err := parseLdapConfig(c, loginSource.Cfg.(*ldap.Source)); err != nil { | |||
return err | |||
} | |||
@@ -365,7 +361,7 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { | |||
} | |||
parseLoginSource(c, loginSource) | |||
if err := parseLdapConfig(c, loginSource.LDAP()); err != nil { | |||
if err := parseLdapConfig(c, loginSource.Cfg.(*ldap.Source)); err != nil { | |||
return err | |||
} | |||
@@ -8,7 +8,7 @@ import ( | |||
"testing" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/ldap" | |||
"code.gitea.io/gitea/services/auth/source/ldap" | |||
"github.com/stretchr/testify/assert" | |||
"github.com/urfave/cli" | |||
@@ -54,30 +54,28 @@ func TestAddLdapBindDn(t *testing.T) { | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Name: "ldap (via Bind DN) source full", | |||
IsActived: false, | |||
IsActive: false, | |||
IsSyncEnabled: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source full", | |||
Host: "ldap-bind-server full", | |||
Port: 9876, | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
SkipVerify: true, | |||
BindDN: "cn=readonly,dc=full-domain-bind,dc=org", | |||
BindPassword: "secret-bind-full", | |||
UserBase: "ou=Users,dc=full-domain-bind,dc=org", | |||
AttributeUsername: "uid-bind full", | |||
AttributeName: "givenName-bind full", | |||
AttributeSurname: "sn-bind full", | |||
AttributeMail: "mail-bind full", | |||
AttributesInBind: true, | |||
AttributeSSHPublicKey: "publickey-bind full", | |||
SearchPageSize: 99, | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source full", | |||
Host: "ldap-bind-server full", | |||
Port: 9876, | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
SkipVerify: true, | |||
BindDN: "cn=readonly,dc=full-domain-bind,dc=org", | |||
BindPassword: "secret-bind-full", | |||
UserBase: "ou=Users,dc=full-domain-bind,dc=org", | |||
AttributeUsername: "uid-bind full", | |||
AttributeName: "givenName-bind full", | |||
AttributeSurname: "sn-bind full", | |||
AttributeMail: "mail-bind full", | |||
AttributesInBind: true, | |||
AttributeSSHPublicKey: "publickey-bind full", | |||
SearchPageSize: 99, | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
}, | |||
}, | |||
@@ -94,20 +92,18 @@ func TestAddLdapBindDn(t *testing.T) { | |||
"--email-attribute", "mail-bind min", | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Name: "ldap (via Bind DN) source min", | |||
IsActived: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source min", | |||
Host: "ldap-bind-server min", | |||
Port: 1234, | |||
SecurityProtocol: ldap.SecurityProtocol(0), | |||
UserBase: "ou=Users,dc=min-domain-bind,dc=org", | |||
AttributeMail: "mail-bind min", | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=min-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
Type: models.LoginLDAP, | |||
Name: "ldap (via Bind DN) source min", | |||
IsActive: true, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source min", | |||
Host: "ldap-bind-server min", | |||
Port: 1234, | |||
SecurityProtocol: ldap.SecurityProtocol(0), | |||
UserBase: "ou=Users,dc=min-domain-bind,dc=org", | |||
AttributeMail: "mail-bind min", | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=min-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
}, | |||
}, | |||
@@ -276,28 +272,26 @@ func TestAddLdapSimpleAuth(t *testing.T) { | |||
"--user-dn", "cn=%s,ou=Users,dc=full-domain-simple,dc=org", | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source full", | |||
IsActived: false, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (simple auth) source full", | |||
Host: "ldap-simple-server full", | |||
Port: 987, | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
SkipVerify: true, | |||
UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", | |||
UserBase: "ou=Users,dc=full-domain-simple,dc=org", | |||
AttributeUsername: "uid-simple full", | |||
AttributeName: "givenName-simple full", | |||
AttributeSurname: "sn-simple full", | |||
AttributeMail: "mail-simple full", | |||
AttributeSSHPublicKey: "publickey-simple full", | |||
Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", | |||
Enabled: true, | |||
}, | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source full", | |||
IsActive: false, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (simple auth) source full", | |||
Host: "ldap-simple-server full", | |||
Port: 987, | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
SkipVerify: true, | |||
UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", | |||
UserBase: "ou=Users,dc=full-domain-simple,dc=org", | |||
AttributeUsername: "uid-simple full", | |||
AttributeName: "givenName-simple full", | |||
AttributeSurname: "sn-simple full", | |||
AttributeMail: "mail-simple full", | |||
AttributeSSHPublicKey: "publickey-simple full", | |||
Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", | |||
Enabled: true, | |||
}, | |||
}, | |||
}, | |||
@@ -314,20 +308,18 @@ func TestAddLdapSimpleAuth(t *testing.T) { | |||
"--user-dn", "cn=%s,ou=Users,dc=min-domain-simple,dc=org", | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source min", | |||
IsActived: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (simple auth) source min", | |||
Host: "ldap-simple-server min", | |||
Port: 123, | |||
SecurityProtocol: ldap.SecurityProtocol(0), | |||
UserDN: "cn=%s,ou=Users,dc=min-domain-simple,dc=org", | |||
AttributeMail: "mail-simple min", | |||
Filter: "(&(objectClass=posixAccount)(min-simple-cn=%s))", | |||
Enabled: true, | |||
}, | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source min", | |||
IsActive: true, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (simple auth) source min", | |||
Host: "ldap-simple-server min", | |||
Port: 123, | |||
SecurityProtocol: ldap.SecurityProtocol(0), | |||
UserDN: "cn=%s,ou=Users,dc=min-domain-simple,dc=org", | |||
AttributeMail: "mail-simple min", | |||
Filter: "(&(objectClass=posixAccount)(min-simple-cn=%s))", | |||
Enabled: true, | |||
}, | |||
}, | |||
}, | |||
@@ -516,41 +508,37 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
id: 23, | |||
existingLoginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
IsActived: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Enabled: true, | |||
}, | |||
Type: models.LoginLDAP, | |||
IsActive: true, | |||
Cfg: &ldap.Source{ | |||
Enabled: true, | |||
}, | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Name: "ldap (via Bind DN) source full", | |||
IsActived: false, | |||
IsActive: false, | |||
IsSyncEnabled: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source full", | |||
Host: "ldap-bind-server full", | |||
Port: 9876, | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
SkipVerify: true, | |||
BindDN: "cn=readonly,dc=full-domain-bind,dc=org", | |||
BindPassword: "secret-bind-full", | |||
UserBase: "ou=Users,dc=full-domain-bind,dc=org", | |||
AttributeUsername: "uid-bind full", | |||
AttributeName: "givenName-bind full", | |||
AttributeSurname: "sn-bind full", | |||
AttributeMail: "mail-bind full", | |||
AttributesInBind: false, | |||
AttributeSSHPublicKey: "publickey-bind full", | |||
SearchPageSize: 99, | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source full", | |||
Host: "ldap-bind-server full", | |||
Port: 9876, | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
SkipVerify: true, | |||
BindDN: "cn=readonly,dc=full-domain-bind,dc=org", | |||
BindPassword: "secret-bind-full", | |||
UserBase: "ou=Users,dc=full-domain-bind,dc=org", | |||
AttributeUsername: "uid-bind full", | |||
AttributeName: "givenName-bind full", | |||
AttributeSurname: "sn-bind full", | |||
AttributeMail: "mail-bind full", | |||
AttributesInBind: false, | |||
AttributeSSHPublicKey: "publickey-bind full", | |||
SearchPageSize: 99, | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=full-domain-bind,dc=org)", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-bind,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-bind,dc=org)", | |||
Enabled: true, | |||
}, | |||
}, | |||
}, | |||
@@ -562,9 +550,7 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
}, | |||
// case 2 | |||
@@ -577,10 +563,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Name: "ldap (via Bind DN) source", | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (via Bind DN) source", | |||
}, | |||
}, | |||
}, | |||
@@ -592,18 +576,14 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
"--not-active", | |||
}, | |||
existingLoginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
IsActived: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Type: models.LoginLDAP, | |||
IsActive: true, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
IsActived: false, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Type: models.LoginLDAP, | |||
IsActive: false, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
}, | |||
// case 4 | |||
@@ -615,10 +595,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
}, | |||
Cfg: &ldap.Source{ | |||
SecurityProtocol: ldap.SecurityProtocol(1), | |||
}, | |||
}, | |||
}, | |||
@@ -631,10 +609,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
SkipVerify: true, | |||
}, | |||
Cfg: &ldap.Source{ | |||
SkipVerify: true, | |||
}, | |||
}, | |||
}, | |||
@@ -647,10 +623,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Host: "ldap-server", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Host: "ldap-server", | |||
}, | |||
}, | |||
}, | |||
@@ -663,10 +637,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Port: 389, | |||
}, | |||
Cfg: &ldap.Source{ | |||
Port: 389, | |||
}, | |||
}, | |||
}, | |||
@@ -679,10 +651,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
UserBase: "ou=Users,dc=domain,dc=org", | |||
}, | |||
Cfg: &ldap.Source{ | |||
UserBase: "ou=Users,dc=domain,dc=org", | |||
}, | |||
}, | |||
}, | |||
@@ -695,10 +665,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Filter: "(memberOf=cn=user-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
}, | |||
}, | |||
@@ -711,10 +679,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
}, | |||
}, | |||
@@ -727,10 +693,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeUsername: "uid", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeUsername: "uid", | |||
}, | |||
}, | |||
}, | |||
@@ -743,10 +707,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeName: "givenName", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeName: "givenName", | |||
}, | |||
}, | |||
}, | |||
@@ -759,10 +721,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeSurname: "sn", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeSurname: "sn", | |||
}, | |||
}, | |||
}, | |||
@@ -775,10 +735,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeMail: "mail", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeMail: "mail", | |||
}, | |||
}, | |||
}, | |||
@@ -791,10 +749,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributesInBind: true, | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributesInBind: true, | |||
}, | |||
}, | |||
}, | |||
@@ -807,10 +763,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeSSHPublicKey: "publickey", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeSSHPublicKey: "publickey", | |||
}, | |||
}, | |||
}, | |||
@@ -823,10 +777,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
BindDN: "cn=readonly,dc=domain,dc=org", | |||
}, | |||
Cfg: &ldap.Source{ | |||
BindDN: "cn=readonly,dc=domain,dc=org", | |||
}, | |||
}, | |||
}, | |||
@@ -839,10 +791,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
BindPassword: "secret", | |||
}, | |||
Cfg: &ldap.Source{ | |||
BindPassword: "secret", | |||
}, | |||
}, | |||
}, | |||
@@ -856,9 +806,7 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
IsSyncEnabled: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
}, | |||
// case 20 | |||
@@ -870,10 +818,8 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
SearchPageSize: 12, | |||
}, | |||
Cfg: &ldap.Source{ | |||
SearchPageSize: 12, | |||
}, | |||
}, | |||
}, | |||
@@ -901,9 +847,7 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
}, | |||
existingLoginSource: &models.LoginSource{ | |||
Type: models.LoginOAuth2, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
errMsg: "Invalid authentication type. expected: LDAP (via BindDN), actual: OAuth2", | |||
}, | |||
@@ -933,9 +877,7 @@ func TestUpdateLdapBindDn(t *testing.T) { | |||
} | |||
return &models.LoginSource{ | |||
Type: models.LoginLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, nil | |||
}, | |||
} | |||
@@ -994,27 +936,25 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
id: 7, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source full", | |||
IsActived: false, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (simple auth) source full", | |||
Host: "ldap-simple-server full", | |||
Port: 987, | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
SkipVerify: true, | |||
UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", | |||
UserBase: "ou=Users,dc=full-domain-simple,dc=org", | |||
AttributeUsername: "uid-simple full", | |||
AttributeName: "givenName-simple full", | |||
AttributeSurname: "sn-simple full", | |||
AttributeMail: "mail-simple full", | |||
AttributeSSHPublicKey: "publickey-simple full", | |||
Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", | |||
}, | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source full", | |||
IsActive: false, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (simple auth) source full", | |||
Host: "ldap-simple-server full", | |||
Port: 987, | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
SkipVerify: true, | |||
UserDN: "cn=%s,ou=Users,dc=full-domain-simple,dc=org", | |||
UserBase: "ou=Users,dc=full-domain-simple,dc=org", | |||
AttributeUsername: "uid-simple full", | |||
AttributeName: "givenName-simple full", | |||
AttributeSurname: "sn-simple full", | |||
AttributeMail: "mail-simple full", | |||
AttributeSSHPublicKey: "publickey-simple full", | |||
Filter: "(&(objectClass=posixAccount)(full-simple-cn=%s))", | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=full-domain-simple,dc=org)", | |||
RestrictedFilter: "(memberOf=cn=restricted-group,ou=example,dc=full-domain-simple,dc=org)", | |||
}, | |||
}, | |||
}, | |||
@@ -1026,9 +966,7 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
}, | |||
// case 2 | |||
@@ -1041,10 +979,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Name: "ldap (simple auth) source", | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: "ldap (simple auth) source", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Name: "ldap (simple auth) source", | |||
}, | |||
}, | |||
}, | |||
@@ -1056,18 +992,14 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
"--not-active", | |||
}, | |||
existingLoginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
IsActived: true, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Type: models.LoginDLDAP, | |||
IsActive: true, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
IsActived: false, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Type: models.LoginDLDAP, | |||
IsActive: false, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
}, | |||
// case 4 | |||
@@ -1079,10 +1011,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
}, | |||
Cfg: &ldap.Source{ | |||
SecurityProtocol: ldap.SecurityProtocol(2), | |||
}, | |||
}, | |||
}, | |||
@@ -1095,10 +1025,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
SkipVerify: true, | |||
}, | |||
Cfg: &ldap.Source{ | |||
SkipVerify: true, | |||
}, | |||
}, | |||
}, | |||
@@ -1111,10 +1039,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Host: "ldap-server", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Host: "ldap-server", | |||
}, | |||
}, | |||
}, | |||
@@ -1127,10 +1053,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Port: 987, | |||
}, | |||
Cfg: &ldap.Source{ | |||
Port: 987, | |||
}, | |||
}, | |||
}, | |||
@@ -1143,10 +1067,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
UserBase: "ou=Users,dc=domain,dc=org", | |||
}, | |||
Cfg: &ldap.Source{ | |||
UserBase: "ou=Users,dc=domain,dc=org", | |||
}, | |||
}, | |||
}, | |||
@@ -1159,10 +1081,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Filter: "(&(objectClass=posixAccount)(cn=%s))", | |||
}, | |||
Cfg: &ldap.Source{ | |||
Filter: "(&(objectClass=posixAccount)(cn=%s))", | |||
}, | |||
}, | |||
}, | |||
@@ -1175,10 +1095,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AdminFilter: "(memberOf=cn=admin-group,ou=example,dc=domain,dc=org)", | |||
}, | |||
}, | |||
}, | |||
@@ -1191,10 +1109,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeUsername: "uid", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeUsername: "uid", | |||
}, | |||
}, | |||
}, | |||
@@ -1207,10 +1123,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeName: "givenName", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeName: "givenName", | |||
}, | |||
}, | |||
}, | |||
@@ -1223,10 +1137,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeSurname: "sn", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeSurname: "sn", | |||
}, | |||
}, | |||
}, | |||
@@ -1239,10 +1151,9 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeMail: "mail", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeMail: "mail", | |||
}, | |||
}, | |||
}, | |||
@@ -1255,10 +1166,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
AttributeSSHPublicKey: "publickey", | |||
}, | |||
Cfg: &ldap.Source{ | |||
AttributeSSHPublicKey: "publickey", | |||
}, | |||
}, | |||
}, | |||
@@ -1271,10 +1180,8 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
loginSource: &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
UserDN: "cn=%s,ou=Users,dc=domain,dc=org", | |||
}, | |||
Cfg: &ldap.Source{ | |||
UserDN: "cn=%s,ou=Users,dc=domain,dc=org", | |||
}, | |||
}, | |||
}, | |||
@@ -1302,9 +1209,7 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
}, | |||
existingLoginSource: &models.LoginSource{ | |||
Type: models.LoginPAM, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, | |||
errMsg: "Invalid authentication type. expected: LDAP (simple auth), actual: PAM", | |||
}, | |||
@@ -1334,9 +1239,7 @@ func TestUpdateLdapSimpleAuth(t *testing.T) { | |||
} | |||
return &models.LoginSource{ | |||
Type: models.LoginDLDAP, | |||
Cfg: &models.LDAPConfig{ | |||
Source: &ldap.Source{}, | |||
}, | |||
Cfg: &ldap.Source{}, | |||
}, nil | |||
}, | |||
} |
@@ -11,7 +11,7 @@ import ( | |||
"strings" | |||
"testing" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/services/auth" | |||
"github.com/stretchr/testify/assert" | |||
"github.com/unknwon/i18n" | |||
@@ -205,7 +205,7 @@ func TestLDAPUserSync(t *testing.T) { | |||
} | |||
defer prepareTestEnv(t)() | |||
addAuthSourceLDAP(t, "") | |||
models.SyncExternalUsers(context.Background(), true) | |||
auth.SyncExternalUsers(context.Background(), true) | |||
session := loginUser(t, "user1") | |||
// Check if users exists | |||
@@ -270,7 +270,7 @@ func TestLDAPUserSSHKeySync(t *testing.T) { | |||
defer prepareTestEnv(t)() | |||
addAuthSourceLDAP(t, "sshPublicKey") | |||
models.SyncExternalUsers(context.Background(), true) | |||
auth.SyncExternalUsers(context.Background(), true) | |||
// Check if users has SSH keys synced | |||
for _, u := range gitLDAPUsers { |
@@ -4,6 +4,12 @@ | |||
package models | |||
import ( | |||
"encoding/binary" | |||
jsoniter "github.com/json-iterator/go" | |||
) | |||
func keysInt64(m map[int64]struct{}) []int64 { | |||
keys := make([]int64, 0, len(m)) | |||
for k := range m { | |||
@@ -27,3 +33,33 @@ func valuesUser(m map[int64]*User) []*User { | |||
} | |||
return values | |||
} | |||
// JSONUnmarshalHandleDoubleEncode - due to a bug in xorm (see https://gitea.com/xorm/xorm/pulls/1957) - it's | |||
// possible that a Blob may be double encoded or gain an unwanted prefix of 0xff 0xfe. | |||
func JSONUnmarshalHandleDoubleEncode(bs []byte, v interface{}) error { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
err := json.Unmarshal(bs, v) | |||
if err != nil { | |||
ok := true | |||
rs := []byte{} | |||
temp := make([]byte, 2) | |||
for _, rn := range string(bs) { | |||
if rn > 0xffff { | |||
ok = false | |||
break | |||
} | |||
binary.LittleEndian.PutUint16(temp, uint16(rn)) | |||
rs = append(rs, temp...) | |||
} | |||
if ok { | |||
if rs[0] == 0xff && rs[1] == 0xfe { | |||
rs = rs[2:] | |||
} | |||
err = json.Unmarshal(rs, v) | |||
} | |||
} | |||
if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe { | |||
err = json.Unmarshal(bs[2:], v) | |||
} | |||
return err | |||
} |
@@ -6,25 +6,11 @@ | |||
package models | |||
import ( | |||
"crypto/tls" | |||
"encoding/binary" | |||
"errors" | |||
"fmt" | |||
"net/smtp" | |||
"net/textproto" | |||
"reflect" | |||
"strconv" | |||
"strings" | |||
"code.gitea.io/gitea/modules/auth/ldap" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/auth/pam" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/secret" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"code.gitea.io/gitea/modules/util" | |||
gouuid "github.com/google/uuid" | |||
jsoniter "github.com/json-iterator/go" | |||
"xorm.io/xorm" | |||
"xorm.io/xorm/convert" | |||
@@ -45,6 +31,11 @@ const ( | |||
LoginSSPI // 7 | |||
) | |||
// String returns the string name of the LoginType | |||
func (typ LoginType) String() string { | |||
return LoginNames[typ] | |||
} | |||
// LoginNames contains the name of LoginType values. | |||
var LoginNames = map[LoginType]string{ | |||
LoginLDAP: "LDAP (via BindDN)", | |||
@@ -55,173 +46,66 @@ var LoginNames = map[LoginType]string{ | |||
LoginSSPI: "SPNEGO with SSPI", | |||
} | |||
// SecurityProtocolNames contains the name of SecurityProtocol values. | |||
var SecurityProtocolNames = map[ldap.SecurityProtocol]string{ | |||
ldap.SecurityProtocolUnencrypted: "Unencrypted", | |||
ldap.SecurityProtocolLDAPS: "LDAPS", | |||
ldap.SecurityProtocolStartTLS: "StartTLS", | |||
} | |||
// Ensure structs implemented interface. | |||
var ( | |||
_ convert.Conversion = &LDAPConfig{} | |||
_ convert.Conversion = &SMTPConfig{} | |||
_ convert.Conversion = &PAMConfig{} | |||
_ convert.Conversion = &OAuth2Config{} | |||
_ convert.Conversion = &SSPIConfig{} | |||
) | |||
// jsonUnmarshalHandleDoubleEncode - due to a bug in xorm (see https://gitea.com/xorm/xorm/pulls/1957) - it's | |||
// possible that a Blob may be double encoded or gain an unwanted prefix of 0xff 0xfe. | |||
func jsonUnmarshalHandleDoubleEncode(bs []byte, v interface{}) error { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
err := json.Unmarshal(bs, v) | |||
if err != nil { | |||
ok := true | |||
rs := []byte{} | |||
temp := make([]byte, 2) | |||
for _, rn := range string(bs) { | |||
if rn > 0xffff { | |||
ok = false | |||
break | |||
} | |||
binary.LittleEndian.PutUint16(temp, uint16(rn)) | |||
rs = append(rs, temp...) | |||
} | |||
if ok { | |||
if rs[0] == 0xff && rs[1] == 0xfe { | |||
rs = rs[2:] | |||
} | |||
err = json.Unmarshal(rs, v) | |||
} | |||
} | |||
if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe { | |||
err = json.Unmarshal(bs[2:], v) | |||
} | |||
return err | |||
} | |||
// LDAPConfig holds configuration for LDAP login source. | |||
type LDAPConfig struct { | |||
*ldap.Source | |||
} | |||
// FromDB fills up a LDAPConfig from serialized format. | |||
func (cfg *LDAPConfig) FromDB(bs []byte) error { | |||
err := jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
if err != nil { | |||
return err | |||
} | |||
if cfg.BindPasswordEncrypt != "" { | |||
cfg.BindPassword, err = secret.DecryptSecret(setting.SecretKey, cfg.BindPasswordEncrypt) | |||
cfg.BindPasswordEncrypt = "" | |||
} | |||
return err | |||
} | |||
// ToDB exports a LDAPConfig to a serialized format. | |||
func (cfg *LDAPConfig) ToDB() ([]byte, error) { | |||
var err error | |||
cfg.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, cfg.BindPassword) | |||
if err != nil { | |||
return nil, err | |||
} | |||
cfg.BindPassword = "" | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
return json.Marshal(cfg) | |||
// LoginConfig represents login config as far as the db is concerned | |||
type LoginConfig interface { | |||
convert.Conversion | |||
} | |||
// SecurityProtocolName returns the name of configured security | |||
// protocol. | |||
func (cfg *LDAPConfig) SecurityProtocolName() string { | |||
return SecurityProtocolNames[cfg.SecurityProtocol] | |||
// SkipVerifiable configurations provide a IsSkipVerify to check if SkipVerify is set | |||
type SkipVerifiable interface { | |||
IsSkipVerify() bool | |||
} | |||
// SMTPConfig holds configuration for the SMTP login source. | |||
type SMTPConfig struct { | |||
Auth string | |||
Host string | |||
Port int | |||
AllowedDomains string `xorm:"TEXT"` | |||
TLS bool | |||
SkipVerify bool | |||
// HasTLSer configurations provide a HasTLS to check if TLS can be enabled | |||
type HasTLSer interface { | |||
HasTLS() bool | |||
} | |||
// FromDB fills up an SMTPConfig from serialized format. | |||
func (cfg *SMTPConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, cfg) | |||
// UseTLSer configurations provide a HasTLS to check if TLS is enabled | |||
type UseTLSer interface { | |||
UseTLS() bool | |||
} | |||
// ToDB exports an SMTPConfig to a serialized format. | |||
func (cfg *SMTPConfig) ToDB() ([]byte, error) { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
return json.Marshal(cfg) | |||
// SSHKeyProvider configurations provide ProvidesSSHKeys to check if they provide SSHKeys | |||
type SSHKeyProvider interface { | |||
ProvidesSSHKeys() bool | |||
} | |||
// PAMConfig holds configuration for the PAM login source. | |||
type PAMConfig struct { | |||
ServiceName string // pam service (e.g. system-auth) | |||
EmailDomain string | |||
// RegisterableSource configurations provide RegisterSource which needs to be run on creation | |||
type RegisterableSource interface { | |||
RegisterSource() error | |||
UnregisterSource() error | |||
} | |||
// FromDB fills up a PAMConfig from serialized format. | |||
func (cfg *PAMConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, cfg) | |||
// LoginSourceSettable configurations can have their loginSource set on them | |||
type LoginSourceSettable interface { | |||
SetLoginSource(*LoginSource) | |||
} | |||
// ToDB exports a PAMConfig to a serialized format. | |||
func (cfg *PAMConfig) ToDB() ([]byte, error) { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
return json.Marshal(cfg) | |||
} | |||
// OAuth2Config holds configuration for the OAuth2 login source. | |||
type OAuth2Config struct { | |||
Provider string | |||
ClientID string | |||
ClientSecret string | |||
OpenIDConnectAutoDiscoveryURL string | |||
CustomURLMapping *oauth2.CustomURLMapping | |||
IconURL string | |||
} | |||
// FromDB fills up an OAuth2Config from serialized format. | |||
func (cfg *OAuth2Config) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, cfg) | |||
} | |||
// ToDB exports an SMTPConfig to a serialized format. | |||
func (cfg *OAuth2Config) ToDB() ([]byte, error) { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
return json.Marshal(cfg) | |||
} | |||
// SSPIConfig holds configuration for SSPI single sign-on. | |||
type SSPIConfig struct { | |||
AutoCreateUsers bool | |||
AutoActivateUsers bool | |||
StripDomainNames bool | |||
SeparatorReplacement string | |||
DefaultLanguage string | |||
} | |||
// RegisterLoginTypeConfig register a config for a provided type | |||
func RegisterLoginTypeConfig(typ LoginType, exemplar LoginConfig) { | |||
if reflect.TypeOf(exemplar).Kind() == reflect.Ptr { | |||
// Pointer: | |||
registeredLoginConfigs[typ] = func() LoginConfig { | |||
return reflect.New(reflect.ValueOf(exemplar).Elem().Type()).Interface().(LoginConfig) | |||
} | |||
return | |||
} | |||
// FromDB fills up an SSPIConfig from serialized format. | |||
func (cfg *SSPIConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, cfg) | |||
// Not a Pointer | |||
registeredLoginConfigs[typ] = func() LoginConfig { | |||
return reflect.New(reflect.TypeOf(exemplar)).Elem().Interface().(LoginConfig) | |||
} | |||
} | |||
// ToDB exports an SSPIConfig to a serialized format. | |||
func (cfg *SSPIConfig) ToDB() ([]byte, error) { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
return json.Marshal(cfg) | |||
} | |||
var registeredLoginConfigs = map[LoginType]func() LoginConfig{} | |||
// LoginSource represents an external way for authorizing users. | |||
type LoginSource struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type LoginType | |||
Name string `xorm:"UNIQUE"` | |||
IsActived bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
Cfg convert.Conversion `xorm:"TEXT"` | |||
@@ -245,19 +129,14 @@ func Cell2Int64(val xorm.Cell) int64 { | |||
// BeforeSet is invoked from XORM before setting the value of a field of this object. | |||
func (source *LoginSource) BeforeSet(colName string, val xorm.Cell) { | |||
if colName == "type" { | |||
switch LoginType(Cell2Int64(val)) { | |||
case LoginLDAP, LoginDLDAP: | |||
source.Cfg = new(LDAPConfig) | |||
case LoginSMTP: | |||
source.Cfg = new(SMTPConfig) | |||
case LoginPAM: | |||
source.Cfg = new(PAMConfig) | |||
case LoginOAuth2: | |||
source.Cfg = new(OAuth2Config) | |||
case LoginSSPI: | |||
source.Cfg = new(SSPIConfig) | |||
default: | |||
panic(fmt.Sprintf("unrecognized login source type: %v", *val)) | |||
typ := LoginType(Cell2Int64(val)) | |||
constructor, ok := registeredLoginConfigs[typ] | |||
if !ok { | |||
return | |||
} | |||
source.Cfg = constructor() | |||
if settable, ok := source.Cfg.(LoginSourceSettable); ok { | |||
settable.SetLoginSource(source) | |||
} | |||
} | |||
} | |||
@@ -299,59 +178,21 @@ func (source *LoginSource) IsSSPI() bool { | |||
// HasTLS returns true of this source supports TLS. | |||
func (source *LoginSource) HasTLS() bool { | |||
return ((source.IsLDAP() || source.IsDLDAP()) && | |||
source.LDAP().SecurityProtocol > ldap.SecurityProtocolUnencrypted) || | |||
source.IsSMTP() | |||
hasTLSer, ok := source.Cfg.(HasTLSer) | |||
return ok && hasTLSer.HasTLS() | |||
} | |||
// UseTLS returns true of this source is configured to use TLS. | |||
func (source *LoginSource) UseTLS() bool { | |||
switch source.Type { | |||
case LoginLDAP, LoginDLDAP: | |||
return source.LDAP().SecurityProtocol != ldap.SecurityProtocolUnencrypted | |||
case LoginSMTP: | |||
return source.SMTP().TLS | |||
} | |||
return false | |||
useTLSer, ok := source.Cfg.(UseTLSer) | |||
return ok && useTLSer.UseTLS() | |||
} | |||
// SkipVerify returns true if this source is configured to skip SSL | |||
// verification. | |||
func (source *LoginSource) SkipVerify() bool { | |||
switch source.Type { | |||
case LoginLDAP, LoginDLDAP: | |||
return source.LDAP().SkipVerify | |||
case LoginSMTP: | |||
return source.SMTP().SkipVerify | |||
} | |||
return false | |||
} | |||
// LDAP returns LDAPConfig for this source, if of LDAP type. | |||
func (source *LoginSource) LDAP() *LDAPConfig { | |||
return source.Cfg.(*LDAPConfig) | |||
} | |||
// SMTP returns SMTPConfig for this source, if of SMTP type. | |||
func (source *LoginSource) SMTP() *SMTPConfig { | |||
return source.Cfg.(*SMTPConfig) | |||
} | |||
// PAM returns PAMConfig for this source, if of PAM type. | |||
func (source *LoginSource) PAM() *PAMConfig { | |||
return source.Cfg.(*PAMConfig) | |||
} | |||
// OAuth2 returns OAuth2Config for this source, if of OAuth2 type. | |||
func (source *LoginSource) OAuth2() *OAuth2Config { | |||
return source.Cfg.(*OAuth2Config) | |||
} | |||
// SSPI returns SSPIConfig for this source, if of SSPI type. | |||
func (source *LoginSource) SSPI() *SSPIConfig { | |||
return source.Cfg.(*SSPIConfig) | |||
skipVerifiable, ok := source.Cfg.(SkipVerifiable) | |||
return ok && skipVerifiable.IsSkipVerify() | |||
} | |||
// CreateLoginSource inserts a LoginSource in the DB if not already | |||
@@ -369,16 +210,24 @@ func CreateLoginSource(source *LoginSource) error { | |||
} | |||
_, err = x.Insert(source) | |||
if err == nil && source.IsOAuth2() && source.IsActived { | |||
oAuth2Config := source.OAuth2() | |||
err = oauth2.RegisterProvider(source.Name, oAuth2Config.Provider, oAuth2Config.ClientID, oAuth2Config.ClientSecret, oAuth2Config.OpenIDConnectAutoDiscoveryURL, oAuth2Config.CustomURLMapping) | |||
err = wrapOpenIDConnectInitializeError(err, source.Name, oAuth2Config) | |||
if err != nil { | |||
// remove the LoginSource in case of errors while registering OAuth2 providers | |||
if _, err := x.Delete(source); err != nil { | |||
log.Error("CreateLoginSource: Error while wrapOpenIDConnectInitializeError: %v", err) | |||
} | |||
return err | |||
if err != nil { | |||
return err | |||
} | |||
if !source.IsActive { | |||
return nil | |||
} | |||
registerableSource, ok := source.Cfg.(RegisterableSource) | |||
if !ok { | |||
return nil | |||
} | |||
err = registerableSource.RegisterSource() | |||
if err != nil { | |||
// remove the LoginSource in case of errors while registering configuration | |||
if _, err := x.Delete(source); err != nil { | |||
log.Error("CreateLoginSource: Error while wrapOpenIDConnectInitializeError: %v", err) | |||
} | |||
} | |||
return err | |||
@@ -399,10 +248,19 @@ func LoginSourcesByType(loginType LoginType) ([]*LoginSource, error) { | |||
return sources, nil | |||
} | |||
// AllActiveLoginSources returns all active sources | |||
func AllActiveLoginSources() ([]*LoginSource, error) { | |||
sources := make([]*LoginSource, 0, 5) | |||
if err := x.Where("is_active = ?", true).Find(&sources); err != nil { | |||
return nil, err | |||
} | |||
return sources, nil | |||
} | |||
// ActiveLoginSources returns all active sources of the specified type | |||
func ActiveLoginSources(loginType LoginType) ([]*LoginSource, error) { | |||
sources := make([]*LoginSource, 0, 1) | |||
if err := x.Where("is_actived = ? and type = ?", true, loginType).Find(&sources); err != nil { | |||
if err := x.Where("is_active = ? and type = ?", true, loginType).Find(&sources); err != nil { | |||
return nil, err | |||
} | |||
return sources, nil | |||
@@ -425,6 +283,14 @@ func IsSSPIEnabled() bool { | |||
// GetLoginSourceByID returns login source by given ID. | |||
func GetLoginSourceByID(id int64) (*LoginSource, error) { | |||
source := new(LoginSource) | |||
if id == 0 { | |||
source.Cfg = registeredLoginConfigs[LoginNoType]() | |||
// Set this source to active | |||
// FIXME: allow disabling of db based password authentication in future | |||
source.IsActive = true | |||
return source, nil | |||
} | |||
has, err := x.ID(id).Get(source) | |||
if err != nil { | |||
return nil, err | |||
@@ -446,16 +312,24 @@ func UpdateSource(source *LoginSource) error { | |||
} | |||
_, err := x.ID(source.ID).AllCols().Update(source) | |||
if err == nil && source.IsOAuth2() && source.IsActived { | |||
oAuth2Config := source.OAuth2() | |||
err = oauth2.RegisterProvider(source.Name, oAuth2Config.Provider, oAuth2Config.ClientID, oAuth2Config.ClientSecret, oAuth2Config.OpenIDConnectAutoDiscoveryURL, oAuth2Config.CustomURLMapping) | |||
err = wrapOpenIDConnectInitializeError(err, source.Name, oAuth2Config) | |||
if err != nil { | |||
// restore original values since we cannot update the provider it self | |||
if _, err := x.ID(source.ID).AllCols().Update(originalLoginSource); err != nil { | |||
log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err) | |||
} | |||
return err | |||
if err != nil { | |||
return err | |||
} | |||
if !source.IsActive { | |||
return nil | |||
} | |||
registerableSource, ok := source.Cfg.(RegisterableSource) | |||
if !ok { | |||
return nil | |||
} | |||
err = registerableSource.RegisterSource() | |||
if err != nil { | |||
// restore original values since we cannot update the provider it self | |||
if _, err := x.ID(source.ID).AllCols().Update(originalLoginSource); err != nil { | |||
log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err) | |||
} | |||
} | |||
return err | |||
@@ -477,8 +351,10 @@ func DeleteSource(source *LoginSource) error { | |||
return ErrLoginSourceInUse{source.ID} | |||
} | |||
if source.IsOAuth2() { | |||
oauth2.RemoveProvider(source.Name) | |||
if registerableSource, ok := source.Cfg.(RegisterableSource); ok { | |||
if err := registerableSource.UnregisterSource(); err != nil { | |||
return err | |||
} | |||
} | |||
_, err = x.ID(source.ID).Delete(new(LoginSource)) | |||
@@ -490,404 +366,3 @@ func CountLoginSources() int64 { | |||
count, _ := x.Count(new(LoginSource)) | |||
return count | |||
} | |||
// .____ ________ _____ __________ | |||
// | | \______ \ / _ \\______ \ | |||
// | | | | \ / /_\ \| ___/ | |||
// | |___ | ` \/ | \ | | |||
// |_______ \/_______ /\____|__ /____| | |||
// \/ \/ \/ | |||
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 | |||
} | |||
} | |||
// LoginViaLDAP queries if login/password is valid against the LDAP directory pool, | |||
// and create a local user if success when enabled. | |||
func LoginViaLDAP(user *User, login, password string, source *LoginSource) (*User, error) { | |||
sr := source.Cfg.(*LDAPConfig).SearchEntry(login, password, source.Type == LoginDLDAP) | |||
if sr == nil { | |||
// User not in LDAP, do nothing | |||
return nil, ErrUserNotExist{0, login, 0} | |||
} | |||
isAttributeSSHPublicKeySet := len(strings.TrimSpace(source.LDAP().AttributeSSHPublicKey)) > 0 | |||
// Update User admin flag if exist | |||
if isExist, err := IsUserExist(0, sr.Username); err != nil { | |||
return nil, err | |||
} else if isExist { | |||
if user == nil { | |||
user, err = GetUserByName(sr.Username) | |||
if err != nil { | |||
return nil, err | |||
} | |||
} | |||
if user != nil && !user.ProhibitLogin { | |||
cols := make([]string, 0) | |||
if len(source.LDAP().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.LDAP().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 = UpdateUserCols(user, cols...) | |||
if err != nil { | |||
return nil, err | |||
} | |||
} | |||
} | |||
} | |||
if user != nil { | |||
if isAttributeSSHPublicKeySet && synchronizeLdapSSHPublicKeys(user, source, sr.SSHPublicKey) { | |||
return user, 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 = &User{ | |||
LowerName: strings.ToLower(sr.Username), | |||
Name: sr.Username, | |||
FullName: composeFullName(sr.Name, sr.Surname, sr.Username), | |||
Email: sr.Mail, | |||
LoginType: source.Type, | |||
LoginSource: source.ID, | |||
LoginName: login, | |||
IsActive: true, | |||
IsAdmin: sr.IsAdmin, | |||
IsRestricted: sr.IsRestricted, | |||
} | |||
err := CreateUser(user) | |||
if err == nil && isAttributeSSHPublicKeySet && addLdapSSHPublicKeys(user, source, sr.SSHPublicKey) { | |||
err = RewriteAllPublicKeys() | |||
} | |||
return user, err | |||
} | |||
// _________ __________________________ | |||
// / _____/ / \__ ___/\______ \ | |||
// \_____ \ / \ / \| | | ___/ | |||
// / \/ Y \ | | | | |||
// /_______ /\____|__ /____| |____| | |||
// \/ \/ | |||
type smtpLoginAuth struct { | |||
username, password string | |||
} | |||
func (auth *smtpLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { | |||
return "LOGIN", []byte(auth.username), nil | |||
} | |||
func (auth *smtpLoginAuth) 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 ( | |||
SMTPPlain = "PLAIN" | |||
SMTPLogin = "LOGIN" | |||
) | |||
// SMTPAuths contains available SMTP authentication type names. | |||
var SMTPAuths = []string{SMTPPlain, SMTPLogin} | |||
// SMTPAuth performs an SMTP authentication. | |||
func SMTPAuth(a smtp.Auth, cfg *SMTPConfig) error { | |||
c, err := smtp.Dial(fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)) | |||
if err != nil { | |||
return err | |||
} | |||
defer c.Close() | |||
if err = c.Hello("gogs"); err != nil { | |||
return err | |||
} | |||
if cfg.TLS { | |||
if ok, _ := c.Extension("STARTTLS"); ok { | |||
if err = c.StartTLS(&tls.Config{ | |||
InsecureSkipVerify: cfg.SkipVerify, | |||
ServerName: cfg.Host, | |||
}); err != nil { | |||
return err | |||
} | |||
} else { | |||
return errors.New("SMTP server unsupports TLS") | |||
} | |||
} | |||
if ok, _ := c.Extension("AUTH"); ok { | |||
return c.Auth(a) | |||
} | |||
return ErrUnsupportedLoginType | |||
} | |||
// LoginViaSMTP queries if login/password is valid against the SMTP, | |||
// and create a local user if success when enabled. | |||
func LoginViaSMTP(user *User, login, password string, sourceID int64, cfg *SMTPConfig) (*User, error) { | |||
// Verify allowed domains. | |||
if len(cfg.AllowedDomains) > 0 { | |||
idx := strings.Index(login, "@") | |||
if idx == -1 { | |||
return nil, ErrUserNotExist{0, login, 0} | |||
} else if !util.IsStringInSlice(login[idx+1:], strings.Split(cfg.AllowedDomains, ","), true) { | |||
return nil, ErrUserNotExist{0, login, 0} | |||
} | |||
} | |||
var auth smtp.Auth | |||
if cfg.Auth == SMTPPlain { | |||
auth = smtp.PlainAuth("", login, password, cfg.Host) | |||
} else if cfg.Auth == SMTPLogin { | |||
auth = &smtpLoginAuth{login, password} | |||
} else { | |||
return nil, errors.New("Unsupported SMTP auth type") | |||
} | |||
if err := SMTPAuth(auth, cfg); 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, ErrUserNotExist{0, login, 0} | |||
} | |||
return nil, err | |||
} | |||
if user != nil { | |||
return user, nil | |||
} | |||
username := login | |||
idx := strings.Index(login, "@") | |||
if idx > -1 { | |||
username = login[:idx] | |||
} | |||
user = &User{ | |||
LowerName: strings.ToLower(username), | |||
Name: strings.ToLower(username), | |||
Email: login, | |||
Passwd: password, | |||
LoginType: LoginSMTP, | |||
LoginSource: sourceID, | |||
LoginName: login, | |||
IsActive: true, | |||
} | |||
return user, CreateUser(user) | |||
} | |||
// __________ _____ _____ | |||
// \______ \/ _ \ / \ | |||
// | ___/ /_\ \ / \ / \ | |||
// | | / | \/ Y \ | |||
// |____| \____|__ /\____|__ / | |||
// \/ \/ | |||
// LoginViaPAM queries if login/password is valid against the PAM, | |||
// and create a local user if success when enabled. | |||
func LoginViaPAM(user *User, login, password string, sourceID int64, cfg *PAMConfig) (*User, error) { | |||
pamLogin, err := pam.Auth(cfg.ServiceName, login, password) | |||
if err != nil { | |||
if strings.Contains(err.Error(), "Authentication failure") { | |||
return nil, ErrUserNotExist{0, login, 0} | |||
} | |||
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 ValidateEmail(email) != nil { | |||
if cfg.EmailDomain != "" { | |||
email = fmt.Sprintf("%s@%s", username, cfg.EmailDomain) | |||
} else { | |||
email = fmt.Sprintf("%s@%s", username, setting.Service.NoReplyAddress) | |||
} | |||
if ValidateEmail(email) != nil { | |||
email = gouuid.New().String() + "@localhost" | |||
} | |||
} | |||
user = &User{ | |||
LowerName: strings.ToLower(username), | |||
Name: username, | |||
Email: email, | |||
Passwd: password, | |||
LoginType: LoginPAM, | |||
LoginSource: sourceID, | |||
LoginName: login, // This is what the user typed in | |||
IsActive: true, | |||
} | |||
return user, CreateUser(user) | |||
} | |||
// ExternalUserLogin attempts a login using external source types. | |||
func ExternalUserLogin(user *User, login, password string, source *LoginSource) (*User, error) { | |||
if !source.IsActived { | |||
return nil, ErrLoginSourceNotActived | |||
} | |||
var err error | |||
switch source.Type { | |||
case LoginLDAP, LoginDLDAP: | |||
user, err = LoginViaLDAP(user, login, password, source) | |||
case LoginSMTP: | |||
user, err = LoginViaSMTP(user, login, password, source.ID, source.Cfg.(*SMTPConfig)) | |||
case LoginPAM: | |||
user, err = LoginViaPAM(user, login, password, source.ID, source.Cfg.(*PAMConfig)) | |||
default: | |||
return nil, ErrUnsupportedLoginType | |||
} | |||
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, ErrUserProhibitLogin{user.ID, user.Name} | |||
} | |||
return user, nil | |||
} | |||
// UserSignIn validates user name and password. | |||
func UserSignIn(username, password string) (*User, error) { | |||
var user *User | |||
if strings.Contains(username, "@") { | |||
user = &User{Email: strings.ToLower(strings.TrimSpace(username))} | |||
// check same email | |||
cnt, err := x.Count(user) | |||
if err != nil { | |||
return nil, err | |||
} | |||
if cnt > 1 { | |||
return nil, ErrEmailAlreadyUsed{ | |||
Email: user.Email, | |||
} | |||
} | |||
} else { | |||
trimmedUsername := strings.TrimSpace(username) | |||
if len(trimmedUsername) == 0 { | |||
return nil, ErrUserNotExist{0, username, 0} | |||
} | |||
user = &User{LowerName: strings.ToLower(trimmedUsername)} | |||
} | |||
hasUser, err := x.Get(user) | |||
if err != nil { | |||
return nil, err | |||
} | |||
if hasUser { | |||
switch user.LoginType { | |||
case LoginNoType, LoginPlain, LoginOAuth2: | |||
if user.IsPasswordSet() && user.ValidatePassword(password) { | |||
// 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 = 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, ErrUserProhibitLogin{user.ID, user.Name} | |||
} | |||
return user, nil | |||
} | |||
return nil, ErrUserNotExist{user.ID, user.Name, 0} | |||
default: | |||
var source LoginSource | |||
hasSource, err := x.ID(user.LoginSource).Get(&source) | |||
if err != nil { | |||
return nil, err | |||
} else if !hasSource { | |||
return nil, ErrLoginSourceNotExist{user.LoginSource} | |||
} | |||
return ExternalUserLogin(user, user.LoginName, password, &source) | |||
} | |||
} | |||
sources := make([]*LoginSource, 0, 5) | |||
if err = x.Where("is_actived = ?", true).Find(&sources); err != nil { | |||
return nil, err | |||
} | |||
for _, source := range sources { | |||
if source.IsOAuth2() || source.IsSSPI() { | |||
// don't try to authenticate against OAuth2 and SSPI sources here | |||
continue | |||
} | |||
authUser, err := ExternalUserLogin(nil, username, password, source) | |||
if err == nil { | |||
return authUser, nil | |||
} | |||
if 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, ErrUserNotExist{user.ID, user.Name, 0} | |||
} |
@@ -0,0 +1,48 @@ | |||
# type LoginSource struct { | |||
# ID int64 `xorm:"pk autoincr"` | |||
# Type int | |||
# Cfg []byte `xorm:"TEXT"` | |||
# Expected []byte `xorm:"TEXT"` | |||
# } | |||
- | |||
id: 1 | |||
type: 1 | |||
is_actived: false | |||
cfg: "{\"Source\":{\"A\":\"string\",\"B\":1}}" | |||
expected: "{\"Source\":{\"A\":\"string\",\"B\":1}}" | |||
- | |||
id: 2 | |||
type: 2 | |||
is_actived: true | |||
cfg: "{\"Source\":{\"A\":\"string2\",\"B\":2}}" | |||
expected: "{\"A\":\"string2\",\"B\":2}" | |||
- | |||
id: 3 | |||
type: 3 | |||
is_actived: false | |||
cfg: "{\"Source\":{\"A\":\"string3\",\"B\":3}}" | |||
expected: "{\"Source\":{\"A\":\"string3\",\"B\":3}}" | |||
- | |||
id: 4 | |||
type: 4 | |||
is_actived: true | |||
cfg: "{\"Source\":{\"A\":\"string4\",\"B\":4}}" | |||
expected: "{\"Source\":{\"A\":\"string4\",\"B\":4}}" | |||
- | |||
id: 5 | |||
type: 5 | |||
is_actived: false | |||
cfg: "{\"Source\":{\"A\":\"string5\",\"B\":5}}" | |||
expected: "{\"A\":\"string5\",\"B\":5}" | |||
- | |||
id: 6 | |||
type: 2 | |||
is_actived: true | |||
cfg: "{\"A\":\"string6\",\"B\":6}" | |||
expected: "{\"A\":\"string6\",\"B\":6}" | |||
- | |||
id: 7 | |||
type: 5 | |||
is_actived: false | |||
cfg: "{\"A\":\"string7\",\"B\":7}" | |||
expected: "{\"A\":\"string7\",\"B\":7}" |
@@ -327,6 +327,8 @@ var migrations = []Migration{ | |||
NewMigration("Drop unneeded webhook related columns", dropWebhookColumns), | |||
// v188 -> v189 | |||
NewMigration("Add key is verified to gpg key", addKeyIsVerified), | |||
// v189 -> v190 | |||
NewMigration("Unwrap ldap.Sources", unwrapLDAPSourceCfg), | |||
} | |||
// GetCurrentDBVersion returns the current db version |
@@ -220,6 +220,9 @@ func prepareTestEnv(t *testing.T, skip int, syncModels ...interface{}) (*xorm.En | |||
if err := x.Close(); err != nil { | |||
t.Errorf("error during close: %v", err) | |||
} | |||
if err := deleteDB(); err != nil { | |||
t.Errorf("unable to reset database: %v", err) | |||
} | |||
} | |||
} | |||
if err != nil { |
@@ -0,0 +1,111 @@ | |||
// 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 migrations | |||
import ( | |||
"encoding/binary" | |||
"fmt" | |||
jsoniter "github.com/json-iterator/go" | |||
"xorm.io/xorm" | |||
) | |||
func unwrapLDAPSourceCfg(x *xorm.Engine) error { | |||
jsonUnmarshalHandleDoubleEncode := func(bs []byte, v interface{}) error { | |||
json := jsoniter.ConfigCompatibleWithStandardLibrary | |||
err := json.Unmarshal(bs, v) | |||
if err != nil { | |||
ok := true | |||
rs := []byte{} | |||
temp := make([]byte, 2) | |||
for _, rn := range string(bs) { | |||
if rn > 0xffff { | |||
ok = false | |||
break | |||
} | |||
binary.LittleEndian.PutUint16(temp, uint16(rn)) | |||
rs = append(rs, temp...) | |||
} | |||
if ok { | |||
if rs[0] == 0xff && rs[1] == 0xfe { | |||
rs = rs[2:] | |||
} | |||
err = json.Unmarshal(rs, v) | |||
} | |||
} | |||
if err != nil && len(bs) > 2 && bs[0] == 0xff && bs[1] == 0xfe { | |||
err = json.Unmarshal(bs[2:], v) | |||
} | |||
return err | |||
} | |||
// LoginSource represents an external way for authorizing users. | |||
type LoginSource struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type int | |||
IsActived bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
Cfg string `xorm:"TEXT"` | |||
} | |||
const ldapType = 2 | |||
const dldapType = 5 | |||
type WrappedSource struct { | |||
Source map[string]interface{} | |||
} | |||
// change lower_email as unique | |||
if err := x.Sync2(new(LoginSource)); err != nil { | |||
return err | |||
} | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
const batchSize = 100 | |||
for start := 0; ; start += batchSize { | |||
sources := make([]*LoginSource, 0, batchSize) | |||
if err := sess.Limit(batchSize, start).Where("`type` = ? OR `type` = ?", ldapType, dldapType).Find(&sources); err != nil { | |||
return err | |||
} | |||
if len(sources) == 0 { | |||
break | |||
} | |||
for _, source := range sources { | |||
wrapped := &WrappedSource{ | |||
Source: map[string]interface{}{}, | |||
} | |||
err := jsonUnmarshalHandleDoubleEncode([]byte(source.Cfg), &wrapped) | |||
if err != nil { | |||
return fmt.Errorf("failed to unmarshal %s: %w", string(source.Cfg), err) | |||
} | |||
if wrapped.Source != nil && len(wrapped.Source) > 0 { | |||
bs, err := jsoniter.Marshal(wrapped.Source) | |||
if err != nil { | |||
return err | |||
} | |||
source.Cfg = string(bs) | |||
if _, err := sess.ID(source.ID).Cols("cfg").Update(source); err != nil { | |||
return err | |||
} | |||
} | |||
} | |||
} | |||
if _, err := x.SetExpr("is_active", "is_actived").Update(&LoginSource{}); err != nil { | |||
return fmt.Errorf("SetExpr Update failed: %w", err) | |||
} | |||
if err := sess.Begin(); err != nil { | |||
return err | |||
} | |||
if err := dropTableColumns(sess, "login_source", "is_actived"); err != nil { | |||
return err | |||
} | |||
return sess.Commit() | |||
} |
@@ -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 migrations | |||
import ( | |||
"testing" | |||
jsoniter "github.com/json-iterator/go" | |||
"github.com/stretchr/testify/assert" | |||
) | |||
// LoginSource represents an external way for authorizing users. | |||
type LoginSourceOriginalV189 struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type int | |||
IsActived bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
Cfg string `xorm:"TEXT"` | |||
Expected string `xorm:"TEXT"` | |||
} | |||
func (ls *LoginSourceOriginalV189) TableName() string { | |||
return "login_source" | |||
} | |||
func Test_unwrapLDAPSourceCfg(t *testing.T) { | |||
// Prepare and load the testing database | |||
x, deferable := prepareTestEnv(t, 0, new(LoginSourceOriginalV189)) | |||
if x == nil || t.Failed() { | |||
defer deferable() | |||
return | |||
} | |||
defer deferable() | |||
// LoginSource represents an external way for authorizing users. | |||
type LoginSource struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
Type int | |||
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"` | |||
Cfg string `xorm:"TEXT"` | |||
Expected string `xorm:"TEXT"` | |||
} | |||
// Run the migration | |||
if err := unwrapLDAPSourceCfg(x); err != nil { | |||
assert.NoError(t, err) | |||
return | |||
} | |||
const batchSize = 100 | |||
for start := 0; ; start += batchSize { | |||
sources := make([]*LoginSource, 0, batchSize) | |||
if err := x.Table("login_source").Limit(batchSize, start).Find(&sources); err != nil { | |||
assert.NoError(t, err) | |||
return | |||
} | |||
if len(sources) == 0 { | |||
break | |||
} | |||
for _, source := range sources { | |||
converted := map[string]interface{}{} | |||
expected := map[string]interface{}{} | |||
if err := jsoniter.Unmarshal([]byte(source.Cfg), &converted); err != nil { | |||
assert.NoError(t, err) | |||
return | |||
} | |||
if err := jsoniter.Unmarshal([]byte(source.Expected), &expected); err != nil { | |||
assert.NoError(t, err) | |||
return | |||
} | |||
assert.EqualValues(t, expected, converted, "unwrapLDAPSourceCfg failed for %d", source.ID) | |||
assert.EqualValues(t, source.ID%2 == 0, source.IsActive, "unwrapLDAPSourceCfg failed for %d", source.ID) | |||
} | |||
} | |||
} |
@@ -4,89 +4,10 @@ | |||
package models | |||
import ( | |||
"sort" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/log" | |||
) | |||
// OAuth2Provider describes the display values of a single OAuth2 provider | |||
type OAuth2Provider struct { | |||
Name string | |||
DisplayName string | |||
Image string | |||
CustomURLMapping *oauth2.CustomURLMapping | |||
} | |||
// OAuth2Providers 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 OAuth2Providers = map[string]OAuth2Provider{ | |||
"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: &oauth2.CustomURLMapping{ | |||
TokenURL: oauth2.GetDefaultTokenURL("github"), | |||
AuthURL: oauth2.GetDefaultAuthURL("github"), | |||
ProfileURL: oauth2.GetDefaultProfileURL("github"), | |||
EmailURL: oauth2.GetDefaultEmailURL("github"), | |||
}, | |||
}, | |||
"gitlab": { | |||
Name: "gitlab", DisplayName: "GitLab", Image: "/assets/img/auth/gitlab.png", | |||
CustomURLMapping: &oauth2.CustomURLMapping{ | |||
TokenURL: oauth2.GetDefaultTokenURL("gitlab"), | |||
AuthURL: oauth2.GetDefaultAuthURL("gitlab"), | |||
ProfileURL: oauth2.GetDefaultProfileURL("gitlab"), | |||
}, | |||
}, | |||
"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: &oauth2.CustomURLMapping{ | |||
TokenURL: oauth2.GetDefaultTokenURL("gitea"), | |||
AuthURL: oauth2.GetDefaultAuthURL("gitea"), | |||
ProfileURL: oauth2.GetDefaultProfileURL("gitea"), | |||
}, | |||
}, | |||
"nextcloud": { | |||
Name: "nextcloud", DisplayName: "Nextcloud", Image: "/assets/img/auth/nextcloud.png", | |||
CustomURLMapping: &oauth2.CustomURLMapping{ | |||
TokenURL: oauth2.GetDefaultTokenURL("nextcloud"), | |||
AuthURL: oauth2.GetDefaultAuthURL("nextcloud"), | |||
ProfileURL: oauth2.GetDefaultProfileURL("nextcloud"), | |||
}, | |||
}, | |||
"yandex": {Name: "yandex", DisplayName: "Yandex", Image: "/assets/img/auth/yandex.png"}, | |||
"mastodon": { | |||
Name: "mastodon", DisplayName: "Mastodon", Image: "/assets/img/auth/mastodon.png", | |||
CustomURLMapping: &oauth2.CustomURLMapping{ | |||
AuthURL: oauth2.GetDefaultAuthURL("mastodon"), | |||
}, | |||
}, | |||
} | |||
// OAuth2DefaultCustomURLMappings 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 OAuth2DefaultCustomURLMappings = map[string]*oauth2.CustomURLMapping{ | |||
"github": OAuth2Providers["github"].CustomURLMapping, | |||
"gitlab": OAuth2Providers["gitlab"].CustomURLMapping, | |||
"gitea": OAuth2Providers["gitea"].CustomURLMapping, | |||
"nextcloud": OAuth2Providers["nextcloud"].CustomURLMapping, | |||
"mastodon": OAuth2Providers["mastodon"].CustomURLMapping, | |||
} | |||
// GetActiveOAuth2ProviderLoginSources returns all actived LoginOAuth2 sources | |||
func GetActiveOAuth2ProviderLoginSources() ([]*LoginSource, error) { | |||
sources := make([]*LoginSource, 0, 1) | |||
if err := x.Where("is_actived = ? and type = ?", true, LoginOAuth2).Find(&sources); err != nil { | |||
if err := x.Where("is_active = ? and type = ?", true, LoginOAuth2).Find(&sources); err != nil { | |||
return nil, err | |||
} | |||
return sources, nil | |||
@@ -95,81 +16,10 @@ func GetActiveOAuth2ProviderLoginSources() ([]*LoginSource, error) { | |||
// GetActiveOAuth2LoginSourceByName returns a OAuth2 LoginSource based on the given name | |||
func GetActiveOAuth2LoginSourceByName(name string) (*LoginSource, error) { | |||
loginSource := new(LoginSource) | |||
has, err := x.Where("name = ? and type = ? and is_actived = ?", name, LoginOAuth2, true).Get(loginSource) | |||
has, err := x.Where("name = ? and type = ? and is_active = ?", name, LoginOAuth2, true).Get(loginSource) | |||
if !has || err != nil { | |||
return nil, err | |||
} | |||
return loginSource, nil | |||
} | |||
// 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]OAuth2Provider, error) { | |||
// Maybe also separate used and unused providers so we can force the registration of only 1 active provider for each type | |||
loginSources, err := GetActiveOAuth2ProviderLoginSources() | |||
if err != nil { | |||
return nil, nil, err | |||
} | |||
var orderedKeys []string | |||
providers := make(map[string]OAuth2Provider) | |||
for _, source := range loginSources { | |||
prov := OAuth2Providers[source.OAuth2().Provider] | |||
if source.OAuth2().IconURL != "" { | |||
prov.Image = source.OAuth2().IconURL | |||
} | |||
providers[source.Name] = prov | |||
orderedKeys = append(orderedKeys, source.Name) | |||
} | |||
sort.Strings(orderedKeys) | |||
return orderedKeys, providers, nil | |||
} | |||
// InitOAuth2 initialize the OAuth2 lib and register all active OAuth2 providers in the library | |||
func InitOAuth2() error { | |||
if err := oauth2.InitSigningKey(); err != nil { | |||
return err | |||
} | |||
if err := oauth2.Init(x); err != nil { | |||
return err | |||
} | |||
return initOAuth2LoginSources() | |||
} | |||
// ResetOAuth2 clears existing OAuth2 providers and loads them from DB | |||
func ResetOAuth2() error { | |||
oauth2.ClearProviders() | |||
return initOAuth2LoginSources() | |||
} | |||
// initOAuth2LoginSources is used to load and register all active OAuth2 providers | |||
func initOAuth2LoginSources() error { | |||
loginSources, _ := GetActiveOAuth2ProviderLoginSources() | |||
for _, source := range loginSources { | |||
oAuth2Config := source.OAuth2() | |||
err := oauth2.RegisterProvider(source.Name, oAuth2Config.Provider, oAuth2Config.ClientID, oAuth2Config.ClientSecret, oAuth2Config.OpenIDConnectAutoDiscoveryURL, oAuth2Config.CustomURLMapping) | |||
if err != nil { | |||
log.Critical("Unable to register source: %s due to Error: %v. This source will be disabled.", source.Name, err) | |||
source.IsActived = false | |||
if err = UpdateSource(source); err != nil { | |||
log.Critical("Unable to update source %s to disable it. Error: %v", err) | |||
return err | |||
} | |||
} | |||
} | |||
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, oAuth2Config *OAuth2Config) error { | |||
if err != nil && "openidConnect" == oAuth2Config.Provider { | |||
err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: oAuth2Config.OpenIDConnectAutoDiscoveryURL, Cause: err} | |||
} | |||
return err | |||
} |
@@ -10,14 +10,11 @@ import ( | |||
"fmt" | |||
"net/url" | |||
"strings" | |||
"time" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/secret" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"code.gitea.io/gitea/modules/util" | |||
"github.com/dgrijalva/jwt-go" | |||
uuid "github.com/google/uuid" | |||
"golang.org/x/crypto/bcrypt" | |||
"xorm.io/xorm" | |||
@@ -516,77 +513,3 @@ func revokeOAuth2Grant(e Engine, grantID, userID int64) error { | |||
_, err := e.Delete(&OAuth2Grant{ID: grantID, UserID: userID}) | |||
return err | |||
} | |||
////////////////////////////////////////////////////////////// | |||
// OAuth2TokenType represents the type of token for an oauth application | |||
type OAuth2TokenType int | |||
const ( | |||
// TypeAccessToken is a token with short lifetime to access the api | |||
TypeAccessToken OAuth2TokenType = 0 | |||
// TypeRefreshToken is token with long lifetime to refresh access tokens obtained by the client | |||
TypeRefreshToken = iota | |||
) | |||
// OAuth2Token represents a JWT token used to authenticate a client | |||
type OAuth2Token struct { | |||
GrantID int64 `json:"gnt"` | |||
Type OAuth2TokenType `json:"tt"` | |||
Counter int64 `json:"cnt,omitempty"` | |||
jwt.StandardClaims | |||
} | |||
// ParseOAuth2Token parses a signed jwt string | |||
func ParseOAuth2Token(jwtToken string) (*OAuth2Token, error) { | |||
parsedToken, err := jwt.ParseWithClaims(jwtToken, &OAuth2Token{}, func(token *jwt.Token) (interface{}, error) { | |||
if token.Method == nil || token.Method.Alg() != oauth2.DefaultSigningKey.SigningMethod().Alg() { | |||
return nil, fmt.Errorf("unexpected signing algo: %v", token.Header["alg"]) | |||
} | |||
return oauth2.DefaultSigningKey.VerifyKey(), nil | |||
}) | |||
if err != nil { | |||
return nil, err | |||
} | |||
var token *OAuth2Token | |||
var ok bool | |||
if token, ok = parsedToken.Claims.(*OAuth2Token); !ok || !parsedToken.Valid { | |||
return nil, fmt.Errorf("invalid token") | |||
} | |||
return token, nil | |||
} | |||
// SignToken signs the token with the JWT secret | |||
func (token *OAuth2Token) SignToken() (string, error) { | |||
token.IssuedAt = time.Now().Unix() | |||
jwtToken := jwt.NewWithClaims(oauth2.DefaultSigningKey.SigningMethod(), token) | |||
oauth2.DefaultSigningKey.PreProcessToken(jwtToken) | |||
return jwtToken.SignedString(oauth2.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 oauth2.JWTSigningKey) (string, error) { | |||
token.IssuedAt = time.Now().Unix() | |||
jwtToken := jwt.NewWithClaims(signingKey.SigningMethod(), token) | |||
signingKey.PreProcessToken(jwtToken) | |||
return jwtToken.SignedString(signingKey.SignKey()) | |||
} |
@@ -28,7 +28,7 @@ type UnitConfig struct{} | |||
// FromDB fills up a UnitConfig from serialized format. | |||
func (cfg *UnitConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
return JSONUnmarshalHandleDoubleEncode(bs, &cfg) | |||
} | |||
// ToDB exports a UnitConfig to a serialized format. | |||
@@ -44,7 +44,7 @@ type ExternalWikiConfig struct { | |||
// FromDB fills up a ExternalWikiConfig from serialized format. | |||
func (cfg *ExternalWikiConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
return JSONUnmarshalHandleDoubleEncode(bs, &cfg) | |||
} | |||
// ToDB exports a ExternalWikiConfig to a serialized format. | |||
@@ -62,7 +62,7 @@ type ExternalTrackerConfig struct { | |||
// FromDB fills up a ExternalTrackerConfig from serialized format. | |||
func (cfg *ExternalTrackerConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
return JSONUnmarshalHandleDoubleEncode(bs, &cfg) | |||
} | |||
// ToDB exports a ExternalTrackerConfig to a serialized format. | |||
@@ -80,7 +80,7 @@ type IssuesConfig struct { | |||
// FromDB fills up a IssuesConfig from serialized format. | |||
func (cfg *IssuesConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
return JSONUnmarshalHandleDoubleEncode(bs, &cfg) | |||
} | |||
// ToDB exports a IssuesConfig to a serialized format. | |||
@@ -104,7 +104,7 @@ type PullRequestsConfig struct { | |||
// FromDB fills up a PullRequestsConfig from serialized format. | |||
func (cfg *PullRequestsConfig) FromDB(bs []byte) error { | |||
return jsonUnmarshalHandleDoubleEncode(bs, &cfg) | |||
return JSONUnmarshalHandleDoubleEncode(bs, &cfg) | |||
} | |||
// ToDB exports a PullRequestsConfig to a serialized format. |
@@ -0,0 +1,219 @@ | |||
// 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 models | |||
import ( | |||
"bufio" | |||
"fmt" | |||
"io" | |||
"os" | |||
"path/filepath" | |||
"strings" | |||
"sync" | |||
"time" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/util" | |||
) | |||
// _____ __ .__ .__ .___ | |||
// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ | |||
// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | | |||
// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | | |||
// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | | |||
// \/ \/ \/ \/ \/ | |||
// ____ __. | |||
// | |/ _|____ ___.__. ______ | |||
// | <_/ __ < | |/ ___/ | |||
// | | \ ___/\___ |\___ \ | |||
// |____|__ \___ > ____/____ > | |||
// \/ \/\/ \/ | |||
// | |||
// This file contains functions for creating authorized_keys files | |||
// | |||
// There is a dependence on the database within RegeneratePublicKeys however most of these functions probably belong in a module | |||
const ( | |||
tplCommentPrefix = `# gitea public key` | |||
tplPublicKey = tplCommentPrefix + "\n" + `command=%s,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s` + "\n" | |||
) | |||
var sshOpLocker sync.Mutex | |||
// AuthorizedStringForKey creates the authorized keys string appropriate for the provided key | |||
func AuthorizedStringForKey(key *PublicKey) string { | |||
sb := &strings.Builder{} | |||
_ = setting.SSH.AuthorizedKeysCommandTemplateTemplate.Execute(sb, map[string]interface{}{ | |||
"AppPath": util.ShellEscape(setting.AppPath), | |||
"AppWorkPath": util.ShellEscape(setting.AppWorkPath), | |||
"CustomConf": util.ShellEscape(setting.CustomConf), | |||
"CustomPath": util.ShellEscape(setting.CustomPath), | |||
"Key": key, | |||
}) | |||
return fmt.Sprintf(tplPublicKey, util.ShellEscape(sb.String()), key.Content) | |||
} | |||
// appendAuthorizedKeysToFile appends new SSH keys' content to authorized_keys file. | |||
func appendAuthorizedKeysToFile(keys ...*PublicKey) error { | |||
// Don't need to rewrite this file if builtin SSH server is enabled. | |||
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { | |||
return nil | |||
} | |||
sshOpLocker.Lock() | |||
defer sshOpLocker.Unlock() | |||
if setting.SSH.RootPath != "" { | |||
// First of ensure that the RootPath is present, and if not make it with 0700 permissions | |||
// This of course doesn't guarantee that this is the right directory for authorized_keys | |||
// but at least if it's supposed to be this directory and it doesn't exist and we're the | |||
// right user it will at least be created properly. | |||
err := os.MkdirAll(setting.SSH.RootPath, 0o700) | |||
if err != nil { | |||
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) | |||
return err | |||
} | |||
} | |||
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") | |||
f, err := os.OpenFile(fPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) | |||
if err != nil { | |||
return err | |||
} | |||
defer f.Close() | |||
// Note: chmod command does not support in Windows. | |||
if !setting.IsWindows { | |||
fi, err := f.Stat() | |||
if err != nil { | |||
return err | |||
} | |||
// .ssh directory should have mode 700, and authorized_keys file should have mode 600. | |||
if fi.Mode().Perm() > 0o600 { | |||
log.Error("authorized_keys file has unusual permission flags: %s - setting to -rw-------", fi.Mode().Perm().String()) | |||
if err = f.Chmod(0o600); err != nil { | |||
return err | |||
} | |||
} | |||
} | |||
for _, key := range keys { | |||
if key.Type == KeyTypePrincipal { | |||
continue | |||
} | |||
if _, err = f.WriteString(key.AuthorizedString()); err != nil { | |||
return err | |||
} | |||
} | |||
return nil | |||
} | |||
// RewriteAllPublicKeys removes any authorized key and rewrite all keys from database again. | |||
// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function | |||
// outside any session scope independently. | |||
func RewriteAllPublicKeys() error { | |||
return rewriteAllPublicKeys(x) | |||
} | |||
func rewriteAllPublicKeys(e Engine) error { | |||
// Don't rewrite key if internal server | |||
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedKeysFile { | |||
return nil | |||
} | |||
sshOpLocker.Lock() | |||
defer sshOpLocker.Unlock() | |||
if setting.SSH.RootPath != "" { | |||
// First of ensure that the RootPath is present, and if not make it with 0700 permissions | |||
// This of course doesn't guarantee that this is the right directory for authorized_keys | |||
// but at least if it's supposed to be this directory and it doesn't exist and we're the | |||
// right user it will at least be created properly. | |||
err := os.MkdirAll(setting.SSH.RootPath, 0o700) | |||
if err != nil { | |||
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) | |||
return err | |||
} | |||
} | |||
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") | |||
tmpPath := fPath + ".tmp" | |||
t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |||
if err != nil { | |||
return err | |||
} | |||
defer func() { | |||
t.Close() | |||
if err := util.Remove(tmpPath); err != nil { | |||
log.Warn("Unable to remove temporary authorized keys file: %s: Error: %v", tmpPath, err) | |||
} | |||
}() | |||
if setting.SSH.AuthorizedKeysBackup { | |||
isExist, err := util.IsExist(fPath) | |||
if err != nil { | |||
log.Error("Unable to check if %s exists. Error: %v", fPath, err) | |||
return err | |||
} | |||
if isExist { | |||
bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) | |||
if err = util.CopyFile(fPath, bakPath); err != nil { | |||
return err | |||
} | |||
} | |||
} | |||
if err := regeneratePublicKeys(e, t); err != nil { | |||
return err | |||
} | |||
t.Close() | |||
return util.Rename(tmpPath, fPath) | |||
} | |||
// RegeneratePublicKeys regenerates the authorized_keys file | |||
func RegeneratePublicKeys(t io.StringWriter) error { | |||
return regeneratePublicKeys(x, t) | |||
} | |||
func regeneratePublicKeys(e Engine, t io.StringWriter) error { | |||
if err := e.Where("type != ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) { | |||
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) | |||
return err | |||
}); err != nil { | |||
return err | |||
} | |||
fPath := filepath.Join(setting.SSH.RootPath, "authorized_keys") | |||
isExist, err := util.IsExist(fPath) | |||
if err != nil { | |||
log.Error("Unable to check if %s exists. Error: %v", fPath, err) | |||
return err | |||
} | |||
if isExist { | |||
f, err := os.Open(fPath) | |||
if err != nil { | |||
return err | |||
} | |||
scanner := bufio.NewScanner(f) | |||
for scanner.Scan() { | |||
line := scanner.Text() | |||
if strings.HasPrefix(line, tplCommentPrefix) { | |||
scanner.Scan() | |||
continue | |||
} | |||
_, err = t.WriteString(line + "\n") | |||
if err != nil { | |||
f.Close() | |||
return err | |||
} | |||
} | |||
f.Close() | |||
} | |||
return nil | |||
} |
@@ -0,0 +1,142 @@ | |||
// 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 models | |||
import ( | |||
"bufio" | |||
"fmt" | |||
"io" | |||
"os" | |||
"path/filepath" | |||
"strings" | |||
"time" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/util" | |||
) | |||
// _____ __ .__ .__ .___ | |||
// / _ \ __ ___/ |_| |__ ___________|__|_______ ____ __| _/ | |||
// / /_\ \| | \ __\ | \ / _ \_ __ \ \___ // __ \ / __ | | |||
// / | \ | /| | | Y ( <_> ) | \/ |/ /\ ___// /_/ | | |||
// \____|__ /____/ |__| |___| /\____/|__| |__/_____ \\___ >____ | | |||
// \/ \/ \/ \/ \/ | |||
// __________ .__ .__ .__ | |||
// \______ _______|__| ____ ____ |_____________ | | ______ | |||
// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/ | |||
// | | | | \| | | \ \___| | |_> / __ \| |__\___ \ | |||
// |____| |__| |__|___| /\___ |__| __(____ |____/____ > | |||
// \/ \/ |__| \/ \/ | |||
// | |||
// This file contains functions for creating authorized_principals files | |||
// | |||
// There is a dependence on the database within RewriteAllPrincipalKeys & RegeneratePrincipalKeys | |||
// The sshOpLocker is used from ssh_key_authorized_keys.go | |||
const authorizedPrincipalsFile = "authorized_principals" | |||
// RewriteAllPrincipalKeys removes any authorized principal and rewrite all keys from database again. | |||
// Note: x.Iterate does not get latest data after insert/delete, so we have to call this function | |||
// outside any session scope independently. | |||
func RewriteAllPrincipalKeys() error { | |||
return rewriteAllPrincipalKeys(x) | |||
} | |||
func rewriteAllPrincipalKeys(e Engine) error { | |||
// Don't rewrite key if internal server | |||
if setting.SSH.StartBuiltinServer || !setting.SSH.CreateAuthorizedPrincipalsFile { | |||
return nil | |||
} | |||
sshOpLocker.Lock() | |||
defer sshOpLocker.Unlock() | |||
if setting.SSH.RootPath != "" { | |||
// First of ensure that the RootPath is present, and if not make it with 0700 permissions | |||
// This of course doesn't guarantee that this is the right directory for authorized_keys | |||
// but at least if it's supposed to be this directory and it doesn't exist and we're the | |||
// right user it will at least be created properly. | |||
err := os.MkdirAll(setting.SSH.RootPath, 0o700) | |||
if err != nil { | |||
log.Error("Unable to MkdirAll(%s): %v", setting.SSH.RootPath, err) | |||
return err | |||
} | |||
} | |||
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile) | |||
tmpPath := fPath + ".tmp" | |||
t, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) | |||
if err != nil { | |||
return err | |||
} | |||
defer func() { | |||
t.Close() | |||
os.Remove(tmpPath) | |||
}() | |||
if setting.SSH.AuthorizedPrincipalsBackup { | |||
isExist, err := util.IsExist(fPath) | |||
if err != nil { | |||
log.Error("Unable to check if %s exists. Error: %v", fPath, err) | |||
return err | |||
} | |||
if isExist { | |||
bakPath := fmt.Sprintf("%s_%d.gitea_bak", fPath, time.Now().Unix()) | |||
if err = util.CopyFile(fPath, bakPath); err != nil { | |||
return err | |||
} | |||
} | |||
} | |||
if err := regeneratePrincipalKeys(e, t); err != nil { | |||
return err | |||
} | |||
t.Close() | |||
return util.Rename(tmpPath, fPath) | |||
} | |||
// RegeneratePrincipalKeys regenerates the authorized_principals file | |||
func RegeneratePrincipalKeys(t io.StringWriter) error { | |||
return regeneratePrincipalKeys(x, t) | |||
} | |||
func regeneratePrincipalKeys(e Engine, t io.StringWriter) error { | |||
if err := e.Where("type = ?", KeyTypePrincipal).Iterate(new(PublicKey), func(idx int, bean interface{}) (err error) { | |||
_, err = t.WriteString((bean.(*PublicKey)).AuthorizedString()) | |||
return err | |||
}); err != nil { | |||
return err | |||
} | |||
fPath := filepath.Join(setting.SSH.RootPath, authorizedPrincipalsFile) | |||
isExist, err := util.IsExist(fPath) | |||
if err != nil { | |||
log.Error("Unable to check if %s exists. Error: %v", fPath, err) | |||
return err | |||
} | |||
if isExist { | |||
f, err := os.Open(fPath) | |||
if err != nil { | |||
return err | |||
} | |||
scanner := bufio.NewScanner(f) | |||
for scanner.Scan() { | |||
line := scanner.Text() | |||
if strings.HasPrefix(line, tplCommentPrefix) { | |||
scanner.Scan() | |||
continue | |||
} | |||
_, err = t.WriteString(line + "\n") | |||
if err != nil { | |||
f.Close() | |||
return err | |||
} | |||
} | |||
f.Close() | |||
} | |||
return nil | |||
} |
@@ -0,0 +1,299 @@ | |||
// 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 models | |||
import ( | |||
"fmt" | |||
"time" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"xorm.io/builder" | |||
"xorm.io/xorm" | |||
) | |||
// ________ .__ ____ __. | |||
// \______ \ ____ ______ | | ____ ___.__.| |/ _|____ ___.__. | |||
// | | \_/ __ \\____ \| | / _ < | || <_/ __ < | | | |||
// | ` \ ___/| |_> > |_( <_> )___ || | \ ___/\___ | | |||
// /_______ /\___ > __/|____/\____// ____||____|__ \___ > ____| | |||
// \/ \/|__| \/ \/ \/\/ | |||
// | |||
// This file contains functions specific to DeployKeys | |||
// DeployKey represents deploy key information and its relation with repository. | |||
type DeployKey struct { | |||
ID int64 `xorm:"pk autoincr"` | |||
KeyID int64 `xorm:"UNIQUE(s) INDEX"` | |||
RepoID int64 `xorm:"UNIQUE(s) INDEX"` | |||
Name string | |||
Fingerprint string | |||
Content string `xorm:"-"` | |||
Mode AccessMode `xorm:"NOT NULL DEFAULT 1"` | |||
CreatedUnix timeutil.TimeStamp `xorm:"created"` | |||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"` | |||
HasRecentActivity bool `xorm:"-"` | |||
HasUsed bool `xorm:"-"` | |||
} | |||
// AfterLoad is invoked from XORM after setting the values of all fields of this object. | |||
func (key *DeployKey) AfterLoad() { | |||
key.HasUsed = key.UpdatedUnix > key.CreatedUnix | |||
key.HasRecentActivity = key.UpdatedUnix.AddDuration(7*24*time.Hour) > timeutil.TimeStampNow() | |||
} | |||
// GetContent gets associated public key content. | |||
func (key *DeployKey) GetContent() error { | |||
pkey, err := GetPublicKeyByID(key.KeyID) | |||
if err != nil { | |||
return err | |||
} | |||
key.Content = pkey.Content | |||
return nil | |||
} | |||
// IsReadOnly checks if the key can only be used for read operations | |||
func (key *DeployKey) IsReadOnly() bool { | |||
return key.Mode == AccessModeRead | |||
} | |||
func checkDeployKey(e Engine, keyID, repoID int64, name string) error { | |||
// Note: We want error detail, not just true or false here. | |||
has, err := e. | |||
Where("key_id = ? AND repo_id = ?", keyID, repoID). | |||
Get(new(DeployKey)) | |||
if err != nil { | |||
return err | |||
} else if has { | |||
return ErrDeployKeyAlreadyExist{keyID, repoID} | |||
} | |||
has, err = e. | |||
Where("repo_id = ? AND name = ?", repoID, name). | |||
Get(new(DeployKey)) | |||
if err != nil { | |||
return err | |||
} else if has { | |||
return ErrDeployKeyNameAlreadyUsed{repoID, name} | |||
} | |||
return nil | |||
} | |||
// addDeployKey adds new key-repo relation. | |||
func addDeployKey(e *xorm.Session, keyID, repoID int64, name, fingerprint string, mode AccessMode) (*DeployKey, error) { | |||
if err := checkDeployKey(e, keyID, repoID, name); err != nil { | |||
return nil, err | |||
} | |||
key := &DeployKey{ | |||
KeyID: keyID, | |||
RepoID: repoID, | |||
Name: name, | |||
Fingerprint: fingerprint, | |||
Mode: mode, | |||
} | |||
_, err := e.Insert(key) | |||
return key, err | |||
} | |||
// HasDeployKey returns true if public key is a deploy key of given repository. | |||
func HasDeployKey(keyID, repoID int64) bool { | |||
has, _ := x. | |||
Where("key_id = ? AND repo_id = ?", keyID, repoID). | |||
Get(new(DeployKey)) | |||
return has | |||
} | |||
// AddDeployKey add new deploy key to database and authorized_keys file. | |||
func AddDeployKey(repoID int64, name, content string, readOnly bool) (*DeployKey, error) { | |||
fingerprint, err := calcFingerprint(content) | |||
if err != nil { | |||
return nil, err | |||
} | |||
accessMode := AccessModeRead | |||
if !readOnly { | |||
accessMode = AccessModeWrite | |||
} | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err = sess.Begin(); err != nil { | |||
return nil, err | |||
} | |||
pkey := &PublicKey{ | |||
Fingerprint: fingerprint, | |||
} | |||
has, err := sess.Get(pkey) | |||
if err != nil { | |||
return nil, err | |||
} | |||
if has { | |||
if pkey.Type != KeyTypeDeploy { | |||
return nil, ErrKeyAlreadyExist{0, fingerprint, ""} | |||
} | |||
} else { | |||
// First time use this deploy key. | |||
pkey.Mode = accessMode | |||
pkey.Type = KeyTypeDeploy | |||
pkey.Content = content | |||
pkey.Name = name | |||
if err = addKey(sess, pkey); err != nil { | |||
return nil, fmt.Errorf("addKey: %v", err) | |||
} | |||
} | |||
key, err := addDeployKey(sess, pkey.ID, repoID, name, pkey.Fingerprint, accessMode) | |||
if err != nil { | |||
return nil, err | |||
} | |||
return key, sess.Commit() | |||
} | |||
// GetDeployKeyByID returns deploy key by given ID. | |||
func GetDeployKeyByID(id int64) (*DeployKey, error) { | |||
return getDeployKeyByID(x, id) | |||
} | |||
func getDeployKeyByID(e Engine, id int64) (*DeployKey, error) { | |||
key := new(DeployKey) | |||
has, err := e.ID(id).Get(key) | |||
if err != nil { | |||
return nil, err | |||
} else if !has { | |||
return nil, ErrDeployKeyNotExist{id, 0, 0} | |||
} | |||
return key, nil | |||
} | |||
// GetDeployKeyByRepo returns deploy key by given public key ID and repository ID. | |||
func GetDeployKeyByRepo(keyID, repoID int64) (*DeployKey, error) { | |||
return getDeployKeyByRepo(x, keyID, repoID) | |||
} | |||
func getDeployKeyByRepo(e Engine, keyID, repoID int64) (*DeployKey, error) { | |||
key := &DeployKey{ | |||
KeyID: keyID, | |||
RepoID: repoID, | |||
} | |||
has, err := e.Get(key) | |||
if err != nil { | |||
return nil, err | |||
} else if !has { | |||
return nil, ErrDeployKeyNotExist{0, keyID, repoID} | |||
} | |||
return key, nil | |||
} | |||
// UpdateDeployKeyCols updates deploy key information in the specified columns. | |||
func UpdateDeployKeyCols(key *DeployKey, cols ...string) error { | |||
_, err := x.ID(key.ID).Cols(cols...).Update(key) | |||
return err | |||
} | |||
// UpdateDeployKey updates deploy key information. | |||
func UpdateDeployKey(key *DeployKey) error { | |||
_, err := x.ID(key.ID).AllCols().Update(key) | |||
return err | |||
} | |||
// DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed. | |||
func DeleteDeployKey(doer *User, id int64) error { | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Begin(); err != nil { | |||
return err | |||
} | |||
if err := deleteDeployKey(sess, doer, id); err != nil { | |||
return err | |||
} | |||
return sess.Commit() | |||
} | |||
func deleteDeployKey(sess Engine, doer *User, id int64) error { | |||
key, err := getDeployKeyByID(sess, id) | |||
if err != nil { | |||
if IsErrDeployKeyNotExist(err) { | |||
return nil | |||
} | |||
return fmt.Errorf("GetDeployKeyByID: %v", err) | |||
} | |||
// Check if user has access to delete this key. | |||
if !doer.IsAdmin { | |||
repo, err := getRepositoryByID(sess, key.RepoID) | |||
if err != nil { | |||
return fmt.Errorf("GetRepositoryByID: %v", err) | |||
} | |||
has, err := isUserRepoAdmin(sess, repo, doer) | |||
if err != nil { | |||
return fmt.Errorf("GetUserRepoPermission: %v", err) | |||
} else if !has { | |||
return ErrKeyAccessDenied{doer.ID, key.ID, "deploy"} | |||
} | |||
} | |||
if _, err = sess.ID(key.ID).Delete(new(DeployKey)); err != nil { | |||
return fmt.Errorf("delete deploy key [%d]: %v", key.ID, err) | |||
} | |||
// Check if this is the last reference to same key content. | |||
has, err := sess. | |||
Where("key_id = ?", key.KeyID). | |||
Get(new(DeployKey)) | |||
if err != nil { | |||
return err | |||
} else if !has { | |||
if err = deletePublicKeys(sess, key.KeyID); err != nil { | |||
return err | |||
} | |||
// after deleted the public keys, should rewrite the public keys file | |||
if err = rewriteAllPublicKeys(sess); err != nil { | |||
return err | |||
} | |||
} | |||
return nil | |||
} | |||
// ListDeployKeys returns all deploy keys by given repository ID. | |||
func ListDeployKeys(repoID int64, listOptions ListOptions) ([]*DeployKey, error) { | |||
return listDeployKeys(x, repoID, listOptions) | |||
} | |||
func listDeployKeys(e Engine, repoID int64, listOptions ListOptions) ([]*DeployKey, error) { | |||
sess := e.Where("repo_id = ?", repoID) | |||
if listOptions.Page != 0 { | |||
sess = listOptions.setSessionPagination(sess) | |||
keys := make([]*DeployKey, 0, listOptions.PageSize) | |||
return keys, sess.Find(&keys) | |||
} | |||
keys := make([]*DeployKey, 0, 5) | |||
return keys, sess.Find(&keys) | |||
} | |||
// SearchDeployKeys returns a list of deploy keys matching the provided arguments. | |||
func SearchDeployKeys(repoID, keyID int64, fingerprint string) ([]*DeployKey, error) { | |||
keys := make([]*DeployKey, 0, 5) | |||
cond := builder.NewCond() | |||
if repoID != 0 { | |||
cond = cond.And(builder.Eq{"repo_id": repoID}) | |||
} | |||
if keyID != 0 { | |||
cond = cond.And(builder.Eq{"key_id": keyID}) | |||
} | |||
if fingerprint != "" { | |||
cond = cond.And(builder.Eq{"fingerprint": fingerprint}) | |||
} | |||
return keys, x.Where(cond).Find(&keys) | |||
} |
@@ -0,0 +1,97 @@ | |||
// 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 models | |||
import ( | |||
"errors" | |||
"fmt" | |||
"strings" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/process" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/util" | |||
"golang.org/x/crypto/ssh" | |||
) | |||
// ___________.__ .__ __ | |||
// \_ _____/|__| ____ ____ ________________________|__| _____/ |_ | |||
// | __) | |/ \ / ___\_/ __ \_ __ \____ \_ __ \ |/ \ __\ | |||
// | \ | | | \/ /_/ > ___/| | \/ |_> > | \/ | | \ | | |||
// \___ / |__|___| /\___ / \___ >__| | __/|__| |__|___| /__| | |||
// \/ \//_____/ \/ |__| \/ | |||
// | |||
// This file contains functions for fingerprinting SSH keys | |||
// | |||
// The database is used in checkKeyFingerprint however most of these functions probably belong in a module | |||
// checkKeyFingerprint only checks if key fingerprint has been used as public key, | |||
// it is OK to use same key as deploy key for multiple repositories/users. | |||
func checkKeyFingerprint(e Engine, fingerprint string) error { | |||
has, err := e.Get(&PublicKey{ | |||
Fingerprint: fingerprint, | |||
}) | |||
if err != nil { | |||
return err | |||
} else if has { | |||
return ErrKeyAlreadyExist{0, fingerprint, ""} | |||
} | |||
return nil | |||
} | |||
func calcFingerprintSSHKeygen(publicKeyContent string) (string, error) { | |||
// Calculate fingerprint. | |||
tmpPath, err := writeTmpKeyFile(publicKeyContent) | |||
if err != nil { | |||
return "", err | |||
} | |||
defer func() { | |||
if err := util.Remove(tmpPath); err != nil { | |||
log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpPath, err) | |||
} | |||
}() | |||
stdout, stderr, err := process.GetManager().Exec("AddPublicKey", "ssh-keygen", "-lf", tmpPath) | |||
if err != nil { | |||
if strings.Contains(stderr, "is not a public key file") { | |||
return "", ErrKeyUnableVerify{stderr} | |||
} | |||
return "", fmt.Errorf("'ssh-keygen -lf %s' failed with error '%s': %s", tmpPath, err, stderr) | |||
} else if len(stdout) < 2 { | |||
return "", errors.New("not enough output for calculating fingerprint: " + stdout) | |||
} | |||
return strings.Split(stdout, " ")[1], nil | |||
} | |||
func calcFingerprintNative(publicKeyContent string) (string, error) { | |||
// Calculate fingerprint. | |||
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeyContent)) | |||
if err != nil { | |||
return "", err | |||
} | |||
return ssh.FingerprintSHA256(pk), nil | |||
} | |||
func calcFingerprint(publicKeyContent string) (string, error) { | |||
// Call the method based on configuration | |||
var ( | |||
fnName, fp string | |||
err error | |||
) | |||
if setting.SSH.StartBuiltinServer { | |||
fnName = "calcFingerprintNative" | |||
fp, err = calcFingerprintNative(publicKeyContent) | |||
} else { | |||
fnName = "calcFingerprintSSHKeygen" | |||
fp, err = calcFingerprintSSHKeygen(publicKeyContent) | |||
} | |||
if err != nil { | |||
if IsErrKeyUnableVerify(err) { | |||
log.Info("%s", publicKeyContent) | |||
return "", err | |||
} | |||
return "", fmt.Errorf("%s: %v", fnName, err) | |||
} | |||
return fp, nil | |||
} |
@@ -0,0 +1,309 @@ | |||
// 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 models | |||
import ( | |||
"crypto/rsa" | |||
"crypto/x509" | |||
"encoding/asn1" | |||
"encoding/base64" | |||
"encoding/binary" | |||
"encoding/pem" | |||
"errors" | |||
"fmt" | |||
"io/ioutil" | |||
"math/big" | |||
"strconv" | |||
"strings" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/process" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/util" | |||
"golang.org/x/crypto/ssh" | |||
) | |||
// ____ __. __________ | |||
// | |/ _|____ ___.__. \______ \_____ _______ ______ ___________ | |||
// | <_/ __ < | | | ___/\__ \\_ __ \/ ___// __ \_ __ \ | |||
// | | \ ___/\___ | | | / __ \| | \/\___ \\ ___/| | \/ | |||
// |____|__ \___ > ____| |____| (____ /__| /____ >\___ >__| | |||
// \/ \/\/ \/ \/ \/ | |||
// | |||
// This file contains functiosn for parsing ssh-keys | |||
// | |||
// TODO: Consider if these functions belong in models - no other models function call them or are called by them | |||
// They may belong in a service or a module | |||
const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----" | |||
func extractTypeFromBase64Key(key string) (string, error) { | |||
b, err := base64.StdEncoding.DecodeString(key) | |||
if err != nil || len(b) < 4 { | |||
return "", fmt.Errorf("invalid key format: %v", err) | |||
} | |||
keyLength := int(binary.BigEndian.Uint32(b)) | |||
if len(b) < 4+keyLength { | |||
return "", fmt.Errorf("invalid key format: not enough length %d", keyLength) | |||
} | |||
return string(b[4 : 4+keyLength]), nil | |||
} | |||
// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253). | |||
func parseKeyString(content string) (string, error) { | |||
// remove whitespace at start and end | |||
content = strings.TrimSpace(content) | |||
var keyType, keyContent, keyComment string | |||
if strings.HasPrefix(content, ssh2keyStart) { | |||
// Parse SSH2 file format. | |||
// Transform all legal line endings to a single "\n". | |||
content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content) | |||
lines := strings.Split(content, "\n") | |||
continuationLine := false | |||
for _, line := range lines { | |||
// Skip lines that: | |||
// 1) are a continuation of the previous line, | |||
// 2) contain ":" as that are comment lines | |||
// 3) contain "-" as that are begin and end tags | |||
if continuationLine || strings.ContainsAny(line, ":-") { | |||
continuationLine = strings.HasSuffix(line, "\\") | |||
} else { | |||
keyContent += line | |||
} | |||
} | |||
t, err := extractTypeFromBase64Key(keyContent) | |||
if err != nil { | |||
return "", fmt.Errorf("extractTypeFromBase64Key: %v", err) | |||
} | |||
keyType = t | |||
} else { | |||
if strings.Contains(content, "-----BEGIN") { | |||
// Convert PEM Keys to OpenSSH format | |||
// Transform all legal line endings to a single "\n". | |||
content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content) | |||
block, _ := pem.Decode([]byte(content)) | |||
if block == nil { | |||
return "", fmt.Errorf("failed to parse PEM block containing the public key") | |||
} | |||
pub, err := x509.ParsePKIXPublicKey(block.Bytes) | |||
if err != nil { | |||
var pk rsa.PublicKey | |||
_, err2 := asn1.Unmarshal(block.Bytes, &pk) | |||
if err2 != nil { | |||
return "", fmt.Errorf("failed to parse DER encoded public key as either PKIX or PEM RSA Key: %v %v", err, err2) | |||
} | |||
pub = &pk | |||
} | |||
sshKey, err := ssh.NewPublicKey(pub) | |||
if err != nil { | |||
return "", fmt.Errorf("unable to convert to ssh public key: %v", err) | |||
} | |||
content = string(ssh.MarshalAuthorizedKey(sshKey)) | |||
} | |||
// Parse OpenSSH format. | |||
// Remove all newlines | |||
content = strings.NewReplacer("\r\n", "", "\n", "").Replace(content) | |||
parts := strings.SplitN(content, " ", 3) | |||
switch len(parts) { | |||
case 0: | |||
return "", errors.New("empty key") | |||
case 1: | |||
keyContent = parts[0] | |||
case 2: | |||
keyType = parts[0] | |||
keyContent = parts[1] | |||
default: | |||
keyType = parts[0] | |||
keyContent = parts[1] | |||
keyComment = parts[2] | |||
} | |||
// If keyType is not given, extract it from content. If given, validate it. | |||
t, err := extractTypeFromBase64Key(keyContent) | |||
if err != nil { | |||
return "", fmt.Errorf("extractTypeFromBase64Key: %v", err) | |||
} | |||
if len(keyType) == 0 { | |||
keyType = t | |||
} else if keyType != t { | |||
return "", fmt.Errorf("key type and content does not match: %s - %s", keyType, t) | |||
} | |||
} | |||
// Finally we need to check whether we can actually read the proposed key: | |||
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(keyType + " " + keyContent + " " + keyComment)) | |||
if err != nil { | |||
return "", fmt.Errorf("invalid ssh public key: %v", err) | |||
} | |||
return keyType + " " + keyContent + " " + keyComment, nil | |||
} | |||
// CheckPublicKeyString checks if the given public key string is recognized by SSH. | |||
// It returns the actual public key line on success. | |||
func CheckPublicKeyString(content string) (_ string, err error) { | |||
if setting.SSH.Disabled { | |||
return "", ErrSSHDisabled{} | |||
} | |||
content, err = parseKeyString(content) | |||
if err != nil { | |||
return "", err | |||
} | |||
content = strings.TrimRight(content, "\n\r") | |||
if strings.ContainsAny(content, "\n\r") { | |||
return "", errors.New("only a single line with a single key please") | |||
} | |||
// remove any unnecessary whitespace now | |||
content = strings.TrimSpace(content) | |||
if !setting.SSH.MinimumKeySizeCheck { | |||
return content, nil | |||
} | |||
var ( | |||
fnName string | |||
keyType string | |||
length int | |||
) | |||
if setting.SSH.StartBuiltinServer { | |||
fnName = "SSHNativeParsePublicKey" | |||
keyType, length, err = SSHNativeParsePublicKey(content) | |||
} else { | |||
fnName = "SSHKeyGenParsePublicKey" | |||
keyType, length, err = SSHKeyGenParsePublicKey(content) | |||
} | |||
if err != nil { | |||
return "", fmt.Errorf("%s: %v", fnName, err) | |||
} | |||
log.Trace("Key info [native: %v]: %s-%d", setting.SSH.StartBuiltinServer, keyType, length) | |||
if minLen, found := setting.SSH.MinimumKeySizes[keyType]; found && length >= minLen { | |||
return content, nil | |||
} else if found && length < minLen { | |||
return "", fmt.Errorf("key length is not enough: got %d, needs %d", length, minLen) | |||
} | |||
return "", fmt.Errorf("key type is not allowed: %s", keyType) | |||
} | |||
// SSHNativeParsePublicKey extracts the key type and length using the golang SSH library. | |||
func SSHNativeParsePublicKey(keyLine string) (string, int, error) { | |||
fields := strings.Fields(keyLine) | |||
if len(fields) < 2 { | |||
return "", 0, fmt.Errorf("not enough fields in public key line: %s", keyLine) | |||
} | |||
raw, err := base64.StdEncoding.DecodeString(fields[1]) | |||
if err != nil { | |||
return "", 0, err | |||
} | |||
pkey, err := ssh.ParsePublicKey(raw) | |||
if err != nil { | |||
if strings.Contains(err.Error(), "ssh: unknown key algorithm") { | |||
return "", 0, ErrKeyUnableVerify{err.Error()} | |||
} | |||
return "", 0, fmt.Errorf("ParsePublicKey: %v", err) | |||
} | |||
// The ssh library can parse the key, so next we find out what key exactly we have. | |||
switch pkey.Type() { | |||
case ssh.KeyAlgoDSA: | |||
rawPub := struct { | |||
Name string | |||
P, Q, G, Y *big.Int | |||
}{} | |||
if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { | |||
return "", 0, err | |||
} | |||
// as per https://bugzilla.mindrot.org/show_bug.cgi?id=1647 we should never | |||
// see dsa keys != 1024 bit, but as it seems to work, we will not check here | |||
return "dsa", rawPub.P.BitLen(), nil // use P as per crypto/dsa/dsa.go (is L) | |||
case ssh.KeyAlgoRSA: | |||
rawPub := struct { | |||
Name string | |||
E *big.Int | |||
N *big.Int | |||
}{} | |||
if err := ssh.Unmarshal(pkey.Marshal(), &rawPub); err != nil { | |||
return "", 0, err | |||
} | |||
return "rsa", rawPub.N.BitLen(), nil // use N as per crypto/rsa/rsa.go (is bits) | |||
case ssh.KeyAlgoECDSA256: | |||
return "ecdsa", 256, nil | |||
case ssh.KeyAlgoECDSA384: | |||
return "ecdsa", 384, nil | |||
case ssh.KeyAlgoECDSA521: | |||
return "ecdsa", 521, nil | |||
case ssh.KeyAlgoED25519: | |||
return "ed25519", 256, nil | |||
case ssh.KeyAlgoSKECDSA256: | |||
return "ecdsa-sk", 256, nil | |||
case ssh.KeyAlgoSKED25519: | |||
return "ed25519-sk", 256, nil | |||
} | |||
return "", 0, fmt.Errorf("unsupported key length detection for type: %s", pkey.Type()) | |||
} | |||
// writeTmpKeyFile writes key content to a temporary file | |||
// and returns the name of that file, along with any possible errors. | |||
func writeTmpKeyFile(content string) (string, error) { | |||
tmpFile, err := ioutil.TempFile(setting.SSH.KeyTestPath, "gitea_keytest") | |||
if err != nil { | |||
return "", fmt.Errorf("TempFile: %v", err) | |||
} | |||
defer tmpFile.Close() | |||
if _, err = tmpFile.WriteString(content); err != nil { | |||
return "", fmt.Errorf("WriteString: %v", err) | |||
} | |||
return tmpFile.Name(), nil | |||
} | |||
// SSHKeyGenParsePublicKey extracts key type and length using ssh-keygen. | |||
func SSHKeyGenParsePublicKey(key string) (string, int, error) { | |||
tmpName, err := writeTmpKeyFile(key) | |||
if err != nil { | |||
return "", 0, fmt.Errorf("writeTmpKeyFile: %v", err) | |||
} | |||
defer func() { | |||
if err := util.Remove(tmpName); err != nil { | |||
log.Warn("Unable to remove temporary key file: %s: Error: %v", tmpName, err) | |||
} | |||
}() | |||
stdout, stderr, err := process.GetManager().Exec("SSHKeyGenParsePublicKey", setting.SSH.KeygenPath, "-lf", tmpName) | |||
if err != nil { | |||
return "", 0, fmt.Errorf("fail to parse public key: %s - %s", err, stderr) | |||
} | |||
if strings.Contains(stdout, "is not a public key file") { | |||
return "", 0, ErrKeyUnableVerify{stdout} | |||
} | |||
fields := strings.Split(stdout, " ") | |||
if len(fields) < 4 { | |||
return "", 0, fmt.Errorf("invalid public key line: %s", stdout) | |||
} | |||
keyType := strings.Trim(fields[len(fields)-1], "()\r\n") | |||
length, err := strconv.ParseInt(fields[0], 10, 32) | |||
if err != nil { | |||
return "", 0, err | |||
} | |||
return strings.ToLower(keyType), int(length), nil | |||
} |
@@ -0,0 +1,125 @@ | |||
// 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 models | |||
import ( | |||
"errors" | |||
"fmt" | |||
"strings" | |||
"code.gitea.io/gitea/modules/setting" | |||
) | |||
// __________ .__ .__ .__ | |||
// \______ _______|__| ____ ____ |_____________ | | ______ | |||
// | ___\_ __ | |/ \_/ ___\| \____ \__ \ | | / ___/ | |||
// | | | | \| | | \ \___| | |_> / __ \| |__\___ \ | |||
// |____| |__| |__|___| /\___ |__| __(____ |____/____ > | |||
// \/ \/ |__| \/ \/ | |||
// | |||
// This file contains functions related to principals | |||
// AddPrincipalKey adds new principal to database and authorized_principals file. | |||
func AddPrincipalKey(ownerID int64, content string, loginSourceID int64) (*PublicKey, error) { | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Begin(); err != nil { | |||
return nil, err | |||
} | |||
// Principals cannot be duplicated. | |||
has, err := sess. | |||
Where("content = ? AND type = ?", content, KeyTypePrincipal). | |||
Get(new(PublicKey)) | |||
if err != nil { | |||
return nil, err | |||
} else if has { | |||
return nil, ErrKeyAlreadyExist{0, "", content} | |||
} | |||
key := &PublicKey{ | |||
OwnerID: ownerID, | |||
Name: content, | |||
Content: content, | |||
Mode: AccessModeWrite, | |||
Type: KeyTypePrincipal, | |||
LoginSourceID: loginSourceID, | |||
} | |||
if err = addPrincipalKey(sess, key); err != nil { | |||
return nil, fmt.Errorf("addKey: %v", err) | |||
} | |||
if err = sess.Commit(); err != nil { | |||
return nil, err | |||
} | |||
sess.Close() | |||
return key, RewriteAllPrincipalKeys() | |||
} | |||
func addPrincipalKey(e Engine, key *PublicKey) (err error) { | |||
// Save Key representing a principal. | |||
if _, err = e.Insert(key); err != nil { | |||
return err | |||
} | |||
return nil | |||
} | |||
// CheckPrincipalKeyString strips spaces and returns an error if the given principal contains newlines | |||
func CheckPrincipalKeyString(user *User, content string) (_ string, err error) { | |||
if setting.SSH.Disabled { | |||
return "", ErrSSHDisabled{} | |||
} | |||
content = strings.TrimSpace(content) | |||
if strings.ContainsAny(content, "\r\n") { | |||
return "", errors.New("only a single line with a single principal please") | |||
} | |||
// check all the allowed principals, email, username or anything | |||
// if any matches, return ok | |||
for _, v := range setting.SSH.AuthorizedPrincipalsAllow { | |||
switch v { | |||
case "anything": | |||
return content, nil | |||
case "email": | |||
emails, err := GetEmailAddresses(user.ID) | |||
if err != nil { | |||
return "", err | |||
} | |||
for _, email := range emails { | |||
if !email.IsActivated { | |||
continue | |||
} | |||
if content == email.Email { | |||
return content, nil | |||
} | |||
} | |||
case "username": | |||
if content == user.Name { | |||
return content, nil | |||
} | |||
} | |||
} | |||
return "", fmt.Errorf("didn't match allowed principals: %s", setting.SSH.AuthorizedPrincipalsAllow) | |||
} | |||
// ListPrincipalKeys returns a list of principals belongs to given user. | |||
func ListPrincipalKeys(uid int64, listOptions ListOptions) ([]*PublicKey, error) { | |||
sess := x.Where("owner_id = ? AND type = ?", uid, KeyTypePrincipal) | |||
if listOptions.Page != 0 { | |||
sess = listOptions.setSessionPagination(sess) | |||
keys := make([]*PublicKey, 0, listOptions.PageSize) | |||
return keys, sess.Find(&keys) | |||
} | |||
keys := make([]*PublicKey, 0, 5) | |||
return keys, sess.Find(&keys) | |||
} |
@@ -0,0 +1,16 @@ | |||
// 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 models | |||
import "github.com/lafriks/xormstore" | |||
// CreateStore creates a xormstore for the provided table and key | |||
func CreateStore(table, key string) (*xormstore.Store, error) { | |||
store, err := xormstore.NewOptions(x, xormstore.Options{ | |||
TableName: table, | |||
}, []byte(key)) | |||
return store, err | |||
} |
@@ -34,7 +34,6 @@ import ( | |||
"golang.org/x/crypto/bcrypt" | |||
"golang.org/x/crypto/pbkdf2" | |||
"golang.org/x/crypto/scrypt" | |||
"golang.org/x/crypto/ssh" | |||
"xorm.io/builder" | |||
) | |||
@@ -1484,6 +1483,13 @@ func GetUserIDsByNames(names []string, ignoreNonExistent bool) ([]int64, error) | |||
return ids, nil | |||
} | |||
// GetUsersBySource returns a list of Users for a login source | |||
func GetUsersBySource(s *LoginSource) ([]*User, error) { | |||
var users []*User | |||
err := x.Where("login_type = ? AND login_source = ?", s.Type, s.ID).Find(&users) | |||
return users, err | |||
} | |||
// UserCommit represents a commit with validation of user. | |||
type UserCommit struct { | |||
User *User | |||
@@ -1724,339 +1730,6 @@ func GetWatchedRepos(userID int64, private bool, listOptions ListOptions) ([]*Re | |||
return repos, sess.Find(&repos) | |||
} | |||
// deleteKeysMarkedForDeletion returns true if ssh keys needs update | |||
func deleteKeysMarkedForDeletion(keys []string) (bool, error) { | |||
// Start session | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Begin(); err != nil { | |||
return false, err | |||
} | |||
// Delete keys marked for deletion | |||
var sshKeysNeedUpdate bool | |||
for _, KeyToDelete := range keys { | |||
key, err := searchPublicKeyByContentWithEngine(sess, KeyToDelete) | |||
if err != nil { | |||
log.Error("SearchPublicKeyByContent: %v", err) | |||
continue | |||
} | |||
if err = deletePublicKeys(sess, key.ID); err != nil { | |||
log.Error("deletePublicKeys: %v", err) | |||
continue | |||
} | |||
sshKeysNeedUpdate = true | |||
} | |||
if err := sess.Commit(); err != nil { | |||
return false, err | |||
} | |||
return sshKeysNeedUpdate, nil | |||
} | |||
// addLdapSSHPublicKeys add a users public keys. Returns true if there are changes. | |||
func addLdapSSHPublicKeys(usr *User, s *LoginSource, sshPublicKeys []string) bool { | |||
var sshKeysNeedUpdate bool | |||
for _, sshKey := range sshPublicKeys { | |||
var err error | |||
found := false | |||
keys := []byte(sshKey) | |||
loop: | |||
for len(keys) > 0 && err == nil { | |||
var out ssh.PublicKey | |||
// We ignore options as they are not relevant to Gitea | |||
out, _, _, keys, err = ssh.ParseAuthorizedKey(keys) | |||
if err != nil { | |||
break loop | |||
} | |||
found = true | |||
marshalled := string(ssh.MarshalAuthorizedKey(out)) | |||
marshalled = marshalled[:len(marshalled)-1] | |||
sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) | |||
if _, err := AddPublicKey(usr.ID, sshKeyName, marshalled, s.ID); err != nil { | |||
if IsErrKeyAlreadyExist(err) { | |||
log.Trace("addLdapSSHPublicKeys[%s]: LDAP Public SSH Key %s already exists for user", sshKeyName, usr.Name) | |||
} else { | |||
log.Error("addLdapSSHPublicKeys[%s]: Error adding LDAP Public SSH Key for user %s: %v", sshKeyName, usr.Name, err) | |||
} | |||
} else { | |||
log.Trace("addLdapSSHPublicKeys[%s]: Added LDAP Public SSH Key for user %s", sshKeyName, usr.Name) | |||
sshKeysNeedUpdate = true | |||
} | |||
} | |||
if !found && err != nil { | |||
log.Warn("addLdapSSHPublicKeys[%s]: Skipping invalid LDAP Public SSH Key for user %s: %v", s.Name, usr.Name, sshKey) | |||
} | |||
} | |||
return sshKeysNeedUpdate | |||
} | |||
// synchronizeLdapSSHPublicKeys updates a users public keys. Returns true if there are changes. | |||
func synchronizeLdapSSHPublicKeys(usr *User, s *LoginSource, sshPublicKeys []string) bool { | |||
var sshKeysNeedUpdate bool | |||
log.Trace("synchronizeLdapSSHPublicKeys[%s]: Handling LDAP Public SSH Key synchronization for user %s", s.Name, usr.Name) | |||
// Get Public Keys from DB with current LDAP source | |||
var giteaKeys []string | |||
keys, err := ListPublicLdapSSHKeys(usr.ID, s.ID) | |||
if err != nil { | |||
log.Error("synchronizeLdapSSHPublicKeys[%s]: Error listing LDAP Public SSH Keys for user %s: %v", s.Name, usr.Name, err) | |||
} | |||
for _, v := range keys { | |||
giteaKeys = append(giteaKeys, v.OmitEmail()) | |||
} | |||
// Get Public Keys from LDAP and skip duplicate keys | |||
var ldapKeys []string | |||
for _, v := range sshPublicKeys { | |||
sshKeySplit := strings.Split(v, " ") | |||
if len(sshKeySplit) > 1 { | |||
ldapKey := strings.Join(sshKeySplit[:2], " ") | |||
if !util.ExistsInSlice(ldapKey, ldapKeys) { | |||
ldapKeys = append(ldapKeys, ldapKey) | |||
} | |||
} | |||
} | |||
// Check if Public Key sync is needed | |||
if util.IsEqualSlice(giteaKeys, ldapKeys) { | |||
log.Trace("synchronizeLdapSSHPublicKeys[%s]: LDAP Public Keys are already in sync for %s (LDAP:%v/DB:%v)", s.Name, usr.Name, len(ldapKeys), len(giteaKeys)) | |||
return false | |||
} | |||
log.Trace("synchronizeLdapSSHPublicKeys[%s]: LDAP Public Key needs update for user %s (LDAP:%v/DB:%v)", s.Name, usr.Name, len(ldapKeys), len(giteaKeys)) | |||
// Add LDAP Public SSH Keys that doesn't already exist in DB | |||
var newLdapSSHKeys []string | |||
for _, LDAPPublicSSHKey := range ldapKeys { | |||
if !util.ExistsInSlice(LDAPPublicSSHKey, giteaKeys) { | |||
newLdapSSHKeys = append(newLdapSSHKeys, LDAPPublicSSHKey) | |||
} | |||
} | |||
if addLdapSSHPublicKeys(usr, s, newLdapSSHKeys) { | |||
sshKeysNeedUpdate = true | |||
} | |||
// Mark LDAP keys from DB that doesn't exist in LDAP for deletion | |||
var giteaKeysToDelete []string | |||
for _, giteaKey := range giteaKeys { | |||
if !util.ExistsInSlice(giteaKey, ldapKeys) { | |||
log.Trace("synchronizeLdapSSHPublicKeys[%s]: Marking LDAP Public SSH Key for deletion for user %s: %v", s.Name, usr.Name, giteaKey) | |||
giteaKeysToDelete = append(giteaKeysToDelete, giteaKey) | |||
} | |||
} | |||
// Delete LDAP keys from DB that doesn't exist in LDAP | |||
needUpd, err := deleteKeysMarkedForDeletion(giteaKeysToDelete) | |||
if err != nil { | |||
log.Error("synchronizeLdapSSHPublicKeys[%s]: Error deleting LDAP Public SSH Keys marked for deletion for user %s: %v", s.Name, usr.Name, err) | |||
} | |||
if needUpd { | |||
sshKeysNeedUpdate = true | |||
} | |||
return sshKeysNeedUpdate | |||
} | |||
// SyncExternalUsers is used to synchronize users with external authorization source | |||
func SyncExternalUsers(ctx context.Context, updateExisting bool) error { | |||
log.Trace("Doing: SyncExternalUsers") | |||
ls, err := LoginSources() | |||
if err != nil { | |||
log.Error("SyncExternalUsers: %v", err) | |||
return err | |||
} | |||
for _, s := range ls { | |||
if !s.IsActived || !s.IsSyncEnabled { | |||
continue | |||
} | |||
select { | |||
case <-ctx.Done(): | |||
log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name) | |||
return ErrCancelledf("Before update of %s", s.Name) | |||
default: | |||
} | |||
if s.IsLDAP() { | |||
log.Trace("Doing: SyncExternalUsers[%s]", s.Name) | |||
var existingUsers []int64 | |||
isAttributeSSHPublicKeySet := len(strings.TrimSpace(s.LDAP().AttributeSSHPublicKey)) > 0 | |||
var sshKeysNeedUpdate bool | |||
// Find all users with this login type | |||
var users []*User | |||
err = x.Where("login_type = ?", LoginLDAP). | |||
And("login_source = ?", s.ID). | |||
Find(&users) | |||
if err != nil { | |||
log.Error("SyncExternalUsers: %v", err) | |||
return err | |||
} | |||
select { | |||
case <-ctx.Done(): | |||
log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name) | |||
return ErrCancelledf("Before update of %s", s.Name) | |||
default: | |||
} | |||
sr, err := s.LDAP().SearchEntries() | |||
if err != nil { | |||
log.Error("SyncExternalUsers LDAP source failure [%s], skipped", s.Name) | |||
continue | |||
} | |||
if len(sr) == 0 { | |||
if !s.LDAP().AllowDeactivateAll { | |||
log.Error("LDAP search found no entries but did not report an error. Refusing to deactivate all users") | |||
continue | |||
} else { | |||
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", s.Name) | |||
// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed | |||
if sshKeysNeedUpdate { | |||
err = RewriteAllPublicKeys() | |||
if err != nil { | |||
log.Error("RewriteAllPublicKeys: %v", err) | |||
} | |||
} | |||
return ErrCancelledf("During update of %s before completed update of users", s.Name) | |||
default: | |||
} | |||
if len(su.Username) == 0 { | |||
continue | |||
} | |||
if len(su.Mail) == 0 { | |||
su.Mail = fmt.Sprintf("%s@localhost", su.Username) | |||
} | |||
var usr *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", s.Name, su.Username) | |||
usr = &User{ | |||
LowerName: strings.ToLower(su.Username), | |||
Name: su.Username, | |||
FullName: fullName, | |||
LoginType: s.Type, | |||
LoginSource: s.ID, | |||
LoginName: su.Username, | |||
Email: su.Mail, | |||
IsAdmin: su.IsAdmin, | |||
IsRestricted: su.IsRestricted, | |||
IsActive: true, | |||
} | |||
err = CreateUser(usr) | |||
if err != nil { | |||
log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", s.Name, su.Username, err) | |||
} else if isAttributeSSHPublicKeySet { | |||
log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", s.Name, usr.Name) | |||
if addLdapSSHPublicKeys(usr, s, su.SSHPublicKey) { | |||
sshKeysNeedUpdate = true | |||
} | |||
} | |||
} else if updateExisting { | |||
existingUsers = append(existingUsers, usr.ID) | |||
// Synchronize SSH Public Key if that attribute is set | |||
if isAttributeSSHPublicKeySet && synchronizeLdapSSHPublicKeys(usr, s, su.SSHPublicKey) { | |||
sshKeysNeedUpdate = true | |||
} | |||
// Check if user data has changed | |||
if (len(s.LDAP().AdminFilter) > 0 && usr.IsAdmin != su.IsAdmin) || | |||
(len(s.LDAP().RestrictedFilter) > 0 && usr.IsRestricted != su.IsRestricted) || | |||
!strings.EqualFold(usr.Email, su.Mail) || | |||
usr.FullName != fullName || | |||
!usr.IsActive { | |||
log.Trace("SyncExternalUsers[%s]: Updating user %s", s.Name, usr.Name) | |||
usr.FullName = fullName | |||
usr.Email = su.Mail | |||
// Change existing admin flag only if AdminFilter option is set | |||
if len(s.LDAP().AdminFilter) > 0 { | |||
usr.IsAdmin = su.IsAdmin | |||
} | |||
// Change existing restricted flag only if RestrictedFilter option is set | |||
if !usr.IsAdmin && len(s.LDAP().RestrictedFilter) > 0 { | |||
usr.IsRestricted = su.IsRestricted | |||
} | |||
usr.IsActive = true | |||
err = UpdateUserCols(usr, "full_name", "email", "is_admin", "is_restricted", "is_active") | |||
if err != nil { | |||
log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", s.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 = RewriteAllPublicKeys() | |||
if err != nil { | |||
log.Error("RewriteAllPublicKeys: %v", err) | |||
} | |||
} | |||
select { | |||
case <-ctx.Done(): | |||
log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", s.Name) | |||
return ErrCancelledf("During update of %s before delete users", s.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", s.Name, usr.Name) | |||
usr.IsActive = false | |||
err = UpdateUserCols(usr, "is_active") | |||
if err != nil { | |||
log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", s.Name, usr.Name, err) | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
return nil | |||
} | |||
// IterateUser iterate users | |||
func IterateUser(f func(user *User) error) error { | |||
var start int |
@@ -453,8 +453,8 @@ ssh-dss AAAAB3NzaC1kc3MAAACBAOChCC7lf6Uo9n7BmZ6M8St19PZf4Tn59NriyboW2x/DZuYAz3ib | |||
for i, kase := range testCases { | |||
s.ID = int64(i) + 20 | |||
addLdapSSHPublicKeys(user, s, []string{kase.keyString}) | |||
keys, err := ListPublicLdapSSHKeys(user.ID, s.ID) | |||
AddPublicKeysBySource(user, s, []string{kase.keyString}) | |||
keys, err := ListPublicKeysBySource(user.ID, s.ID) | |||
assert.NoError(t, err) | |||
if err != nil { | |||
continue |
@@ -218,7 +218,7 @@ func (ctx *APIContext) CheckForOTP() { | |||
} | |||
// APIAuth converts auth.Auth as a middleware | |||
func APIAuth(authMethod auth.Auth) func(*APIContext) { | |||
func APIAuth(authMethod auth.Method) func(*APIContext) { | |||
return func(ctx *APIContext) { | |||
// Get user from session if logged in. | |||
ctx.User = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) |
@@ -627,7 +627,7 @@ func getCsrfOpts() CsrfOptions { | |||
} | |||
// Auth converts auth.Auth as a middleware | |||
func Auth(authMethod auth.Auth) func(*Context) { | |||
func Auth(authMethod auth.Method) func(*Context) { | |||
return func(ctx *Context) { | |||
ctx.User = authMethod.Verify(ctx.Req, ctx.Resp, ctx, ctx.Session) | |||
if ctx.User != nil { |
@@ -12,6 +12,7 @@ import ( | |||
"code.gitea.io/gitea/modules/migrations" | |||
repository_service "code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/services/auth" | |||
mirror_service "code.gitea.io/gitea/services/mirror" | |||
) | |||
@@ -80,7 +81,7 @@ func registerSyncExternalUsers() { | |||
UpdateExisting: true, | |||
}, func(ctx context.Context, _ *models.User, config Config) error { | |||
realConfig := config.(*UpdateExistingConfig) | |||
return models.SyncExternalUsers(ctx, realConfig.UpdateExisting) | |||
return auth.SyncExternalUsers(ctx, realConfig.UpdateExisting) | |||
}) | |||
} | |||
@@ -35,6 +35,7 @@ import ( | |||
web_routers "code.gitea.io/gitea/routers/web" | |||
"code.gitea.io/gitea/services/archiver" | |||
"code.gitea.io/gitea/services/auth" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
"code.gitea.io/gitea/services/mailer" | |||
mirror_service "code.gitea.io/gitea/services/mirror" | |||
pull_service "code.gitea.io/gitea/services/pull" | |||
@@ -100,7 +101,7 @@ func GlobalInit(ctx context.Context) { | |||
log.Fatal("ORM engine initialization failed: %v", err) | |||
} | |||
if err := models.InitOAuth2(); err != nil { | |||
if err := oauth2.Init(); err != nil { | |||
log.Fatal("Failed to initialize OAuth2 support: %v", err) | |||
} | |||
@@ -11,8 +11,6 @@ import ( | |||
"regexp" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/ldap" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/auth/pam" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
@@ -20,6 +18,11 @@ import ( | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/util" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/services/auth/source/ldap" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
pamService "code.gitea.io/gitea/services/auth/source/pam" | |||
"code.gitea.io/gitea/services/auth/source/smtp" | |||
"code.gitea.io/gitea/services/auth/source/sspi" | |||
"code.gitea.io/gitea/services/forms" | |||
"xorm.io/xorm/convert" | |||
@@ -74,9 +77,9 @@ var ( | |||
}() | |||
securityProtocols = []dropdownItem{ | |||
{models.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted}, | |||
{models.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, | |||
{models.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, | |||
{ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted}, | |||
{ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS}, | |||
{ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS}, | |||
} | |||
) | |||
@@ -88,15 +91,15 @@ func NewAuthSource(ctx *context.Context) { | |||
ctx.Data["type"] = models.LoginLDAP | |||
ctx.Data["CurrentTypeName"] = models.LoginNames[models.LoginLDAP] | |||
ctx.Data["CurrentSecurityProtocol"] = models.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted] | |||
ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted] | |||
ctx.Data["smtp_auth"] = "PLAIN" | |||
ctx.Data["is_active"] = true | |||
ctx.Data["is_sync_enabled"] = true | |||
ctx.Data["AuthSources"] = authSources | |||
ctx.Data["SecurityProtocols"] = securityProtocols | |||
ctx.Data["SMTPAuths"] = models.SMTPAuths | |||
ctx.Data["OAuth2Providers"] = models.OAuth2Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings | |||
ctx.Data["SMTPAuths"] = smtp.Authenticators | |||
ctx.Data["OAuth2Providers"] = oauth2.Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = oauth2.DefaultCustomURLMappings | |||
ctx.Data["SSPIAutoCreateUsers"] = true | |||
ctx.Data["SSPIAutoActivateUsers"] = true | |||
@@ -105,7 +108,7 @@ func NewAuthSource(ctx *context.Context) { | |||
ctx.Data["SSPIDefaultLanguage"] = "" | |||
// only the first as default | |||
for key := range models.OAuth2Providers { | |||
for key := range oauth2.Providers { | |||
ctx.Data["oauth2_provider"] = key | |||
break | |||
} | |||
@@ -113,45 +116,43 @@ func NewAuthSource(ctx *context.Context) { | |||
ctx.HTML(http.StatusOK, tplAuthNew) | |||
} | |||
func parseLDAPConfig(form forms.AuthenticationForm) *models.LDAPConfig { | |||
func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source { | |||
var pageSize uint32 | |||
if form.UsePagedSearch { | |||
pageSize = uint32(form.SearchPageSize) | |||
} | |||
return &models.LDAPConfig{ | |||
Source: &ldap.Source{ | |||
Name: form.Name, | |||
Host: form.Host, | |||
Port: form.Port, | |||
SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol), | |||
SkipVerify: form.SkipVerify, | |||
BindDN: form.BindDN, | |||
UserDN: form.UserDN, | |||
BindPassword: form.BindPassword, | |||
UserBase: form.UserBase, | |||
AttributeUsername: form.AttributeUsername, | |||
AttributeName: form.AttributeName, | |||
AttributeSurname: form.AttributeSurname, | |||
AttributeMail: form.AttributeMail, | |||
AttributesInBind: form.AttributesInBind, | |||
AttributeSSHPublicKey: form.AttributeSSHPublicKey, | |||
SearchPageSize: pageSize, | |||
Filter: form.Filter, | |||
GroupsEnabled: form.GroupsEnabled, | |||
GroupDN: form.GroupDN, | |||
GroupFilter: form.GroupFilter, | |||
GroupMemberUID: form.GroupMemberUID, | |||
UserUID: form.UserUID, | |||
AdminFilter: form.AdminFilter, | |||
RestrictedFilter: form.RestrictedFilter, | |||
AllowDeactivateAll: form.AllowDeactivateAll, | |||
Enabled: true, | |||
}, | |||
return &ldap.Source{ | |||
Name: form.Name, | |||
Host: form.Host, | |||
Port: form.Port, | |||
SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol), | |||
SkipVerify: form.SkipVerify, | |||
BindDN: form.BindDN, | |||
UserDN: form.UserDN, | |||
BindPassword: form.BindPassword, | |||
UserBase: form.UserBase, | |||
AttributeUsername: form.AttributeUsername, | |||
AttributeName: form.AttributeName, | |||
AttributeSurname: form.AttributeSurname, | |||
AttributeMail: form.AttributeMail, | |||
AttributesInBind: form.AttributesInBind, | |||
AttributeSSHPublicKey: form.AttributeSSHPublicKey, | |||
SearchPageSize: pageSize, | |||
Filter: form.Filter, | |||
GroupsEnabled: form.GroupsEnabled, | |||
GroupDN: form.GroupDN, | |||
GroupFilter: form.GroupFilter, | |||
GroupMemberUID: form.GroupMemberUID, | |||
UserUID: form.UserUID, | |||
AdminFilter: form.AdminFilter, | |||
RestrictedFilter: form.RestrictedFilter, | |||
AllowDeactivateAll: form.AllowDeactivateAll, | |||
Enabled: true, | |||
} | |||
} | |||
func parseSMTPConfig(form forms.AuthenticationForm) *models.SMTPConfig { | |||
return &models.SMTPConfig{ | |||
func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source { | |||
return &smtp.Source{ | |||
Auth: form.SMTPAuth, | |||
Host: form.SMTPHost, | |||
Port: form.SMTPPort, | |||
@@ -161,7 +162,7 @@ func parseSMTPConfig(form forms.AuthenticationForm) *models.SMTPConfig { | |||
} | |||
} | |||
func parseOAuth2Config(form forms.AuthenticationForm) *models.OAuth2Config { | |||
func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { | |||
var customURLMapping *oauth2.CustomURLMapping | |||
if form.Oauth2UseCustomURL { | |||
customURLMapping = &oauth2.CustomURLMapping{ | |||
@@ -173,7 +174,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *models.OAuth2Config { | |||
} else { | |||
customURLMapping = nil | |||
} | |||
return &models.OAuth2Config{ | |||
return &oauth2.Source{ | |||
Provider: form.Oauth2Provider, | |||
ClientID: form.Oauth2Key, | |||
ClientSecret: form.Oauth2Secret, | |||
@@ -183,7 +184,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *models.OAuth2Config { | |||
} | |||
} | |||
func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*models.SSPIConfig, error) { | |||
func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*sspi.Source, error) { | |||
if util.IsEmptyString(form.SSPISeparatorReplacement) { | |||
ctx.Data["Err_SSPISeparatorReplacement"] = true | |||
return nil, errors.New(ctx.Tr("form.SSPISeparatorReplacement") + ctx.Tr("form.require_error")) | |||
@@ -198,7 +199,7 @@ func parseSSPIConfig(ctx *context.Context, form forms.AuthenticationForm) (*mode | |||
return nil, errors.New(ctx.Tr("form.lang_select_error")) | |||
} | |||
return &models.SSPIConfig{ | |||
return &sspi.Source{ | |||
AutoCreateUsers: form.SSPIAutoCreateUsers, | |||
AutoActivateUsers: form.SSPIAutoActivateUsers, | |||
StripDomainNames: form.SSPIStripDomainNames, | |||
@@ -215,12 +216,12 @@ func NewAuthSourcePost(ctx *context.Context) { | |||
ctx.Data["PageIsAdminAuthentications"] = true | |||
ctx.Data["CurrentTypeName"] = models.LoginNames[models.LoginType(form.Type)] | |||
ctx.Data["CurrentSecurityProtocol"] = models.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)] | |||
ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)] | |||
ctx.Data["AuthSources"] = authSources | |||
ctx.Data["SecurityProtocols"] = securityProtocols | |||
ctx.Data["SMTPAuths"] = models.SMTPAuths | |||
ctx.Data["OAuth2Providers"] = models.OAuth2Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings | |||
ctx.Data["SMTPAuths"] = smtp.Authenticators | |||
ctx.Data["OAuth2Providers"] = oauth2.Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = oauth2.DefaultCustomURLMappings | |||
ctx.Data["SSPIAutoCreateUsers"] = true | |||
ctx.Data["SSPIAutoActivateUsers"] = true | |||
@@ -238,7 +239,7 @@ func NewAuthSourcePost(ctx *context.Context) { | |||
config = parseSMTPConfig(form) | |||
hasTLS = true | |||
case models.LoginPAM: | |||
config = &models.PAMConfig{ | |||
config = &pamService.Source{ | |||
ServiceName: form.PAMServiceName, | |||
EmailDomain: form.PAMEmailDomain, | |||
} | |||
@@ -271,7 +272,7 @@ func NewAuthSourcePost(ctx *context.Context) { | |||
if err := models.CreateLoginSource(&models.LoginSource{ | |||
Type: models.LoginType(form.Type), | |||
Name: form.Name, | |||
IsActived: form.IsActive, | |||
IsActive: form.IsActive, | |||
IsSyncEnabled: form.IsSyncEnabled, | |||
Cfg: config, | |||
}); err != nil { | |||
@@ -297,9 +298,9 @@ func EditAuthSource(ctx *context.Context) { | |||
ctx.Data["PageIsAdminAuthentications"] = true | |||
ctx.Data["SecurityProtocols"] = securityProtocols | |||
ctx.Data["SMTPAuths"] = models.SMTPAuths | |||
ctx.Data["OAuth2Providers"] = models.OAuth2Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings | |||
ctx.Data["SMTPAuths"] = smtp.Authenticators | |||
ctx.Data["OAuth2Providers"] = oauth2.Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = oauth2.DefaultCustomURLMappings | |||
source, err := models.GetLoginSourceByID(ctx.ParamsInt64(":authid")) | |||
if err != nil { | |||
@@ -310,7 +311,7 @@ func EditAuthSource(ctx *context.Context) { | |||
ctx.Data["HasTLS"] = source.HasTLS() | |||
if source.IsOAuth2() { | |||
ctx.Data["CurrentOAuth2Provider"] = models.OAuth2Providers[source.OAuth2().Provider] | |||
ctx.Data["CurrentOAuth2Provider"] = oauth2.Providers[source.Cfg.(*oauth2.Source).Provider] | |||
} | |||
ctx.HTML(http.StatusOK, tplAuthEdit) | |||
} | |||
@@ -322,9 +323,9 @@ func EditAuthSourcePost(ctx *context.Context) { | |||
ctx.Data["PageIsAdmin"] = true | |||
ctx.Data["PageIsAdminAuthentications"] = true | |||
ctx.Data["SMTPAuths"] = models.SMTPAuths | |||
ctx.Data["OAuth2Providers"] = models.OAuth2Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = models.OAuth2DefaultCustomURLMappings | |||
ctx.Data["SMTPAuths"] = smtp.Authenticators | |||
ctx.Data["OAuth2Providers"] = oauth2.Providers | |||
ctx.Data["OAuth2DefaultCustomURLMappings"] = oauth2.DefaultCustomURLMappings | |||
source, err := models.GetLoginSourceByID(ctx.ParamsInt64(":authid")) | |||
if err != nil { | |||
@@ -346,7 +347,7 @@ func EditAuthSourcePost(ctx *context.Context) { | |||
case models.LoginSMTP: | |||
config = parseSMTPConfig(form) | |||
case models.LoginPAM: | |||
config = &models.PAMConfig{ | |||
config = &pamService.Source{ | |||
ServiceName: form.PAMServiceName, | |||
EmailDomain: form.PAMEmailDomain, | |||
} | |||
@@ -364,7 +365,7 @@ func EditAuthSourcePost(ctx *context.Context) { | |||
} | |||
source.Name = form.Name | |||
source.IsActived = form.IsActive | |||
source.IsActive = form.IsActive | |||
source.IsSyncEnabled = form.IsSyncEnabled | |||
source.Cfg = config | |||
if err := models.UpdateSource(source); err != nil { |
@@ -14,7 +14,6 @@ import ( | |||
"strings" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/eventsource" | |||
@@ -27,6 +26,8 @@ import ( | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/modules/web/middleware" | |||
"code.gitea.io/gitea/routers/utils" | |||
"code.gitea.io/gitea/services/auth" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
"code.gitea.io/gitea/services/externalaccount" | |||
"code.gitea.io/gitea/services/forms" | |||
"code.gitea.io/gitea/services/mailer" | |||
@@ -135,7 +136,7 @@ func SignIn(ctx *context.Context) { | |||
return | |||
} | |||
orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() | |||
orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() | |||
if err != nil { | |||
ctx.ServerError("UserSignIn", err) | |||
return | |||
@@ -155,7 +156,7 @@ func SignIn(ctx *context.Context) { | |||
func SignInPost(ctx *context.Context) { | |||
ctx.Data["Title"] = ctx.Tr("sign_in") | |||
orderedOAuth2Names, oauth2Providers, err := models.GetActiveOAuth2Providers() | |||
orderedOAuth2Names, oauth2Providers, err := oauth2.GetActiveOAuth2Providers() | |||
if err != nil { | |||
ctx.ServerError("UserSignIn", err) | |||
return | |||
@@ -174,7 +175,7 @@ func SignInPost(ctx *context.Context) { | |||
} | |||
form := web.GetForm(ctx).(*forms.SignInForm) | |||
u, err := models.UserSignIn(form.UserName, form.Password) | |||
u, err := auth.UserSignIn(form.UserName, form.Password) | |||
if err != nil { | |||
if models.IsErrUserNotExist(err) { | |||
ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplSignIn, &form) | |||
@@ -577,13 +578,13 @@ func SignInOAuth(ctx *context.Context) { | |||
return | |||
} | |||
if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { | |||
if err = loginSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil { | |||
if strings.Contains(err.Error(), "no provider for ") { | |||
if err = models.ResetOAuth2(); err != nil { | |||
if err = oauth2.ResetOAuth2(); err != nil { | |||
ctx.ServerError("SignIn", err) | |||
return | |||
} | |||
if err = oauth2.Auth(loginSource.Name, ctx.Req, ctx.Resp); err != nil { | |||
if err = loginSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp); err != nil { | |||
ctx.ServerError("SignIn", err) | |||
} | |||
return | |||
@@ -631,7 +632,7 @@ func SignInOAuthCallback(ctx *context.Context) { | |||
} | |||
if len(missingFields) > 0 { | |||
log.Error("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) | |||
if loginSource.IsOAuth2() && loginSource.OAuth2().Provider == "openidConnect" { | |||
if loginSource.IsOAuth2() && loginSource.Cfg.(*oauth2.Source).Provider == "openidConnect" { | |||
log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") | |||
} | |||
err = fmt.Errorf("OAuth2 Provider %s returned empty or missing fields: %s", loginSource.Name, missingFields) | |||
@@ -772,8 +773,7 @@ func handleOAuth2SignIn(ctx *context.Context, u *models.User, gothUser goth.User | |||
// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful | |||
// login the user | |||
func oAuth2UserLoginCallback(loginSource *models.LoginSource, request *http.Request, response http.ResponseWriter) (*models.User, goth.User, error) { | |||
gothUser, err := oauth2.ProviderCallback(loginSource.Name, request, response) | |||
gothUser, err := loginSource.Cfg.(*oauth2.Source).Callback(request, response) | |||
if err != nil { | |||
if err.Error() == "securecookie: the value is too long" { | |||
log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", loginSource.Name, setting.OAuth2.MaxTokenLength) | |||
@@ -901,7 +901,7 @@ func LinkAccountPostSignIn(ctx *context.Context) { | |||
return | |||
} | |||
u, err := models.UserSignIn(signInForm.UserName, signInForm.Password) | |||
u, err := auth.UserSignIn(signInForm.UserName, signInForm.Password) | |||
if err != nil { | |||
if models.IsErrUserNotExist(err) { | |||
ctx.Data["user_exists"] = true |
@@ -20,6 +20,7 @@ import ( | |||
"code.gitea.io/gitea/modules/util" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/modules/web/middleware" | |||
"code.gitea.io/gitea/services/auth" | |||
"code.gitea.io/gitea/services/forms" | |||
) | |||
@@ -290,7 +291,7 @@ func ConnectOpenIDPost(ctx *context.Context) { | |||
ctx.Data["EnableOpenIDSignUp"] = setting.Service.EnableOpenIDSignUp | |||
ctx.Data["OpenID"] = oid | |||
u, err := models.UserSignIn(form.UserName, form.Password) | |||
u, err := auth.UserSignIn(form.UserName, form.Password) | |||
if err != nil { | |||
if models.IsErrUserNotExist(err) { | |||
ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), tplConnectOID, &form) |
@@ -13,7 +13,6 @@ import ( | |||
"strings" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/auth/oauth2" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/log" | |||
@@ -21,6 +20,7 @@ import ( | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/services/auth" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
"code.gitea.io/gitea/services/forms" | |||
"gitea.com/go-chi/binding" | |||
@@ -144,9 +144,9 @@ func newAccessTokenResponse(grant *models.OAuth2Grant, signingKey oauth2.JWTSign | |||
} | |||
// generate access token to access the API | |||
expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) | |||
accessToken := &models.OAuth2Token{ | |||
accessToken := &oauth2.Token{ | |||
GrantID: grant.ID, | |||
Type: models.TypeAccessToken, | |||
Type: oauth2.TypeAccessToken, | |||
StandardClaims: jwt.StandardClaims{ | |||
ExpiresAt: expirationDate.AsTime().Unix(), | |||
}, | |||
@@ -161,10 +161,10 @@ func newAccessTokenResponse(grant *models.OAuth2Grant, signingKey oauth2.JWTSign | |||
// generate refresh token to request an access token after it expired later | |||
refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime().Unix() | |||
refreshToken := &models.OAuth2Token{ | |||
refreshToken := &oauth2.Token{ | |||
GrantID: grant.ID, | |||
Counter: grant.Counter, | |||
Type: models.TypeRefreshToken, | |||
Type: oauth2.TypeRefreshToken, | |||
StandardClaims: jwt.StandardClaims{ | |||
ExpiresAt: refreshExpirationDate, | |||
}, | |||
@@ -202,7 +202,7 @@ func newAccessTokenResponse(grant *models.OAuth2Grant, signingKey oauth2.JWTSign | |||
} | |||
} | |||
idToken := &models.OIDCToken{ | |||
idToken := &oauth2.OIDCToken{ | |||
StandardClaims: jwt.StandardClaims{ | |||
ExpiresAt: expirationDate.AsTime().Unix(), | |||
Issuer: setting.AppURL, | |||
@@ -568,7 +568,7 @@ func AccessTokenOAuth(ctx *context.Context) { | |||
} | |||
func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, signingKey oauth2.JWTSigningKey) { | |||
token, err := models.ParseOAuth2Token(form.RefreshToken) | |||
token, err := oauth2.ParseToken(form.RefreshToken) | |||
if err != nil { | |||
handleAccessTokenError(ctx, AccessTokenError{ | |||
ErrorCode: AccessTokenErrorCodeUnauthorizedClient, |
@@ -18,6 +18,7 @@ import ( | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"code.gitea.io/gitea/modules/web" | |||
"code.gitea.io/gitea/services/auth" | |||
"code.gitea.io/gitea/services/forms" | |||
"code.gitea.io/gitea/services/mailer" | |||
) | |||
@@ -228,7 +229,7 @@ func DeleteAccount(ctx *context.Context) { | |||
ctx.Data["Title"] = ctx.Tr("settings") | |||
ctx.Data["PageIsSettingsAccount"] = true | |||
if _, err := models.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { | |||
if _, err := auth.UserSignIn(ctx.User.Name, ctx.Query("password")); err != nil { | |||
if models.IsErrUserNotExist(err) { | |||
loadAccountData(ctx) | |||
@@ -12,6 +12,7 @@ import ( | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/services/auth/source/oauth2" | |||
) | |||
const ( | |||
@@ -92,8 +93,8 @@ func loadSecurityData(ctx *context.Context) { | |||
if loginSource, err := models.GetLoginSourceByID(externalAccount.LoginSourceID); err == nil { | |||
var providerDisplayName string | |||
if loginSource.IsOAuth2() { | |||
providerTechnicalName := loginSource.OAuth2().Provider | |||
providerDisplayName = models.OAuth2Providers[providerTechnicalName].DisplayName | |||
providerTechnicalName := loginSource.Cfg.(*oauth2.Source).Provider | |||
providerDisplayName = oauth2.Providers[providerTechnicalName].DisplayName | |||
} else { | |||
providerDisplayName = loginSource.Name | |||
} |
@@ -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) | |||
} |
@@ -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) |
@@ -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 | |||
} |
@@ -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 | |||
} |
@@ -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() |
@@ -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 |
@@ -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. |
@@ -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} | |||
} |
@@ -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{} |
@@ -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 | |||
} |
@@ -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{}) | |||
} |
@@ -1,5 +1,4 @@ | |||
Gitea LDAP Authentication Module | |||
=============================== | |||
# Gitea LDAP Authentication Module | |||
## About | |||
@@ -30,94 +29,94 @@ 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. | |||
* A name to assign to the new method of authorization. | |||
* Host **(required)** | |||
* The address where the LDAP server can be reached. | |||
* Example: mydomain.com | |||
* 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 | |||
* 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. | |||
* Whether to use TLS when connecting to the LDAP server. | |||
* Admin Filter (optional) | |||
* An LDAP filter specifying if a user should be given administrator | |||
* 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) | |||
* Example: (objectClass=adminAccount) | |||
* First name attribute (optional) | |||
* The attribute of the user's LDAP record containing the user's first name. | |||
* 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 | |||
* Example: givenName | |||
* Surname attribute (optional) | |||
* The attribute of the user's LDAP record containing the user's surname This | |||
* The attribute of the user's LDAP record containing the user's surname This | |||
will be used to populate their account information. | |||
* Example: sn | |||
* Example: sn | |||
* E-mail attribute **(required)** | |||
* The attribute of the user's LDAP record containing the user's email | |||
* 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 | |||
* 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 | |||
* 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 | |||
* Example: cn=Search,dc=mydomain,dc=com | |||
* Bind Password (optional) | |||
* The password for the Bind DN specified above, if any. _Note: The password | |||
* 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 | |||
* 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 | |||
* 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)) | |||
* 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 | |||
* 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 | |||
* 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 | |||
* 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` | |||
* 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)) | |||
* 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 | |||
* 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)) | |||
* 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 | |||
* 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 | |||
* Which group LDAP attribute contains an array above user attribute names. | |||
* Example: memberUid |
@@ -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{} |
@@ -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", | |||
} |
@@ -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{}) | |||
} |
@@ -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 | |||
} |
@@ -3,8 +3,6 @@ | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
// 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 | |||
package ldap | |||
import ( | |||
@@ -17,47 +15,6 @@ import ( | |||
"github.com/go-ldap/ldap/v3" | |||
) | |||
// 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 | |||
) | |||
// 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 | |||
} | |||
// SearchResult : user data | |||
type SearchResult struct { | |||
Username string // Username |
@@ -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 | |||
} |
@@ -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 | |||
} | |||
} |
@@ -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{} |
@@ -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 | |||
} |
@@ -1,20 +1,18 @@ | |||
// Copyright 2017 The Gitea Authors. All rights reserved. | |||
// 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" | |||
"net/url" | |||
"sort" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/setting" | |||
uuid "github.com/google/uuid" | |||
"github.com/lafriks/xormstore" | |||
"github.com/markbates/goth" | |||
"github.com/markbates/goth/gothic" | |||
"github.com/markbates/goth/providers/bitbucket" | |||
"github.com/markbates/goth/providers/discord" | |||
"github.com/markbates/goth/providers/dropbox" | |||
@@ -28,79 +26,94 @@ import ( | |||
"github.com/markbates/goth/providers/openidConnect" | |||
"github.com/markbates/goth/providers/twitter" | |||
"github.com/markbates/goth/providers/yandex" | |||
"xorm.io/xorm" | |||
) | |||
var ( | |||
sessionUsersStoreKey = "gitea-oauth2-sessions" | |||
providerHeaderKey = "gitea-oauth2-provider" | |||
) | |||
// Provider describes the display values of a single OAuth2 provider | |||
type Provider struct { | |||
Name string | |||
DisplayName string | |||
Image string | |||
CustomURLMapping *CustomURLMapping | |||
} | |||
// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs | |||
type CustomURLMapping struct { | |||
AuthURL string | |||
TokenURL string | |||
ProfileURL string | |||
EmailURL string | |||
// 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, | |||
}, | |||
}, | |||
} | |||
// Init initialize the setup of the OAuth2 library | |||
func Init(x *xorm.Engine) error { | |||
store, err := xormstore.NewOptions(x, xormstore.Options{ | |||
TableName: "oauth2_session", | |||
}, []byte(sessionUsersStoreKey)) | |||
// 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 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() | |||
return nil, nil, err | |||
} | |||
gothic.GetProviderName = func(req *http.Request) (string, error) { | |||
return req.Header.Get(providerHeaderKey), nil | |||
} | |||
return nil | |||
} | |||
// Auth OAuth2 auth service | |||
func Auth(provider string, request *http.Request, response http.ResponseWriter) error { | |||
// not sure if goth is thread safe (?) when using multiple providers | |||
request.Header.Set(providerHeaderKey, provider) | |||
// 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) | |||
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) | |||
} | |||
return err | |||
} | |||
// ProviderCallback 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 ProviderCallback(provider string, request *http.Request, response http.ResponseWriter) (goth.User, error) { | |||
// not sure if goth is thread safe (?) when using multiple providers | |||
request.Header.Set(providerHeaderKey, provider) | |||
user, err := gothic.CompleteUserAuth(response, request) | |||
if err != nil { | |||
return user, err | |||
} | |||
sort.Strings(orderedKeys) | |||
return user, nil | |||
return orderedKeys, providers, nil | |||
} | |||
// RegisterProvider register a OAuth2 provider in goth lib | |||
@@ -242,58 +255,3 @@ func createProvider(providerName, providerType, clientID, clientSecret, openIDCo | |||
return provider, err | |||
} | |||
// GetDefaultTokenURL return the default token url for the given provider | |||
func GetDefaultTokenURL(provider string) string { | |||
switch provider { | |||
case "github": | |||
return github.TokenURL | |||
case "gitlab": | |||
return gitlab.TokenURL | |||
case "gitea": | |||
return gitea.TokenURL | |||
case "nextcloud": | |||
return nextcloud.TokenURL | |||
} | |||
return "" | |||
} | |||
// GetDefaultAuthURL return the default authorize url for the given provider | |||
func GetDefaultAuthURL(provider string) string { | |||
switch provider { | |||
case "github": | |||
return github.AuthURL | |||
case "gitlab": | |||
return gitlab.AuthURL | |||
case "gitea": | |||
return gitea.AuthURL | |||
case "nextcloud": | |||
return nextcloud.AuthURL | |||
case "mastodon": | |||
return mastodon.InstanceURL | |||
} | |||
return "" | |||
} | |||
// GetDefaultProfileURL return the default profile url for the given provider | |||
func GetDefaultProfileURL(provider string) string { | |||
switch provider { | |||
case "github": | |||
return github.ProfileURL | |||
case "gitlab": | |||
return gitlab.ProfileURL | |||
case "gitea": | |||
return gitea.ProfileURL | |||
case "nextcloud": | |||
return nextcloud.ProfileURL | |||
} | |||
return "" | |||
} | |||
// GetDefaultEmailURL return the default email url for the given provider | |||
func GetDefaultEmailURL(provider string) string { | |||
if provider == "github" { | |||
return github.EmailURL | |||
} | |||
return "" | |||
} |
@@ -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{}) | |||
} |
@@ -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) | |||
} |
@@ -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 | |||
} |
@@ -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 | |||
} |
@@ -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()) | |||
} |
@@ -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, | |||
} |
@@ -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{} |
@@ -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{}) | |||
} |
@@ -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) | |||
} |
@@ -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{} |
@@ -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 | |||
} |
@@ -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{}) | |||
} |
@@ -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) | |||
} |
@@ -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{} |
@@ -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{}) | |||
} |
@@ -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 "" | |||
} |
@@ -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 | |||
} |
@@ -22,7 +22,7 @@ | |||
<!-- LDAP and DLDAP --> | |||
{{if or .Source.IsLDAP .Source.IsDLDAP}} | |||
{{ $cfg:=.Source.LDAP }} | |||
{{ $cfg:=.Source.Cfg }} | |||
<div class="inline required field {{if .Err_SecurityProtocol}}error{{end}}"> | |||
<label>{{.i18n.Tr "admin.auths.security_protocol"}}</label> | |||
<div class="ui selection security-protocol dropdown"> | |||
@@ -151,7 +151,7 @@ | |||
<!-- SMTP --> | |||
{{if .Source.IsSMTP}} | |||
{{ $cfg:=.Source.SMTP }} | |||
{{ $cfg:=.Source.Cfg }} | |||
<div class="inline required field"> | |||
<label>{{.i18n.Tr "admin.auths.smtp_auth"}}</label> | |||
<div class="ui selection type dropdown"> | |||
@@ -182,7 +182,7 @@ | |||
<!-- PAM --> | |||
{{if .Source.IsPAM}} | |||
{{ $cfg:=.Source.PAM }} | |||
{{ $cfg:=.Source.Cfg }} | |||
<div class="required field"> | |||
<label for="pam_service_name">{{.i18n.Tr "admin.auths.pam_service_name"}}</label> | |||
<input id="pam_service_name" name="pam_service_name" value="{{$cfg.ServiceName}}" required> | |||
@@ -195,7 +195,7 @@ | |||
<!-- OAuth2 --> | |||
{{if .Source.IsOAuth2}} | |||
{{ $cfg:=.Source.OAuth2 }} | |||
{{ $cfg:=.Source.Cfg }} | |||
<div class="inline required field"> | |||
<label>{{.i18n.Tr "admin.auths.oauth2_provider"}}</label> | |||
<div class="ui selection type dropdown"> | |||
@@ -258,7 +258,7 @@ | |||
<!-- SSPI --> | |||
{{if .Source.IsSSPI}} | |||
{{ $cfg:=.Source.SSPI }} | |||
{{ $cfg:=.Source.Cfg }} | |||
<div class="field"> | |||
<div class="ui checkbox"> | |||
<label for="sspi_auto_create_users"><strong>{{.i18n.Tr "admin.auths.sspi_auto_create_users"}}</strong></label> | |||
@@ -325,7 +325,7 @@ | |||
<div class="inline field"> | |||
<div class="ui checkbox"> | |||
<label><strong>{{.i18n.Tr "admin.auths.activated"}}</strong></label> | |||
<input name="is_active" type="checkbox" {{if .Source.IsActived}}checked{{end}}> | |||
<input name="is_active" type="checkbox" {{if .Source.IsActive}}checked{{end}}> | |||
</div> | |||
</div> | |||
@@ -28,7 +28,7 @@ | |||
<td>{{.ID}}</td> | |||
<td><a href="{{AppSubUrl}}/admin/auths/{{.ID}}">{{.Name}}</a></td> | |||
<td>{{.TypeName}}</td> | |||
<td>{{if .IsActived}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</td> | |||
<td>{{if .IsActive}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}</td> | |||
<td><span class="poping up" data-content="{{.UpdatedUnix.FormatShort}}" data-variation="tiny">{{.UpdatedUnix.FormatShort}}</span></td> | |||
<td><span class="poping up" data-content="{{.CreatedUnix.FormatLong}}" data-variation="tiny">{{.CreatedUnix.FormatShort}}</span></td> | |||
<td><a href="{{AppSubUrl}}/admin/auths/{{.ID}}">{{svg "octicon-pencil"}}</a></td> |
@@ -16,7 +16,7 @@ | |||
</div> | |||
<div class="content"> | |||
<strong>{{$provider}}</strong> | |||
{{if $loginSource.IsActived}}<span class="text red">{{$.i18n.Tr "settings.active"}}</span>{{end}} | |||
{{if $loginSource.IsActive}}<span class="text red">{{$.i18n.Tr "settings.active"}}</span>{{end}} | |||
</div> | |||
</div> | |||
{{end}} |