mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 21:17:58 +08:00
dfb5d1df54
* fix: make config read/write thread safe and fix some other similar issues 1. The config config has a lock, and safe methods to update and read the attributes 2. The config has methods to retrieve copies of specific attributes, such as the extyensions config, the auth config, and the authz config. These are needed, as the config object may mutate in the middle of an auth/authz requests, and we avoid partial configuration being applied for that request. 3. Fix an issue with the monitoring server not stopping when the controller is shut down. 4. Fix an issue with the HTPasswdWatcher not stopping when the background tasks are supposed to finish. 5. Fix some tests using hardcoded ports. Moved some of the methods which were on the main config to the auth, access control and extension configs Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
959 lines
26 KiB
Go
959 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
guuid "github.com/gofrs/uuid"
|
|
"github.com/google/go-github/v62/github"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/mux"
|
|
"github.com/gorilla/sessions"
|
|
godigest "github.com/opencontainers/go-digest"
|
|
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
|
httphelper "github.com/zitadel/oidc/v3/pkg/http"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
"golang.org/x/oauth2"
|
|
githubOAuth "golang.org/x/oauth2/github"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
"zotregistry.dev/zot/v2/pkg/api/config"
|
|
"zotregistry.dev/zot/v2/pkg/api/constants"
|
|
apiErr "zotregistry.dev/zot/v2/pkg/api/errors"
|
|
zcommon "zotregistry.dev/zot/v2/pkg/common"
|
|
"zotregistry.dev/zot/v2/pkg/log"
|
|
reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext"
|
|
)
|
|
|
|
const (
|
|
issuedAtOffset = 5 * time.Second
|
|
relyingPartyCookieMaxAge = 120
|
|
)
|
|
|
|
type AuthnMiddleware struct {
|
|
htpasswd *HTPasswd
|
|
ldapClient *LDAPClient
|
|
log log.Logger
|
|
}
|
|
|
|
func AuthHandler(ctlr *Controller) mux.MiddlewareFunc {
|
|
authnMiddleware := &AuthnMiddleware{
|
|
htpasswd: ctlr.HTPasswd,
|
|
log: ctlr.Log,
|
|
}
|
|
|
|
authConfig := ctlr.Config.CopyAuthConfig()
|
|
if authConfig.IsBearerAuthEnabled() {
|
|
return bearerAuthHandler(ctlr)
|
|
}
|
|
|
|
return authnMiddleware.tryAuthnHandlers(ctlr)
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) sessionAuthn(ctlr *Controller, userAc *reqCtx.UserAccessControl,
|
|
response http.ResponseWriter, request *http.Request,
|
|
) (bool, error) {
|
|
identity, ok := 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)
|
|
|
|
return false, nil
|
|
}
|
|
|
|
userAc.SetUsername(identity)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
groups, err := ctlr.MetaDB.GetUserGroups(request.Context())
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("failed to get user profile in DB")
|
|
|
|
return false, err
|
|
}
|
|
|
|
userAc.AddGroups(groups)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) basicAuthn(ctlr *Controller, userAc *reqCtx.UserAccessControl,
|
|
response http.ResponseWriter, request *http.Request,
|
|
) (bool, error) {
|
|
cookieStore := ctlr.CookieStore
|
|
|
|
// Get auth config once to avoid multiple calls
|
|
authConfig := ctlr.Config.CopyAuthConfig()
|
|
if authConfig == nil {
|
|
return false, nil
|
|
}
|
|
|
|
identity, passphrase, err := getUsernamePasswordBasicAuth(request)
|
|
if err != nil {
|
|
ctlr.Log.Error().Err(err).Msg("failed to parse authorization header")
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// first, HTTPPassword authN (which is local)
|
|
htOk, _ := amw.htpasswd.Authenticate(identity, passphrase)
|
|
if htOk {
|
|
// Process request
|
|
var groups []string
|
|
|
|
accessControl := ctlr.Config.CopyAccessControlConfig()
|
|
if accessControl != nil {
|
|
ac := NewAccessController(ctlr.Config)
|
|
groups = ac.getUserGroups(identity)
|
|
}
|
|
|
|
userAc.SetUsername(identity)
|
|
userAc.AddGroups(groups)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
// saved logged session only if the request comes from web (has UI session header value)
|
|
if hasSessionHeader(request) {
|
|
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// we have already populated the request context with userAc
|
|
if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("failed to update user profile")
|
|
|
|
return false, err
|
|
}
|
|
|
|
ctlr.Log.Info().Str("identity", identity).Msgf("user profile successfully set")
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// next, LDAP if configured (network-based which can lose connectivity)
|
|
if authConfig.IsLdapAuthEnabled() {
|
|
ok, _, ldapgroups, err := amw.ldapClient.Authenticate(identity, passphrase)
|
|
if ok && err == nil {
|
|
// Process request
|
|
var groups []string
|
|
|
|
accessControl := ctlr.Config.CopyAccessControlConfig()
|
|
if accessControl != nil {
|
|
ac := NewAccessController(ctlr.Config)
|
|
groups = ac.getUserGroups(identity)
|
|
}
|
|
|
|
groups = append(groups, ldapgroups...)
|
|
|
|
userAc.SetUsername(identity)
|
|
userAc.AddGroups(groups)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
// saved logged session only if the request comes from web (has UI session header value)
|
|
if hasSessionHeader(request) {
|
|
if err := saveUserLoggedSession(cookieStore, response, request, identity, ctlr.Log); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
// we have already populated the request context with userAc
|
|
if err := ctlr.MetaDB.SetUserGroups(request.Context(), groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", identity).Msg("failed to update user profile")
|
|
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
// last try API keys
|
|
if authConfig.IsAPIKeyEnabled() {
|
|
apiKey := passphrase
|
|
|
|
if !strings.HasPrefix(apiKey, constants.APIKeysPrefix) {
|
|
ctlr.Log.Error().Msg("invalid api token format")
|
|
|
|
return false, nil
|
|
}
|
|
|
|
trimmedAPIKey := strings.TrimPrefix(apiKey, constants.APIKeysPrefix)
|
|
|
|
hashedKey := hashUUID(trimmedAPIKey)
|
|
|
|
storedIdentity, err := ctlr.MetaDB.GetUserAPIKeyInfo(hashedKey)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrUserAPIKeyNotFound) {
|
|
ctlr.Log.Info().Err(err).Msgf("failed to find any user info for hashed key %s in DB", hashedKey)
|
|
|
|
return false, nil
|
|
}
|
|
|
|
ctlr.Log.Error().Err(err).Msgf("failed to get user info for hashed key %s in DB", hashedKey)
|
|
|
|
return false, err
|
|
}
|
|
|
|
if storedIdentity == identity {
|
|
userAc.SetUsername(identity)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
// check if api key expired
|
|
isExpired, err := ctlr.MetaDB.IsAPIKeyExpired(request.Context(), hashedKey)
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("failed to verify if api key expired")
|
|
|
|
return false, err
|
|
}
|
|
|
|
if isExpired {
|
|
return false, nil
|
|
}
|
|
|
|
err = ctlr.MetaDB.UpdateUserAPIKeyLastUsed(request.Context(), hashedKey)
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("failed to update user profile in DB")
|
|
|
|
return false, err
|
|
}
|
|
|
|
groups, err := ctlr.MetaDB.GetUserGroups(request.Context())
|
|
if err != nil {
|
|
ctlr.Log.Err(err).Str("identity", identity).Msg("failed to get user's groups in DB")
|
|
|
|
return false, err
|
|
}
|
|
|
|
userAc.AddGroups(groups)
|
|
userAc.SaveOnRequest(request)
|
|
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func (amw *AuthnMiddleware) tryAuthnHandlers(ctlr *Controller) mux.MiddlewareFunc { //nolint: gocyclo
|
|
// Get auth config once to avoid multiple calls
|
|
authConfig := ctlr.Config.CopyAuthConfig()
|
|
|
|
// no password based authN, if neither LDAP nor HTTP BASIC is enabled
|
|
if !authConfig.IsBasicAuthnEnabled() {
|
|
return noPasswdAuth(ctlr)
|
|
}
|
|
|
|
delay := authConfig.GetFailDelay()
|
|
realm := ctlr.Config.GetRealm()
|
|
|
|
// ldap and htpasswd based authN
|
|
if authConfig.IsLdapAuthEnabled() {
|
|
ldapConfig := authConfig.LDAP
|
|
|
|
ctlr.LDAPClient = &LDAPClient{
|
|
Host: ldapConfig.Address,
|
|
Port: ldapConfig.Port,
|
|
UseSSL: !ldapConfig.Insecure,
|
|
SkipTLS: !ldapConfig.StartTLS,
|
|
Base: ldapConfig.BaseDN,
|
|
BindDN: ldapConfig.BindDN(),
|
|
BindPassword: ldapConfig.BindPassword(),
|
|
UserGroupAttribute: ldapConfig.UserGroupAttribute, // from config
|
|
UserAttribute: ldapConfig.UserAttribute,
|
|
UserFilter: ldapConfig.UserFilter,
|
|
InsecureSkipVerify: ldapConfig.SkipVerify,
|
|
ServerName: ldapConfig.Address,
|
|
Log: ctlr.Log,
|
|
SubtreeSearch: ldapConfig.SubtreeSearch,
|
|
}
|
|
|
|
amw.ldapClient = ctlr.LDAPClient
|
|
|
|
if authConfig.LDAP.CACert != "" {
|
|
caCert, err := os.ReadFile(authConfig.LDAP.CACert)
|
|
if err != nil {
|
|
amw.log.Panic().Err(err).Str("caCert", authConfig.LDAP.CACert).
|
|
Msg("failed to read caCert")
|
|
}
|
|
|
|
caCertPool := x509.NewCertPool()
|
|
|
|
if !caCertPool.AppendCertsFromPEM(caCert) {
|
|
amw.log.Panic().Err(zerr.ErrBadCACert).Str("caCert", authConfig.LDAP.CACert).
|
|
Msg("failed to read caCert")
|
|
}
|
|
|
|
amw.ldapClient.ClientCAs = caCertPool
|
|
} else {
|
|
// default to system cert pool
|
|
caCertPool, err := x509.SystemCertPool()
|
|
if err != nil {
|
|
amw.log.Panic().Err(zerr.ErrBadCACert).Str("caCert", authConfig.LDAP.CACert).
|
|
Msg("failed to get system cert pool")
|
|
}
|
|
|
|
amw.ldapClient.ClientCAs = caCertPool
|
|
}
|
|
}
|
|
|
|
if authConfig.IsHtpasswdAuthEnabled() {
|
|
err := amw.htpasswd.Reload(authConfig.HTPasswd.Path)
|
|
if err != nil {
|
|
amw.log.Panic().Err(err).Str("credsFile", authConfig.HTPasswd.Path).
|
|
Msg("failed to open creds-file")
|
|
}
|
|
}
|
|
|
|
// openid based authN
|
|
if authConfig.IsOpenIDAuthEnabled() {
|
|
ctlr.RelyingParties = make(map[string]rp.RelyingParty)
|
|
|
|
for provider := range authConfig.OpenID.Providers {
|
|
if config.IsOpenIDSupported(provider) {
|
|
rp := NewRelyingPartyOIDC(context.TODO(), ctlr.Config, provider, authConfig.SessionHashKey,
|
|
authConfig.SessionEncryptKey, ctlr.Log)
|
|
ctlr.RelyingParties[provider] = rp
|
|
} else if config.IsOauth2Supported(provider) {
|
|
rp := NewRelyingPartyGithub(ctlr.Config, provider, authConfig.SessionHashKey,
|
|
authConfig.SessionEncryptKey, ctlr.Log)
|
|
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
|
|
}
|
|
|
|
isMgmtRequested := request.RequestURI == constants.FullMgmt
|
|
|
|
// Get access control config safely
|
|
accessControlConfig := ctlr.Config.CopyAccessControlConfig()
|
|
allowAnonymous := accessControlConfig != nil && accessControlConfig.AnonymousPolicyExists()
|
|
|
|
// build user access control info
|
|
userAc := reqCtx.NewUserAccessControl()
|
|
// if it will not be populated by authn handlers, this represents an anonymous user
|
|
userAc.SaveOnRequest(request)
|
|
|
|
// try basic auth if authorization header is given
|
|
if !isAuthorizationHeaderEmpty(request) { //nolint: gocritic
|
|
//nolint: contextcheck
|
|
authenticated, err := amw.basicAuthn(ctlr, userAc, response, request)
|
|
if err != nil {
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if authenticated {
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
} else if hasSessionHeader(request) {
|
|
// try session auth
|
|
//nolint: contextcheck
|
|
authenticated, err := amw.sessionAuthn(ctlr, userAc, response, request)
|
|
if err != nil {
|
|
if errors.Is(err, zerr.ErrUserDataNotFound) {
|
|
ctlr.Log.Err(err).Msg("failed to find user profile in DB")
|
|
|
|
authFail(response, request, realm, delay)
|
|
}
|
|
|
|
response.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
if authenticated {
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
|
|
// the session header can be present also for anonymous calls
|
|
if allowAnonymous || isMgmtRequested {
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
} else if allowAnonymous || isMgmtRequested {
|
|
// try anonymous auth only if basic auth/session was not given
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
|
|
authFail(response, request, realm, delay)
|
|
})
|
|
}
|
|
}
|
|
|
|
func bearerAuthHandler(ctlr *Controller) mux.MiddlewareFunc {
|
|
// Get auth config safely
|
|
authConfig := ctlr.Config.CopyAuthConfig()
|
|
|
|
// although the configuration option is called 'cert', this function will also parse a public key directly
|
|
// see https://github.com/project-zot/zot/issues/3173 for info
|
|
publicKey, err := loadPublicKeyFromFile(authConfig.Bearer.Cert)
|
|
if err != nil {
|
|
ctlr.Log.Panic().Err(err).Msg("failed to load public key for bearer authentication")
|
|
}
|
|
|
|
authorizer := NewBearerAuthorizer(
|
|
authConfig.Bearer.Realm,
|
|
authConfig.Bearer.Service,
|
|
publicKey,
|
|
)
|
|
|
|
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)
|
|
|
|
// we want to bypass auth for mgmt route
|
|
isMgmtRequested := request.RequestURI == constants.FullMgmt
|
|
|
|
header := request.Header.Get("Authorization")
|
|
|
|
if isAuthorizationHeaderEmpty(request) && isMgmtRequested {
|
|
next.ServeHTTP(response, request)
|
|
|
|
return
|
|
}
|
|
|
|
var requestedAccess *ResourceAction
|
|
|
|
if request.RequestURI != "/v2/" {
|
|
// if this is not the base route, the requested repository/action must be authorized
|
|
vars := mux.Vars(request)
|
|
name := vars["name"]
|
|
|
|
action := "pull"
|
|
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
|
|
action = "push"
|
|
}
|
|
|
|
requestedAccess = &ResourceAction{
|
|
Type: "repository",
|
|
Name: name,
|
|
Action: action,
|
|
}
|
|
}
|
|
|
|
err := authorizer.Authorize(header, requestedAccess)
|
|
if err != nil {
|
|
var challenge *AuthChallengeError
|
|
if errors.As(err, &challenge) {
|
|
ctlr.Log.Debug().Err(challenge).Msg("bearer token authorization failed")
|
|
response.Header().Set("Content-Type", "application/json")
|
|
response.Header().Set("WWW-Authenticate", challenge.Header())
|
|
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
|
|
|
|
return
|
|
}
|
|
|
|
ctlr.Log.Error().Err(err).Msg("failed to parse Authorization header")
|
|
response.Header().Set("Content-Type", "application/json")
|
|
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNSUPPORTED))
|
|
|
|
return
|
|
}
|
|
|
|
amCtx := acCtrlr.getAuthnMiddlewareContext(BEARER, request)
|
|
next.ServeHTTP(response, request.WithContext(amCtx)) //nolint:contextcheck
|
|
})
|
|
}
|
|
}
|
|
|
|
func noPasswdAuth(ctlr *Controller) 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
|
|
}
|
|
|
|
userAc := reqCtx.NewUserAccessControl()
|
|
|
|
// if no basic auth enabled then try to get identity from mTLS auth
|
|
if request.TLS != nil {
|
|
verifiedChains := request.TLS.VerifiedChains
|
|
if len(verifiedChains) > 0 && len(verifiedChains[0]) > 0 {
|
|
for _, cert := range request.TLS.PeerCertificates {
|
|
identity := cert.Subject.CommonName
|
|
if identity != "" {
|
|
// assign identity to authz context, needed for extensions
|
|
userAc.SetUsername(identity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ctlr.Config.IsMTLSAuthEnabled() && userAc.IsAnonymous() {
|
|
authConfig := ctlr.Config.CopyAuthConfig()
|
|
failDelay := authConfig.GetFailDelay()
|
|
realm := ctlr.Config.GetRealm()
|
|
|
|
authFail(response, request, realm, failDelay)
|
|
|
|
return
|
|
}
|
|
|
|
userAc.SaveOnRequest(request)
|
|
|
|
// Process request
|
|
next.ServeHTTP(response, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
rh.c.Log.Error().Msg("failed to authenticate due to unrecognized openid provider")
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
/* 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("failed to save http session")
|
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
stateFunc := func() string {
|
|
return state
|
|
}
|
|
|
|
rp.AuthURLHandler(stateFunc, client)(w, r)
|
|
}
|
|
}
|
|
|
|
func NewRelyingPartyOIDC(ctx context.Context, config *config.Config, provider string,
|
|
hashKey, encryptKey []byte, log log.Logger,
|
|
) rp.RelyingParty {
|
|
issuer, clientID, clientSecret, redirectURI, scopes, options := getRelyingPartyArgs(config,
|
|
provider, hashKey, encryptKey, log)
|
|
|
|
relyingParty, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopes, options...)
|
|
if err != nil {
|
|
log.Panic().Err(err).Str("issuer", issuer).Str("redirectURI", redirectURI).Strs("scopes", scopes).
|
|
Msg("failed to initialize new relying party oidc")
|
|
}
|
|
|
|
return relyingParty
|
|
}
|
|
|
|
func NewRelyingPartyGithub(config *config.Config, provider string, hashKey, encryptKey []byte, log log.Logger,
|
|
) rp.RelyingParty {
|
|
_, clientID, clientSecret, redirectURI, scopes,
|
|
options := getRelyingPartyArgs(config, provider, hashKey, encryptKey, log)
|
|
|
|
rpConfig := &oauth2.Config{
|
|
ClientID: clientID,
|
|
ClientSecret: clientSecret,
|
|
RedirectURL: redirectURI,
|
|
Scopes: scopes,
|
|
Endpoint: githubOAuth.Endpoint,
|
|
}
|
|
|
|
relyingParty, err := rp.NewRelyingPartyOAuth(rpConfig, options...)
|
|
if err != nil {
|
|
log.Panic().Err(err).Str("redirectURI", redirectURI).Strs("scopes", scopes).
|
|
Msg("failed to initialize new relying party oauth")
|
|
}
|
|
|
|
return relyingParty
|
|
}
|
|
|
|
func getRelyingPartyArgs(cfg *config.Config, provider string, hashKey, encryptKey []byte, log log.Logger) (
|
|
string, string, string, string, []string, []rp.Option,
|
|
) {
|
|
if _, ok := cfg.HTTP.Auth.OpenID.Providers[provider]; !ok {
|
|
log.Panic().Err(zerr.ErrOpenIDProviderDoesNotExist).Str("provider", provider).Msg("")
|
|
}
|
|
|
|
providerConfig := cfg.HTTP.Auth.OpenID.Providers[provider]
|
|
clientID := providerConfig.ClientID
|
|
clientSecret := providerConfig.ClientSecret
|
|
|
|
scopes := providerConfig.Scopes
|
|
// openid scope must be the first one in list
|
|
if !zcommon.Contains(scopes, oidc.ScopeOpenID) && config.IsOpenIDSupported(provider) {
|
|
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
|
|
}
|
|
|
|
port := cfg.HTTP.Port
|
|
issuer := providerConfig.Issuer
|
|
keyPath := providerConfig.KeyPath
|
|
baseURL := net.JoinHostPort(cfg.HTTP.Address, port)
|
|
|
|
callback := constants.CallbackBasePath + "/" + provider
|
|
|
|
var redirectURI string
|
|
|
|
if cfg.HTTP.ExternalURL != "" {
|
|
externalURL := strings.TrimSuffix(cfg.HTTP.ExternalURL, "/")
|
|
redirectURI = fmt.Sprintf("%s%s", externalURL, callback)
|
|
} else {
|
|
scheme := "http"
|
|
if cfg.HTTP.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
|
|
redirectURI = fmt.Sprintf("%s://%s%s", scheme, baseURL, callback)
|
|
}
|
|
|
|
options := []rp.Option{
|
|
rp.WithVerifierOpts(rp.WithIssuedAtOffset(issuedAtOffset)),
|
|
}
|
|
|
|
cookieHandler := httphelper.NewCookieHandler(hashKey, encryptKey, 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 authFail(w http.ResponseWriter, r *http.Request, realm string, delay int) {
|
|
if !isAuthorizationHeaderEmpty(r) || hasSessionHeader(r) {
|
|
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")
|
|
zcommon.WriteJSON(w, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
|
|
}
|
|
|
|
func isAuthorizationHeaderEmpty(request *http.Request) bool {
|
|
header := request.Header.Get("Authorization")
|
|
|
|
if header == "" || (strings.ToLower(header) == "basic og==") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func hasSessionHeader(request *http.Request) bool {
|
|
clientHeader := request.Header.Get(constants.SessionClientHeaderName)
|
|
|
|
return clientHeader == constants.SessionClientHeaderValue
|
|
}
|
|
|
|
func getUsernamePasswordBasicAuth(request *http.Request) (string, string, error) {
|
|
basicAuth := request.Header.Get("Authorization")
|
|
|
|
if basicAuth == "" {
|
|
return "", "", zerr.ErrParsingAuthHeader
|
|
}
|
|
|
|
splitStr := strings.SplitN(basicAuth, " ", 2) //nolint:mnd
|
|
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:mnd
|
|
if len(pair) != 2 { //nolint:mnd
|
|
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("failed to 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("failed to 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("failed 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).Str("component", "openID").
|
|
Msg("failed to get 'state' cookie from request")
|
|
|
|
return "", zerr.ErrInvalidStateCookie
|
|
}
|
|
|
|
if stateOrigin != state {
|
|
ctlr.Log.Error().Err(zerr.ErrInvalidStateCookie).Str("component", "openID").
|
|
Msg("'state' cookie differs from the actual one")
|
|
|
|
return "", zerr.ErrInvalidStateCookie
|
|
}
|
|
|
|
userAc := reqCtx.NewUserAccessControl()
|
|
userAc.SetUsername(email)
|
|
userAc.AddGroups(groups)
|
|
userAc.SaveOnRequest(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.MetaDB.SetUserGroups(r.Context(), groups); err != nil {
|
|
ctlr.Log.Error().Err(err).Str("identity", email).Msg("failed to 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, hex.EncodeToString(digester.Sum(nil))).Encoded()
|
|
}
|
|
|
|
/*
|
|
GetAuthUserFromRequestSession returns identity
|
|
and auth status if on the request's cookie session is a logged in user.
|
|
*/
|
|
func GetAuthUserFromRequestSession(cookieStore sessions.Store, request *http.Request, log log.Logger,
|
|
) (string, bool) {
|
|
session, err := cookieStore.Get(request, "session")
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to decode existing session")
|
|
// expired cookie, no need to return err
|
|
return "", false
|
|
}
|
|
|
|
// at this point we should have a session set on cookie.
|
|
// if created in the earlier Get() call then user is not logged in with sessions.
|
|
if session.IsNew {
|
|
return "", false
|
|
}
|
|
|
|
authenticated := session.Values["authStatus"]
|
|
if authenticated != true {
|
|
log.Error().Msg("failed to get `user` session value")
|
|
|
|
return "", false
|
|
}
|
|
|
|
identity, ok := session.Values["user"].(string)
|
|
if !ok {
|
|
log.Error().Msg("failed to get `user` session value")
|
|
|
|
return "", false
|
|
}
|
|
|
|
return identity, true
|
|
}
|
|
|
|
func GenerateAPIKey(uuidGenerator guuid.Generator, log log.Logger,
|
|
) (string, string, error) {
|
|
apiKeyBase, err := uuidGenerator.NewV4()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to generate uuid for api key base")
|
|
|
|
return "", "", err
|
|
}
|
|
|
|
apiKey := strings.ReplaceAll(apiKeyBase.String(), "-", "")
|
|
|
|
// will be used for identifying a specific api key
|
|
apiKeyID, err := uuidGenerator.NewV4()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to generate uuid for api key id")
|
|
|
|
return "", "", err
|
|
}
|
|
|
|
return apiKey, apiKeyID.String(), err
|
|
}
|
|
|
|
func loadPublicKeyFromFile(path string) (crypto.PublicKey, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w, path %s", zerr.ErrCouldNotLoadPublicKey, err, path)
|
|
}
|
|
|
|
block, _ := pem.Decode(raw)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("%w: no valid PEM data found", zerr.ErrCouldNotLoadPublicKey)
|
|
}
|
|
|
|
pemBytes := block.Bytes
|
|
|
|
if cert, err := x509.ParseCertificate(pemBytes); err == nil {
|
|
return cert.PublicKey, nil
|
|
}
|
|
|
|
if key, err := x509.ParsePKIXPublicKey(pemBytes); err == nil {
|
|
return key, nil
|
|
}
|
|
|
|
if key, err := x509.ParsePKCS1PublicKey(pemBytes); err == nil {
|
|
return key, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: no valid x509 certificate or public key found", zerr.ErrCouldNotLoadPublicKey)
|
|
}
|