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{}) |
||||||
|
} |
@ -1,5 +1,4 @@ |
|||||||
Gitea LDAP Authentication Module |
# Gitea LDAP Authentication Module |
||||||
=============================== |
|
||||||
|
|
||||||
## About |
## About |
||||||
|
|
@ -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