Refactor: Move login out of models (#16199)
`models` does far too much. In particular it handles all `UserSignin`. It shouldn't be responsible for calling LDAP, SMTP or PAM for signing in. Therefore we should move this code out of `models`. This code has to depend on `models` - therefore it belongs in `services`. There is a package in `services` called `auth` and clearly this functionality belongs in there. Plan: - [x] Change `auth.Auth` to `auth.Method` - as they represent methods of authentication. - [x] Move `models.UserSignIn` into `auth` - [x] Move `models.ExternalUserLogin` - [x] Move most of the `LoginVia*` methods to `auth` or subpackages - [x] Move Resynchronize functionality to `auth` - Involved some restructuring of `models/ssh_key.go` to reduce the size of this massive file and simplify its files. - [x] Move the rest of the LDAP functionality in to the ldap subpackage - [x] Re-factor the login sources to express an interfaces `auth.Source`? - I've done this through some smaller interfaces Authenticator and Synchronizable - which would allow us to extend things in future - [x] Now LDAP is out of models - need to think about modules/auth/ldap and I think all of that functionality might just be moveable - [x] Similarly a lot Oauth2 functionality need not be in models too and should be moved to services/auth/source/oauth2 - [x] modules/auth/oauth2/oauth2.go uses xorm... This is naughty - probably need to move this into models. - [x] models/oauth2.go - mostly should be in modules/auth/oauth2 or services/auth/source/oauth2 - [x] More simplifications of login_source.go may need to be done - Allow wiring in of notify registration - *this can now easily be done - but I think we should do it in another PR* - see #16178 - More refactors...? - OpenID should probably become an auth Method but I think that can be left for another PR - Methods should also probably be cleaned up - again another PR I think. - SSPI still needs more refactors.* Rename auth.Auth auth.Method * Restructure ssh_key.go - move functions from models/user.go that relate to ssh_key to ssh_key - split ssh_key.go to try create clearer function domains for allow for future refactors here. Signed-off-by: Andrew Thornton <art27@cantab.net>tokarchuk/v1.17
parent
f135a818f5
commit
5d2e11eedb
@ -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}" |
@ -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) |
||||
} |
||||
} |
||||
|
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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 |
||||
} |
@ -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{}) |
||||
} |
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -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{}) |
||||
} |
@ -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 |
||||
} |
Loading…
Reference in new issue