Initial QSfera import
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
BROWSER=none
|
||||
VITE_KOPANO_BUILD=0.0.0-dev-env
|
||||
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/ignore-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.yarninstall
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.eslintcache
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# i18n
|
||||
src/locales/*.json
|
||||
src/locales/**/*.json
|
||||
|
||||
# yarn
|
||||
.pnp.*
|
||||
!.yarnrc.yml
|
||||
!.yarn/
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# Tools
|
||||
|
||||
YARN ?= yarn
|
||||
|
||||
# Variables
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty --match=v* 2>/dev/null | sed 's/^v//' || \
|
||||
cat $(CURDIR)/../.version 2> /dev/null || echo 0.0.0-unreleased)
|
||||
|
||||
# Build
|
||||
|
||||
.PHONY: all
|
||||
all: build
|
||||
|
||||
.PHONY: build
|
||||
build: vendor | src i18n ; $(info building identifier Webapp ...) @
|
||||
@rm -rf build
|
||||
|
||||
VITE_KOPANO_BUILD="${VERSION}" CI=false $(YARN) run build
|
||||
|
||||
.PHONY: src
|
||||
src:
|
||||
@$(MAKE) -C src
|
||||
|
||||
.PHONY: i18n
|
||||
i18n: vendor
|
||||
@$(MAKE) -C i18n
|
||||
|
||||
.PHONY: lint
|
||||
lint: vendor ; $(info running eslint ...) @
|
||||
@$(YARN) lint . --cache && echo "eslint: no lint errors"
|
||||
|
||||
.PHONY: lint-checkstyle
|
||||
lint-checkstyle: vendor ; $(info running eslint checkstyle ...) @
|
||||
@mkdir -p ../test
|
||||
$(YARN) lint -f checkstyle -o ../test/tests.eslint.xml . || true
|
||||
|
||||
# Yarn
|
||||
|
||||
.PHONY: vendor
|
||||
vendor: .yarninstall
|
||||
|
||||
.yarninstall: package.json ; $(info getting depdencies with yarn ...) @
|
||||
@$(YARN) install --immutable
|
||||
@touch $@
|
||||
|
||||
# Stuff
|
||||
|
||||
.PHONY: licenses
|
||||
licenses:
|
||||
echo "## LibreGraph Connect identifier web app\n"
|
||||
@$(YARN) run licenses
|
||||
|
||||
.PHONY: clean ; $(info cleaning identifier Webapp ...) @
|
||||
clean:
|
||||
$(YARN) cache clean
|
||||
@rm -rf build
|
||||
@rm -rf node_modules
|
||||
@rm -f .yarninstall
|
||||
|
||||
@$(MAKE) -C src clean
|
||||
|
||||
.PHONY: version
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
# LibreGraph Connect Identifier
|
||||
|
||||
Web app for browser sign-in, sign-out and account management.
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
|
||||
"github.com/libregraph/lico/identifier/meta"
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
)
|
||||
|
||||
func (i *Identifier) writeWebappIndexHTML(rw http.ResponseWriter, req *http.Request) {
|
||||
nonce := rndm.GenerateRandomString(32)
|
||||
|
||||
// FIXME(longsleep): Set a secure CSP. Right now we need `data:` for images
|
||||
// since it is used. Since `data:` URLs possibly could allow xss, a better
|
||||
// way should be found for our early loading inline SVG stuff.
|
||||
rw.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'self'; img-src 'self' data:; font-src 'self' data:; script-src 'self' 'nonce-%s'; style-src 'self' 'nonce-%s'; base-uri 'none'; frame-ancestors 'none';", nonce, nonce))
|
||||
|
||||
// Write index with random nonce to response.
|
||||
index := bytes.ReplaceAll(i.webappIndexHTML, []byte("__CSP_NONCE__"), []byte(nonce))
|
||||
rw.Write(index)
|
||||
}
|
||||
|
||||
func (i Identifier) writeHelloResponse(rw http.ResponseWriter, req *http.Request, r *HelloRequest, identifiedUser *IdentifiedUser) (*HelloResponse, error) {
|
||||
var err error
|
||||
response := &HelloResponse{
|
||||
State: r.State,
|
||||
Branding: &meta.Branding{
|
||||
BannerLogo: i.defaultBannerLogo,
|
||||
UsernameHintText: i.Config.DefaultUsernameHintText,
|
||||
SignInPageText: i.Config.DefaultSignInPageText,
|
||||
SignInPageLogoURI: i.Config.DefaultSignInPageLogoURI,
|
||||
Locales: i.Config.UILocales,
|
||||
},
|
||||
}
|
||||
|
||||
handleHelloLoop:
|
||||
for {
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case r.Prompts[oidc.PromptNone] == true:
|
||||
// Never show sign-in, directly return error.
|
||||
return nil, fmt.Errorf("prompt none requested")
|
||||
case r.Prompts[oidc.PromptLogin] == true:
|
||||
// Ignore all potential sources, when prompt login was requested.
|
||||
if identifiedUser != nil {
|
||||
response.Username = identifiedUser.Username()
|
||||
response.DisplayName = identifiedUser.Name()
|
||||
if response.Username != "" {
|
||||
response.Success = true
|
||||
}
|
||||
}
|
||||
break handleHelloLoop
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
if identifiedUser == nil {
|
||||
// Check if logged in via cookie.
|
||||
identifiedUser, err = i.GetUserFromLogonCookie(req.Context(), req, r.MaxAge, true)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode logon cookie in hello")
|
||||
}
|
||||
}
|
||||
|
||||
if identifiedUser != nil {
|
||||
response.Username = identifiedUser.Username()
|
||||
response.DisplayName = identifiedUser.Name()
|
||||
if response.Username != "" {
|
||||
response.Success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
switch r.Flow {
|
||||
case FlowOAuth:
|
||||
fallthrough
|
||||
case FlowConsent:
|
||||
fallthrough
|
||||
case FlowOIDC:
|
||||
// TODO(longsleep): Add something to validate the parameters.
|
||||
clientDetails, err := i.clients.Lookup(req.Context(), r.ClientID, "", r.RedirectURI, "", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
promptConsent := false
|
||||
|
||||
// Check prompt value.
|
||||
switch {
|
||||
case r.Prompts[oidc.PromptConsent] == true:
|
||||
promptConsent = true
|
||||
default:
|
||||
// Let all other prompt values pass.
|
||||
}
|
||||
|
||||
// If not trusted, always force consent.
|
||||
if !clientDetails.Trusted {
|
||||
promptConsent = true
|
||||
}
|
||||
|
||||
if promptConsent {
|
||||
// TODO(longsleep): Filter scopes to scopes we know about and all.
|
||||
response.Next = FlowConsent
|
||||
response.Scopes = r.Scopes
|
||||
response.ClientDetails = clientDetails
|
||||
response.Meta = &meta.Meta{
|
||||
Scopes: scopes.NewScopesFromIDs(r.Scopes, i.meta.Scopes),
|
||||
}
|
||||
}
|
||||
|
||||
// Add authorize endpoint URI as continue URI.
|
||||
response.ContinueURI = i.authorizationEndpointURI.String()
|
||||
response.Flow = r.Flow
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
"github.com/libregraph/lico/identity"
|
||||
)
|
||||
|
||||
// A Backend is an identifier Backend providing functionality to logon and to
|
||||
// fetch user meta data.
|
||||
type Backend interface {
|
||||
RunWithContext(context.Context) error
|
||||
|
||||
Logon(ctx context.Context, audience string, username string, password string) (success bool, userID *string, sessionRef *string, user UserFromBackend, err error)
|
||||
GetUser(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (user UserFromBackend, err error)
|
||||
|
||||
ResolveUserByUsername(ctx context.Context, username string) (user UserFromBackend, err error)
|
||||
|
||||
RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error
|
||||
DestroySession(ctx context.Context, sessionRef *string) error
|
||||
|
||||
UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{}
|
||||
ScopesSupported() []string
|
||||
ScopesMeta() *scopes.Scopes
|
||||
|
||||
Name() string
|
||||
}
|
||||
|
||||
// UserFromBackend are users as provided by backends which can have additional
|
||||
// claims together with a user name.
|
||||
type UserFromBackend interface {
|
||||
identity.UserWithUsername
|
||||
BackendClaims() map[string]interface{}
|
||||
BackendScopes() []string
|
||||
RequiredScopes() []string
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package ldap
|
||||
|
||||
// Define some known LDAP attribute descriptors.
|
||||
const (
|
||||
AttributeDN = "dn"
|
||||
AttributeLogin = "uid"
|
||||
AttributeEmail = "mail"
|
||||
AttributeName = "cn"
|
||||
AttributeFamilyName = "sn"
|
||||
AttributeGivenName = "givenName"
|
||||
AttributeUUID = "uuid"
|
||||
)
|
||||
|
||||
// Additional mappable virtual attributes.
|
||||
const (
|
||||
AttributeNumericUID = "konnectNumericID"
|
||||
)
|
||||
|
||||
// Define our known LDAP attribute value types.
|
||||
const (
|
||||
AttributeValueTypeText = "text"
|
||||
AttributeValueTypeBinary = "binary"
|
||||
AttributeValueTypeUUID = "uuid"
|
||||
)
|
||||
+670
@@ -0,0 +1,670 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
uuid "github.com/gofrs/uuid"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/config"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
)
|
||||
|
||||
const ldapIdentifierBackendName = "identifier-ldap"
|
||||
|
||||
var ldapSupportedScopes = []string{
|
||||
oidc.ScopeProfile,
|
||||
oidc.ScopeEmail,
|
||||
konnect.ScopeUniqueUserID,
|
||||
konnect.ScopeRawSubject,
|
||||
}
|
||||
|
||||
// LDAPIdentifierBackend is a backend for the Identifier which connects LDAP.
|
||||
type LDAPIdentifierBackend struct {
|
||||
addr string
|
||||
isTLS bool
|
||||
bindDN string
|
||||
bindPassword string
|
||||
|
||||
baseDN string
|
||||
scope int
|
||||
searchFilter string
|
||||
getFilter string
|
||||
|
||||
entryIDMapping []string
|
||||
attributeMapping ldapAttributeMapping
|
||||
supportedScopes []string
|
||||
|
||||
logger logrus.FieldLogger
|
||||
dialer *net.Dialer
|
||||
tlsConfig *tls.Config
|
||||
|
||||
timeout int
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
|
||||
type ldapAttributeMapping map[string]string
|
||||
|
||||
var ldapDefaultAttributeMapping = ldapAttributeMapping{
|
||||
AttributeLogin: AttributeLogin,
|
||||
AttributeEmail: AttributeEmail,
|
||||
AttributeName: AttributeName,
|
||||
AttributeFamilyName: AttributeFamilyName,
|
||||
AttributeGivenName: AttributeGivenName,
|
||||
AttributeUUID: AttributeUUID,
|
||||
fmt.Sprintf("%s_type", AttributeUUID): AttributeValueTypeText,
|
||||
}
|
||||
|
||||
func (m ldapAttributeMapping) attributes() []string {
|
||||
attributes := make([]string, len(m)+1)
|
||||
attributes[0] = AttributeDN
|
||||
idx := 1
|
||||
for _, attribute := range m {
|
||||
attributes[idx] = attribute
|
||||
idx++
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
type ldapUser struct {
|
||||
entryID string
|
||||
id int64
|
||||
data ldapAttributeMapping
|
||||
}
|
||||
|
||||
func newLdapUser(entryID string, mapping ldapAttributeMapping, entry *ldap.Entry) (*ldapUser, error) {
|
||||
// Go through all returned attributes, add them to the local data set if
|
||||
// we know them in the mapping.
|
||||
var id int64
|
||||
data := make(ldapAttributeMapping)
|
||||
for _, attribute := range entry.Attributes {
|
||||
if len(attribute.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
for n, mapped := range mapping {
|
||||
// LDAP attribute descriptors / short names are case insensitive. See
|
||||
// https://tools.ietf.org/html/rfc4512#page-4.
|
||||
if strings.ToLower(attribute.Name) == strings.ToLower(mapped) {
|
||||
// Check if we need conversion.
|
||||
switch mapping[fmt.Sprintf("%s_type", n)] {
|
||||
case AttributeValueTypeBinary:
|
||||
// Binary gets encoded witih Base64.
|
||||
data[n] = base64.StdEncoding.EncodeToString(attribute.ByteValues[0])
|
||||
case AttributeValueTypeUUID:
|
||||
// Try to decode as UUID https://tools.ietf.org/html/rfc4122 and
|
||||
// serialize to string.
|
||||
if value, err := uuid.FromBytes(attribute.ByteValues[0]); err == nil {
|
||||
data[n] = value.String()
|
||||
}
|
||||
default:
|
||||
data[n] = attribute.Values[0]
|
||||
}
|
||||
|
||||
if n == AttributeNumericUID {
|
||||
numericID, err := strconv.ParseInt(data[n], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid numeric ID %v in record", err)
|
||||
}
|
||||
id = numericID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ldapUser{
|
||||
entryID: entryID,
|
||||
id: id,
|
||||
data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *ldapUser) getAttributeValue(n string) string {
|
||||
if n == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return u.data[n]
|
||||
}
|
||||
|
||||
func (u *ldapUser) Subject() string {
|
||||
return u.entryID
|
||||
}
|
||||
|
||||
func (u *ldapUser) Email() string {
|
||||
return u.getAttributeValue(AttributeEmail)
|
||||
}
|
||||
|
||||
func (u *ldapUser) EmailVerified() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *ldapUser) Name() string {
|
||||
return u.getAttributeValue(AttributeName)
|
||||
}
|
||||
|
||||
func (u *ldapUser) FamilyName() string {
|
||||
return u.getAttributeValue(AttributeFamilyName)
|
||||
}
|
||||
|
||||
func (u *ldapUser) GivenName() string {
|
||||
return u.getAttributeValue(AttributeGivenName)
|
||||
}
|
||||
|
||||
func (u *ldapUser) Username() string {
|
||||
return u.getAttributeValue(AttributeLogin)
|
||||
}
|
||||
|
||||
func (u *ldapUser) ID() int64 {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u *ldapUser) UniqueID() string {
|
||||
return u.getAttributeValue(AttributeUUID)
|
||||
}
|
||||
|
||||
func (u *ldapUser) BackendClaims() map[string]interface{} {
|
||||
claims := make(map[string]interface{})
|
||||
claims[konnect.IdentifiedUserIDClaim] = u.entryID
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
func (u *ldapUser) BackendScopes() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *ldapUser) RequiredScopes() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewLDAPIdentifierBackend creates a new LDAPIdentifierBackend with the provided
|
||||
// parameters.
|
||||
func NewLDAPIdentifierBackend(
|
||||
c *config.Config,
|
||||
tlsConfig *tls.Config,
|
||||
uriString,
|
||||
bindDN,
|
||||
bindPassword,
|
||||
baseDN,
|
||||
scopeString,
|
||||
filter string,
|
||||
subAttributes []string,
|
||||
mappedAttributes map[string]string,
|
||||
) (*LDAPIdentifierBackend, error) {
|
||||
var err error
|
||||
var scope int
|
||||
var uri *url.URL
|
||||
for {
|
||||
if uriString == "" {
|
||||
err = fmt.Errorf("server must not be empty")
|
||||
break
|
||||
}
|
||||
uri, err = url.Parse(uriString)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if bindDN == "" && bindPassword != "" {
|
||||
err = fmt.Errorf("bind DN must not be empty when bind password is given")
|
||||
break
|
||||
}
|
||||
if baseDN == "" {
|
||||
err = fmt.Errorf("base DN must not be empty")
|
||||
break
|
||||
}
|
||||
switch scopeString {
|
||||
case "sub":
|
||||
scope = ldap.ScopeWholeSubtree
|
||||
case "one":
|
||||
scope = ldap.ScopeSingleLevel
|
||||
case "base":
|
||||
scope = ldap.ScopeBaseObject
|
||||
case "":
|
||||
scope = ldap.ScopeWholeSubtree
|
||||
default:
|
||||
err = fmt.Errorf("unknown scope value: %v, must be one of sub, one or base", scopeString)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend %v", err)
|
||||
}
|
||||
|
||||
attributeMapping := ldapAttributeMapping{}
|
||||
for k, v := range ldapDefaultAttributeMapping {
|
||||
if mapped, ok := mappedAttributes[k]; ok && mapped != "" {
|
||||
v = mapped
|
||||
}
|
||||
attributeMapping[k] = v
|
||||
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", k, v)).Debugln("ldap identifier backend set attribute")
|
||||
}
|
||||
|
||||
// Build supported scopes based on default scopes and scope mapping.
|
||||
supportedScopes := make([]string, len(ldapSupportedScopes))
|
||||
copy(supportedScopes, ldapSupportedScopes)
|
||||
if numericUIDAttribute := mappedAttributes[AttributeNumericUID]; numericUIDAttribute != "" {
|
||||
supportedScopes = append(supportedScopes, konnect.ScopeNumericID)
|
||||
attributeMapping[AttributeNumericUID] = numericUIDAttribute
|
||||
c.Logger.WithField("attribute", fmt.Sprintf("%v:%v", AttributeNumericUID, numericUIDAttribute)).Debugln("ldap identifier backend use attribute")
|
||||
}
|
||||
|
||||
if filter == "" {
|
||||
filter = "(objectClass=inetOrgPerson)"
|
||||
}
|
||||
c.Logger.WithField("filter", filter).Debugln("ldap identifier backend set filter")
|
||||
|
||||
loginAttribute := attributeMapping[AttributeLogin]
|
||||
|
||||
addr := uri.Host
|
||||
isTLS := false
|
||||
|
||||
switch uri.Scheme {
|
||||
case "":
|
||||
uri.Scheme = "ldap"
|
||||
fallthrough
|
||||
case "ldap":
|
||||
if uri.Port() == "" {
|
||||
addr += ":389"
|
||||
}
|
||||
case "ldaps":
|
||||
if uri.Port() == "" {
|
||||
addr += ":636"
|
||||
}
|
||||
// To be able to verify the servers TLS certificate we need to set the
|
||||
// server's hostname. (Normally tls.DialWithDialer() would take care of
|
||||
// that, but we're not using that in LDAPIdentifierBackend.connect())
|
||||
if !tlsConfig.InsecureSkipVerify && tlsConfig.ServerName == "" {
|
||||
tlsConfig.ServerName = uri.Hostname()
|
||||
}
|
||||
isTLS = true
|
||||
default:
|
||||
err = fmt.Errorf("invalid URI scheme: %v", uri.Scheme)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend %v", err)
|
||||
}
|
||||
|
||||
var entryIDMapping []string
|
||||
if len(subAttributes) > 0 {
|
||||
entryIDMapping = subAttributes
|
||||
c.Logger.WithField("mapping", entryIDMapping).Debugln("ldap identifier sub is mapped")
|
||||
}
|
||||
|
||||
b := &LDAPIdentifierBackend{
|
||||
addr: addr,
|
||||
isTLS: isTLS,
|
||||
bindDN: bindDN,
|
||||
bindPassword: bindPassword,
|
||||
baseDN: baseDN,
|
||||
scope: scope,
|
||||
searchFilter: fmt.Sprintf("(&(%s)(%s=%%s))", filter, loginAttribute),
|
||||
getFilter: filter,
|
||||
|
||||
entryIDMapping: entryIDMapping,
|
||||
attributeMapping: attributeMapping,
|
||||
supportedScopes: supportedScopes,
|
||||
|
||||
logger: c.Logger,
|
||||
dialer: &net.Dialer{
|
||||
Timeout: ldap.DefaultTimeout,
|
||||
DualStack: true,
|
||||
},
|
||||
tlsConfig: tlsConfig,
|
||||
|
||||
timeout: 60, //XXX(longsleep): make timeout configuration.
|
||||
limiter: rate.NewLimiter(100, 200), //XXX(longsleep): make rate limits configuration.
|
||||
}
|
||||
|
||||
b.logger.WithField("ldap", fmt.Sprintf("%s://%s ", uri.Scheme, addr)).Infoln("ldap server identifier backend set up")
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// RunWithContext implements the Backend interface.
|
||||
func (b *LDAPIdentifierBackend) RunWithContext(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logon implements the Backend interface, enabling Logon with user name and
|
||||
// password as provided. Requests are bound to the provided context.
|
||||
func (b *LDAPIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
|
||||
loginAttributeName := b.attributeMapping[AttributeLogin]
|
||||
if loginAttributeName == "" {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon impossible as no login attribute is set")
|
||||
}
|
||||
|
||||
l, err := b.connect(ctx)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon connect error: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// Search for the given username.
|
||||
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
|
||||
switch {
|
||||
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
|
||||
return false, nil, nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search error: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon search returned wrong user")
|
||||
}
|
||||
|
||||
// Bind as the user to verify the password.
|
||||
err = l.Bind(entry.DN, password)
|
||||
switch {
|
||||
case ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials):
|
||||
return false, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon error: %v", err)
|
||||
}
|
||||
|
||||
entryID := b.entryIDFromEntry(b.attributeMapping, entry)
|
||||
if entryID == "" {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry without entry ID: %v", entry.DN)
|
||||
}
|
||||
|
||||
user, err := newLdapUser(entryID, b.attributeMapping, entry)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("ldap identifier backend logon entry data error: %v", err)
|
||||
}
|
||||
|
||||
// Use the users subject as user id.
|
||||
userID := user.Subject()
|
||||
|
||||
b.logger.WithFields(logrus.Fields{
|
||||
"username": user.Username(),
|
||||
"id": userID,
|
||||
}).Debugln("ldap identifier backend logon")
|
||||
|
||||
return true, &userID, nil, user, nil
|
||||
}
|
||||
|
||||
// ResolveUserByUsername implements the Beckend interface, providing lookup for
|
||||
// user by providing the username. Requests are bound to the provided context.
|
||||
func (b *LDAPIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
|
||||
loginAttributeName := b.attributeMapping[AttributeLogin]
|
||||
if loginAttributeName == "" {
|
||||
return nil, fmt.Errorf("ldap identifier backend resolve impossible as no login attribute is set")
|
||||
}
|
||||
|
||||
l, err := b.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend resolve connect error: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
// Search for the given username.
|
||||
entry, err := b.searchUsername(l, username, b.attributeMapping.attributes())
|
||||
switch {
|
||||
case ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject):
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend resolve search error: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(entry.GetEqualFoldAttributeValue(loginAttributeName), username) {
|
||||
return nil, fmt.Errorf("ldap identifier backend resolve search returned wrong user")
|
||||
}
|
||||
|
||||
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
|
||||
|
||||
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend resolve entry data error: %v", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUser implements the Backend interface, providing user meta data retrieval
|
||||
// for the user specified by the userID. Requests are bound to the provided
|
||||
// context.
|
||||
func (b *LDAPIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
|
||||
l, err := b.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend get user connect error: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
entry, err := b.getUser(l, entryID, b.attributeMapping.attributes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend get user error: %v", err)
|
||||
}
|
||||
|
||||
newEntryID := b.entryIDFromEntry(b.attributeMapping, entry)
|
||||
if !strings.EqualFold(newEntryID, entryID) {
|
||||
return nil, fmt.Errorf("ldap identifier backend get user returned wrong user")
|
||||
}
|
||||
|
||||
user, err := newLdapUser(newEntryID, b.attributeMapping, entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap identifier backend get user entry data error: %v", err)
|
||||
}
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// RefreshSession implements the Backend interface.
|
||||
func (b *LDAPIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DestroySession implements the Backend interface providing destroy to KC session.
|
||||
func (b *LDAPIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserClaims implements the Backend interface, providing user specific claims
|
||||
// for the user specified by the userID.
|
||||
func (b *LDAPIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScopesSupported implements the Backend interface, providing supported scopes
|
||||
// when running this backend.
|
||||
func (b *LDAPIdentifierBackend) ScopesSupported() []string {
|
||||
return b.supportedScopes
|
||||
}
|
||||
|
||||
// ScopesMeta implements the Backend interface, providing meta data for
|
||||
// supported scopes.
|
||||
func (b *LDAPIdentifierBackend) ScopesMeta() *scopes.Scopes {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name implements the Backend interface.
|
||||
func (b *LDAPIdentifierBackend) Name() string {
|
||||
return ldapIdentifierBackendName
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) connect(parentCtx context.Context) (*ldap.Conn, error) {
|
||||
// A timeout for waiting for a limiter slot. The timeout also includes the
|
||||
// time to connect to the LDAP server which as a consequence means that both
|
||||
// getting a free slot and establishing the connection are one timeout.
|
||||
ctx, cancel := context.WithTimeout(parentCtx, time.Duration(b.timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := b.limiter.Wait(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c, err := b.dialer.DialContext(ctx, "tcp", b.addr)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.ErrorNetwork, err)
|
||||
}
|
||||
|
||||
var l *ldap.Conn
|
||||
if b.isTLS {
|
||||
sc := tls.Client(c, b.tlsConfig)
|
||||
err = sc.Handshake()
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, ldap.NewError(ldap.ErrorNetwork, err)
|
||||
}
|
||||
l = ldap.NewConn(sc, true)
|
||||
|
||||
} else {
|
||||
l = ldap.NewConn(c, false)
|
||||
}
|
||||
|
||||
l.Start()
|
||||
|
||||
// Bind with general user (which is preferably read only).
|
||||
if b.bindDN != "" {
|
||||
err = l.Bind(b.bindDN, b.bindPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) searchUsername(l *ldap.Conn, username string, attributes []string) (*ldap.Entry, error) {
|
||||
base, filter := b.baseAndSearchFilterFromUsername(username)
|
||||
// Search for the given username.
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
base,
|
||||
b.scope, ldap.NeverDerefAliases, 1, b.timeout, false,
|
||||
filter,
|
||||
attributes,
|
||||
nil,
|
||||
)
|
||||
sr, err := l.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch len(sr.Entries) {
|
||||
case 0:
|
||||
// Nothing found.
|
||||
return nil, ldap.NewError(ldap.LDAPResultNoSuchObject, err)
|
||||
case 1:
|
||||
// Exactly one found, success.
|
||||
return sr.Entries[0], nil
|
||||
default:
|
||||
// Invalid when multiple matched.
|
||||
return nil, fmt.Errorf("user too many entries returned")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) getUser(l *ldap.Conn, entryID string, attributes []string) (*ldap.Entry, error) {
|
||||
base, filter := b.baseAndGetFilterFromEntryID(entryID)
|
||||
if base == "" || filter == "" || entryID == "" {
|
||||
return nil, fmt.Errorf("ldap identifier backend get user invalid user ID: %v", entryID)
|
||||
}
|
||||
|
||||
scope := b.scope
|
||||
if base == entryID {
|
||||
// Ensure that scope is limited, when directly requesting an entry.
|
||||
scope = ldap.ScopeBaseObject
|
||||
}
|
||||
|
||||
// search for the given DN.
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
base,
|
||||
scope, ldap.NeverDerefAliases, 1, b.timeout, false,
|
||||
filter,
|
||||
attributes,
|
||||
nil,
|
||||
)
|
||||
sr, err := l.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sr.Entries) != 1 {
|
||||
return nil, fmt.Errorf("user does not exist or too many entries returned")
|
||||
}
|
||||
|
||||
return sr.Entries[0], nil
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) entryIDFromEntry(mapping ldapAttributeMapping, entry *ldap.Entry) string {
|
||||
if b.entryIDMapping != nil {
|
||||
// Encode as URL query.
|
||||
values := url.Values{}
|
||||
for _, k := range b.entryIDMapping {
|
||||
v := entry.GetEqualFoldAttributeValues(k)
|
||||
if len(v) > 0 {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
// URL encode values to string.
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
// Use DN by default is no mapping is set.
|
||||
return entry.DN
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) baseAndGetFilterFromEntryID(entryID string) (string, string) {
|
||||
if b.entryIDMapping != nil {
|
||||
// Parse entryID as URL encoded query values, and build & filter to search for them all.
|
||||
if values, err := url.ParseQuery(entryID); err == nil {
|
||||
filter := ""
|
||||
for k, values := range values {
|
||||
for _, value := range values {
|
||||
filter = fmt.Sprintf("%s(%s=%s)", filter, k, ldap.EscapeFilter(value))
|
||||
}
|
||||
}
|
||||
if filter != "" {
|
||||
return b.baseDN, fmt.Sprintf("(&%s%s)", b.getFilter, filter)
|
||||
}
|
||||
}
|
||||
// Failed to parse entry ID.
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Map DN to entryID.
|
||||
_, err := ldap.ParseDN(entryID)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return entryID, b.getFilter
|
||||
}
|
||||
|
||||
func (b *LDAPIdentifierBackend) baseAndSearchFilterFromUsername(username string) (string, string) {
|
||||
// Build search filter with username.
|
||||
return b.baseDN, fmt.Sprintf(b.searchFilter, ldap.EscapeFilter(username))
|
||||
}
|
||||
Generated
Vendored
+660
@@ -0,0 +1,660 @@
|
||||
/*
|
||||
* Copyright 2021 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package libregraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cevaris/ordered_map"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/config"
|
||||
"github.com/libregraph/lico/identifier"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
identityClients "github.com/libregraph/lico/identity/clients"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
const libreGraphIdentifierBackendName = "identifier-libregraph"
|
||||
|
||||
const (
|
||||
OpenTypeExtensionType = "#microsoft.graph.openTypeExtension"
|
||||
|
||||
IdentityClaimsExtensionName = "libregraph.identityClaims"
|
||||
IDTokenClaimsExtensionName = "libregraph.idTokenClaims"
|
||||
AccessTokenClaimsExtensionName = "libregraph.accessTokenClaims"
|
||||
RequestedScopesExtensionName = "libregraph.requestedScopes"
|
||||
SessionExtensionName = "libregraph.session"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPathMe = "/api/v1/me"
|
||||
apiPathUsers = "/api/v1/users"
|
||||
apiPathRevokeSignInSession = "/api/v1/me/revokeSignInSession"
|
||||
)
|
||||
|
||||
var libreGraphSpportedScopes = []string{
|
||||
oidc.ScopeProfile,
|
||||
oidc.ScopeEmail,
|
||||
konnect.ScopeUniqueUserID,
|
||||
konnect.ScopeRawSubject,
|
||||
}
|
||||
|
||||
type LibreGraphIdentifierBackend struct {
|
||||
config *config.Config
|
||||
|
||||
supportedScopes []string
|
||||
|
||||
logger logrus.FieldLogger
|
||||
tlsConfig *tls.Config
|
||||
|
||||
client *http.Client
|
||||
|
||||
baseURLMap *ordered_map.OrderedMap
|
||||
useMultipleBackends bool
|
||||
|
||||
clients *identityClients.Registry
|
||||
}
|
||||
|
||||
type libreGraphUser struct {
|
||||
AccountEnabled bool `json:"accountEnabled"`
|
||||
DisplayName string `json:"displayName"`
|
||||
RawGivenName string `json:"givenName"`
|
||||
ID string `json:"id"`
|
||||
Mail string `json:"mail"`
|
||||
Surname string `json:"surname"`
|
||||
UserPrincipalName string `json:"userPrincipalName"`
|
||||
|
||||
Extensions []map[string]interface{} `json:"extensions"`
|
||||
|
||||
identityClaims map[string]interface{}
|
||||
requestedScopes []string
|
||||
requiredScopes []string
|
||||
}
|
||||
|
||||
func decodeLibreGraphUser(r io.Reader) (*libreGraphUser, error) {
|
||||
decoder := json.NewDecoder(r)
|
||||
u := &libreGraphUser{}
|
||||
if err := decoder.Decode(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identityClaims := make(map[string]interface{})
|
||||
identityClaims[konnect.IdentifiedUserIDClaim] = u.ID
|
||||
|
||||
var idTokenClaims map[string]interface{}
|
||||
var accessTokenClaims map[string]interface{}
|
||||
var requestedScopes []string
|
||||
|
||||
for _, extension := range u.Extensions {
|
||||
if odataType, ok := extension["@odata.type"]; ok && odataType.(string) != OpenTypeExtensionType {
|
||||
continue
|
||||
}
|
||||
if extensionName, ok := extension["extensionName"].(string); ok {
|
||||
switch extensionName {
|
||||
case IdentityClaimsExtensionName:
|
||||
if v, ok := extension["claims"].(map[string]interface{}); ok {
|
||||
for k, v := range v {
|
||||
if k == konnect.InternalExtraIDTokenClaimsClaim || k == konnect.InternalExtraAccessTokenClaimsClaim {
|
||||
// Ignore keys which areused internally by IDTokenClaimsExtensionName
|
||||
// and AccessTokenClaimsExtensionName.
|
||||
continue
|
||||
}
|
||||
identityClaims[k] = v
|
||||
}
|
||||
}
|
||||
case IDTokenClaimsExtensionName:
|
||||
if idTokenClaims == nil {
|
||||
idTokenClaims = make(map[string]interface{})
|
||||
}
|
||||
if v, ok := extension["claims"].(map[string]interface{}); ok {
|
||||
for k, v := range v {
|
||||
idTokenClaims[k] = v
|
||||
}
|
||||
}
|
||||
case AccessTokenClaimsExtensionName:
|
||||
if accessTokenClaims == nil {
|
||||
accessTokenClaims = make(map[string]interface{})
|
||||
}
|
||||
if v, ok := extension["claims"].(map[string]interface{}); ok {
|
||||
for k, v := range v {
|
||||
accessTokenClaims[k] = v
|
||||
}
|
||||
}
|
||||
case RequestedScopesExtensionName:
|
||||
if values, ok := extension["scopes"].([]interface{}); ok {
|
||||
for _, v := range values {
|
||||
if s, ok := v.(string); ok {
|
||||
requestedScopes = append(requestedScopes, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
case SessionExtensionName:
|
||||
if sid, ok := extension[oidc.SessionIDClaim].(string); ok {
|
||||
if sid != "" {
|
||||
if accessTokenClaims == nil {
|
||||
accessTokenClaims = make(map[string]interface{})
|
||||
}
|
||||
accessTokenClaims[oidc.SessionIDClaim] = sid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if idTokenClaims != nil {
|
||||
// Inject claims as nested identity claim. The key is picket up by the
|
||||
// token signer and used to extend ID token root claims.
|
||||
identityClaims[konnect.InternalExtraIDTokenClaimsClaim] = idTokenClaims
|
||||
}
|
||||
if accessTokenClaims != nil {
|
||||
// Inject claims as nested identity claims. The key is picked up by the
|
||||
// token signer and userinfo handler to extend ID and access token root
|
||||
// claims based on the request.
|
||||
identityClaims[konnect.InternalExtraAccessTokenClaimsClaim] = accessTokenClaims
|
||||
}
|
||||
if requestedScopes != nil {
|
||||
u.requestedScopes = requestedScopes
|
||||
}
|
||||
|
||||
u.identityClaims = identityClaims
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) Subject() string {
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) Email() string {
|
||||
return u.Mail
|
||||
}
|
||||
func (u *libreGraphUser) EmailVerified() bool {
|
||||
return true
|
||||
}
|
||||
func (u *libreGraphUser) Name() string {
|
||||
return u.DisplayName
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) FamilyName() string {
|
||||
return u.Surname
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) GivenName() string {
|
||||
return u.RawGivenName
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) Username() string {
|
||||
return u.UserPrincipalName
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) UniqueID() string {
|
||||
// Provide our ID as unique ID.
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) BackendClaims() map[string]interface{} {
|
||||
return u.identityClaims
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) BackendScopes() []string {
|
||||
return u.requestedScopes
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) RequiredScopes() []string {
|
||||
return u.requiredScopes
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) setRequiredScopes(selectedScope string, scopeMap *ordered_map.OrderedMap) []string {
|
||||
var requiredScopes []string
|
||||
|
||||
if selectedScope != "" {
|
||||
requiredScopes = []string{selectedScope}
|
||||
}
|
||||
iter := scopeMap.IterFunc()
|
||||
for kv, ok := iter(); ok; kv, ok = iter() {
|
||||
if scope := kv.Key.(string); scope != selectedScope {
|
||||
requiredScopes = append(requiredScopes, "!"+scope)
|
||||
}
|
||||
}
|
||||
u.requiredScopes = requiredScopes
|
||||
return requiredScopes
|
||||
}
|
||||
|
||||
func (u *libreGraphUser) sessionID() string {
|
||||
if accessTokenClaims, ok := u.identityClaims[konnect.InternalExtraAccessTokenClaimsClaim].(map[string]interface{}); ok {
|
||||
if sessionID, withSessionID := accessTokenClaims[oidc.SessionIDClaim].(string); withSessionID {
|
||||
if sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withSelectQuery(r *http.Request) {
|
||||
if r.Form == nil {
|
||||
r.Form = make(url.Values)
|
||||
}
|
||||
r.Form.Set("$select", "accountEnabled,displayName,givenName,id,mail,surname,userPrincipalName,extensions")
|
||||
}
|
||||
|
||||
func NewLibreGraphIdentifierBackend(
|
||||
c *config.Config,
|
||||
tlsConfig *tls.Config,
|
||||
baseURI string,
|
||||
baseURIByScope *ordered_map.OrderedMap,
|
||||
clients *identityClients.Registry,
|
||||
) (*LibreGraphIdentifierBackend, error) {
|
||||
|
||||
if baseURI == "" {
|
||||
return nil, fmt.Errorf("base uri must not be empty")
|
||||
}
|
||||
|
||||
// Build supported scopes based on default scopes.
|
||||
supportedScopes := make([]string, len(libreGraphSpportedScopes))
|
||||
copy(supportedScopes, libreGraphSpportedScopes)
|
||||
|
||||
baseURLMap := ordered_map.NewOrderedMapWithArgs([]*ordered_map.KVPair{{
|
||||
Key: "",
|
||||
Value: baseURI,
|
||||
}})
|
||||
if baseURIByScope != nil {
|
||||
iter := baseURIByScope.IterFunc()
|
||||
for kv, ok := iter(); ok; kv, ok = iter() {
|
||||
if kv.Key == "" {
|
||||
return nil, fmt.Errorf("scoped base uri with empty scope is not allowed")
|
||||
}
|
||||
baseURLMap.Set(kv.Key, kv.Value)
|
||||
}
|
||||
}
|
||||
|
||||
transport := utils.HTTPTransportWithTLSClientConfig(tlsConfig)
|
||||
transport.MaxIdleConns = 100
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
|
||||
b := &LibreGraphIdentifierBackend{
|
||||
config: c,
|
||||
|
||||
supportedScopes: supportedScopes,
|
||||
|
||||
logger: c.Logger,
|
||||
tlsConfig: tlsConfig,
|
||||
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 60 * time.Second,
|
||||
},
|
||||
|
||||
baseURLMap: baseURLMap,
|
||||
useMultipleBackends: baseURLMap.Len() > 1,
|
||||
|
||||
clients: clients,
|
||||
}
|
||||
|
||||
b.logger.WithField("map", baseURLMap).Infoln("libregraph server identified backend connection set up")
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// RunWithContext implements the Backend interface.
|
||||
func (b *LibreGraphIdentifierBackend) RunWithContext(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logon implements the Backend interface, enabling Logon with user name and
|
||||
// password as provided. Requests are bound to the provided context.
|
||||
func (b *LibreGraphIdentifierBackend) Logon(ctx context.Context, audience, username, password string) (bool, *string, *string, backends.UserFromBackend, error) {
|
||||
record, _ := identifier.FromRecordContext(ctx)
|
||||
var requestedScopes map[string]bool
|
||||
if record != nil {
|
||||
requestedScopes = record.HelloRequest.Scopes
|
||||
}
|
||||
|
||||
// Inject implicit scopes set by client registration. This is needed here,
|
||||
// as the requested scopes might not have the implicit scopes applied yet,
|
||||
// based on the calling stack chain and since we use the scopes to select
|
||||
// the backend.
|
||||
registration, _ := b.clients.Get(ctx, audience)
|
||||
if registration != nil {
|
||||
_ = registration.ApplyImplicitScopes(requestedScopes)
|
||||
}
|
||||
|
||||
selectedScope, meURL := b.getMeURL(requestedScopes)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request error: %w", err)
|
||||
}
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
if record == nil {
|
||||
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
|
||||
}
|
||||
if record != nil {
|
||||
// Inject HTTP headers.
|
||||
if record.HelloRequest != nil {
|
||||
if record.HelloRequest.Flow != "" {
|
||||
req.Header.Set("X-Flow", record.HelloRequest.Flow)
|
||||
}
|
||||
if record.HelloRequest.RawScope != "" {
|
||||
req.Header.Set("X-Scope", record.HelloRequest.RawScope)
|
||||
}
|
||||
if record.HelloRequest.RawPrompt != "" {
|
||||
req.Header.Set("X-Prompt", record.HelloRequest.RawPrompt)
|
||||
}
|
||||
}
|
||||
if record.RealIP != "" {
|
||||
req.Header.Set("X-User-Real-IP", record.RealIP)
|
||||
}
|
||||
if record.UserAgent != "" {
|
||||
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
|
||||
}
|
||||
}
|
||||
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
|
||||
|
||||
// Inject select parameter.
|
||||
withSelectQuery(req)
|
||||
|
||||
response, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
// breaks
|
||||
case http.StatusNotFound:
|
||||
return false, nil, nil, nil, nil
|
||||
case http.StatusUnauthorized:
|
||||
return false, nil, nil, nil, nil
|
||||
default:
|
||||
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
user, err := decodeLibreGraphUser(response.Body)
|
||||
if err != nil {
|
||||
return false, nil, nil, nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
|
||||
}
|
||||
|
||||
if !user.AccountEnabled {
|
||||
return false, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
requiredScopes := user.setRequiredScopes(selectedScope, b.baseURLMap)
|
||||
|
||||
// Use the users subject as user id.
|
||||
userID := user.Subject()
|
||||
|
||||
sessionID := user.sessionID()
|
||||
|
||||
b.logger.WithFields(logrus.Fields{
|
||||
"username": user.Username(),
|
||||
"id": userID,
|
||||
"scope": requiredScopes,
|
||||
"sessionID": sessionID,
|
||||
}).Debugln("libregraph identifier backend logon")
|
||||
|
||||
// Put the user into the record (if any).
|
||||
if record != nil {
|
||||
record.BackendUser = user
|
||||
}
|
||||
|
||||
return true, &userID, &sessionID, user, nil
|
||||
}
|
||||
|
||||
// GetUser implements the Backend interface, providing user meta data retrieval
|
||||
// for the user specified by the userID. Requests are bound to the provided
|
||||
// context.
|
||||
func (b *LibreGraphIdentifierBackend) GetUser(ctx context.Context, entryID string, sessionRef *string, requestedScopes map[string]bool) (backends.UserFromBackend, error) {
|
||||
record, _ := identifier.FromRecordContext(ctx)
|
||||
if record != nil {
|
||||
if record.BackendUser != nil {
|
||||
if user, ok := record.BackendUser.(*libreGraphUser); ok {
|
||||
// Fastpath, if logon previously injected the user.
|
||||
if user.ID == entryID {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if requestedScopes == nil && record.HelloRequest != nil {
|
||||
requestedScopes = record.HelloRequest.Scopes
|
||||
}
|
||||
}
|
||||
|
||||
selectedScope, userURL := b.getUserURL(requestedScopes)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, userURL+"/"+entryID, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("libregraph identifier backend get user request error: %w", err)
|
||||
}
|
||||
|
||||
// Inject HTTP headers.
|
||||
if requestedScopes != nil {
|
||||
rawRequestedScopes := make([]string, 0)
|
||||
for scope, enabled := range requestedScopes {
|
||||
if enabled {
|
||||
rawRequestedScopes = append(rawRequestedScopes, scope)
|
||||
}
|
||||
}
|
||||
req.Header.Set("X-Scope", strings.Join(rawRequestedScopes, " "))
|
||||
}
|
||||
if sessionRef != nil {
|
||||
sessionID := *sessionRef
|
||||
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
|
||||
// Only send the session ID if it is not a ref generated by lico.
|
||||
req.Header.Set("X-SessionID", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
|
||||
}
|
||||
if record != nil {
|
||||
if record.IdentifiedUser != nil {
|
||||
if ok, ts := record.IdentifiedUser.LoggedOn(); ok {
|
||||
req.Header.Set("X-User-Logon-At", ts.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
}
|
||||
if record.RealIP != "" {
|
||||
req.Header.Set("X-User-Real-IP", record.RealIP)
|
||||
}
|
||||
if record.UserAgent != "" {
|
||||
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
|
||||
}
|
||||
}
|
||||
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
|
||||
|
||||
// Inject select parameter.
|
||||
withSelectQuery(req)
|
||||
|
||||
response, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("libregraph identifier backend get user request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
// breaks
|
||||
case http.StatusNotFound:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("libregraph identifier backend get user request unexpected response status: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
user, err := decodeLibreGraphUser(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("libregraph identifier backend logon json decode error: %w", err)
|
||||
}
|
||||
|
||||
if !user.AccountEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user.setRequiredScopes(selectedScope, b.baseURLMap)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ResolveUserByUsername implements the Backend interface, providing lookup for
|
||||
// user by providing the username. Requests are bound to the provided context.
|
||||
func (b *LibreGraphIdentifierBackend) ResolveUserByUsername(ctx context.Context, username string) (backends.UserFromBackend, error) {
|
||||
// Libregraph backend accept both user name and ID lookups, so this is
|
||||
// the same as GetUser without a session.
|
||||
return b.GetUser(ctx, username, nil, nil)
|
||||
}
|
||||
|
||||
// RefreshSession implements the Backend interface.
|
||||
func (b *LibreGraphIdentifierBackend) RefreshSession(ctx context.Context, userID string, sessionRef *string, claims map[string]interface{}) error {
|
||||
user, err := b.GetUser(ctx, userID, sessionRef, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("refresh session did not yield a user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DestroySession implements the Backend interface providing explicit revoke
|
||||
// of the backend session.
|
||||
func (b *LibreGraphIdentifierBackend) DestroySession(ctx context.Context, sessionRef *string) error {
|
||||
var requestedScopes map[string]bool
|
||||
record, _ := identifier.FromRecordContext(ctx)
|
||||
if record != nil {
|
||||
if record.HelloRequest != nil {
|
||||
requestedScopes = record.HelloRequest.Scopes
|
||||
}
|
||||
}
|
||||
|
||||
_, revokeSessionURL := b.getRevokeSigninSessionURL(requestedScopes)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeSessionURL, http.NoBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("libregraph identifier backend destroy session request error: %w", err)
|
||||
}
|
||||
|
||||
if sessionRef != nil {
|
||||
sessionID := *sessionRef
|
||||
if !strings.HasPrefix(sessionID, libreGraphIdentifierBackendName+":") {
|
||||
// Only send the session ID if it is not a ref generated by lico.
|
||||
req.Header.Set("X-SessionID", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
record, _ = identifier.RecordFromRequestContext(ctx, b.config)
|
||||
}
|
||||
if record != nil {
|
||||
// Inject HTTP headers.
|
||||
if record.RealIP != "" {
|
||||
req.Header.Set("X-User-Real-IP", record.RealIP)
|
||||
}
|
||||
if record.UserAgent != "" {
|
||||
req.Header.Set("X-User-Real-User-Agent", record.UserAgent)
|
||||
}
|
||||
}
|
||||
req.Header.Set("User-Agent", utils.DefaultHTTPUserAgent)
|
||||
|
||||
response, err := b.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("libregraph identifier backend destroy session request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusOK:
|
||||
// breaks
|
||||
case http.StatusNotFound:
|
||||
return nil
|
||||
case http.StatusUnauthorized:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("libregraph identifier backend logon request unexpected response status: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserClaims implements the Backend interface, providing user specific claims
|
||||
// for the user specified by the userID.
|
||||
func (b *LibreGraphIdentifierBackend) UserClaims(userID string, authorizedScopes map[string]bool) map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScopesSupported implements the Backend interface, providing supported scopes
|
||||
// when running this backend.
|
||||
func (b *LibreGraphIdentifierBackend) ScopesSupported() []string {
|
||||
return b.supportedScopes
|
||||
}
|
||||
|
||||
// ScopesMeta implements the Backend interface, providing meta data for
|
||||
// supported scopes.
|
||||
func (b *LibreGraphIdentifierBackend) ScopesMeta() *scopes.Scopes {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name implements the Backend interface.
|
||||
func (b *LibreGraphIdentifierBackend) Name() string {
|
||||
return libreGraphIdentifierBackendName
|
||||
}
|
||||
|
||||
func (b *LibreGraphIdentifierBackend) getBaseURL(requestedScopes map[string]bool) (string, string) {
|
||||
if b.useMultipleBackends && requestedScopes != nil {
|
||||
// Loop through configured backends for each requested scope.
|
||||
for s, v := range requestedScopes {
|
||||
if !v {
|
||||
continue
|
||||
}
|
||||
if u, ok := b.baseURLMap.Get(s); ok {
|
||||
return s, u.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If nothing found, return default.
|
||||
u, _ := b.baseURLMap.Get("")
|
||||
return "", u.(string)
|
||||
}
|
||||
|
||||
func (b *LibreGraphIdentifierBackend) getMeURL(requestedScopes map[string]bool) (string, string) {
|
||||
scope, baseURL := b.getBaseURL(requestedScopes)
|
||||
|
||||
return scope, baseURL + apiPathMe
|
||||
}
|
||||
|
||||
func (b *LibreGraphIdentifierBackend) getUserURL(requestedScopes map[string]bool) (string, string) {
|
||||
scope, baseURL := b.getBaseURL(requestedScopes)
|
||||
|
||||
return scope, baseURL + apiPathUsers
|
||||
}
|
||||
|
||||
func (b *LibreGraphIdentifierBackend) getRevokeSigninSessionURL(requestedScopes map[string]bool) (string, string) {
|
||||
scope, baseURL := b.getBaseURL(requestedScopes)
|
||||
|
||||
return scope, baseURL + apiPathRevokeSignInSession
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
// Additional claims as used by the identifier in its own tokens.
|
||||
const (
|
||||
SessionIDClaim = "sid"
|
||||
LogonRefClaim = "lref"
|
||||
ExternalAuthorityIDClaim = "eaid"
|
||||
LockedScopesClaim = "lscp"
|
||||
)
|
||||
|
||||
// History claims previously used by the identifier in its own tokens.
|
||||
const (
|
||||
ObsoleteUserClaimsClaim = "claims"
|
||||
)
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/libregraph/lico/config"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
)
|
||||
|
||||
// Config defines a Server's configuration settings.
|
||||
type Config struct {
|
||||
Config *config.Config
|
||||
|
||||
BaseURI *url.URL
|
||||
ScopesConf string
|
||||
|
||||
LogonCookieName string
|
||||
LogonCookieSameSite http.SameSite
|
||||
|
||||
ConsentCookieSameSite http.SameSite
|
||||
|
||||
StateCookieSameSite http.SameSite
|
||||
|
||||
PathPrefix string
|
||||
StaticFolder string
|
||||
WebAppDisabled bool
|
||||
|
||||
AuthorizationEndpointURI *url.URL
|
||||
SignedOutEndpointURI *url.URL
|
||||
|
||||
DefaultBannerLogo []byte
|
||||
DefaultSignInPageText *string
|
||||
DefaultSignInPageLogoURI *string
|
||||
DefaultUsernameHintText *string
|
||||
UILocales []string
|
||||
|
||||
Backend backends.Backend
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2021 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/config"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
// Record is the struct which the identifier puts into the context.
|
||||
type Record struct {
|
||||
HelloRequest *HelloRequest
|
||||
RealIP string
|
||||
UserAgent string
|
||||
|
||||
BackendUser backends.UserFromBackend
|
||||
IdentifiedUser *IdentifiedUser
|
||||
}
|
||||
|
||||
func NewRecord(req *http.Request, c *config.Config) *Record {
|
||||
record := &Record{
|
||||
UserAgent: req.UserAgent(),
|
||||
}
|
||||
|
||||
trusted, _ := utils.IsRequestFromTrustedSource(req, c.TrustedProxyIPs, c.TrustedProxyNets)
|
||||
|
||||
if trusted {
|
||||
record.RealIP = req.Header.Get("X-Real-Ip")
|
||||
}
|
||||
if record.RealIP == "" {
|
||||
if ip, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
|
||||
record.RealIP = ip
|
||||
}
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// key is an unexported type for keys defined in this package.
|
||||
// This prevents collisions with keys defined in other packages.
|
||||
type key int
|
||||
|
||||
// Keys for context data.
|
||||
// Unexported; Clients use identifier.New{?}Context and
|
||||
// identifier.From{?}Context functions instead of using these keys directly.
|
||||
const (
|
||||
recordKey key = iota
|
||||
)
|
||||
|
||||
// NewRecordContext returns a new Context that carries the Record.
|
||||
func NewRecordContext(ctx context.Context, record *Record) context.Context {
|
||||
return context.WithValue(ctx, recordKey, record)
|
||||
}
|
||||
|
||||
// FromRecordContext returns the Record value stored in ctx, if any.
|
||||
func FromRecordContext(ctx context.Context) (*Record, bool) {
|
||||
record, ok := ctx.Value(recordKey).(*Record)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
// RecordFromRequestContext returns a new Record value based on the request
|
||||
// stored in ctx, if any.
|
||||
func RecordFromRequestContext(ctx context.Context, c *config.Config) (*Record, bool) {
|
||||
if req, ok := konnect.FromRequestContext(ctx); ok {
|
||||
return NewRecord(req, c), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/blake2b"
|
||||
)
|
||||
|
||||
const (
|
||||
consentCookieNamePrefix = "__Secure-KKTC" // Kopano Konnect Temorary Consent
|
||||
stateCookieNamePrefix = "__Secure-KKTS" // Kopano Konnect Temporary State
|
||||
)
|
||||
|
||||
func (i *Identifier) setLogonCookie(rw http.ResponseWriter, value string) error {
|
||||
cookie := http.Cookie{
|
||||
Name: i.logonCookieName,
|
||||
Value: value,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/_/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.logonCookieSameSite,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) getLogonCookie(req *http.Request) (*http.Cookie, error) {
|
||||
return req.Cookie(i.logonCookieName)
|
||||
}
|
||||
|
||||
func (i *Identifier) removeLogonCookie(rw http.ResponseWriter) error {
|
||||
cookie := http.Cookie{
|
||||
Name: i.logonCookieName,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/_/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.logonCookieSameSite,
|
||||
|
||||
Expires: farPastExpiryTime,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) setConsentCookie(rw http.ResponseWriter, cr *ConsentRequest, value string) error {
|
||||
name, err := i.getConsentCookieName(cr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
MaxAge: 60,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/_/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.consentCookieSameSite,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) getConsentCookie(req *http.Request, cr *ConsentRequest) (*http.Cookie, error) {
|
||||
name, err := i.getConsentCookieName(cr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req.Cookie(name)
|
||||
}
|
||||
|
||||
func (i *Identifier) removeConsentCookie(rw http.ResponseWriter, req *http.Request, cr *ConsentRequest) error {
|
||||
name, err := i.getConsentCookieName(cr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: name,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/_/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.consentCookieSameSite,
|
||||
|
||||
Expires: farPastExpiryTime,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) getConsentCookieName(cr *ConsentRequest) (string, error) {
|
||||
// Consent cookie names are based on parameters in the request.
|
||||
hasher, err := blake2b.New256(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasher.Write([]byte(cr.State))
|
||||
hasher.Write([]byte("h"))
|
||||
hasher.Write([]byte(cr.ClientID))
|
||||
hasher.Write([]byte("e"))
|
||||
hasher.Write([]byte(cr.RawRedirectURI))
|
||||
hasher.Write([]byte("l"))
|
||||
hasher.Write([]byte(cr.Ref))
|
||||
hasher.Write([]byte("o"))
|
||||
hasher.Write([]byte(cr.Nonce))
|
||||
|
||||
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
return consentCookieNamePrefix + "-" + name, nil
|
||||
}
|
||||
|
||||
func (i *Identifier) setStateCookie(rw http.ResponseWriter, scope string, state string, value string) error {
|
||||
name, err := i.getStateCookieName(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
MaxAge: 600,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/" + scope,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.stateCookieSameSite,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) getStateCookie(req *http.Request, state string) (*http.Cookie, error) {
|
||||
name, err := i.getStateCookieName(state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req.Cookie(name)
|
||||
}
|
||||
|
||||
func (i *Identifier) removeStateCookie(rw http.ResponseWriter, req *http.Request, scope string, state string) error {
|
||||
name, err := i.getStateCookieName(state)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cookie := http.Cookie{
|
||||
Name: name,
|
||||
|
||||
Path: i.pathPrefix + "/identifier/" + scope,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: i.stateCookieSameSite,
|
||||
|
||||
Expires: farPastExpiryTime,
|
||||
}
|
||||
http.SetCookie(rw, &cookie)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) getStateCookieName(state string) (string, error) {
|
||||
// State cookie names are based on the state value.
|
||||
hasher, err := blake2b.New256(nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hasher.Write([]byte(state))
|
||||
|
||||
name := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return stateCookieNamePrefix + "-" + name, nil
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
const (
|
||||
// FlowOIDC is the string value for the oidc flow.
|
||||
FlowOIDC = "oidc"
|
||||
// FlowOAuth is the string value for the oauth flow.
|
||||
FlowOAuth = "oauth"
|
||||
// FlowConsent is the string value for the consent flow.
|
||||
FlowConsent = "consent"
|
||||
)
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identity/authorities"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
func (i *Identifier) staticHandler(handler http.Handler, cache bool) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
addCommonResponseHeaders(rw.Header())
|
||||
if cache {
|
||||
rw.Header().Set("Cache-Control", "max-age=3153600, public")
|
||||
} else {
|
||||
rw.Header().Set("Cache-Control", "no-cache, max-age=0, public")
|
||||
}
|
||||
if strings.HasSuffix(req.URL.Path, "/") {
|
||||
// Do not serve folder-ish resources.
|
||||
i.ErrorPage(rw, http.StatusNotFound, "", "")
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(rw, req)
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Identifier) secureHandler(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
||||
var err error
|
||||
|
||||
// TODO(longsleep): Add support for X-Forwareded-Host with trusted proxy.
|
||||
// NOTE: this does not protect from DNS rebinding. Protection for that
|
||||
// should be added at the frontend proxy.
|
||||
requiredHost := req.Host
|
||||
if host, port, splitErr := net.SplitHostPort(requiredHost); splitErr == nil {
|
||||
if port == "443" {
|
||||
// Ignore the port 443 as it is the default port and it is
|
||||
// usually not part of any of the urls. It might be in the
|
||||
// request for HTTP/3 requests.
|
||||
requiredHost = host
|
||||
}
|
||||
}
|
||||
|
||||
// This follows https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
|
||||
for {
|
||||
if req.Header.Get("Kopano-Konnect-XSRF") != "1" {
|
||||
err = fmt.Errorf("missing xsrf header")
|
||||
break
|
||||
}
|
||||
|
||||
origin := req.Header.Get("Origin")
|
||||
referer := req.Header.Get("Referer")
|
||||
|
||||
// Require either Origin and Referer header.
|
||||
// NOTE(longsleep): Firefox does not send Origin header for POST
|
||||
// requests when on the same domain - this is fuck (tm). See
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=446344 for reference.
|
||||
if origin == "" && referer == "" {
|
||||
err = fmt.Errorf("missing origin or referer header")
|
||||
break
|
||||
}
|
||||
|
||||
if origin != "" {
|
||||
originURL, urlParseErr := url.Parse(origin)
|
||||
if urlParseErr != nil {
|
||||
err = fmt.Errorf("invalid origin value: %v", urlParseErr)
|
||||
break
|
||||
}
|
||||
if originURL.Host != requiredHost {
|
||||
err = fmt.Errorf("origin does not match request URL")
|
||||
break
|
||||
}
|
||||
} else if referer != "" {
|
||||
refererURL, urlParseErr := url.Parse(referer)
|
||||
if urlParseErr != nil {
|
||||
err = fmt.Errorf("invalid referer value: %v", urlParseErr)
|
||||
break
|
||||
}
|
||||
if refererURL.Host != requiredHost {
|
||||
err = fmt.Errorf("referer does not match request URL")
|
||||
break
|
||||
}
|
||||
} else {
|
||||
i.logger.WithFields(logrus.Fields{
|
||||
"host": requiredHost,
|
||||
"user-agent": req.UserAgent(),
|
||||
}).Warn("identifier HTTP request is insecure with no Origin and Referer")
|
||||
}
|
||||
|
||||
handler.ServeHTTP(rw, req)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
i.logger.WithError(err).WithFields(logrus.Fields{
|
||||
"host": requiredHost,
|
||||
"referer": req.Referer(),
|
||||
"user-agent": req.UserAgent(),
|
||||
"origin": req.Header.Get("Origin"),
|
||||
}).Warn("rejecting identifier HTTP request")
|
||||
}
|
||||
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "")
|
||||
})
|
||||
}
|
||||
|
||||
func (i *Identifier) handleIdentifier(rw http.ResponseWriter, req *http.Request) {
|
||||
addCommonResponseHeaders(rw.Header())
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Form.Get("flow") {
|
||||
case FlowOIDC, FlowOAuth, "":
|
||||
if req.Form.Get("identifier") != MustBeSignedIn {
|
||||
// Check if there is a default authority, if so use that.
|
||||
authority := i.authorities.Default(req.Context())
|
||||
if authority != nil {
|
||||
switch authority.AuthorityType {
|
||||
case authorities.AuthorityTypeOIDC:
|
||||
i.writeOAuth2Start(rw, req, authority)
|
||||
case authorities.AuthorityTypeSAML2:
|
||||
i.writeSAML2Start(rw, req, authority)
|
||||
default:
|
||||
i.ErrorPage(rw, http.StatusNotImplemented, "", "unknown authority type")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show default.
|
||||
i.writeWebappIndexHTML(rw, req)
|
||||
}
|
||||
|
||||
func (i *Identifier) handleLogon(rw http.ResponseWriter, req *http.Request) {
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
var r LogonRequest
|
||||
err := decoder.Decode(&r)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode logon request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
||||
return
|
||||
}
|
||||
|
||||
var user *IdentifiedUser
|
||||
response := &LogonResponse{
|
||||
State: r.State,
|
||||
}
|
||||
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
record := NewRecord(req, i.Config.Config)
|
||||
|
||||
if r.Hello != nil {
|
||||
err = r.Hello.parse()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to parse logon request hello")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
|
||||
return
|
||||
}
|
||||
record.HelloRequest = r.Hello
|
||||
}
|
||||
|
||||
req = req.WithContext(NewRecordContext(konnect.NewRequestContext(req.Context(), req), record))
|
||||
|
||||
// Params is an array like this [$username, $password, $mode], defining a
|
||||
// extensible way to extend login modes over time. The minimal length of
|
||||
// the params array is 1 with only [$username]. Second field is the password
|
||||
// but its interpretation depends on the third field ($mode). The rest of the
|
||||
// fields are mode specific.
|
||||
params := r.Params
|
||||
for {
|
||||
paramSize := len(params)
|
||||
if paramSize == 0 {
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "params required")
|
||||
break
|
||||
}
|
||||
|
||||
if paramSize >= 3 && params[1] == "" && params[2] == ModeLogonUsernameEmptyPasswordCookie {
|
||||
// Special mode to allow when same user is logged in via cookie. This
|
||||
// is used in the select account page logon flow with empty password.
|
||||
identifiedUser, cookieErr := i.GetUserFromLogonCookie(req.Context(), req, 0, true)
|
||||
if cookieErr != nil {
|
||||
i.logger.WithError(cookieErr).Debugln("identifier failed to decode logon cookie in logon request")
|
||||
}
|
||||
if identifiedUser != nil {
|
||||
if identifiedUser.Username() == params[0] {
|
||||
user = identifiedUser
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audience := ""
|
||||
if r.Hello != nil {
|
||||
audience = r.Hello.ClientID
|
||||
}
|
||||
|
||||
if paramSize < 3 {
|
||||
// Unsupported logon mode.
|
||||
break
|
||||
}
|
||||
if params[1] == "" {
|
||||
// Empty password, stop here - never allowed in any mode.
|
||||
break
|
||||
}
|
||||
|
||||
switch params[2] {
|
||||
case ModeLogonUsernamePassword:
|
||||
// Username and password validation mode.
|
||||
logonedUser, logonErr := i.logonUser(req.Context(), audience, params[0], params[1])
|
||||
if logonErr != nil {
|
||||
i.logger.WithError(logonErr).Errorln("identifier failed to logon with backend")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to logon")
|
||||
return
|
||||
}
|
||||
user = logonedUser
|
||||
|
||||
default:
|
||||
i.logger.Debugln("identifier unknown logon mode: %v", params[2])
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if user == nil || user.Subject() == "" {
|
||||
rw.Header().Set("Kopano-Konnect-State", response.State)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user meta data.
|
||||
// TODO(longsleep): This is an additional request to the backend. This
|
||||
// should be avoided. Best would be if the backend would return everything
|
||||
// in one shot (TODO in core).
|
||||
err = i.updateUser(req.Context(), user, nil)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to update user data in logon request")
|
||||
}
|
||||
|
||||
// Set logon time.
|
||||
user.logonAt = time.Now()
|
||||
|
||||
if r.Hello != nil {
|
||||
hello, errHello := i.writeHelloResponse(rw, req, r.Hello, user)
|
||||
if errHello != nil {
|
||||
i.logger.WithError(errHello).Debugln("rejecting identifier logon request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", errHello.Error())
|
||||
return
|
||||
}
|
||||
if !hello.Success {
|
||||
rw.Header().Set("Kopano-Konnect-State", response.State)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
response.Hello = hello
|
||||
}
|
||||
|
||||
err = i.SetUserToLogonCookie(req.Context(), rw, user)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to serialize logon ticket")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success = true
|
||||
|
||||
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("logon request failed writing response")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) handleLogoff(rw http.ResponseWriter, req *http.Request) {
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
var r StateRequest
|
||||
err := decoder.Decode(&r)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode logoff request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
||||
return
|
||||
}
|
||||
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
ctx := req.Context()
|
||||
u, err := i.GetUserFromLogonCookie(ctx, req, 0, false)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Warnln("identifier logoff failed to get logon from ticket")
|
||||
}
|
||||
err = i.UnsetLogonCookie(ctx, u, rw)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier failed to set logoff ticket")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set logoff ticket")
|
||||
return
|
||||
}
|
||||
|
||||
response := &StateResponse{
|
||||
State: r.State,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("logoff request failed writing response")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) handleConsent(rw http.ResponseWriter, req *http.Request) {
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
var r ConsentRequest
|
||||
err := decoder.Decode(&r)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode consent request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
||||
return
|
||||
}
|
||||
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
consent := &Consent{
|
||||
Allow: r.Allow,
|
||||
}
|
||||
if r.Allow {
|
||||
consent.RawScope = r.RawScope
|
||||
}
|
||||
|
||||
err = i.SetConsentToConsentCookie(req.Context(), rw, &r, consent)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to serialize consent ticket")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize consent ticket")
|
||||
return
|
||||
}
|
||||
|
||||
if !r.Allow {
|
||||
rw.Header().Set("Kopano-Konnect-State", r.State)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
response := &StateResponse{
|
||||
State: r.State,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("logoff request failed writing response")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) handleHello(rw http.ResponseWriter, req *http.Request) {
|
||||
decoder := json.NewDecoder(req.Body)
|
||||
var r HelloRequest
|
||||
err := decoder.Decode(&r)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode hello request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
||||
return
|
||||
}
|
||||
err = r.parse()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to parse hello request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
|
||||
return
|
||||
}
|
||||
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
response, err := i.writeHelloResponse(rw, req, &r, nil)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("rejecting identifier hello request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("hello request failed writing response")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) handleTrampolin(rw http.ResponseWriter, req *http.Request) {
|
||||
if !strings.HasSuffix(req.URL.Path, ".js") {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode trampolin request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "trampolin", req.Form.Get("state"))
|
||||
if err != nil {
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
|
||||
return
|
||||
}
|
||||
if sd == nil || sd.Trampolin == nil {
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "no state")
|
||||
return
|
||||
}
|
||||
|
||||
scope := sd.Trampolin.Scope
|
||||
uri, _ := url.Parse(sd.Trampolin.URI)
|
||||
sd.Trampolin = nil
|
||||
|
||||
err = i.SetStateToStateCookie(req.Context(), rw, scope, sd)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to write trampolin state cookie")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to write trampolin state cookie")
|
||||
return
|
||||
}
|
||||
|
||||
i.writeTrampolinHTML(rw, req, uri)
|
||||
} else {
|
||||
i.writeTrampolinScript(rw, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) handleOAuth2Start(rw http.ResponseWriter, req *http.Request) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 start request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
var authority *authorities.Details
|
||||
if authorityID := req.Form.Get("authority_id"); authorityID != "" {
|
||||
authority, _ = i.authorities.Lookup(req.Context(), authorityID)
|
||||
}
|
||||
|
||||
i.writeOAuth2Start(rw, req, authority)
|
||||
}
|
||||
|
||||
func (i *Identifier) handleOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 cb request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
i.writeOAuth2Cb(rw, req)
|
||||
}
|
||||
|
||||
func (i *Identifier) handleSAML2Metadata(rw http.ResponseWriter, req *http.Request) {
|
||||
authorityDetails := i.authorities.Default(req.Context())
|
||||
if authorityDetails == nil || authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
|
||||
i.ErrorPage(rw, http.StatusNotFound, "", "saml not configured")
|
||||
return
|
||||
}
|
||||
|
||||
metadata := authorityDetails.Metadata()
|
||||
if metadata == nil {
|
||||
i.ErrorPage(rw, http.StatusNotFound, "", "saml has no meta data")
|
||||
return
|
||||
}
|
||||
|
||||
buf, _ := xml.MarshalIndent(metadata, "", " ")
|
||||
rw.Header().Set("Content-Type", "application/samlmetadata+xml")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte(xml.Header))
|
||||
rw.Write(buf)
|
||||
}
|
||||
|
||||
func (i *Identifier) handleSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode saml2 acs request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
i.writeSAML2AssertionConsumerService(rw, req)
|
||||
}
|
||||
|
||||
func (i *Identifier) handleSAML2SingleLogoutService(rw http.ResponseWriter, req *http.Request) {
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to decode saml2 slo request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := req.Form["SAMLRequest"]; ok {
|
||||
i.writeSAMLSingleLogoutServiceRequest(rw, req)
|
||||
} else if _, ok := req.Form["SAMLResponse"]; ok {
|
||||
i.writeSAMLSingleLogoutServiceResponse(rw, req)
|
||||
} else {
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "neither SAMLRequest nor SAMLResponse parameter found")
|
||||
}
|
||||
}
|
||||
+774
@@ -0,0 +1,774 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mapset "github.com/deckarep/golang-set"
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/go-jose/go-jose/v3/jwt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/oidc-go"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
"github.com/libregraph/lico/identifier/meta"
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/authorities"
|
||||
"github.com/libregraph/lico/identity/clients"
|
||||
"github.com/libregraph/lico/managers"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
// audienceMarker defines the value which gets included in logon cookies. Valid
|
||||
// logon cookies must have the first value of this list in their audience claim.
|
||||
// Increment this value whenever logon cookie claims and format changes in
|
||||
// non-backwards compatible ways. User will have to sign in again to get a new
|
||||
// cookie.
|
||||
var audienceMarker = jwt.Audience([]string{"2019012201"})
|
||||
|
||||
// Identifier defines a identification login area with its endpoints using
|
||||
// a Kopano Core server as backend logon provider.
|
||||
type Identifier struct {
|
||||
Config *Config
|
||||
|
||||
baseURI *url.URL
|
||||
pathPrefix string
|
||||
staticFolder string
|
||||
scopesConf string
|
||||
webappIndexHTML []byte
|
||||
|
||||
logonCookieName string
|
||||
logonCookieSameSite http.SameSite
|
||||
|
||||
consentCookieSameSite http.SameSite
|
||||
|
||||
stateCookieSameSite http.SameSite
|
||||
|
||||
authorizationEndpointURI *url.URL
|
||||
signedOutEndpointURI *url.URL
|
||||
oauth2CbEndpointURI *url.URL
|
||||
|
||||
encrypter jose.Encrypter
|
||||
recipient *jose.Recipient
|
||||
backend backends.Backend
|
||||
clients *clients.Registry
|
||||
authorities *authorities.Registry
|
||||
|
||||
meta *meta.Meta
|
||||
|
||||
defaultBannerLogo *string
|
||||
|
||||
onSetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter, user identity.User) error
|
||||
onUnsetLogonCallbacks []func(ctx context.Context, rw http.ResponseWriter) error
|
||||
|
||||
logger logrus.FieldLogger
|
||||
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
// NewIdentifier returns a new Identifier.
|
||||
func NewIdentifier(c *Config) (*Identifier, error) {
|
||||
staticFolder := c.StaticFolder
|
||||
var webappIndexHTML = make([]byte, 0)
|
||||
|
||||
if !c.WebAppDisabled {
|
||||
fn := staticFolder + "/index.html"
|
||||
if _, statErr := os.Stat(fn); os.IsNotExist(statErr) {
|
||||
return nil, fmt.Errorf("identifier client index.html not found: %w", statErr)
|
||||
}
|
||||
readData, readErr := ioutil.ReadFile(fn)
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("identifier failed to read client index.html: %w", readErr)
|
||||
}
|
||||
webappIndexHTML = bytes.Replace(readData, []byte("__PATH_PREFIX__"), []byte(c.PathPrefix), 1)
|
||||
}
|
||||
|
||||
oauth2CbEndpointURI, _ := url.Parse(c.BaseURI.String())
|
||||
oauth2CbEndpointURI.Path = c.PathPrefix + "/identifier/oauth2/cb"
|
||||
|
||||
i := &Identifier{
|
||||
Config: c,
|
||||
|
||||
baseURI: c.BaseURI,
|
||||
pathPrefix: c.PathPrefix,
|
||||
staticFolder: staticFolder,
|
||||
scopesConf: c.ScopesConf,
|
||||
webappIndexHTML: webappIndexHTML,
|
||||
|
||||
logonCookieName: c.LogonCookieName,
|
||||
logonCookieSameSite: c.LogonCookieSameSite,
|
||||
|
||||
consentCookieSameSite: c.ConsentCookieSameSite,
|
||||
|
||||
stateCookieSameSite: c.StateCookieSameSite,
|
||||
|
||||
authorizationEndpointURI: c.AuthorizationEndpointURI,
|
||||
signedOutEndpointURI: c.SignedOutEndpointURI,
|
||||
oauth2CbEndpointURI: oauth2CbEndpointURI,
|
||||
|
||||
backend: c.Backend,
|
||||
|
||||
onSetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter, user identity.User) error, 0),
|
||||
onUnsetLogonCallbacks: make([]func(ctx context.Context, rw http.ResponseWriter) error, 0),
|
||||
|
||||
logger: c.Config.Logger,
|
||||
}
|
||||
|
||||
var err error
|
||||
i.meta = &meta.Meta{}
|
||||
i.meta.Scopes, err = scopes.NewScopesFromFile(i.scopesConf, i.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.DefaultBannerLogo != nil {
|
||||
defaultBannerLogo, err := encodeImageAsDataURL(c.DefaultBannerLogo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode default banner logo: %w", err)
|
||||
}
|
||||
i.defaultBannerLogo = &defaultBannerLogo
|
||||
}
|
||||
|
||||
i.meta.Scopes.Extend(c.Backend.ScopesMeta())
|
||||
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// RegisterManagers registers the provided managers,
|
||||
func (i *Identifier) RegisterManagers(mgrs *managers.Managers) error {
|
||||
i.clients = mgrs.Must("clients").(*clients.Registry)
|
||||
i.authorities = mgrs.Must("authorities").(*authorities.Registry)
|
||||
|
||||
if service, ok := i.backend.(managers.ServiceUsesManagers); ok {
|
||||
err := service.RegisterManagers(mgrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddRoutes adds the endpoint routes of the accociated Identifier to the
|
||||
// provided router with the provided context.
|
||||
func (i *Identifier) AddRoutes(ctx context.Context, router *mux.Router) {
|
||||
r := router.PathPrefix(i.pathPrefix).Subrouter()
|
||||
|
||||
r.PathPrefix("/static/").Handler(i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), true))
|
||||
r.Handle("/service-worker.js", i.staticHandler(http.StripPrefix(i.pathPrefix, http.FileServer(http.Dir(i.staticFolder))), false))
|
||||
r.Handle("/identifier", http.HandlerFunc(i.handleIdentifier)).Methods(http.MethodGet).Name("index")
|
||||
r.Handle("/chooseaccount", i).Methods(http.MethodGet).Name("chooseaccount")
|
||||
r.Handle("/consent", i).Methods(http.MethodGet).Name("consent")
|
||||
r.Handle("/welcome", i).Methods(http.MethodGet).Name("welcome")
|
||||
r.Handle("/goodbye", i).Methods(http.MethodGet).Name("goodbye")
|
||||
r.Handle("/index.html", i).Methods(http.MethodGet) // For service worker.
|
||||
r.Handle("/identifier/_/logon", i.secureHandler(http.HandlerFunc(i.handleLogon))).Methods(http.MethodPost)
|
||||
r.Handle("/identifier/_/logoff", i.secureHandler(http.HandlerFunc(i.handleLogoff))).Methods(http.MethodPost)
|
||||
r.Handle("/identifier/_/hello", i.secureHandler(http.HandlerFunc(i.handleHello))).Methods(http.MethodPost)
|
||||
r.Handle("/identifier/_/consent", i.secureHandler(http.HandlerFunc(i.handleConsent))).Methods(http.MethodPost)
|
||||
r.Handle("/identifier/oauth2/start", http.HandlerFunc(i.handleOAuth2Start)).Methods(http.MethodGet).Name("oauth2/start")
|
||||
r.Handle("/identifier/oauth2/cb", http.HandlerFunc(i.handleOAuth2Cb)).Methods(http.MethodGet).Name("oauth2/cb")
|
||||
r.Handle("/identifier/saml2/metadata", http.HandlerFunc(i.handleSAML2Metadata))
|
||||
r.Handle("/identifier/saml2/acs", http.HandlerFunc(i.handleSAML2AssertionConsumerService)).Methods(http.MethodPost).Name("saml2/acs")
|
||||
r.Handle("/identifier/_/saml2/slo", http.HandlerFunc(i.handleSAML2SingleLogoutService)).Methods(http.MethodGet).Name("saml2/slo")
|
||||
r.Handle("/identifier/trampolin", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet).Name("trampolin")
|
||||
r.Handle("/identifier/trampolin/trampolin.js", http.HandlerFunc(i.handleTrampolin)).Methods(http.MethodGet)
|
||||
|
||||
i.router = r
|
||||
|
||||
if i.backend != nil {
|
||||
i.backend.RunWithContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface.
|
||||
func (i *Identifier) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
addCommonResponseHeaders(rw.Header())
|
||||
addNoCacheResponseHeaders(rw.Header())
|
||||
|
||||
// Show default.
|
||||
i.writeWebappIndexHTML(rw, req)
|
||||
}
|
||||
|
||||
// SetKey sets the provided key for the accociated identifier.
|
||||
func (i *Identifier) SetKey(key []byte) error {
|
||||
var ce jose.ContentEncryption
|
||||
var algo jose.KeyAlgorithm
|
||||
switch len(key) {
|
||||
case 16:
|
||||
ce = jose.A128GCM
|
||||
algo = jose.A128GCMKW
|
||||
case 24:
|
||||
ce = jose.A192GCM
|
||||
algo = jose.A192GCMKW
|
||||
case 32:
|
||||
ce = jose.A256GCM
|
||||
algo = jose.A256GCMKW
|
||||
default:
|
||||
return fmt.Errorf("identifier invalid encryption key size. Need 16, 24 or 32 bytes")
|
||||
}
|
||||
|
||||
if len(key) < 32 {
|
||||
i.logger.Warnf("identifier using encryption key size with %d bytes which is below 32 bytes", len(key))
|
||||
} else {
|
||||
i.logger.WithField("security", fmt.Sprintf("%s:%s", ce, algo)).Infoln("identifier set up")
|
||||
}
|
||||
|
||||
recipient := jose.Recipient{
|
||||
Algorithm: algo,
|
||||
KeyID: "",
|
||||
Key: key,
|
||||
}
|
||||
encrypter, err := jose.NewEncrypter(
|
||||
ce,
|
||||
recipient,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.encrypter = encrypter
|
||||
i.recipient = &recipient
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorPage writes a HTML error page to the provided ResponseWriter.
|
||||
func (i *Identifier) ErrorPage(rw http.ResponseWriter, code int, title string, message string) {
|
||||
utils.WriteErrorPage(rw, code, title, message)
|
||||
}
|
||||
|
||||
// SetUserToLogonCookie serializes the provided user into an encrypted string
|
||||
// and sets it as cookie on the provided http.ResponseWriter.
|
||||
func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseWriter, user *IdentifiedUser) error {
|
||||
loggedOn, logonAt := user.LoggedOn()
|
||||
if !loggedOn {
|
||||
return fmt.Errorf("refused to set cookie for not logged on user")
|
||||
}
|
||||
|
||||
// Add standard claims.
|
||||
claims := jwt.Claims{
|
||||
Issuer: user.BackendName(),
|
||||
Audience: audienceMarker,
|
||||
Subject: user.Subject(),
|
||||
IssuedAt: jwt.NewNumericDate(logonAt),
|
||||
}
|
||||
// Add expiration, if set.
|
||||
if user.expiresAfter != nil {
|
||||
claims.Expiry = jwt.NewNumericDate(*user.expiresAfter)
|
||||
}
|
||||
|
||||
// Additional claims.
|
||||
userClaims := map[string]interface{}(user.Claims())
|
||||
if sessionRef := user.SessionRef(); sessionRef != nil {
|
||||
userClaims[SessionIDClaim] = *sessionRef
|
||||
}
|
||||
if logonRef := user.LogonRef(); logonRef != nil {
|
||||
userClaims[LogonRefClaim] = *logonRef
|
||||
}
|
||||
if externalAuthorityID := user.ExternalAuthorityID(); externalAuthorityID != nil {
|
||||
userClaims[ExternalAuthorityIDClaim] = *externalAuthorityID
|
||||
}
|
||||
if lockedScopes := user.LockedScopes(); lockedScopes != nil {
|
||||
userClaims[LockedScopesClaim] = strings.Join(lockedScopes, " ")
|
||||
}
|
||||
|
||||
// Serialize and encrypt cookie value.
|
||||
serialized, err := jwt.Encrypted(i.encrypter).Claims(claims).Claims(userClaims).CompactSerialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set cookie.
|
||||
err = i.setLogonCookie(rw, serialized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Trigger callbacks.
|
||||
for _, f := range i.onSetLogonCallbacks {
|
||||
err = f(ctx, rw, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsetLogonCookie adds cookie remove headers to the provided http.ResponseWriter
|
||||
// effectively implementing logout.
|
||||
func (i *Identifier) UnsetLogonCookie(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter) error {
|
||||
// Remove cookie.
|
||||
err := i.removeLogonCookie(rw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Destroy backend user session if any.
|
||||
if user != nil {
|
||||
if sessionRef := user.SessionRef(); sessionRef != nil {
|
||||
err = i.backend.DestroySession(ctx, sessionRef)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Warnln("failed to destroy session on unset logon cookie")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trigger callbacks.
|
||||
for _, f := range i.onUnsetLogonCallbacks {
|
||||
err = f(ctx, rw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndSession begins the process to end the session either directly or indirectly
|
||||
// based on the provided user. It optionally returns an uri which shall be used
|
||||
// as redirection target or an error.
|
||||
func (i *Identifier) EndSession(ctx context.Context, user *IdentifiedUser, rw http.ResponseWriter, postRedirectURI *url.URL, state string) (*url.URL, error) {
|
||||
err := i.UnsetLogonCookie(ctx, user, rw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var uri *url.URL
|
||||
if user.externalAuthority != nil && user.externalAuthority.EndSessionEnabled {
|
||||
// Generate state and set state cookie with postRedirectURI.
|
||||
if state == "" {
|
||||
state = rndm.GenerateRandomString(32)
|
||||
}
|
||||
sd := &StateData{
|
||||
State: state,
|
||||
Mode: StateModeEndSession,
|
||||
|
||||
Ref: user.externalAuthority.ID,
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
uri, extra, err = user.externalAuthority.MakeRedirectEndSessionRequestURL(user.LogonRef(), sd.State)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sd.Extra = extra
|
||||
if postRedirectURI != nil && postRedirectURI.String() != "" {
|
||||
sd.RawQuery = postRedirectURI.String()
|
||||
}
|
||||
|
||||
var scope string
|
||||
switch user.externalAuthority.AuthorityType {
|
||||
case authorities.AuthorityTypeOIDC:
|
||||
// Inject post logout url target.
|
||||
cb, _ := i.router.GetRoute("oauth2/cb").URL()
|
||||
next, _ := url.Parse(i.baseURI.String())
|
||||
next.Path = cb.Path
|
||||
query := uri.Query()
|
||||
query.Set("post_logout_redirect_uri", next.String())
|
||||
uri.RawQuery = query.Encode()
|
||||
// Redirect using trampolin, to ensure origin checks of external
|
||||
// authority can pass.
|
||||
sd.Trampolin = &TrampolinData{
|
||||
URI: uri.String(),
|
||||
Scope: "oauth2/cb",
|
||||
}
|
||||
scope = "trampolin"
|
||||
uri, _ = i.router.GetRoute("trampolin").URL()
|
||||
query = make(url.Values)
|
||||
query.Add("state", sd.State)
|
||||
uri.RawQuery = query.Encode()
|
||||
|
||||
case authorities.AuthorityTypeSAML2:
|
||||
scope = "_/saml2/slo"
|
||||
}
|
||||
|
||||
if scope != "" {
|
||||
err = i.SetStateToStateCookie(ctx, rw, scope, sd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set saml2 slo state cookie: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
// GetUserFromLogonCookie looks up the associated cookie name from the provided
|
||||
// request, parses it and returns the user containing the information found in
|
||||
// the coookie payload data.
|
||||
func (i *Identifier) GetUserFromLogonCookie(ctx context.Context, req *http.Request, maxAge time.Duration, refreshSession bool) (*IdentifiedUser, error) {
|
||||
cookie, err := i.getLogonCookie(req)
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt and parse cookie value.
|
||||
token, err := jwt.ParseEncrypted(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse claims.
|
||||
var claims jwt.Claims
|
||||
var userClaims map[string]interface{}
|
||||
if claimsErr := token.Claims(i.recipient.Key, &claims, &userClaims); claimsErr != nil {
|
||||
return nil, claimsErr
|
||||
}
|
||||
|
||||
// Validate claims.
|
||||
if claimsErr := claims.Validate(jwt.Expected{
|
||||
// Ignore cookie, when issuer does not match our backend name. This usually
|
||||
// means that konnect was reconfigured. Users need to sign in again.
|
||||
Issuer: i.backend.Name(),
|
||||
// Ignore cookie, when audience marker does not match. This happens
|
||||
// for cookies from an older version of konnect. Users need to sign in again.
|
||||
Audience: jwt.Audience{audienceMarker[0]},
|
||||
}); claimsErr != nil {
|
||||
i.logger.WithError(claimsErr).Debugln("logon token claims validation failed")
|
||||
return nil, nil
|
||||
}
|
||||
if claims.Subject == "" {
|
||||
return nil, fmt.Errorf("invalid subject in logon token")
|
||||
}
|
||||
if userClaims == nil {
|
||||
return nil, fmt.Errorf("invalid user claims in logon token")
|
||||
}
|
||||
|
||||
// New user with details from claims.
|
||||
user := &IdentifiedUser{
|
||||
sub: claims.Subject,
|
||||
|
||||
// TODO(longsleep): It is not verified here that the user still exists at
|
||||
// our current backend. We still assign the backend happily here - probably
|
||||
// needs some sort of veritification / lookup.
|
||||
backend: i.backend,
|
||||
|
||||
logonAt: claims.IssuedAt.Time(),
|
||||
}
|
||||
if claims.Expiry != nil {
|
||||
expiresAfter := claims.Expiry.Time()
|
||||
user.expiresAfter = &expiresAfter
|
||||
}
|
||||
|
||||
loggedOn, logonAt := user.LoggedOn()
|
||||
if !loggedOn {
|
||||
// Ignore logons which are not valid.
|
||||
return nil, nil
|
||||
}
|
||||
if maxAge > 0 {
|
||||
if logonAt.Add(maxAge).Before(time.Now()) {
|
||||
// Ignore logon as it is no longer valid within maxAge.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get specific data from claims.
|
||||
if v := userClaims[SessionIDClaim]; v != nil {
|
||||
sessionRef := v.(string)
|
||||
if sessionRef != "" {
|
||||
// Remember session ref in user.
|
||||
user.sessionRef = &sessionRef
|
||||
// Ensure the session is still valid, by refreshing it.
|
||||
if refreshSession {
|
||||
err = i.backend.RefreshSession(ctx, user.Subject(), &sessionRef, userClaims)
|
||||
if err != nil {
|
||||
// Ignore logons which fail session refresh.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := userClaims[LogonRefClaim]; v != nil {
|
||||
logonRef := v.(string)
|
||||
if logonRef != "" {
|
||||
// Remember logon ref in user.
|
||||
user.logonRef = &logonRef
|
||||
}
|
||||
}
|
||||
if v := userClaims[ExternalAuthorityIDClaim]; v != nil {
|
||||
externalAuthorityID := v.(string)
|
||||
if externalAuthorityID != "" {
|
||||
authority, err := i.authorities.Lookup(ctx, externalAuthorityID)
|
||||
if err != nil {
|
||||
// Ignore logons which have set an unknown external authority.
|
||||
return nil, nil
|
||||
}
|
||||
// TODO(longsleep): Check if authority is actually enabled. For now
|
||||
// we check if it is ready.
|
||||
if !authority.IsReady() {
|
||||
// Ignore logons which have sent an authority which is not ready.
|
||||
return nil, nil
|
||||
}
|
||||
user.externalAuthority = authority
|
||||
}
|
||||
}
|
||||
|
||||
if v := userClaims[LockedScopesClaim]; v != nil {
|
||||
lockedScopes := v.(string)
|
||||
if lockedScopes != "" {
|
||||
user.lockedScopes = strings.Split(lockedScopes, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// Fill additional claim.
|
||||
user.claims = make(map[string]interface{})
|
||||
for k, v := range userClaims {
|
||||
switch k {
|
||||
case konnect.IdentifiedUsernameClaim:
|
||||
user.username = v.(string)
|
||||
case konnect.IdentifiedDisplayNameClaim:
|
||||
user.displayName = v.(string)
|
||||
|
||||
case SessionIDClaim:
|
||||
// Already handled above.
|
||||
continue
|
||||
case LogonRefClaim:
|
||||
// Already handled above.
|
||||
continue
|
||||
case ExternalAuthorityIDClaim:
|
||||
// Already handled above.
|
||||
continue
|
||||
case LockedScopesClaim:
|
||||
// Already handled above.
|
||||
continue
|
||||
case ObsoleteUserClaimsClaim:
|
||||
// Keep and ignore for history reasons.
|
||||
continue
|
||||
|
||||
case oidc.AudienceClaim, oidc.IssuedAtClaim, oidc.ExpirationClaim, oidc.SubjectIdentifierClaim, oidc.IssuerIdentifierClaim:
|
||||
// Ignore default OIDC claims when resurrecting claims data.
|
||||
continue
|
||||
|
||||
default:
|
||||
// Add the rest.
|
||||
user.claims[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserFromID looks up the user identified by the provided userID by
|
||||
// requesting the associated backend.
|
||||
func (i *Identifier) GetUserFromID(ctx context.Context, userID string, sessionRef *string, requestedScopes map[string]bool) (*IdentifiedUser, error) {
|
||||
user, err := i.backend.GetUser(ctx, userID, sessionRef, requestedScopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// XXX(longsleep): This is quite crappy. Move IdentifiedUser to a package
|
||||
// which can be imported by backends so they directly can return that shit.
|
||||
identifiedUser := &IdentifiedUser{
|
||||
sub: user.Subject(),
|
||||
|
||||
username: user.Username(),
|
||||
|
||||
backend: i.backend,
|
||||
|
||||
sessionRef: sessionRef,
|
||||
claims: user.BackendClaims(),
|
||||
scopes: user.BackendScopes(),
|
||||
|
||||
lockedScopes: user.RequiredScopes(),
|
||||
}
|
||||
if userWithEmail, ok := user.(identity.UserWithEmail); ok {
|
||||
identifiedUser.email = userWithEmail.Email()
|
||||
identifiedUser.emailVerified = userWithEmail.EmailVerified()
|
||||
}
|
||||
if userWithProfile, ok := user.(identity.UserWithProfile); ok {
|
||||
identifiedUser.displayName = userWithProfile.Name()
|
||||
identifiedUser.familyName = userWithProfile.FamilyName()
|
||||
identifiedUser.givenName = userWithProfile.GivenName()
|
||||
}
|
||||
if userWithID, ok := user.(identity.UserWithID); ok {
|
||||
identifiedUser.id = userWithID.ID()
|
||||
}
|
||||
if userWithUniqueID, ok := user.(identity.UserWithUniqueID); ok {
|
||||
identifiedUser.uid = userWithUniqueID.UniqueID()
|
||||
}
|
||||
|
||||
return identifiedUser, nil
|
||||
}
|
||||
|
||||
// SetConsentToConsentCookie serializses the provided Consent using the provided
|
||||
// ConsentRequest and sets it as cookie on the provided ReponseWriter.
|
||||
func (i *Identifier) SetConsentToConsentCookie(ctx context.Context, rw http.ResponseWriter, cr *ConsentRequest, consent *Consent) error {
|
||||
serialized, err := jwt.Encrypted(i.encrypter).Claims(consent).CompactSerialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.setConsentCookie(rw, cr, serialized)
|
||||
}
|
||||
|
||||
// GetConsentFromConsentCookie extract consent information for the provided
|
||||
// request and the provide state.
|
||||
func (i *Identifier) GetConsentFromConsentCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, state string) (*Consent, error) {
|
||||
if state == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cr := &ConsentRequest{
|
||||
State: state,
|
||||
ClientID: req.Form.Get("client_id"),
|
||||
RawRedirectURI: req.Form.Get("redirect_uri"),
|
||||
Ref: req.Form.Get("state"),
|
||||
Nonce: req.Form.Get("nonce"),
|
||||
}
|
||||
|
||||
cookie, err := i.getConsentCookie(req, cr)
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Directly remove the cookie again after we used it.
|
||||
i.removeConsentCookie(rw, req, cr)
|
||||
|
||||
token, err := jwt.ParseEncrypted(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var consent Consent
|
||||
if err = token.Claims(i.recipient.Key, &consent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &consent, nil
|
||||
}
|
||||
|
||||
// SetStateToStateCookie serializses the provided StateRequest and sets it
|
||||
// as cookie on the provided ReponseWriter.
|
||||
func (i *Identifier) SetStateToStateCookie(ctx context.Context, rw http.ResponseWriter, scope string, sd *StateData) error {
|
||||
serialized, err := jwt.Encrypted(i.encrypter).Claims(sd).CompactSerialize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return i.setStateCookie(rw, scope, sd.State, serialized)
|
||||
}
|
||||
|
||||
// GetStateFromStateCookie extracts state information for the provided
|
||||
// request using the provided scope and state.
|
||||
func (i *Identifier) GetStateFromStateCookie(ctx context.Context, rw http.ResponseWriter, req *http.Request, scope string, state string) (*StateData, error) {
|
||||
if state == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cookie, err := i.getStateCookie(req, state)
|
||||
if err != nil {
|
||||
if err == http.ErrNoCookie {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Directly remove the cookie again after we used it.
|
||||
i.removeStateCookie(rw, req, scope, state)
|
||||
|
||||
token, err := jwt.ParseEncrypted(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sd := &StateData{}
|
||||
if err = token.Claims(i.recipient.Key, sd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if sd.State != state {
|
||||
return nil, fmt.Errorf("state mismatch")
|
||||
}
|
||||
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
// Name returns the active identifiers backend's name.
|
||||
func (i *Identifier) Name() string {
|
||||
return i.backend.Name()
|
||||
}
|
||||
|
||||
// ScopesSupported return the scopes supported by the accociated Identifier.
|
||||
func (i *Identifier) ScopesSupported() []string {
|
||||
scopes := mapset.NewThreadUnsafeSet()
|
||||
|
||||
for scope := range i.meta.Scopes.Definitions {
|
||||
scopes.Add(scope)
|
||||
}
|
||||
for _, scope := range i.backend.ScopesSupported() {
|
||||
scopes.Add(scope)
|
||||
}
|
||||
|
||||
supportedScopes := make([]string, 0)
|
||||
it := scopes.Iterator()
|
||||
for scope := range it.C {
|
||||
supportedScopes = append(supportedScopes, scope.(string))
|
||||
}
|
||||
|
||||
return supportedScopes
|
||||
}
|
||||
|
||||
// OnSetLogon implements a way to register hooks whenever logon information is
|
||||
// set by the accociated Identifier.
|
||||
func (i *Identifier) OnSetLogon(cb func(ctx context.Context, rw http.ResponseWriter, user identity.User) error) error {
|
||||
i.onSetLogonCallbacks = append(i.onSetLogonCallbacks, cb)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnUnsetLogon implements a way to register hooks whenever logon information is
|
||||
// set by the accociated Identifier.
|
||||
func (i *Identifier) OnUnsetLogon(cb func(ctx context.Context, rw http.ResponseWriter) error) error {
|
||||
i.onUnsetLogonCallbacks = append(i.onUnsetLogonCallbacks, cb)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Identifier) absoluteURLForRoute(name string) (*url.URL, error) {
|
||||
uri, _ := url.Parse(i.Config.BaseURI.String())
|
||||
|
||||
route := i.router.Get(name)
|
||||
|
||||
path, err := route.URL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri.Path = path.Path
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head data-kopano-build="%VITE_KOPANO_BUILD%">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
|
||||
<meta property="csp-nonce" content="__CSP_NONCE__">
|
||||
<title>Sign in to your account</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="bg">
|
||||
<div></div>
|
||||
</div>
|
||||
<div id="root" data-path-prefix="__PATH_PREFIX__"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2021 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package meta
|
||||
|
||||
// Branding is a container to hold identifier branding meta data.
|
||||
type Branding struct {
|
||||
BannerLogo *string `json:"bannerLogo,omitempty"`
|
||||
SignInPageText *string `json:"signinPageText,omitempty"`
|
||||
UsernameHintText *string `json:"usernameHintText,omitempty"`
|
||||
SignInPageLogoURI *string `json:"signinPageLogoURI,omitempty"`
|
||||
Locales []string `json:"locales,omitempty"`
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package meta
|
||||
|
||||
import (
|
||||
"github.com/libregraph/lico/identifier/meta/scopes"
|
||||
)
|
||||
|
||||
// Meta is a container to hold identifier meta data which can be requested by
|
||||
// clients.
|
||||
type Meta struct {
|
||||
Scopes *scopes.Scopes `json:"scopes"`
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package scopes
|
||||
|
||||
// A Definition contains the meta data for a single scope.
|
||||
type Definition struct {
|
||||
Priority int `json:"priority" yaml:"priority"`
|
||||
Description string `json:"description,omitempty" yaml:"description"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package scopes
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
)
|
||||
|
||||
const (
|
||||
scopeAliasBasic = "basic"
|
||||
scopeUnknown = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
priorityBasic = 20
|
||||
priorityOfflineAccess = 10
|
||||
)
|
||||
|
||||
var defaultScopesMap = map[string]string{
|
||||
oidc.ScopeOpenID: scopeAliasBasic,
|
||||
oidc.ScopeEmail: scopeAliasBasic,
|
||||
oidc.ScopeProfile: scopeAliasBasic,
|
||||
|
||||
konnect.ScopeNumericID: scopeAliasBasic,
|
||||
konnect.ScopeUniqueUserID: scopeAliasBasic,
|
||||
konnect.ScopeRawSubject: scopeAliasBasic,
|
||||
}
|
||||
|
||||
var defaultScopesDefinitionMap = map[string]*Definition{
|
||||
scopeAliasBasic: &Definition{
|
||||
ID: "scope_alias_basic",
|
||||
Priority: priorityBasic,
|
||||
},
|
||||
oidc.ScopeOfflineAccess: &Definition{
|
||||
ID: "scope_offline_access",
|
||||
Priority: priorityOfflineAccess,
|
||||
},
|
||||
}
|
||||
|
||||
// Scopes contain collections for scope related meta data
|
||||
type Scopes struct {
|
||||
Mapping map[string]string `json:"mapping" yaml:"mapping"`
|
||||
Definitions map[string]*Definition `json:"definitions" yaml:"scopes"`
|
||||
}
|
||||
|
||||
// NewScopesFromIDs creates a new scopes meta data collection from the provided
|
||||
// scopes IDs optionally also adding definitions from a parent.
|
||||
func NewScopesFromIDs(scopes map[string]bool, parent *Scopes) *Scopes {
|
||||
mapping := make(map[string]string)
|
||||
definitions := make(map[string]*Definition)
|
||||
|
||||
for scope, enabled := range scopes {
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
alias := scope
|
||||
if mapped, ok := parent.Mapping[scope]; ok {
|
||||
alias = mapped
|
||||
mapping[scope] = mapped
|
||||
} else if mapped, ok := defaultScopesMap[scope]; ok {
|
||||
alias = mapped
|
||||
mapping[scope] = mapped
|
||||
}
|
||||
|
||||
if definition, ok := parent.Definitions[alias]; ok {
|
||||
definitions[alias] = definition
|
||||
} else if definition, ok := defaultScopesDefinitionMap[alias]; ok {
|
||||
definitions[alias] = definition
|
||||
}
|
||||
}
|
||||
|
||||
return &Scopes{
|
||||
Mapping: mapping,
|
||||
Definitions: definitions,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScopesFromFile loads scope definitions from a file.
|
||||
func NewScopesFromFile(scopesConfFilepath string, logger logrus.FieldLogger) (*Scopes, error) {
|
||||
scopes := &Scopes{}
|
||||
|
||||
if scopesConfFilepath != "" {
|
||||
logger.Debugf("parsing scopes conf from %v", scopesConfFilepath)
|
||||
confFile, err := ioutil.ReadFile(scopesConfFilepath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(confFile, scopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for id, definition := range scopes.Definitions {
|
||||
fields := logrus.Fields{
|
||||
"id": id,
|
||||
"priority": definition.Priority,
|
||||
}
|
||||
|
||||
logger.WithFields(fields).Debugln("registered scope")
|
||||
}
|
||||
|
||||
for id, mapped := range scopes.Mapping {
|
||||
fields := logrus.Fields{
|
||||
"id": id,
|
||||
"to": mapped,
|
||||
}
|
||||
|
||||
logger.WithFields(fields).Debugln("registered scope mapping")
|
||||
}
|
||||
}
|
||||
|
||||
if scopes.Mapping == nil {
|
||||
scopes.Mapping = make(map[string]string)
|
||||
}
|
||||
if scopes.Definitions == nil {
|
||||
scopes.Definitions = make(map[string]*Definition)
|
||||
}
|
||||
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
// Extend adds the provided scope mappings and definitions to the accociated
|
||||
// scopes mappings and definitions with replacing already existing. If scopes is
|
||||
// nil, Extends is a no-op.
|
||||
func (s *Scopes) Extend(scopes *Scopes) error {
|
||||
if scopes == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for scope, definition := range scopes.Definitions {
|
||||
s.Definitions[scope] = definition
|
||||
}
|
||||
for mapped, mapping := range scopes.Mapping {
|
||||
s.Mapping[mapped] = mapping
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/libregraph/lico/identifier/meta"
|
||||
"github.com/libregraph/lico/identity/clients"
|
||||
)
|
||||
|
||||
// A LogonRequest is the request data as sent to the logon endpoint
|
||||
type LogonRequest struct {
|
||||
State string `json:"state"`
|
||||
|
||||
Params []string `json:"params"`
|
||||
Hello *HelloRequest `json:"hello"`
|
||||
}
|
||||
|
||||
// A LogonResponse holds a response as sent by the logon endpoint.
|
||||
type LogonResponse struct {
|
||||
Success bool `json:"success"`
|
||||
State string `json:"state"`
|
||||
|
||||
Hello *HelloResponse `json:"hello"`
|
||||
}
|
||||
|
||||
// A HelloRequest is the request data as send to the hello endpoint.
|
||||
type HelloRequest struct {
|
||||
State string `json:"state"`
|
||||
Flow string `json:"flow"`
|
||||
RawScope string `json:"scope"`
|
||||
RawPrompt string `json:"prompt"`
|
||||
ClientID string `json:"client_id"`
|
||||
RawRedirectURI string `json:"redirect_uri"`
|
||||
RawIDTokenHint string `json:"id_token_hint"`
|
||||
RawMaxAge string `json:"max_age"`
|
||||
|
||||
Scopes map[string]bool `json:"-"`
|
||||
Prompts map[string]bool `json:"-"`
|
||||
RedirectURI *url.URL `json:"-"`
|
||||
IDTokenHint *jwt.Token `json:"-"`
|
||||
MaxAge time.Duration `json:"-"`
|
||||
|
||||
//TODO(longsleep): Add support to pass request parameters as JWT as
|
||||
// specified in http://openid.net/specs/openid-connect-core-1_0.html#JWTRequests
|
||||
}
|
||||
|
||||
func (hr *HelloRequest) parse() error {
|
||||
hr.Scopes = make(map[string]bool)
|
||||
hr.Prompts = make(map[string]bool)
|
||||
|
||||
hr.RedirectURI, _ = url.Parse(hr.RawRedirectURI)
|
||||
|
||||
if hr.RawScope != "" {
|
||||
for _, scope := range strings.Split(hr.RawScope, " ") {
|
||||
hr.Scopes[scope] = true
|
||||
}
|
||||
}
|
||||
if hr.RawPrompt != "" {
|
||||
for _, prompt := range strings.Split(hr.RawPrompt, " ") {
|
||||
hr.Prompts[prompt] = true
|
||||
}
|
||||
}
|
||||
if hr.RawMaxAge != "" {
|
||||
maxAgeInt, err := strconv.ParseInt(hr.RawMaxAge, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hr.MaxAge = time.Duration(maxAgeInt) * time.Second
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// A HelloResponse holds a response as sent by the hello endpoint.
|
||||
type HelloResponse struct {
|
||||
State string `json:"state"`
|
||||
Flow string `json:"flow"`
|
||||
Success bool `json:"success"`
|
||||
Username string `json:"username,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
|
||||
Next string `json:"next,omitempty"`
|
||||
ContinueURI string `json:"continue_uri,omitempty"`
|
||||
Scopes map[string]bool `json:"scopes,omitempty"`
|
||||
ClientDetails *clients.Details `json:"client,omitempty"`
|
||||
Meta *meta.Meta `json:"meta,omitempty"`
|
||||
Branding *meta.Branding `json:"branding,omitempty"`
|
||||
}
|
||||
|
||||
// A StateRequest is a general request with a state.
|
||||
type StateRequest struct {
|
||||
State string
|
||||
}
|
||||
|
||||
// A StateResponse hilds a response as reply to a StateRequest.
|
||||
type StateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// StateData contains data bound to a state.
|
||||
type StateData struct {
|
||||
State string `json:"state"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
RawQuery string `json:"raw_query,omitempty"`
|
||||
|
||||
ClientID string `json:"client_id"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
|
||||
Extra map[string]interface{} `json:"extra,omitempty"`
|
||||
|
||||
Trampolin *TrampolinData `json:"trampolin,omitempty"`
|
||||
}
|
||||
|
||||
type TrampolinData struct {
|
||||
URI string `json:"uri"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// A ConsentRequest is the request data as sent to the consent endpoint.
|
||||
type ConsentRequest struct {
|
||||
State string `json:"state"`
|
||||
Allow bool `json:"allow"`
|
||||
RawScope string `json:"scope"`
|
||||
ClientID string `json:"client_id"`
|
||||
RawRedirectURI string `json:"redirect_uri"`
|
||||
Ref string `json:"ref"`
|
||||
Nonce string `json:"flow_nonce"`
|
||||
}
|
||||
|
||||
// Consent is the data received and sent to allow or cancel consent flows.
|
||||
type Consent struct {
|
||||
Allow bool `json:"allow"`
|
||||
RawScope string `json:"scope"`
|
||||
}
|
||||
|
||||
// Scopes returns the associated consents approved scopes filtered by the
|
||||
//provided requested scopes and the full unfiltered approved scopes table.
|
||||
func (c *Consent) Scopes(requestedScopes map[string]bool) (map[string]bool, map[string]bool) {
|
||||
scopes := make(map[string]bool)
|
||||
if c.RawScope != "" {
|
||||
for _, scope := range strings.Split(c.RawScope, " ") {
|
||||
scopes[scope] = true
|
||||
}
|
||||
}
|
||||
|
||||
approved := make(map[string]bool)
|
||||
for n, v := range requestedScopes {
|
||||
if ok, _ := scopes[n]; ok && v {
|
||||
approved[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
return approved, scopes
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
const (
|
||||
// ModeLogonUsernameEmptyPasswordCookie is the logon mode which requires a
|
||||
// username which matches the currently signed in user in the cookie and an
|
||||
// empty password.
|
||||
ModeLogonUsernameEmptyPasswordCookie = "0"
|
||||
// ModeLogonUsernamePassword is the logon mode which requires a username
|
||||
// and a password.
|
||||
ModeLogonUsernamePassword = "1"
|
||||
)
|
||||
|
||||
const (
|
||||
// MustBeSignedIn is a authorize mode which tells the authorization code,
|
||||
// that it is expected to have a signed in user and everything else should
|
||||
// be treated as error.
|
||||
MustBeSignedIn = "must"
|
||||
)
|
||||
|
||||
const (
|
||||
// StateModeEndSession is a state mode which selects end session specific
|
||||
// actions when processing state requests.
|
||||
StateModeEndSession = "0"
|
||||
)
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* Copyright 2017-2020 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/libregraph/lico/identity/authorities"
|
||||
konnectoidc "github.com/libregraph/lico/oidc"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
func (i *Identifier) writeOAuth2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
|
||||
var err error
|
||||
|
||||
if authority == nil {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
|
||||
} else if !authority.IsReady() {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
|
||||
}
|
||||
|
||||
switch typedErr := err.(type) {
|
||||
case nil:
|
||||
// breaks
|
||||
case *konnectoidc.OAuth2Error:
|
||||
// Redirect back, with error.
|
||||
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 start error")
|
||||
// NOTE(longsleep): Pass along error ID but not the description to avoid
|
||||
// leaking potentially internal information to our RP.
|
||||
uri, _ := url.Parse(i.authorizationEndpointURI.String())
|
||||
query, _ := url.ParseQuery(req.URL.RawQuery)
|
||||
query.Del("flow")
|
||||
query.Set("error", typedErr.ErrorID)
|
||||
query.Set("error_description", "identifier failed to authenticate")
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
return
|
||||
default:
|
||||
i.logger.WithError(err).Errorln("identifier failed to process oauth2 start")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
|
||||
return
|
||||
}
|
||||
|
||||
sd := &StateData{
|
||||
State: rndm.GenerateRandomString(32),
|
||||
RawQuery: req.URL.RawQuery,
|
||||
|
||||
ClientID: authority.ClientID,
|
||||
Ref: authority.ID,
|
||||
}
|
||||
|
||||
// Construct URL to redirect client to external OAuth2 authorize endpoints.
|
||||
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
|
||||
return
|
||||
}
|
||||
if extra != nil {
|
||||
sd.Extra = extra
|
||||
} else {
|
||||
sd.Extra = make(map[string]interface{})
|
||||
}
|
||||
|
||||
query := uri.Query()
|
||||
query.Add("client_id", authority.ClientID)
|
||||
if authority.ResponseType != "" {
|
||||
query.Add("response_type", authority.ResponseType)
|
||||
}
|
||||
if authority.ResponseMode != "" {
|
||||
query.Add("response_mode", authority.ResponseMode)
|
||||
}
|
||||
query.Add("scope", strings.Join(authority.Scopes, " "))
|
||||
query.Add("redirect_uri", i.oauth2CbEndpointURI.String())
|
||||
query.Add("nonce", rndm.GenerateRandomString(32))
|
||||
if authority.CodeChallengeMethod != "" {
|
||||
codeVerifier := rndm.GenerateRandomString(32)
|
||||
sd.Extra["code_verifier"] = codeVerifier
|
||||
codeChallenge := ""
|
||||
if codeChallenge, err = oidc.MakeCodeChallenge(authority.CodeChallengeMethod, codeVerifier); err == nil {
|
||||
query.Add("code_challenge", codeChallenge)
|
||||
query.Add("code_challenge_method", authority.CodeChallengeMethod)
|
||||
} else {
|
||||
i.logger.WithError(err).Debugln("identifier failed to create oauth2 code challenge")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to create code challenge")
|
||||
return
|
||||
}
|
||||
}
|
||||
if display := req.Form.Get("display"); display != "" {
|
||||
query.Add("display", display)
|
||||
}
|
||||
if prompt := req.Form.Get("prompt"); prompt != "" && prompt != oidc.PromptConsent {
|
||||
// Pass along all prompt values, except consent to external provider and
|
||||
// handle consent as needed ourselves.
|
||||
query.Add("prompt", prompt)
|
||||
}
|
||||
if maxAge := req.Form.Get("max_age"); maxAge != "" {
|
||||
query.Add("max_age", maxAge)
|
||||
}
|
||||
if uiLocales := req.Form.Get("ui_locales"); uiLocales != "" {
|
||||
query.Add("ui_locales", uiLocales)
|
||||
}
|
||||
if acrValues := req.Form.Get("acr_values"); acrValues != "" {
|
||||
query.Add("acr_values", acrValues)
|
||||
}
|
||||
if claimsLocales := req.Form.Get("claims_locales"); claimsLocales != "" {
|
||||
query.Add("claims_locales", claimsLocales)
|
||||
}
|
||||
|
||||
// Set cookie which is consumed by the callback later.
|
||||
err = i.SetStateToStateCookie(req.Context(), rw, "oauth2/cb", sd)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to set oauth2 state cookie")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
|
||||
return
|
||||
}
|
||||
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
|
||||
func (i *Identifier) writeOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
|
||||
// Callbacks from authorization or end session. Validate as specified at
|
||||
// https://tools.ietf.org/html/rfc6749#section-4.1.2 and https://tools.ietf.org/html/rfc6749#section-10.12.
|
||||
var err error
|
||||
var sd *StateData
|
||||
var user *IdentifiedUser
|
||||
var userInfoClaims jwt.MapClaims
|
||||
var authority *authorities.Details
|
||||
|
||||
for {
|
||||
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "oauth2/cb", req.Form.Get("state"))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to decode oauth2 cb state: %w", err)
|
||||
break
|
||||
}
|
||||
if sd == nil {
|
||||
err = errors.New("state not found")
|
||||
break
|
||||
}
|
||||
|
||||
// Load authority with client_id in state.
|
||||
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
|
||||
if authority == nil {
|
||||
i.logger.WithField("client_id", sd.ClientID).Debugln("identifier failed to find authority in oauth2 cb")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
|
||||
break
|
||||
}
|
||||
|
||||
if authority.AuthorityType != authorities.AuthorityTypeOIDC {
|
||||
err = errors.New("unknown authority type")
|
||||
break
|
||||
}
|
||||
|
||||
// Check incoming state type.
|
||||
var done bool
|
||||
done, err = func() (bool, error) {
|
||||
switch sd.Mode {
|
||||
case StateModeEndSession:
|
||||
// Special mode. When in end session, take value from state and
|
||||
// redirect to it. This completes end session callback.
|
||||
uri, _ := url.Parse(sd.RawQuery)
|
||||
if uri == nil {
|
||||
return false, konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "no uri in state")
|
||||
}
|
||||
if sd.State != "" {
|
||||
query := uri.Query()
|
||||
query.Set("state", sd.State)
|
||||
uri.RawQuery = query.Encode()
|
||||
}
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
|
||||
return true, nil
|
||||
default:
|
||||
// Continue further.
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if done {
|
||||
// Already done, nothing further so return.
|
||||
return
|
||||
}
|
||||
|
||||
if authority.ResponseType == oidc.ResponseTypeCode ||
|
||||
authority.ResponseType == oidc.ResponseTypeCodeIDToken ||
|
||||
authority.ResponseType == oidc.ResponseTypeCodeIDTokenToken {
|
||||
// Exchange code for ID token.
|
||||
md := authority.Metadata().(*oidc.WellKnown)
|
||||
config := &oauth2.Config{
|
||||
ClientID: authority.ClientID,
|
||||
ClientSecret: authority.ClientSecret,
|
||||
|
||||
RedirectURL: i.oauth2CbEndpointURI.String(),
|
||||
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: md.TokenEndpoint,
|
||||
},
|
||||
|
||||
Scopes: authority.Scopes,
|
||||
}
|
||||
var httpClient *http.Client
|
||||
if authority.Insecure {
|
||||
httpClient = utils.InsecureHTTPClient
|
||||
} else {
|
||||
httpClient = utils.DefaultHTTPClient
|
||||
}
|
||||
t, exchangeErr := config.Exchange(
|
||||
context.WithValue(req.Context(), oauth2.HTTPClient, httpClient),
|
||||
req.Form.Get("code"),
|
||||
oauth2.SetAuthURLParam("code_verifier",
|
||||
sd.Extra["code_verifier"].(string)),
|
||||
)
|
||||
if exchangeErr != nil {
|
||||
err = fmt.Errorf("failed to exchange code for token: %w", exchangeErr)
|
||||
break
|
||||
}
|
||||
// Inject found data into request for later parse.
|
||||
req.Form.Set("access_token", t.AccessToken)
|
||||
req.Form.Set("token_type", t.TokenType)
|
||||
req.Form.Set("refresh_token", t.RefreshToken)
|
||||
if v, ok := t.Extra("expires_in").(string); ok {
|
||||
req.Form.Set("expires_in", v)
|
||||
}
|
||||
if v, ok := t.Extra("id_token").(string); ok {
|
||||
req.Form.Set("id_token", v)
|
||||
}
|
||||
// Fetch userinfo.
|
||||
uiReq, requestErr := http.NewRequest(http.MethodGet, md.UserInfoEndpoint, http.NoBody)
|
||||
if requestErr != nil {
|
||||
err = fmt.Errorf("failed to create userinfo request: %w", requestErr)
|
||||
break
|
||||
}
|
||||
t.SetAuthHeader(uiReq)
|
||||
uiResp, responseErr := httpClient.Do(uiReq)
|
||||
if responseErr != nil {
|
||||
err = fmt.Errorf("failed to get userinfo: %w", responseErr)
|
||||
break
|
||||
}
|
||||
// Decode userinfo as JSON, directly into the claims set.
|
||||
if decodeErr := json.NewDecoder(uiResp.Body).Decode(&userInfoClaims); decodeErr != nil {
|
||||
err = fmt.Errorf("failed to decode userinfo response: %w", decodeErr)
|
||||
uiResp.Body.Close()
|
||||
break
|
||||
}
|
||||
uiResp.Body.Close()
|
||||
}
|
||||
|
||||
// Parse incoming state response.
|
||||
var authenticationSuccess *payload.AuthenticationSuccess
|
||||
if authenticationSuccessRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
|
||||
authenticationSuccess = authenticationSuccessRaw.(*payload.AuthenticationSuccess)
|
||||
} else {
|
||||
err = parseErr
|
||||
break
|
||||
}
|
||||
|
||||
// Parse and validate IDToken.
|
||||
idToken, idTokenParseErr := jwt.ParseWithClaims(authenticationSuccess.IDToken, userInfoClaims, authority.JWTKeyfunc())
|
||||
if idTokenParseErr != nil {
|
||||
if authority.Insecure {
|
||||
i.logger.WithField("client_id", sd.ClientID).WithError(idTokenParseErr).Warnln("identifier ignoring validation error for insecure authority")
|
||||
} else {
|
||||
i.logger.WithError(idTokenParseErr).Debugln("identifier failed to validate oauth2 cb id token")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "authority response validation failed")
|
||||
break
|
||||
}
|
||||
}
|
||||
if claims, _ := idToken.Claims.(jwt.MapClaims); claims == nil {
|
||||
err = errors.New("invalid id token claims")
|
||||
break
|
||||
}
|
||||
|
||||
// Lookup username and user.
|
||||
un, extra, claimsErr := authority.IdentityClaimValue(idToken)
|
||||
if claimsErr != nil {
|
||||
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from oauth2 cb id token claims")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
|
||||
break
|
||||
}
|
||||
|
||||
username := &un
|
||||
|
||||
// TODO(longsleep): This flow currently does not provide a hello
|
||||
// context, means that downwards a backend might fail to resolve the
|
||||
// user when it requires additional information for multiple backend
|
||||
// routing.
|
||||
user, err = i.resolveUser(req.Context(), *username)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve oauth2 cb user with backend")
|
||||
// TODO(longsleep): Break on validation error.
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
|
||||
break
|
||||
}
|
||||
if user == nil || user.Subject() == "" {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
|
||||
break
|
||||
}
|
||||
|
||||
var logonRef string
|
||||
if rawIDToken, ok := extra["RawIDToken"]; ok {
|
||||
logonRef = rawIDToken.(string)
|
||||
}
|
||||
if logonRef != "" {
|
||||
user.logonRef = &logonRef
|
||||
}
|
||||
|
||||
// Get user meta data.
|
||||
// TODO(longsleep): This is an additional request to the backend. This
|
||||
// should be avoided. Best would be if the backend would return everything
|
||||
// in one shot (TODO in core).
|
||||
err = i.updateUser(req.Context(), user, authority)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to update user data in oauth2 cb request")
|
||||
}
|
||||
|
||||
// Set logon time.
|
||||
user.logonAt = time.Now()
|
||||
|
||||
err = i.SetUserToLogonCookie(req.Context(), rw, user)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in oauth2 cb")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if sd == nil {
|
||||
i.logger.WithError(err).Debugln("identifier oauth2 cb without state")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
|
||||
return
|
||||
}
|
||||
|
||||
uri, _ := url.Parse(i.authorizationEndpointURI.String())
|
||||
query, _ := url.ParseQuery(sd.RawQuery)
|
||||
query.Del("flow")
|
||||
query.Set("identifier", MustBeSignedIn)
|
||||
if query.Get("prompt") == oidc.PromptSelectAccount {
|
||||
// Remove select_acount prompt for our secondary indentifier, it was
|
||||
// already processed by the external provider.
|
||||
query.Del("prompt")
|
||||
}
|
||||
|
||||
switch typedErr := err.(type) {
|
||||
case nil:
|
||||
// breaks
|
||||
case *konnectoidc.OAuth2Error:
|
||||
// Pass along OAuth2 error.
|
||||
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 cb error")
|
||||
// NOTE(longsleep): Pass along error ID but not the description to avoid
|
||||
// leaking potetially internal information to our RP.
|
||||
query.Set("error", typedErr.ErrorID)
|
||||
query.Set("error_description", "identifier failed to authenticate")
|
||||
//breaks
|
||||
default:
|
||||
i.logger.WithError(err).Errorln("identifier failed to process oauth2 cb")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 cb failed")
|
||||
return
|
||||
}
|
||||
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "identifier",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"homepage": ".",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^4.5.8",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"axios": "^0.22.0",
|
||||
"classnames": "^2.3.2",
|
||||
"glob": "^8.1.0",
|
||||
"i18next": "^21.10.0",
|
||||
"i18next-browser-languagedetector": "^6.1.8",
|
||||
"i18next-http-backend": "^1.4.5",
|
||||
"i18next-resources-to-backend": "^1.0.0",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^11.18.6",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-router": "^5.3.4",
|
||||
"react-router-dom": "5.3.4",
|
||||
"redux": "^4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"render-if": "^0.1.1",
|
||||
"validator": "^13.12.0",
|
||||
"web-vitals": "^1.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest",
|
||||
"lint": "eslint --max-warnings=0 src/**/*.{ts,tsx,js,jsx}",
|
||||
"licenses": "node ../scripts/js-license-ranger.mjs",
|
||||
"analyze": "source-map-explorer 'build/static/assets/*.js'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.1.4",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/jest": "^29.5.8",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "^17.0.70",
|
||||
"@types/react-dom": "^17.0.23",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"@types/redux-logger": "^3.0.12",
|
||||
"@types/validator": "^13",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"@typescript-eslint/typescript-estree": "^6.11.0",
|
||||
"@vitejs/plugin-legacy": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"cldr": "^7.4.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-react-app-bump": "^1.0.16",
|
||||
"eslint-plugin-i18next": "^5.2.1",
|
||||
"i18next-conv": "^12.1.1",
|
||||
"i18next-parser": "^5.4.0",
|
||||
"if-node-version": "^1.1.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"terser": "^5.30.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.13",
|
||||
"vite-plugin-checker": "^0.6.2",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"src/**/*.{js,jsx,ts,tsx}"
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"plugins": [
|
||||
"i18next"
|
||||
],
|
||||
"extends": [
|
||||
"react-app-bump",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:i18next/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error"
|
||||
],
|
||||
"no-use-before-define": "off",
|
||||
"@typescript-eslint/no-use-before-define": [
|
||||
"error"
|
||||
],
|
||||
"i18next/no-literal-string": [
|
||||
"off",
|
||||
{
|
||||
"markupOnly": true
|
||||
}
|
||||
],
|
||||
"react/prop-types": [
|
||||
"warn"
|
||||
]
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.0.2"
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
/*
|
||||
* Copyright 2017-2020 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/libregraph/oidc-go"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/lico/identity/authorities"
|
||||
konnectoidc "github.com/libregraph/lico/oidc"
|
||||
|
||||
"github.com/libregraph/lico/identity/authorities/samlext"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
func (i *Identifier) writeSAML2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
|
||||
var err error
|
||||
|
||||
if authority == nil {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
|
||||
} else if !authority.IsReady() {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
|
||||
}
|
||||
|
||||
switch typedErr := err.(type) {
|
||||
case nil:
|
||||
// breaks
|
||||
case *konnectoidc.OAuth2Error:
|
||||
// Redirect back, with error.
|
||||
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 start error")
|
||||
// NOTE(longsleep): Pass along error ID but not the description to avoid
|
||||
// leaking potentially internal information to our RP.
|
||||
uri, _ := url.Parse(i.authorizationEndpointURI.String())
|
||||
query, _ := url.ParseQuery(req.URL.RawQuery)
|
||||
query.Del("flow")
|
||||
query.Set("error", typedErr.ErrorID)
|
||||
query.Set("error_description", "identifier failed to authenticate")
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
return
|
||||
default:
|
||||
i.logger.WithError(err).Errorln("identifier failed to process saml2 start")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
|
||||
return
|
||||
}
|
||||
|
||||
sd := &StateData{
|
||||
State: rndm.GenerateRandomString(32),
|
||||
RawQuery: req.URL.RawQuery,
|
||||
|
||||
Ref: authority.ID,
|
||||
}
|
||||
|
||||
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 start failed")
|
||||
return
|
||||
}
|
||||
sd.Extra = extra
|
||||
|
||||
// Set cookie which is consumed by the callback later.
|
||||
err = i.SetStateToStateCookie(req.Context(), rw, "saml2/acs", sd)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to set saml2 state cookie")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
|
||||
func (i *Identifier) writeSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
|
||||
var err error
|
||||
var sd *StateData
|
||||
var user *IdentifiedUser
|
||||
var authority *authorities.Details
|
||||
|
||||
for {
|
||||
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "saml2/acs", req.Form.Get("RelayState"))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to decode saml2 acs state: %v", err)
|
||||
break
|
||||
}
|
||||
if sd == nil {
|
||||
err = errors.New("state not found")
|
||||
break
|
||||
}
|
||||
|
||||
// Load authority with client_id in state.
|
||||
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
|
||||
if authority == nil {
|
||||
i.logger.Debugln("identifier failed to find authority in saml2 acs")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
|
||||
break
|
||||
}
|
||||
|
||||
if authority.AuthorityType != authorities.AuthorityTypeSAML2 {
|
||||
err = errors.New("unknown authority type")
|
||||
break
|
||||
}
|
||||
|
||||
// Parse incoming state response.
|
||||
var assertion *saml.Assertion
|
||||
if assertionRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
|
||||
assertion = assertionRaw.(*saml.Assertion)
|
||||
} else {
|
||||
err = parseErr
|
||||
break
|
||||
}
|
||||
|
||||
// Lookup username and user.
|
||||
un, claims, claimsErr := authority.IdentityClaimValue(assertion)
|
||||
if claimsErr != nil {
|
||||
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from saml2 acs assertion")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
|
||||
break
|
||||
}
|
||||
|
||||
username := &un
|
||||
|
||||
// TODO(longsleep): This flow currently does not provide a hello
|
||||
// context, means that downwards a backend might fail to resolve the
|
||||
// user when it requires additional information for multiple backend
|
||||
// routing.
|
||||
user, err = i.resolveUser(req.Context(), *username)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve saml2 acs user with backend")
|
||||
// TODO(longsleep): Break on validation error.
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
|
||||
break
|
||||
}
|
||||
if user == nil || user.Subject() == "" {
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
|
||||
break
|
||||
}
|
||||
|
||||
// Apply additional authority claims.
|
||||
if sessionNotOnOrAfter, ok := claims["SessionNotOnOrAfter"]; ok {
|
||||
user.expiresAfter = sessionNotOnOrAfter.(*time.Time)
|
||||
}
|
||||
var logonRef string
|
||||
if nameIDTransient, ok := claims["TransientNameID"]; ok {
|
||||
logonRef = "transient:" + nameIDTransient.(string)
|
||||
} else if nameIDPersistent, ok := claims["PersistentNameID"]; ok {
|
||||
logonRef = "persistent:" + nameIDPersistent.(string)
|
||||
} else if nameIDUnspecified, ok := claims["UnspecifiedNameID"]; ok {
|
||||
logonRef = "unspecified:" + nameIDUnspecified.(string)
|
||||
}
|
||||
if logonRef != "" {
|
||||
user.logonRef = &logonRef
|
||||
}
|
||||
if authority.Trusted {
|
||||
// Use external authority session, if the external authority is trusted.
|
||||
if sessionIndexString, ok := claims["SessionIndex"]; ok {
|
||||
sessionIndex := sessionIndexString.(string)
|
||||
user.sessionRef = &sessionIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Get user meta data.
|
||||
// TODO(longsleep): This is an additional request to the backend. This
|
||||
// should be avoided. Best would be if the backend would return everything
|
||||
// in one shot (TODO in core).
|
||||
err = i.updateUser(req.Context(), user, authority)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to get user data in saml2 acs request")
|
||||
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to get user data")
|
||||
break
|
||||
}
|
||||
|
||||
// Set logon time.
|
||||
user.logonAt = time.Now()
|
||||
|
||||
err = i.SetUserToLogonCookie(req.Context(), rw, user)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in saml2 acs")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if sd == nil {
|
||||
i.logger.WithError(err).Debugln("identifier saml2 acs without state")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
|
||||
return
|
||||
}
|
||||
|
||||
uri, _ := url.Parse(i.authorizationEndpointURI.String())
|
||||
query, _ := url.ParseQuery(sd.RawQuery)
|
||||
query.Del("flow")
|
||||
query.Set("identifier", MustBeSignedIn)
|
||||
query.Set("prompt", oidc.PromptNone)
|
||||
|
||||
switch typedErr := err.(type) {
|
||||
case nil:
|
||||
// breaks
|
||||
case *saml.InvalidResponseError:
|
||||
i.logger.WithError(err).WithFields(logrus.Fields{
|
||||
"reason": typedErr.PrivateErr,
|
||||
}).Debugf("saml2 acs invalid response")
|
||||
query.Set("error", oidc.ErrorCodeOAuth2AccessDenied)
|
||||
query.Set("error_description", "identifier received invalid response")
|
||||
// breaks
|
||||
case *konnectoidc.OAuth2Error:
|
||||
// Pass along OAuth2 error.
|
||||
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("saml2 acs error")
|
||||
// NOTE(longsleep): Pass along error ID but not the description to avoid
|
||||
// leaking potetially internal information to our RP.
|
||||
query.Set("error", typedErr.ErrorID)
|
||||
query.Set("error_description", "identifier failed to authenticate")
|
||||
//breaks
|
||||
default:
|
||||
i.logger.WithError(err).Errorln("identifier failed to process saml2 acs")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 acs failed")
|
||||
return
|
||||
}
|
||||
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
|
||||
func (i *Identifier) writeSAMLSingleLogoutServiceRequest(rw http.ResponseWriter, req *http.Request) {
|
||||
lor, err := samlext.NewIdpLogoutRequest(req)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo request")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request")
|
||||
return
|
||||
}
|
||||
|
||||
err = lor.Validate()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier saml2 slo request validation failed")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request validation failed")
|
||||
return
|
||||
}
|
||||
|
||||
// In http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf §3.4.5.2
|
||||
// we get a description of the Destination attribute:
|
||||
//
|
||||
// If the message is signed, the Destination XML attribute in the root SAML
|
||||
// element of the protocol message MUST contain the URL to which the sender
|
||||
// has instructed the user agent to deliver the message. The recipient MUST
|
||||
// then verify that the value matches the location at which the message has
|
||||
// been received.
|
||||
//
|
||||
// We require the destination be correct either (a) if signing is enabled or
|
||||
// (b) if it was provided.
|
||||
mustHaveDestination := lor.SigAlg != nil
|
||||
mustHaveDestination = mustHaveDestination || lor.Request.Destination != ""
|
||||
if mustHaveDestination {
|
||||
uri, _ := i.absoluteURLForRoute("saml2/slo")
|
||||
if lor.Request.Destination != uri.String() {
|
||||
i.logger.WithField("destination", lor.Request.Destination).Debugln("identifier saml2 slo request with wrong desitation")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request destination wrong")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching authority.
|
||||
authority, found := i.authorities.Find(req.Context(), func(authority authorities.AuthorityRegistration) bool {
|
||||
if authority.AuthorityType() != authorities.AuthorityTypeSAML2 {
|
||||
return false
|
||||
}
|
||||
if lor.Request.Issuer.Value == authority.Issuer() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !found {
|
||||
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request from unknown issuer")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer unknown")
|
||||
return
|
||||
}
|
||||
|
||||
authorityDetails := authority.Authority()
|
||||
if lor.SigAlg == nil {
|
||||
// Never consider trusted if not signed.
|
||||
authorityDetails.Trusted = false
|
||||
}
|
||||
|
||||
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
|
||||
i.logger.WithField("issuer", lor.Request.Issuer.Value).Debugln("identifier saml2 slo request for unknown authority type")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request issuer authority type unknown")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate.
|
||||
validated, err := authority.ValidateIdpEndSessionRequest(lor, lor.RelayState)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo request authority validation failed")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request authority validation failed")
|
||||
return
|
||||
}
|
||||
if !validated && authorityDetails.Trusted {
|
||||
// Never consider unvalidated logout requests as trusted.
|
||||
authorityDetails.Trusted = false
|
||||
}
|
||||
|
||||
user, _ := i.GetUserFromLogonCookie(req.Context(), req, 0, false)
|
||||
if user != nil {
|
||||
// Compare signed in SAML SessionIndex with the on provided in the LogoutRequest.
|
||||
if user.SessionRef() != nil {
|
||||
if lor.Request.SessionIndex == nil {
|
||||
i.logger.Debugln("identifier saml2 slo request without session index")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request missing session index")
|
||||
return
|
||||
}
|
||||
if lor.Request.SessionIndex.Value != *user.SessionRef() {
|
||||
i.logger.Debugln("identifier saml2 slo request for other session index")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo request session index mismatch")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if authorityDetails != nil && authorityDetails.Trusted {
|
||||
// Directly clear identifier session when a trusted authority requests it.
|
||||
err = i.UnsetLogonCookie(req.Context(), user, rw)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("identifier saml2 slo failed to unset logon cookie")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo logout failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ignore when not signed in, for end session.
|
||||
}
|
||||
|
||||
if authorityDetails == nil || !authorityDetails.Trusted {
|
||||
// Handle directly by redirecting to our logout confirm url for untrusted
|
||||
// registies or when no URL was set.
|
||||
uri, _ := i.absoluteURLForRoute("goodbye")
|
||||
query := &url.Values{}
|
||||
|
||||
uri.RawQuery = query.Encode()
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
uri, _, err := authorityDetails.MakeRedirectEndSessionResponseURL(lor.Request, lor.RelayState)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to make saml2 slo redirect request url")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo failed")
|
||||
return
|
||||
}
|
||||
if uri == nil {
|
||||
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
|
||||
// Fall back to logout confirm url.
|
||||
uri, _ = i.absoluteURLForRoute("goodbye")
|
||||
}
|
||||
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
|
||||
func (i *Identifier) writeSAMLSingleLogoutServiceResponse(rw http.ResponseWriter, req *http.Request) {
|
||||
lor, err := samlext.NewIdpLogoutResponse(req)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier failed to process saml2 slo response")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse response")
|
||||
return
|
||||
}
|
||||
|
||||
err = lor.Validate()
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier saml2 slo response validation failed")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "response validation failed")
|
||||
return
|
||||
}
|
||||
|
||||
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "_/saml2/slo", lor.RelayState)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Debugln("identifier saml2 slo response failed to load state")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "response state invalid")
|
||||
return
|
||||
}
|
||||
if sd == nil {
|
||||
i.logger.WithError(err).Debugln("identifier saml2 slo response failed as state is missing")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "response state missing")
|
||||
return
|
||||
}
|
||||
|
||||
authority, found := i.authorities.Get(req.Context(), sd.Ref)
|
||||
if !found {
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "no authority")
|
||||
return
|
||||
}
|
||||
|
||||
authorityDetails := authority.Authority()
|
||||
if lor.SigAlg == nil {
|
||||
// Never consider trusted if not signed.
|
||||
authorityDetails.Trusted = false
|
||||
}
|
||||
|
||||
if authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
|
||||
i.logger.WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response for unknown authority type")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response issuer authority type unknown")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate.
|
||||
validated, err := authority.ValidateIdpEndSessionResponse(lor, lor.RelayState)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).WithField("issuer", authority.Issuer()).Debugln("identifier saml2 slo response authority validation failed")
|
||||
i.ErrorPage(rw, http.StatusBadRequest, "", "slo response authority validation failed")
|
||||
return
|
||||
}
|
||||
if !validated && authorityDetails.Trusted {
|
||||
// Never consider unvalidated logout responses as trusted.
|
||||
authorityDetails.Trusted = false
|
||||
}
|
||||
|
||||
if lor.Response.Status.StatusCode.Value != saml.StatusSuccess {
|
||||
i.logger.WithField("status", lor.Response.Status.StatusCode).Debugln("saml2 slo response without success status")
|
||||
}
|
||||
|
||||
// Extract destination URI from state data (its put into the RawQuery field).
|
||||
uri, err := url.Parse(sd.RawQuery)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to parse slo response redirect url from state data")
|
||||
i.ErrorPage(rw, http.StatusInternalServerError, "", "saml2 slo response failed")
|
||||
return
|
||||
}
|
||||
if uri == nil || uri.String() == "" {
|
||||
i.logger.Warnln("saml2 slo reached dead end, no post logout redirect uri available")
|
||||
// Fall back to our signed out url or goodbye route.
|
||||
if i.Config.SignedOutEndpointURI != nil {
|
||||
uri = i.Config.SignedOutEndpointURI
|
||||
} else {
|
||||
uri, _ = i.absoluteURLForRoute("goodbye")
|
||||
}
|
||||
}
|
||||
if sd.State != "" {
|
||||
query := uri.Query()
|
||||
query.Set("state", sd.State)
|
||||
uri.RawQuery = query.Encode()
|
||||
}
|
||||
|
||||
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/libregraph/lico/version"
|
||||
)
|
||||
|
||||
var trampolinTemplate = template.Must(template.New("trampolin").Parse(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body trampolin="{{.URI}}">
|
||||
<script src="trampolin/trampolin.js?v={{.Version}}"></script>
|
||||
<noscript>Javascript is required for this app.</noscript>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var trampolinScript = []byte(`(function(window) {
|
||||
window.location.replace(document.body.getAttribute('trampolin'));
|
||||
}(window));
|
||||
`)
|
||||
|
||||
var trampolinVersion = url.QueryEscape(version.Version)
|
||||
|
||||
type trampolinData struct {
|
||||
URI string
|
||||
Version string
|
||||
}
|
||||
|
||||
func (i *Identifier) writeTrampolinHTML(rw http.ResponseWriter, req *http.Request, uri *url.URL) {
|
||||
data := &trampolinData{
|
||||
URI: uri.String(),
|
||||
Version: trampolinVersion,
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
rw.Header().Set("Pragma", "no-cache")
|
||||
rw.Header().Set("Expires", "0")
|
||||
err := trampolinTemplate.Execute(rw, data)
|
||||
if err != nil {
|
||||
i.logger.WithError(err).Errorln("failed to write trampolin")
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Identifier) writeTrampolinScript(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.Header().Set("Content-Type", "application/javascript")
|
||||
rw.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
|
||||
rw.Write(trampolinScript)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"src/vite-env.d.ts",
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
konnect "github.com/libregraph/lico"
|
||||
"github.com/libregraph/lico/identifier/backends"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/authorities"
|
||||
)
|
||||
|
||||
// A IdentifiedUser is a user with meta data.
|
||||
type IdentifiedUser struct {
|
||||
sub string
|
||||
|
||||
backend backends.Backend
|
||||
externalAuthority *authorities.Details
|
||||
|
||||
username string
|
||||
email string
|
||||
emailVerified bool
|
||||
displayName string
|
||||
familyName string
|
||||
givenName string
|
||||
|
||||
id int64
|
||||
uid string
|
||||
|
||||
sessionRef *string
|
||||
logonRef *string
|
||||
claims map[string]interface{}
|
||||
scopes []string
|
||||
|
||||
logonAt time.Time
|
||||
expiresAfter *time.Time
|
||||
|
||||
lockedScopes []string
|
||||
}
|
||||
|
||||
// Subject returns the associated users subject field. The subject is the main
|
||||
// authentication identifier of the user.
|
||||
func (u *IdentifiedUser) Subject() string {
|
||||
return u.sub
|
||||
}
|
||||
|
||||
// Email returns the associated users email field.
|
||||
func (u *IdentifiedUser) Email() string {
|
||||
return u.email
|
||||
}
|
||||
|
||||
// EmailVerified returns trye if the associated users email field was verified.
|
||||
func (u *IdentifiedUser) EmailVerified() bool {
|
||||
return u.emailVerified
|
||||
}
|
||||
|
||||
// Name returns the associated users name field. This is the display name of
|
||||
// the accociated user.
|
||||
func (u *IdentifiedUser) Name() string {
|
||||
return u.displayName
|
||||
}
|
||||
|
||||
// FamilyName returns the associated users family name field.
|
||||
func (u *IdentifiedUser) FamilyName() string {
|
||||
return u.familyName
|
||||
}
|
||||
|
||||
// GivenName returns the associated users given name field.
|
||||
func (u *IdentifiedUser) GivenName() string {
|
||||
return u.givenName
|
||||
}
|
||||
|
||||
// ID returns the associated users numeric user id. If it is 0, it means that
|
||||
// this user does not have a numeric ID. Do not use this field to identify a
|
||||
// user - always use the subject instead. The numeric ID is kept for compatibility
|
||||
// with systems which require user identification to be numeric.
|
||||
func (u *IdentifiedUser) ID() int64 {
|
||||
return u.id
|
||||
}
|
||||
|
||||
// UniqueID returns the accociated users unique user id. When empty, then this
|
||||
// user does not have a unique ID. This field can be used for unique user mapping
|
||||
// to external systems which use the same authentication source as Konnect. The
|
||||
// value depends entirely on the identifier backend.
|
||||
func (u *IdentifiedUser) UniqueID() string {
|
||||
return u.uid
|
||||
}
|
||||
|
||||
// Username returns the accociated users username. This might be different or
|
||||
// the same as the subject, depending on the backend in use. If can also be
|
||||
// empty, which means that the accociated user does not have a username.
|
||||
func (u *IdentifiedUser) Username() string {
|
||||
return u.username
|
||||
}
|
||||
|
||||
// Claims returns extra claims of the accociated user.
|
||||
func (u *IdentifiedUser) Claims() jwt.MapClaims {
|
||||
claims := make(map[string]interface{})
|
||||
claims[konnect.IdentifiedUsernameClaim] = u.Username()
|
||||
claims[konnect.IdentifiedDisplayNameClaim] = u.Name()
|
||||
|
||||
for k, v := range u.claims {
|
||||
claims[k] = v
|
||||
}
|
||||
|
||||
return jwt.MapClaims(claims)
|
||||
}
|
||||
|
||||
// ScopedClaims returns scope bound extra claims of the accociated user.
|
||||
func (u *IdentifiedUser) ScopedClaims(authorizedScopes map[string]bool) jwt.MapClaims {
|
||||
if u.backend == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims := u.backend.UserClaims(u.Subject(), authorizedScopes)
|
||||
return jwt.MapClaims(claims)
|
||||
}
|
||||
|
||||
// Scopes returns the scopes attached to this user.
|
||||
func (u *IdentifiedUser) Scopes() []string {
|
||||
return u.scopes
|
||||
}
|
||||
|
||||
// LoggedOn returns true if the accociated user has a logonAt time set.
|
||||
func (u *IdentifiedUser) LoggedOn() (bool, time.Time) {
|
||||
return !u.logonAt.IsZero(), u.logonAt
|
||||
}
|
||||
|
||||
// SessionRef returns the accociated users underlaying session reference.
|
||||
func (u *IdentifiedUser) SessionRef() *string {
|
||||
return u.sessionRef
|
||||
}
|
||||
|
||||
// UserRef returns the accociated users underlaying logon reference.
|
||||
func (u *IdentifiedUser) LogonRef() *string {
|
||||
return u.logonRef
|
||||
}
|
||||
|
||||
func (u *IdentifiedUser) ExternalAuthorityID() *string {
|
||||
if u.externalAuthority == nil {
|
||||
return nil
|
||||
}
|
||||
id := u.externalAuthority.ID
|
||||
return &id
|
||||
}
|
||||
|
||||
// BackendName returns the accociated users underlaying backend name.
|
||||
func (u *IdentifiedUser) BackendName() string {
|
||||
return u.backend.Name()
|
||||
}
|
||||
|
||||
func (u *IdentifiedUser) LockedScopes() []string {
|
||||
return u.lockedScopes
|
||||
}
|
||||
|
||||
func (i *Identifier) logonUser(ctx context.Context, audience, username, password string) (*IdentifiedUser, error) {
|
||||
success, subject, sessionRef, u, err := i.backend.Logon(ctx, audience, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !success || u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
user := &IdentifiedUser{
|
||||
sub: *subject,
|
||||
|
||||
username: u.Username(),
|
||||
|
||||
backend: i.backend,
|
||||
|
||||
sessionRef: sessionRef,
|
||||
claims: u.BackendClaims(),
|
||||
|
||||
lockedScopes: u.RequiredScopes(),
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (i *Identifier) resolveUser(ctx context.Context, username string) (*IdentifiedUser, error) {
|
||||
u, err := i.backend.ResolveUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Construct user from resolved result.
|
||||
user := &IdentifiedUser{
|
||||
sub: u.Subject(),
|
||||
|
||||
username: u.Username(),
|
||||
|
||||
backend: i.backend,
|
||||
|
||||
claims: u.BackendClaims(),
|
||||
|
||||
lockedScopes: u.RequiredScopes(),
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (i *Identifier) updateUser(ctx context.Context, user *IdentifiedUser, externalAuthority *authorities.Details) error {
|
||||
var userID string
|
||||
identityClaims := user.Claims()
|
||||
if userIDString, ok := identityClaims[konnect.IdentifiedUserIDClaim]; ok {
|
||||
userID = userIDString.(string)
|
||||
}
|
||||
if userID == "" {
|
||||
return errors.New("no id claim in user identity claims")
|
||||
}
|
||||
|
||||
u, err := i.backend.GetUser(ctx, userID, user.sessionRef, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if uwp, ok := u.(identity.UserWithProfile); ok {
|
||||
user.displayName = uwp.Name()
|
||||
}
|
||||
|
||||
user.backend = i.backend
|
||||
user.externalAuthority = externalAuthority
|
||||
|
||||
return nil
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2017-2019 Kopano and its licensors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package identifier
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
var (
|
||||
farPastExpiryTime = time.Unix(0, 0)
|
||||
farPastExpiryTimeHTTPHeaderString = farPastExpiryTime.UTC().Format(http.TimeFormat)
|
||||
)
|
||||
|
||||
func addCommonResponseHeaders(header http.Header) {
|
||||
header.Set("X-Frame-Options", "DENY")
|
||||
header.Set("X-XSS-Protection", "1; mode=block")
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
header.Set("Referrer-Policy", "origin")
|
||||
}
|
||||
|
||||
func addNoCacheResponseHeaders(header http.Header) {
|
||||
header.Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
header.Set("Pragma", "no-cache")
|
||||
header.Set("Expires", farPastExpiryTimeHTTPHeaderString)
|
||||
}
|
||||
|
||||
func encodeImageAsDataURL(b []byte) (string, error) {
|
||||
mt := mimetype.Detect(b)
|
||||
if !strings.HasPrefix(mt.String(), "image/") {
|
||||
return "", fmt.Errorf("not an image: %s", mt)
|
||||
}
|
||||
|
||||
return "data:" + mt.String() + ";base64," + base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { defineConfig, splitVendorChunkPlugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import checker from "vite-plugin-checker";
|
||||
import legacy from "@vitejs/plugin-legacy";
|
||||
|
||||
|
||||
const addScriptCSPNoncePlaceholderPlugin = () => {
|
||||
return {
|
||||
name: "add-script-nonce-placeholderP-plugin",
|
||||
apply: "build",
|
||||
transformIndexHtml: {
|
||||
order: "post",
|
||||
handler(htmlData) {
|
||||
|
||||
return htmlData.replaceAll(
|
||||
/<script nomodule>/gi,
|
||||
`<script nomodule nonce="__CSP_NONCE__">`
|
||||
).replaceAll(
|
||||
/<script type="module">/gi,
|
||||
`<script type="module" nonce="__CSP_NONCE__">`
|
||||
).replaceAll(
|
||||
/<script nomodule crossorigin id="vite-legacy-entry"/gi,
|
||||
`<script nomodule crossorigin id="vite-legacy-entry" nonce="__CSP_NONCE__"`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default defineConfig((env) => {
|
||||
return {
|
||||
build: {
|
||||
outDir: 'build',
|
||||
assetsDir: 'static/assets',
|
||||
manifest: 'asset-manifest.json',
|
||||
sourcemap: true,
|
||||
},
|
||||
base: './',
|
||||
server: {
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
host: '127.0.0.1',
|
||||
hmr: {
|
||||
protocol: 'ws',
|
||||
host: '127.0.0.1',
|
||||
clientPort: 3001,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
legacy({
|
||||
targets: ['edge 18'],
|
||||
}),
|
||||
env.mode !== "test" &&
|
||||
checker({
|
||||
typescript: true,
|
||||
eslint: {
|
||||
lintCommand: 'eslint --max-warnings=0 src',
|
||||
},
|
||||
}),
|
||||
splitVendorChunkPlugin(),
|
||||
addScriptCSPNoncePlaceholderPlugin(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './tests/setup.js',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user