mirror of
https://github.com/project-zot/zot.git
synced 2024-12-16 21:56:37 -05:00
7f6534a52d
Signed-off-by: Petu Eusebiu <peusebiu@cisco.com>
885 lines
24 KiB
Go
885 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chartmuseum/auth"
|
|
guuid "github.com/gofrs/uuid"
|
|
"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"
|
|
zcommon "zotregistry.io/zot/pkg/common"
|
|
"zotregistry.io/zot/pkg/log"
|
|
reqCtx "zotregistry.io/zot/pkg/requestcontext"
|
|
)
|
|
|
|
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 ctlr.Config.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("can not 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
|
|
|
|
identity, passphrase, err := getUsernamePasswordBasicAuth(request)
|
|
if err != nil {
|
|
ctlr.Log.Error().Err(err).Msg("failed to parse authorization header")
|
|
|
|
return false, 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)
|
|
}
|
|
|
|
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("couldn't 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 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...)
|
|
|
|
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("couldn't update user profile")
|
|
|
|
return false, err
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
// last try API keys
|
|
if ctlr.Config.IsAPIKeyEnabled() {
|
|
apiKey := passphrase
|
|
|
|
if !strings.HasPrefix(apiKey, constants.APIKeysPrefix) {
|
|
ctlr.Log.Error().Msg("api token has invalid 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("can not find any user info for hashed key %s in DB", hashedKey)
|
|
|
|
return false, nil
|
|
}
|
|
|
|
ctlr.Log.Error().Err(err).Msgf("can not 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("can not 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("can not 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("can not 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
|
|
// no password based authN, if neither LDAP nor HTTP BASIC is enabled
|
|
if !ctlr.Config.IsBasicAuthnEnabled() {
|
|
return noPasswdAuth(ctlr)
|
|
}
|
|
|
|
amw.credMap = make(map[string]string)
|
|
|
|
delay := ctlr.Config.HTTP.Auth.FailDelay
|
|
|
|
// ldap and htpasswd based authN
|
|
if ctlr.Config.IsLdapAuthEnabled() {
|
|
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.IsHtpasswdAuthEnabled() {
|
|
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.IsOpenIDAuthEnabled() {
|
|
ctlr.RelyingParties = make(map[string]rp.RelyingParty)
|
|
|
|
for provider := range ctlr.Config.HTTP.Auth.OpenID.Providers {
|
|
if config.IsOpenIDSupported(provider) {
|
|
rp := NewRelyingPartyOIDC(ctlr.Config, provider)
|
|
ctlr.RelyingParties[provider] = rp
|
|
} else if config.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
|
|
}
|
|
|
|
isMgmtRequested := request.RequestURI == constants.FullMgmt
|
|
allowAnonymous := ctlr.Config.HTTP.AccessControl.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("can not find user profile in DB")
|
|
|
|
authFail(response, request, ctlr.Config.HTTP.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, ctlr.Config.HTTP.Realm, 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.FullMgmt
|
|
|
|
header := request.Header.Get("Authorization")
|
|
|
|
if isAuthorizationHeaderEmpty(request) && 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")
|
|
zcommon.WriteJSON(response, http.StatusInternalServerError, apiErr.NewError(apiErr.UNSUPPORTED))
|
|
|
|
return
|
|
}
|
|
|
|
if !permissions.Allowed {
|
|
response.Header().Set("Content-Type", "application/json")
|
|
response.Header().Set("WWW-Authenticate", permissions.WWWAuthenticateHeader)
|
|
|
|
zcommon.WriteJSON(response, http.StatusUnauthorized, apiErr.NewError(apiErr.UNAUTHORIZED))
|
|
|
|
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() {
|
|
authFail(response, request, ctlr.Config.HTTP.Realm, ctlr.Config.HTTP.Auth.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("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("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(cfg *config.Config, provider string) (
|
|
string, string, string, string, []string, []rp.Option,
|
|
) {
|
|
if _, ok := cfg.HTTP.Auth.OpenID.Providers[provider]; !ok {
|
|
panic(zerr.ErrOpenIDProviderDoesNotExist)
|
|
}
|
|
|
|
clientID := cfg.HTTP.Auth.OpenID.Providers[provider].ClientID
|
|
clientSecret := cfg.HTTP.Auth.OpenID.Providers[provider].ClientSecret
|
|
|
|
scopes := cfg.HTTP.Auth.OpenID.Providers[provider].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 := cfg.HTTP.Auth.OpenID.Providers[provider].Issuer
|
|
keyPath := cfg.HTTP.Auth.OpenID.Providers[provider].KeyPath
|
|
baseURL := net.JoinHostPort(cfg.HTTP.Address, port)
|
|
|
|
callback := constants.CallbackBasePath + fmt.Sprintf("/%s", 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)),
|
|
}
|
|
|
|
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 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: 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
|
|
}
|
|
|
|
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("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()
|
|
}
|
|
|
|
/*
|
|
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("can not 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("can not get `user` session value")
|
|
|
|
return "", false
|
|
}
|
|
|
|
identity, ok := session.Values["user"].(string)
|
|
if !ok {
|
|
log.Error().Msg("can not 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("unable 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("unable to generate uuid for api key id")
|
|
|
|
return "", "", err
|
|
}
|
|
|
|
return apiKey, apiKeyID.String(), err
|
|
}
|