初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/webauthn"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
"gitea.dev/modules/web/middleware"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
type ErrUserAuthMessage string
|
||||
|
||||
func (e ErrUserAuthMessage) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func ErrAsUserAuthMessage(err error) (string, bool) {
|
||||
var msg ErrUserAuthMessage
|
||||
if errors.As(err, &msg) {
|
||||
return msg.Error(), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Init should be called exactly once when the application starts to allow plugins
|
||||
// to allocate necessary resources
|
||||
func Init() {
|
||||
webauthn.Init()
|
||||
}
|
||||
|
||||
// handleSignIn clears existing session variables and stores new ones for the specified user object
|
||||
func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore, user *user_model.User) {
|
||||
// We need to regenerate the session...
|
||||
newSess, err := session.RegenerateSession(resp, req)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error regenerating session: %v", err))
|
||||
} else {
|
||||
sess = newSess
|
||||
}
|
||||
|
||||
_ = sess.Delete("openid_verified_uri")
|
||||
_ = sess.Delete("openid_signin_remember")
|
||||
_ = sess.Delete("openid_determined_email")
|
||||
_ = sess.Delete("openid_determined_username")
|
||||
_ = sess.Delete("twofaUid")
|
||||
_ = sess.Delete("twofaRemember")
|
||||
_ = sess.Delete("webauthnAssertion")
|
||||
_ = sess.Delete("linkAccount")
|
||||
err = sess.Set("uid", user.ID)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
err = sess.Set("uname", user.Name)
|
||||
if err != nil {
|
||||
log.Error(fmt.Sprintf("Error setting session: %v", err))
|
||||
}
|
||||
|
||||
// Language setting of the user overwrites the one previously set
|
||||
// If the user does not have a locale set, we save the current one.
|
||||
if len(user.Language) == 0 {
|
||||
lc := middleware.Locale(resp, req)
|
||||
opts := &user_service.UpdateOptions{
|
||||
Language: optional.Some(lc.Language()),
|
||||
}
|
||||
if err := user_service.UpdateUser(req.Context(), user, opts); err != nil {
|
||||
log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
middleware.SetLocaleCookie(resp, user.Language, 0)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// Based on https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence#secure-remember-me-cookies
|
||||
|
||||
// The auth token consists of two parts: ID and token hash
|
||||
// Every device login creates a new auth token with an individual id and hash.
|
||||
// If a device uses the token to login into the instance, a fresh token gets generated which has the same id but a new hash.
|
||||
|
||||
var (
|
||||
ErrAuthTokenInvalidFormat = util.NewInvalidArgumentErrorf("auth token has an invalid format")
|
||||
ErrAuthTokenExpired = util.NewInvalidArgumentErrorf("auth token has expired")
|
||||
ErrAuthTokenInvalidHash = util.NewInvalidArgumentErrorf("auth token is invalid")
|
||||
)
|
||||
|
||||
func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, error) {
|
||||
if len(value) == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
parts := strings.SplitN(value, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, ErrAuthTokenInvalidFormat
|
||||
}
|
||||
|
||||
t, err := auth_model.GetAuthTokenByID(ctx, parts[0])
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
return nil, ErrAuthTokenExpired
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if t.ExpiresUnix < timeutil.TimeStampNow() {
|
||||
return nil, ErrAuthTokenExpired
|
||||
}
|
||||
|
||||
hashedToken := sha256.Sum256([]byte(parts[1]))
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(t.TokenHash), []byte(hex.EncodeToString(hashedToken[:]))) == 0 {
|
||||
// If an attacker steals a token and uses the token to create a new session the hash gets updated.
|
||||
// When the victim uses the old token the hashes don't match anymore and the victim should be notified about the compromised token.
|
||||
return nil, ErrAuthTokenInvalidHash
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) {
|
||||
token, hash := generateTokenAndHash()
|
||||
|
||||
newToken := &auth_model.AuthToken{
|
||||
ID: t.ID,
|
||||
TokenHash: hash,
|
||||
UserID: t.UserID,
|
||||
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
|
||||
}
|
||||
|
||||
if err := auth_model.UpdateAuthTokenByID(ctx, newToken); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return newToken, token, nil
|
||||
}
|
||||
|
||||
func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.AuthToken, string, error) {
|
||||
t := &auth_model.AuthToken{
|
||||
UserID: userID,
|
||||
ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour),
|
||||
}
|
||||
|
||||
t.ID = util.CryptoRandomString(10)
|
||||
|
||||
token, hash := generateTokenAndHash()
|
||||
|
||||
t.TokenHash = hash
|
||||
|
||||
if err := auth_model.InsertAuthToken(ctx, t); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return t, token, nil
|
||||
}
|
||||
|
||||
func generateTokenAndHash() (string, string) {
|
||||
buf := util.CryptoRandomBytes(32)
|
||||
|
||||
token := hex.EncodeToString(buf)
|
||||
|
||||
hashedToken := sha256.Sum256([]byte(token))
|
||||
|
||||
return token, hex.EncodeToString(hashedToken[:])
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckAuthToken(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
token, err := CheckAuthToken(t.Context(), "")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, token)
|
||||
})
|
||||
|
||||
t.Run("InvalidFormat", func(t *testing.T) {
|
||||
token, err := CheckAuthToken(t.Context(), "dummy")
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalidFormat)
|
||||
assert.Nil(t, token)
|
||||
})
|
||||
|
||||
t.Run("NotFound", func(t *testing.T) {
|
||||
token, err := CheckAuthToken(t.Context(), "notexists:dummy")
|
||||
assert.ErrorIs(t, err, ErrAuthTokenExpired)
|
||||
assert.Nil(t, token)
|
||||
})
|
||||
|
||||
t.Run("Expired", func(t *testing.T) {
|
||||
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
timeutil.MockUnset()
|
||||
|
||||
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token)
|
||||
assert.ErrorIs(t, err, ErrAuthTokenExpired)
|
||||
assert.Nil(t, at2)
|
||||
|
||||
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
|
||||
})
|
||||
|
||||
t.Run("InvalidHash", func(t *testing.T) {
|
||||
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token+"dummy")
|
||||
assert.ErrorIs(t, err, ErrAuthTokenInvalidHash)
|
||||
assert.Nil(t, at2)
|
||||
|
||||
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
|
||||
})
|
||||
|
||||
t.Run("Valid", func(t *testing.T) {
|
||||
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
at2, err := CheckAuthToken(t.Context(), at.ID+":"+token)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at2)
|
||||
|
||||
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegenerateAuthToken(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
defer timeutil.MockUnset()
|
||||
|
||||
at, token, err := CreateAuthTokenForUserID(t.Context(), 2)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
timeutil.MockSet(time.Date(2023, 1, 1, 0, 0, 1, 0, time.UTC))
|
||||
|
||||
at2, token2, err := RegenerateAuthToken(t.Context(), at)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, at2)
|
||||
assert.NotEmpty(t, token2)
|
||||
|
||||
assert.Equal(t, at.ID, at2.ID)
|
||||
assert.Equal(t, at.UserID, at2.UserID)
|
||||
assert.NotEqual(t, token, token2)
|
||||
assert.NotEqual(t, at.ExpiresUnix, at2.ExpiresUnix)
|
||||
|
||||
assert.NoError(t, auth_model.DeleteAuthTokenByID(t.Context(), at.ID))
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/httpauth"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
var (
|
||||
_ Method = &Basic{}
|
||||
)
|
||||
|
||||
// BasicMethodName is the constant name of the basic authentication method
|
||||
const (
|
||||
BasicMethodName = "basic"
|
||||
AccessTokenMethodName = "access_token"
|
||||
OAuth2TokenMethodName = "oauth2_token"
|
||||
ActionTokenMethodName = "action_token"
|
||||
)
|
||||
|
||||
// Basic implements the Auth interface and authenticates requests (API requests
|
||||
// only) by looking for Basic authentication data or "x-oauth-basic" token in the "Authorization"
|
||||
// header.
|
||||
type Basic struct{}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (b *Basic) Name() string {
|
||||
return BasicMethodName
|
||||
}
|
||||
|
||||
func (b *Basic) parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd string }) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ret
|
||||
}
|
||||
parsed, ok := httpauth.ParseAuthorizationHeader(authHeader)
|
||||
if !ok || parsed.BasicAuth == nil {
|
||||
return ret
|
||||
}
|
||||
uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password
|
||||
|
||||
// Check if username or password is a token
|
||||
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
|
||||
// Assume username is token
|
||||
authToken := uname
|
||||
if !isUsernameToken {
|
||||
log.Trace("Basic Authorization: Attempting login for: %s", uname)
|
||||
// Assume password is token
|
||||
authToken = passwd
|
||||
} else {
|
||||
log.Trace("Basic Authorization: Attempting login with username as token")
|
||||
}
|
||||
ret.authToken, ret.uname, ret.passwd = authToken, uname, passwd
|
||||
return ret
|
||||
}
|
||||
|
||||
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
|
||||
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
|
||||
// get oauth2 token's user's ID
|
||||
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
|
||||
if uid != 0 {
|
||||
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), uid)
|
||||
if err != nil {
|
||||
log.Error("GetUserByID: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = accessTokenScope
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// check personal access token
|
||||
token, err := auth_model.GetAccessTokenBySHA(req.Context(), authToken)
|
||||
if err == nil {
|
||||
log.Trace("Basic Authorization: Valid AccessToken for user[%d]", uid)
|
||||
u, err := user_model.GetUserByID(req.Context(), token.UID)
|
||||
if err != nil {
|
||||
log.Error("GetUserByID: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token.UpdatedUnix = timeutil.TimeStampNow()
|
||||
if err = auth_model.UpdateAccessToken(req.Context(), token); err != nil {
|
||||
log.Error("UpdateAccessToken: %v", err)
|
||||
}
|
||||
|
||||
store.GetData()["LoginMethod"] = AccessTokenMethodName
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = token.Scope
|
||||
return u, nil
|
||||
} else if !auth_model.IsErrAccessTokenNotExist(err) && !auth_model.IsErrAccessTokenEmpty(err) {
|
||||
log.Error("GetAccessTokenBySha: %v", err)
|
||||
}
|
||||
|
||||
// check task token
|
||||
task, err := actions_model.GetRunningTaskByToken(req.Context(), authToken)
|
||||
if err == nil && task != nil {
|
||||
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
|
||||
store.GetData()["LoginMethod"] = ActionTokenMethodName
|
||||
return user_model.NewActionsUserWithTaskID(task.ID), nil
|
||||
}
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
// Verify extracts and validates Basic data (username and password/token) from the
|
||||
// "Authorization" header of the request and returns the corresponding user object for that
|
||||
// name/token on successful validation.
|
||||
// Returns nil if header is empty or validation fails.
|
||||
func (b *Basic) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
parseBasicRet := b.parseAuthBasic(req)
|
||||
authToken, uname, passwd := parseBasicRet.authToken, parseBasicRet.uname, parseBasicRet.passwd
|
||||
if authToken == "" && uname == "" {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
u, err := b.VerifyAuthToken(req, w, store, sess, authToken)
|
||||
if u != nil || err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
if !setting.Service.EnableBasicAuth {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
log.Trace("Basic Authorization: Attempting SignIn for %s", uname)
|
||||
u, source, err := UserSignIn(req.Context(), uname, passwd)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("UserSignIn: %v", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !source.TwoFactorShouldSkip() {
|
||||
// Check if the user has WebAuthn registration
|
||||
hasWebAuthn, err := auth_model.HasWebAuthnRegistrationsByUID(req.Context(), u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasWebAuthn {
|
||||
return nil, ErrUserAuthMessage("basic authorization is not allowed while WebAuthn enrolled")
|
||||
}
|
||||
|
||||
if err := validateTOTP(req, u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
store.GetData()["LoginMethod"] = BasicMethodName
|
||||
log.Trace("Basic Authorization: Logged in user %-v", u)
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func validateTOTP(req *http.Request, u *user_model.User) error {
|
||||
twofa, err := auth_model.GetTwoFactorByUID(req.Context(), u.ID)
|
||||
if err != nil {
|
||||
if auth_model.IsErrTwoFactorNotEnrolled(err) {
|
||||
// No 2FA enrollment for this user
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAccessScope(store DataStore) auth_model.AccessTokenScope {
|
||||
if v, ok := store.GetData()["ApiTokenScope"]; ok {
|
||||
return v.(auth_model.AccessTokenScope)
|
||||
}
|
||||
switch store.GetData()["LoginMethod"] {
|
||||
case OAuth2TokenMethodName:
|
||||
fallthrough
|
||||
case BasicMethodName, AccessTokenMethodName:
|
||||
return auth_model.AccessTokenScopeAll
|
||||
case ActionTokenMethodName:
|
||||
fallthrough
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
var (
|
||||
_ Method = &Group{}
|
||||
)
|
||||
|
||||
// Group implements the Auth interface with serval Auth.
|
||||
type Group struct {
|
||||
methods []Method
|
||||
}
|
||||
|
||||
// NewGroup creates a new auth group
|
||||
func NewGroup(methods ...Method) *Group {
|
||||
return &Group{
|
||||
methods: methods,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a new method to group
|
||||
func (b *Group) Add(method Method) {
|
||||
b.methods = append(b.methods, method)
|
||||
}
|
||||
|
||||
// Name returns group's methods name
|
||||
func (b *Group) Name() string {
|
||||
names := make([]string, 0, len(b.methods))
|
||||
for _, m := range b.methods {
|
||||
names = append(names, m.Name())
|
||||
}
|
||||
return strings.Join(names, ",")
|
||||
}
|
||||
|
||||
func (b *Group) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
// Try to sign in with each of the enabled plugins
|
||||
var retErr error
|
||||
for _, m := range b.methods {
|
||||
user, err := m.Verify(req, w, store, sess)
|
||||
if err != nil {
|
||||
if retErr == nil {
|
||||
retErr = err
|
||||
}
|
||||
// Try other methods if this one failed.
|
||||
// Some methods may share the same protocol to detect if they are matched.
|
||||
// For example, OAuth2 and conan.Auth both read token from "Authorization: Bearer <token>" header,
|
||||
// If OAuth2 returns error, we should give conan.Auth a chance to try.
|
||||
continue
|
||||
}
|
||||
|
||||
// If any method returns a user, we can stop trying.
|
||||
// Return the user and ignore any error returned by previous methods.
|
||||
if user != nil {
|
||||
if store.GetData()["AuthedMethod"] == nil {
|
||||
store.GetData()["AuthedMethod"] = m.Name()
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If no method returns a user, return the error returned by the first method.
|
||||
return nil, retErr
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/42wim/httpsig"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
var (
|
||||
_ Method = &HTTPSign{}
|
||||
)
|
||||
|
||||
// HTTPSign implements the Auth interface and authenticates requests (API requests
|
||||
// only) by looking for http signature data in the "Signature" header.
|
||||
// more information can be found on https://github.com/go-fed/httpsig
|
||||
type HTTPSign struct{}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (h *HTTPSign) Name() string {
|
||||
return "httpsign"
|
||||
}
|
||||
|
||||
// Verify extracts and validates HTTPsign from the Signature header of the request and returns
|
||||
// the corresponding user object on successful validation.
|
||||
// Returns nil if header is empty or validation fails.
|
||||
func (h *HTTPSign) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
sigHead := req.Header.Get("Signature")
|
||||
if len(sigHead) == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
var (
|
||||
publicKey *asymkey_model.PublicKey
|
||||
err error
|
||||
)
|
||||
|
||||
if len(req.Header.Get("X-Ssh-Certificate")) != 0 {
|
||||
// Handle Signature signed by SSH certificates
|
||||
if len(setting.SSH.TrustedUserCAKeys) == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
publicKey, err = VerifyCert(req)
|
||||
if err != nil {
|
||||
log.Debug("VerifyCert on request from %s: failed: %v", req.RemoteAddr, err)
|
||||
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
} else {
|
||||
// Handle Signature signed by Public Key
|
||||
publicKey, err = VerifyPubKey(req)
|
||||
if err != nil {
|
||||
log.Debug("VerifyPubKey on request from %s: failed: %v", req.RemoteAddr, err)
|
||||
log.Warn("Failed authentication attempt from %s", req.RemoteAddr)
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
}
|
||||
|
||||
u, err := user_model.GetUserByID(req.Context(), publicKey.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("GetUserByID: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store.GetData()["IsApiToken"] = true
|
||||
|
||||
log.Trace("HTTP Sign: Logged in user %-v", u)
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func VerifyPubKey(r *http.Request) (*asymkey_model.PublicKey, error) {
|
||||
verifier, err := httpsig.NewVerifier(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
|
||||
}
|
||||
|
||||
keyID := verifier.KeyId()
|
||||
|
||||
publicKeys, err := db.Find[asymkey_model.PublicKey](r.Context(), asymkey_model.FindPublicKeyOptions{
|
||||
Fingerprint: keyID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(publicKeys) == 0 {
|
||||
return nil, fmt.Errorf("no public key found for keyid %s", keyID)
|
||||
}
|
||||
|
||||
sshPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(publicKeys[0].Content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := doVerify(verifier, []ssh.PublicKey{sshPublicKey}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return publicKeys[0], nil
|
||||
}
|
||||
|
||||
// VerifyCert verifies the validity of the ssh certificate and returns the publickey of the signer
|
||||
// We verify that the certificate is signed with the correct CA
|
||||
// We verify that the http request is signed with the private key (of the public key mentioned in the certificate)
|
||||
func VerifyCert(r *http.Request) (*asymkey_model.PublicKey, error) {
|
||||
// Get our certificate from the header
|
||||
bcert, err := base64.RawStdEncoding.DecodeString(r.Header.Get("x-ssh-certificate"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pk, err := ssh.ParsePublicKey(bcert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if it's really a ssh certificate
|
||||
cert, ok := pk.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return nil, errors.New("no certificate found")
|
||||
}
|
||||
|
||||
c := &ssh.CertChecker{
|
||||
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||
marshaled := auth.Marshal()
|
||||
|
||||
for _, k := range setting.SSH.TrustedUserCAKeysParsed {
|
||||
if bytes.Equal(marshaled, k.Marshal()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
// check the CA of the cert
|
||||
if !c.IsUserAuthority(cert.SignatureKey) {
|
||||
return nil, errors.New("CA check failed")
|
||||
}
|
||||
|
||||
// Create a verifier
|
||||
verifier, err := httpsig.NewVerifier(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpsig.NewVerifier failed: %s", err)
|
||||
}
|
||||
|
||||
// now verify that this request was signed with the private key that matches the certificate public key
|
||||
if err := doVerify(verifier, []ssh.PublicKey{cert.Key}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Now for each of the certificate valid principals
|
||||
for _, principal := range cert.ValidPrincipals {
|
||||
// Look in the db for the public key
|
||||
publicKey, err := asymkey_model.SearchPublicKeyByContentExact(r.Context(), principal)
|
||||
if asymkey_model.IsErrKeyNotExist(err) {
|
||||
// No public key matches this principal - try the next principal
|
||||
continue
|
||||
} else if err != nil {
|
||||
// this error will be a db error therefore we can't solve this and we should abort
|
||||
log.Error("SearchPublicKeyByContentExact: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate the cert for this principal
|
||||
if err := c.CheckCert(principal, cert); err != nil {
|
||||
// however, because principal is a member of ValidPrincipals - if this fails then the certificate itself is invalid
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// OK we have a public key for a principal matching a valid certificate whose key has signed this request.
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// No public key matching a principal in the certificate is registered in gitea
|
||||
return nil, errors.New("no valid principal found")
|
||||
}
|
||||
|
||||
// doVerify iterates across the provided public keys attempting the verify the current request against each key in turn
|
||||
func doVerify(verifier httpsig.Verifier, sshPublicKeys []ssh.PublicKey) error {
|
||||
for _, publicKey := range sshPublicKeys {
|
||||
cryptoPubkey := publicKey.(ssh.CryptoPublicKey).CryptoPublicKey()
|
||||
|
||||
var algos []httpsig.Algorithm
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(publicKey.Type(), "ssh-ed25519"):
|
||||
algos = []httpsig.Algorithm{httpsig.ED25519}
|
||||
case strings.HasPrefix(publicKey.Type(), "ssh-rsa"):
|
||||
algos = []httpsig.Algorithm{httpsig.RSA_SHA256, httpsig.RSA_SHA512}
|
||||
}
|
||||
for _, algo := range algos {
|
||||
if err := verifier.Verify(cryptoPubkey, algo); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("verification failed")
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/modules/session"
|
||||
)
|
||||
|
||||
type DataStore = reqctx.ContextDataProvider
|
||||
|
||||
// SessionStore represents a session store
|
||||
type SessionStore session.Store
|
||||
|
||||
// Method represents an authentication method (plugin) for HTTP requests.
|
||||
type Method interface {
|
||||
// Verify tries to verify the authentication data contained in the request.
|
||||
// If verification succeeds, it returns either an existing user object (with id > 0)
|
||||
// or a new user object (with id = 0) populated with the information that was found
|
||||
// in the authentication data (username or email).
|
||||
// Second argument returns err if verification fails, otherwise
|
||||
// First return argument returns nil if no matched verification condition
|
||||
Verify(http *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error)
|
||||
|
||||
Name() string
|
||||
}
|
||||
|
||||
// PasswordAuthenticator represents a source of authentication
|
||||
type PasswordAuthenticator interface {
|
||||
Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error)
|
||||
}
|
||||
|
||||
// SynchronizableSource represents a source that can synchronize users
|
||||
type SynchronizableSource interface {
|
||||
Sync(ctx context.Context, updateExisting bool) error
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/httpauth"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/actions"
|
||||
"gitea.dev/services/oauth2_provider"
|
||||
)
|
||||
|
||||
var _ Method = &OAuth2{}
|
||||
|
||||
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
|
||||
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
|
||||
var accessTokenScope auth_model.AccessTokenScope
|
||||
if !setting.OAuth2.Enabled {
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
|
||||
// JWT tokens require a ".", if the token isn't like that, return early
|
||||
if !strings.Contains(accessToken, ".") {
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
|
||||
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
|
||||
if err != nil {
|
||||
log.Trace("oauth2.ParseToken: %v", err)
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
var grant *auth_model.OAuth2Grant
|
||||
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
if token.Kind != oauth2_provider.KindAccessToken {
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
|
||||
return accessTokenScope, 0
|
||||
}
|
||||
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
|
||||
return accessTokenScope, grant.UserID
|
||||
}
|
||||
|
||||
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
|
||||
func CheckTaskIsRunning(ctx context.Context, taskID int64) bool {
|
||||
// Verify the task exists
|
||||
task, err := actions_model.GetTaskByID(ctx, taskID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify that it's running
|
||||
return task.Status == actions_model.StatusRunning
|
||||
}
|
||||
|
||||
// OAuth2 implements the Auth interface and authenticates requests
|
||||
// (API requests only) by looking for an OAuth token in query parameters or the
|
||||
// "Authorization" header.
|
||||
type OAuth2 struct{}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (o *OAuth2) Name() string {
|
||||
return "oauth2"
|
||||
}
|
||||
|
||||
// parseToken returns the token from request, and a boolean value
|
||||
// representing whether the token exists or not
|
||||
func parseToken(req *http.Request) (string, bool) {
|
||||
_ = req.ParseForm()
|
||||
if !setting.DisableQueryAuthToken {
|
||||
// Check token.
|
||||
if token := req.Form.Get("token"); token != "" {
|
||||
return token, true
|
||||
}
|
||||
// Check access token.
|
||||
if token := req.Form.Get("access_token"); token != "" {
|
||||
return token, true
|
||||
}
|
||||
} else if req.Form.Get("token") != "" || req.Form.Get("access_token") != "" {
|
||||
log.Warn("API token sent in query string but DISABLE_QUERY_AUTH_TOKEN=true")
|
||||
}
|
||||
|
||||
// check header token
|
||||
if auHead := req.Header.Get("Authorization"); auHead != "" {
|
||||
parsed, ok := httpauth.ParseAuthorizationHeader(auHead)
|
||||
if ok && parsed.BearerToken != nil {
|
||||
return parsed.BearerToken.Token, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// userFromToken returns the user corresponding to the OAuth token.
|
||||
// It will set 'IsApiToken' to true if the token is an API token and
|
||||
// set 'ApiTokenScope' to the scope of the access token (TODO: this behavior should be fixed, don't set ctx.Data)
|
||||
func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataStore) (*user_model.User, error) {
|
||||
// Let's see if token is valid.
|
||||
if strings.Contains(tokenSHA, ".") {
|
||||
// First attempt to decode an actions JWT, returning the actions user
|
||||
if taskID, err := actions.TokenToTaskID(tokenSHA); err == nil {
|
||||
if CheckTaskIsRunning(ctx, taskID) {
|
||||
return user_model.NewActionsUserWithTaskID(taskID), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, check if this is an OAuth access token
|
||||
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
|
||||
if uid != 0 {
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = accessTokenScope
|
||||
}
|
||||
return user_model.GetUserByID(ctx, uid)
|
||||
}
|
||||
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
|
||||
if err != nil {
|
||||
if auth_model.IsErrAccessTokenNotExist(err) {
|
||||
// check task token
|
||||
if task, err := actions_model.GetRunningTaskByToken(ctx, tokenSHA); err == nil {
|
||||
log.Trace("Basic Authorization: Valid AccessToken for task[%d]", task.ID)
|
||||
return user_model.NewActionsUserWithTaskID(task.ID), nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.UpdatedUnix = timeutil.TimeStampNow()
|
||||
if err = auth_model.UpdateAccessToken(ctx, t); err != nil {
|
||||
log.Error("UpdateAccessToken: %v", err)
|
||||
}
|
||||
store.GetData()["IsApiToken"] = true
|
||||
store.GetData()["ApiTokenScope"] = t.Scope
|
||||
return user_model.GetUserByID(ctx, t.UID)
|
||||
}
|
||||
|
||||
// Verify extracts the user ID from the OAuth token in the query parameters
|
||||
// or the "Authorization" header and returns the corresponding user object for that ID.
|
||||
// If verification is successful returns an existing user object.
|
||||
// Returns nil if verification fails.
|
||||
func (o *OAuth2) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
token, ok := parseToken(req)
|
||||
if !ok {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
user, err := o.userFromToken(req.Context(), token, store)
|
||||
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
||||
log.Error("userFromToken: %v", err) // the callers might ignore the error, so log it here
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/reqctx"
|
||||
"gitea.dev/services/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserIDFromToken(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
t.Run("Actions JWT", func(t *testing.T) {
|
||||
const RunningTaskID int64 = 47
|
||||
token, err := actions.CreateAuthorizationToken(RunningTaskID, 1, 2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ds := make(reqctx.ContextData)
|
||||
|
||||
o := OAuth2{}
|
||||
u, err := o.userFromToken(t.Context(), token, ds)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user_model.ActionsUserID, u.ID)
|
||||
taskID, ok := user_model.GetActionsUserTaskID(u)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, RunningTaskID, taskID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckTaskIsRunning(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
cases := map[string]struct {
|
||||
TaskID int64
|
||||
Expected bool
|
||||
}{
|
||||
"Running": {TaskID: 47, Expected: true},
|
||||
"Missing": {TaskID: 1, Expected: false},
|
||||
"Cancelled": {TaskID: 46, Expected: false},
|
||||
}
|
||||
|
||||
for name := range cases {
|
||||
c := cases[name]
|
||||
t.Run(name, func(t *testing.T) {
|
||||
actual := CheckTaskIsRunning(t.Context(), c.TaskID)
|
||||
assert.Equal(t, c.Expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
gouuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
var (
|
||||
_ Method = &ReverseProxy{}
|
||||
)
|
||||
|
||||
// ReverseProxyMethodName is the constant name of the ReverseProxy authentication method
|
||||
const ReverseProxyMethodName = "reverse_proxy"
|
||||
|
||||
// ReverseProxy implements the Auth interface, but actually relies on
|
||||
// a reverse proxy for authentication of users.
|
||||
// On successful authentication the proxy is expected to populate the username in the
|
||||
// "setting.ReverseProxyAuthUser" header. Optionally it can also populate the email of the
|
||||
// user in the "setting.ReverseProxyAuthEmail" header.
|
||||
type ReverseProxy struct {
|
||||
CreateSession bool
|
||||
}
|
||||
|
||||
// getUserName extracts the username from the "setting.ReverseProxyAuthUser" header
|
||||
func (r *ReverseProxy) getUserName(req *http.Request) string {
|
||||
return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthUser))
|
||||
}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (r *ReverseProxy) Name() string {
|
||||
return ReverseProxyMethodName
|
||||
}
|
||||
|
||||
// getUserFromAuthUser extracts the username from the "setting.ReverseProxyAuthUser" header
|
||||
// of the request and returns the corresponding user object for that name.
|
||||
// Verification of header data is not performed as it should have already been done by
|
||||
// the reverse proxy.
|
||||
// If a username is available in the "setting.ReverseProxyAuthUser" header an existing
|
||||
// user object is returned (populated with username or email found in header).
|
||||
// Returns nil if header is empty.
|
||||
func (r *ReverseProxy) getUserFromAuthUser(req *http.Request) (*user_model.User, error) {
|
||||
username := r.getUserName(req)
|
||||
if len(username) == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
log.Trace("ReverseProxy Authorization: Found username: %s", username)
|
||||
|
||||
user, err := user_model.GetUserByName(req.Context(), username)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) || !r.isAutoRegisterAllowed() {
|
||||
log.Error("GetUserByName: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
user = r.newUser(req)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// getEmail extracts the email from the "setting.ReverseProxyAuthEmail" header
|
||||
func (r *ReverseProxy) getEmail(req *http.Request) string {
|
||||
return strings.TrimSpace(req.Header.Get(setting.ReverseProxyAuthEmail))
|
||||
}
|
||||
|
||||
// getUserFromAuthEmail extracts the username from the "setting.ReverseProxyAuthEmail" header
|
||||
// of the request and returns the corresponding user object for that email.
|
||||
// Verification of header data is not performed as it should have already been done by
|
||||
// the reverse proxy.
|
||||
// If an email is available in the "setting.ReverseProxyAuthEmail" header an existing
|
||||
// user object is returned (populated with the email found in header).
|
||||
// Returns nil if header is empty or if "setting.EnableReverseProxyEmail" is disabled.
|
||||
func (r *ReverseProxy) getUserFromAuthEmail(req *http.Request) *user_model.User {
|
||||
if !setting.Service.EnableReverseProxyEmail {
|
||||
return nil
|
||||
}
|
||||
email := r.getEmail(req)
|
||||
if len(email) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Trace("ReverseProxy Authorization: Found email: %s", email)
|
||||
|
||||
user, err := user_model.GetUserByEmail(req.Context(), email)
|
||||
if err != nil {
|
||||
// Do not allow auto-registration, we don't have a username here
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("GetUserByEmail: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// Verify attempts to load a user object based on headers sent by the reverse proxy.
|
||||
// First it will attempt to load it based on the username (see docs for getUserFromAuthUser),
|
||||
// and failing that it will attempt to load it based on the email (see docs for getUserFromAuthEmail).
|
||||
// Returns nil if the headers are empty or the user is not found.
|
||||
func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
user, err := r.getUserFromAuthUser(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
user = r.getUserFromAuthEmail(req)
|
||||
if user == nil {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
}
|
||||
|
||||
if r.CreateSession {
|
||||
if sess != nil && (sess.Get("uid") == nil || sess.Get("uid").(int64) != user.ID) {
|
||||
handleSignIn(w, req, sess, user)
|
||||
}
|
||||
}
|
||||
store.GetData()["IsReverseProxy"] = true
|
||||
|
||||
log.Trace("ReverseProxy Authorization: Logged in user %-v", user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// isAutoRegisterAllowed checks if EnableReverseProxyAutoRegister setting is true
|
||||
func (r *ReverseProxy) isAutoRegisterAllowed() bool {
|
||||
return setting.Service.EnableReverseProxyAutoRegister
|
||||
}
|
||||
|
||||
// newUser creates a new user object for the purpose of automatic registration
|
||||
// and populates its name and email with the information present in request headers.
|
||||
func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
|
||||
username := r.getUserName(req)
|
||||
if len(username) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
email := gouuid.New().String() + "@localhost"
|
||||
if setting.Service.EnableReverseProxyEmail {
|
||||
webAuthEmail := req.Header.Get(setting.ReverseProxyAuthEmail)
|
||||
if len(webAuthEmail) > 0 {
|
||||
email = webAuthEmail
|
||||
}
|
||||
}
|
||||
|
||||
var fullname string
|
||||
if setting.Service.EnableReverseProxyFullName {
|
||||
fullname = req.Header.Get(setting.ReverseProxyAuthFullName)
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
Name: username,
|
||||
Email: email,
|
||||
FullName: fullname,
|
||||
}
|
||||
|
||||
overwriteDefault := user_model.CreateUserOverwriteOptions{
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
|
||||
if err := user_model.CreateUser(req.Context(), user, &user_model.Meta{}, &overwriteDefault); err != nil {
|
||||
// FIXME: should I create a system notice?
|
||||
log.Error("CreateUser: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
var (
|
||||
_ Method = &Session{}
|
||||
)
|
||||
|
||||
// Session checks if there is a user uid stored in the session and returns the user
|
||||
// object for that uid.
|
||||
type Session struct{}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (s *Session) Name() string {
|
||||
return "session"
|
||||
}
|
||||
|
||||
// Verify checks if there is a user uid stored in the session and returns the user
|
||||
// object for that uid.
|
||||
// Returns nil if there is no user uid stored in the session.
|
||||
func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
if sess == nil {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
// Get user ID
|
||||
uid := sess.Get("uid")
|
||||
if uid == nil {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
log.Trace("Session Authorization: Found user[%d]", uid)
|
||||
|
||||
id, ok := uid.(int64)
|
||||
if !ok {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
// Get user object
|
||||
user, err := user_model.GetUserByID(req.Context(), id)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("GetUserByID: %v", err)
|
||||
// Return the err as-is to keep current signed-in session, in case the err is something like context.Canceled. Otherwise non-existing user (nil, nil) will make the caller clear the signed-in session.
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
log.Trace("Session Authorization: Logged in user %-v", user)
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/auth/source/smtp"
|
||||
|
||||
_ "gitea.dev/services/auth/source/db" // register the sources (and below)
|
||||
_ "gitea.dev/services/auth/source/ldap" // register the ldap source
|
||||
_ "gitea.dev/services/auth/source/pam" // register the pam source
|
||||
_ "gitea.dev/services/auth/source/sspi" // register the sspi source
|
||||
)
|
||||
|
||||
// UserSignIn validates user name and password.
|
||||
func UserSignIn(ctx context.Context, username, password string) (*user_model.User, *auth.Source, error) {
|
||||
var user *user_model.User
|
||||
isEmail := false
|
||||
if strings.Contains(username, "@") {
|
||||
isEmail = true
|
||||
emailAddress := user_model.EmailAddress{LowerEmail: strings.ToLower(strings.TrimSpace(username))}
|
||||
// check same email
|
||||
has, err := db.GetEngine(ctx).Get(&emailAddress)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if has {
|
||||
if !emailAddress.IsActivated {
|
||||
return nil, nil, user_model.ErrEmailAddressNotExist{
|
||||
Email: username,
|
||||
}
|
||||
}
|
||||
user = &user_model.User{ID: emailAddress.UID}
|
||||
}
|
||||
} else {
|
||||
trimmedUsername := strings.TrimSpace(username)
|
||||
if len(trimmedUsername) == 0 {
|
||||
return nil, nil, user_model.ErrUserNotExist{Name: username}
|
||||
}
|
||||
|
||||
user = &user_model.User{LowerName: strings.ToLower(trimmedUsername)}
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
hasUser, err := user_model.GetIndividualUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if hasUser {
|
||||
source, err := auth.GetSourceByID(ctx, user.LoginSource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !source.IsActive {
|
||||
return nil, nil, oauth2.ErrAuthSourceNotActivated
|
||||
}
|
||||
|
||||
authenticator, ok := source.Cfg.(PasswordAuthenticator)
|
||||
if !ok {
|
||||
return nil, nil, smtp.ErrUnsupportedLoginType
|
||||
}
|
||||
|
||||
user, err := authenticator.Authenticate(ctx, user, user.LoginName, password)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// WARN: DON'T check user.IsActive, that will be checked on reqSign so that
|
||||
// user could be hint to resend confirm email.
|
||||
if user.ProhibitLogin {
|
||||
return nil, nil, user_model.ErrUserProhibitLogin{UID: user.ID, Name: user.Name}
|
||||
}
|
||||
|
||||
return user, source, nil
|
||||
}
|
||||
}
|
||||
|
||||
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
|
||||
IsActive: optional.Some(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
if !source.IsActive {
|
||||
// don't try to authenticate non-active sources
|
||||
continue
|
||||
}
|
||||
|
||||
authenticator, ok := source.Cfg.(PasswordAuthenticator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
authUser, err := authenticator.Authenticate(ctx, nil, username, password)
|
||||
|
||||
if err == nil {
|
||||
if !authUser.ProhibitLogin {
|
||||
return authUser, source, nil
|
||||
}
|
||||
err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name}
|
||||
}
|
||||
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
log.Debug("Failed to login '%s' via '%s': %v", username, source.Name, err)
|
||||
} else {
|
||||
log.Warn("Failed to login '%s' via '%s': %v", username, source.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if isEmail {
|
||||
return nil, nil, user_model.ErrEmailAddressNotExist{Email: username}
|
||||
}
|
||||
|
||||
return nil, nil, user_model.ErrUserNotExist{Name: username}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
// DeleteSource deletes a AuthSource record in DB.
|
||||
func DeleteSource(ctx context.Context, source *auth.Source) error {
|
||||
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if count > 0 {
|
||||
return auth.ErrSourceInUse{
|
||||
ID: source.ID,
|
||||
}
|
||||
}
|
||||
|
||||
count, err = db.GetEngine(ctx).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if count > 0 {
|
||||
return auth.ErrSourceInUse{
|
||||
ID: source.ID,
|
||||
}
|
||||
}
|
||||
|
||||
if registerableSource, ok := source.Cfg.(auth.RegisterableSource); ok {
|
||||
if err := registerableSource.UnregisterSource(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package db_test
|
||||
|
||||
import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/db"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth.PasswordAuthenticator
|
||||
auth_model.Config
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &db.Source{}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrUserPasswordNotSet represents a "ErrUserPasswordNotSet" kind of error.
|
||||
type ErrUserPasswordNotSet struct {
|
||||
UID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func (err ErrUserPasswordNotSet) Error() string {
|
||||
return fmt.Sprintf("user's password isn't set [uid: %d, name: %s]", err.UID, err.Name)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrInvalidArgument error
|
||||
func (err ErrUserPasswordNotSet) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// ErrUserPasswordInvalid represents a "ErrUserPasswordInvalid" kind of error.
|
||||
type ErrUserPasswordInvalid struct {
|
||||
UID int64
|
||||
Name string
|
||||
}
|
||||
|
||||
func (err ErrUserPasswordInvalid) Error() string {
|
||||
return fmt.Sprintf("user's password is invalid [uid: %d, name: %s]", err.UID, err.Name)
|
||||
}
|
||||
|
||||
// Unwrap unwraps this error as a ErrInvalidArgument error
|
||||
func (err ErrUserPasswordInvalid) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// Authenticate authenticates the provided user against the DB
|
||||
func Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
|
||||
if user == nil {
|
||||
return nil, user_model.ErrUserNotExist{Name: login}
|
||||
}
|
||||
|
||||
if !user.IsPasswordSet() {
|
||||
return nil, ErrUserPasswordNotSet{UID: user.ID, Name: user.Name}
|
||||
} else if !user.ValidatePassword(password) {
|
||||
return nil, ErrUserPasswordInvalid{UID: user.ID, Name: user.Name}
|
||||
}
|
||||
|
||||
// Update password hash if server password hash algorithm have changed
|
||||
// Or update the password when the salt length doesn't match the current
|
||||
// recommended salt length, this in order to migrate user's salts to a more secure salt.
|
||||
if user.PasswdHashAlgo != setting.PasswordHashAlgo || len(user.Salt) != user_model.SaltByteLength*2 {
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := user_model.UpdateUserCols(ctx, user, "passwd", "passwd_hash_algo", "salt"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// WARN: DON'T check user.IsActive, that will be checked on reqSign so that
|
||||
// user could be hinted to resend confirm email.
|
||||
if user.ProhibitLogin {
|
||||
return nil, user_model.ErrUserProhibitLogin{
|
||||
UID: user.ID,
|
||||
Name: user.Name,
|
||||
}
|
||||
}
|
||||
|
||||
// attempting to login as a non-user account
|
||||
if user.Type != user_model.UserTypeIndividual {
|
||||
return nil, user_model.ErrUserProhibitLogin{
|
||||
UID: user.ID,
|
||||
Name: user.Name,
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
// Source is a password authentication service
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
}
|
||||
|
||||
// FromDB fills up an OAuth2Config from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDB exports the config to a byte slice to be saved into database (this method is just dummy and does nothing for DB source)
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Authenticate queries if login/password is valid against the PAM,
|
||||
// and create a local user if success when enabled.
|
||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
|
||||
return Authenticate(ctx, user, login, password)
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.NoType, &Source{})
|
||||
auth.RegisterTypeConfig(auth.Plain, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Gitea LDAP Authentication Module
|
||||
|
||||
## About
|
||||
|
||||
This authentication module attempts to authorize and authenticate a user
|
||||
against an LDAP server. It provides two methods of authentication: LDAP via
|
||||
BindDN, and LDAP simple authentication.
|
||||
|
||||
LDAP via BindDN functions like most LDAP authentication systems. First, it
|
||||
queries the LDAP server using a Bind DN and searches for the user that is
|
||||
attempting to sign in. If the user is found, the module attempts to bind to the
|
||||
server using the user's supplied credentials. If this succeeds, the user has
|
||||
been authenticated, and his account information is retrieved and passed to the
|
||||
Gogs login infrastructure.
|
||||
|
||||
LDAP simple authentication does not utilize a Bind DN. Instead, it binds
|
||||
directly with the LDAP server using the user's supplied credentials. If the bind
|
||||
succeeds and no filter rules out the user, the user is authenticated.
|
||||
|
||||
LDAP via BindDN is recommended for most users. By using a Bind DN, the server
|
||||
can perform authorization by restricting which entries the Bind DN account can
|
||||
read. Further, using a Bind DN with reduced permissions can reduce security risk
|
||||
in the face of application bugs.
|
||||
|
||||
## Usage
|
||||
|
||||
To use this module, add an LDAP authentication source via the Authentications
|
||||
section in the admin panel. Both the LDAP via BindDN and the simple auth LDAP
|
||||
share the following fields:
|
||||
|
||||
* Authorization Name **(required)**
|
||||
* A name to assign to the new method of authorization.
|
||||
|
||||
* Host **(required)**
|
||||
* The address where the LDAP server can be reached.
|
||||
* Example: mydomain.com
|
||||
|
||||
* Port **(required)**
|
||||
* The port to use when connecting to the server.
|
||||
* Example: 636
|
||||
|
||||
* Enable TLS Encryption (optional)
|
||||
* Whether to use TLS when connecting to the LDAP server.
|
||||
|
||||
* Admin Filter (optional)
|
||||
* An LDAP filter specifying if a user should be given administrator
|
||||
privileges. If a user accounts passes the filter, the user will be
|
||||
privileged as an administrator.
|
||||
* Example: (objectClass=adminAccount)
|
||||
|
||||
* First name attribute (optional)
|
||||
* The attribute of the user's LDAP record containing the user's first name.
|
||||
This will be used to populate their account information.
|
||||
* Example: givenName
|
||||
|
||||
* Surname attribute (optional)
|
||||
* The attribute of the user's LDAP record containing the user's surname This
|
||||
will be used to populate their account information.
|
||||
* Example: sn
|
||||
|
||||
* E-mail attribute **(required)**
|
||||
* The attribute of the user's LDAP record containing the user's email
|
||||
address. This will be used to populate their account information.
|
||||
* Example: mail
|
||||
|
||||
**LDAP via BindDN** adds the following fields:
|
||||
|
||||
* Bind DN (optional)
|
||||
* The DN to bind to the LDAP server with when searching for the user. This
|
||||
may be left blank to perform an anonymous search.
|
||||
* Example: cn=Search,dc=mydomain,dc=com
|
||||
|
||||
* Bind Password (optional)
|
||||
* The password for the Bind DN specified above, if any. _Note: The password
|
||||
is stored in plaintext at the server. As such, ensure that your Bind DN
|
||||
has as few privileges as possible._
|
||||
|
||||
* User Search Base **(required)**
|
||||
* The LDAP base at which user accounts will be searched for.
|
||||
* Example: ou=Users,dc=mydomain,dc=com
|
||||
|
||||
* User Filter **(required)**
|
||||
* An LDAP filter declaring how to find the user record that is attempting to
|
||||
authenticate. The '%[1]s' matching parameter will be substituted with the
|
||||
user's username.
|
||||
* Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
|
||||
|
||||
**LDAP using simple auth** adds the following fields:
|
||||
|
||||
* User DN **(required)**
|
||||
* A template to use as the user's DN. The `%s` matching parameter will be
|
||||
substituted with the user's username.
|
||||
* Example: cn=%s,ou=Users,dc=mydomain,dc=com
|
||||
* Example: uid=%s,ou=Users,dc=mydomain,dc=com
|
||||
|
||||
* User Search Base (optional)
|
||||
* The LDAP base at which user accounts will be searched for.
|
||||
* Example: ou=Users,dc=mydomain,dc=com
|
||||
|
||||
* User Filter **(required)**
|
||||
* An LDAP filter declaring when a user should be allowed to log in. The `%[1]s`
|
||||
matching parameter will be substituted with the user's username.
|
||||
* Example: (&(objectClass=posixAccount)(|(cn=%[1]s)(mail=%[1]s)))
|
||||
* Example: (&(objectClass=posixAccount)(|(uid=%[1]s)(mail=%[1]s)))
|
||||
|
||||
**Verify group membership in LDAP** uses the following fields:
|
||||
|
||||
* Group Search Base (optional)
|
||||
* The LDAP DN used for groups.
|
||||
* Example: ou=group,dc=mydomain,dc=com
|
||||
|
||||
* Group Name Filter (optional)
|
||||
* An LDAP filter declaring how to find valid groups in the above DN.
|
||||
* Example: (|(cn=gitea_users)(cn=admins))
|
||||
|
||||
* User Attribute in Group (optional)
|
||||
* The user attribute that is used to reference a user in the group object.
|
||||
* Example: uid if the group objects contains a member: bender and the user object contains a uid: bender.
|
||||
* Example: dn if the group object contains a member: uid=bender,ou=users,dc=planetexpress,dc=com.
|
||||
|
||||
* Group Attribute for User (optional)
|
||||
* The attribute of the group object that lists/contains the group members.
|
||||
* Example: memberUid or member
|
||||
|
||||
* Team group map (optional)
|
||||
* Automatically add users to Organization teams, depending on LDAP group memberships.
|
||||
* Note: this function only adds users to teams, it never removes users.
|
||||
* Example: {"cn=MyGroup,cn=groups,dc=example,dc=org": {"MyGiteaOrganization": ["MyGiteaTeam1", "MyGiteaTeam2", ...], ...}, ...}
|
||||
|
||||
* Team group map removal (optional)
|
||||
* If set to true, users will be removed from teams if they are not members of the corresponding group.
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap_test
|
||||
|
||||
import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/ldap"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth.PasswordAuthenticator
|
||||
auth.SynchronizableSource
|
||||
auth_model.SSHKeyProvider
|
||||
auth_model.Config
|
||||
auth_model.SkipVerifiable
|
||||
auth_model.HasTLSer
|
||||
auth_model.UseTLSer
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &ldap.Source{}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
// SecurityProtocol protocol type
|
||||
type SecurityProtocol int
|
||||
|
||||
// Note: new type must be added at the end of list to maintain compatibility.
|
||||
const (
|
||||
SecurityProtocolUnencrypted SecurityProtocol = iota
|
||||
SecurityProtocolLDAPS
|
||||
SecurityProtocolStartTLS
|
||||
)
|
||||
|
||||
// String returns the name of the SecurityProtocol
|
||||
func (s SecurityProtocol) String() string {
|
||||
return SecurityProtocolNames[s]
|
||||
}
|
||||
|
||||
// Int returns the int value of the SecurityProtocol
|
||||
func (s SecurityProtocol) Int() int {
|
||||
return int(s)
|
||||
}
|
||||
|
||||
// SecurityProtocolNames contains the name of SecurityProtocol values.
|
||||
var SecurityProtocolNames = map[SecurityProtocol]string{
|
||||
SecurityProtocolUnencrypted: "Unencrypted",
|
||||
SecurityProtocolLDAPS: "LDAPS",
|
||||
SecurityProtocolStartTLS: "StartTLS",
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/secret"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// .____ ________ _____ __________
|
||||
// | | \______ \ / _ \\______ \
|
||||
// | | | | \ / /_\ \| ___/
|
||||
// | |___ | ` \/ | \ |
|
||||
// |_______ \/_______ /\____|__ /____|
|
||||
// \/ \/ \/
|
||||
|
||||
// Package ldap provide functions & structure to query a LDAP ldap directory
|
||||
// For now, it's mainly tested again an MS Active Directory service, see README.md for more information
|
||||
|
||||
// Source Basic LDAP authentication service
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
|
||||
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
|
||||
AttributeAvatar string
|
||||
SSHKeysAreVerified bool // true if SSH keys in LDAP are verified
|
||||
SearchPageSize uint32 // Search with paging page size
|
||||
Filter string // Query filter to validate entry
|
||||
AdminFilter string // Query filter to check if user is admin
|
||||
RestrictedFilter string // Query filter to check if user is restricted
|
||||
Enabled bool // if this source is disabled
|
||||
AllowDeactivateAll bool // Allow an empty search response to deactivate all users from this source
|
||||
GroupsEnabled bool // if the group checking is enabled
|
||||
GroupDN string // Group Search Base
|
||||
GroupFilter string // Group Name Filter
|
||||
GroupMemberUID string // Group Attribute containing array of UserUID
|
||||
GroupTeamMap string // Map LDAP groups to teams
|
||||
GroupTeamMapRemoval bool // Remove user from teams which are synchronized and user is not a member of the corresponding LDAP group
|
||||
UserUID string // User Attribute listed in Group
|
||||
}
|
||||
|
||||
// FromDB fills up a LDAPConfig from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error {
|
||||
err := json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if source.BindPasswordEncrypt != "" {
|
||||
source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt)
|
||||
if err != nil {
|
||||
log.Error("Unable to decrypt bind password for LDAP source, maybe SECRET_KEY is wrong: %v", err)
|
||||
}
|
||||
source.BindPasswordEncrypt = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToDB exports a LDAPConfig to a serialized format.
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
var err error
|
||||
source.BindPasswordEncrypt, err = secret.EncryptSecret(setting.SecretKey, source.BindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source.BindPassword = ""
|
||||
return json.Marshal(source)
|
||||
}
|
||||
|
||||
// SecurityProtocolName returns the name of configured security
|
||||
// protocol.
|
||||
func (source *Source) SecurityProtocolName() string {
|
||||
return SecurityProtocolNames[source.SecurityProtocol]
|
||||
}
|
||||
|
||||
// IsSkipVerify returns if SkipVerify is set
|
||||
func (source *Source) IsSkipVerify() bool {
|
||||
return source.SkipVerify
|
||||
}
|
||||
|
||||
// HasTLS returns if HasTLS
|
||||
func (source *Source) HasTLS() bool {
|
||||
return source.SecurityProtocol > SecurityProtocolUnencrypted
|
||||
}
|
||||
|
||||
// UseTLS returns if UseTLS
|
||||
func (source *Source) UseTLS() bool {
|
||||
return source.SecurityProtocol != SecurityProtocolUnencrypted
|
||||
}
|
||||
|
||||
// ProvidesSSHKeys returns if this source provides SSH Keys
|
||||
func (source *Source) ProvidesSSHKeys() bool {
|
||||
return strings.TrimSpace(source.AttributeSSHPublicKey) != ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.LDAP, &Source{})
|
||||
auth.RegisterTypeConfig(auth.DLDAP, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
auth_module "gitea.dev/modules/auth"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
source_service "gitea.dev/services/auth/source"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
// Authenticate queries if login/password is valid against the LDAP directory pool,
|
||||
// and create a local user if success when enabled.
|
||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
|
||||
loginName := userName
|
||||
if user != nil {
|
||||
loginName = user.LoginName
|
||||
}
|
||||
sr := source.SearchEntry(loginName, password, source.AuthSource.Type == auth.DLDAP)
|
||||
if sr == nil {
|
||||
// User not in LDAP, do nothing
|
||||
return nil, user_model.ErrUserNotExist{Name: loginName}
|
||||
}
|
||||
// Fallback.
|
||||
// FIXME: this fallback would cause problems when the "Username" attribute is not set and a user inputs their email.
|
||||
// In this case, the email would be used as the username, and will cause the "CreateUser" failure for the first login.
|
||||
if sr.Username == "" {
|
||||
if strings.Contains(userName, "@") {
|
||||
log.Error("No username in search result (Username Attribute is not set properly?), using email as username might cause problems")
|
||||
}
|
||||
sr.Username = userName
|
||||
}
|
||||
if sr.Mail == "" {
|
||||
sr.Mail = sr.Username + "@localhost.local"
|
||||
}
|
||||
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
|
||||
|
||||
// Update User admin flag if exist
|
||||
if isExist, err := user_model.IsUserExist(ctx, 0, sr.Username); err != nil {
|
||||
return nil, err
|
||||
} else if isExist {
|
||||
if user == nil {
|
||||
user, err = user_model.GetUserByName(ctx, sr.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if user != nil && !user.ProhibitLogin {
|
||||
opts := &user_service.UpdateOptions{}
|
||||
if source.AdminFilter != "" && user.IsAdmin != sr.IsAdmin {
|
||||
// Change existing admin flag only if AdminFilter option is set
|
||||
opts.IsAdmin = user_service.UpdateOptionFieldFromSync(sr.IsAdmin)
|
||||
}
|
||||
if !sr.IsAdmin && source.RestrictedFilter != "" && user.IsRestricted != sr.IsRestricted {
|
||||
// Change existing restricted flag only if RestrictedFilter option is set
|
||||
opts.IsRestricted = optional.Some(sr.IsRestricted)
|
||||
}
|
||||
if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||
if err := user_service.UpdateUser(ctx, user, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
|
||||
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user = &user_model.User{
|
||||
LowerName: strings.ToLower(sr.Username),
|
||||
Name: sr.Username,
|
||||
FullName: composeFullName(sr.Name, sr.Surname, sr.Username),
|
||||
Email: sr.Mail,
|
||||
LoginType: source.AuthSource.Type,
|
||||
LoginSource: source.AuthSource.ID,
|
||||
LoginName: userName,
|
||||
IsAdmin: sr.IsAdmin,
|
||||
}
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsRestricted: optional.Some(sr.IsRestricted),
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
|
||||
err := user_model.CreateUser(ctx, user, &user_model.Meta{}, overwriteDefault)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
|
||||
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
if source.AttributeAvatar != "" {
|
||||
_ = user_service.UploadAvatar(ctx, user, sr.Avatar)
|
||||
}
|
||||
}
|
||||
|
||||
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
|
||||
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
if err := source_service.SyncGroupsToTeams(ctx, user, sr.Groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// SearchResult : user data
|
||||
type SearchResult struct {
|
||||
Username string // Username
|
||||
Name string // Name
|
||||
Surname string // Surname
|
||||
Mail string // E-mail address
|
||||
SSHPublicKey []string // SSH Public Key
|
||||
IsAdmin bool // if user is administrator
|
||||
IsRestricted bool // if user is restricted
|
||||
LowerName string // LowerName
|
||||
Avatar []byte
|
||||
Groups container.Set[string]
|
||||
}
|
||||
|
||||
func (source *Source) sanitizedUserQuery(username string) (string, bool) {
|
||||
// See http://tools.ietf.org/search/rfc4515
|
||||
badCharacters := "\x00()*\\"
|
||||
if strings.ContainsAny(username, badCharacters) {
|
||||
log.Debug("'%s' contains invalid query characters. Aborting.", username)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fmt.Sprintf(source.Filter, username), true
|
||||
}
|
||||
|
||||
func (source *Source) sanitizedUserDN(username string) (string, bool) {
|
||||
// See http://tools.ietf.org/search/rfc4514: "special characters"
|
||||
badCharacters := "\x00()*\\,='\"#+;<>"
|
||||
if strings.ContainsAny(username, badCharacters) {
|
||||
log.Debug("'%s' contains invalid DN characters. Aborting.", username)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fmt.Sprintf(source.UserDN, username), true
|
||||
}
|
||||
|
||||
func (source *Source) sanitizedGroupFilter(group string) (string, bool) {
|
||||
// See http://tools.ietf.org/search/rfc4515
|
||||
badCharacters := "\x00*\\"
|
||||
if strings.ContainsAny(group, badCharacters) {
|
||||
log.Trace("Group filter invalid query characters: %s", group)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return group, true
|
||||
}
|
||||
|
||||
func (source *Source) sanitizedGroupDN(groupDn string) (string, bool) {
|
||||
// See http://tools.ietf.org/search/rfc4514: "special characters"
|
||||
badCharacters := "\x00()*\\'\"#+;<>"
|
||||
if strings.ContainsAny(groupDn, badCharacters) || strings.HasPrefix(groupDn, " ") || strings.HasSuffix(groupDn, " ") {
|
||||
log.Trace("Group DN contains invalid query characters: %s", groupDn)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return groupDn, true
|
||||
}
|
||||
|
||||
func (source *Source) findUserDN(l *ldap.Conn, name string) (string, bool) {
|
||||
log.Trace("Search for LDAP user: %s", name)
|
||||
|
||||
// A search for the user.
|
||||
userFilter, ok := source.sanitizedUserQuery(name)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
log.Trace("Searching for DN using filter %s and base %s", userFilter, source.UserBase)
|
||||
search := ldap.NewSearchRequest(
|
||||
source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0,
|
||||
false, userFilter, []string{}, nil)
|
||||
|
||||
// Ensure we found a user
|
||||
sr, err := l.Search(search)
|
||||
if err != nil || len(sr.Entries) < 1 {
|
||||
log.Debug("Failed search using filter[%s]: %v", userFilter, err)
|
||||
return "", false
|
||||
} else if len(sr.Entries) > 1 {
|
||||
log.Debug("Filter '%s' returned more than one user.", userFilter)
|
||||
return "", false
|
||||
}
|
||||
|
||||
userDN := sr.Entries[0].DN
|
||||
if userDN == "" {
|
||||
log.Error("LDAP search was successful, but found no DN!")
|
||||
return "", false
|
||||
}
|
||||
|
||||
return userDN, true
|
||||
}
|
||||
|
||||
func dial(source *Source) (*ldap.Conn, error) {
|
||||
log.Trace("Dialing LDAP with security protocol (%v) without verifying: %v", source.SecurityProtocol, source.SkipVerify)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: source.Host,
|
||||
InsecureSkipVerify: source.SkipVerify,
|
||||
}
|
||||
|
||||
hostPort := net.JoinHostPort(source.Host, strconv.Itoa(source.Port))
|
||||
if source.SecurityProtocol == SecurityProtocolLDAPS {
|
||||
return ldap.DialURL("ldaps://"+hostPort, ldap.DialWithTLSConfig(tlsConfig))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL("ldap://" + hostPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error during Dial: %w", err)
|
||||
}
|
||||
|
||||
if source.SecurityProtocol == SecurityProtocolStartTLS {
|
||||
if err = conn.StartTLS(tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("error during StartTLS: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func bindUser(l *ldap.Conn, userDN, passwd string) error {
|
||||
log.Trace("Binding with userDN: %s", userDN)
|
||||
err := l.Bind(userDN, passwd)
|
||||
if err != nil {
|
||||
log.Debug("LDAP auth. failed for %s, reason: %v", userDN, err)
|
||||
return err
|
||||
}
|
||||
log.Trace("Bound successfully with userDN: %s", userDN)
|
||||
return err
|
||||
}
|
||||
|
||||
func checkAdmin(l *ldap.Conn, ls *Source, userDN string) bool {
|
||||
if ls.AdminFilter == "" {
|
||||
return false
|
||||
}
|
||||
log.Trace("Checking admin with filter %s and base %s", ls.AdminFilter, userDN)
|
||||
search := ldap.NewSearchRequest(
|
||||
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.AdminFilter,
|
||||
[]string{ls.AttributeName},
|
||||
nil)
|
||||
|
||||
sr, err := l.Search(search)
|
||||
|
||||
if err != nil {
|
||||
log.Error("LDAP Admin Search with filter %s for %s failed unexpectedly! (%v)", ls.AdminFilter, userDN, err)
|
||||
} else if len(sr.Entries) < 1 {
|
||||
log.Trace("LDAP Admin Search found no matching entries.")
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkRestricted(l *ldap.Conn, ls *Source, userDN string) bool {
|
||||
if ls.RestrictedFilter == "" {
|
||||
return false
|
||||
}
|
||||
if ls.RestrictedFilter == "*" {
|
||||
return true
|
||||
}
|
||||
log.Trace("Checking restricted with filter %s and base %s", ls.RestrictedFilter, userDN)
|
||||
search := ldap.NewSearchRequest(
|
||||
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ls.RestrictedFilter,
|
||||
[]string{ls.AttributeName},
|
||||
nil)
|
||||
|
||||
sr, err := l.Search(search)
|
||||
|
||||
if err != nil {
|
||||
log.Error("LDAP Restrictred Search with filter %s for %s failed unexpectedly! (%v)", ls.RestrictedFilter, userDN, err)
|
||||
} else if len(sr.Entries) < 1 {
|
||||
log.Trace("LDAP Restricted Search found no matching entries.")
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// List all group memberships of a user
|
||||
func (source *Source) listLdapGroupMemberships(l *ldap.Conn, uid string, applyGroupFilter bool) container.Set[string] {
|
||||
ldapGroups := make(container.Set[string])
|
||||
|
||||
groupFilter, ok := source.sanitizedGroupFilter(source.GroupFilter)
|
||||
if !ok {
|
||||
return ldapGroups
|
||||
}
|
||||
|
||||
groupDN, ok := source.sanitizedGroupDN(source.GroupDN)
|
||||
if !ok {
|
||||
return ldapGroups
|
||||
}
|
||||
|
||||
var searchFilter string
|
||||
if applyGroupFilter && groupFilter != "" {
|
||||
searchFilter = fmt.Sprintf("(&(%s)(%s=%s))", groupFilter, source.GroupMemberUID, ldap.EscapeFilter(uid))
|
||||
} else {
|
||||
searchFilter = fmt.Sprintf("(%s=%s)", source.GroupMemberUID, ldap.EscapeFilter(uid))
|
||||
}
|
||||
result, err := l.Search(ldap.NewSearchRequest(
|
||||
groupDN,
|
||||
ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
searchFilter,
|
||||
[]string{},
|
||||
nil,
|
||||
))
|
||||
if err != nil {
|
||||
log.Error("Failed group search in LDAP with filter [%s]: %v", searchFilter, err)
|
||||
return ldapGroups
|
||||
}
|
||||
|
||||
for _, entry := range result.Entries {
|
||||
if entry.DN == "" {
|
||||
log.Error("LDAP search was successful, but found no DN!")
|
||||
continue
|
||||
}
|
||||
ldapGroups.Add(entry.DN)
|
||||
}
|
||||
|
||||
return ldapGroups
|
||||
}
|
||||
|
||||
func (source *Source) getUserAttributeListedInGroup(entry *ldap.Entry) string {
|
||||
if strings.EqualFold(source.UserUID, "dn") {
|
||||
return entry.DN
|
||||
}
|
||||
|
||||
return entry.GetAttributeValue(source.UserUID)
|
||||
}
|
||||
|
||||
// SearchEntry : search an LDAP source if an entry (name, passwd) is valid and in the specific filter
|
||||
func (source *Source) SearchEntry(name, passwd string, directBind bool) *SearchResult {
|
||||
if MockedSearchEntry != nil {
|
||||
return MockedSearchEntry(source, name, passwd, directBind)
|
||||
}
|
||||
return realSearchEntry(source, name, passwd, directBind)
|
||||
}
|
||||
|
||||
var MockedSearchEntry func(source *Source, name, passwd string, directBind bool) *SearchResult
|
||||
|
||||
func realSearchEntry(source *Source, name, passwd string, directBind bool) *SearchResult {
|
||||
// See https://tools.ietf.org/search/rfc4513#section-5.1.2
|
||||
if passwd == "" {
|
||||
log.Debug("Auth. failed for %s, password cannot be empty", name)
|
||||
return nil
|
||||
}
|
||||
l, err := dial(source)
|
||||
if err != nil {
|
||||
log.Error("LDAP Connect error, %s:%v", source.Host, err)
|
||||
source.Enabled = false
|
||||
return nil
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
var userDN string
|
||||
if directBind {
|
||||
log.Trace("LDAP will bind directly via UserDN template: %s", source.UserDN)
|
||||
|
||||
var ok bool
|
||||
userDN, ok = source.sanitizedUserDN(name)
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = bindUser(l, userDN, passwd)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if source.UserBase != "" {
|
||||
// not everyone has a CN compatible with input name so we need to find
|
||||
// the real userDN in that case
|
||||
|
||||
userDN, ok = source.findUserDN(l, name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Trace("LDAP will use BindDN.")
|
||||
|
||||
var found bool
|
||||
|
||||
if source.BindDN != "" && source.BindPassword != "" {
|
||||
err := l.Bind(source.BindDN, source.BindPassword)
|
||||
if err != nil {
|
||||
log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
|
||||
return nil
|
||||
}
|
||||
log.Trace("Bound as BindDN %s", source.BindDN)
|
||||
} else {
|
||||
log.Trace("Proceeding with anonymous LDAP search.")
|
||||
}
|
||||
|
||||
userDN, found = source.findUserDN(l, name)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !source.AttributesInBind {
|
||||
// binds user (checking password) before looking-up attributes in user context
|
||||
err = bindUser(l, userDN, passwd)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
userFilter, ok := source.sanitizedUserQuery(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
|
||||
isAttributeAvatarSet := strings.TrimSpace(source.AttributeAvatar) != ""
|
||||
|
||||
attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail}
|
||||
if strings.TrimSpace(source.UserUID) != "" {
|
||||
attribs = append(attribs, source.UserUID)
|
||||
}
|
||||
if isAttributeSSHPublicKeySet {
|
||||
attribs = append(attribs, source.AttributeSSHPublicKey)
|
||||
}
|
||||
if isAttributeAvatarSet {
|
||||
attribs = append(attribs, source.AttributeAvatar)
|
||||
}
|
||||
|
||||
log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v', '%v' with filter '%s' and base '%s'", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, source.UserUID, userFilter, userDN)
|
||||
search := ldap.NewSearchRequest(
|
||||
userDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
|
||||
attribs, nil)
|
||||
|
||||
sr, err := l.Search(search)
|
||||
if err != nil {
|
||||
log.Error("LDAP Search failed unexpectedly! (%v)", err)
|
||||
return nil
|
||||
} else if len(sr.Entries) < 1 {
|
||||
if directBind {
|
||||
log.Trace("User filter inhibited user login.")
|
||||
} else {
|
||||
log.Trace("LDAP Search found no matching entries.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var sshPublicKey []string
|
||||
var Avatar []byte
|
||||
|
||||
username := sr.Entries[0].GetAttributeValue(source.AttributeUsername)
|
||||
firstname := sr.Entries[0].GetAttributeValue(source.AttributeName)
|
||||
surname := sr.Entries[0].GetAttributeValue(source.AttributeSurname)
|
||||
mail := sr.Entries[0].GetAttributeValue(source.AttributeMail)
|
||||
|
||||
if isAttributeSSHPublicKeySet {
|
||||
sshPublicKey = sr.Entries[0].GetAttributeValues(source.AttributeSSHPublicKey)
|
||||
}
|
||||
|
||||
isAdmin := checkAdmin(l, source, userDN)
|
||||
|
||||
var isRestricted bool
|
||||
if !isAdmin {
|
||||
isRestricted = checkRestricted(l, source, userDN)
|
||||
}
|
||||
|
||||
if isAttributeAvatarSet {
|
||||
Avatar = sr.Entries[0].GetRawAttributeValue(source.AttributeAvatar)
|
||||
}
|
||||
|
||||
// Check group membership
|
||||
var usersLdapGroups container.Set[string]
|
||||
if source.GroupsEnabled {
|
||||
userAttributeListedInGroup := source.getUserAttributeListedInGroup(sr.Entries[0])
|
||||
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
|
||||
|
||||
if source.GroupFilter != "" && len(usersLdapGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !directBind && source.AttributesInBind {
|
||||
// binds user (checking password) after looking-up attributes in BindDN context
|
||||
err = bindUser(l, userDN, passwd)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return &SearchResult{
|
||||
LowerName: strings.ToLower(username),
|
||||
Username: username,
|
||||
Name: firstname,
|
||||
Surname: surname,
|
||||
Mail: mail,
|
||||
SSHPublicKey: sshPublicKey,
|
||||
IsAdmin: isAdmin,
|
||||
IsRestricted: isRestricted,
|
||||
Avatar: Avatar,
|
||||
Groups: usersLdapGroups,
|
||||
}
|
||||
}
|
||||
|
||||
// UsePagedSearch returns if need to use paged search
|
||||
func (source *Source) UsePagedSearch() bool {
|
||||
return source.SearchPageSize > 0
|
||||
}
|
||||
|
||||
// SearchEntries : search an LDAP source for all users matching userFilter
|
||||
func (source *Source) SearchEntries() ([]*SearchResult, error) {
|
||||
l, err := dial(source)
|
||||
if err != nil {
|
||||
log.Error("LDAP Connect error, %s:%v", source.Host, err)
|
||||
source.Enabled = false
|
||||
return nil, err
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if source.BindDN != "" && source.BindPassword != "" {
|
||||
err := l.Bind(source.BindDN, source.BindPassword)
|
||||
if err != nil {
|
||||
log.Debug("Failed to bind as BindDN[%s]: %v", source.BindDN, err)
|
||||
return nil, err
|
||||
}
|
||||
log.Trace("Bound as BindDN %s", source.BindDN)
|
||||
} else {
|
||||
log.Trace("Proceeding with anonymous LDAP search.")
|
||||
}
|
||||
|
||||
userFilter := fmt.Sprintf(source.Filter, "*")
|
||||
|
||||
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
|
||||
isAttributeAvatarSet := strings.TrimSpace(source.AttributeAvatar) != ""
|
||||
|
||||
attribs := []string{source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.UserUID}
|
||||
if isAttributeSSHPublicKeySet {
|
||||
attribs = append(attribs, source.AttributeSSHPublicKey)
|
||||
}
|
||||
if isAttributeAvatarSet {
|
||||
attribs = append(attribs, source.AttributeAvatar)
|
||||
}
|
||||
|
||||
log.Trace("Fetching attributes '%v', '%v', '%v', '%v', '%v', '%v' with filter %s and base %s", source.AttributeUsername, source.AttributeName, source.AttributeSurname, source.AttributeMail, source.AttributeSSHPublicKey, source.AttributeAvatar, userFilter, source.UserBase)
|
||||
search := ldap.NewSearchRequest(
|
||||
source.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, userFilter,
|
||||
attribs, nil)
|
||||
|
||||
var sr *ldap.SearchResult
|
||||
if source.UsePagedSearch() {
|
||||
sr, err = l.SearchWithPaging(search, source.SearchPageSize)
|
||||
} else {
|
||||
sr, err = l.Search(search)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("LDAP Search failed unexpectedly! (%v)", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*SearchResult, 0, len(sr.Entries))
|
||||
|
||||
for _, v := range sr.Entries {
|
||||
var usersLdapGroups container.Set[string]
|
||||
if source.GroupsEnabled {
|
||||
userAttributeListedInGroup := source.getUserAttributeListedInGroup(v)
|
||||
|
||||
if source.GroupFilter != "" {
|
||||
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, true)
|
||||
if len(usersLdapGroups) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
|
||||
usersLdapGroups = source.listLdapGroupMemberships(l, userAttributeListedInGroup, false)
|
||||
}
|
||||
}
|
||||
|
||||
user := &SearchResult{
|
||||
Username: v.GetAttributeValue(source.AttributeUsername),
|
||||
Name: v.GetAttributeValue(source.AttributeName),
|
||||
Surname: v.GetAttributeValue(source.AttributeSurname),
|
||||
Mail: v.GetAttributeValue(source.AttributeMail),
|
||||
IsAdmin: checkAdmin(l, source, v.DN),
|
||||
Groups: usersLdapGroups,
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
user.IsRestricted = checkRestricted(l, source, v.DN)
|
||||
}
|
||||
|
||||
if isAttributeSSHPublicKeySet {
|
||||
user.SSHPublicKey = v.GetAttributeValues(source.AttributeSSHPublicKey)
|
||||
}
|
||||
|
||||
if isAttributeAvatarSet {
|
||||
user.Avatar = v.GetRawAttributeValue(source.AttributeAvatar)
|
||||
}
|
||||
|
||||
user.LowerName = strings.ToLower(user.Username)
|
||||
|
||||
result = append(result, user)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
auth_module "gitea.dev/modules/auth"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
source_service "gitea.dev/services/auth/source"
|
||||
user_service "gitea.dev/services/user"
|
||||
)
|
||||
|
||||
// Sync causes this ldap source to synchronize its users with the db
|
||||
func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
|
||||
log.Trace("Doing: SyncExternalUsers[%s]", source.AuthSource.Name)
|
||||
|
||||
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
|
||||
var sshKeysNeedUpdate bool
|
||||
|
||||
// Find all users with this login type - FIXME: Should this be an iterator?
|
||||
users, err := user_model.GetUsersBySource(ctx, source.AuthSource)
|
||||
if err != nil {
|
||||
log.Error("SyncExternalUsers: %v", err)
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn("SyncExternalUsers: Cancelled before update of %s", source.AuthSource.Name)
|
||||
return db.ErrCancelledf("Before update of %s", source.AuthSource.Name)
|
||||
default:
|
||||
}
|
||||
|
||||
usernameUsers := make(map[string]*user_model.User, len(users))
|
||||
mailUsers := make(map[string]*user_model.User, len(users))
|
||||
keepActiveUsers := make(container.Set[int64])
|
||||
|
||||
for _, u := range users {
|
||||
usernameUsers[u.LowerName] = u
|
||||
mailUsers[strings.ToLower(u.Email)] = u
|
||||
}
|
||||
|
||||
sr, err := source.SearchEntries()
|
||||
if err != nil {
|
||||
log.Error("SyncExternalUsers LDAP source failure [%s], skipped", source.AuthSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(sr) == 0 {
|
||||
if !source.AllowDeactivateAll {
|
||||
log.Error("LDAP search found no entries but did not report an error. Refusing to deactivate all users")
|
||||
return nil
|
||||
}
|
||||
log.Warn("LDAP search found no entries but did not report an error. All users will be deactivated as per settings")
|
||||
}
|
||||
|
||||
orgCache := make(map[string]*organization.Organization)
|
||||
teamCache := make(map[string]*organization.Team)
|
||||
|
||||
groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, su := range sr {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", source.AuthSource.Name)
|
||||
// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
|
||||
if sshKeysNeedUpdate {
|
||||
err = asymkey_service.RewriteAllPublicKeys(ctx)
|
||||
if err != nil {
|
||||
log.Error("RewriteAllPublicKeys: %v", err)
|
||||
}
|
||||
}
|
||||
return db.ErrCancelledf("During update of %s before completed update of users", source.AuthSource.Name)
|
||||
default:
|
||||
}
|
||||
if su.Username == "" && su.Mail == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var usr *user_model.User
|
||||
if su.Username != "" {
|
||||
usr = usernameUsers[su.LowerName]
|
||||
}
|
||||
if usr == nil && su.Mail != "" {
|
||||
usr = mailUsers[strings.ToLower(su.Mail)]
|
||||
}
|
||||
|
||||
if usr != nil {
|
||||
keepActiveUsers.Add(usr.ID)
|
||||
} else if su.Username == "" {
|
||||
// we cannot create the user if su.Username is empty
|
||||
continue
|
||||
}
|
||||
|
||||
if su.Mail == "" {
|
||||
su.Mail = su.Username + "@localhost.local"
|
||||
}
|
||||
|
||||
fullName := composeFullName(su.Name, su.Surname, su.Username)
|
||||
// If no existing user found, create one
|
||||
if usr == nil {
|
||||
log.Trace("SyncExternalUsers[%s]: Creating user %s", source.AuthSource.Name, su.Username)
|
||||
|
||||
usr = &user_model.User{
|
||||
LowerName: su.LowerName,
|
||||
Name: su.Username,
|
||||
FullName: fullName,
|
||||
LoginType: source.AuthSource.Type,
|
||||
LoginSource: source.AuthSource.ID,
|
||||
LoginName: su.Username,
|
||||
Email: su.Mail,
|
||||
IsAdmin: su.IsAdmin,
|
||||
}
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsRestricted: optional.Some(su.IsRestricted),
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
|
||||
err = user_model.CreateUser(ctx, usr, &user_model.Meta{}, overwriteDefault)
|
||||
if err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.AuthSource.Name, su.Username, err)
|
||||
}
|
||||
|
||||
if err == nil && isAttributeSSHPublicKeySet {
|
||||
log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.AuthSource.Name, usr.Name)
|
||||
if asymkey_model.AddPublicKeysBySource(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
|
||||
sshKeysNeedUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil && source.AttributeAvatar != "" {
|
||||
_ = user_service.UploadAvatar(ctx, usr, su.Avatar)
|
||||
}
|
||||
} else if updateExisting {
|
||||
// Synchronize SSH Public Key if that attribute is set
|
||||
if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.AuthSource, su.SSHPublicKey, source.SSHKeysAreVerified) {
|
||||
sshKeysNeedUpdate = true
|
||||
}
|
||||
|
||||
// Check if user data has changed
|
||||
if (source.AdminFilter != "" && usr.IsAdmin != su.IsAdmin) ||
|
||||
(source.RestrictedFilter != "" && usr.IsRestricted != su.IsRestricted) ||
|
||||
!strings.EqualFold(usr.Email, su.Mail) ||
|
||||
usr.FullName != fullName ||
|
||||
!usr.IsActive {
|
||||
log.Trace("SyncExternalUsers[%s]: Updating user %s", source.AuthSource.Name, usr.Name)
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
FullName: optional.Some(fullName),
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
if source.AdminFilter != "" {
|
||||
opts.IsAdmin = user_service.UpdateOptionFieldFromSync(su.IsAdmin)
|
||||
}
|
||||
// Change existing restricted flag only if RestrictedFilter option is set
|
||||
if !su.IsAdmin && source.RestrictedFilter != "" {
|
||||
opts.IsRestricted = optional.Some(su.IsRestricted)
|
||||
}
|
||||
|
||||
if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.AuthSource.Name, usr.Name, err)
|
||||
}
|
||||
|
||||
if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.AuthSource.Name, usr.Name, su.Mail, err)
|
||||
}
|
||||
}
|
||||
|
||||
if source.AttributeAvatar != "" {
|
||||
if len(su.Avatar) > 0 && usr.IsUploadAvatarChanged(su.Avatar) {
|
||||
log.Trace("SyncExternalUsers[%s]: Uploading new avatar for %s", source.AuthSource.Name, usr.Name)
|
||||
_ = user_service.UploadAvatar(ctx, usr, su.Avatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synchronize LDAP groups with organization and team memberships
|
||||
if source.GroupsEnabled && (source.GroupTeamMap != "" || source.GroupTeamMapRemoval) {
|
||||
if err := source_service.SyncGroupsToTeamsCached(ctx, usr, su.Groups, groupTeamMapping, source.GroupTeamMapRemoval, orgCache, teamCache); err != nil {
|
||||
log.Error("SyncGroupsToTeamsCached: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
|
||||
if sshKeysNeedUpdate {
|
||||
err = asymkey_service.RewriteAllPublicKeys(ctx)
|
||||
if err != nil {
|
||||
log.Error("RewriteAllPublicKeys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", source.AuthSource.Name)
|
||||
return db.ErrCancelledf("During update of %s before delete users", source.AuthSource.Name)
|
||||
default:
|
||||
}
|
||||
|
||||
// Deactivate users not present in LDAP
|
||||
if updateExisting {
|
||||
for _, usr := range users {
|
||||
if keepActiveUsers.Contains(usr.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Trace("SyncExternalUsers[%s]: Deactivating user %s", source.AuthSource.Name, usr.Name)
|
||||
|
||||
opts := &user_service.UpdateOptions{
|
||||
IsActive: optional.Some(false),
|
||||
}
|
||||
if err := user_service.UpdateUser(ctx, usr, opts); err != nil {
|
||||
log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.AuthSource.Name, usr.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package ldap
|
||||
|
||||
// composeFullName composes a firstname surname or username
|
||||
func composeFullName(firstname, surname, username string) string {
|
||||
switch {
|
||||
case firstname == "" && surname == "":
|
||||
return username
|
||||
case firstname == "":
|
||||
return surname
|
||||
case surname == "":
|
||||
return firstname
|
||||
default:
|
||||
return firstname + " " + surname
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth_model.Config
|
||||
auth_model.RegisterableSource
|
||||
auth.PasswordAuthenticator
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &oauth2.Source{}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/markbates/goth/gothic"
|
||||
)
|
||||
|
||||
var gothRWMutex = sync.RWMutex{}
|
||||
|
||||
// ProviderHeaderKey is the HTTP header key
|
||||
const ProviderHeaderKey = "gitea-oauth2-provider"
|
||||
|
||||
// Init initializes the oauth source
|
||||
func Init(ctx context.Context) error {
|
||||
// Lock our mutex
|
||||
gothRWMutex.Lock()
|
||||
|
||||
gob.Register(&sessions.Session{}) // TODO: CHI-SESSION-GOB-REGISTER. FIXME: it seems to be an abuse, why the Session struct itself is stored in session store again?
|
||||
|
||||
gothic.Store = &SessionsStore{
|
||||
maxLength: int64(setting.OAuth2.MaxTokenLength),
|
||||
}
|
||||
|
||||
gothic.SetState = func(req *http.Request) string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
gothic.GetProviderName = func(req *http.Request) (string, error) {
|
||||
return req.Header.Get(ProviderHeaderKey), nil
|
||||
}
|
||||
|
||||
// Unlock our mutex
|
||||
gothRWMutex.Unlock()
|
||||
|
||||
return initOAuth2Sources(ctx)
|
||||
}
|
||||
|
||||
// ResetOAuth2 clears existing OAuth2 providers and loads them from DB
|
||||
func ResetOAuth2(ctx context.Context) error {
|
||||
ClearProviders()
|
||||
return initOAuth2Sources(ctx)
|
||||
}
|
||||
|
||||
// initOAuth2Sources is used to load and register all active OAuth2 providers
|
||||
func initOAuth2Sources(ctx context.Context) error {
|
||||
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
|
||||
IsActive: optional.Some(true),
|
||||
LoginType: auth.OAuth2,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range authSources {
|
||||
oauth2Source, ok := source.Cfg.(*Source)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
err := oauth2Source.RegisterSource()
|
||||
if err != nil {
|
||||
log.Error("Unable to register source: %s due to Error: %v.", source.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m, &unittest.TestOptions{})
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/providers/openidConnect"
|
||||
)
|
||||
|
||||
// Provider is an interface for describing a single OAuth2 provider
|
||||
type Provider interface {
|
||||
Name() string
|
||||
DisplayName() string
|
||||
IconHTML(size int) template.HTML
|
||||
CustomURLSettings() *CustomURLSettings
|
||||
SupportSSHPublicKey() bool
|
||||
}
|
||||
|
||||
// GothProviderCreator provides a function to create a goth.Provider
|
||||
type GothProviderCreator interface {
|
||||
CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error)
|
||||
}
|
||||
|
||||
// GothProvider is an interface for describing a single OAuth2 provider
|
||||
type GothProvider interface {
|
||||
Provider
|
||||
GothProviderCreator
|
||||
}
|
||||
|
||||
// AuthSourceProvider provides a provider for an AuthSource. Multiple auth sources could use the same registered GothProvider
|
||||
// So each auth source should have its own DisplayName and IconHTML for display.
|
||||
// The Name is the GothProvider's name, to help to find the GothProvider to sign in.
|
||||
// The DisplayName is the auth source config's name, site admin set it on the admin page, the IconURL can also be set there.
|
||||
type AuthSourceProvider struct {
|
||||
GothProvider
|
||||
sourceName, iconURL string
|
||||
}
|
||||
|
||||
func (p *AuthSourceProvider) Name() string {
|
||||
return p.GothProvider.Name()
|
||||
}
|
||||
|
||||
func (p *AuthSourceProvider) DisplayName() string {
|
||||
return p.sourceName
|
||||
}
|
||||
|
||||
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
|
||||
if p.iconURL != "" {
|
||||
img := fmt.Sprintf(`<img class="tw-object-contain" width="%d" height="%d" src="%s" alt="%s">`,
|
||||
size,
|
||||
size,
|
||||
html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
|
||||
)
|
||||
return template.HTML(img)
|
||||
}
|
||||
return p.GothProvider.IconHTML(size)
|
||||
}
|
||||
|
||||
// Providers contains the map of registered OAuth2 providers in Gitea (based on goth)
|
||||
// key is used to map the OAuth2Provider with the goth provider type (also in AuthSource.OAuth2Config.Provider)
|
||||
// value is used to store display data
|
||||
var gothProviders = map[string]GothProvider{}
|
||||
|
||||
func isAzureProvider(name string) bool {
|
||||
return name == "azuread" || name == "microsoftonline" || name == "azureadv2"
|
||||
}
|
||||
|
||||
// RegisterGothProvider registers a GothProvider
|
||||
func RegisterGothProvider(provider GothProvider) {
|
||||
if _, has := gothProviders[provider.Name()]; has {
|
||||
log.Fatal("Duplicate oauth2provider type provided: %s", provider.Name())
|
||||
}
|
||||
gothProviders[provider.Name()] = provider
|
||||
}
|
||||
|
||||
// getExistingAzureADAuthSources returns a list of Azure AD provider names that are already configured
|
||||
func getExistingAzureADAuthSources(ctx context.Context) ([]string, error) {
|
||||
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
|
||||
LoginType: auth.OAuth2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var existingAzureProviders []string
|
||||
for _, source := range authSources {
|
||||
if oauth2Cfg, ok := source.Cfg.(*Source); ok {
|
||||
if isAzureProvider(oauth2Cfg.Provider) {
|
||||
existingAzureProviders = append(existingAzureProviders, oauth2Cfg.Provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
return existingAzureProviders, nil
|
||||
}
|
||||
|
||||
// GetSupportedOAuth2Providers returns the list of supported OAuth2 providers with context for filtering
|
||||
// key is used as technical name (like in the callbackURL)
|
||||
// values to display
|
||||
// Note: Azure AD providers (azuread, microsoftonline, azureadv2) are filtered out
|
||||
// unless they already exist in the system to encourage use of OpenID Connect
|
||||
func GetSupportedOAuth2Providers(ctx context.Context) []Provider {
|
||||
providers := make([]Provider, 0, len(gothProviders))
|
||||
existingAzureSources, err := getExistingAzureADAuthSources(ctx)
|
||||
if err != nil {
|
||||
log.Error("Failed to get existing OAuth2 auth sources: %v", err)
|
||||
}
|
||||
|
||||
for _, provider := range gothProviders {
|
||||
if isAzureProvider(provider.Name()) && !slices.Contains(existingAzureSources, provider.Name()) {
|
||||
continue
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
sort.Slice(providers, func(i, j int) bool {
|
||||
return providers[i].Name() < providers[j].Name()
|
||||
})
|
||||
return providers
|
||||
}
|
||||
|
||||
func CreateProviderFromSource(source *auth.Source) (Provider, error) {
|
||||
oauth2Cfg, ok := source.Cfg.(*Source)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid OAuth2 source config: %v", oauth2Cfg)
|
||||
}
|
||||
gothProv := gothProviders[oauth2Cfg.Provider]
|
||||
return &AuthSourceProvider{GothProvider: gothProv, sourceName: source.Name, iconURL: oauth2Cfg.IconURL}, nil
|
||||
}
|
||||
|
||||
// GetOAuth2Providers returns the list of configured OAuth2 providers
|
||||
func GetOAuth2Providers(ctx context.Context, isActive optional.Option[bool]) ([]Provider, error) {
|
||||
authSources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
|
||||
IsActive: isActive,
|
||||
LoginType: auth.OAuth2,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
providers := make([]Provider, 0, len(authSources))
|
||||
for _, source := range authSources {
|
||||
provider, err := CreateProviderFromSource(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
|
||||
sort.Slice(providers, func(i, j int) bool {
|
||||
return providers[i].Name() < providers[j].Name()
|
||||
})
|
||||
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
// RegisterProviderWithGothic register a OAuth2 provider in goth lib
|
||||
func RegisterProviderWithGothic(providerName string, source *Source) error {
|
||||
provider, err := createProvider(providerName, source)
|
||||
|
||||
if err == nil && provider != nil {
|
||||
gothRWMutex.Lock()
|
||||
defer gothRWMutex.Unlock()
|
||||
|
||||
goth.UseProviders(provider)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveProviderFromGothic removes the given OAuth2 provider from the goth lib
|
||||
func RemoveProviderFromGothic(providerName string) {
|
||||
gothRWMutex.Lock()
|
||||
defer gothRWMutex.Unlock()
|
||||
|
||||
delete(goth.GetProviders(), providerName)
|
||||
}
|
||||
|
||||
// ClearProviders clears all OAuth2 providers from the goth lib
|
||||
func ClearProviders() {
|
||||
gothRWMutex.Lock()
|
||||
defer gothRWMutex.Unlock()
|
||||
|
||||
goth.ClearProviders()
|
||||
}
|
||||
|
||||
// GetOIDCEndSessionEndpoint returns the OIDC end_session_endpoint for the
|
||||
// given provider name. Returns "" if the provider is not OIDC or doesn't
|
||||
// advertise an end_session_endpoint in its discovery document.
|
||||
func GetOIDCEndSessionEndpoint(providerName string) string {
|
||||
gothRWMutex.RLock()
|
||||
defer gothRWMutex.RUnlock()
|
||||
|
||||
provider, ok := goth.GetProviders()[providerName]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
oidcProvider, ok := provider.(*openidConnect.Provider)
|
||||
if !ok || oidcProvider.OpenIDConfig == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return oidcProvider.OpenIDConfig.EndSessionEndpoint
|
||||
}
|
||||
|
||||
var ErrAuthSourceNotActivated = errors.New("auth source is not activated")
|
||||
|
||||
// used to create different types of goth providers
|
||||
func createProvider(providerName string, source *Source) (goth.Provider, error) {
|
||||
callbackURL := setting.AppURL + "user/oauth2/" + url.PathEscape(providerName) + "/callback"
|
||||
|
||||
var provider goth.Provider
|
||||
var err error
|
||||
|
||||
p, ok := gothProviders[source.Provider]
|
||||
if !ok {
|
||||
return nil, ErrAuthSourceNotActivated
|
||||
}
|
||||
|
||||
provider, err = p.CreateGothProvider(providerName, callbackURL, source)
|
||||
if err != nil {
|
||||
return provider, err
|
||||
}
|
||||
|
||||
// always set the name if provider is created so we can support multiple setups of 1 provider
|
||||
if provider != nil {
|
||||
provider.SetName(providerName)
|
||||
}
|
||||
|
||||
return provider, err
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/svg"
|
||||
)
|
||||
|
||||
// BaseProvider represents a common base for Provider
|
||||
type BaseProvider struct {
|
||||
name string
|
||||
displayName string
|
||||
|
||||
// TODO: maybe some providers also support SSH public keys, then they can set this to true
|
||||
supportSSHPublicKey bool
|
||||
}
|
||||
|
||||
func (b *BaseProvider) SupportSSHPublicKey() bool {
|
||||
return b.supportSSHPublicKey
|
||||
}
|
||||
|
||||
// Name provides the technical name for this provider
|
||||
func (b *BaseProvider) Name() string {
|
||||
return b.name
|
||||
}
|
||||
|
||||
// DisplayName returns the friendly name for this provider
|
||||
func (b *BaseProvider) DisplayName() string {
|
||||
return b.displayName
|
||||
}
|
||||
|
||||
// IconHTML returns icon HTML for this provider
|
||||
func (b *BaseProvider) IconHTML(size int) template.HTML {
|
||||
svgName := "gitea-" + b.name
|
||||
switch b.name {
|
||||
case "gplus":
|
||||
svgName = "gitea-google"
|
||||
case "github":
|
||||
svgName = "octicon-mark-github"
|
||||
}
|
||||
svgHTML := svg.RenderHTML(svgName, size)
|
||||
if svgHTML == "" {
|
||||
log.Error("No SVG icon for oauth2 provider %q", b.name)
|
||||
svgHTML = svg.RenderHTML("gitea-openid", size)
|
||||
}
|
||||
return svgHTML
|
||||
}
|
||||
|
||||
// CustomURLSettings returns the custom url settings for this provider
|
||||
func (b *BaseProvider) CustomURLSettings() *CustomURLSettings {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ Provider = &BaseProvider{}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/providers/azureadv2"
|
||||
"github.com/markbates/goth/providers/gitea"
|
||||
"github.com/markbates/goth/providers/github"
|
||||
"github.com/markbates/goth/providers/gitlab"
|
||||
"github.com/markbates/goth/providers/mastodon"
|
||||
"github.com/markbates/goth/providers/nextcloud"
|
||||
)
|
||||
|
||||
// CustomProviderNewFn creates a goth.Provider using a custom url mapping
|
||||
type CustomProviderNewFn func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error)
|
||||
|
||||
// CustomProvider is a GothProvider that has CustomURL features
|
||||
type CustomProvider struct {
|
||||
BaseProvider
|
||||
customURLSettings *CustomURLSettings
|
||||
newFn CustomProviderNewFn
|
||||
}
|
||||
|
||||
// CustomURLSettings returns the CustomURLSettings for this provider
|
||||
func (c *CustomProvider) CustomURLSettings() *CustomURLSettings {
|
||||
return c.customURLSettings
|
||||
}
|
||||
|
||||
// CreateGothProvider creates a GothProvider from this Provider
|
||||
func (c *CustomProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
|
||||
custom := c.customURLSettings.OverrideWith(source.CustomURLMapping)
|
||||
|
||||
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, custom, source.Scopes)
|
||||
}
|
||||
|
||||
// NewCustomProvider is a constructor function for custom providers
|
||||
func NewCustomProvider(name, displayName string, customURLSetting *CustomURLSettings, newFn CustomProviderNewFn) *CustomProvider {
|
||||
return &CustomProvider{
|
||||
BaseProvider: BaseProvider{
|
||||
name: name,
|
||||
displayName: displayName,
|
||||
},
|
||||
customURLSettings: customURLSetting,
|
||||
newFn: newFn,
|
||||
}
|
||||
}
|
||||
|
||||
var _ GothProvider = &CustomProvider{}
|
||||
|
||||
func init() {
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"github", "GitHub", &CustomURLSettings{
|
||||
TokenURL: availableAttribute(github.TokenURL),
|
||||
AuthURL: availableAttribute(github.AuthURL),
|
||||
ProfileURL: availableAttribute(github.ProfileURL),
|
||||
EmailURL: availableAttribute(github.EmailURL),
|
||||
},
|
||||
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
if setting.OAuth2Client.EnableAutoRegistration {
|
||||
scopes = append(scopes, "user:email")
|
||||
}
|
||||
return github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...), nil
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"gitlab", "GitLab", &CustomURLSettings{
|
||||
AuthURL: availableAttribute(gitlab.AuthURL),
|
||||
TokenURL: availableAttribute(gitlab.TokenURL),
|
||||
ProfileURL: availableAttribute(gitlab.ProfileURL),
|
||||
}, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
scopes = append(scopes, "read_user")
|
||||
return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"gitea", "Gitea", &CustomURLSettings{
|
||||
TokenURL: requiredAttribute(gitea.TokenURL),
|
||||
AuthURL: requiredAttribute(gitea.AuthURL),
|
||||
ProfileURL: requiredAttribute(gitea.ProfileURL),
|
||||
},
|
||||
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"nextcloud", "Nextcloud", &CustomURLSettings{
|
||||
TokenURL: requiredAttribute(nextcloud.TokenURL),
|
||||
AuthURL: requiredAttribute(nextcloud.AuthURL),
|
||||
ProfileURL: requiredAttribute(nextcloud.ProfileURL),
|
||||
},
|
||||
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"mastodon", "Mastodon", &CustomURLSettings{
|
||||
AuthURL: requiredAttribute(mastodon.InstanceURL),
|
||||
},
|
||||
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewCustomProvider(
|
||||
"azureadv2", "Azure AD v2", &CustomURLSettings{
|
||||
Tenant: requiredAttribute("organizations"),
|
||||
},
|
||||
func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) {
|
||||
azureScopes := make([]azureadv2.ScopeType, len(scopes))
|
||||
for i, scope := range scopes {
|
||||
azureScopes[i] = azureadv2.ScopeType(scope)
|
||||
}
|
||||
|
||||
return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{
|
||||
Tenant: azureadv2.TenantType(custom.Tenant),
|
||||
Scopes: azureScopes,
|
||||
}), nil
|
||||
},
|
||||
))
|
||||
|
||||
RegisterGothProvider(&AwsCognitoProvider{})
|
||||
}
|
||||
|
||||
const ProviderNameAwsCognito = "aws-cognito"
|
||||
|
||||
// AwsCognitoProvider is a GothProvider for AWS Cognito (based on OpenID Connect)
|
||||
type AwsCognitoProvider struct {
|
||||
OpenIDProvider
|
||||
}
|
||||
|
||||
// Name provides the technical name for this provider
|
||||
func (c *AwsCognitoProvider) Name() string {
|
||||
return ProviderNameAwsCognito
|
||||
}
|
||||
|
||||
// DisplayName returns the friendly name for this provider
|
||||
func (c *AwsCognitoProvider) DisplayName() string {
|
||||
return "AWS Cognito"
|
||||
}
|
||||
|
||||
var _ GothProvider = &AwsCognitoProvider{}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/providers/openidConnect"
|
||||
)
|
||||
|
||||
// OpenIDProvider is a GothProvider for OpenID
|
||||
type OpenIDProvider struct{}
|
||||
|
||||
func (o *OpenIDProvider) SupportSSHPublicKey() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Name provides the technical name for this provider
|
||||
func (o *OpenIDProvider) Name() string {
|
||||
return "openidConnect"
|
||||
}
|
||||
|
||||
// DisplayName returns the friendly name for this provider
|
||||
func (o *OpenIDProvider) DisplayName() string {
|
||||
return "OpenID Connect"
|
||||
}
|
||||
|
||||
// IconHTML returns icon HTML for this provider
|
||||
func (o *OpenIDProvider) IconHTML(size int) template.HTML {
|
||||
return svg.RenderHTML("gitea-openid", size)
|
||||
}
|
||||
|
||||
// CreateGothProvider creates a GothProvider from this Provider
|
||||
func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
|
||||
scopes := setting.OAuth2Client.OpenIDConnectScopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = append(scopes, source.Scopes...)
|
||||
}
|
||||
|
||||
provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
|
||||
return nil, err
|
||||
}
|
||||
if source.ExternalIDClaim != "" {
|
||||
// UserIdClaims is a fallback list; goth returns the first non-empty matching claim.
|
||||
// A single entry is sufficient because the admin explicitly chooses one claim (e.g. "oid" for Azure AD).
|
||||
provider.UserIdClaims = []string{source.ExternalIDClaim}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// CustomURLSettings returns the custom url settings for this provider
|
||||
func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ GothProvider = &OpenIDProvider{}
|
||||
|
||||
func init() {
|
||||
RegisterGothProvider(&OpenIDProvider{})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/providers/azuread"
|
||||
"github.com/markbates/goth/providers/bitbucket"
|
||||
"github.com/markbates/goth/providers/discord"
|
||||
"github.com/markbates/goth/providers/dropbox"
|
||||
"github.com/markbates/goth/providers/facebook"
|
||||
"github.com/markbates/goth/providers/google"
|
||||
"github.com/markbates/goth/providers/microsoftonline"
|
||||
"github.com/markbates/goth/providers/twitter"
|
||||
"github.com/markbates/goth/providers/yandex"
|
||||
)
|
||||
|
||||
// SimpleProviderNewFn create goth.Providers without custom url features
|
||||
type SimpleProviderNewFn func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider
|
||||
|
||||
// SimpleProvider is a GothProvider which does not have custom url features
|
||||
type SimpleProvider struct {
|
||||
BaseProvider
|
||||
scopes []string
|
||||
newFn SimpleProviderNewFn
|
||||
}
|
||||
|
||||
// CreateGothProvider creates a GothProvider from this Provider
|
||||
func (c *SimpleProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
|
||||
scopes := make([]string, len(c.scopes)+len(source.Scopes))
|
||||
copy(scopes, c.scopes)
|
||||
copy(scopes[len(c.scopes):], source.Scopes)
|
||||
return c.newFn(source.ClientID, source.ClientSecret, callbackURL, scopes...), nil
|
||||
}
|
||||
|
||||
// NewSimpleProvider is a constructor function for simple providers
|
||||
func NewSimpleProvider(name, displayName string, scopes []string, newFn SimpleProviderNewFn) *SimpleProvider {
|
||||
return &SimpleProvider{
|
||||
BaseProvider: BaseProvider{
|
||||
name: name,
|
||||
displayName: displayName,
|
||||
},
|
||||
scopes: scopes,
|
||||
newFn: newFn,
|
||||
}
|
||||
}
|
||||
|
||||
var _ GothProvider = &SimpleProvider{}
|
||||
|
||||
func init() {
|
||||
RegisterGothProvider(
|
||||
NewSimpleProvider("bitbucket", "Bitbucket", []string{"account"},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return bitbucket.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
RegisterGothProvider(
|
||||
NewSimpleProvider("dropbox", "Dropbox", nil,
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return dropbox.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewSimpleProvider("facebook", "Facebook", nil,
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return facebook.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
// named gplus due to legacy gplus -> google migration (Google killed Google+). This ensures old connections still work
|
||||
RegisterGothProvider(NewSimpleProvider("gplus", "Google", []string{"email"},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
if setting.OAuth2Client.UpdateAvatar || setting.OAuth2Client.EnableAutoRegistration {
|
||||
scopes = append(scopes, "profile")
|
||||
}
|
||||
return google.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewSimpleProvider("twitter", "Twitter", nil,
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return twitter.New(clientKey, secret, callbackURL)
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewSimpleProvider("discord", "Discord", []string{discord.ScopeIdentify, discord.ScopeEmail},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return discord.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
// See https://tech.yandex.com/passport/doc/dg/reference/response-docpage/
|
||||
RegisterGothProvider(NewSimpleProvider("yandex", "Yandex", []string{"login:email", "login:info", "login:avatar"},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return yandex.New(clientKey, secret, callbackURL, scopes...)
|
||||
}))
|
||||
|
||||
RegisterGothProvider(NewSimpleProvider(
|
||||
"azuread", "Azure AD", nil,
|
||||
func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return azuread.New(clientID, secret, callbackURL, nil, scopes...)
|
||||
},
|
||||
))
|
||||
|
||||
RegisterGothProvider(NewSimpleProvider(
|
||||
"microsoftonline", "Microsoft Online", nil,
|
||||
func(clientID, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return microsoftonline.New(clientID, secret, callbackURL, scopes...)
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type fakeProvider struct{}
|
||||
|
||||
func (p *fakeProvider) Name() string {
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (p *fakeProvider) SetName(name string) {}
|
||||
|
||||
func (p *fakeProvider) BeginAuth(state string) (goth.Session, error) {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
func (p *fakeProvider) UnmarshalSession(string) (goth.Session, error) {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
func (p *fakeProvider) FetchUser(goth.Session) (goth.User, error) {
|
||||
return goth.User{}, nil
|
||||
}
|
||||
|
||||
func (p *fakeProvider) Debug(bool) {
|
||||
}
|
||||
|
||||
func (p *fakeProvider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
|
||||
switch refreshToken {
|
||||
case "expired":
|
||||
return nil, &oauth2.RetrieveError{
|
||||
ErrorCode: "invalid_grant",
|
||||
}
|
||||
default:
|
||||
return &oauth2.Token{
|
||||
AccessToken: "token",
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: "refresh",
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fakeProvider) RefreshTokenAvailable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterGothProvider(
|
||||
NewSimpleProvider("fake", "Fake", []string{"account"},
|
||||
func(clientKey, secret, callbackURL string, scopes ...string) goth.Provider {
|
||||
return &fakeProvider{}
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
)
|
||||
|
||||
// Source holds configuration for the OAuth2 login source.
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
|
||||
Provider string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
OpenIDConnectAutoDiscoveryURL string
|
||||
CustomURLMapping *CustomURLMapping
|
||||
IconURL string
|
||||
|
||||
Scopes []string
|
||||
RequiredClaimName string
|
||||
RequiredClaimValue string
|
||||
GroupClaimName string
|
||||
AdminGroup string
|
||||
GroupTeamMap string
|
||||
GroupTeamMapRemoval bool
|
||||
RestrictedGroup string
|
||||
|
||||
SSHPublicKeyClaimName string
|
||||
FullNameClaimName string
|
||||
ExternalIDClaim string
|
||||
}
|
||||
|
||||
// FromDB fills up an OAuth2Config from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||
}
|
||||
|
||||
// ToDB exports an OAuth2Config to a serialized format.
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
return json.Marshal(source)
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.OAuth2, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/services/auth/source/db"
|
||||
)
|
||||
|
||||
// Authenticate falls back to the db authenticator
|
||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, login, password string) (*user_model.User, error) {
|
||||
return db.Authenticate(ctx, user, login, password)
|
||||
}
|
||||
|
||||
// NB: Oauth2 does not implement LocalTwoFASkipper for password authentication
|
||||
// as its password authentication drops to db authentication
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
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.AuthSource.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)
|
||||
|
||||
gothRWMutex.RLock()
|
||||
defer gothRWMutex.RUnlock()
|
||||
|
||||
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.AuthSource.Name)
|
||||
|
||||
gothRWMutex.RLock()
|
||||
defer gothRWMutex.RUnlock()
|
||||
|
||||
user, err := gothic.CompleteUserAuth(response, request)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
// Name returns the provider name of this source
|
||||
func (source *Source) Name() string {
|
||||
return source.Provider
|
||||
}
|
||||
|
||||
// DisplayName returns the display name of this source
|
||||
func (source *Source) DisplayName() string {
|
||||
provider, has := gothProviders[source.Provider]
|
||||
if !has {
|
||||
return source.Provider
|
||||
}
|
||||
return provider.DisplayName()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// RegisterSource causes an OAuth2 configuration to be registered
|
||||
func (source *Source) RegisterSource() error {
|
||||
err := RegisterProviderWithGothic(source.AuthSource.Name, source)
|
||||
return wrapOpenIDConnectInitializeError(err, source.AuthSource.Name, source)
|
||||
}
|
||||
|
||||
// UnregisterSource causes an OAuth2 configuration to be unregistered
|
||||
func (source *Source) UnregisterSource() error {
|
||||
RemoveProviderFromGothic(source.AuthSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrOpenIDConnectInitialize represents a "OpenIDConnectInitialize" kind of error.
|
||||
type ErrOpenIDConnectInitialize struct {
|
||||
OpenIDConnectAutoDiscoveryURL string
|
||||
ProviderName string
|
||||
Cause error
|
||||
}
|
||||
|
||||
// IsErrOpenIDConnectInitialize checks if an error is a ExternalLoginUserAlreadyExist.
|
||||
func IsErrOpenIDConnectInitialize(err error) bool {
|
||||
_, ok := err.(ErrOpenIDConnectInitialize)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrOpenIDConnectInitialize) Error() string {
|
||||
return fmt.Sprintf("Failed to initialize OpenID Connect Provider with name '%s' with url '%s': %v", err.ProviderName, err.OpenIDConnectAutoDiscoveryURL, err.Cause)
|
||||
}
|
||||
|
||||
func (err ErrOpenIDConnectInitialize) Unwrap() error {
|
||||
return err.Cause
|
||||
}
|
||||
|
||||
// wrapOpenIDConnectInitializeError is used to wrap the error but this cannot be done in modules/auth/oauth2
|
||||
// inside oauth2: import cycle not allowed models -> modules/auth/oauth2 -> models
|
||||
func wrapOpenIDConnectInitializeError(err error, providerName string, source *Source) error {
|
||||
if err != nil && source.Provider == "openidConnect" {
|
||||
err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Sync causes this OAuth2 source to synchronize its users with the db.
|
||||
func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
|
||||
log.Trace("Doing: SyncExternalUsers[%s] %d", source.AuthSource.Name, source.AuthSource.ID)
|
||||
|
||||
if !updateExisting {
|
||||
log.Info("SyncExternalUsers[%s] not running since updateExisting is false", source.AuthSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := createProvider(source.AuthSource.Name, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !provider.RefreshTokenAvailable() {
|
||||
log.Trace("SyncExternalUsers[%s] provider doesn't support refresh tokens, can't synchronize", source.AuthSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := user_model.FindExternalUserOptions{
|
||||
HasRefreshToken: true,
|
||||
Expired: true,
|
||||
LoginSourceID: source.AuthSource.ID,
|
||||
}
|
||||
|
||||
return user_model.IterateExternalLogin(ctx, opts, func(ctx context.Context, u *user_model.ExternalLoginUser) error {
|
||||
return source.refresh(ctx, provider, u)
|
||||
})
|
||||
}
|
||||
|
||||
func (source *Source) refresh(ctx context.Context, provider goth.Provider, u *user_model.ExternalLoginUser) error {
|
||||
log.Trace("Syncing login_source_id=%d external_id=%s expiration=%s", u.LoginSourceID, u.ExternalID, u.ExpiresAt)
|
||||
|
||||
shouldDisable := false
|
||||
|
||||
token, err := provider.RefreshToken(u.RefreshToken)
|
||||
if err != nil {
|
||||
if err, ok := err.(*oauth2.RetrieveError); ok && err.ErrorCode == "invalid_grant" {
|
||||
// this signals that the token is not valid and the user should be disabled
|
||||
shouldDisable = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
LoginName: u.ExternalID,
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: u.LoginSourceID,
|
||||
}
|
||||
|
||||
hasUser, err := user_model.GetIndividualUser(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the grant is no longer valid, disable the user and
|
||||
// delete local tokens. If the OAuth2 provider still
|
||||
// recognizes them as a valid user, they will be able to login
|
||||
// via their provider and reactivate their account.
|
||||
if shouldDisable {
|
||||
log.Info("SyncExternalUsers[%s] disabling user %d", source.AuthSource.Name, user.ID)
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if hasUser {
|
||||
user.IsActive = false
|
||||
err := user_model.UpdateUserCols(ctx, user, "is_active")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stored tokens, since they are invalid. This
|
||||
// also provents us from checking this in subsequent runs.
|
||||
u.AccessToken = ""
|
||||
u.RefreshToken = ""
|
||||
u.ExpiresAt = time.Time{}
|
||||
|
||||
return user_model.UpdateExternalUserByExternalID(ctx, u)
|
||||
})
|
||||
}
|
||||
|
||||
// Otherwise, update the tokens
|
||||
u.AccessToken = token.AccessToken
|
||||
u.ExpiresAt = token.Expiry
|
||||
|
||||
// Some providers only update access tokens provide a new
|
||||
// refresh token, so avoid updating it if it's empty
|
||||
if token.RefreshToken != "" {
|
||||
u.RefreshToken = token.RefreshToken
|
||||
}
|
||||
|
||||
err = user_model.UpdateExternalUserByExternalID(ctx, u)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSource(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
source := &Source{
|
||||
Provider: "fake",
|
||||
ConfigBase: auth.ConfigBase{
|
||||
AuthSource: &auth.Source{
|
||||
ID: 12,
|
||||
Type: auth.OAuth2,
|
||||
Name: "fake",
|
||||
IsActive: true,
|
||||
IsSyncEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
user := &user_model.User{
|
||||
LoginName: "external",
|
||||
LoginType: auth.OAuth2,
|
||||
LoginSource: source.AuthSource.ID,
|
||||
Name: "test",
|
||||
Email: "external@example.com",
|
||||
}
|
||||
|
||||
err := user_model.CreateUser(t.Context(), user, &user_model.Meta{}, &user_model.CreateUserOverwriteOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: "external",
|
||||
UserID: user.ID,
|
||||
LoginSourceID: user.LoginSource,
|
||||
RefreshToken: "valid",
|
||||
}
|
||||
err = user_model.LinkExternalToUser(t.Context(), user, e)
|
||||
assert.NoError(t, err)
|
||||
|
||||
provider, err := createProvider(source.AuthSource.Name, source)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("refresh", func(t *testing.T) {
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
err := source.refresh(t.Context(), provider, e)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "refresh", e.RefreshToken)
|
||||
assert.Equal(t, "token", e.AccessToken)
|
||||
|
||||
u, err := user_model.GetUserByID(t.Context(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, u.IsActive)
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
err := source.refresh(t.Context(), provider, &user_model.ExternalLoginUser{
|
||||
ExternalID: "external",
|
||||
UserID: user.ID,
|
||||
LoginSourceID: user.LoginSource,
|
||||
RefreshToken: "expired",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
e := &user_model.ExternalLoginUser{
|
||||
ExternalID: e.ExternalID,
|
||||
LoginSourceID: e.LoginSourceID,
|
||||
}
|
||||
|
||||
ok, err := user_model.GetExternalLogin(t.Context(), e)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Empty(t, e.RefreshToken)
|
||||
assert.Empty(t, e.AccessToken)
|
||||
|
||||
u, err := user_model.GetUserByID(t.Context(), user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, u.IsActive)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
session_module "gitea.dev/modules/session"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
// SessionsStore creates a gothic store from our session
|
||||
type SessionsStore struct {
|
||||
maxLength int64
|
||||
}
|
||||
|
||||
// Get should return a cached session.
|
||||
func (st *SessionsStore) Get(r *http.Request, name string) (*sessions.Session, error) {
|
||||
return st.getOrNew(r, name, false)
|
||||
}
|
||||
|
||||
// New should create and return a new session.
|
||||
//
|
||||
// Note that New should never return a nil session, even in the case of
|
||||
// an error if using the Registry infrastructure to cache the session.
|
||||
func (st *SessionsStore) New(r *http.Request, name string) (*sessions.Session, error) {
|
||||
return st.getOrNew(r, name, true)
|
||||
}
|
||||
|
||||
// getOrNew gets the session from the chi-session if it exists. Override permits the overriding of an unexpected object.
|
||||
func (st *SessionsStore) getOrNew(r *http.Request, name string, override bool) (*sessions.Session, error) {
|
||||
store := session_module.GetContextSession(r)
|
||||
|
||||
session := sessions.NewSession(st, name)
|
||||
|
||||
rawData := store.Get(name)
|
||||
if rawData != nil {
|
||||
oldSession, ok := rawData.(*sessions.Session)
|
||||
if ok {
|
||||
session.ID = oldSession.ID
|
||||
session.IsNew = oldSession.IsNew
|
||||
session.Options = oldSession.Options
|
||||
session.Values = oldSession.Values
|
||||
|
||||
return session, nil
|
||||
} else if !override {
|
||||
log.Error("Unexpected object in session at name: %s: %v", name, rawData)
|
||||
return nil, fmt.Errorf("unexpected object in session at name: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
session.IsNew = override
|
||||
session.ID = store.ID() // Simply copy the session id from the chi store
|
||||
|
||||
return session, store.Set(name, session)
|
||||
}
|
||||
|
||||
// Save should persist session to the underlying store implementation.
|
||||
func (st *SessionsStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
|
||||
store := session_module.GetContextSession(r)
|
||||
|
||||
if session.IsNew {
|
||||
_, _ = session_module.RegenerateSession(w, r)
|
||||
session.IsNew = false
|
||||
}
|
||||
|
||||
if err := store.Set(session.Name(), session); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if st.maxLength > 0 {
|
||||
sizeWriter := &sizeWriter{}
|
||||
|
||||
_ = gob.NewEncoder(sizeWriter).Encode(session)
|
||||
if sizeWriter.size > st.maxLength {
|
||||
return fmt.Errorf("encode session: Data too long: %d > %d", sizeWriter.size, st.maxLength)
|
||||
}
|
||||
}
|
||||
|
||||
return store.Release()
|
||||
}
|
||||
|
||||
type sizeWriter struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func (s *sizeWriter) Write(data []byte) (int, error) {
|
||||
s.size += int64(len(data))
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
var _ (sessions.Store) = &SessionsStore{}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package oauth2
|
||||
|
||||
// CustomURLMapping describes the urls values to use when customizing OAuth2 provider URLs
|
||||
type CustomURLMapping struct {
|
||||
AuthURL string `json:",omitempty"`
|
||||
TokenURL string `json:",omitempty"`
|
||||
ProfileURL string `json:",omitempty"`
|
||||
EmailURL string `json:",omitempty"`
|
||||
Tenant string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// CustomURLSettings describes the urls values and availability to use when customizing OAuth2 provider URLs
|
||||
type CustomURLSettings struct {
|
||||
AuthURL Attribute
|
||||
TokenURL Attribute
|
||||
ProfileURL Attribute
|
||||
EmailURL Attribute
|
||||
Tenant Attribute
|
||||
}
|
||||
|
||||
// Attribute describes the availability, and required status for a custom url configuration
|
||||
type Attribute struct {
|
||||
Value string
|
||||
Available bool
|
||||
Required bool
|
||||
}
|
||||
|
||||
func availableAttribute(value string) Attribute {
|
||||
return Attribute{Value: value, Available: true}
|
||||
}
|
||||
|
||||
func requiredAttribute(value string) Attribute {
|
||||
return Attribute{Value: value, Available: true, Required: true}
|
||||
}
|
||||
|
||||
// Required is true if any attribute is required
|
||||
func (c *CustomURLSettings) Required() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.AuthURL.Required || c.EmailURL.Required || c.ProfileURL.Required || c.TokenURL.Required || c.Tenant.Required {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OverrideWith copies the current customURLMapping and overrides it with values from the provided mapping
|
||||
func (c *CustomURLSettings) OverrideWith(override *CustomURLMapping) *CustomURLMapping {
|
||||
custom := &CustomURLMapping{
|
||||
AuthURL: c.AuthURL.Value,
|
||||
TokenURL: c.TokenURL.Value,
|
||||
ProfileURL: c.ProfileURL.Value,
|
||||
EmailURL: c.EmailURL.Value,
|
||||
Tenant: c.Tenant.Value,
|
||||
}
|
||||
if override != nil {
|
||||
if len(override.AuthURL) > 0 && c.AuthURL.Available {
|
||||
custom.AuthURL = override.AuthURL
|
||||
}
|
||||
if len(override.TokenURL) > 0 && c.TokenURL.Available {
|
||||
custom.TokenURL = override.TokenURL
|
||||
}
|
||||
if len(override.ProfileURL) > 0 && c.ProfileURL.Available {
|
||||
custom.ProfileURL = override.ProfileURL
|
||||
}
|
||||
if len(override.EmailURL) > 0 && c.EmailURL.Available {
|
||||
custom.EmailURL = override.EmailURL
|
||||
}
|
||||
if len(override.Tenant) > 0 && c.Tenant.Available {
|
||||
custom.Tenant = override.Tenant
|
||||
}
|
||||
}
|
||||
return custom
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pam_test
|
||||
|
||||
import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/pam"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth.PasswordAuthenticator
|
||||
auth_model.Config
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &pam.Source{}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pam
|
||||
|
||||
import (
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
)
|
||||
|
||||
// __________ _____ _____
|
||||
// \______ \/ _ \ / \
|
||||
// | ___/ /_\ \ / \ / \
|
||||
// | | / | \/ Y \
|
||||
// |____| \____|__ /\____|__ /
|
||||
// \/ \/
|
||||
|
||||
// Source holds configuration for the PAM login source.
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
|
||||
ServiceName string // pam service (e.g. system-auth)
|
||||
EmailDomain string
|
||||
}
|
||||
|
||||
// FromDB fills up a PAMConfig from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||
}
|
||||
|
||||
// ToDB exports a PAMConfig to a serialized format.
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
return json.Marshal(source)
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.PAM, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/pam"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Authenticate queries if login/password is valid against the PAM,
|
||||
// and create a local user if success when enabled.
|
||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
|
||||
pamLogin, err := pam.Auth(source.ServiceName, userName, password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Authentication failure") {
|
||||
return nil, user_model.ErrUserNotExist{Name: userName}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Allow PAM sources with `@` in their name, like from Active Directory
|
||||
username := pamLogin
|
||||
email := pamLogin
|
||||
before, _, ok := strings.Cut(pamLogin, "@")
|
||||
if ok {
|
||||
username = before
|
||||
}
|
||||
if user_model.ValidateEmail(email) != nil {
|
||||
if source.EmailDomain != "" {
|
||||
email = fmt.Sprintf("%s@%s", username, source.EmailDomain)
|
||||
} else {
|
||||
email = fmt.Sprintf("%s@%s", username, setting.Service.NoReplyAddress)
|
||||
}
|
||||
if user_model.ValidateEmail(email) != nil {
|
||||
email = uuid.New().String() + "@localhost"
|
||||
}
|
||||
}
|
||||
|
||||
user = &user_model.User{
|
||||
LowerName: strings.ToLower(username),
|
||||
Name: username,
|
||||
Email: email,
|
||||
Passwd: password,
|
||||
LoginType: auth.PAM,
|
||||
LoginSource: source.AuthSource.ID,
|
||||
LoginName: userName, // This is what the user typed in
|
||||
}
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
|
||||
if err := user_model.CreateUser(ctx, user, &user_model.Meta{}, overwriteDefault); err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package smtp_test
|
||||
|
||||
import (
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth"
|
||||
"gitea.dev/services/auth/source/smtp"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth.PasswordAuthenticator
|
||||
auth_model.Config
|
||||
auth_model.SkipVerifiable
|
||||
auth_model.HasTLSer
|
||||
auth_model.UseTLSer
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &smtp.Source{}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// _________ __________________________
|
||||
// / _____/ / \__ ___/\______ \
|
||||
// \_____ \ / \ / \| | | ___/
|
||||
// / \/ Y \ | | |
|
||||
// /_______ /\____|__ /____| |____|
|
||||
// \/ \/
|
||||
|
||||
type loginAuthenticator struct {
|
||||
username, password string
|
||||
}
|
||||
|
||||
func (auth *loginAuthenticator) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
return "LOGIN", []byte(auth.username), nil
|
||||
}
|
||||
|
||||
func (auth *loginAuthenticator) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return []byte(auth.username), nil
|
||||
case "Password:":
|
||||
return []byte(auth.password), nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SMTP authentication type names.
|
||||
const (
|
||||
PlainAuthentication = "PLAIN"
|
||||
LoginAuthentication = "LOGIN"
|
||||
CRAMMD5Authentication = "CRAM-MD5"
|
||||
)
|
||||
|
||||
// Authenticators contains available SMTP authentication type names.
|
||||
var Authenticators = []string{PlainAuthentication, LoginAuthentication, CRAMMD5Authentication}
|
||||
|
||||
// ErrUnsupportedLoginType login source is unknown error
|
||||
var ErrUnsupportedLoginType = errors.New("Login source is unknown")
|
||||
|
||||
// Authenticate performs an SMTP authentication.
|
||||
func Authenticate(a smtp.Auth, source *Source) error {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: source.SkipVerify,
|
||||
ServerName: source.Host,
|
||||
}
|
||||
|
||||
conn, err := net.Dial("tcp", net.JoinHostPort(source.Host, strconv.Itoa(source.Port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if source.UseTLS() {
|
||||
conn = tls.Client(conn, tlsConfig)
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, source.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create NewClient: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if !source.DisableHelo {
|
||||
hostname := source.HeloHostname
|
||||
if len(hostname) == 0 {
|
||||
hostname, err = os.Hostname()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find Hostname: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = client.Hello(hostname); err != nil {
|
||||
return fmt.Errorf("failed to send Helo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// If not using SMTPS, always use STARTTLS if available
|
||||
hasStartTLS, _ := client.Extension("STARTTLS")
|
||||
if !source.UseTLS() && hasStartTLS {
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("failed to start StartTLS: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
return client.Auth(a)
|
||||
}
|
||||
|
||||
return ErrUnsupportedLoginType
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
)
|
||||
|
||||
// _________ __________________________
|
||||
// / _____/ / \__ ___/\______ \
|
||||
// \_____ \ / \ / \| | | ___/
|
||||
// / \/ Y \ | | |
|
||||
// /_______ /\____|__ /____| |____|
|
||||
// \/ \/
|
||||
|
||||
// Source holds configuration for the SMTP login source.
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
|
||||
Auth string
|
||||
Host string
|
||||
Port int
|
||||
AllowedDomains string `xorm:"TEXT"`
|
||||
ForceSMTPS bool
|
||||
SkipVerify bool
|
||||
HeloHostname string
|
||||
DisableHelo bool
|
||||
}
|
||||
|
||||
// FromDB fills up an SMTPConfig from serialized format.
|
||||
func (source *Source) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &source)
|
||||
}
|
||||
|
||||
// ToDB exports an SMTPConfig to a serialized format.
|
||||
func (source *Source) ToDB() ([]byte, error) {
|
||||
return json.Marshal(source)
|
||||
}
|
||||
|
||||
// IsSkipVerify returns if SkipVerify is set
|
||||
func (source *Source) IsSkipVerify() bool {
|
||||
return source.SkipVerify
|
||||
}
|
||||
|
||||
// HasTLS returns true for SMTP
|
||||
func (source *Source) HasTLS() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UseTLS returns if TLS is set
|
||||
func (source *Source) UseTLS() bool {
|
||||
return source.ForceSMTPS || source.Port == 465
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.SMTP, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// Authenticate queries if the provided login/password is authenticates against the SMTP server
|
||||
// Users will be autoregistered as required
|
||||
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
|
||||
// Verify allowed domains.
|
||||
if len(source.AllowedDomains) > 0 {
|
||||
_, after, ok := strings.Cut(userName, "@")
|
||||
if !ok {
|
||||
return nil, user_model.ErrUserNotExist{Name: userName}
|
||||
} else if !util.SliceContainsString(strings.Split(source.AllowedDomains, ","), after, true) {
|
||||
return nil, user_model.ErrUserNotExist{Name: userName}
|
||||
}
|
||||
}
|
||||
|
||||
var auth smtp.Auth
|
||||
switch source.Auth {
|
||||
case PlainAuthentication:
|
||||
auth = smtp.PlainAuth("", userName, password, source.Host)
|
||||
case LoginAuthentication:
|
||||
auth = &loginAuthenticator{userName, password}
|
||||
case CRAMMD5Authentication:
|
||||
auth = smtp.CRAMMD5Auth(userName, password)
|
||||
default:
|
||||
return nil, errors.New("unsupported SMTP auth type")
|
||||
}
|
||||
|
||||
if err := Authenticate(auth, source); err != nil {
|
||||
// Check standard error format first,
|
||||
// then fallback to worse case.
|
||||
tperr, ok := err.(*textproto.Error)
|
||||
if (ok && tperr.Code == 535) ||
|
||||
strings.Contains(err.Error(), "Username and Password not accepted") {
|
||||
return nil, user_model.ErrUserNotExist{Name: userName}
|
||||
}
|
||||
if (ok && tperr.Code == 534) ||
|
||||
strings.Contains(err.Error(), "Application-specific password required") {
|
||||
return nil, user_model.ErrUserNotExist{Name: userName}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
username := userName
|
||||
before, _, ok := strings.Cut(userName, "@")
|
||||
if ok {
|
||||
username = before
|
||||
}
|
||||
|
||||
user = &user_model.User{
|
||||
LowerName: strings.ToLower(username),
|
||||
Name: strings.ToLower(username),
|
||||
Email: userName,
|
||||
Passwd: password,
|
||||
LoginType: auth_model.SMTP,
|
||||
LoginSource: source.AuthSource.ID,
|
||||
LoginName: userName,
|
||||
}
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: optional.Some(true),
|
||||
}
|
||||
|
||||
if err := user_model.CreateUser(ctx, user, &user_model.Meta{}, overwriteDefault); err != nil {
|
||||
return user, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package source
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/organization"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
org_service "gitea.dev/services/org"
|
||||
)
|
||||
|
||||
type syncType int
|
||||
|
||||
const (
|
||||
syncAdd syncType = iota
|
||||
syncRemove
|
||||
)
|
||||
|
||||
// SyncGroupsToTeams maps authentication source groups to organization and team memberships
|
||||
func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool) error {
|
||||
orgCache := make(map[string]*organization.Organization)
|
||||
teamCache := make(map[string]*organization.Team)
|
||||
return SyncGroupsToTeamsCached(ctx, user, sourceUserGroups, sourceGroupTeamMapping, performRemoval, orgCache, teamCache)
|
||||
}
|
||||
|
||||
// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships
|
||||
func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
|
||||
membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping)
|
||||
|
||||
if performRemoval {
|
||||
if err := syncGroupsToTeamsCached(ctx, user, membershipsToRemove, syncRemove, orgCache, teamCache); err != nil {
|
||||
return fmt.Errorf("could not sync[remove] user groups: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := syncGroupsToTeamsCached(ctx, user, membershipsToAdd, syncAdd, orgCache, teamCache); err != nil {
|
||||
return fmt.Errorf("could not sync[add] user groups: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveMappedMemberships(sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string) (map[string][]string, map[string][]string) {
|
||||
membershipsToAdd := map[string][]string{}
|
||||
membershipsToRemove := map[string][]string{}
|
||||
for group, memberships := range sourceGroupTeamMapping {
|
||||
isUserInGroup := sourceUserGroups.Contains(group)
|
||||
if isUserInGroup {
|
||||
for org, teams := range memberships {
|
||||
membershipsToAdd[org] = append(membershipsToAdd[org], teams...)
|
||||
}
|
||||
} else {
|
||||
for org, teams := range memberships {
|
||||
membershipsToRemove[org] = append(membershipsToRemove[org], teams...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return membershipsToAdd, membershipsToRemove
|
||||
}
|
||||
|
||||
func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeamMap map[string][]string, action syncType, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
|
||||
for orgName, teamNames := range orgTeamMap {
|
||||
var err error
|
||||
org, ok := orgCache[orgName]
|
||||
if !ok {
|
||||
org, err = organization.GetOrgByName(ctx, orgName)
|
||||
if err != nil {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
// organization must be created before group sync
|
||||
log.Warn("group sync: Could not find organisation %s: %v", orgName, err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
orgCache[orgName] = org
|
||||
}
|
||||
for _, teamName := range teamNames {
|
||||
team, ok := teamCache[orgName+teamName]
|
||||
if !ok {
|
||||
team, err = org.GetTeam(ctx, teamName)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
// team must be created before group sync
|
||||
log.Warn("group sync: Could not find team %s: %v", teamName, err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
teamCache[orgName+teamName] = team
|
||||
}
|
||||
|
||||
isMember, err := organization.IsTeamMember(ctx, org.ID, team.ID, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if action == syncAdd && !isMember {
|
||||
if err := org_service.AddTeamMember(ctx, team, user); err != nil {
|
||||
log.Error("group sync: Could not add user to team: %v", err)
|
||||
return err
|
||||
}
|
||||
} else if action == syncRemove && isMember {
|
||||
if err := org_service.RemoveTeamMember(ctx, team, user); err != nil {
|
||||
log.Error("group sync: Could not remove user from team: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sspi_test
|
||||
|
||||
import (
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/services/auth/source/sspi"
|
||||
)
|
||||
|
||||
// This test file exists to assert that our Source exposes the interfaces that we expect
|
||||
// It tightly binds the interfaces and implementation without breaking go import cycles
|
||||
|
||||
type sourceInterface interface {
|
||||
auth.Config
|
||||
}
|
||||
|
||||
var _ (sourceInterface) = &sspi.Source{}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sspi
|
||||
|
||||
import (
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
)
|
||||
|
||||
// _________ ___________________.___
|
||||
// / _____// _____/\______ \ |
|
||||
// \_____ \ \_____ \ | ___/ |
|
||||
// / \/ \ | | | |
|
||||
// /_______ /_______ / |____| |___|
|
||||
// \/ \/
|
||||
|
||||
// Source holds configuration for SSPI single sign-on.
|
||||
type Source struct {
|
||||
auth.ConfigBase `json:"-"`
|
||||
|
||||
AutoCreateUsers bool
|
||||
AutoActivateUsers bool
|
||||
StripDomainNames bool
|
||||
SeparatorReplacement string
|
||||
DefaultLanguage string
|
||||
}
|
||||
|
||||
// FromDB fills up an SSPIConfig from serialized format.
|
||||
func (cfg *Source) FromDB(bs []byte) error {
|
||||
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports an SSPIConfig to a serialized format.
|
||||
func (cfg *Source) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
func init() {
|
||||
auth.RegisterTypeConfig(auth.SSPI, &Source{})
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/services/auth/source/sspi"
|
||||
gitea_context "gitea.dev/services/context"
|
||||
|
||||
gouuid "github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
tplSignIn templates.TplName = "user/auth/signin"
|
||||
)
|
||||
|
||||
type SSPIAuth interface {
|
||||
AppendAuthenticateHeader(w http.ResponseWriter, data string)
|
||||
Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
sspiAuth SSPIAuth // a global instance of the websspi authenticator to avoid acquiring the server credential handle on every request
|
||||
sspiAuthOnce sync.Once
|
||||
sspiAuthErrInit error
|
||||
|
||||
// Ensure the struct implements the interface.
|
||||
_ Method = &SSPI{}
|
||||
)
|
||||
|
||||
// SSPI implements the SingleSignOn interface and authenticates requests
|
||||
// via the built-in SSPI module in Windows for SPNEGO authentication.
|
||||
// The SSPI plugin is expected to be executed last, as it returns 401 status code if negotiation
|
||||
// fails (or if negotiation should continue), which would prevent other authentication methods
|
||||
// to execute at all.
|
||||
type SSPI struct {
|
||||
CreateSession bool
|
||||
}
|
||||
|
||||
// Name represents the name of auth method
|
||||
func (s *SSPI) Name() string {
|
||||
return "sspi"
|
||||
}
|
||||
|
||||
// Verify uses SSPI (Windows implementation of SPNEGO) to authenticate the request.
|
||||
// If authentication is successful, returns the corresponding user object.
|
||||
// If negotiation should continue or authentication fails, immediately returns a 401 HTTP
|
||||
// response code, as required by the SPNEGO protocol.
|
||||
func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore) (*user_model.User, error) {
|
||||
sspiAuthOnce.Do(func() { sspiAuthErrInit = sspiAuthInit() })
|
||||
if sspiAuthErrInit != nil {
|
||||
return nil, sspiAuthErrInit
|
||||
}
|
||||
if !s.shouldAuthenticate(req) {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
|
||||
cfg, err := s.getConfig(req.Context())
|
||||
if err != nil {
|
||||
log.Error("could not get SSPI config: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace("SSPI Authorization: Attempting to authenticate")
|
||||
userInfo, outToken, err := sspiAuth.Authenticate(req, w)
|
||||
if err != nil {
|
||||
log.Warn("Authentication failed with error: %v\n", err)
|
||||
sspiAuth.AppendAuthenticateHeader(w, outToken)
|
||||
|
||||
// Include the user login page in the 401 response to allow the user
|
||||
// to login with another authentication method if SSPI authentication
|
||||
// fails
|
||||
store.GetData()["Flash"] = map[string]string{
|
||||
"ErrorMsg": err.Error(),
|
||||
}
|
||||
store.GetData()["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn
|
||||
store.GetData()["EnableSSPI"] = true
|
||||
// in this case, the Verify function is called in Gitea's web context
|
||||
// FIXME: it doesn't look good to render the page here, why not redirect?
|
||||
gitea_context.GetWebContext(req.Context()).HTML(http.StatusUnauthorized, tplSignIn)
|
||||
return nil, err
|
||||
}
|
||||
if outToken != "" {
|
||||
sspiAuth.AppendAuthenticateHeader(w, outToken)
|
||||
}
|
||||
|
||||
username := sanitizeUsername(userInfo.Username, cfg)
|
||||
if len(username) == 0 {
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
log.Info("Authenticated as %s\n", username)
|
||||
|
||||
user, err := user_model.GetUserByName(req.Context(), username)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("GetUserByName: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.AutoCreateUsers {
|
||||
log.Error("User '%s' not found", username)
|
||||
return nil, nil //nolint:nilnil // the auth method is not applicable
|
||||
}
|
||||
user, err = s.newUser(req.Context(), username, cfg)
|
||||
if err != nil {
|
||||
log.Error("CreateUser: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if s.CreateSession {
|
||||
handleSignIn(w, req, sess, user)
|
||||
}
|
||||
|
||||
log.Trace("SSPI Authorization: Logged in user %-v", user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// getConfig retrieves the SSPI configuration from login sources
|
||||
func (s *SSPI) getConfig(ctx context.Context) (*sspi.Source, error) {
|
||||
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
|
||||
IsActive: optional.Some(true),
|
||||
LoginType: auth.SSPI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return nil, errors.New("no active login sources of type SSPI found")
|
||||
}
|
||||
if len(sources) > 1 {
|
||||
return nil, errors.New("more than one active login source of type SSPI found")
|
||||
}
|
||||
return sources[0].Cfg.(*sspi.Source), nil
|
||||
}
|
||||
|
||||
func (s *SSPI) shouldAuthenticate(req *http.Request) (shouldAuth bool) {
|
||||
// SSPI is only applicable for login requests with "auth_with_sspi" form value set to "1"
|
||||
// See the template code with "auth_with_sspi"
|
||||
shouldAuth = req.URL.Path == "/user/login" && req.FormValue("auth_with_sspi") == "1"
|
||||
return shouldAuth
|
||||
}
|
||||
|
||||
// newUser creates a new user object for the purpose of automatic registration
|
||||
// and populates its name and email with the information present in request headers.
|
||||
func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (*user_model.User, error) {
|
||||
email := gouuid.New().String() + "@localhost.localdomain"
|
||||
user := &user_model.User{
|
||||
Name: username,
|
||||
Email: email,
|
||||
Language: cfg.DefaultLanguage,
|
||||
}
|
||||
emailNotificationPreference := user_model.EmailNotificationsDisabled
|
||||
overwriteDefault := &user_model.CreateUserOverwriteOptions{
|
||||
IsActive: optional.Some(cfg.AutoActivateUsers),
|
||||
KeepEmailPrivate: optional.Some(true),
|
||||
EmailNotificationsPreference: &emailNotificationPreference,
|
||||
}
|
||||
if err := user_model.CreateUser(ctx, user, &user_model.Meta{}, overwriteDefault); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// stripDomainNames removes NETBIOS domain name and separator from down-level logon names
|
||||
// (eg. "DOMAIN\user" becomes "user"), and removes the UPN suffix (domain name) and separator
|
||||
// from UPNs (eg. "user@domain.local" becomes "user")
|
||||
func stripDomainNames(username string) string {
|
||||
if strings.Contains(username, "\\") {
|
||||
parts := strings.SplitN(username, "\\", 2)
|
||||
if len(parts) > 1 {
|
||||
username = parts[1]
|
||||
}
|
||||
} else if strings.Contains(username, "@") {
|
||||
parts := strings.Split(username, "@")
|
||||
if len(parts) > 1 {
|
||||
username = parts[0]
|
||||
}
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func replaceSeparators(username string, cfg *sspi.Source) string {
|
||||
newSep := cfg.SeparatorReplacement
|
||||
username = strings.ReplaceAll(username, "\\", newSep)
|
||||
username = strings.ReplaceAll(username, "/", newSep)
|
||||
username = strings.ReplaceAll(username, "@", newSep)
|
||||
return username
|
||||
}
|
||||
|
||||
func sanitizeUsername(username string, cfg *sspi.Source) string {
|
||||
if len(username) == 0 {
|
||||
return ""
|
||||
}
|
||||
if cfg.StripDomainNames {
|
||||
username = stripDomainNames(username)
|
||||
}
|
||||
// Replace separators even if we have already stripped the domain name part,
|
||||
// as the username can contain several separators: eg. "MICROSOFT\useremail@live.com"
|
||||
username = replaceSeparators(username, cfg)
|
||||
return username
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type SSPIUserInfo struct {
|
||||
Username string // Name of user, usually in the form DOMAIN\User
|
||||
Groups []string // The global groups the user is a member of
|
||||
}
|
||||
|
||||
type sspiAuthMock struct{}
|
||||
|
||||
func (s sspiAuthMock) AppendAuthenticateHeader(w http.ResponseWriter, data string) {
|
||||
}
|
||||
|
||||
func (s sspiAuthMock) Authenticate(r *http.Request, w http.ResponseWriter) (userInfo *SSPIUserInfo, outToken string, err error) {
|
||||
return nil, "", errors.New("not implemented")
|
||||
}
|
||||
|
||||
func sspiAuthInit() error {
|
||||
sspiAuth = &sspiAuthMock{} // TODO: we can mock the SSPI auth in tests
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/quasoft/websspi"
|
||||
)
|
||||
|
||||
type SSPIUserInfo = websspi.UserInfo
|
||||
|
||||
func sspiAuthInit() error {
|
||||
var err error
|
||||
config := websspi.NewConfig()
|
||||
sspiAuth, err = websspi.New(config)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2021 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/auth"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// SyncExternalUsers is used to synchronize users with external authorization source
|
||||
func SyncExternalUsers(ctx context.Context, updateExisting bool) error {
|
||||
log.Trace("Doing: SyncExternalUsers")
|
||||
|
||||
ls, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{})
|
||||
if err != nil {
|
||||
log.Error("SyncExternalUsers: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range ls {
|
||||
if !s.IsActive || !s.IsSyncEnabled {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name)
|
||||
return db.ErrCancelledf("Before update of %s", s.Name)
|
||||
default:
|
||||
}
|
||||
|
||||
if syncable, ok := s.Cfg.(SynchronizableSource); ok {
|
||||
err := syncable.Sync(ctx, updateExisting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user