初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+82
View File
@@ -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)
}
+110
View File
@@ -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[:])
}
+106
View File
@@ -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))
}
+201
View File
@@ -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 ""
}
}
+72
View File
@@ -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
}
+218
View File
@@ -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")
}
+41
View File
@@ -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
}
+14
View File
@@ -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)
}
+165
View File
@@ -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
}
+57
View File
@@ -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)
})
}
}
+174
View File
@@ -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
}
+60
View File
@@ -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
}
+128
View File
@@ -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}
}
+42
View File
@@ -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{}
+87
View File
@@ -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
}
+37
View File
@@ -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{})
}
+131
View File
@@ -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",
}
+119
View File
@@ -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
}
+526
View File
@@ -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
}
+229
View File
@@ -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
}
+18
View File
@@ -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{}
+79
View File
@@ -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
}
+14
View File
@@ -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{})
}
+246
View File
@@ -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{}
}))
}
+48
View File
@@ -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
}
+114
View File
@@ -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)
})
})
}
+97
View File
@@ -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{}
+77
View File
@@ -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{}
+38
View File
@@ -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{}
+106
View File
@@ -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
}
+59
View File
@@ -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
}
+116
View File
@@ -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{}
+41
View File
@@ -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{})
}
+215
View File
@@ -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
}
+30
View File
@@ -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
}
+19
View File
@@ -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
}
+43
View File
@@ -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
}