mirror of
https://github.com/project-zot/zot.git
synced 2024-12-30 22:34:13 -05:00
17d1338af1
This change introduces OpenID authn by using providers such as Github, Gitlab, Google and Dex. User sessions are now used for web clients to identify and persist an authenticated users session, thus not requiring every request to use credentials. Another change is apikey feature, users can create/revoke their api keys and use them to authenticate when using cli clients such as skopeo. eg: login: /auth/login?provider=github /auth/login?provider=gitlab and so on logout: /auth/logout redirectURL: /auth/callback/github /auth/callback/gitlab and so on If network policy doesn't allow inbound connections, this callback wont work! for more info read documentation added in this commit. Signed-off-by: Alex Stan <alexandrustan96@yahoo.ro> Signed-off-by: Petu Eusebiu <peusebiu@cisco.com> Co-authored-by: Alex Stan <alexandrustan96@yahoo.ro>
865 lines
24 KiB
Go
865 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chartmuseum/auth"
|
|
"github.com/google/go-github/v52/github"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/mux"
|
|
"github.com/gorilla/securecookie"
|
|
"github.com/gorilla/sessions"
|
|
godigest "github.com/opencontainers/go-digest"
|
|
"github.com/zitadel/oidc/pkg/client/rp"
|
|
httphelper "github.com/zitadel/oidc/pkg/http"
|
|
"github.com/zitadel/oidc/pkg/oidc"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/oauth2"
|
|
githubOAuth "golang.org/x/oauth2/github"
|
|
|
|
zerr "zotregistry.io/zot/errors"
|
|
"zotregistry.io/zot/pkg/api/config"
|
|
"zotregistry.io/zot/pkg/api/constants"
|
|
apiErr "zotregistry.io/zot/pkg/api/errors"
|
|
"zotregistry.io/zot/pkg/common"
|
|
"zotregistry.io/zot/pkg/log"
|
|
localCtx "zotregistry.io/zot/pkg/requestcontext"
|
|
storageConstants "zotregistry.io/zot/pkg/storage/constants"
|
|
)
|
|
|
|
const (
|
|
bearerAuthDefaultAccessEntryType = "repository"
|
|
issuedAtOffset = 5 * time.Second
|
|
relyingPartyCookieMaxAge = 120
|
|
)
|
|
|
|
type AuthnMiddleware struct {
|
|
credMap map[string]string
|
|
ldapClient *LDAPClient
|
|
}
|
|
|
|
func AuthHandler(ctlr *Controller) mux.MiddlewareFunc {
|
|
authnMiddleware := &AuthnMiddleware{}
|
|
|
|
if isBearerAuthEnabled(ctlr.Config) {
|
|
return bearerAuthHandler(ctlr)
|
|
}
|
|
|
|
return authnMiddleware.TryAuthnHandlers(ctlr)
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) sessionAuthn(ctlr *Controller, next http.Handler, response http.ResponseWriter,
|
|
request *http.Request, delay int,
|
|
) {
|
|
clientHeader := request.Header.Get(constants.SessionClientHeaderName)
|
|
if clientHeader != constants.SessionClientHeaderValue {
|
|
authFail(response, request, ctlr.Config.HTTP.Realm, delay)
|
|
|
|
return
|
|
}
|
|
|
|
identity, ok := common.GetAuthUserFromRequestSession(ctlr.CookieStore, request, ctlr.Log)
|
|
if !ok {
|
|
// let the client know that this session is invalid/expired
|
|
cookie := &http.Cookie{
|
|
Name: "session",
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
|
|
HttpOnly: true,
|
|
}
|
|
|
|
http.SetCookie(response, cookie)
|
|
|
|
authFail(response, request, ctlr.Config.HTTP.Realm, delay)
|
|
|
|
return
|
|
}
|
|
|
|
ctx := getReqContextWithAuthorization(identity, []string{}, request)
|
|
|
|
groups, err := ctlr.RepoDB.GetUserGroups(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrUserDataNotFound) {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("can not find user profile in DB")
|
|
|
|
authFail(response, request, ctlr.Config.HTTP.Realm, delay)
|
|
|
|
return
|
|
}
|
|
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("can not get user profile in DB")
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
ctx = getReqContextWithAuthorization(identity, groups, request)
|
|
|
|
next.ServeHTTP(response, request.WithContext(ctx))
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, response http.ResponseWriter,
|
|
request *http.Request,
|
|
) (bool, http.ResponseWriter, *http.Request, error) {
|
|
cookieStore := ctlr.CookieStore
|
|
|
|
// we want to bypass auth for mgmt route
|
|
isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix
|
|
|
|
if request.Header.Get("Authorization") == "" {
|
|
if ctlr.Config.HTTP.AccessControl.AnonymousPolicyExists() || isMgmtRequested {
|
|
ctx := getReqContextWithAuthorization("", []string{}, request)
|
|
// Process request
|
|
|
|
return true, response, request.WithContext(ctx), nil
|
|
}
|
|
}
|
|
|
|
identity, passphrase, err := getUsernamePasswordBasicAuth(request)
|
|
if err != nil {
|
|
ctlr.Log.Error().Err(err).Msg("failed to parse authorization header")
|
|
|
|
return false, nil, nil, nil
|
|
}
|
|
|
|
// some client tools might send Authorization: Basic Og== (decoded into ":")
|
|
// empty username and password
|
|
if identity == "" && passphrase == "" {
|
|
if ctlr.Config.HTTP.AccessControl.AnonymousPolicyExists() || isMgmtRequested {
|
|
ctx := getReqContextWithAuthorization("", []string{}, request)
|
|
|
|
return true, response, request.WithContext(ctx), nil
|
|
}
|
|
}
|
|
|
|
passphraseHash, ok := amw.credMap[identity]
|
|
if ok {
|
|
// first, HTTPPassword authN (which is local)
|
|
if err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase)); err == nil {
|
|
// Process request
|
|
var groups []string
|
|
|
|
if ctlr.Config.HTTP.AccessControl != nil {
|
|
ac := NewAccessController(ctlr.Config)
|
|
groups = ac.getUserGroups(identity)
|
|
}
|
|
|
|
ctx := getReqContextWithAuthorization(identity, groups, request)
|
|
|
|
// saved logged session
|
|
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
|
|
return false, response, request, err
|
|
}
|
|
|
|
if err := ctlr.RepoDB.SetUserGroups(ctx, groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("couldn't update user profile")
|
|
|
|
return false, response, request, err
|
|
}
|
|
|
|
ctlr.Log.Info().Str("identity", identity).Msgf("user profile successfully set")
|
|
|
|
return true, response, request.WithContext(ctx), nil
|
|
}
|
|
}
|
|
|
|
// next, LDAP if configured (network-based which can lose connectivity)
|
|
if ctlr.Config.HTTP.Auth != nil && ctlr.Config.HTTP.Auth.LDAP != nil {
|
|
ok, _, ldapgroups, err := amw.ldapClient.Authenticate(identity, passphrase)
|
|
if ok && err == nil {
|
|
// Process request
|
|
var groups []string
|
|
|
|
if ctlr.Config.HTTP.AccessControl != nil {
|
|
ac := NewAccessController(ctlr.Config)
|
|
groups = ac.getUserGroups(identity)
|
|
}
|
|
|
|
groups = append(groups, ldapgroups...)
|
|
|
|
ctx := getReqContextWithAuthorization(identity, groups, request)
|
|
|
|
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
|
|
return false, response, request, err
|
|
}
|
|
|
|
if err := ctlr.RepoDB.SetUserGroups(ctx, groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("couldn't update user profile")
|
|
|
|
return false, response, request, err
|
|
}
|
|
|
|
return true, response, request.WithContext(ctx), nil
|
|
}
|
|
}
|
|
|
|
// last try API keys
|
|
if isAPIKeyEnabled(ctlr.Config) {
|
|
apiKey := passphrase
|
|
|
|
if !strings.HasPrefix(apiKey, constants.APIKeysPrefix) {
|
|
ctlr.Log.Error().Msg("api token has invalid format")
|
|
|
|
return false, nil, nil, nil
|
|
}
|
|
|
|
trimmedAPIKey := strings.TrimPrefix(apiKey, constants.APIKeysPrefix)
|
|
|
|
hashedKey := hashUUID(trimmedAPIKey)
|
|
|
|
storedIdentity, err := ctlr.RepoDB.GetUserAPIKeyInfo(hashedKey)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrUserAPIKeyNotFound) {
|
|
ctlr.Log.Info().Err(err).Msgf("can not find any user info for hashed key %s in DB", hashedKey)
|
|
|
|
return false, nil, nil, nil
|
|
}
|
|
|
|
ctlr.Log.Error().Err(err).Msgf("can not get user info for hashed key %s in DB", hashedKey)
|
|
|
|
return false, nil, nil, err
|
|
}
|
|
|
|
if storedIdentity == identity {
|
|
ctx := getReqContextWithAuthorization(identity, []string{}, request)
|
|
|
|
err := ctlr.RepoDB.UpdateUserAPIKeyLastUsed(ctx, hashedKey)
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("can not update user profile in DB")
|
|
|
|
return false, nil, nil, err
|
|
}
|
|
|
|
groups, err := ctlr.RepoDB.GetUserGroups(ctx)
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("can not get user's groups in DB")
|
|
|
|
return false, nil, nil, err
|
|
}
|
|
|
|
ctx = getReqContextWithAuthorization(identity, groups, request)
|
|
|
|
return true, response, request.WithContext(ctx), nil
|
|
}
|
|
}
|
|
|
|
return false, nil, nil, nil
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) TryAuthnHandlers(ctlr *Controller) mux.MiddlewareFunc { //nolint: gocyclo
|
|
// no password based authN, if neither LDAP nor HTTP BASIC is enabled
|
|
if ctlr.Config.HTTP.Auth == nil ||
|
|
(ctlr.Config.HTTP.Auth.HTPasswd.Path == "" && ctlr.Config.HTTP.Auth.LDAP == nil &&
|
|
ctlr.Config.HTTP.Auth.OpenID == nil) {
|
|
return noPasswdAuth(ctlr.Config)
|
|
}
|
|
|
|
amw.credMap = make(map[string]string)
|
|
|
|
delay := ctlr.Config.HTTP.Auth.FailDelay
|
|
|
|
// setup sessions cookie store used to preserve logged in user in web sessions
|
|
if isAuthnEnabled(ctlr.Config) || isOpenIDAuthEnabled(ctlr.Config) {
|
|
// To store custom types in our cookies,
|
|
// we must first register them using gob.Register
|
|
gob.Register(map[string]interface{}{})
|
|
|
|
cookieStoreHashKey := securecookie.GenerateRandomKey(64)
|
|
if cookieStoreHashKey == nil {
|
|
panic(zerr.ErrHashKeyNotCreated)
|
|
}
|
|
|
|
// if storage is filesystem then use zot's rootDir to store sessions
|
|
if ctlr.Config.Storage.StorageDriver == nil {
|
|
sessionsDir := path.Join(ctlr.Config.Storage.RootDirectory, "_sessions")
|
|
if err := os.MkdirAll(sessionsDir, storageConstants.DefaultDirPerms); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
cookieStore := sessions.NewFilesystemStore(sessionsDir, cookieStoreHashKey)
|
|
|
|
cookieStore.MaxAge(cookiesMaxAge)
|
|
|
|
ctlr.CookieStore = cookieStore
|
|
} else {
|
|
cookieStore := sessions.NewCookieStore(cookieStoreHashKey)
|
|
|
|
cookieStore.MaxAge(cookiesMaxAge)
|
|
|
|
ctlr.CookieStore = cookieStore
|
|
}
|
|
}
|
|
|
|
// ldap and htpasswd based authN
|
|
if ctlr.Config.HTTP.Auth != nil {
|
|
if ctlr.Config.HTTP.Auth.LDAP != nil {
|
|
ldapConfig := ctlr.Config.HTTP.Auth.LDAP
|
|
amw.ldapClient = &LDAPClient{
|
|
Host: ldapConfig.Address,
|
|
Port: ldapConfig.Port,
|
|
UseSSL: !ldapConfig.Insecure,
|
|
SkipTLS: !ldapConfig.StartTLS,
|
|
Base: ldapConfig.BaseDN,
|
|
BindDN: ldapConfig.BindDN,
|
|
UserGroupAttribute: ldapConfig.UserGroupAttribute, // from config
|
|
BindPassword: ldapConfig.BindPassword,
|
|
UserFilter: fmt.Sprintf("(%s=%%s)", ldapConfig.UserAttribute),
|
|
InsecureSkipVerify: ldapConfig.SkipVerify,
|
|
ServerName: ldapConfig.Address,
|
|
Log: ctlr.Log,
|
|
SubtreeSearch: ldapConfig.SubtreeSearch,
|
|
}
|
|
|
|
if ctlr.Config.HTTP.Auth.LDAP.CACert != "" {
|
|
caCert, err := os.ReadFile(ctlr.Config.HTTP.Auth.LDAP.CACert)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
|
|
if !caCertPool.AppendCertsFromPEM(caCert) {
|
|
panic(zerr.ErrBadCACert)
|
|
}
|
|
|
|
amw.ldapClient.ClientCAs = caCertPool
|
|
} else {
|
|
// default to system cert pool
|
|
caCertPool, err := x509.SystemCertPool()
|
|
if err != nil {
|
|
panic(zerr.ErrBadCACert)
|
|
}
|
|
|
|
amw.ldapClient.ClientCAs = caCertPool
|
|
}
|
|
}
|
|
|
|
if ctlr.Config.HTTP.Auth.HTPasswd.Path != "" {
|
|
credsFile, err := os.Open(ctlr.Config.HTTP.Auth.HTPasswd.Path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer credsFile.Close()
|
|
|
|
scanner := bufio.NewScanner(credsFile)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.Contains(line, ":") {
|
|
tokens := strings.Split(scanner.Text(), ":")
|
|
amw.credMap[tokens[0]] = tokens[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// openid based authN
|
|
if ctlr.Config.HTTP.Auth.OpenID != nil {
|
|
ctlr.RelyingParties = make(map[string]rp.RelyingParty)
|
|
|
|
for provider := range ctlr.Config.HTTP.Auth.OpenID.Providers {
|
|
if IsOpenIDSupported(provider) {
|
|
rp := NewRelyingPartyOIDC(ctlr.Config, provider)
|
|
ctlr.RelyingParties[provider] = rp
|
|
} else if IsOauth2Supported(provider) {
|
|
rp := NewRelyingPartyGithub(ctlr.Config, provider)
|
|
ctlr.RelyingParties[provider] = rp
|
|
}
|
|
}
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
next.ServeHTTP(response, request)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
//nolint: contextcheck
|
|
authenticated, cloneResp, cloneReq, err := amw.basicAuthn(ctlr, response, request)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if authenticated && cloneResp != nil && cloneReq != nil {
|
|
next.ServeHTTP(cloneResp, cloneReq)
|
|
|
|
return
|
|
}
|
|
|
|
//nolint: contextcheck
|
|
amw.sessionAuthn(ctlr, next, response, request, delay)
|
|
})
|
|
}
|
|
}
|
|
|
|
func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
|
|
authorizer, err := auth.NewAuthorizer(&auth.AuthorizerOptions{
|
|
Realm: ctlr.Config.HTTP.Auth.Bearer.Realm,
|
|
Service: ctlr.Config.HTTP.Auth.Bearer.Service,
|
|
PublicKeyPath: ctlr.Config.HTTP.Auth.Bearer.Cert,
|
|
AccessEntryType: bearerAuthDefaultAccessEntryType,
|
|
EmptyDefaultNamespace: true,
|
|
})
|
|
if err != nil {
|
|
ctlr.Log.Panic().Err(err).Msg("error creating bearer authorizer")
|
|
}
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
next.ServeHTTP(response, request)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
acCtrlr := NewAccessController(ctlr.Config)
|
|
vars := mux.Vars(request)
|
|
name := vars["name"]
|
|
|
|
// we want to bypass auth for mgmt route
|
|
isMgmtRequested := request.RequestURI == constants.FullMgmtPrefix
|
|
|
|
header := request.Header.Get("Authorization")
|
|
|
|
if (header == "" || header == "Basic Og==") && isMgmtRequested {
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
|
|
action := auth.PullAction
|
|
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
|
|
action = auth.PushAction
|
|
}
|
|
|
|
permissions, err := authorizer.Authorize(header, action, name)
|
|
if err != nil {
|
|
ctlr.Log.Error().Err(err).Msg("issue parsing Authorization header")
|
|
response.Header().Set("Content-Type", "application/json")
|
|
common.WriteJSON(response, http.StatusInternalServerError, apiErr.NewErrorList(apiErr.NewError(apiErr.UNSUPPORTED)))
|
|
|
|
return
|
|
}
|
|
|
|
if !permissions.Allowed {
|
|
response.Header().Set("Content-Type", "application/json")
|
|
response.Header().Set("WWW-Authenticate", permissions.WWWAuthenticateHeader)
|
|
|
|
common.WriteJSON(response, http.StatusUnauthorized,
|
|
apiErr.NewErrorList(apiErr.NewError(apiErr.UNAUTHORIZED)))
|
|
|
|
return
|
|
}
|
|
|
|
amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request)
|
|
next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck
|
|
})
|
|
}
|
|
}
|
|
|
|
func noPasswdAuth(config *config.Config) mux.MiddlewareFunc {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method == http.MethodOptions {
|
|
next.ServeHTTP(response, request)
|
|
response.WriteHeader(http.StatusNoContent)
|
|
|
|
return
|
|
}
|
|
|
|
ctx := getReqContextWithAuthorization("", []string{}, request)
|
|
// Process request
|
|
next.ServeHTTP(response, request.WithContext(ctx)) //nolint:contextcheck
|
|
})
|
|
}
|
|
}
|
|
|
|
func (rh *RouteHandler) AuthURLHandler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query()
|
|
callbackUI := query.Get(constants.CallbackUIQueryParam)
|
|
|
|
provider := query.Get("provider")
|
|
|
|
client, ok := rh.c.RelyingParties[provider]
|
|
if !ok {
|
|
http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
response.WriteHeader(http.StatusBadRequest)
|
|
})(w, r)
|
|
}
|
|
|
|
/* save cookie containing state to later verify it and
|
|
callback ui where we will redirect after openid/oauth2 logic is completed*/
|
|
session, _ := rh.c.CookieStore.Get(r, "statecookie")
|
|
|
|
session.Options.Secure = true
|
|
session.Options.HttpOnly = true
|
|
session.Options.SameSite = http.SameSiteDefaultMode
|
|
session.Options.Path = constants.CallbackBasePath
|
|
|
|
state := uuid.New().String()
|
|
|
|
session.Values["state"] = state
|
|
session.Values["callback"] = callbackUI
|
|
|
|
// let the session set its own id
|
|
err := session.Save(r, w)
|
|
if err != nil {
|
|
rh.c.Log.Error().Err(err).Msg("unable to save http session")
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
stateFunc := func() string {
|
|
return state
|
|
}
|
|
|
|
rp.AuthURLHandler(stateFunc, client)(w, r)
|
|
}
|
|
}
|
|
|
|
func NewRelyingPartyOIDC(config *config.Config, provider string) rp.RelyingParty {
|
|
issuer, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config, provider)
|
|
|
|
relyingParty, err := rp.NewRelyingPartyOIDC(issuer, clientID, clientSecret, redirectURI, scopes, options...)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return relyingParty
|
|
}
|
|
|
|
func NewRelyingPartyGithub(config *config.Config, provider string) rp.RelyingParty {
|
|
_, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config, provider)
|
|
|
|
rpConfig := &oauth2.Config{
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURL: redirectURI,
|
|
Scopes: scopes,
|
|
Endpoint: githubOAuth.Endpoint,
|
|
}
|
|
|
|
relyingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
return relyingParty
|
|
}
|
|
|
|
func getRelyingPartyArgs(config *config.Config, provider string) (
|
|
string, string, string, string, []string, []rp.Option,
|
|
) {
|
|
if _, ok := config.HTTP.Auth.OpenID.Providers[provider]; !ok {
|
|
panic(zerr.ErrOpenIDProviderDoesNotExist)
|
|
}
|
|
|
|
scheme := "http"
|
|
if config.HTTP.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
|
|
clientID := config.HTTP.Auth.OpenID.Providers[provider].ClientID
|
|
clientSecret := config.HTTP.Auth.OpenID.Providers[provider].ClientSecret
|
|
|
|
scopes := config.HTTP.Auth.OpenID.Providers[provider].Scopes
|
|
// openid scope must be the first one in list
|
|
if !common.Contains(scopes, oidc.ScopeOpenID) && IsOpenIDSupported(provider) {
|
|
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
|
|
}
|
|
|
|
port := config.HTTP.Port
|
|
issuer := config.HTTP.Auth.OpenID.Providers[provider].Issuer
|
|
keyPath := config.HTTP.Auth.OpenID.Providers[provider].KeyPath
|
|
baseURL := net.JoinHostPort(config.HTTP.Address, port)
|
|
redirectURI := fmt.Sprintf("%s://%s%s", scheme, baseURL, constants.CallbackBasePath+fmt.Sprintf("/%s", provider))
|
|
|
|
options := []rp.Option{
|
|
rp.WithVerifierOpts(rp.WithIssuedAtOffset(issuedAtOffset)),
|
|
}
|
|
|
|
key := securecookie.GenerateRandomKey(32) //nolint: gomnd
|
|
|
|
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithMaxAge(relyingPartyCookieMaxAge))
|
|
options = append(options, rp.WithCookieHandler(cookieHandler))
|
|
|
|
if clientSecret == "" {
|
|
options = append(options, rp.WithPKCE(cookieHandler))
|
|
}
|
|
|
|
if keyPath != "" {
|
|
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyPath(keyPath)))
|
|
}
|
|
|
|
return issuer, clientID, clientSecret, redirectURI, scopes, options
|
|
}
|
|
|
|
func getReqContextWithAuthorization(username string, groups []string, request *http.Request) context.Context {
|
|
acCtx := localCtx.AccessControlContext{
|
|
Username: username,
|
|
Groups: groups,
|
|
}
|
|
|
|
authzCtxKey := localCtx.GetContextKey()
|
|
ctx := context.WithValue(request.Context(), authzCtxKey, acCtx)
|
|
|
|
return ctx
|
|
}
|
|
|
|
func isAuthnEnabled(config *config.Config) bool {
|
|
if config.HTTP.Auth != nil &&
|
|
(config.HTTP.Auth.HTPasswd.Path != "" || config.HTTP.Auth.LDAP != nil) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isBearerAuthEnabled(config *config.Config) bool {
|
|
if config.HTTP.Auth != nil &&
|
|
config.HTTP.Auth.Bearer != nil &&
|
|
config.HTTP.Auth.Bearer.Cert != "" &&
|
|
config.HTTP.Auth.Bearer.Realm != "" &&
|
|
config.HTTP.Auth.Bearer.Service != "" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isOpenIDAuthEnabled(config *config.Config) bool {
|
|
if config.HTTP.Auth != nil &&
|
|
config.HTTP.Auth.OpenID != nil {
|
|
for provider := range config.HTTP.Auth.OpenID.Providers {
|
|
if isOpenIDAuthProviderEnabled(config, provider) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isAPIKeyEnabled(config *config.Config) bool {
|
|
if config.Extensions != nil && config.Extensions.APIKey != nil &&
|
|
*config.Extensions.APIKey.Enable {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func isOpenIDAuthProviderEnabled(config *config.Config, provider string) bool {
|
|
if providerConfig, ok := config.HTTP.Auth.OpenID.Providers[provider]; ok {
|
|
if IsOpenIDSupported(provider) {
|
|
if providerConfig.ClientID != "" || providerConfig.Issuer != "" ||
|
|
len(providerConfig.Scopes) > 0 {
|
|
return true
|
|
}
|
|
} else if IsOauth2Supported(provider) {
|
|
if providerConfig.ClientID != "" || len(providerConfig.Scopes) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func IsOpenIDSupported(provider string) bool {
|
|
supported := []string{"google", "gitlab", "dex"}
|
|
|
|
return common.Contains(supported, provider)
|
|
}
|
|
|
|
func IsOauth2Supported(provider string) bool {
|
|
supported := []string{"github"}
|
|
|
|
return common.Contains(supported, provider)
|
|
}
|
|
|
|
func authFail(w http.ResponseWriter, r *http.Request, realm string, delay int) {
|
|
time.Sleep(time.Duration(delay) * time.Second)
|
|
|
|
// don't send auth headers if request is coming from UI
|
|
if r.Header.Get(constants.SessionClientHeaderName) != constants.SessionClientHeaderValue {
|
|
if realm == "" {
|
|
realm = "Authorization Required"
|
|
}
|
|
|
|
realm = "Basic realm=" + strconv.Quote(realm)
|
|
|
|
w.Header().Set("WWW-Authenticate", realm)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
common.WriteJSON(w, http.StatusUnauthorized, apiErr.NewErrorList(apiErr.NewError(apiErr.UNAUTHORIZED)))
|
|
}
|
|
|
|
func getUsernamePasswordBasicAuth(request *http.Request) (string, string, error) {
|
|
basicAuth := request.Header.Get("Authorization")
|
|
|
|
if basicAuth == "" {
|
|
return "", "", zerr.ErrParsingAuthHeader
|
|
}
|
|
|
|
splitStr := strings.SplitN(basicAuth, " ", 2) //nolint: gomnd
|
|
if len(splitStr) != 2 || strings.ToLower(splitStr[0]) != "basic" {
|
|
return "", "", zerr.ErrParsingAuthHeader
|
|
}
|
|
|
|
decodedStr, err := base64.StdEncoding.DecodeString(splitStr[1])
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
pair := strings.SplitN(string(decodedStr), ":", 2) //nolint: gomnd
|
|
if len(pair) != 2 { //nolint: gomnd
|
|
return "", "", zerr.ErrParsingAuthHeader
|
|
}
|
|
|
|
username := pair[0]
|
|
passphrase := pair[1]
|
|
|
|
return username, passphrase, nil
|
|
}
|
|
|
|
func GetGithubUserInfo(ctx context.Context, client *github.Client, log log.Logger) (string, []string, error) {
|
|
var primaryEmail string
|
|
|
|
userEmails, _, err := client.Users.ListEmails(ctx, nil)
|
|
if err != nil {
|
|
log.Error().Msg("couldn't set user record for empty email value")
|
|
|
|
return "", []string{}, err
|
|
}
|
|
|
|
if len(userEmails) != 0 {
|
|
for _, email := range userEmails { // should have at least one primary email, if any
|
|
if email.GetPrimary() { // check if it's primary email
|
|
primaryEmail = email.GetEmail()
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
orgs, _, err := client.Organizations.List(ctx, "", nil)
|
|
if err != nil {
|
|
log.Error().Msg("couldn't set user record for empty email value")
|
|
|
|
return "", []string{}, err
|
|
}
|
|
|
|
groups := []string{}
|
|
for _, org := range orgs {
|
|
groups = append(groups, *org.Login)
|
|
}
|
|
|
|
return primaryEmail, groups, nil
|
|
}
|
|
|
|
func saveUserLoggedSession(cookieStore sessions.Store, response http.ResponseWriter,
|
|
request *http.Request, identity string, log log.Logger,
|
|
) error {
|
|
session, _ := cookieStore.Get(request, "session")
|
|
|
|
session.Options.Secure = true
|
|
session.Options.HttpOnly = true
|
|
session.Options.SameSite = http.SameSiteDefaultMode
|
|
session.Values["authStatus"] = true
|
|
session.Values["user"] = identity
|
|
|
|
// let the session set its own id
|
|
err := session.Save(request, response)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("identity", identity).Msg("unable to save http session")
|
|
|
|
return err
|
|
}
|
|
|
|
userInfoCookie := sessions.NewCookie("user", identity, &sessions.Options{
|
|
Secure: true,
|
|
HttpOnly: false,
|
|
MaxAge: cookiesMaxAge,
|
|
SameSite: http.SameSiteDefaultMode,
|
|
Path: "/",
|
|
})
|
|
|
|
http.SetCookie(response, userInfoCookie)
|
|
|
|
return nil
|
|
}
|
|
|
|
// OAuth2Callback is the callback logic where openid/oauth2 will redirect back to our app.
|
|
func OAuth2Callback(ctlr *Controller, w http.ResponseWriter, r *http.Request, state, email string,
|
|
groups []string,
|
|
) (string, error) {
|
|
stateCookie, _ := ctlr.CookieStore.Get(r, "statecookie")
|
|
|
|
stateOrigin, ok := stateCookie.Values["state"].(string)
|
|
if !ok {
|
|
ctlr.Log.Error().Err(zerr.ErrInvalidStateCookie).Msg("openID: unable to get 'state' cookie from request")
|
|
|
|
return "", zerr.ErrInvalidStateCookie
|
|
}
|
|
|
|
if stateOrigin != state {
|
|
ctlr.Log.Error().Err(zerr.ErrInvalidStateCookie).Msg("openID: 'state' cookie differs from the actual one")
|
|
|
|
return "", zerr.ErrInvalidStateCookie
|
|
}
|
|
|
|
ctx := getReqContextWithAuthorization(email, groups, r)
|
|
|
|
// if this line has been reached, then a new session should be created
|
|
// if the `session` key is already on the cookie, it's not a valid one
|
|
if err := saveUserLoggedSession(ctlr.CookieStore, w, r, email, ctlr.Log); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := ctlr.RepoDB.SetUserGroups(ctx, groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", email).Msg("couldn't update the user profile")
|
|
|
|
return "", err
|
|
}
|
|
|
|
ctlr.Log.Info().Msgf("user profile set successfully for email %s", email)
|
|
|
|
// redirect to UI
|
|
callbackUI, _ := stateCookie.Values["callback"].(string)
|
|
|
|
return callbackUI, nil
|
|
}
|
|
|
|
func hashUUID(uuid string) string {
|
|
digester := sha256.New()
|
|
digester.Write([]byte(uuid))
|
|
|
|
return godigest.NewDigestFromEncoded(godigest.SHA256, fmt.Sprintf("%x", digester.Sum(nil))).Encoded()
|
|
}
|