Initial QSfera import
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: http://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
/vendor
|
||||
/bin
|
||||
/golint.txt
|
||||
/govet.txt
|
||||
/dist
|
||||
/test/tests.*
|
||||
/3rdparty-LICENSES.md
|
||||
/.vscode
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
run:
|
||||
modules-download-mode: vendor
|
||||
issues-exit-code: 0
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
check-shadowing: true
|
||||
settings:
|
||||
printf:
|
||||
funcs:
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
|
||||
gocyclo:
|
||||
min-complexity: 10
|
||||
maligned:
|
||||
suggest-new: true
|
||||
dupl:
|
||||
threshold: 100
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
misspell:
|
||||
locale: US
|
||||
lll:
|
||||
line-length: 140
|
||||
goimports:
|
||||
local-prefixes: github.com/libregraph/idm
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- performance
|
||||
- style
|
||||
- experimental
|
||||
revive:
|
||||
min-confidence: 0
|
||||
|
||||
linters:
|
||||
# inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint
|
||||
disable-all: true
|
||||
enable:
|
||||
- bodyclose
|
||||
- dupl
|
||||
- errcheck
|
||||
- exportloopref
|
||||
- funlen
|
||||
- gochecknoinits
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- lll
|
||||
- misspell
|
||||
- nakedret
|
||||
- revive
|
||||
- staticcheck
|
||||
- stylecheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
|
||||
# don't enable:
|
||||
# - depguard - until https://github.com/OpenPeeDeeP/depguard/issues/7 gets fixed
|
||||
# - maligned,prealloc
|
||||
# - gochecknoglobals
|
||||
|
||||
severity:
|
||||
default-severity: warning
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"mode": "mod-vendor",
|
||||
"header": "# LibreGraph Identity Management 3rd party notices\n\nCopyright 2021 The LibreGraph Authors. See LICENSE.txt for license information. This document contains a list of open source components used in this project.\n",
|
||||
"manual": {
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# This is the official list of LibreGraph authors for copyright purposes.
|
||||
# This file is distinct from the CONTRIBUTORS files.
|
||||
# See the latter for an explanation.
|
||||
|
||||
# Names should be added to this file as one of
|
||||
# Organization's name
|
||||
# Individual's name <submission email address>
|
||||
# Individual's name <submission email address> <email2> <emailN>
|
||||
# See CONTRIBUTORS for the meaning of multiple email addresses.
|
||||
|
||||
# Please keep the list sorted.
|
||||
|
||||
Kopano b.v.
|
||||
ownCloud GmbH
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
|
||||
|
||||
## v0.5.0 (2024-04-17)
|
||||
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.7 to 3.4.8
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.6 to 3.4.7
|
||||
- Bump google.golang.org/protobuf from 1.32.0 to 1.33.0
|
||||
- Bump golangci-lint action to v4
|
||||
- Remove deprecated linters from config
|
||||
- Bump required go version to 1.21 + go mod tidy
|
||||
- Bump github.com/prometheus/client_golang from 1.18.0 to 1.19.0
|
||||
- Bump go.etcd.io/bbolt from 1.3.8 to 1.3.9
|
||||
- Bump github.com/prometheus/client_golang from 1.17.0 to 1.18.0
|
||||
- Bump github.com/go-logr/logr from 1.3.0 to 1.4.1
|
||||
- Bump golang.org/x/crypto from 0.14.0 to 0.17.0
|
||||
- Fix the DN comparison in a ServerFilterScope
|
||||
- Bump golang.org/x/text from 0.13.0 to 0.14.0
|
||||
- Bump github.com/spf13/cobra from 1.7.0 to 1.8.0
|
||||
- Bump github.com/go-logr/logr from 1.2.4 to 1.3.0
|
||||
- Bump go.etcd.io/bbolt from 1.3.7 to 1.3.8
|
||||
- Bump github.com/alexedwards/argon2id
|
||||
- Bump github.com/prometheus/client_golang from 1.16.0 to 1.17.0
|
||||
- Bump github.com/prometheus/client_golang from 1.15.1 to 1.16.0
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.5 to 3.4.6
|
||||
- Bump github.com/go-asn1-ber/asn1-ber from 1.5.4 to 1.5.5
|
||||
- Bump golang.org/x/text from 0.12.0 to 0.13.0
|
||||
- Bump golang.org/x/text from 0.11.0 to 0.12.0
|
||||
- Bump golang.org/x/text from 0.10.0 to 0.11.0
|
||||
- Bump golang.org/x/text from 0.9.0 to 0.10.0
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.4 to 3.4.5
|
||||
- Bump github.com/sirupsen/logrus from 1.9.2 to 1.9.3
|
||||
- Bump github.com/sirupsen/logrus from 1.9.1 to 1.9.2
|
||||
- Bump github.com/sirupsen/logrus from 1.9.0 to 1.9.1
|
||||
- Bump github.com/prometheus/client_golang from 1.15.0 to 1.15.1
|
||||
- Bump github.com/prometheus/client_golang from 1.14.0 to 1.15.0
|
||||
- Bump golang.org/x/text from 0.8.0 to 0.9.0
|
||||
- Bump github.com/spf13/cobra from 1.6.1 to 1.7.0
|
||||
- Bump github.com/go-logr/logr from 1.2.3 to 1.2.4
|
||||
- Bump golang.org/x/text from 0.7.0 to 0.8.0
|
||||
- Fix ModifyDN operation in boltdb backend
|
||||
- Add support for ModifyDN
|
||||
- Fix error behaviour when receiving unsupported operation
|
||||
- Bump golang.org/x/text from 0.6.0 to 0.7.0
|
||||
- Bump required go version to 1.18 + go mod tidy
|
||||
- Bump go.etcd.io/bbolt from 1.3.6 to 1.3.7
|
||||
- Bump golang.org/x/text from 0.5.0 to 0.6.0
|
||||
- Bump golang.org/x/text from 0.4.0 to 0.5.0
|
||||
|
||||
|
||||
## v0.4.0 (2022-12-01)
|
||||
|
||||
- Migrate to Go rndm module from GitHub
|
||||
- Bump github.com/prometheus/client_golang from 1.13.0 to 1.14.0
|
||||
- Bump github.com/coreos/go-systemd/v22 from 22.4.0 to 22.5.0
|
||||
- Bump github.com/spf13/cobra from 1.6.0 to 1.6.1
|
||||
- Bump github.com/bombsimon/logrusr/v3 from 3.0.0 to 3.1.0
|
||||
- Bump golang.org/x/text from 0.3.8 to 0.4.0
|
||||
- Bump stash.kopano.io/kgol/rndm from 1.1.1 to 1.1.2
|
||||
- Bump github.com/spf13/cobra from 1.5.0 to 1.6.0
|
||||
- Bump golang.org/x/text from 0.3.7 to 0.3.8
|
||||
- Bump github.com/coreos/go-systemd/v22 from 22.3.2 to 22.4.0
|
||||
- Bump github.com/prometheus/client_golang from 1.12.2 to 1.13.0
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.3 to 3.4.4
|
||||
- Switch pkg/ldapserver to logr
|
||||
- Set custom logger for go-ldap/ldap
|
||||
- Bump github.com/sirupsen/logrus from 1.8.1 to 1.9.0
|
||||
- Make substring filter case-insensitve
|
||||
- Return proper error code when exceeding size limit
|
||||
- Fix normalized DN attribute escaping
|
||||
- Switch to go-ldap/ldap for filter (de-)compilation
|
||||
- Fix DN compoare condition
|
||||
- Switch github action to use `make test`
|
||||
- improve DN comparison
|
||||
- pass through unparsed DN
|
||||
- Address a few linter complaints
|
||||
- Implement modify password extended operation for boltdb backend
|
||||
- Add backend plumbing for password modify extended operation
|
||||
- pwexop: Add support of generating a random password
|
||||
- Groundwork for password modify extended operation
|
||||
- Bump github.com/spf13/cobra from 1.4.0 to 1.5.0
|
||||
- Bump github.com/Songmu/prompter from 0.5.0 to 0.5.1
|
||||
- Bump github.com/prometheus/client_golang from 1.12.1 to 1.12.2
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.2 to 3.4.3
|
||||
- Bump github.com/go-asn1-ber/asn1-ber from 1.5.3 to 1.5.4
|
||||
- boltdb: Fix modify replace on RDN Attribute
|
||||
- Bump github.com/spf13/cobra from 1.3.0 to 1.4.0
|
||||
- boltdb bind: attributeTypes are case-insensitive
|
||||
- Tone down debug logging
|
||||
- encodeSearchDone might be called with nil doneControls
|
||||
- Bump go-crypt to latest master
|
||||
- Allow to disable go-crypt related code
|
||||
- Fix build on Darwin
|
||||
- Bump github.com/go-ldap/ldap/v3 from 3.4.1 to 3.4.2
|
||||
- Cleanup logging in boltdb handler
|
||||
- Bump github.com/prometheus/client_golang from 1.12.0 to 1.12.1
|
||||
- Bump github.com/prometheus/client_golang from 1.11.0 to 1.12.0
|
||||
- Introduce new parameter "ldap-admin-dn"
|
||||
- Normalize BaseDN and BindDN
|
||||
- LDAP Modify support for boltdb Handler
|
||||
- Add utils to apply LDAP Modify Request on Entries
|
||||
- Create ldapentry and ldapdn helper modules
|
||||
- Add shortcut for normalizing DN string
|
||||
- Parse and validate incoming LDAP Modify Requests
|
||||
- fix typo
|
||||
- boltdb: Add getEntryByID method
|
||||
- boltdb: Make internal helper methods private
|
||||
- Bump all unversioned dependencies to their latest code
|
||||
- Implement Delete Support for boltdb Handler
|
||||
- Parse and validate incoming LDAP Delete Requests
|
||||
- Bump github.com/sirupsen/logrus from 1.6.0 to 1.8.1
|
||||
- Bump github.com/spf13/cobra from 1.2.1 to 1.3.0
|
||||
- Bump github.com/prometheus/client_golang from 0.9.3 to 1.11.0
|
||||
- Bump golang.org/x/text from 0.3.5 to 0.3.7
|
||||
- Initial LDAPAdd Support for the boltdb Handler
|
||||
- LDAPAdd support for the backend handlers
|
||||
- boltdb: Disallow adding an already existing Entry
|
||||
- Bump github.com/spf13/cobra from 1.1.3 to 1.2.1
|
||||
- Bump github.com/coreos/go-systemd/v22 from 22.3.0 to 22.3.2
|
||||
- Enable dependabot for go modules
|
||||
- Don't consider linter failures fatal
|
||||
- Parse and validate incoming LDAP Add Requests
|
||||
- Add basic plumbing for LDAP Add support
|
||||
- Update to latest bbolt release
|
||||
- Add some initial unit tests for boltdb backend ([#23](https://github.com/libregraph/idm/issues/23/))
|
||||
- Tone down golangci-lint annotation to warnings ([#24](https://github.com/libregraph/idm/issues/24/))
|
||||
- Add "boltdb export" subcommand
|
||||
- Set a default log-level for the boltdb related subcommands
|
||||
- Add ability to pass bolt.Options on database
|
||||
- Add SimpleBind support for BoltDB
|
||||
- Introduce a BoltDB based Database Handler
|
||||
- Add options to use other backends than 'ldif'
|
||||
- Add TLS support
|
||||
- Adjust golangci-lint config
|
||||
- Add initial Github Action as a starting point for CI
|
||||
- Bump go-ldap to v3.4.1
|
||||
|
||||
|
||||
## v0.3.0 (2021-09-29)
|
||||
|
||||
- Add new contributor/authors
|
||||
- Fix loading of LDIF directory
|
||||
- Change license to Apache License 2.0
|
||||
- review comments
|
||||
- review comments
|
||||
- Update readme for usage from compiled binary
|
||||
- Rewrite readme
|
||||
- Remove Kopano wording from readme file
|
||||
- Change copyright headers from Kopano to LibreGraph Authors
|
||||
- Add A+C files
|
||||
- Avoid duplicate index entries when using sub and pres
|
||||
- Index mail pres and sub for mail attribute
|
||||
- Cure potential panic in search without pagination
|
||||
- Apply search BaseDN when returning values from index
|
||||
- Introduce proper way to set defaults with option to override
|
||||
- Remove Kopano specific defaults and naming for white label rename
|
||||
- Rename public stuttering API functions
|
||||
- Make internal ldappasswd package importable
|
||||
- Make internal ldapserver package importable
|
||||
- Remove Jenkinsfile to prepare for external CI
|
||||
- Move project to github.com/libregraph/idm
|
||||
- Add proper LICENSE file
|
||||
- Add readme file
|
||||
|
||||
|
||||
## v0.2.7 (2021-05-31)
|
||||
|
||||
- Skip loading nil LDIF entries
|
||||
|
||||
|
||||
## v0.2.6 (2021-05-26)
|
||||
|
||||
- Use correct parts count for glibc2 CRYPT
|
||||
- Ignore case when selecting password crypt algo
|
||||
- Use absolute path for kill command
|
||||
|
||||
|
||||
## v0.2.5 (2021-05-26)
|
||||
|
||||
- Fix file loading in newusers sub command
|
||||
|
||||
|
||||
## v0.2.4 (2021-04-29)
|
||||
|
||||
- Fix missing variable in default LDIF main config template
|
||||
|
||||
|
||||
## v0.2.3 (2021-04-29)
|
||||
|
||||
- Ensure to setup folders with correct permissions
|
||||
|
||||
|
||||
## v0.2.2 (2021-04-29)
|
||||
|
||||
- Add setup step for systemd based startup
|
||||
|
||||
|
||||
## v0.2.1 (2021-04-29)
|
||||
|
||||
- Fix refactoring error for hash based password checks
|
||||
|
||||
|
||||
## v0.2.0 (2021-04-29)
|
||||
|
||||
- Move password hash functionality to internal module
|
||||
- Add password strength checks
|
||||
- Add gen passwd subcommand
|
||||
- Consolidate password hashing functions
|
||||
- Ignore commented lines when processing templates
|
||||
- Support relative paths in templates
|
||||
- Include demo LDIF generator script
|
||||
- Only load files in templates which are in a base folder
|
||||
- Unify config and commandline options
|
||||
- Add binscript, systemd service and config
|
||||
- Add reload support via SIGHUP
|
||||
- Enable index and index lookup for objectClass only filters
|
||||
- Add sub index support
|
||||
- Add present index support
|
||||
- Add proper license headers and origin reference
|
||||
- Add some AD attributres for equality indexing
|
||||
|
||||
|
||||
## v0.1.0 (2021-04-22)
|
||||
|
||||
- Improve string comparison performance
|
||||
- Improve LDIF parse logging
|
||||
- Prevent duplicates from multiple search equality index matches
|
||||
- Allow negative search equality index match
|
||||
- Add support to load LDIF data from folder
|
||||
- Implement gen newusers sub command with LDIF output
|
||||
- Add support for argon2 password hashing
|
||||
- Implement more LDAP server metrics
|
||||
- Add metrics support
|
||||
- Fix LDAP server stats support
|
||||
- Log LDAP close
|
||||
- Remove unsupported Unbinder
|
||||
- Fix debug log formatting
|
||||
- Use better anonymous bind for standard compliance
|
||||
- Add pprof support
|
||||
- Implement difference between startup and runtime errors
|
||||
- Add environment variables to set default config values
|
||||
- Move serve command into sub folder to prepare for other sub commands
|
||||
- Use template syntax in demo users generator
|
||||
- Apply ldif template defaults
|
||||
- Move LDIF template functionality into its own file
|
||||
- Improve flexibility of template support
|
||||
- Support setting current value in AutoIncrement template function
|
||||
- Improve commandline parameter naming
|
||||
- Use better names for example ldif
|
||||
- Allow configuration of LDIF template defaults
|
||||
- Add support to allow local anonymoys LDAP bind and search
|
||||
- Load LDIF files with template support
|
||||
- Actually allow LDIF middleware bind to succeed
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# This is the official list of people who can contribute
|
||||
# (and typically have contributed) code to the LibreGraph repository.
|
||||
# The AUTHORS file lists the copyright holders; this file
|
||||
# lists people. For example, Kopano employees are listed here
|
||||
# but not in AUTHORS, because Kopano holds the copyright.
|
||||
#
|
||||
# Names should be added to this file like so:
|
||||
# Individual's name <submission email address>
|
||||
# Individual's name <submission email address> <email2> <emailN>
|
||||
#
|
||||
# An entry with multiple email addresses specifies that the
|
||||
# first address should be used in the submit logs.
|
||||
|
||||
# Please keep the list sorted.
|
||||
|
||||
Felix Bartels <f.bartels@kopano.com>
|
||||
Ralf Haferkamp <rhaferkamp@owncloud.com>
|
||||
Simon Eisenmann <s.eisenmann@kopano.com> <simon@longsleep.org>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#
|
||||
# License: Apache-2.0
|
||||
# Copyright 2021 The LibreGraph Authors.
|
||||
#
|
||||
|
||||
FROM golang:1.16.3-buster
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
ARG GOLANGCI_LINT_TAG=v1.38.0
|
||||
RUN curl -sfL \
|
||||
https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
|
||||
sh -s -- -b /usr/local/bin ${GOLANGCI_LINT_TAG}
|
||||
|
||||
RUN GOBIN=/usr/local/bin go get -v \
|
||||
github.com/tebeka/go2xunit \
|
||||
&& go clean -cache && rm -rf /root/go
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ENV GOCACHE=/tmp/go-build
|
||||
ENV GOPATH=""
|
||||
ENV HOME=/tmp
|
||||
|
||||
CMD ["make", "DATE=reproducible"]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
PACKAGE = github.com/libregraph/idm
|
||||
PACKAGE_NAME = libregraph-$(shell basename $(PACKAGE))
|
||||
|
||||
# Tools
|
||||
|
||||
GO ?= go
|
||||
GOFMT ?= gofmt
|
||||
GOLINT ?= golangci-lint
|
||||
|
||||
GO2XUNIT ?= go2xunit
|
||||
|
||||
CHGLOG ?= git-chglog
|
||||
CURL ?= curl
|
||||
|
||||
# Cgo
|
||||
|
||||
CGO_ENABLED ?= 1
|
||||
|
||||
# Go modules
|
||||
|
||||
GO111MODULE ?= on
|
||||
|
||||
# Variables
|
||||
|
||||
export CGO_ENABLED GO111MODULE
|
||||
unexport GOPATH
|
||||
|
||||
ARGS ?=
|
||||
PWD := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
||||
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
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)
|
||||
PKGS = $(or $(PKG),$(shell $(GO) list -mod=readonly ./... | grep -v "^$(PACKAGE)/vendor/"))
|
||||
TESTPKGS = $(shell $(GO) list -mod=readonly -f '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' $(PKGS) 2>/dev/null)
|
||||
CMDS = $(or $(CMD),$(addprefix cmd/,$(notdir $(shell find "$(PWD)/cmd/" -maxdepth 1 -type d))))
|
||||
TIMEOUT = 30
|
||||
BUILD_TAGS ?=
|
||||
|
||||
# Build
|
||||
|
||||
.PHONY: all
|
||||
all: fmt | $(CMDS) $(PLUGINS)
|
||||
|
||||
plugins: fmt | $(PLUGINS)
|
||||
|
||||
.PHONY: $(CMDS)
|
||||
$(CMDS): vendor ; $(info building $@ ...) @
|
||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build \
|
||||
-mod=vendor \
|
||||
-trimpath \
|
||||
-tags "release $(BUILD_TAGS)" \
|
||||
-buildmode=exe \
|
||||
-ldflags '-s -w -buildid=reproducible/$(VERSION) -X $(PACKAGE)/version.Version=$(VERSION) -X $(PACKAGE)/version.BuildDate=$(DATE) -extldflags -static' \
|
||||
-o bin/$(notdir $@) ./$@
|
||||
|
||||
# Helpers
|
||||
|
||||
.PHONY: lint
|
||||
lint: vendor ; $(info running $(GOLINT) ...) @
|
||||
$(GOLINT) run
|
||||
|
||||
.PHONY: lint-checkstyle
|
||||
lint-checkstyle: vendor ; $(info running $(GOLINT) checkstyle ...) @
|
||||
@mkdir -p test
|
||||
$(GOLINT) run --out-format checkstyle --issues-exit-code 0 > test/tests.lint.xml
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ; $(info running gofmt ...) @
|
||||
@ret=0 && for d in $$($(GO) list -mod=readonly -f '{{.Dir}}' ./... | grep -v /vendor/); do \
|
||||
$(GOFMT) -l -w $$d/*.go || ret=$$? ; \
|
||||
done ; exit $$ret
|
||||
|
||||
.PHONY: check
|
||||
check: ; $(info checking dependencies ...) @
|
||||
@$(GO) mod verify && echo OK
|
||||
|
||||
# Tests
|
||||
|
||||
TEST_TARGETS := test-default test-bench test-short test-race test-verbose
|
||||
.PHONY: $(TEST_TARGETS)
|
||||
test-bench: ARGS=-run=_Bench* -test.benchmem -bench=.
|
||||
test-short: ARGS=-short
|
||||
test-race: ARGS=-race
|
||||
test-race: CGO_ENABLED=1
|
||||
test-verbose: ARGS=-v
|
||||
$(TEST_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
|
||||
$(TEST_TARGETS): test
|
||||
|
||||
.PHONY: test
|
||||
test: ; $(info running $(NAME:%=% )tests ...) @
|
||||
@CGO_ENABLED=$(CGO_ENABLED) $(GO) test -timeout $(TIMEOUT)s $(ARGS) $(TESTPKGS)
|
||||
|
||||
TEST_XML_TARGETS := test-xml-default test-xml-short test-xml-race
|
||||
.PHONY: $(TEST_XML_TARGETS)
|
||||
test-xml-short: ARGS=-short
|
||||
test-xml-race: ARGS=-race
|
||||
test-xml-race: CGO_ENABLED=1
|
||||
$(TEST_XML_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
|
||||
$(TEST_XML_TARGETS): test-xml
|
||||
|
||||
.PHONY: test-xml
|
||||
test-xml: ; $(info running $(NAME:%=% )tests ...) @
|
||||
@mkdir -p test
|
||||
2>&1 CGO_ENABLED=$(CGO_ENABLED) $(GO) test -timeout $(TIMEOUT)s $(ARGS) -v $(TESTPKGS) | tee test/tests.output
|
||||
$(shell test -s test/tests.output && $(GO2XUNIT) -fail -input test/tests.output -output test/tests.xml)
|
||||
|
||||
# Mod
|
||||
|
||||
go.sum: go.mod ; $(info updating dependencies ...)
|
||||
@$(GO) mod tidy -v
|
||||
@touch $@
|
||||
|
||||
.PHONY: vendor
|
||||
vendor: go.sum ; $(info retrieving dependencies ...)
|
||||
@$(GO) mod vendor -v
|
||||
@touch $@
|
||||
|
||||
# Dist
|
||||
|
||||
.PHONY: licenses
|
||||
licenses: vendor ; $(info building licenses files ...)
|
||||
$(CURDIR)/scripts/go-license-ranger.py > $(CURDIR)/3rdparty-LICENSES.md
|
||||
|
||||
3rdparty-LICENSES.md: licenses
|
||||
|
||||
.PHONY: dist
|
||||
dist: 3rdparty-LICENSES.md ; $(info building dist tarball ...)
|
||||
@rm -rf "dist/${PACKAGE_NAME}-${VERSION}"
|
||||
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}"
|
||||
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}/scripts"
|
||||
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}/docs"
|
||||
@cd dist && \
|
||||
cp -avf ../LICENSE.txt "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../README.md "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../3rdparty-LICENSES.md "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../bin/* "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../docs/example-template.ldif "${PACKAGE_NAME}-${VERSION}/docs" && \
|
||||
cp -avf ../scripts/libregraph-idmd.binscript "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
cp -avf ../scripts/libregraph-idmd.service "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
cp -avf ../scripts/idmd.cfg "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
cp -avf ../scripts/*.ldif.in "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
tar --owner=0 --group=0 -czvf ${PACKAGE_NAME}-${VERSION}.tar.gz "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cd ..
|
||||
|
||||
.PHONE: changelog
|
||||
changelog: ; $(info updating changelog ...)
|
||||
$(CHGLOG) --output CHANGELOG.md $(ARGS) v0.1.0..
|
||||
|
||||
# Rest
|
||||
|
||||
.PHONY: clean
|
||||
clean: ; $(info cleaning ...) @
|
||||
@rm -rf bin
|
||||
@rm -rf test/test.*
|
||||
|
||||
.PHONY: version
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
## LibreGraph Identity Management
|
||||
|
||||
The LibreGraph Identity Management provides a LDAP server, which is easy to configure, does not have external dependencies and is tailored to work perfectly with other LibreGraph software.
|
||||
|
||||
The goal is that everyone who does not already have or needs an LDAP server, uses IDM.
|
||||
|
||||
Thus, IDM is a (currently read-only) drop in replacement for an existing LDAP server and does provide an LDAP interface if none is there already. IDM uses hard coded indexes and supports LDAP search, bind and unbind operations.
|
||||
|
||||
### Running idmd from a source build
|
||||
|
||||
Until packages and containers for more environments are available it is the easiest to just create a local build of `idmd`. For this just run `make`.
|
||||
|
||||
IDM uses a mixture of environment variables and parameters for configuration and needs to be at least passed a the location of an individual ldif file or a directory containing multiple ldif files.
|
||||
|
||||
```bash
|
||||
$ ./idmd serve --ldif-main ./export.ldif
|
||||
INFO[0000] LDAP listener started listen_addr="127.0.0.1:10389"
|
||||
INFO[0000] ready
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The default base DN of IDM is `dc=lg,dc=local`. There is usually no need to change, it if you don't use the LDAP data for anything else. The value needs to match what the clients have configured. Similarly, the default mail domain is `lg.local`.
|
||||
|
||||
Both values can be changed by passing `--ldap-base-dn` or `--ldif-template-default-mail-domain` respectively.
|
||||
|
||||
IDM uses ldif files for its data source and those files, the location of these files needs to be passed at startup using the `--ldif-main` parameter.
|
||||
|
||||
#### Adding a service user for LDAP access
|
||||
|
||||
By default IDM does not have any users and anonymous bind is disabled. You can enable anonymous bind support for local requests by passing `--ldap-allow-local-anonymous` when running `idmd`. Alternatively a service user can be specified in the following way:
|
||||
|
||||
```bash
|
||||
cat <<EOF > ./config.ldif
|
||||
dn: cn=readonly,{{.BaseDN}}
|
||||
cn: readonly
|
||||
description: LDAP read only service user
|
||||
objectClass: simpleSecurityObject
|
||||
objectClass: organizationalRole
|
||||
userPassword: readonly
|
||||
EOF
|
||||
```
|
||||
|
||||
And then passed as an additional parameter when starting `idmd` by passing `--ldif-config ./config.ldif`. The `config.ldif` is for service users only and the data in there is used for bind requests only, but never returned for search requests.
|
||||
|
||||
#### Add users to the ldap service
|
||||
|
||||
`idmd` serves all ldif files from the folder specified by `--ldif-main` (loaded in lexical order and parsed as templates). Whenever any of the ldif files are changed, added or removed, make sure to restart `idmd`.
|
||||
|
||||
`idmd` listens on `127.0.0.1:10389` by default and does not ship with any default users. Example configuration can be found in the [scripts directory](https://github.com/libregraph/idm/tree/master/scripts) of this repository.
|
||||
|
||||
##### Add new users using the `gen newusers` command
|
||||
|
||||
IDM provides a way to create ldif data for new users using batch mode similar to the unix `newusers` command using the following standard password file format:
|
||||
|
||||
```bash
|
||||
uid:userPassword:uidNumber:gidNumber:cn,[mail][,mailAlternateAddress...]:ignored:ignored
|
||||
```
|
||||
|
||||
For example, like this:
|
||||
|
||||
```bash
|
||||
cat << EOF | ./idmd gen newusers - --min-password-strength=4 > ./ldif/50-users.ldif
|
||||
jonas:passwordOfJonas123:::Jonas Brekke,jonas@lg.local::
|
||||
timmothy:passwordOfTimmothy456:::Timmothy Schöwalter::
|
||||
EOF
|
||||
```
|
||||
|
||||
This outputs an LDIF template file which you can modify as needed. When done run restart `idmd` to make the new users available. Keep in mind that some of the attributes must be unique.
|
||||
|
||||
##### Replace existing OpenLDAP with IDM
|
||||
|
||||
On the LDAP server export all its data using `slapcat` and write the resulting ldif to for example `./ldif/10-main.ldif`. This is a drop in replacement and all what was in OpenLDAP is now also in IDM.
|
||||
|
||||
Either stop `slapd` and change the IDM configuration to listen where `slapd` used to listen or change the clients to connect to where `idmd` listens to migrate.
|
||||
|
||||
### Extra goodies
|
||||
|
||||
#### Template support
|
||||
|
||||
All ldif files loaded by IDM support template syntax as defined in https://golang.org/pkg/text/template to allow auto generation and replacement of various values. You can find example templates in the [scripts directory](https://github.com/libregraph/idm/tree/master/scripts) as well. All the `gen` commands output template syntax if applicable.
|
||||
|
||||
#### Generate secure password hash using the `gen passwd` command
|
||||
|
||||
IDM supports secure password hashing using ARGON2. To create such password hashes either use `gen newusers` or the interactive `gen passwd` which is very similar to `slappasswd` from OpenLDAP.
|
||||
|
||||
```bash
|
||||
./idmd gen passwd
|
||||
New password:
|
||||
Re-enter new password:
|
||||
{ARGON2}$argon2id$v=19$m=65536,t=1,p=2$MaB5gX2BI484dATbGFyEIg$h2X8rbPowzZ/Exsz4W20Z/Zk54C30YnY+YbivSIRpcI
|
||||
```
|
||||
|
||||
#### Test IDM
|
||||
|
||||
Since `idmd` provides a standard LDAP interface, also standard LDAP tools can be used to interact with it for testing. Run `apt install ldap-utils` to install LDAP commandline tools.
|
||||
|
||||
```bash
|
||||
ldapsearch -x -H ldap://127.0.0.1:10389 -b "dc=lg,dc=local" -D "cn=readonly,dc=lg,dc=local" -w 'readonly'
|
||||
```
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package idm
|
||||
|
||||
// Defaults as used by multiple sub packages.
|
||||
var (
|
||||
DefaultLDAPBaseDN = "dc=lg,dc=local"
|
||||
DefaultMailDomain = "lg.local"
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package idm // import "github.com/libregraph/idm"
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package ldapdn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
// Normalize takes an ldap.DN struct and turns it into a "normalized" DN string
|
||||
// by cases folding all RDN (attributetypes and values). Note: This currently
|
||||
// handles all attributes as caseIgnoreStrings ignoring the Syntax the Attribute
|
||||
// Type might have assigned.
|
||||
func Normalize(dn *ldap.DN) string {
|
||||
var nDN string
|
||||
caseFold := cases.Fold()
|
||||
for r, rdn := range dn.RDNs {
|
||||
// FIXME to really normalize multivalued RDNs we'd need
|
||||
// to normalize the order of Attributes here as well
|
||||
for a, ava := range rdn.Attributes {
|
||||
if a > 0 {
|
||||
// This is a multivalued RDN.
|
||||
nDN += "+"
|
||||
} else if r > 0 {
|
||||
nDN += ","
|
||||
}
|
||||
nDN = nDN + caseFold.String(ava.Type) + "=" + encodeRDNValue(caseFold.String(ava.Value))
|
||||
}
|
||||
}
|
||||
return nDN
|
||||
}
|
||||
|
||||
// encodeRDNValue applies the DN escaping rules (RFC4514) to the supplied
|
||||
// string (the value part of an RDN). Returns the escaped string.
|
||||
// Note: This function is taken from https://github.com/go-ldap/ldap/pull/104
|
||||
func encodeRDNValue(rDNValue string) string {
|
||||
encodedBuf := bytes.Buffer{}
|
||||
|
||||
escapeChar := func(c byte) {
|
||||
encodedBuf.WriteByte('\\')
|
||||
encodedBuf.WriteByte(c)
|
||||
}
|
||||
|
||||
escapeHex := func(c byte) {
|
||||
encodedBuf.WriteByte('\\')
|
||||
encodedBuf.WriteString(hex.EncodeToString([]byte{c}))
|
||||
}
|
||||
|
||||
for i := 0; i < len(rDNValue); i++ {
|
||||
char := rDNValue[i]
|
||||
if i == 0 && char == ' ' || char == '#' {
|
||||
// Special case leading space or number sign.
|
||||
escapeChar(char)
|
||||
continue
|
||||
}
|
||||
if i == len(rDNValue)-1 && char == ' ' {
|
||||
// Special case trailing space.
|
||||
escapeChar(char)
|
||||
continue
|
||||
}
|
||||
|
||||
switch char {
|
||||
case '"', '+', ',', ';', '<', '>', '\\':
|
||||
// Each of these special characters must be escaped.
|
||||
escapeChar(char)
|
||||
continue
|
||||
}
|
||||
|
||||
if char < ' ' || char > '~' {
|
||||
// All special character escapes are handled first
|
||||
// above. All bytes less than ASCII SPACE and all bytes
|
||||
// greater than ASCII TILDE must be hex-escaped.
|
||||
escapeHex(char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Any other character does not require escaping.
|
||||
encodedBuf.WriteByte(char)
|
||||
}
|
||||
|
||||
return encodedBuf.String()
|
||||
}
|
||||
|
||||
// ParseNormalize normalizes the passed LDAP DN string by first parsing it (using ldap.ParseDN)
|
||||
// and then casefolding all RDN using ldapdn.Normalize(). ParseNormalize will return an error
|
||||
// when parsing the DN fails.
|
||||
func ParseNormalize(dn string) (string, error) {
|
||||
parsed, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Normalize(parsed), nil
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package ldapentry
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
func ApplyModify(old *ldap.Entry, mod *ldap.ModifyRequest) (newEntry *ldap.Entry, err error) {
|
||||
oldDN, err := ldap.ParseDN(old.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdn := oldDN.RDNs[0]
|
||||
modDN, err := ldap.ParseDN(mod.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// This shouldn't happen if we ge here (TM)
|
||||
if !oldDN.EqualFold(modDN) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("DNs do not match"))
|
||||
}
|
||||
|
||||
casefold := cases.Fold()
|
||||
newEntry = ldap.NewEntry(old.DN, map[string][]string{})
|
||||
newEntry.Attributes = old.Attributes
|
||||
for _, c := range mod.Changes {
|
||||
nType := casefold.String(c.Modification.Type)
|
||||
switch c.Operation {
|
||||
case ldap.AddAttribute:
|
||||
newValues := entryApplyModAdd(newEntry.GetEqualFoldAttributeValues(nType), c.Modification.Vals)
|
||||
newEntry.Attributes = entryReplaceValues(newEntry.Attributes, c.Modification.Type, newValues)
|
||||
|
||||
case ldap.ReplaceAttribute:
|
||||
// Modifies on RDN attributes need special care to make sure that the rdn Value is not removed
|
||||
for _, rdnAttr := range rdn.Attributes {
|
||||
if nType == casefold.String(rdnAttr.Type) {
|
||||
rdnPresent := false
|
||||
nRdnVal := casefold.String(rdnAttr.Value)
|
||||
for _, newVal := range c.Modification.Vals {
|
||||
if nRdnVal == casefold.String(newVal) {
|
||||
rdnPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !rdnPresent {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotAllowedOnRDN, errors.New(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
newEntry.Attributes = entryReplaceValues(newEntry.Attributes, c.Modification.Type, c.Modification.Vals)
|
||||
case ldap.DeleteAttribute:
|
||||
for _, rdnAttr := range rdn.Attributes {
|
||||
// Modifies on RDN attributes need special care
|
||||
if nType == casefold.String(rdnAttr.Type) {
|
||||
if len(c.Modification.Vals) == 0 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotAllowedOnRDN, errors.New(""))
|
||||
}
|
||||
nRdnVal := casefold.String(rdnAttr.Value)
|
||||
for _, delVal := range c.Modification.Vals {
|
||||
if nRdnVal == casefold.String(delVal) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotAllowedOnRDN, errors.New(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newValues := entryApplyModDelete(old.GetEqualFoldAttributeValues(nType), c.Modification.Vals)
|
||||
newEntry.Attributes = entryReplaceValues(newEntry.Attributes, c.Modification.Type, newValues)
|
||||
}
|
||||
}
|
||||
return newEntry, nil
|
||||
}
|
||||
|
||||
func entryReplaceValues(ea []*ldap.EntryAttribute, attrType string, newValues []string) (updatedAttrs []*ldap.EntryAttribute) {
|
||||
casefold := cases.Fold()
|
||||
nType := casefold.String(attrType)
|
||||
updated := false
|
||||
for _, attr := range ea {
|
||||
if casefold.String(attr.Name) == nType {
|
||||
updated = true
|
||||
if len(newValues) == 0 {
|
||||
continue
|
||||
}
|
||||
updatedAttrs = append(updatedAttrs, ldap.NewEntryAttribute(attr.Name, newValues))
|
||||
} else {
|
||||
updatedAttrs = append(updatedAttrs, attr)
|
||||
}
|
||||
}
|
||||
if !updated {
|
||||
if len(newValues) != 0 {
|
||||
updatedAttrs = append(updatedAttrs, ldap.NewEntryAttribute(attrType, newValues))
|
||||
}
|
||||
}
|
||||
return updatedAttrs
|
||||
}
|
||||
|
||||
func entryApplyModAdd(curVals, addVals []string) (newVals []string) {
|
||||
newVals = curVals
|
||||
casefold := cases.Fold()
|
||||
for _, newVal := range addVals {
|
||||
present := false
|
||||
for _, val := range curVals {
|
||||
if casefold.String(newVal) == casefold.String(val) {
|
||||
present = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !present {
|
||||
newVals = append(newVals, newVal)
|
||||
}
|
||||
}
|
||||
return newVals
|
||||
}
|
||||
|
||||
func entryApplyModDelete(curVals, delVals []string) (newVals []string) {
|
||||
casefold := cases.Fold()
|
||||
if len(delVals) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
for _, curVal := range curVals {
|
||||
nCurVal := casefold.String(curVal)
|
||||
keep := true
|
||||
for _, del := range delVals {
|
||||
if nCurVal == casefold.String(del) {
|
||||
keep = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
newVals = append(newVals, curVal)
|
||||
}
|
||||
}
|
||||
return newVals
|
||||
}
|
||||
|
||||
func EntryFromAddRequest(add *ldap.AddRequest) *ldap.Entry {
|
||||
attrs := map[string][]string{}
|
||||
|
||||
for _, a := range add.Attributes {
|
||||
attrs[a.Type] = a.Vals
|
||||
}
|
||||
return ldap.NewEntry(add.DN, attrs)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
//go:build !disable_crypt && (linux || freebsd || netbsd)
|
||||
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldappassword
|
||||
|
||||
import (
|
||||
gocrypt "github.com/amoghe/go-crypt"
|
||||
)
|
||||
|
||||
func crypt(pass, salt string) (string, error) {
|
||||
return gocrypt.Crypt(pass, salt)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
//go:build disable_crypt || darwin || windows
|
||||
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldappassword
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
func crypt(pass, salt string) (string, error) {
|
||||
return "", errors.New("CRYPT unsupported on this platform")
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldappassword
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //nolint,gosec
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/alexedwards/argon2id"
|
||||
"github.com/trustelem/zxcvbn"
|
||||
)
|
||||
|
||||
var Argon2DefaultParams = argon2id.DefaultParams
|
||||
|
||||
func Validate(password string, hash string) (bool, error) {
|
||||
algorithm := ""
|
||||
if hash[0] == '{' {
|
||||
algorithmEnd := strings.Index(hash[0:], "}")
|
||||
if algorithmEnd >= 1 {
|
||||
algorithm = hash[0 : algorithmEnd+1]
|
||||
hash = hash[algorithmEnd+1:]
|
||||
}
|
||||
}
|
||||
|
||||
hashBytes := []byte(hash)
|
||||
var passwordBytes []byte
|
||||
|
||||
switch strings.ToUpper(algorithm) {
|
||||
case "":
|
||||
// No password scheme, direct comparison.
|
||||
passwordBytes = []byte(password)
|
||||
|
||||
case "{ARGON2}":
|
||||
// Follows the format used by the Argon2 reference C implementation and looks like this:
|
||||
// $argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
|
||||
match, err := argon2id.ComparePasswordAndHash(password, hash)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("argon2 error: %w", err)
|
||||
}
|
||||
if !match {
|
||||
return false, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case "{CRYPT}":
|
||||
// By default the salt is a two character string.
|
||||
salt := hash[:2]
|
||||
if hash[0] == '$' {
|
||||
// In the glibc2 version, salt format for additional encryption
|
||||
// $id$salt$encrypted.
|
||||
hashParts := strings.SplitN(hash, "$", 4)
|
||||
if len(hashParts) == 4 {
|
||||
salt = strings.Join(hashParts[:4], "$")
|
||||
}
|
||||
}
|
||||
encrypted, err := crypt(password, salt)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("crypt error: %w", err)
|
||||
}
|
||||
passwordBytes = []byte(encrypted)
|
||||
|
||||
case "{SSHA}":
|
||||
// BASE64(SHA-1(clear_text + salt) + salt)
|
||||
// The salt is 4 bytes long.
|
||||
decodedBytes, err := base64.StdEncoding.DecodeString(hash)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ssha error: %w", err)
|
||||
}
|
||||
salt := decodedBytes[len(decodedBytes)-4:]
|
||||
h := sha1.New() //nolint,gosec
|
||||
h.Write([]byte(password))
|
||||
h.Write(salt)
|
||||
passwordBytes = h.Sum(nil)
|
||||
passwordBytes = append(passwordBytes, salt...)
|
||||
hashBytes = decodedBytes
|
||||
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported password algorithm: %s", algorithm)
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(hashBytes, passwordBytes) != 1 {
|
||||
return false, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func Hash(password string, algorithm string) (string, error) {
|
||||
var result string
|
||||
|
||||
switch algorithm {
|
||||
case "", "{CLEARTEXT}":
|
||||
result = password
|
||||
|
||||
case "{ARGON2}":
|
||||
hash, hashErr := argon2id.CreateHash(password, Argon2DefaultParams)
|
||||
if hashErr != nil {
|
||||
return "", fmt.Errorf("password hash error: %w", hashErr)
|
||||
}
|
||||
result = "{ARGON2}" + hash
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("password hash alg not supported: %s", algorithm)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EstimatePasswordStrength(password string, userInputs []string) int {
|
||||
result := zxcvbn.PasswordStrength(password, userInputs)
|
||||
return result.Score
|
||||
}
|
||||
|
||||
func GenerateRandomPassword(length int) (string, error) {
|
||||
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-=+!@#$%^&*."
|
||||
ret := make([]byte, length)
|
||||
for i := 0; i < length; i++ {
|
||||
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ret[i] = chars[num.Int64()]
|
||||
}
|
||||
|
||||
return string(ret), nil
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
Copyright (c) 2021 The LibreGraph Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# LDAP server library for Golang
|
||||
|
||||
This library provides LDAP server v3 functionality for the GO programming
|
||||
language.
|
||||
|
||||
The server implementation is based on github.com/nmcclain/ldap and is enhanced
|
||||
so it can be used together with github.com/go-ldap/ldap/v3.
|
||||
|
||||
From the server perspective, all of RFC4510 is implemented except:
|
||||
|
||||
4.5.1.3. SearchRequest.derefAliases
|
||||
4.5.1.5. SearchRequest.timeLimit
|
||||
4.5.1.6. SearchRequest.typesOnly
|
||||
4.14. StartTLS Operation
|
||||
|
||||
The purpose of this library is not a general LDAP server implementation but to
|
||||
provide enough of an LDAP server for Kopano compatible identity management.
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE.txt` for licensing information of this module.
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleAddRequest(req *ber.Packet, boundDN string, server *Server, conn net.Conn) error {
|
||||
if boundDN == "" {
|
||||
return ldap.NewError(ldap.LDAPResultInsufficientAccessRights, errors.New("anonymous Write denied"))
|
||||
}
|
||||
addReq, err := parseAddRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fnNames := []string{}
|
||||
for k := range server.AddFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(addReq.DN, fnNames)
|
||||
var adder Adder
|
||||
if adder = server.AddFns[fn]; adder == nil {
|
||||
if fn == "" {
|
||||
err = fmt.Errorf("no suitable handler found for dn: '%s'", addReq.DN)
|
||||
} else {
|
||||
err = fmt.Errorf("handler '%s' does not support add", fn)
|
||||
}
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, err)
|
||||
}
|
||||
code, err := adder.Add(boundDN, addReq, conn)
|
||||
return ldap.NewError(uint16(code), err)
|
||||
}
|
||||
|
||||
func parseAddRequest(req *ber.Packet) (*ldap.AddRequest, error) {
|
||||
addReq := ldap.AddRequest{}
|
||||
// LDAP Add request have 2 Elements (DN and AttributeList)
|
||||
if len(req.Children) != 2 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("invalid add request"))
|
||||
}
|
||||
|
||||
dn, ok := req.Children[0].Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding entry DN"))
|
||||
}
|
||||
|
||||
_, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, err)
|
||||
}
|
||||
addReq.DN = dn
|
||||
|
||||
al, err := parseAttributeList(req.Children[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addReq.Attributes = al
|
||||
|
||||
return &addReq, nil
|
||||
}
|
||||
|
||||
func parseAttributeList(req *ber.Packet) ([]ldap.Attribute, error) {
|
||||
ldapAttrs := []ldap.Attribute{}
|
||||
|
||||
if req.ClassType != ber.ClassUniversal || req.TagType != ber.TypeConstructed || req.Tag != ber.TagSequence {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute List"))
|
||||
}
|
||||
|
||||
for _, a := range req.Children {
|
||||
attr, err := parseAttribute(a, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ldapAttrs = append(ldapAttrs, *attr)
|
||||
|
||||
}
|
||||
return ldapAttrs, nil
|
||||
}
|
||||
|
||||
func parseAttribute(attr *ber.Packet, partial bool) (*ldap.Attribute, error) {
|
||||
var la ldap.Attribute
|
||||
var ok bool
|
||||
var err error
|
||||
|
||||
// Partial attributes, might just contain a type and allow the Value to be absent
|
||||
if partial && (len(attr.Children) < 1 || len(attr.Children) > 2) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding partial Attribute"))
|
||||
} else if !partial && len(attr.Children) != 2 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute"))
|
||||
}
|
||||
|
||||
ad := attr.Children[0]
|
||||
if ad.ClassType != ber.ClassUniversal || ad.TagType != ber.TypePrimitive || ad.Tag != ber.TagOctetString {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute Description"))
|
||||
}
|
||||
la.Type, ok = ad.Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute Description"))
|
||||
}
|
||||
|
||||
// We can return here if this is a Partial Attribute without values
|
||||
if partial && len(attr.Children) == 1 {
|
||||
return &la, nil
|
||||
}
|
||||
if la.Vals, err = parseAttributeValues(attr.Children[1]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &la, nil
|
||||
}
|
||||
|
||||
func parseAttributeValues(values *ber.Packet) ([]string, error) {
|
||||
var strVals []string
|
||||
if values.ClassType != ber.ClassUniversal || values.TagType != ber.TypeConstructed || values.Tag != ber.TagSet {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute Values"))
|
||||
}
|
||||
for _, value := range values.Children {
|
||||
strVal, ok := value.Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Attribute Value"))
|
||||
}
|
||||
strVals = append(strVals, strVal)
|
||||
}
|
||||
return strVals, nil
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Copyright 2012 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleBindRequest(req *ber.Packet, fns map[string]Binder, conn net.Conn) (resultCode LDAPResultCode) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
}
|
||||
}()
|
||||
|
||||
// we only support ldapv3
|
||||
ldapVersion, ok := req.Children[0].Value.(int64)
|
||||
if !ok {
|
||||
return ldap.LDAPResultProtocolError
|
||||
}
|
||||
if ldapVersion != 3 {
|
||||
logger.V(1).Info("Unsupported LDAP version", "version", ldapVersion)
|
||||
return ldap.LDAPResultInappropriateAuthentication
|
||||
}
|
||||
|
||||
// auth types
|
||||
bindDN, ok := req.Children[1].Value.(string)
|
||||
if !ok {
|
||||
return ldap.LDAPResultProtocolError
|
||||
}
|
||||
bindAuth := req.Children[2]
|
||||
switch bindAuth.Tag {
|
||||
default:
|
||||
logger.V(1).Info("Unknown LDAP authentication method", "tag", bindAuth.Tag)
|
||||
return ldap.LDAPResultInappropriateAuthentication
|
||||
case LDAPBindAuthSimple:
|
||||
if len(req.Children) == 3 {
|
||||
fnNames := []string{}
|
||||
for k := range fns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(bindDN, fnNames)
|
||||
resultCode, err := fns[fn].Bind(bindDN, bindAuth.Data.String(), conn)
|
||||
if err != nil {
|
||||
logger.Error(err, "BindFn Error")
|
||||
return ldap.LDAPResultOperationsError
|
||||
}
|
||||
return resultCode
|
||||
} else {
|
||||
logger.V(1).Info("Simple bind request has wrong # children. len(req.Children) != 3")
|
||||
return ldap.LDAPResultInappropriateAuthentication
|
||||
}
|
||||
case LDAPBindAuthSASL:
|
||||
logger.V(1).Info("SASL authentication is not supported")
|
||||
return ldap.LDAPResultInappropriateAuthentication
|
||||
}
|
||||
return ldap.LDAPResultOperationsError
|
||||
}
|
||||
|
||||
func encodeBindResponse(messageID int64, ldapResultCode LDAPResultCode) *ber.Packet {
|
||||
responsePacket := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
|
||||
responsePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "Message ID"))
|
||||
|
||||
bindReponse := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldap.ApplicationBindResponse, nil, "Bind Response")
|
||||
bindReponse.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, uint64(ldapResultCode), "resultCode: "))
|
||||
bindReponse.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN: "))
|
||||
bindReponse.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "errorMessage: "))
|
||||
|
||||
responsePacket.AppendChild(bindReponse)
|
||||
|
||||
// ber.PrintPacket(responsePacket)
|
||||
return responsePacket
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleDeleteRequest(req *ber.Packet, boundDN string, server *Server, conn net.Conn) error {
|
||||
if boundDN == "" {
|
||||
return ldap.NewError(ldap.LDAPResultInsufficientAccessRights, errors.New("anonymous Write denied"))
|
||||
}
|
||||
delReq, err := parseDeleteRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fnNames := []string{}
|
||||
for k := range server.DeleteFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(delReq.DN, fnNames)
|
||||
var del Deleter
|
||||
if del = server.DeleteFns[fn]; del == nil {
|
||||
if fn == "" {
|
||||
err = fmt.Errorf("no suitable handler found for dn: '%s'", delReq.DN)
|
||||
} else {
|
||||
err = fmt.Errorf("handler '%s' does not support add", fn)
|
||||
}
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, err)
|
||||
}
|
||||
code, err := del.Delete(boundDN, delReq, conn)
|
||||
return ldap.NewError(uint16(code), err)
|
||||
}
|
||||
|
||||
func parseDeleteRequest(req *ber.Packet) (*ldap.DelRequest, error) {
|
||||
delReq := ldap.DelRequest{}
|
||||
// LDAP Delete requests contain just the DN (no Sequence, or set)
|
||||
// i.e. they have no childre
|
||||
if len(req.Children) != 0 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("invalid delete request"))
|
||||
}
|
||||
dn := req.Data.String()
|
||||
|
||||
_, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, err)
|
||||
}
|
||||
delReq.DN = dn
|
||||
|
||||
return &delReq, nil
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
type ExopHandler func(req *ber.Packet, boundDN string, server *Server, conn net.Conn) (*ber.Packet, error)
|
||||
|
||||
type ExtendedRequest struct {
|
||||
OID string
|
||||
Body *ber.Packet
|
||||
}
|
||||
|
||||
var exopRegistry = map[string]ExopHandler{}
|
||||
|
||||
func RegisterExtendedOperation(oid string, handler ExopHandler) {
|
||||
exopRegistry[oid] = handler
|
||||
}
|
||||
|
||||
func HandleExtendedRequest(req *ber.Packet, boundDN string, server *Server, conn net.Conn) (*ber.Packet, error) {
|
||||
extReq, err := parseExtendedRequest(req)
|
||||
if err != nil {
|
||||
logger.V(1).Info("parsing extened request failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
if handler, ok := exopRegistry[extReq.OID]; ok {
|
||||
innerBer, err := handler(extReq.Body, boundDN, server, conn)
|
||||
var resCode LDAPResultCode = ldap.LDAPResultSuccess
|
||||
msg := ""
|
||||
if err != nil {
|
||||
if lerr, ok := err.(*ldap.Error); ok {
|
||||
msg = lerr.Err.Error()
|
||||
resCode = LDAPResultCode(lerr.ResultCode)
|
||||
} else {
|
||||
msg = err.Error()
|
||||
resCode = ldap.LDAPResultOther
|
||||
}
|
||||
}
|
||||
return encodeExtendedResponse(resCode, msg, extReq.OID, innerBer), nil
|
||||
} else {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Unsupported Extented Operations"))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func parseExtendedRequest(req *ber.Packet) (*ExtendedRequest, error) {
|
||||
// RFC 4511 Extended Operation:
|
||||
// ExtendedRequest ::= [APPLICATION 23] SEQUENCE {
|
||||
// requestName [0] LDAPOID,
|
||||
// requestValue [1] OCTET STRING OPTIONAL }
|
||||
extReq := ExtendedRequest{}
|
||||
if len(req.Children) < 1 || len(req.Children) > 2 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("invalid extented request"))
|
||||
}
|
||||
|
||||
if req.Children[0].Identifier.ClassType != ber.ClassContext || req.Children[0].Identifier.Tag != 0 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("error decoding extented request OID"))
|
||||
}
|
||||
|
||||
extReq.OID = req.Children[0].Data.String()
|
||||
if len(req.Children) == 2 {
|
||||
if req.Children[1].Identifier.ClassType != ber.ClassContext || req.Children[1].Identifier.Tag != 1 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("error decoding extented request OID"))
|
||||
}
|
||||
extReq.Body = req.Children[1]
|
||||
}
|
||||
|
||||
logger.V(1).Info("Extended Request", "oid", extReq.OID)
|
||||
return &extReq, nil
|
||||
}
|
||||
|
||||
func encodeExtendedResponse(rescode LDAPResultCode, msg, oid string, responseValue *ber.Packet) *ber.Packet {
|
||||
respBer := ber.Encode(ber.ClassApplication, ber.TypeConstructed,
|
||||
ber.Tag(ldap.ApplicationExtendedResponse), nil,
|
||||
ldap.ApplicationMap[ldap.ApplicationExtendedResponse])
|
||||
respBer.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, uint64(rescode), "resultCode: "))
|
||||
respBer.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN: "))
|
||||
respBer.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, msg, "errorMessage: "))
|
||||
respBer.AppendChild(ber.NewString(ber.ClassContext, ber.TypePrimitive, 10, oid, "responseName"))
|
||||
if responseValue != nil {
|
||||
encValue := ber.Encode(ber.ClassContext, ber.TypePrimitive, 11, nil, "responseValue")
|
||||
encValue.AppendChild(responseValue)
|
||||
respBer.AppendChild(encValue)
|
||||
}
|
||||
return respBer
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
const (
|
||||
FilterAnd = ldap.FilterAnd
|
||||
FilterOr = ldap.FilterOr
|
||||
FilterNot = ldap.FilterNot
|
||||
FilterEqualityMatch = ldap.FilterEqualityMatch
|
||||
FilterSubstrings = ldap.FilterSubstrings
|
||||
FilterGreaterOrEqual = ldap.FilterGreaterOrEqual
|
||||
FilterLessOrEqual = ldap.FilterLessOrEqual
|
||||
FilterPresent = ldap.FilterPresent
|
||||
FilterApproxMatch = ldap.FilterApproxMatch
|
||||
FilterExtensibleMatch = ldap.FilterExtensibleMatch
|
||||
)
|
||||
|
||||
var (
|
||||
FilterMap = ldap.FilterMap
|
||||
casefold = cases.Fold()
|
||||
)
|
||||
|
||||
const (
|
||||
FilterSubstringsInitial = ldap.FilterSubstringsInitial
|
||||
FilterSubstringsAny = ldap.FilterSubstringsAny
|
||||
FilterSubstringsFinal = ldap.FilterSubstringsFinal
|
||||
)
|
||||
|
||||
func CompileFilter(filter string) (*ber.Packet, error) {
|
||||
return ldap.CompileFilter(filter)
|
||||
}
|
||||
|
||||
func DecompileFilter(packet *ber.Packet) (ret string, err error) {
|
||||
return ldap.DecompileFilter(packet)
|
||||
}
|
||||
|
||||
func ServerApplyFilter(f *ber.Packet, entry *ldap.Entry) (bool, LDAPResultCode) {
|
||||
switch FilterMap[uint64(f.Tag)] {
|
||||
default:
|
||||
//log.Fatalf("Unknown LDAP filter code: %d", f.Tag)
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
case "Equality Match":
|
||||
if len(f.Children) != 2 {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
attribute := f.Children[0].Value.(string)
|
||||
value := f.Children[1].Value.(string)
|
||||
for _, a := range entry.Attributes {
|
||||
if strings.EqualFold(a.Name, attribute) {
|
||||
for _, v := range a.Values {
|
||||
if strings.EqualFold(v, value) {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "Present":
|
||||
for _, a := range entry.Attributes {
|
||||
if strings.EqualFold(a.Name, f.Data.String()) {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
}
|
||||
case "And":
|
||||
for _, child := range f.Children {
|
||||
ok, exitCode := ServerApplyFilter(child, entry)
|
||||
if exitCode != ldap.LDAPResultSuccess {
|
||||
return false, exitCode
|
||||
}
|
||||
if !ok {
|
||||
return false, ldap.LDAPResultSuccess
|
||||
}
|
||||
}
|
||||
return true, ldap.LDAPResultSuccess
|
||||
case "Or":
|
||||
anyOk := false
|
||||
for _, child := range f.Children {
|
||||
ok, exitCode := ServerApplyFilter(child, entry)
|
||||
if exitCode != ldap.LDAPResultSuccess {
|
||||
return false, exitCode
|
||||
} else if ok {
|
||||
anyOk = true
|
||||
}
|
||||
}
|
||||
if anyOk {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
case "Not":
|
||||
if len(f.Children) != 1 {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
ok, exitCode := ServerApplyFilter(f.Children[0], entry)
|
||||
if exitCode != ldap.LDAPResultSuccess {
|
||||
return false, exitCode
|
||||
} else if !ok {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
case "Substrings":
|
||||
if len(f.Children) != 2 {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
attribute := f.Children[0].Value.(string)
|
||||
bytes := f.Children[1].Children[0].Data.Bytes()
|
||||
value := casefold.String(string(bytes))
|
||||
for _, a := range entry.Attributes {
|
||||
if strings.EqualFold(a.Name, attribute) {
|
||||
for _, v := range a.Values {
|
||||
v = casefold.String(v)
|
||||
switch f.Children[1].Children[0].Tag {
|
||||
case FilterSubstringsInitial:
|
||||
if strings.HasPrefix(v, value) {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
case FilterSubstringsAny:
|
||||
if strings.Contains(v, value) {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
case FilterSubstringsFinal:
|
||||
if strings.HasSuffix(v, value) {
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "FilterGreaterOrEqual": // TODO
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
case "FilterLessOrEqual": // TODO
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
case "FilterApproxMatch": // TODO
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
case "FilterExtensibleMatch": // TODO
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
|
||||
return false, ldap.LDAPResultSuccess
|
||||
}
|
||||
|
||||
func ServerFilterScope(baseDN string, scope int, entry *ldap.Entry) (bool, LDAPResultCode) {
|
||||
// constrained search scope
|
||||
parsedBaseDn, err := ldap.ParseDN(baseDN)
|
||||
if err != nil {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
parsedDn, err := ldap.ParseDN(entry.DN)
|
||||
if err != nil {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
switch scope {
|
||||
case ldap.ScopeWholeSubtree: // The scope is constrained to the entry named by baseObject and to all its subordinates.
|
||||
case ldap.ScopeBaseObject: // The scope is constrained to the entry named by baseObject.
|
||||
if !parsedDn.EqualFold(parsedBaseDn) {
|
||||
return false, ldap.LDAPResultSuccess
|
||||
}
|
||||
case ldap.ScopeSingleLevel: // The scope is constrained to the immediate subordinates of the entry named by baseObject.
|
||||
parts := strings.Split(entry.DN, ",")
|
||||
if len(parts) < 2 && !parsedDn.EqualFold(parsedBaseDn) {
|
||||
return false, ldap.LDAPResultSuccess
|
||||
}
|
||||
subDn := strings.Join(parts[1:], ",")
|
||||
parsedSubDn, err := ldap.ParseDN(subDn)
|
||||
if err != nil {
|
||||
return false, ldap.LDAPResultOperationsError
|
||||
}
|
||||
if !parsedSubDn.EqualFold(parsedBaseDn) {
|
||||
return false, ldap.LDAPResultSuccess
|
||||
}
|
||||
}
|
||||
|
||||
return true, ldap.LDAPResultSuccess
|
||||
}
|
||||
|
||||
func ServerFilterAttributes(attributes []string, entry *ldap.Entry) (LDAPResultCode, error) {
|
||||
// attributes
|
||||
if len(attributes) > 1 || (len(attributes) == 1 && len(attributes[0]) > 0) {
|
||||
_, err := filterAttributes(entry, attributes)
|
||||
if err != nil {
|
||||
return ldap.LDAPResultOperationsError, err
|
||||
}
|
||||
}
|
||||
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
type LDAPResultCode uint8
|
||||
|
||||
const (
|
||||
LDAPBindAuthSimple = 0
|
||||
LDAPBindAuthSASL = 3
|
||||
)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleModifyRequest(req *ber.Packet, boundDN string, server *Server, conn net.Conn) error {
|
||||
if boundDN == "" {
|
||||
return ldap.NewError(ldap.LDAPResultInsufficientAccessRights, errors.New("anonymous Write denied"))
|
||||
}
|
||||
modReq, err := parseModifyRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.V(1).Info("Parsed Modification", "request", dumpModRequest(modReq))
|
||||
|
||||
fnNames := []string{}
|
||||
for k := range server.ModifyFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(modReq.DN, fnNames)
|
||||
var modifier Modifier
|
||||
if modifier = server.ModifyFns[fn]; modifier == nil {
|
||||
if fn == "" {
|
||||
err = fmt.Errorf("no suitable handler found for dn: '%s'", modReq.DN)
|
||||
} else {
|
||||
err = fmt.Errorf("handler '%s' does not support modify", fn)
|
||||
}
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, err)
|
||||
}
|
||||
code, err := modifier.Modify(boundDN, modReq, conn)
|
||||
return ldap.NewError(uint16(code), err)
|
||||
}
|
||||
|
||||
func dumpModRequest(mr *ldap.ModifyRequest) string {
|
||||
str := fmt.Sprintf("dn: %s\n", mr.DN)
|
||||
for _, change := range mr.Changes {
|
||||
str += fmt.Sprintf("op: %d\n attr: %s values: %v\n", change.Operation, change.Modification.Type, change.Modification.Vals)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func parseModifyRequest(req *ber.Packet) (*ldap.ModifyRequest, error) {
|
||||
modReq := ldap.ModifyRequest{}
|
||||
// LDAP Modify requests have 2 Elements (DN and AttributeList)
|
||||
if len(req.Children) != 2 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("invalid modify request"))
|
||||
}
|
||||
|
||||
dn, ok := req.Children[0].Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding entry DN"))
|
||||
}
|
||||
|
||||
_, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, err)
|
||||
}
|
||||
modReq.DN = dn
|
||||
|
||||
ml, err := parseModList(req.Children[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modReq.Changes = ml
|
||||
|
||||
return &modReq, nil
|
||||
}
|
||||
|
||||
func parseModList(req *ber.Packet) ([]ldap.Change, error) {
|
||||
var changes []ldap.Change
|
||||
|
||||
if req.ClassType != ber.ClassUniversal || req.TagType != ber.TypeConstructed || req.Tag != ber.TagSequence {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding Changes List"))
|
||||
}
|
||||
|
||||
for _, c := range req.Children {
|
||||
var change ldap.Change
|
||||
switch c.Children[0].Data.Bytes()[0] {
|
||||
default:
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("invalid change operation"))
|
||||
case ldap.AddAttribute:
|
||||
change.Operation = ldap.AddAttribute
|
||||
logger.V(1).Info("op=add")
|
||||
case ldap.ReplaceAttribute:
|
||||
change.Operation = ldap.ReplaceAttribute
|
||||
logger.V(1).Info("op=replace")
|
||||
case ldap.DeleteAttribute:
|
||||
change.Operation = ldap.DeleteAttribute
|
||||
logger.V(1).Info("op=delete")
|
||||
}
|
||||
attr, err := parseAttribute(c.Children[1], true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change.Modification = ldap.PartialAttribute{Type: attr.Type, Vals: attr.Vals}
|
||||
changes = append(changes, change)
|
||||
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleModifyDNRequest(req *ber.Packet, boundDN string, server *Server, conn net.Conn) error {
|
||||
if boundDN == "" {
|
||||
return ldap.NewError(ldap.LDAPResultInsufficientAccessRights, errors.New("anonymous Write denied"))
|
||||
}
|
||||
|
||||
modDNReq, err := parseModifyDNRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fnNames := []string{}
|
||||
for k := range server.ModifyDNFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(modDNReq.DN, fnNames)
|
||||
var rename Renamer
|
||||
if rename = server.ModifyDNFns[fn]; rename == nil {
|
||||
if fn == "" {
|
||||
err = fmt.Errorf("no suitable handler found for dn: '%s'", modDNReq.DN)
|
||||
} else {
|
||||
err = fmt.Errorf("handler '%s' does not support rename", fn)
|
||||
}
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, err)
|
||||
}
|
||||
code, err := rename.ModifyDN(boundDN, modDNReq, conn)
|
||||
return ldap.NewError(uint16(code), err)
|
||||
return ldap.NewError(ldap.LDAPResultProtocolError, errors.New("invalid ModifyDN request"))
|
||||
}
|
||||
|
||||
func parseModifyDNRequest(req *ber.Packet) (*ldap.ModifyDNRequest, error) {
|
||||
modDNReq := ldap.ModifyDNRequest{}
|
||||
// LDAP ModifyDN requests have up to 4 Elements (DN, newRDN, deleteOld flag and new superior)
|
||||
if len(req.Children) < 3 || len(req.Children) > 4 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("invalid ModifyDN request"))
|
||||
}
|
||||
|
||||
dn, ok := req.Children[0].Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding entry DN"))
|
||||
}
|
||||
|
||||
_, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, err)
|
||||
}
|
||||
modDNReq.DN = dn
|
||||
|
||||
newRDN, ok := req.Children[1].Value.(string)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding entry new RDN"))
|
||||
}
|
||||
|
||||
_, err = ldap.ParseDN(newRDN)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, err)
|
||||
}
|
||||
modDNReq.NewRDN = newRDN
|
||||
|
||||
removeOld, ok := req.Children[2].Value.(bool)
|
||||
if !ok {
|
||||
return nil, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("error decoding 'deleteOldRDN` flag"))
|
||||
}
|
||||
|
||||
modDNReq.DeleteOldRDN = removeOld
|
||||
|
||||
// moving to a new subtree is not yet supported
|
||||
if len(req.Children) == 4 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("moving to 'newSuperior' is not implemented"))
|
||||
}
|
||||
|
||||
return &modDNReq, nil
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/libregraph/idm/pkg/ldappassword"
|
||||
)
|
||||
|
||||
const pwmodOID = "1.3.6.1.4.1.4203.1.11.1"
|
||||
|
||||
const (
|
||||
TagReqIdentity = 0
|
||||
TagReqOldPW = 1
|
||||
TagReqNewPW = 2
|
||||
TagRespGenPW = 0
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterExtendedOperation(pwmodOID, HandlePasswordModifyExOp)
|
||||
}
|
||||
|
||||
func HandlePasswordModifyExOp(req *ber.Packet, boundDN string, server *Server, conn net.Conn) (*ber.Packet, error) {
|
||||
var passwordGenerated bool
|
||||
logger.V(1).Info("HandlePasswordModifyExOp")
|
||||
if boundDN == "" {
|
||||
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("authentication required"))
|
||||
}
|
||||
|
||||
pwReq, err := parsePasswordModifyExop(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If `UserIdentity` is empty, this is a request to update the bound user's own password
|
||||
if pwReq.UserIdentity == "" {
|
||||
pwReq.UserIdentity = boundDN
|
||||
}
|
||||
|
||||
if pwReq.NewPassword == "" {
|
||||
// New password empty means, we're requested to generate a new password
|
||||
var err error
|
||||
if pwReq.NewPassword, err = ldappassword.GenerateRandomPassword(server.GeneratedPasswordLength); err != nil {
|
||||
logger.Error(err, "Failed to generate new password")
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("Failed to generate new Password"))
|
||||
}
|
||||
passwordGenerated = true
|
||||
}
|
||||
pwReq.NewPassword, err = ldappassword.Hash(pwReq.NewPassword, "{ARGON2}")
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, err)
|
||||
}
|
||||
|
||||
logger.V(1).Info("Modify password extended operation", "dn", pwReq.UserIdentity)
|
||||
|
||||
fnNames := []string{}
|
||||
for k := range server.PasswordExOpFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(pwReq.UserIdentity, fnNames)
|
||||
var pwUpdatefn PasswordUpdater
|
||||
if pwUpdatefn = server.PasswordExOpFns[fn]; pwUpdatefn == nil {
|
||||
if fn == "" {
|
||||
err = fmt.Errorf("no suitable handler found for dn: '%s'", pwReq.UserIdentity)
|
||||
} else {
|
||||
err = fmt.Errorf("handler '%s' does not support add", fn)
|
||||
}
|
||||
return nil, ldap.NewError(ldap.LDAPResultUnwillingToPerform, err)
|
||||
}
|
||||
code, err := pwUpdatefn.ModifyPasswordExop(boundDN, pwReq, conn)
|
||||
if code != ldap.LDAPResultSuccess {
|
||||
return nil, ldap.NewError(uint16(code), err)
|
||||
}
|
||||
var response *ber.Packet
|
||||
if passwordGenerated {
|
||||
response = ber.NewSequence("PasswdModifyResponseValue")
|
||||
response.AppendChild(ber.NewString(ber.ClassContext, ber.TypePrimitive, 0, pwReq.NewPassword, "genPasswd"))
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func parsePasswordModifyExop(req *ber.Packet) (*ldap.PasswordModifyRequest, error) {
|
||||
pwReq := ldap.PasswordModifyRequest{}
|
||||
|
||||
// An absent (or empty) body of the request is valid. Translates into: "generate a new password for
|
||||
// for the current user"
|
||||
if req == nil {
|
||||
return &pwReq, nil
|
||||
}
|
||||
|
||||
inner := ber.DecodePacket(req.Data.Bytes())
|
||||
if inner == nil {
|
||||
return &pwReq, nil
|
||||
}
|
||||
|
||||
if len(inner.Children) > 3 {
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("invalid request"))
|
||||
}
|
||||
|
||||
for _, kid := range inner.Children {
|
||||
if kid.ClassType != ber.ClassContext {
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("invalid request"))
|
||||
}
|
||||
switch kid.Tag {
|
||||
default:
|
||||
return nil, ldap.NewError(ldap.LDAPResultDecodingError, errors.New("invalid request"))
|
||||
case TagReqIdentity:
|
||||
pwReq.UserIdentity = kid.Data.String()
|
||||
case TagReqOldPW:
|
||||
pwReq.OldPassword = kid.Data.String()
|
||||
case TagReqNewPW:
|
||||
pwReq.NewPassword = kid.Data.String()
|
||||
}
|
||||
}
|
||||
return &pwReq, nil
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func HandleSearchRequest(req *ber.Packet, controls *[]ldap.Control, messageID int64, boundDN string, server *Server, conn net.Conn) (doneControls *[]ldap.Control, resultErr error) {
|
||||
searchReq, err := parseSearchRequest(boundDN, req, controls)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, err)
|
||||
}
|
||||
|
||||
var filterPacket *ber.Packet
|
||||
if server.EnforceLDAP {
|
||||
filterPacket, err = ldap.CompileFilter(searchReq.Filter)
|
||||
if err != nil {
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, err)
|
||||
}
|
||||
}
|
||||
|
||||
fnNames := []string{}
|
||||
for k := range server.SearchFns {
|
||||
fnNames = append(fnNames, k)
|
||||
}
|
||||
fn := routeFunc(searchReq.BaseDN, fnNames)
|
||||
searchResp, err := server.SearchFns[fn].Search(boundDN, searchReq, conn)
|
||||
if err != nil {
|
||||
return &searchResp.Controls, ldap.NewError(uint16(searchResp.ResultCode), err)
|
||||
}
|
||||
|
||||
if server.EnforceLDAP {
|
||||
if searchReq.DerefAliases != ldap.NeverDerefAliases { // [-a {never|always|search|find}
|
||||
// TODO: Server DerefAliases not supported: RFC4511 4.5.1.3
|
||||
}
|
||||
if searchReq.TimeLimit > 0 {
|
||||
// TODO: Server TimeLimit not implemented
|
||||
}
|
||||
}
|
||||
|
||||
i := 0
|
||||
for _, entry := range searchResp.Entries {
|
||||
if server.EnforceLDAP {
|
||||
// filter
|
||||
keep, resultCode := ServerApplyFilter(filterPacket, entry)
|
||||
if resultCode != ldap.LDAPResultSuccess {
|
||||
return &searchResp.Controls, ldap.NewError(uint16(resultCode), errors.New("ServerApplyFilter error"))
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
|
||||
keep, resultCode = ServerFilterScope(searchReq.BaseDN, searchReq.Scope, entry)
|
||||
if resultCode != ldap.LDAPResultSuccess {
|
||||
return &searchResp.Controls, ldap.NewError(uint16(resultCode), errors.New("ServerApplyScope error"))
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
|
||||
resultCode, err = ServerFilterAttributes(searchReq.Attributes, entry)
|
||||
if err != nil {
|
||||
return &searchResp.Controls, ldap.NewError(uint16(resultCode), err)
|
||||
}
|
||||
|
||||
// size limit
|
||||
if searchReq.SizeLimit > 0 && i >= searchReq.SizeLimit {
|
||||
resultErr = ldap.NewError(
|
||||
ldap.LDAPResultSizeLimitExceeded,
|
||||
errors.New(ldap.LDAPResultCodeMap[ldap.LDAPResultSizeLimitExceeded]),
|
||||
)
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
// respond
|
||||
responsePacket := encodeSearchResponse(messageID, searchReq, entry)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
return &searchResp.Controls, ldap.NewError(ldap.LDAPResultOperationsError, err)
|
||||
}
|
||||
}
|
||||
return &searchResp.Controls, resultErr
|
||||
}
|
||||
|
||||
func parseSearchRequest(boundDN string, req *ber.Packet, controls *[]ldap.Control) (*ldap.SearchRequest, error) {
|
||||
if len(req.Children) != 8 {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("Bad search request"))
|
||||
}
|
||||
|
||||
// Parse the request.
|
||||
baseObject, ok := req.Children[0].Value.(string)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
s, ok := req.Children[1].Value.(int64)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
scope := int(s)
|
||||
d, ok := req.Children[2].Value.(int64)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
derefAliases := int(d)
|
||||
s, ok = req.Children[3].Value.(int64)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
sizeLimit := int(s)
|
||||
t, ok := req.Children[4].Value.(int64)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
timeLimit := int(t)
|
||||
typesOnly := false
|
||||
if req.Children[5].Value != nil {
|
||||
typesOnly, ok = req.Children[5].Value.(bool)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
}
|
||||
filter, err := DecompileFilter(req.Children[6])
|
||||
if err != nil {
|
||||
return &ldap.SearchRequest{}, err
|
||||
}
|
||||
attributes := []string{}
|
||||
for _, attr := range req.Children[7].Children {
|
||||
a, ok := attr.Value.(string)
|
||||
if !ok {
|
||||
return &ldap.SearchRequest{}, ldap.NewError(ldap.LDAPResultProtocolError, errors.New("Bad search request"))
|
||||
}
|
||||
attributes = append(attributes, a)
|
||||
}
|
||||
searchReq := &ldap.SearchRequest{
|
||||
BaseDN: baseObject,
|
||||
Scope: scope,
|
||||
DerefAliases: derefAliases,
|
||||
SizeLimit: sizeLimit,
|
||||
TimeLimit: timeLimit,
|
||||
TypesOnly: typesOnly,
|
||||
Filter: filter,
|
||||
Attributes: attributes,
|
||||
Controls: *controls,
|
||||
}
|
||||
|
||||
return searchReq, nil
|
||||
}
|
||||
|
||||
func filterAttributes(entry *ldap.Entry, attributes []string) (*ldap.Entry, error) {
|
||||
// Only return requested attributes.
|
||||
newAttributes := []*ldap.EntryAttribute{}
|
||||
|
||||
for _, attr := range entry.Attributes {
|
||||
for _, requested := range attributes {
|
||||
if requested == "*" || strings.EqualFold(attr.Name, requested) {
|
||||
newAttributes = append(newAttributes, attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.Attributes = newAttributes
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func encodeSearchResponse(messageID int64, req *ldap.SearchRequest, res *ldap.Entry) *ber.Packet {
|
||||
responsePacket := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
|
||||
responsePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "Message ID"))
|
||||
|
||||
searchEntry := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldap.ApplicationSearchResultEntry, nil, "Search Result Entry")
|
||||
searchEntry.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, res.DN, "Object Name"))
|
||||
|
||||
attrs := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "Attributes: ")
|
||||
for _, attribute := range res.Attributes {
|
||||
attrs.AppendChild(encodeSearchAttribute(attribute.Name, attribute.Values))
|
||||
}
|
||||
|
||||
searchEntry.AppendChild(attrs)
|
||||
responsePacket.AppendChild(searchEntry)
|
||||
|
||||
return responsePacket
|
||||
}
|
||||
|
||||
func encodeSearchAttribute(name string, values []string) *ber.Packet {
|
||||
packet := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "Attribute")
|
||||
packet.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, name, "Attribute Name"))
|
||||
|
||||
valuesPacket := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSet, nil, "Attribute Values: ")
|
||||
for _, value := range values {
|
||||
valuesPacket.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, value, "Attribute Value"))
|
||||
}
|
||||
|
||||
packet.AppendChild(valuesPacket)
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
func encodeSearchDone(messageID int64, ldapResultCode LDAPResultCode, doneControls *[]ldap.Control) *ber.Packet {
|
||||
responsePacket := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
|
||||
responsePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "Message ID"))
|
||||
donePacket := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ldap.ApplicationSearchResultDone, nil, "Search result done")
|
||||
donePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, uint64(ldapResultCode), "resultCode: "))
|
||||
donePacket.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN: "))
|
||||
donePacket.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "errorMessage: "))
|
||||
responsePacket.AppendChild(donePacket)
|
||||
|
||||
if doneControls != nil {
|
||||
contextPacket := ber.Encode(ber.ClassContext, ber.TypeConstructed, ber.TagEOC, nil, "Controls: ")
|
||||
for _, control := range *doneControls {
|
||||
contextPacket.AppendChild(control.Encode())
|
||||
}
|
||||
responsePacket.AppendChild(contextPacket)
|
||||
}
|
||||
// ber.PrintPacket(responsePacket)
|
||||
return responsePacket
|
||||
}
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
ber "github.com/go-asn1-ber/asn1-ber"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/go-logr/stdr"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapdn"
|
||||
)
|
||||
|
||||
type Adder interface {
|
||||
Add(boundDN string, req *ldap.AddRequest, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type Binder interface {
|
||||
Bind(bindDN, bindSimplePw string, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type Deleter interface {
|
||||
Delete(boundDN string, req *ldap.DelRequest, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type Modifier interface {
|
||||
Modify(boundDN string, req *ldap.ModifyRequest, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type PasswordUpdater interface {
|
||||
ModifyPasswordExop(boundDN string, req *ldap.PasswordModifyRequest, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type Renamer interface {
|
||||
ModifyDN(boundDN string, req *ldap.ModifyDNRequest, conn net.Conn) (LDAPResultCode, error)
|
||||
}
|
||||
|
||||
type Searcher interface {
|
||||
Search(boundDN string, req *ldap.SearchRequest, conn net.Conn) (ServerSearchResult, error)
|
||||
}
|
||||
|
||||
type Closer interface {
|
||||
Close(boundDN string, conn net.Conn) error
|
||||
}
|
||||
|
||||
var logger logr.Logger = stdr.New(log.Default())
|
||||
|
||||
type Server struct {
|
||||
AddFns map[string]Adder
|
||||
BindFns map[string]Binder
|
||||
DeleteFns map[string]Deleter
|
||||
ModifyFns map[string]Modifier
|
||||
ModifyDNFns map[string]Renamer
|
||||
PasswordExOpFns map[string]PasswordUpdater
|
||||
SearchFns map[string]Searcher
|
||||
CloseFns map[string]Closer
|
||||
Quit chan bool
|
||||
EnforceLDAP bool
|
||||
GeneratedPasswordLength int
|
||||
Stats *Stats
|
||||
}
|
||||
|
||||
type ServerSearchResult struct {
|
||||
Entries []*ldap.Entry
|
||||
Referrals []string
|
||||
Controls []ldap.Control
|
||||
ResultCode LDAPResultCode
|
||||
}
|
||||
|
||||
func NewServer() *Server {
|
||||
s := new(Server)
|
||||
s.Quit = make(chan bool)
|
||||
|
||||
d := defaultHandler{}
|
||||
s.AddFns = make(map[string]Adder)
|
||||
s.BindFns = make(map[string]Binder)
|
||||
s.DeleteFns = make(map[string]Deleter)
|
||||
s.ModifyFns = make(map[string]Modifier)
|
||||
s.ModifyDNFns = make(map[string]Renamer)
|
||||
s.PasswordExOpFns = make(map[string]PasswordUpdater)
|
||||
s.SearchFns = make(map[string]Searcher)
|
||||
s.CloseFns = make(map[string]Closer)
|
||||
s.BindFunc("", d)
|
||||
s.SearchFunc("", d)
|
||||
s.CloseFunc("", d)
|
||||
s.GeneratedPasswordLength = 16
|
||||
s.Stats = nil
|
||||
return s
|
||||
}
|
||||
|
||||
func Logger(l logr.Logger) {
|
||||
logger = l
|
||||
}
|
||||
|
||||
func (server *Server) AddFunc(baseDN string, f Adder) {
|
||||
server.AddFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) BindFunc(baseDN string, f Binder) {
|
||||
server.BindFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) DeleteFunc(baseDN string, f Deleter) {
|
||||
server.DeleteFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) ModifyFunc(baseDN string, f Modifier) {
|
||||
server.ModifyFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) ModifyDNFunc(baseDN string, f Renamer) {
|
||||
server.ModifyDNFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) PasswordExOpFunc(baseDN string, f PasswordUpdater) {
|
||||
server.PasswordExOpFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) SearchFunc(baseDN string, f Searcher) {
|
||||
server.SearchFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) CloseFunc(baseDN string, f Closer) {
|
||||
server.CloseFns[baseDN] = f
|
||||
}
|
||||
|
||||
func (server *Server) QuitChannel(quit chan bool) {
|
||||
server.Quit = quit
|
||||
}
|
||||
|
||||
func (server *Server) ListenAndServeTLS(listenString string, certFile string, keyFile string) error {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
tlsConfig.ServerName = "localhost"
|
||||
ln, err := tls.Listen("tcp", listenString, &tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = server.Serve(ln)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) SetStats(enable bool) {
|
||||
if enable {
|
||||
server.Stats = &Stats{}
|
||||
} else {
|
||||
server.Stats = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) GetStats() Stats {
|
||||
return *server.Stats.Clone()
|
||||
}
|
||||
|
||||
func (server *Server) ListenAndServe(listenString string) error {
|
||||
ln, err := net.Listen("tcp", listenString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = server.Serve(ln)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) Serve(ln net.Listener) error {
|
||||
newConn := make(chan net.Conn)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
|
||||
logger.Error(err, "Error accepting network connection")
|
||||
}
|
||||
break
|
||||
}
|
||||
logger.V(1).Info("New Connection", "addr", ln.Addr())
|
||||
newConn <- conn
|
||||
}
|
||||
}()
|
||||
|
||||
listener:
|
||||
for {
|
||||
select {
|
||||
case c := <-newConn:
|
||||
server.Stats.countConns(1)
|
||||
go server.handleConnection(c)
|
||||
case <-server.Quit:
|
||||
ln.Close()
|
||||
break listener
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) handleConnection(conn net.Conn) {
|
||||
boundDN := "" // "" == anonymous
|
||||
|
||||
handler:
|
||||
for {
|
||||
// Read incoming LDAP packet.
|
||||
packet, err := ber.ReadPacket(conn)
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF { // Client closed connection.
|
||||
break
|
||||
} else if err != nil {
|
||||
logger.Error(err, "handleConnection ber.ReadPacket")
|
||||
break
|
||||
}
|
||||
|
||||
// Sanity check this packet.
|
||||
if len(packet.Children) < 2 {
|
||||
logger.V(1).Info("len(packet.Children) < 2")
|
||||
break
|
||||
}
|
||||
// Check the message ID and ClassType.
|
||||
messageID, ok := packet.Children[0].Value.(int64)
|
||||
if !ok {
|
||||
logger.V(1).Info("malformed messageID")
|
||||
break
|
||||
}
|
||||
req := packet.Children[1]
|
||||
if req.ClassType != ber.ClassApplication {
|
||||
logger.V(1).Info("req.ClassType != ber.ClassApplication")
|
||||
break
|
||||
}
|
||||
// Handle controls if present.
|
||||
controls := []ldap.Control{}
|
||||
if len(packet.Children) > 2 {
|
||||
for _, child := range packet.Children[2].Children {
|
||||
c, err := ldap.DecodeControl(child)
|
||||
if err != nil {
|
||||
logger.Error(err, "handleConnection decode control")
|
||||
continue
|
||||
}
|
||||
controls = append(controls, c)
|
||||
}
|
||||
}
|
||||
|
||||
// log.Printf("DEBUG: handling operation: %s [%d]", ldap.ApplicationMap[uint8(req.Tag)], req.Tag)
|
||||
// ber.PrintPacket(packet) // DEBUG
|
||||
|
||||
// Dispatch the LDAP operation.
|
||||
switch req.Tag { // LDAP op code.
|
||||
default:
|
||||
op, ok := ldap.ApplicationMap[uint8(req.Tag)]
|
||||
if !ok {
|
||||
op = "unknown"
|
||||
}
|
||||
|
||||
logger.V(1).Info("Unhandled operation", "type", op, "tag", req.Tag)
|
||||
break handler
|
||||
|
||||
case ldap.ApplicationAddRequest:
|
||||
server.Stats.countAdds(1)
|
||||
|
||||
resultCode := uint16(ldap.LDAPResultSuccess)
|
||||
resultMsg := ""
|
||||
if err = HandleAddRequest(req, boundDN, server, conn); err != nil {
|
||||
var lErr *ldap.Error
|
||||
if errors.As(err, &lErr) {
|
||||
resultCode = lErr.ResultCode
|
||||
if lErr.Err != nil {
|
||||
resultMsg = lErr.Err.Error()
|
||||
}
|
||||
} else {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
resultMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
responsePacket := encodeLDAPResponse(messageID, ldap.ApplicationAddResponse, LDAPResultCode(resultCode), resultMsg)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationBindRequest:
|
||||
server.Stats.countBinds(1)
|
||||
ldapResultCode := HandleBindRequest(req, server.BindFns, conn)
|
||||
if ldapResultCode == ldap.LDAPResultSuccess {
|
||||
boundDN, ok = req.Children[1].Value.(string)
|
||||
if !ok {
|
||||
logger.V(1).Info("Malformed Bind DN")
|
||||
break handler
|
||||
}
|
||||
if boundDN, err = ldapdn.ParseNormalize(boundDN); err != nil {
|
||||
logger.V(1).Info("Error normalizing Bind DN", "error", err.Error())
|
||||
break handler
|
||||
}
|
||||
|
||||
}
|
||||
responsePacket := encodeBindResponse(messageID, ldapResultCode)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationCompareRequest:
|
||||
responsePacket := encodeLDAPResponse(messageID, ldap.ApplicationCompareRequest, ldap.LDAPResultOperationsError, "Unsupported operation: compare")
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
}
|
||||
logger.V(1).Info("Unhandled operation", "type", ldap.ApplicationMap[uint8(req.Tag)], "tag", req.Tag)
|
||||
break handler
|
||||
|
||||
case ldap.ApplicationDelRequest:
|
||||
server.Stats.countDeletes(1)
|
||||
resultCode := uint16(ldap.LDAPResultSuccess)
|
||||
resultMsg := ""
|
||||
if err = HandleDeleteRequest(req, boundDN, server, conn); err != nil {
|
||||
var lErr *ldap.Error
|
||||
if errors.As(err, &lErr) {
|
||||
resultCode = lErr.ResultCode
|
||||
if lErr.Err != nil {
|
||||
resultMsg = lErr.Err.Error()
|
||||
}
|
||||
} else {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
resultMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
responsePacket := encodeLDAPResponse(messageID, ldap.ApplicationDelResponse, LDAPResultCode(resultCode), resultMsg)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationExtendedRequest:
|
||||
resultCode := uint16(ldap.LDAPResultSuccess)
|
||||
resultMsg := ""
|
||||
var innerBer, responsePacket *ber.Packet
|
||||
if innerBer, err = HandleExtendedRequest(req, boundDN, server, conn); err != nil {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
resultMsg = err.Error()
|
||||
responsePacket = encodeLDAPResponse(messageID, ldap.ApplicationExtendedResponse, LDAPResultCode(resultCode), resultMsg)
|
||||
} else {
|
||||
responsePacket = ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
|
||||
responsePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "Message ID"))
|
||||
responsePacket.AppendChild(innerBer)
|
||||
}
|
||||
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationModifyDNRequest:
|
||||
server.Stats.countModifyDNs(1)
|
||||
resultCode := uint16(ldap.LDAPResultSuccess)
|
||||
resultMsg := ""
|
||||
if err = HandleModifyDNRequest(req, boundDN, server, conn); err != nil {
|
||||
var lErr *ldap.Error
|
||||
if errors.As(err, &lErr) {
|
||||
resultCode = lErr.ResultCode
|
||||
if lErr.Err != nil {
|
||||
resultMsg = lErr.Err.Error()
|
||||
}
|
||||
} else {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
resultMsg = err.Error()
|
||||
}
|
||||
}
|
||||
responsePacket := encodeLDAPResponse(messageID, ldap.ApplicationModifyDNResponse, LDAPResultCode(resultCode), resultMsg)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationModifyRequest:
|
||||
server.Stats.countModifies(1)
|
||||
resultCode := uint16(ldap.LDAPResultSuccess)
|
||||
resultMsg := ""
|
||||
if err = HandleModifyRequest(req, boundDN, server, conn); err != nil {
|
||||
var lErr *ldap.Error
|
||||
if errors.As(err, &lErr) {
|
||||
resultCode = lErr.ResultCode
|
||||
if lErr.Err != nil {
|
||||
resultMsg = lErr.Err.Error()
|
||||
}
|
||||
} else {
|
||||
resultCode = ldap.LDAPResultOperationsError
|
||||
resultMsg = err.Error()
|
||||
}
|
||||
}
|
||||
responsePacket := encodeLDAPResponse(messageID, ldap.ApplicationModifyResponse, LDAPResultCode(resultCode), resultMsg)
|
||||
if err = sendPacket(conn, responsePacket); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
|
||||
case ldap.ApplicationSearchRequest:
|
||||
server.Stats.countSearches(1)
|
||||
if doneControls, err := HandleSearchRequest(req, &controls, messageID, boundDN, server, conn); err != nil {
|
||||
// TODO: make this more testable/better err handling - stop using log, stop using breaks?
|
||||
logger.V(1).Info("handleSearchRequest", "error", err.Error())
|
||||
e := err.(*ldap.Error)
|
||||
if err = sendPacket(conn, encodeSearchDone(messageID, LDAPResultCode(e.ResultCode), doneControls)); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
break handler
|
||||
} else {
|
||||
if err = sendPacket(conn, encodeSearchDone(messageID, ldap.LDAPResultSuccess, doneControls)); err != nil {
|
||||
logger.Error(err, "sendPacket error")
|
||||
break handler
|
||||
}
|
||||
}
|
||||
case ldap.ApplicationUnbindRequest:
|
||||
server.Stats.countUnbinds(1)
|
||||
break handler // Simply disconnect.
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range server.CloseFns {
|
||||
c.Close(boundDN, conn)
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
server.Stats.countConnsClose(1)
|
||||
}
|
||||
|
||||
func sendPacket(conn net.Conn, packet *ber.Packet) error {
|
||||
_, err := conn.Write(packet.Bytes())
|
||||
if err != nil {
|
||||
logger.Error(err, "Error Sending Message")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func routeFunc(dn string, funcNames []string) string {
|
||||
bestPick := ""
|
||||
for _, fn := range funcNames {
|
||||
if strings.HasSuffix(dn, fn) {
|
||||
l := len(strings.Split(bestPick, ","))
|
||||
if bestPick == "" {
|
||||
l = 0
|
||||
}
|
||||
if len(strings.Split(fn, ",")) > l {
|
||||
bestPick = fn
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestPick
|
||||
}
|
||||
|
||||
func encodeLDAPResponse(messageID int64, responseType uint8, ldapResultCode LDAPResultCode, message string) *ber.Packet {
|
||||
responsePacket := ber.Encode(ber.ClassUniversal, ber.TypeConstructed, ber.TagSequence, nil, "LDAP Response")
|
||||
responsePacket.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagInteger, messageID, "Message ID"))
|
||||
response := ber.Encode(ber.ClassApplication, ber.TypeConstructed, ber.Tag(responseType), nil, ldap.ApplicationMap[responseType])
|
||||
response.AppendChild(ber.NewInteger(ber.ClassUniversal, ber.TypePrimitive, ber.TagEnumerated, uint64(ldapResultCode), "resultCode: "))
|
||||
response.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, "", "matchedDN: "))
|
||||
response.AppendChild(ber.NewString(ber.ClassUniversal, ber.TypePrimitive, ber.TagOctetString, message, "errorMessage: "))
|
||||
responsePacket.AppendChild(response)
|
||||
return responsePacket
|
||||
}
|
||||
|
||||
type defaultHandler struct {
|
||||
}
|
||||
|
||||
func (h defaultHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (LDAPResultCode, error) {
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
func (h defaultHandler) Search(boundDN string, req *ldap.SearchRequest, conn net.Conn) (ServerSearchResult, error) {
|
||||
return ServerSearchResult{make([]*ldap.Entry, 0), []string{}, []ldap.Control{}, ldap.LDAPResultSuccess}, nil
|
||||
}
|
||||
|
||||
func (h defaultHandler) Close(boundDN string, conn net.Conn) error {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Copyright 2012 The Go Authors. All rights reserved.
|
||||
// Copyright 2021 The LibreGraph Authors.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package ldapserver
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
Conns uint64
|
||||
ConnsCurrent uint64
|
||||
ConnsMax uint64
|
||||
Adds uint64
|
||||
Binds uint64
|
||||
Deletes uint64
|
||||
ModifyDNs uint64
|
||||
Modifies uint64
|
||||
Unbinds uint64
|
||||
Searches uint64
|
||||
statsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (stats *Stats) countConns(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Conns += delta
|
||||
stats.ConnsCurrent += delta
|
||||
if stats.ConnsCurrent > stats.ConnsMax {
|
||||
stats.ConnsMax = stats.ConnsCurrent
|
||||
}
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countConnsClose(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.ConnsCurrent -= delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countAdds(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Adds += delta
|
||||
stats.statsMutex.Lock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countBinds(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Binds += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countDeletes(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Deletes += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countModifyDNs(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.ModifyDNs += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countModifies(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Modifies += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countUnbinds(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Unbinds += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) countSearches(delta uint64) {
|
||||
if stats != nil {
|
||||
stats.statsMutex.Lock()
|
||||
stats.Searches += delta
|
||||
stats.statsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (stats *Stats) Clone() *Stats {
|
||||
var s2 *Stats
|
||||
if stats != nil {
|
||||
s2 = &Stats{}
|
||||
stats.statsMutex.RLock()
|
||||
s2.Conns = stats.Conns
|
||||
s2.ConnsCurrent = stats.ConnsCurrent
|
||||
s2.Adds = stats.Adds
|
||||
s2.Binds = stats.Binds
|
||||
s2.Deletes = stats.Deletes
|
||||
s2.ModifyDNs = stats.ModifyDNs
|
||||
s2.Modifies = stats.Modifies
|
||||
s2.Unbinds = stats.Unbinds
|
||||
s2.Searches = stats.Searches
|
||||
stats.statsMutex.RUnlock()
|
||||
}
|
||||
return s2
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
// Package ldbbolt provides the lower-level Database functions for managing LDAP Entries
|
||||
// in a BoltDB database. Some implementation details:
|
||||
//
|
||||
// # The database is currently separated in these three buckets
|
||||
//
|
||||
// - id2entry: This bucket contains the GOB encoded ldap.Entry instances keyed
|
||||
// by a unique 64bit ID
|
||||
//
|
||||
// - dn2id: This bucket is used as an index to lookup the ID of an entry by its DN. The DN
|
||||
// is used in an normalized (case-folded) form here.
|
||||
//
|
||||
// - id2children: This bucket uses the entry-ids as and index and the values contain a list
|
||||
// of the entry ids of its direct childdren
|
||||
//
|
||||
// Additional buckets will likely be added in the future to create efficient search indexes
|
||||
package ldbbolt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/gob"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapdn"
|
||||
"github.com/libregraph/idm/pkg/ldapentry"
|
||||
"github.com/libregraph/idm/pkg/ldappassword"
|
||||
)
|
||||
|
||||
type LdbBolt struct {
|
||||
logger logrus.FieldLogger
|
||||
db *bolt.DB
|
||||
options *bolt.Options
|
||||
base string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrEntryAlreadyExists = errors.New("entry already exists")
|
||||
ErrEntryNotFound = errors.New("entry does not exist")
|
||||
ErrNonLeafEntry = errors.New("entry is not a leaf entry")
|
||||
)
|
||||
|
||||
func (bdb *LdbBolt) Configure(logger logrus.FieldLogger, baseDN, dbfile string, options *bolt.Options) error {
|
||||
bdb.logger = logger
|
||||
logger = logger.WithField("db", dbfile)
|
||||
logger.Debug("Open boltdb")
|
||||
db, err := bolt.Open(dbfile, 0o600, options)
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Error opening database")
|
||||
return err
|
||||
}
|
||||
bdb.db = db
|
||||
bdb.options = options
|
||||
bdb.base, _ = ldapdn.ParseNormalize(baseDN)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize() opens the Database file and create the required buckets if they do not
|
||||
// exist yet. After calling initialize the database is ready to process transactions
|
||||
func (bdb *LdbBolt) Initialize() error {
|
||||
var err error
|
||||
logger := bdb.logger.WithField("db", bdb.db.Path())
|
||||
if bdb.options == nil || !bdb.options.ReadOnly {
|
||||
logger.Debug("Adding default buckets")
|
||||
err = bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
_, err = tx.CreateBucketIfNotExists([]byte("dn2id"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bucket 'dn2id': %w", err)
|
||||
}
|
||||
_, err = tx.CreateBucketIfNotExists([]byte("id2children"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bucket 'dn2id': %w", err)
|
||||
}
|
||||
_, err = tx.CreateBucketIfNotExists([]byte("id2entry"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bucket 'id2entry': %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("Error creating default buckets")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Performs basic LDAP searches, using the dn2id and id2children buckets to generate
|
||||
// a list of Result entries. Currently this does strip of the non-request attribute
|
||||
// Neither does it support LDAP filters. For now we rely on the frontent (LDAPServer)
|
||||
// to both.
|
||||
func (bdb *LdbBolt) Search(base string, scope int) ([]*ldap.Entry, error) {
|
||||
entries := []*ldap.Entry{}
|
||||
nDN, err := ldapdn.ParseNormalize(base)
|
||||
if err != nil {
|
||||
return entries, err
|
||||
}
|
||||
|
||||
err = bdb.db.View(func(tx *bolt.Tx) error {
|
||||
entryID := bdb.getIDByDN(tx, nDN)
|
||||
var entryIDs []uint64
|
||||
if entryID == 0 {
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
switch scope {
|
||||
case ldap.ScopeBaseObject:
|
||||
entryIDs = append(entryIDs, entryID)
|
||||
case ldap.ScopeSingleLevel:
|
||||
entryIDs = bdb.getChildrenIDs(tx, entryID)
|
||||
case ldap.ScopeWholeSubtree:
|
||||
entryIDs = append(entryIDs, entryID)
|
||||
entryIDs = append(entryIDs, bdb.getSubtreeIDs(tx, entryID)...)
|
||||
}
|
||||
for _, id := range entryIDs {
|
||||
entry, err := bdb.getEntryByID(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return entries, err
|
||||
}
|
||||
|
||||
func idToBytes(id uint64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(b, id)
|
||||
return b
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) getChildrenIDs(tx *bolt.Tx, parent uint64) []uint64 {
|
||||
id2Children := tx.Bucket([]byte("id2children"))
|
||||
children := id2Children.Get(idToBytes(parent))
|
||||
r := bytes.NewReader(children)
|
||||
ids := make([]uint64, len(children)/8)
|
||||
if err := binary.Read(r, binary.LittleEndian, &ids); err != nil {
|
||||
bdb.logger.Error(err)
|
||||
}
|
||||
// This logging it too verbose even for the "debug" level. Leaving
|
||||
// it here commented out as it can be helpful during development.
|
||||
// bdb.logger.WithFields(logrus.Fields{
|
||||
// "parentid": parent,
|
||||
// "children": ids,
|
||||
// }).Debug("getChildrenIDs")
|
||||
return ids
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) getSubtreeIDs(tx *bolt.Tx, root uint64) []uint64 {
|
||||
var res []uint64
|
||||
children := bdb.getChildrenIDs(tx, root)
|
||||
res = append(res, children...)
|
||||
for _, child := range children {
|
||||
res = append(res, bdb.getSubtreeIDs(tx, child)...)
|
||||
}
|
||||
// This logging it too verbose even for the "debug" level. Leaving
|
||||
// it here commented out as it can be helpful during development.
|
||||
// bdb.logger.WithFields(logrus.Fields{
|
||||
// "rootid": root,
|
||||
// "subtree": res,
|
||||
// }).Debug("getSubtreeIDs")
|
||||
return res
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) EntryPut(e *ldap.Entry) error {
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
if err := enc.Encode(e); err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dn, _ := ldap.ParseDN(e.DN)
|
||||
parentDN := &ldap.DN{
|
||||
RDNs: dn.RDNs[1:],
|
||||
}
|
||||
nDN := ldapdn.Normalize(dn)
|
||||
|
||||
if !strings.HasSuffix(nDN, bdb.base) {
|
||||
return fmt.Errorf("'%s' is not a descendant of '%s'", e.DN, bdb.base)
|
||||
}
|
||||
|
||||
nParentDN := ldapdn.Normalize(parentDN)
|
||||
err := bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
id2entry := tx.Bucket([]byte("id2entry"))
|
||||
id := bdb.getIDByDN(tx, nDN)
|
||||
if id != 0 {
|
||||
return ErrEntryAlreadyExists
|
||||
}
|
||||
var err error
|
||||
if id, err = id2entry.NextSequence(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := id2entry.Put(idToBytes(id), buf.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if nDN != bdb.base {
|
||||
if err := bdb.addID2Children(tx, nParentDN, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
dn2id := tx.Bucket([]byte("dn2id"))
|
||||
if err := dn2id.Put([]byte(nDN), idToBytes(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) EntryDelete(dn string) error {
|
||||
parsed, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pparentDN := &ldap.DN{
|
||||
RDNs: parsed.RDNs[1:],
|
||||
}
|
||||
pdn := ldapdn.Normalize(pparentDN)
|
||||
|
||||
ndn := ldapdn.Normalize(parsed)
|
||||
err = bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
// Does this entry even exist?
|
||||
entryID := bdb.getIDByDN(tx, ndn)
|
||||
if entryID == 0 {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
|
||||
// Refuse to delete if the entry has childs
|
||||
id2Children := tx.Bucket([]byte("id2children"))
|
||||
children := id2Children.Get(idToBytes(entryID))
|
||||
if len(children) != 0 {
|
||||
return ErrNonLeafEntry
|
||||
}
|
||||
|
||||
// Update id2children bucket (remove entryid from parent)
|
||||
parentid := bdb.getIDByDN(tx, pdn)
|
||||
if parentid == 0 {
|
||||
return ErrEntryNotFound
|
||||
}
|
||||
children = id2Children.Get(idToBytes(parentid))
|
||||
r := bytes.NewReader(children)
|
||||
var newids []byte
|
||||
idBytes := make([]byte, 8)
|
||||
for _, err = io.ReadFull(r, idBytes); err == nil; _, err = io.ReadFull(r, idBytes) {
|
||||
if entryID != binary.LittleEndian.Uint64(idBytes) {
|
||||
newids = append(newids, idBytes...)
|
||||
}
|
||||
}
|
||||
if err = id2Children.Put(idToBytes(parentid), newids); err != nil {
|
||||
return fmt.Errorf("error updating id2Children index for %d: %w", parentid, err)
|
||||
}
|
||||
|
||||
// Remove entry from dn2id bucket
|
||||
dn2id := tx.Bucket([]byte("dn2id"))
|
||||
err = dn2id.Delete([]byte(ndn))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id2entry := tx.Bucket([]byte("id2entry"))
|
||||
err = id2entry.Delete(idToBytes(entryID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) EntryModify(req *ldap.ModifyRequest) error {
|
||||
ndn, err := ldapdn.ParseNormalize(req.DN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
oldEntry, id, innerErr := bdb.getEntryByDN(tx, ndn)
|
||||
if innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
return bdb.entryModifyWithTxn(tx, id, oldEntry, req)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) entryModifyWithTxn(tx *bolt.Tx, id uint64, entry *ldap.Entry, req *ldap.ModifyRequest) error {
|
||||
newEntry, innerErr := ldapentry.ApplyModify(entry, req)
|
||||
if innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := gob.NewEncoder(&buf)
|
||||
if innerErr := enc.Encode(newEntry); innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
id2entry := tx.Bucket([]byte("id2entry"))
|
||||
if innerErr := id2entry.Put(idToBytes(id), buf.Bytes()); innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) EntryModifyDN(req *ldap.ModifyDNRequest) error {
|
||||
olddn, err := ldap.ParseDN(req.DN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newrdn, err := ldap.ParseDN(req.NewRDN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var newDN ldap.DN
|
||||
|
||||
newDN.RDNs = []*ldap.RelativeDN{newrdn.RDNs[0]}
|
||||
newDN.RDNs = append(newDN.RDNs, olddn.RDNs[1:]...)
|
||||
|
||||
err = bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
flatNewDN := ldapdn.Normalize(&newDN)
|
||||
flatOldDN := ldapdn.Normalize(olddn)
|
||||
|
||||
// error out if there is an entry with the new name already
|
||||
if id := bdb.getIDByDN(tx, flatNewDN); id != 0 {
|
||||
return ErrEntryAlreadyExists
|
||||
}
|
||||
|
||||
entry, id, innerErr := bdb.getEntryByDN(tx, flatOldDN)
|
||||
if innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
|
||||
// only allow renaming leaf entries
|
||||
childIds := bdb.getChildrenIDs(tx, id)
|
||||
if len(childIds) > 0 {
|
||||
return ErrNonLeafEntry
|
||||
}
|
||||
|
||||
entry.DN = flatNewDN
|
||||
|
||||
modReq := ldap.ModifyRequest{
|
||||
DN: entry.DN,
|
||||
}
|
||||
|
||||
// create modify operation for the change attribute values
|
||||
if req.DeleteOldRDN {
|
||||
oldRDN := olddn.RDNs[0]
|
||||
for _, ava := range oldRDN.Attributes {
|
||||
modReq.Delete(ava.Type, []string{ava.Value})
|
||||
}
|
||||
}
|
||||
for _, ava := range newrdn.RDNs[0].Attributes {
|
||||
modReq.Add(ava.Type, []string{ava.Value})
|
||||
}
|
||||
innerErr = bdb.entryModifyWithTxn(tx, id, entry, &modReq)
|
||||
if innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
|
||||
// update the dn2id index
|
||||
dn2id := tx.Bucket([]byte("dn2id"))
|
||||
if err := dn2id.Put([]byte(flatNewDN), idToBytes(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dn2id.Delete([]byte(flatOldDN)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) UpdatePassword(req *ldap.PasswordModifyRequest) error {
|
||||
ndn, err := ldapdn.ParseNormalize(req.UserIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bdb.db.Update(func(tx *bolt.Tx) error {
|
||||
userEntry, id, innerErr := bdb.getEntryByDN(tx, ndn)
|
||||
if innerErr != nil {
|
||||
return innerErr
|
||||
}
|
||||
// Note: the password check we perform here is more or less unneeded.
|
||||
// If the request got here it's either issued by the admin (which does
|
||||
// not need the old password to reset a users password) or a user trying
|
||||
// to update its own password. In which case the password is already verified
|
||||
// as we only allow authenticated users to issue this request. Still, if
|
||||
// the request contains an old password we verify it and error out if it
|
||||
// doesn't match.
|
||||
if req.OldPassword != "" {
|
||||
userPassword := userEntry.GetEqualFoldAttributeValue("userPassword")
|
||||
match, err := ldappassword.Validate(req.OldPassword, userPassword)
|
||||
if err != nil {
|
||||
bdb.logger.Error(err)
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("Failed to validate old Password"))
|
||||
}
|
||||
if !match {
|
||||
bdb.logger.Debug("Old password does not match")
|
||||
return ldap.NewError(ldap.LDAPResultUnwillingToPerform, errors.New("Failed to validate old Password"))
|
||||
}
|
||||
}
|
||||
|
||||
mod := ldap.ModifyRequest{}
|
||||
mod.DN = req.UserIdentity
|
||||
mod.Replace("userPassword", []string{req.NewPassword})
|
||||
innerErr = bdb.entryModifyWithTxn(tx, id, userEntry, &mod)
|
||||
if innerErr != nil {
|
||||
bdb.logger.Debugf("Failed to update password for '%s': '%s'", ndn, err)
|
||||
return ldap.NewError(ldap.LDAPResultOperationsError, errors.New("Failed to update Password"))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) addID2Children(tx *bolt.Tx, nParentDN string, newChildID uint64) error {
|
||||
bdb.logger.Debugf("AddID2Children '%s' id '%d'", nParentDN, newChildID)
|
||||
parentID := bdb.getIDByDN(tx, nParentDN)
|
||||
if parentID == 0 {
|
||||
return fmt.Errorf("parent not found '%s'", nParentDN)
|
||||
}
|
||||
|
||||
bdb.logger.Debugf("Parent ID: %v", parentID)
|
||||
|
||||
id2Children := tx.Bucket([]byte("id2children"))
|
||||
// FIXME add sanity check here if ID is already present
|
||||
children := id2Children.Get(idToBytes(parentID))
|
||||
children = append(children, idToBytes(newChildID)...)
|
||||
if err := id2Children.Put(idToBytes(parentID), children); err != nil {
|
||||
return fmt.Errorf("error updating id2Children index for %d: %w", parentID, err)
|
||||
}
|
||||
|
||||
bdb.logger.Debugf("AddID2Children '%d' id '%v'", parentID, children)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) getIDByDN(tx *bolt.Tx, nDN string) uint64 {
|
||||
dn2id := tx.Bucket([]byte("dn2id"))
|
||||
if dn2id == nil {
|
||||
bdb.logger.Debugf("Bucket 'dn2id' does not exist")
|
||||
return 0
|
||||
}
|
||||
id := dn2id.Get([]byte(nDN))
|
||||
if id == nil {
|
||||
bdb.logger.Debugf("DN: '%s' not found", nDN)
|
||||
return 0
|
||||
}
|
||||
return binary.LittleEndian.Uint64(id)
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) getEntryByID(tx *bolt.Tx, id uint64) (entry *ldap.Entry, err error) {
|
||||
id2entry := tx.Bucket([]byte("id2entry"))
|
||||
entrybytes := id2entry.Get(idToBytes(id))
|
||||
buf := bytes.NewBuffer(entrybytes)
|
||||
dec := gob.NewDecoder(buf)
|
||||
if err := dec.Decode(&entry); err != nil {
|
||||
return nil, fmt.Errorf("error decoding entry id: %d, %w", id, err)
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) getEntryByDN(tx *bolt.Tx, ndn string) (entry *ldap.Entry, id uint64, err error) {
|
||||
id = bdb.getIDByDN(tx, ndn)
|
||||
if id == 0 {
|
||||
return nil, id, ErrEntryNotFound
|
||||
}
|
||||
entry, err = bdb.getEntryByID(tx, id)
|
||||
return entry, id, err
|
||||
}
|
||||
|
||||
func (bdb *LdbBolt) Close() {
|
||||
bdb.db.Close()
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Config bundles server configuration settings.
|
||||
type Config struct {
|
||||
Logger logrus.FieldLogger
|
||||
|
||||
LDAPHandler string
|
||||
|
||||
LDAPListenAddr string
|
||||
LDAPSListenAddr string
|
||||
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
|
||||
LDAPBaseDN string
|
||||
LDAPAdminDN string
|
||||
|
||||
LDAPAllowLocalAnonymousBind bool
|
||||
|
||||
BoltDBFile string
|
||||
|
||||
LDIFMain string
|
||||
LDIFConfig string
|
||||
|
||||
LDIFDefaultCompany string
|
||||
LDIFDefaultMailDomain string
|
||||
LDIFTemplateExtraVars map[string]interface{}
|
||||
|
||||
Metrics prometheus.Registerer
|
||||
|
||||
OnReady func(*Server)
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapdn"
|
||||
"github.com/libregraph/idm/pkg/ldapentry"
|
||||
"github.com/libregraph/idm/pkg/ldappassword"
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/libregraph/idm/pkg/ldbbolt"
|
||||
"github.com/libregraph/idm/server/handler"
|
||||
)
|
||||
|
||||
type boltdbHandler struct {
|
||||
logger logrus.FieldLogger
|
||||
dbfile string
|
||||
baseDN string
|
||||
adminDN string
|
||||
allowLocalAnonymousBind bool
|
||||
ctx context.Context
|
||||
bdb *ldbbolt.LdbBolt
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
BaseDN string
|
||||
AdminDN string
|
||||
|
||||
AllowLocalAnonymousBind bool
|
||||
}
|
||||
|
||||
func NewBoltDBHandler(logger logrus.FieldLogger, fn string, options *Options) (handler.Handler, error) {
|
||||
if fn == "" {
|
||||
return nil, fmt.Errorf("file name is empty")
|
||||
}
|
||||
if options.BaseDN == "" {
|
||||
return nil, fmt.Errorf("base dn is empty")
|
||||
}
|
||||
|
||||
fn, err := filepath.Abs(fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := &boltdbHandler{
|
||||
logger: logger,
|
||||
dbfile: fn,
|
||||
|
||||
allowLocalAnonymousBind: options.AllowLocalAnonymousBind,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
if h.baseDN, err = ldapdn.ParseNormalize(options.BaseDN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.adminDN, err = ldapdn.ParseNormalize(options.AdminDN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = h.setup()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) setup() error {
|
||||
bdb := &ldbbolt.LdbBolt{}
|
||||
|
||||
if err := bdb.Configure(h.logger, h.baseDN, h.dbfile, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := bdb.Initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.bdb = bdb
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Add(boundDN string, req *ldap.AddRequest, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "add",
|
||||
"bind_dn": boundDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
if !h.writeAllowed(boundDN) {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
|
||||
e := ldapentry.EntryFromAddRequest(req)
|
||||
|
||||
if err := h.bdb.EntryPut(e); err != nil {
|
||||
logger.WithError(err).WithField("entrydn", e.DN).Debugln("ldap add failed")
|
||||
if errors.Is(err, ldbbolt.ErrEntryAlreadyExists) {
|
||||
return ldap.LDAPResultEntryAlreadyExists, nil
|
||||
}
|
||||
return ldap.LDAPResultUnwillingToPerform, err
|
||||
}
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "bind",
|
||||
"bind_dn": bindDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
// Handle anoymous bind
|
||||
if bindDN == "" {
|
||||
if !h.allowLocalAnonymousBind {
|
||||
logger.Debugln("ldap anonymous Bind disabled")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
} else if bindSimplePw == "" {
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
}
|
||||
|
||||
bindDN, err := ldapdn.ParseNormalize(bindDN)
|
||||
if err != nil {
|
||||
logger.WithError(err).Debugln("ldap bind request BindDN validation failed")
|
||||
return ldap.LDAPResultInvalidDNSyntax, nil
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(bindDN, h.baseDN) {
|
||||
logger.WithError(err).Debugln("ldap bind request BindDN outside of Database tree")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
// Disallow empty password
|
||||
if bindSimplePw == "" {
|
||||
logger.Debugf("BindDN without password")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
return h.validatePassword(logger, bindDN, bindSimplePw)
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) validatePassword(logger logrus.FieldLogger, bindDN, bindSimplePw string) (ldapserver.LDAPResultCode, error) {
|
||||
// Lookup Bind DN in database
|
||||
entries, err := h.bdb.Search(bindDN, ldap.ScopeBaseObject)
|
||||
if err != nil || len(entries) != 1 {
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
logger.Debugf("Entry '%s' does not exist", bindDN)
|
||||
}
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
userPassword := entries[0].GetEqualFoldAttributeValue("userPassword")
|
||||
match, err := ldappassword.Validate(bindSimplePw, userPassword)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
if match {
|
||||
logger.Debug("success")
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Delete(boundDN string, req *ldap.DelRequest, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "delete",
|
||||
"bind_dn": boundDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
if !h.writeAllowed(boundDN) {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
|
||||
logger.Debug("Calling boltdb delete")
|
||||
if err := h.bdb.EntryDelete(req.DN); err != nil {
|
||||
logger.WithError(err).WithField("entrydn", req.DN).Debugln("ldap delete failed")
|
||||
if errors.Is(err, ldbbolt.ErrEntryAlreadyExists) {
|
||||
return ldap.LDAPResultEntryAlreadyExists, nil
|
||||
}
|
||||
return ldap.LDAPResultUnwillingToPerform, err
|
||||
}
|
||||
logger.Debug("delete succeeded")
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Modify(boundDN string, req *ldap.ModifyRequest, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "modify",
|
||||
"bind_dn": boundDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
"entrydn": req.DN,
|
||||
})
|
||||
|
||||
if !h.writeAllowed(boundDN) {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
|
||||
logger.Debug("Calling boltdb modify")
|
||||
if err := h.bdb.EntryModify(req); err != nil {
|
||||
logger.WithError(err).Debug("ldap modify failed")
|
||||
if errors.Is(err, ldbbolt.ErrEntryAlreadyExists) {
|
||||
return ldap.LDAPResultEntryAlreadyExists, nil
|
||||
}
|
||||
ldapError, ok := err.(*ldap.Error)
|
||||
if !ok {
|
||||
return ldap.LDAPResultUnwillingToPerform, err
|
||||
}
|
||||
return ldapserver.LDAPResultCode(ldapError.ResultCode), ldapError.Err
|
||||
}
|
||||
logger.Debug("modify succeeded")
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) ModifyDN(boundDN string, req *ldap.ModifyDNRequest, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "modifyDN",
|
||||
"bind_dn": boundDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
"entrydn": req.DN,
|
||||
})
|
||||
|
||||
if !h.writeAllowed(boundDN) {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
logger.Debug("Calling boltdb modify DN")
|
||||
if err := h.bdb.EntryModifyDN(req); err != nil {
|
||||
logger.WithError(err).Debug("ldap modifyDN failed")
|
||||
if errors.Is(err, ldbbolt.ErrEntryAlreadyExists) {
|
||||
return ldap.LDAPResultEntryAlreadyExists, nil
|
||||
}
|
||||
ldapError, ok := err.(*ldap.Error)
|
||||
if !ok {
|
||||
return ldap.LDAPResultUnwillingToPerform, err
|
||||
}
|
||||
return ldapserver.LDAPResultCode(ldapError.ResultCode), ldapError.Err
|
||||
}
|
||||
logger.Debug("modify DN succeeded")
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) ModifyPasswordExop(boundDN string, req *ldap.PasswordModifyRequest, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "modpw_exop",
|
||||
"binddn": boundDN,
|
||||
"UserIdentity": req.UserIdentity,
|
||||
"OldPWPresent": req.OldPassword != "",
|
||||
"NewPwPresent": req.NewPassword != "",
|
||||
})
|
||||
|
||||
if boundDN == "" {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
|
||||
if req.UserIdentity != "" {
|
||||
pwUserDN, err := ldapdn.ParseNormalize(req.UserIdentity)
|
||||
if err != nil {
|
||||
return ldap.LDAPResultOperationsError, errors.New("Error parsing DN in password modify extended operation.")
|
||||
}
|
||||
if boundDN != pwUserDN && boundDN != h.adminDN {
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Calling boltdb UpdatePassword")
|
||||
err := h.bdb.UpdatePassword(req)
|
||||
if err != nil {
|
||||
logger.Debugf("boltdb UpdatePassword returned '%s'", err)
|
||||
return ldap.LDAPResultOther, err
|
||||
}
|
||||
return ldap.LDAPResultSuccess, err
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Search(boundDN string, req *ldap.SearchRequest, conn net.Conn) (ldapserver.ServerSearchResult, error) {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"op": "search",
|
||||
"binddn": boundDN,
|
||||
"basedn": req.BaseDN,
|
||||
"filter": req.Filter,
|
||||
"attrs": req.Attributes,
|
||||
})
|
||||
|
||||
logger.Debug("Calling boltdb search")
|
||||
entries, _ := h.bdb.Search(req.BaseDN, req.Scope)
|
||||
logger.Debugf("boltdb search returned %d entries", len(entries))
|
||||
|
||||
return ldapserver.ServerSearchResult{
|
||||
Entries: entries,
|
||||
Referrals: []string{},
|
||||
Controls: []ldap.Control{},
|
||||
ResultCode: ldap.LDAPResultSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Close(boundDN string, conn net.Conn) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) WithContext(ctx context.Context) handler.Handler {
|
||||
if ctx == nil {
|
||||
panic("nil context")
|
||||
}
|
||||
|
||||
h2 := new(boltdbHandler)
|
||||
*h2 = *h
|
||||
h2.ctx = ctx
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) Reload(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *boltdbHandler) writeAllowed(boundDN string) bool {
|
||||
if h.adminDN != "" && h.adminDN == boundDN {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
)
|
||||
|
||||
// Interface for handlers.
|
||||
type Handler interface {
|
||||
ldapserver.Adder
|
||||
ldapserver.Binder
|
||||
ldapserver.Deleter
|
||||
ldapserver.Modifier
|
||||
ldapserver.PasswordUpdater
|
||||
ldapserver.Renamer
|
||||
ldapserver.Searcher
|
||||
ldapserver.Closer
|
||||
|
||||
WithContext(context.Context) Handler
|
||||
Reload(context.Context) error
|
||||
}
|
||||
|
||||
// Interface for middlewares.
|
||||
type Middleware interface {
|
||||
WithHandler(next Handler) Handler
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldappassword"
|
||||
)
|
||||
|
||||
type ldifEntry struct {
|
||||
*ldap.Entry
|
||||
|
||||
UserPassword *ldap.EntryAttribute
|
||||
}
|
||||
|
||||
func (entry *ldifEntry) validatePassword(bindSimplePw string) error {
|
||||
match, err := ldappassword.Validate(bindSimplePw, entry.UserPassword.Values[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !match {
|
||||
return fmt.Errorf("password mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-asn1-ber/asn1-ber"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
)
|
||||
|
||||
func parseFilterToIndexFilter(filter string) ([][]string, error) {
|
||||
f, err := ldapserver.CompileFilter(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result [][]string
|
||||
matches, err := parseFilterMatchLeavesForIndex(f, nil, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse filter for index failed: %w", err)
|
||||
} else {
|
||||
withObjectClass := true
|
||||
if len(matches) > 1 {
|
||||
// NOTE(longsleep): In case of more than one filter, we remove the
|
||||
// index lookup for objectClass since pretty much everything has
|
||||
// an object class anyways and our index lookup does not support
|
||||
// "and" lookups. Thus this gains a lot of efficiency.
|
||||
for _, f := range matches {
|
||||
if !strings.EqualFold("objectClass", f[1]) {
|
||||
withObjectClass = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range matches {
|
||||
if !withObjectClass && strings.EqualFold("objectClass", f[1]) {
|
||||
continue
|
||||
}
|
||||
result = append(result, f[1:])
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func parseFilterMatchLeavesForIndex(f *ber.Packet, parent [][]string, level string) ([][]string, error) {
|
||||
var err error
|
||||
|
||||
if parent == nil {
|
||||
parent = make([][]string, 0)
|
||||
}
|
||||
|
||||
switch f.Tag {
|
||||
case ldapserver.FilterEqualityMatch:
|
||||
if len(f.Children) != 2 {
|
||||
return nil, errors.New("unsupported number of children in equality match filter")
|
||||
}
|
||||
attribute := f.Children[0].Value.(string)
|
||||
value := f.Children[1].Value.(string)
|
||||
parent = append(parent, []string{level, attribute, "eq", value})
|
||||
|
||||
case ldapserver.FilterPresent:
|
||||
if len(f.Children) != 0 {
|
||||
return nil, errors.New("unsupported number of children in presence match filter")
|
||||
}
|
||||
attribute := f.Data.String()
|
||||
parent = append(parent, []string{level, attribute, "pres", ""})
|
||||
|
||||
case ldapserver.FilterSubstrings:
|
||||
if len(f.Children) != 2 {
|
||||
return nil, errors.New("unsupported number of children in substrings filter")
|
||||
}
|
||||
attribute := f.Children[0].Value.(string)
|
||||
if len(f.Children[1].Children) != 1 {
|
||||
return nil, errors.New("unsupported number of children in substrings filter")
|
||||
}
|
||||
value := f.Children[1].Children[0].Value.(string)
|
||||
switch f.Children[1].Children[0].Tag {
|
||||
case ldapserver.FilterSubstringsInitial, ldapserver.FilterSubstringsAny, ldapserver.FilterSubstringsFinal:
|
||||
parent = append(parent, []string{level, attribute, "sub", value, strconv.FormatInt(int64(f.Children[1].Children[0].Tag), 10)})
|
||||
}
|
||||
|
||||
case ldapserver.FilterAnd:
|
||||
for idx, child := range f.Children {
|
||||
parent, err = parseFilterMatchLeavesForIndex(child, parent, fmt.Sprintf("%s.&%d", level, idx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
case ldapserver.FilterOr:
|
||||
for idx, child := range f.Children {
|
||||
parent, err = parseFilterMatchLeavesForIndex(child, parent, fmt.Sprintf("%s.|%d", level, idx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
case ldapserver.FilterNot:
|
||||
// Ignored for now.
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
return parent, nil
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/go-ldap/ldif"
|
||||
"github.com/longsleep/rndm"
|
||||
cmap "github.com/orcaman/concurrent-map"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapdn"
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/libregraph/idm/server/handler"
|
||||
)
|
||||
|
||||
type ldifHandler struct {
|
||||
logger logrus.FieldLogger
|
||||
fn string
|
||||
options *Options
|
||||
|
||||
baseDN string
|
||||
adminDN string
|
||||
allowLocalAnonymousBind bool
|
||||
|
||||
ctx context.Context
|
||||
|
||||
current atomic.Value
|
||||
|
||||
activeSearchPagings cmap.ConcurrentMap
|
||||
}
|
||||
|
||||
var _ handler.Handler = (*ldifHandler)(nil) // Verify that *ldifHandler implements handler.Handler.
|
||||
|
||||
func NewLDIFHandler(logger logrus.FieldLogger, fn string, options *Options) (handler.Handler, error) {
|
||||
if fn == "" {
|
||||
return nil, fmt.Errorf("file name is empty")
|
||||
}
|
||||
if options.BaseDN == "" {
|
||||
return nil, fmt.Errorf("base dn is empty")
|
||||
}
|
||||
|
||||
fn, err := filepath.Abs(fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger = logger.WithField("fn", fn)
|
||||
|
||||
h := &ldifHandler{
|
||||
logger: logger,
|
||||
fn: fn,
|
||||
options: options,
|
||||
|
||||
allowLocalAnonymousBind: options.AllowLocalAnonymousBind,
|
||||
|
||||
ctx: context.Background(),
|
||||
|
||||
activeSearchPagings: cmap.New(),
|
||||
}
|
||||
if h.baseDN, err = ldapdn.ParseNormalize(options.BaseDN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.adminDN, err = ldapdn.ParseNormalize(options.AdminDN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = h.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *ldifHandler) open() error {
|
||||
if !strings.EqualFold(h.options.BaseDN, h.baseDN) {
|
||||
return fmt.Errorf("mismatched BaseDN")
|
||||
}
|
||||
|
||||
info, err := os.Stat(h.fn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open LDIF: %w", err)
|
||||
}
|
||||
|
||||
var l *ldif.LDIF
|
||||
index := newIndexMapRegister()
|
||||
|
||||
if info.IsDir() {
|
||||
h.logger.Debugln("loading LDIF files from folder")
|
||||
h.options.templateBasePath = h.fn
|
||||
var parseErrors []error
|
||||
l, parseErrors, err = parseLDIFDirectory(h.fn, h.options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(parseErrors) > 0 {
|
||||
for _, parseErr := range parseErrors {
|
||||
h.logger.WithError(parseErr).Errorln("LDIF error")
|
||||
}
|
||||
return fmt.Errorf("error in LDIF files")
|
||||
}
|
||||
} else {
|
||||
h.logger.Debugln("loading LDIF")
|
||||
h.options.templateBasePath = filepath.Dir(h.fn)
|
||||
l, err = parseLDIFFile(h.fn, h.options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
t, err := treeFromLDIF(l, index, h.options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store parsed data as memory value.
|
||||
value := &ldifMemoryValue{
|
||||
l: l,
|
||||
t: t,
|
||||
|
||||
index: index,
|
||||
}
|
||||
h.current.Store(value)
|
||||
|
||||
h.logger.WithFields(logrus.Fields{
|
||||
"version": l.Version,
|
||||
"entries_count": len(l.Entries),
|
||||
"tree_length": t.Len(),
|
||||
"base_dn": h.options.BaseDN,
|
||||
"indexes": len(index),
|
||||
}).Debugln("loaded LDIF")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ldifHandler) load() *ldifMemoryValue {
|
||||
value := h.current.Load()
|
||||
return value.(*ldifMemoryValue)
|
||||
}
|
||||
|
||||
func (h *ldifHandler) WithContext(ctx context.Context) handler.Handler {
|
||||
if ctx == nil {
|
||||
panic("nil context")
|
||||
}
|
||||
|
||||
h2 := new(ldifHandler)
|
||||
*h2 = *h
|
||||
h2.ctx = ctx
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Reload(ctx context.Context) error {
|
||||
return h.open()
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Add(_ string, _ *ldap.AddRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Bind(bindDN, bindSimplePw string, conn net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
bindDN = strings.ToLower(bindDN)
|
||||
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"bind_dn": bindDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
if err := h.validateBindDN(bindDN, conn); err != nil {
|
||||
logger.WithError(err).Debugln("ldap bind request BindDN validation failed")
|
||||
return ldap.LDAPResultInsufficientAccessRights, nil
|
||||
}
|
||||
|
||||
if bindSimplePw == "" {
|
||||
logger.Debugf("ldap anonymous bind request")
|
||||
if bindDN == "" {
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
} else {
|
||||
return ldap.LDAPResultUnwillingToPerform, nil
|
||||
}
|
||||
} else {
|
||||
logger.Debugf("ldap bind request")
|
||||
}
|
||||
|
||||
current := h.load()
|
||||
|
||||
entryRecord, found := current.t.Get([]byte(bindDN))
|
||||
if !found {
|
||||
err := fmt.Errorf("user not found")
|
||||
logger.WithError(err).Debugf("ldap bind error")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
entry := entryRecord.(*ldifEntry)
|
||||
|
||||
if err := entry.validatePassword(bindSimplePw); err != nil {
|
||||
logger.WithError(err).Debugf("ldap bind credentials error")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Delete(_ string, _ *ldap.DelRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Modify(_ string, _ *ldap.ModifyRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifHandler) ModifyDN(_ string, _ *ldap.ModifyDNRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifHandler) ModifyPasswordExop(_ string, _ *ldap.PasswordModifyRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Search(bindDN string, searchReq *ldap.SearchRequest, conn net.Conn) (ldapserver.ServerSearchResult, error) {
|
||||
bindDN = strings.ToLower(bindDN)
|
||||
searchBaseDN := strings.ToLower(searchReq.BaseDN)
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"bind_dn": bindDN,
|
||||
"search_base_dn": searchBaseDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
"controls": searchReq.Controls,
|
||||
"size_limit": searchReq.SizeLimit,
|
||||
})
|
||||
|
||||
logger.Debugf("ldap search request for %s", searchReq.Filter)
|
||||
|
||||
if err := h.validateBindDN(bindDN, conn); err != nil {
|
||||
logger.WithError(err).Debugln("ldap search request BindDN validation failed")
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: ldap.LDAPResultInsufficientAccessRights,
|
||||
}, err
|
||||
}
|
||||
|
||||
indexFilter, _ := parseFilterToIndexFilter(searchReq.Filter)
|
||||
|
||||
if !strings.HasSuffix(searchBaseDN, h.baseDN) {
|
||||
err := fmt.Errorf("ldap search BaseDN is not in our BaseDN %s", h.baseDN)
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: ldap.LDAPResultInsufficientAccessRights,
|
||||
}, err
|
||||
}
|
||||
|
||||
doneControls := []ldap.Control{}
|
||||
var pagingControl *ldap.ControlPaging
|
||||
var pagingCookie []byte
|
||||
if paging := ldap.FindControl(searchReq.Controls, ldap.ControlTypePaging); paging != nil {
|
||||
pagingControl = paging.(*ldap.ControlPaging)
|
||||
if searchReq.SizeLimit > 0 && pagingControl.PagingSize >= uint32(searchReq.SizeLimit) {
|
||||
pagingControl = nil
|
||||
} else {
|
||||
pagingCookie = pagingControl.Cookie
|
||||
}
|
||||
}
|
||||
|
||||
pumpCh, resultCode := func() (<-chan *ldifEntry, ldapserver.LDAPResultCode) {
|
||||
var pumpCh chan *ldifEntry
|
||||
var start = true
|
||||
if pagingControl != nil {
|
||||
if len(pagingCookie) == 0 {
|
||||
pagingCookie = []byte(base64.RawStdEncoding.EncodeToString(rndm.GenerateRandomBytes(8)))
|
||||
pagingControl.Cookie = pagingCookie
|
||||
pumpCh = make(chan *ldifEntry)
|
||||
h.activeSearchPagings.Set(string(pagingControl.Cookie), pumpCh)
|
||||
logger.WithField("paging_cookie", string(pagingControl.Cookie)).Debugln("ldap search paging pump start")
|
||||
} else {
|
||||
pumpChRecord, ok := h.activeSearchPagings.Get(string(pagingControl.Cookie))
|
||||
if !ok {
|
||||
return nil, ldap.LDAPResultUnwillingToPerform
|
||||
}
|
||||
if pagingControl.PagingSize > 0 {
|
||||
logger.WithField("paging_cookie", string(pagingControl.Cookie)).Debugln("ldap search paging pump continue")
|
||||
pumpCh = pumpChRecord.(chan *ldifEntry)
|
||||
start = false
|
||||
} else {
|
||||
// No paging size with cookie, means abandon.
|
||||
start = false
|
||||
logger.WithField("paging_cookie", string(pagingControl.Cookie)).Debugln("search paging pump abandon")
|
||||
// TODO(longsleep): Cancel paging pump context.
|
||||
h.activeSearchPagings.Remove(string(pagingControl.Cookie))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pumpCh = make(chan *ldifEntry)
|
||||
}
|
||||
if start {
|
||||
current := h.load()
|
||||
go h.searchEntriesPump(h.ctx, current, pumpCh, searchReq, pagingControl, indexFilter)
|
||||
}
|
||||
|
||||
return pumpCh, ldap.LDAPResultSuccess
|
||||
}()
|
||||
if resultCode != ldap.LDAPResultSuccess {
|
||||
err := fmt.Errorf("search unable to perform: %d", resultCode)
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: resultCode,
|
||||
}, err
|
||||
}
|
||||
|
||||
filterPacket, err := ldapserver.CompileFilter(searchReq.Filter)
|
||||
if err != nil {
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: ldap.LDAPResultOperationsError,
|
||||
}, err
|
||||
}
|
||||
|
||||
var entryRecord *ldifEntry
|
||||
var entries []*ldap.Entry
|
||||
var entry *ldap.Entry
|
||||
var count uint32
|
||||
var keep bool
|
||||
results:
|
||||
for {
|
||||
select {
|
||||
case entryRecord = <-pumpCh:
|
||||
if entryRecord == nil {
|
||||
// All done, set cookie to empty.
|
||||
pagingCookie = []byte{}
|
||||
break results
|
||||
|
||||
} else {
|
||||
entry = entryRecord.Entry
|
||||
|
||||
// Apply filter.
|
||||
keep, resultCode = ldapserver.ServerApplyFilter(filterPacket, entry)
|
||||
if resultCode != ldap.LDAPResultSuccess {
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: resultCode,
|
||||
}, errors.New("search filter apply error")
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter scope.
|
||||
keep, resultCode = ldapserver.ServerFilterScope(searchReq.BaseDN, searchReq.Scope, entry)
|
||||
if resultCode != ldap.LDAPResultSuccess {
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: resultCode,
|
||||
}, errors.New("search scope apply error")
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
|
||||
// Make a copy, before filtering attributes.
|
||||
e := &ldap.Entry{
|
||||
DN: entry.DN,
|
||||
Attributes: make([]*ldap.EntryAttribute, len(entry.Attributes)),
|
||||
}
|
||||
copy(e.Attributes, entry.Attributes)
|
||||
|
||||
// Filter attributes from entry.
|
||||
resultCode, err = ldapserver.ServerFilterAttributes(searchReq.Attributes, e)
|
||||
if err != nil {
|
||||
return ldapserver.ServerSearchResult{
|
||||
ResultCode: resultCode,
|
||||
}, err
|
||||
}
|
||||
|
||||
// Append entry as result.
|
||||
entries = append(entries, e)
|
||||
|
||||
// Count and more.
|
||||
count++
|
||||
if pagingControl != nil {
|
||||
if count >= pagingControl.PagingSize {
|
||||
break results
|
||||
}
|
||||
}
|
||||
if searchReq.SizeLimit > 0 && count >= uint32(searchReq.SizeLimit) {
|
||||
// TODO(longsleep): handle total sizelimit for paging.
|
||||
break results
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pagingControl != nil {
|
||||
doneControls = append(doneControls, &ldap.ControlPaging{
|
||||
PagingSize: 0,
|
||||
Cookie: pagingCookie,
|
||||
})
|
||||
}
|
||||
|
||||
return ldapserver.ServerSearchResult{
|
||||
Entries: entries,
|
||||
Referrals: []string{},
|
||||
Controls: doneControls,
|
||||
ResultCode: ldap.LDAPResultSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *ldifHandler) searchEntriesPump(ctx context.Context, current *ldifMemoryValue, pumpCh chan<- *ldifEntry, searchReq *ldap.SearchRequest, pagingControl *ldap.ControlPaging, indexFilter [][]string) {
|
||||
defer func() {
|
||||
if pagingControl != nil {
|
||||
h.activeSearchPagings.Remove(string(pagingControl.Cookie))
|
||||
close(pumpCh)
|
||||
h.logger.WithField("paging_cookie", string(pagingControl.Cookie)).Debugln("ldap search paging pump ended")
|
||||
} else {
|
||||
close(pumpCh)
|
||||
}
|
||||
}()
|
||||
|
||||
pump := func(entryRecord *ldifEntry) bool {
|
||||
select {
|
||||
case pumpCh <- entryRecord:
|
||||
case <-ctx.Done():
|
||||
if pagingControl != nil {
|
||||
h.logger.WithField("paging_cookie", string(pagingControl.Cookie)).Warnln("ldap search paging pump context done")
|
||||
} else {
|
||||
h.logger.Warnln("ldap search pump context done")
|
||||
}
|
||||
return false
|
||||
case <-time.After(1 * time.Minute):
|
||||
if pagingControl != nil {
|
||||
h.logger.WithField("paging_cookie", string(pagingControl.Cookie)).Warnln("ldap search paging pump timeout")
|
||||
} else {
|
||||
h.logger.Warnln("ldap search pump timeout")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
searchBaseDN := strings.ToLower(searchReq.BaseDN)
|
||||
|
||||
load := true
|
||||
if len(indexFilter) > 0 {
|
||||
// Get entries with help of index.
|
||||
load = false
|
||||
var results []*[]*ldifEntry
|
||||
for _, f := range indexFilter {
|
||||
indexed, found := current.index.Load(f[0], f[1], f[2:]...)
|
||||
if !found {
|
||||
load = true
|
||||
break
|
||||
}
|
||||
results = append(results, &indexed)
|
||||
}
|
||||
if !load {
|
||||
cache := make(map[*ldifEntry]struct{})
|
||||
for _, indexed := range results {
|
||||
for _, entryRecord := range *indexed {
|
||||
if _, cached := cache[entryRecord]; cached {
|
||||
// Prevent duplicates.
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(entryRecord.DN, searchBaseDN) {
|
||||
if ok := pump(entryRecord); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
cache[entryRecord] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if load {
|
||||
// Walk through all entries (this is slow).
|
||||
h.logger.WithField("filter", searchReq.Filter).Warnln("ldap search filter does not match any index, using slow walk")
|
||||
current.t.WalkSuffix([]byte(searchBaseDN), func(key []byte, entryRecord interface{}) bool {
|
||||
if ok := pump(entryRecord.(*ldifEntry)); !ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ldifHandler) validateBindDN(bindDN string, conn net.Conn) error {
|
||||
if bindDN == "" {
|
||||
if h.allowLocalAnonymousBind {
|
||||
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
||||
if net.ParseIP(host).IsLoopback() {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("anonymous BindDN rejected")
|
||||
}
|
||||
return fmt.Errorf("anonymous BindDN not allowed")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(bindDN, h.baseDN) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("the BindDN is not in our BaseDN: %s", h.baseDN)
|
||||
}
|
||||
|
||||
func (h *ldifHandler) Close(bindDN string, conn net.Conn) error {
|
||||
h.logger.WithFields(logrus.Fields{
|
||||
"bind_dn": bindDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
}).Debugln("ldap close")
|
||||
|
||||
return nil
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/armon/go-radix"
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/spacewander/go-suffix-tree"
|
||||
)
|
||||
|
||||
var indexAttributes = map[string]string{
|
||||
"entryCSN": "eq",
|
||||
"entryUUID": "eq",
|
||||
"objectClass": "eq",
|
||||
"cn": "pres,eq,sub",
|
||||
"gidNumber": "eq",
|
||||
"mail": "pres,eq,sub",
|
||||
"memberUid": "eq",
|
||||
"ou": "eq",
|
||||
"uid": "eq",
|
||||
"uidNumber": "eq",
|
||||
"uniqueMember": "eq",
|
||||
|
||||
"sn": "pres,eq,sub",
|
||||
"givenName": "pres,eq,sub",
|
||||
|
||||
"mailAlternateAddress": "eq",
|
||||
|
||||
// Additional indexes for attributes usually used with AD.
|
||||
"objectGUID": "eq",
|
||||
"objectSID": "eq",
|
||||
"otherMailbox": "eq",
|
||||
"samAccountName": "eq",
|
||||
}
|
||||
|
||||
func AddIndexAttribute(name string, indices string) {
|
||||
indexAttributes[name] = indices
|
||||
}
|
||||
|
||||
func RemoveIndexAttribute(name string) {
|
||||
delete(indexAttributes, name)
|
||||
}
|
||||
|
||||
type Index interface {
|
||||
Add(name, op string, values []string, entry *ldifEntry) bool
|
||||
Load(name, op string, params ...string) ([]*ldifEntry, bool)
|
||||
}
|
||||
|
||||
type indexMap map[string][]*ldifEntry
|
||||
|
||||
func newIndexMap() indexMap {
|
||||
return make(indexMap)
|
||||
}
|
||||
|
||||
func (im indexMap) Add(name, op string, values []string, entry *ldifEntry) bool {
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(value)
|
||||
im[value] = append(im[value], entry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (im indexMap) Load(name, op string, value ...string) ([]*ldifEntry, bool) {
|
||||
entries := im[strings.ToLower(value[0])]
|
||||
return entries, true
|
||||
}
|
||||
|
||||
type indexSuffixTree struct {
|
||||
t *suffix.Tree
|
||||
}
|
||||
|
||||
func newIndexSuffixTree() *indexSuffixTree {
|
||||
return &indexSuffixTree{
|
||||
t: suffix.NewTree(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ist indexSuffixTree) Add(name, op string, values []string, entry *ldifEntry) bool {
|
||||
for _, value := range values {
|
||||
sfx := []byte(value)
|
||||
var entries []*ldifEntry
|
||||
if v, ok := ist.t.Get(sfx); ok {
|
||||
entries = v.([]*ldifEntry)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
ist.t.Insert(sfx, entries)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (ist indexSuffixTree) Load(name, op string, value ...string) ([]*ldifEntry, bool) {
|
||||
var entries []*ldifEntry
|
||||
sfx := []byte(value[0])
|
||||
ist.t.WalkSuffix(sfx, func(key []byte, value interface{}) bool {
|
||||
entries = append(entries, value.([]*ldifEntry)...)
|
||||
return false
|
||||
})
|
||||
return entries, true
|
||||
}
|
||||
|
||||
type indexRadixTree struct {
|
||||
t *radix.Tree
|
||||
}
|
||||
|
||||
func newIndexRadixTree() *indexRadixTree {
|
||||
return &indexRadixTree{
|
||||
t: radix.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (irt *indexRadixTree) Add(name, op string, values []string, entry *ldifEntry) bool {
|
||||
for _, value := range values {
|
||||
pfx := value
|
||||
var entries []*ldifEntry
|
||||
if v, ok := irt.t.Get(pfx); ok {
|
||||
entries = v.([]*ldifEntry)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
irt.t.Insert(pfx, entries)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (irt *indexRadixTree) Load(name, op string, value ...string) ([]*ldifEntry, bool) {
|
||||
var entries []*ldifEntry
|
||||
pfx := value[0]
|
||||
irt.t.WalkPrefix(pfx, func(key string, value interface{}) bool {
|
||||
entries = append(entries, value.([]*ldifEntry)...)
|
||||
return false
|
||||
})
|
||||
return entries, true
|
||||
}
|
||||
|
||||
type indexSubTree struct {
|
||||
pres indexMap
|
||||
irt *indexRadixTree
|
||||
ist *indexSuffixTree
|
||||
}
|
||||
|
||||
func newIndexSubTree() *indexSubTree {
|
||||
return &indexSubTree{
|
||||
pres: newIndexMap(),
|
||||
irt: newIndexRadixTree(),
|
||||
ist: newIndexSuffixTree(),
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *indexSubTree) Add(name, op string, values []string, entry *ldifEntry) bool {
|
||||
ok0 := idx.pres.Add(name, op, []string{""}, entry)
|
||||
ok1 := idx.irt.Add(name, op, values, entry)
|
||||
ok2 := idx.ist.Add(name, op, values, entry)
|
||||
return ok0 || ok1 || ok2
|
||||
}
|
||||
|
||||
func (idx *indexSubTree) Load(name, op string, params ...string) ([]*ldifEntry, bool) {
|
||||
if len(params) != 2 {
|
||||
// Require one value and sub tag.
|
||||
return nil, false
|
||||
}
|
||||
tag, err := strconv.ParseInt(params[1], 10, 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
switch tag {
|
||||
case ldapserver.FilterSubstringsAny:
|
||||
// TODO(longsleep): Find a suitable way for full text search substring
|
||||
// matching, for example with Knuth-Morris-Pratt algorithm. Currently
|
||||
// we just do a presence match.
|
||||
return idx.pres.Load(name, op, "")
|
||||
case ldapserver.FilterSubstringsInitial:
|
||||
return idx.irt.Load(name, op, params[0])
|
||||
case ldapserver.FilterSubstringsFinal:
|
||||
return idx.ist.Load(name, op, params[0])
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
type indexMapRegister map[string]Index
|
||||
|
||||
func newIndexMapRegister() indexMapRegister {
|
||||
imr := make(indexMapRegister)
|
||||
for name, ops := range indexAttributes {
|
||||
for _, op := range strings.Split(ops, ",") {
|
||||
switch op {
|
||||
case "sub":
|
||||
imr[imr.getKey(name, op)] = newIndexSubTree()
|
||||
case "pres":
|
||||
imr[imr.getKey(name, op)] = newIndexMap()
|
||||
case "eq":
|
||||
imr[imr.getKey(name, op)] = newIndexMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
return imr
|
||||
}
|
||||
|
||||
func (imr indexMapRegister) getKey(name, op string) string {
|
||||
return strings.ToLower(name) + "," + op
|
||||
}
|
||||
|
||||
func (imr indexMapRegister) Add(name, op string, values []string, entry *ldifEntry) bool {
|
||||
index, ok := imr[imr.getKey(name, op)]
|
||||
if !ok {
|
||||
// No matching index, refuse to add.
|
||||
return false
|
||||
}
|
||||
return index.Add(name, op, values, entry)
|
||||
}
|
||||
|
||||
func (imr indexMapRegister) Load(name, op string, params ...string) ([]*ldifEntry, bool) {
|
||||
index, ok := imr[imr.getKey(name, op)]
|
||||
if !ok {
|
||||
// No such index.
|
||||
return nil, false
|
||||
}
|
||||
return index.Load(name, op, params...)
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/go-ldap/ldif"
|
||||
"github.com/spacewander/go-suffix-tree"
|
||||
)
|
||||
|
||||
// parseLDIFFile opens the named file for reading and parses it as LDIF.
|
||||
func parseLDIFFile(fn string, options *Options) (*ldif.LDIF, error) {
|
||||
f, err := os.Open(fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var r io.Reader
|
||||
|
||||
if options.TemplateEngineDisabled {
|
||||
r = f
|
||||
} else {
|
||||
r, err = parseLDIFTemplate(f, options, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return parseLDIF(r, options)
|
||||
}
|
||||
|
||||
// parseLDIFDirectory opens all ldif files in the given path in sorted order,
|
||||
// cats them all together and parses the result as LDIF.
|
||||
func parseLDIFDirectory(pn string, options *Options) (*ldif.LDIF, []error, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(pn, "*.ldif"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i] < matches[j]
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
var matchErrors []error
|
||||
for _, match := range matches {
|
||||
err = func() error {
|
||||
f, openErr := os.Open(match)
|
||||
if openErr != nil {
|
||||
matchErrors = append(matchErrors, fmt.Errorf("file read error: %w", openErr))
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
if options.TemplateEngineDisabled {
|
||||
_, copyErr := io.Copy(&buf, f)
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("file read error: %w", copyErr)
|
||||
}
|
||||
} else {
|
||||
p, parseErr := parseLDIFTemplate(f, options, nil)
|
||||
if parseErr != nil {
|
||||
matchErrors = append(matchErrors, fmt.Errorf("parse error in %s: %w", match, parseErr))
|
||||
return nil
|
||||
}
|
||||
_, copyErr := io.Copy(&buf, p)
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("template read error: %w", copyErr)
|
||||
}
|
||||
}
|
||||
buf.WriteString("\n\n")
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return nil, matchErrors, err
|
||||
}
|
||||
}
|
||||
|
||||
l, err := parseLDIF(&buf, options)
|
||||
return l, matchErrors, err
|
||||
}
|
||||
|
||||
// parseLDIFTemplate exectues the provided text template and then parses the
|
||||
// result as LDIF.
|
||||
func parseLDIFTemplate(r io.Reader, options *Options, m map[string]interface{}) (io.Reader, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Split(bufio.ScanLines)
|
||||
var text []string
|
||||
for scanner.Scan() {
|
||||
t := scanner.Text()
|
||||
if t != "" && t[0] == '#' {
|
||||
// Ignore commented lines.
|
||||
continue
|
||||
}
|
||||
text = append(text, scanner.Text())
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
m = make(map[string]interface{})
|
||||
}
|
||||
tpl, err := template.New("tpl").Funcs(TemplateFuncs(m, options)).Parse(strings.Join(text, "\n"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse LDIF template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tpl.Execute(&buf, m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process LDIF template: %w", err)
|
||||
}
|
||||
|
||||
if options.TemplateDebug {
|
||||
fmt.Println("---\n", buf.String(), "\n----")
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
func parseLDIF(r io.Reader, options *Options) (*ldif.LDIF, error) {
|
||||
l := &ldif.LDIF{}
|
||||
err := ldif.Unmarshal(r, l)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// treeFromLDIF makes a tree out of the provided LDIF and if index is not nil,
|
||||
// also indexes each entry in the provided index.
|
||||
func treeFromLDIF(l *ldif.LDIF, index Index, options *Options) (*suffix.Tree, error) {
|
||||
t := suffix.NewTree()
|
||||
|
||||
// NOTE(longsleep): Create in memory tree records from LDIF data.
|
||||
var entry *ldap.Entry
|
||||
for _, entryRecord := range l.Entries {
|
||||
if entryRecord == nil || entryRecord.Entry == nil {
|
||||
// NOTE(longsleep): We don't use l.AllEntries as "nil" records can happen.
|
||||
continue
|
||||
}
|
||||
entry = entryRecord.Entry
|
||||
e := &ldifEntry{
|
||||
Entry: &ldap.Entry{
|
||||
DN: strings.ToLower(entry.DN),
|
||||
},
|
||||
}
|
||||
for _, a := range entry.Attributes {
|
||||
switch strings.ToLower(a.Name) {
|
||||
case "userpassword":
|
||||
// Don't include the password in the normal attributes.
|
||||
e.UserPassword = &ldap.EntryAttribute{
|
||||
Name: a.Name,
|
||||
Values: a.Values,
|
||||
}
|
||||
default:
|
||||
// Append it.
|
||||
e.Entry.Attributes = append(e.Entry.Attributes, &ldap.EntryAttribute{
|
||||
Name: a.Name,
|
||||
Values: a.Values,
|
||||
})
|
||||
}
|
||||
if index != nil {
|
||||
// Index equalityMatch.
|
||||
index.Add(a.Name, "eq", a.Values, e)
|
||||
// Index present.
|
||||
index.Add(a.Name, "pres", []string{""}, e)
|
||||
// Index substrings.
|
||||
index.Add(a.Name, "sub", a.Values, e)
|
||||
}
|
||||
}
|
||||
v, ok := t.Insert([]byte(e.DN), e)
|
||||
if !ok || v != nil {
|
||||
return nil, fmt.Errorf("duplicate dn value: %s", e.DN)
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"github.com/go-ldap/ldif"
|
||||
"github.com/spacewander/go-suffix-tree"
|
||||
)
|
||||
|
||||
type ldifMemoryValue struct {
|
||||
l *ldif.LDIF
|
||||
t *suffix.Tree
|
||||
|
||||
index Index
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/libregraph/idm/server/handler"
|
||||
)
|
||||
|
||||
type ldifMiddleware struct {
|
||||
logger logrus.FieldLogger
|
||||
fn string
|
||||
options *Options
|
||||
|
||||
baseDN string
|
||||
|
||||
current atomic.Value
|
||||
|
||||
next handler.Handler
|
||||
}
|
||||
|
||||
var _ handler.Handler = (*ldifMiddleware)(nil) // Verify that *configHandler implements handler.Handler.
|
||||
|
||||
func NewLDIFMiddleware(logger logrus.FieldLogger, fn string, options *Options) (handler.Middleware, error) {
|
||||
if fn == "" {
|
||||
return nil, fmt.Errorf("file name is empty")
|
||||
}
|
||||
if options.BaseDN == "" {
|
||||
return nil, fmt.Errorf("base dn is empty")
|
||||
}
|
||||
|
||||
fn, err := filepath.Abs(fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger = logger.WithField("fn", fn)
|
||||
|
||||
h := &ldifMiddleware{
|
||||
logger: logger,
|
||||
fn: fn,
|
||||
options: options,
|
||||
|
||||
baseDN: strings.ToLower(options.BaseDN),
|
||||
}
|
||||
|
||||
err = h.open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) open() error {
|
||||
if !strings.EqualFold(h.options.BaseDN, h.baseDN) {
|
||||
return fmt.Errorf("mismatched BaseDN")
|
||||
}
|
||||
|
||||
h.logger.Debugln("loading LDIF")
|
||||
l, err := parseLDIFFile(h.fn, h.options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err := treeFromLDIF(l, nil, h.options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store parsed data as memory value.
|
||||
value := &ldifMemoryValue{
|
||||
t: t,
|
||||
}
|
||||
h.current.Store(value)
|
||||
|
||||
h.logger.WithFields(logrus.Fields{
|
||||
"version": l.Version,
|
||||
"entries_count": len(l.Entries),
|
||||
"tree_length": t.Len(),
|
||||
"base_dn": h.options.BaseDN,
|
||||
}).Debugln("loaded LDIF")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) load() *ldifMemoryValue {
|
||||
value := h.current.Load()
|
||||
return value.(*ldifMemoryValue)
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) WithHandler(next handler.Handler) handler.Handler {
|
||||
h.next = next
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) WithContext(ctx context.Context) handler.Handler {
|
||||
if ctx == nil {
|
||||
panic("nil context")
|
||||
}
|
||||
|
||||
h2 := new(ldifMiddleware)
|
||||
*h2 = *h
|
||||
h2.next = h.next.WithContext(ctx)
|
||||
return h2
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Reload(ctx context.Context) error {
|
||||
err := h.open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.next.Reload(ctx)
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Add(_ string, _ *ldap.AddRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Bind(bindDN, bindSimplePw string, conn net.Conn) (resultCode ldapserver.LDAPResultCode, err error) {
|
||||
bindDN = strings.ToLower(bindDN)
|
||||
|
||||
if bindSimplePw == "" { // Empty password means anonymous bind.
|
||||
return h.next.Bind(bindDN, bindSimplePw, conn)
|
||||
}
|
||||
|
||||
current := h.load()
|
||||
|
||||
entryRecord, found := current.t.Get([]byte(bindDN))
|
||||
if found {
|
||||
logger := h.logger.WithFields(logrus.Fields{
|
||||
"bind_dn": bindDN,
|
||||
"remote_addr": conn.RemoteAddr().String(),
|
||||
})
|
||||
|
||||
if !strings.HasSuffix(bindDN, h.baseDN) {
|
||||
err := fmt.Errorf("the BindDN is not in our BaseDN %s", h.baseDN)
|
||||
logger.WithError(err).Infoln("ldap bind error")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
if err := entryRecord.(*ldifEntry).validatePassword(bindSimplePw); err != nil {
|
||||
logger.WithError(err).Infoln("bind error")
|
||||
return ldap.LDAPResultInvalidCredentials, nil
|
||||
}
|
||||
|
||||
return ldap.LDAPResultSuccess, nil
|
||||
}
|
||||
|
||||
return h.next.Bind(bindDN, bindSimplePw, conn)
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Delete(_ string, _ *ldap.DelRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Modify(_ string, _ *ldap.ModifyRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) ModifyDN(_ string, _ *ldap.ModifyDNRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) ModifyPasswordExop(_ string, _ *ldap.PasswordModifyRequest, _ net.Conn) (ldapserver.LDAPResultCode, error) {
|
||||
return ldap.LDAPResultUnwillingToPerform, errors.New("unsupported operation")
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Search(bindDN string, searchReq *ldap.SearchRequest, conn net.Conn) (result ldapserver.ServerSearchResult, err error) {
|
||||
return h.next.Search(bindDN, searchReq, conn)
|
||||
}
|
||||
|
||||
func (h *ldifMiddleware) Close(bindDN string, conn net.Conn) error {
|
||||
return h.next.Close(bindDN, conn)
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
type Options struct {
|
||||
BaseDN string
|
||||
AdminDN string
|
||||
AllowLocalAnonymousBind bool
|
||||
|
||||
DefaultCompany string
|
||||
DefaultMailDomain string
|
||||
|
||||
TemplateExtraVars map[string]interface{}
|
||||
TemplateEngineDisabled bool
|
||||
TemplateDebug bool
|
||||
|
||||
templateBasePath string
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package ldif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/libregraph/idm"
|
||||
)
|
||||
|
||||
const formatAsFileSizeLimit int64 = 1024 * 1024
|
||||
|
||||
func TemplateFuncs(m map[string]interface{}, options *Options) template.FuncMap {
|
||||
defaults := map[string]interface{}{
|
||||
"Company": "Default",
|
||||
"BaseDN": idm.DefaultLDAPBaseDN,
|
||||
"MailDomain": idm.DefaultMailDomain,
|
||||
}
|
||||
for k, v := range defaults {
|
||||
if _, ok := m[k]; !ok {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
if options != nil {
|
||||
if options.BaseDN != "" {
|
||||
m["BaseDN"] = options.BaseDN
|
||||
}
|
||||
if options.DefaultCompany != "" {
|
||||
m["Company"] = options.DefaultCompany
|
||||
}
|
||||
if options.DefaultMailDomain != "" {
|
||||
m["MailDomain"] = options.DefaultMailDomain
|
||||
}
|
||||
for k, v := range options.TemplateExtraVars {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
autoIncrement := uint64(1000)
|
||||
if v, ok := m["AutoIncrementMin"]; ok {
|
||||
autoIncrement = v.(uint64)
|
||||
}
|
||||
|
||||
basePath := options.templateBasePath
|
||||
|
||||
return template.FuncMap{
|
||||
"WithCompany": func(value string) string {
|
||||
m["Company"] = value
|
||||
return ""
|
||||
},
|
||||
"WithBaseDN": func(value string) string {
|
||||
m["BaseDN"] = value
|
||||
return ""
|
||||
},
|
||||
"WithMailDomain": func(value string) string {
|
||||
m["MailDomain"] = value
|
||||
return ""
|
||||
},
|
||||
"AutoIncrement": func(values ...uint64) uint64 {
|
||||
if len(values) > 0 {
|
||||
autoIncrement = values[0]
|
||||
} else {
|
||||
autoIncrement++
|
||||
}
|
||||
return autoIncrement
|
||||
},
|
||||
"formatAsBase64": func(s string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
},
|
||||
"formatAsFileBase64": func(fn string) (string, error) {
|
||||
if basePath == "" {
|
||||
return "", fmt.Errorf("LDIF template fromFile failed, no base path")
|
||||
}
|
||||
fn = filepath.Clean(fn)
|
||||
if !filepath.IsAbs(fn) {
|
||||
fn = filepath.Join(basePath, fn)
|
||||
}
|
||||
fn, err := filepath.Abs(fn)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// NOTE(longsleep): Poor man base path check, should work well enough on Linux.
|
||||
// See https://github.com/golang/go/issues/18358 for details.
|
||||
if !strings.HasPrefix(fn, strings.TrimRight(basePath, "/")+"/") {
|
||||
return "", fmt.Errorf("LDIF template formatAsFile %s outside of %s is not allowed", fn, basePath)
|
||||
}
|
||||
|
||||
f, err := os.Open(fn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LDIF template formatAsFile open failed with error: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := io.LimitReader(f, formatAsFileSizeLimit+1)
|
||||
|
||||
var buf bytes.Buffer
|
||||
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
|
||||
n, err := io.Copy(encoder, reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LDIF template formatAsFile error: %w", err)
|
||||
}
|
||||
if n > formatAsFileSizeLimit {
|
||||
return "", fmt.Errorf("LDIF template formatAsFile size limit exceeded: %s", fn)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const (
|
||||
metricsSubsystemLDAPServer = "ldapserver"
|
||||
)
|
||||
|
||||
// MustRegister registers all rtm metrics with the provided registerer and
|
||||
// panics upon the first registration that causes an error.
|
||||
func MustRegister(reg prometheus.Registerer, cs ...prometheus.Collector) {
|
||||
reg.MustRegister(cs...)
|
||||
}
|
||||
|
||||
type ldapServerCollector struct {
|
||||
stats *ldapserver.Stats
|
||||
|
||||
connsTotalDesc *prometheus.Desc
|
||||
connsCurrentDesc *prometheus.Desc
|
||||
connsMaxDesc *prometheus.Desc
|
||||
|
||||
bindsDesc *prometheus.Desc
|
||||
unbindsDesc *prometheus.Desc
|
||||
searchesDsc *prometheus.Desc
|
||||
}
|
||||
|
||||
func NewLDAPServerCollector(s *ldapserver.Server) prometheus.Collector {
|
||||
return &ldapServerCollector{
|
||||
stats: s.Stats,
|
||||
|
||||
connsTotalDesc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "connections_total"),
|
||||
"Total number of incoming LDAP connections",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
connsCurrentDesc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "connections_current"),
|
||||
"Current number of concurrent established incoming LDAP connections",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
connsMaxDesc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "connections_max"),
|
||||
"Maximum number of concurrent established incoming LDAP connections",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
bindsDesc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "binds_total"),
|
||||
"Total number of incoming LDAP bind requests",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
unbindsDesc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "unbinds_total"),
|
||||
"Total number of incoming LDAP unbind requests",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
searchesDsc: prometheus.NewDesc(
|
||||
prometheus.BuildFQName("", metricsSubsystemLDAPServer, "searches_total"),
|
||||
"Total number of incoming LDAP search requests",
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Describe is implemented with DescribeByCollect. That's possible because the
|
||||
// Collect method will always return the same two metrics with the same two
|
||||
// descriptors.
|
||||
func (lc *ldapServerCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
prometheus.DescribeByCollect(lc, ch)
|
||||
}
|
||||
|
||||
// Collect first gathers the associated managers collectors managers data. Then
|
||||
// it creates constant metrics based on the returned data.
|
||||
func (lc *ldapServerCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
stats := lc.stats.Clone()
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.connsTotalDesc,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.Conns),
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.connsCurrentDesc,
|
||||
prometheus.GaugeValue,
|
||||
float64(stats.ConnsCurrent),
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.connsMaxDesc,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.ConnsMax),
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.bindsDesc,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.Binds),
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.unbindsDesc,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.Unbinds),
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
lc.searchesDsc,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.Searches),
|
||||
)
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
* Copyright 2021 The LibreGraph Authors.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/bombsimon/logrusr/v3"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/idm/pkg/ldapserver"
|
||||
"github.com/libregraph/idm/server/handler"
|
||||
"github.com/libregraph/idm/server/handler/boltdb"
|
||||
"github.com/libregraph/idm/server/handler/ldif"
|
||||
)
|
||||
|
||||
const DefaultGeneratedPasswordLength = 16
|
||||
|
||||
// Server is our server implementation.
|
||||
type Server struct {
|
||||
config *Config
|
||||
|
||||
logger logrus.FieldLogger
|
||||
|
||||
LDAPServer *ldapserver.Server
|
||||
LDAPHandler handler.Handler
|
||||
}
|
||||
|
||||
// NewServer constructs a server from the provided parameters.
|
||||
func NewServer(c *Config) (*Server, error) {
|
||||
s := &Server{
|
||||
config: c,
|
||||
|
||||
logger: c.Logger,
|
||||
}
|
||||
|
||||
s.LDAPServer = ldapserver.NewServer()
|
||||
s.LDAPServer.EnforceLDAP = false
|
||||
s.LDAPServer.GeneratedPasswordLength = DefaultGeneratedPasswordLength
|
||||
ldapserver.Logger(logrusr.New(c.Logger))
|
||||
|
||||
var err error
|
||||
switch c.LDAPHandler {
|
||||
case "ldif":
|
||||
ldifHandlerOptions := &ldif.Options{
|
||||
BaseDN: s.config.LDAPBaseDN,
|
||||
AdminDN: s.config.LDAPAdminDN,
|
||||
AllowLocalAnonymousBind: s.config.LDAPAllowLocalAnonymousBind,
|
||||
|
||||
DefaultCompany: s.config.LDIFDefaultCompany,
|
||||
DefaultMailDomain: s.config.LDIFDefaultMailDomain,
|
||||
TemplateExtraVars: s.config.LDIFTemplateExtraVars,
|
||||
|
||||
TemplateDebug: os.Getenv("KIDM_TEMPLATE_DEBUG") != "",
|
||||
}
|
||||
|
||||
s.LDAPHandler, err = ldif.NewLDIFHandler(s.logger, s.config.LDIFMain, ldifHandlerOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create LDIF source handler: %w", err)
|
||||
}
|
||||
if s.config.LDIFConfig != "" {
|
||||
middleware, middlewareErr := ldif.NewLDIFMiddleware(s.logger, s.config.LDIFConfig, ldifHandlerOptions)
|
||||
if middlewareErr != nil {
|
||||
return nil, fmt.Errorf("failed to create LDIF config handler: %w", middlewareErr)
|
||||
}
|
||||
s.LDAPHandler = middleware.WithHandler(s.LDAPHandler)
|
||||
}
|
||||
case "boltdb":
|
||||
boltOptions := &boltdb.Options{
|
||||
BaseDN: s.config.LDAPBaseDN,
|
||||
AdminDN: s.config.LDAPAdminDN,
|
||||
|
||||
AllowLocalAnonymousBind: s.config.LDAPAllowLocalAnonymousBind,
|
||||
}
|
||||
s.LDAPHandler, err = boltdb.NewBoltDBHandler(s.logger, s.config.BoltDBFile, boltOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create BoltDB handler: %w", err)
|
||||
}
|
||||
|
||||
// FIXME Let the frontend (LDAPServer) handle filtering and attribute list until we added backend support
|
||||
s.LDAPServer.EnforceLDAP = true
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown LDAPHandler: '%s'", c.LDAPHandler)
|
||||
}
|
||||
|
||||
if c.Metrics != nil {
|
||||
s.LDAPServer.SetStats(true)
|
||||
MustRegister(c.Metrics, NewLDAPServerCollector(s.LDAPServer))
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Serve starts all the accociated servers resources and listeners and blocks
|
||||
// forever until signals or error occurs.
|
||||
func (s *Server) Serve(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
serveCtx, serveCtxCancel := context.WithCancel(ctx)
|
||||
defer serveCtxCancel()
|
||||
|
||||
logger := s.logger
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
exitCh := make(chan struct{}, 1)
|
||||
signalCh := make(chan os.Signal, 1)
|
||||
readyCh := make(chan struct{}, 1)
|
||||
triggerCh := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-serveCtx.Done():
|
||||
return
|
||||
case <-readyCh:
|
||||
}
|
||||
logger.WithFields(logrus.Fields{}).Infoln("ready")
|
||||
}()
|
||||
|
||||
var serversWg sync.WaitGroup
|
||||
|
||||
// NOTE(rhafer): since v3.4.3 the ldap package allows to set a custom logger.
|
||||
// Set that to use to our logger.
|
||||
loggerWriter := logger.WithField("scope", "ldap").WriterLevel(logrus.DebugLevel)
|
||||
defer loggerWriter.Close()
|
||||
ldap.Logger(log.New(loggerWriter, "", 0))
|
||||
|
||||
ldapHandler := s.LDAPHandler.WithContext(serveCtx)
|
||||
s.LDAPServer.AddFunc("", ldapHandler)
|
||||
s.LDAPServer.BindFunc("", ldapHandler)
|
||||
s.LDAPServer.DeleteFunc("", ldapHandler)
|
||||
s.LDAPServer.ModifyFunc("", ldapHandler)
|
||||
s.LDAPServer.ModifyDNFunc("", ldapHandler)
|
||||
s.LDAPServer.PasswordExOpFunc("", ldapHandler)
|
||||
s.LDAPServer.SearchFunc("", ldapHandler)
|
||||
s.LDAPServer.CloseFunc("", ldapHandler)
|
||||
|
||||
serversWg.Add(1)
|
||||
go func() {
|
||||
defer serversWg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-triggerCh:
|
||||
reloadErr := ldapHandler.Reload(serveCtx)
|
||||
if reloadErr != nil {
|
||||
logger.Debugln("reload error: %w", reloadErr)
|
||||
} else {
|
||||
logger.Debugln("reload complete")
|
||||
}
|
||||
case <-serveCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if s.config.LDAPListenAddr != "" {
|
||||
serversWg.Add(1)
|
||||
go func() {
|
||||
defer serversWg.Done()
|
||||
logger.WithField("listen_addr", s.config.LDAPListenAddr).Infoln("starting LDAP listener")
|
||||
serveErr := s.LDAPServer.ListenAndServe(s.config.LDAPListenAddr)
|
||||
if serveErr != nil {
|
||||
errCh <- serveErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.config.LDAPSListenAddr != "" {
|
||||
serversWg.Add(1)
|
||||
go func() {
|
||||
defer serversWg.Done()
|
||||
logger.WithField("listen_addr_tls", s.config.LDAPSListenAddr).Infoln("starting LDAPS listener")
|
||||
serveErr := s.LDAPServer.ListenAndServeTLS(s.config.LDAPSListenAddr, s.config.TLSCertFile, s.config.TLSKeyFile)
|
||||
if serveErr != nil {
|
||||
errCh <- serveErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
serversWg.Wait()
|
||||
logger.Debugln("server listeners stopped")
|
||||
close(exitCh)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
close(readyCh) // TODO(longsleep): Implement real ready.
|
||||
if s.config.OnReady != nil {
|
||||
go s.config.OnReady(s)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for exit or error, with support for HUP to reload
|
||||
err = func() error {
|
||||
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
for {
|
||||
select {
|
||||
case errFromChannel := <-errCh:
|
||||
return errFromChannel
|
||||
case reason := <-signalCh:
|
||||
if reason == syscall.SIGHUP {
|
||||
logger.Infoln("reload signal received")
|
||||
select {
|
||||
case triggerCh <- true:
|
||||
default:
|
||||
}
|
||||
continue
|
||||
}
|
||||
logger.WithField("signal", reason).Warnln("received signal")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Shutdown, server will stop to accept new connections, requires Go 1.8+.
|
||||
logger.Infoln("clean server shutdown start")
|
||||
_, shutdownCtxCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
go func() {
|
||||
close(s.LDAPServer.Quit)
|
||||
}()
|
||||
|
||||
// Cancel our own context,
|
||||
serveCtxCancel()
|
||||
func() {
|
||||
for {
|
||||
select {
|
||||
case <-exitCh:
|
||||
logger.Infoln("clean server shutdown complete, exiting")
|
||||
return
|
||||
default:
|
||||
// Services have not quit yet.
|
||||
logger.Info("waiting for services to exit")
|
||||
}
|
||||
select {
|
||||
case reason := <-signalCh:
|
||||
logger.WithField("signal", reason).Warn("received signal")
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
shutdownCtxCancel() // Prevents leak.
|
||||
|
||||
return err
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 2
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# EditorConfig is awesome: http://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
; Python: PEP8 defines 4 spaces for indentation
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
; YAML format, 2 spaces
|
||||
[*.{yaml,yml,yaml.in,json}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
; HTML, CSS and JavaScript, 4 spaces
|
||||
[*.{html,css,js,ts,tsx,jsx}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
.*
|
||||
|
||||
# We don't want to ignore the following even if they are dot-files
|
||||
!.editorconfig
|
||||
!.gitattributes
|
||||
!.gitignore
|
||||
!.gitlab-ci.yml
|
||||
!.env
|
||||
!.chglog
|
||||
!.dependabot.yml
|
||||
!.github
|
||||
|
||||
/vendor
|
||||
/bin
|
||||
/cmd/licod/debug
|
||||
/test/tests.*
|
||||
/test/coverage.*
|
||||
/golint.txt
|
||||
/govet.txt
|
||||
/dist
|
||||
/examples
|
||||
/identifier/node_modules
|
||||
/Caddyfile
|
||||
/3rdparty-LICENSES.md
|
||||
/identifier-registration.yaml
|
||||
/scopes.yaml
|
||||
+1124
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
||||
# Example Caddyfile to use with https://caddyserver.com
|
||||
#
|
||||
# This assumes Konnect is running with identifier on 127.0.0.1:8777. In addition
|
||||
# for development, the identifier is used directly from webpack-dev-server
|
||||
# running on 127.0.0.1:3001. Additional examples are included for third party
|
||||
# login provides which use cookie passthrough backend.
|
||||
|
||||
*:8443 {
|
||||
errors stderr
|
||||
log stdout
|
||||
|
||||
tls self_signed
|
||||
|
||||
# konnect oidc
|
||||
proxy /.well-known/openid-configuration 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/jwks.json 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/token 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/userinfo 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/static 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/session 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/register 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
|
||||
# konnect identifier development via webpack-dev-server
|
||||
proxy /signin/v1/ 127.0.0.1:3001 {
|
||||
header_downstream Cache-Control "no-cache, max-age=0, public"
|
||||
header_downstream Referrer-Policy origin
|
||||
header_downstream Content-Security-Policy "object-src 'none'; script-src 'self'; base-uri 'none'; frame-ancestors 'none';"
|
||||
}
|
||||
proxy /ws 127.0.0.1:3001 {
|
||||
websocket
|
||||
}
|
||||
proxy /static 127.0.0.1:3001
|
||||
proxy /signin/v1/identifier/_/ 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
|
||||
# konnect identifier login area
|
||||
proxy /signin/ 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
|
||||
# third party login area provider example
|
||||
# proxy /provider/simple 127.0.0.1:8999
|
||||
|
||||
# konnect authorize endpoint below third party login area provider
|
||||
#proxy /provider/simple/konnect/v1/authorize 127.0.0.1:8777 {
|
||||
# without /provider/simple
|
||||
# header_upstream X-Forwarded-Prefix /provider/simple
|
||||
# transparent
|
||||
#}
|
||||
|
||||
# konnect cookieserver, start with python3 ./examples/cookieserver.py 8088
|
||||
#proxy /cookieserver/simple-userinfo 127.0.0.1:8088
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Example Caddyfile to use with https://caddyserver.com
|
||||
#
|
||||
# This assumes Konnect is running with identifier on 127.0.0.1:8777.
|
||||
|
||||
*:8443 {
|
||||
errors stderr
|
||||
log stdout
|
||||
|
||||
tls self_signed
|
||||
|
||||
# konnect oidc
|
||||
proxy /.well-known/openid-configuration 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/jwks.json 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/token 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/userinfo 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/static 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/session 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
proxy /konnect/v1/register 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
|
||||
# konnect identifier login area
|
||||
proxy /signin/ 127.0.0.1:8777 {
|
||||
transparent
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#
|
||||
# Copyright 2019 Kopano and its licensors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License, version 3 or
|
||||
# later, as published by the Free Software Foundation.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
FROM golang:1.17.2-buster
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
ARG GOLANGCI_LINT_TAG=v1.23.8
|
||||
RUN curl -sfL \
|
||||
https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
|
||||
sh -s -- -b /usr/local/bin ${GOLANGCI_LINT_TAG}
|
||||
|
||||
RUN GOBIN=/usr/local/bin go get -v \
|
||||
github.com/tebeka/go2xunit \
|
||||
github.com/axw/gocov/... \
|
||||
github.com/AlekSi/gocov-xml \
|
||||
github.com/wadey/gocovmerge \
|
||||
&& go clean -cache && rm -rf /root/go
|
||||
|
||||
ENV DEBIAN_FRONTEND noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
gettext-base \
|
||||
imagemagick \
|
||||
scour \
|
||||
nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
|
||||
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
yarn \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ENV GOCACHE=/tmp/go-build
|
||||
ENV GOPATH=""
|
||||
ENV HOME=/tmp
|
||||
|
||||
CMD ["make", "DATE=reproducible"]
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
PACKAGE = github.com/libregraph/lico
|
||||
PACKAGE_NAME = libregraph-$(shell basename $(PACKAGE))
|
||||
|
||||
# Tools
|
||||
|
||||
GO ?= go
|
||||
GOFMT ?= gofmt
|
||||
GOLINT ?= golangci-lint
|
||||
DLV ?= dlv
|
||||
|
||||
GO2XUNIT ?= go2xunit
|
||||
GOCOV ?= gocov
|
||||
GOCOVXML ?= gocov-xml
|
||||
GOCOVMERGE ?= gocovmerge
|
||||
|
||||
CHGLOG ?= git-chglog
|
||||
|
||||
# Cgo
|
||||
CGO_ENABLED ?= 0
|
||||
|
||||
# Go modules
|
||||
|
||||
GO111MODULE ?= on
|
||||
|
||||
# Variables
|
||||
|
||||
export CGO_ENABLED GO111MODULE
|
||||
unexport GOPATH
|
||||
|
||||
ARGS ?=
|
||||
PWD := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
||||
DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
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)
|
||||
PKGS = $(or $(PKG),$(shell $(GO) list -mod=readonly ./... | grep -v "^$(PACKAGE)/vendor/"))
|
||||
TESTPKGS = $(shell $(GO) list -mod=readonly -f '{{ if or .TestGoFiles .XTestGoFiles }}{{ .ImportPath }}{{ end }}' $(PKGS) 2>/dev/null)
|
||||
CMDS = $(or $(CMD),$(addprefix cmd/,$(notdir $(shell find "$(PWD)/cmd/" -type d))))
|
||||
TIMEOUT = 30
|
||||
|
||||
GOLINT_ARGS ?= --new
|
||||
|
||||
# Debug variables
|
||||
|
||||
DLV_APIVERSION ?= 2
|
||||
DLV_ARGS ?=
|
||||
DLV_EXECUTABLE ?= bin/licod
|
||||
DLV_ATTACH_PID ?= $(shell pgrep -f $(DLV_EXECUTABLE))
|
||||
|
||||
# Build
|
||||
|
||||
LDFLAGS ?= -s -w
|
||||
ASMFLAGS ?=
|
||||
GCFLAGS ?=
|
||||
|
||||
.PHONY: all
|
||||
all: vendor | $(CMDS) identifier-webapp
|
||||
|
||||
.PHONY: commands
|
||||
commands: $(CMDS)
|
||||
|
||||
.PHONY: $(CMDS)
|
||||
$(CMDS): vendor ; $(info building $@ ...) @
|
||||
$(GO) build \
|
||||
-mod vendor \
|
||||
-trimpath \
|
||||
-tags release \
|
||||
-buildmode=exe \
|
||||
-asmflags '$(ASMFLAGS)' \
|
||||
-gcflags '$(GCFLAGS)' \
|
||||
-ldflags '$(LDFLAGS) -buildid=reproducible/$(VERSION) -X $(PACKAGE)/version.Version=$(VERSION) -X $(PACKAGE)/version.BuildDate=$(DATE) -extldflags -static' \
|
||||
-o bin/$(notdir $@) ./$@
|
||||
|
||||
.PHONY: identifier-webapp
|
||||
identifier-webapp:
|
||||
$(MAKE) -C identifier build
|
||||
|
||||
# Helpers
|
||||
|
||||
.PHONY: lint
|
||||
lint: vendor ; $(info running $(GOLINT) ...) @
|
||||
$(GOLINT) run $(GOLINT_ARGS)
|
||||
$(MAKE) -C identifier lint
|
||||
|
||||
.PHONY: lint-checkstyle
|
||||
lint-checkstyle: vendor ; $(info running $(GOLINT) checkstyle ...) @
|
||||
@mkdir -p test
|
||||
$(GOLINT) run $(GOLINT_ARGS) --out-format checkstyle --issues-exit-code 0 > test/tests.lint.xml
|
||||
$(MAKE) -C identifier lint-checkstyle
|
||||
|
||||
.PHONY: fulllint
|
||||
fulllint: GOLINT_ARGS=
|
||||
fulllint: lint
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ; $(info running gofmt ...) @
|
||||
@ret=0 && for d in $$($(GO) list -mod=readonly -f '{{.Dir}}' ./... | grep -v /vendor/); do \
|
||||
$(GOFMT) -l -w $$d/*.go || ret=$$? ; \
|
||||
done ; exit $$ret
|
||||
|
||||
.PHONY: check
|
||||
check: ; $(info checking dependencies ...) @
|
||||
@$(GO) mod verify && echo OK
|
||||
|
||||
# Tests
|
||||
|
||||
TEST_TARGETS := test-default test-bench test-short test-race test-verbose
|
||||
.PHONY: $(TEST_TARGETS)
|
||||
test-bench: ARGS=-run=_Bench* -test.benchmem -bench=.
|
||||
test-short: ARGS=-short
|
||||
test-race: ARGS=-race
|
||||
test-race: CGO_ENABLED=1
|
||||
test-verbose: ARGS=-v
|
||||
$(TEST_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
|
||||
$(TEST_TARGETS): test
|
||||
|
||||
.PHONY: test
|
||||
test: ; $(info running $(NAME:%=% )tests ...) @
|
||||
@CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s $(ARGS) $(TESTPKGS)
|
||||
|
||||
TEST_XML_TARGETS := test-xml-default test-xml-short test-xml-race
|
||||
.PHONY: $(TEST_XML_TARGETS)
|
||||
test-xml-short: ARGS=-short
|
||||
test-xml-race: ARGS=-race
|
||||
test-xml-race: CGO_ENABLED=1
|
||||
$(TEST_XML_TARGETS): NAME=$(MAKECMDGOALS:test-%=%)
|
||||
$(TEST_XML_TARGETS): test-xml
|
||||
|
||||
.PHONY: test-xml
|
||||
test-xml: ; $(info running $(NAME:%=% )tests ...) @
|
||||
@mkdir -p test
|
||||
2>&1 CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s $(ARGS) -v $(TESTPKGS) | tee test/tests.output
|
||||
test -s test/tests.output && $(GO2XUNIT) -fail -input test/tests.output -output test/tests.xml
|
||||
|
||||
COVERAGE_PROFILE = $(COVERAGE_DIR)/profile.out
|
||||
COVERAGE_XML = $(COVERAGE_DIR)/coverage.xml
|
||||
COVERAGE_HTML = $(COVERAGE_DIR)/coverage.html
|
||||
.PHONY: test-coverage
|
||||
test-coverage: COVERAGE_DIR := $(CURDIR)/test/coverage.$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
test-coverage: ; $(info running coverage tests ...)
|
||||
@mkdir -p $(COVERAGE_DIR)/coverage
|
||||
@rm -f test/tests.output
|
||||
@for pkg in $(TESTPKGS); do \
|
||||
CGO_ENABLED=1 $(GO) test -timeout $(TIMEOUT)s -v \
|
||||
-coverpkg=$$($(GO) list -mod=readonly -f '{{ join .Deps "\n" }}' $$pkg | \
|
||||
grep '^$(PACKAGE)/' | grep -v '^$(PACKAGE)/vendor/' | \
|
||||
tr '\n' ',')$$pkg \
|
||||
-covermode=atomic \
|
||||
-coverprofile="$(COVERAGE_DIR)/coverage/`echo $$pkg | tr "/" "-"`.cover" $$pkg | tee -a test/tests.output ;\
|
||||
done
|
||||
@$(GO2XUNIT) -fail -input test/tests.output -output test/tests.xml
|
||||
@$(GOCOVMERGE) $(COVERAGE_DIR)/coverage/*.cover > $(COVERAGE_PROFILE)
|
||||
@$(GO) tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_HTML)
|
||||
@$(GOCOV) convert $(COVERAGE_PROFILE) | $(GOCOVXML) > $(COVERAGE_XML)
|
||||
|
||||
# Debug
|
||||
|
||||
.PHONY: dlv
|
||||
dlv: ; $(info attaching Delve debugger ...)
|
||||
$(DLV) attach --api-version=$(DLV_APIVERSION) $(DLV_ARGS) $(DLV_ATTACH_PID) $(DLV_EXECUTABLE)
|
||||
|
||||
# Mod
|
||||
|
||||
.PHONY: go.sum
|
||||
go.sum: go.mod ; $(info updating dependencies ...)
|
||||
@$(GO) mod tidy -v
|
||||
@touch $@
|
||||
|
||||
.PHONY: vendor
|
||||
vendor: go.mod ; $(info retrieving dependencies ...)
|
||||
@$(GO) mod vendor -v
|
||||
@touch $@
|
||||
|
||||
# Dist
|
||||
|
||||
.PHONY: licenses
|
||||
licenses: vendor ; $(info building licenses files ...)
|
||||
$(CURDIR)/scripts/go-license-ranger.py > $(CURDIR)/3rdparty-LICENSES.md
|
||||
make -s -C identifier licenses >> $(CURDIR)/3rdparty-LICENSES.md
|
||||
|
||||
3rdparty-LICENSES.md: licenses
|
||||
|
||||
.PHONY: dist
|
||||
dist: 3rdparty-LICENSES.md ; $(info building dist tarball ...)
|
||||
@rm -rf "dist/${PACKAGE_NAME}-${VERSION}"
|
||||
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}"
|
||||
@mkdir -p "dist/${PACKAGE_NAME}-${VERSION}/scripts"
|
||||
@cd dist && \
|
||||
cp -avf ../LICENSE.txt "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../README.md "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../3rdparty-LICENSES.md "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../*.yaml.in "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avf ../bin/* "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cp -avr ../identifier/build "${PACKAGE_NAME}-${VERSION}/identifier-webapp" && \
|
||||
cp -avf ../scripts/licod.binscript "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
cp -avf ../scripts/licod.service "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
cp -avf ../scripts/licod.cfg "${PACKAGE_NAME}-${VERSION}/scripts" && \
|
||||
tar --owner=0 --group=0 -czvf ${PACKAGE_NAME}-${VERSION}.tar.gz "${PACKAGE_NAME}-${VERSION}" && \
|
||||
cd ..
|
||||
|
||||
.PHONE: changelog
|
||||
changelog: ; $(info updating changelog ...)
|
||||
$(CHGLOG) --output CHANGELOG.md $(ARGS)
|
||||
|
||||
# Rest
|
||||
|
||||
.PHONY: clean
|
||||
clean: ; $(info cleaning ...) @
|
||||
@rm -rf bin
|
||||
@rm -rf test/test.*
|
||||
@$(MAKE) -C identifier clean
|
||||
|
||||
.PHONY: version
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
LibreGraph Connect
|
||||
Copyright 2017-2021 Kopano and its licensors
|
||||
|
||||
This product includes software developed at
|
||||
Kopano (https://kopano.com/).
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
# LibreGraph Connect
|
||||
|
||||
LibreGraph Connect implements an [OpenID provider](http://openid.net/specs/openid-connect-core-1_0.html)
|
||||
(OP) with integrated web login and consent forms.
|
||||
|
||||
[](https://goreportcard.com/report/github.com/libregraph/lico)
|
||||
|
||||
LibreGraph Connect has it origin in Kopano Konnect and is meant as its vendor
|
||||
agnostic successor.
|
||||
|
||||
## Technologies
|
||||
|
||||
- Go
|
||||
- React
|
||||
|
||||
## Standards supported by Lico
|
||||
|
||||
Lico provides services based on open standards. To get you an idea what
|
||||
Lico can do and how you could use it, this section lists the
|
||||
[OpenID Connect](https://openid.net/connect/) standards which are implemented.
|
||||
|
||||
- https://openid.net/specs/openid-connect-core-1_0.html
|
||||
- https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
- https://openid.net/specs/openid-connect-frontchannel-1_0.html
|
||||
- https://openid.net/specs/openid-connect-session-1_0.html
|
||||
- https://openid.net/specs/openid-connect-registration-1_0.html
|
||||
|
||||
Furthermore the following extensions/base specifications extend, define and
|
||||
combine the implementation details.
|
||||
|
||||
- https://tools.ietf.org/html/rfc6749
|
||||
- https://tools.ietf.org/html/rfc7517
|
||||
- https://tools.ietf.org/html/rfc7519
|
||||
- https://tools.ietf.org/html/rfc7636
|
||||
- https://tools.ietf.org/html/rfc7693
|
||||
- https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
|
||||
- https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html
|
||||
- https://www.iana.org/assignments/jose/jose.xhtml
|
||||
- https://nacl.cr.yp.to/secretbox.html
|
||||
|
||||
## Build dependencies
|
||||
|
||||
Make sure you have Go 1.18 or later installed. This project uses Go Modules.
|
||||
|
||||
Lico also includes a modern web app which requires a couple of additional
|
||||
build dependencies which are furthermore also assumed to be in your $PATH.
|
||||
|
||||
- yarn - [Yarn](https://yarnpkg.com)
|
||||
- convert, identify - [Imagemagick](https://www.imagemagick.org)
|
||||
- scour - [Scour](https://github.com/scour-project/scour)
|
||||
|
||||
To build Lico, a `Makefile` is provided, which requires [make](https://www.gnu.org/software/make/manual/make.html).
|
||||
|
||||
When building, third party dependencies will tried to be fetched from the Internet
|
||||
if not there already.
|
||||
|
||||
## Building from source
|
||||
|
||||
```
|
||||
git clone <THIS-PROJECT> lico
|
||||
cd lico
|
||||
make
|
||||
```
|
||||
|
||||
### Optional build dependencies
|
||||
|
||||
Some optional build dependencies are required for linting and continuous
|
||||
integration. Those tools are mostly used by make to perform various tasks and
|
||||
are expected to be found in your $PATH.
|
||||
|
||||
- golangci-lint - [golangci-lint](https://github.com/golangci/golangci-lint)
|
||||
- go2xunit - [go2xunit](https://github.com/tebeka/go2xunit)
|
||||
- gocov - [gocov](https://github.com/axw/gocov)
|
||||
- gocov-xml - [gocov-xml](https://github.com/AlekSi/gocov-xml)
|
||||
- gocovmerge - [gocovmerge](https://github.com/wadey/gocovmerge)
|
||||
|
||||
### Build with Docker
|
||||
|
||||
```
|
||||
docker build -t licod-builder -f Dockerfile.build .
|
||||
docker run -it --rm -u $(id -u):$(id -g) -v $(pwd):/build licod-builder
|
||||
```
|
||||
|
||||
## Running Lico
|
||||
|
||||
Lico can provide user login based on available backends.
|
||||
|
||||
All backends require certain general parameters to be present. Create a RSA
|
||||
key-pair file with `openssl genpkey -algorithm RSA -out private-key.pem -pkeyopt rsa_keygen_bits:4096`
|
||||
and provide the key file with the `--signing-private-key` parameter. Lico can
|
||||
load PEM encoded PKCS#1 and PKCS#8 key files and JSON Web Keys from `.json` files
|
||||
If you skip this, Lico will create a random non-persistent RSA key on startup.
|
||||
|
||||
To encrypt certain values, Lico needs a secure encryption key. Create a
|
||||
suitable key of 32 bytes with `openssl rand -out encryption.key 32` and provide
|
||||
the full path to that file via the `--encryption-secret` parameter. If you skip
|
||||
this, Lico will generate a random key on startup.
|
||||
|
||||
To run a functional OpenID Connect provider, an issuer identifier is required.
|
||||
The `iss` is a full qualified https:// URI pointing to the web server which
|
||||
serves the requests to Lico (example: https://example.com). Provide the
|
||||
Issuer Identifier with the `--iss` parametter when starting Lico.
|
||||
|
||||
Furthermore to allow clients to utilize the Lico services, clients need to
|
||||
be known/registered. For now Lico uses a static configuration file which
|
||||
allows clients and their allowed urls to be registered. See the the example at
|
||||
`identifier-registration.yaml.in`. Copy and modify that file to include all
|
||||
the clients which should be able to use OpenID Connect and/or OAuth2 and start
|
||||
Lico with the `--identifier-registration-conf` parameter pointing to that
|
||||
file. Without any explicitly registered clients, Lico will only accept clients
|
||||
which redirect to an URI which starts with the value provided with the `--iss`
|
||||
parameter.
|
||||
|
||||
### Lico cryptography and validation
|
||||
|
||||
A tool can be used to create keys for Lico and also to validate tokens to
|
||||
ensure correct operation is [Step CLI](https://github.com/smallstep/cli). This
|
||||
helps since OpenSSL is not able to create or validate all of the different key
|
||||
formats, ciphers and curves which are supported by Lico.
|
||||
|
||||
Here are some examples relevant for Lico.
|
||||
|
||||
```
|
||||
step crypto keypair 1-rsa.pub 1-rsa.pem \
|
||||
--kty RSA --size 4096 --no-password --insecure
|
||||
```
|
||||
|
||||
```
|
||||
step crypto keypair 1-ecdsa-p-256.pub 1-ecdsa-p-256.pem \
|
||||
--kty EC --curve P-256 --no-password --insecure
|
||||
```
|
||||
|
||||
```
|
||||
step crypto jwk create 1-eddsa-ed25519.pub.json 1-eddsa-ed25519.key.json \
|
||||
-kty OKP --crv Ed25519 --no-password --insecure
|
||||
```
|
||||
|
||||
```
|
||||
echo $TOKEN_VALUE | step crypto jwt verify --iss $ISS \
|
||||
--aud playground-trusted.js --jwks $ISS/konnect/v1/jwks.json
|
||||
```
|
||||
|
||||
### URL endpoints
|
||||
|
||||
Take a look at `Caddyfile.example` on the URL endpoints provided by Lico and
|
||||
how to expose them through a TLS proxy.
|
||||
|
||||
The base URL of the frontend proxy is what will become the value of the `--iss`
|
||||
parameter when starting up Lico. OIDC requires the Issuer Identifier to be
|
||||
secure (https:// required).
|
||||
|
||||
### LibreGraph backend
|
||||
|
||||
Generic backend support is available through the LibreGraph API. Any service can
|
||||
provide the required endpoints and Lico connects to them.
|
||||
|
||||
```
|
||||
export LIBREGRAPH_URI=http://your-backend.local:5050
|
||||
bin/licod serve --listen=127.0.0.1:8777 \
|
||||
--iss=https://mylico.local \
|
||||
libregraph
|
||||
```
|
||||
|
||||
### LDAP backend
|
||||
|
||||
This assumes that Lico can directly connect to an LDAP server via TCP.
|
||||
|
||||
```
|
||||
export LDAP_URI=ldap://myldap.local:389
|
||||
export LDAP_BINDDN="cn=admin,dc=example,dc=local"
|
||||
export LDAP_BINDPW="its-a-secret"
|
||||
export LDAP_BASEDN="dc=example,dc=local"
|
||||
export LDAP_SCOPE=sub
|
||||
export LDAP_LOGIN_ATTRIBUTE=uid
|
||||
export LDAP_EMAIL_ATTRIBUTE=mail
|
||||
export LDAP_NAME_ATTRIBUTE=cn
|
||||
export LDAP_UUID_ATTRIBUTE=uidNumber
|
||||
export LDAP_UUID_ATTRIBUTE_TYPE=text
|
||||
export LDAP_FILTER="(objectClass=organizationalPerson)"
|
||||
|
||||
bin/licod serve --listen=127.0.0.1:8777 \
|
||||
--iss=https://mylico.local \
|
||||
ldap
|
||||
```
|
||||
|
||||
### Build Lico Docker image
|
||||
|
||||
This project includes a `Dockerfile` which can be used to build a Docker
|
||||
container from the locally build version. Similarly the `Dockerfile.release`
|
||||
builds the Docker image locally from the latest release download.
|
||||
|
||||
```
|
||||
docker build -t licod .
|
||||
```
|
||||
|
||||
```
|
||||
docker build -f Dockerfile.release -t licod .
|
||||
```
|
||||
|
||||
## Run unit tests
|
||||
|
||||
```
|
||||
make test
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
As Lico includes a web application (identifier), a `Caddyfile.dev` file is
|
||||
provided which exposes the identifier's web application directly via a
|
||||
webpack dev server.
|
||||
|
||||
### Debugging
|
||||
|
||||
Lico is built stripped and without debug symbols by default. To build for
|
||||
debugging, compile with additional environment variables which override/reset
|
||||
build optimization like this
|
||||
|
||||
```
|
||||
LDFLAGS="" GCFLAGS="all=-N -l" ASMFLAGS="" make cmd/licod
|
||||
```
|
||||
|
||||
The resulting binary is not stripped and sutiable to be debugged with [Delve](https://github.com/go-delve/delve).
|
||||
|
||||
To connect Delve to a running Lico binary you can use the `make dlv` command.
|
||||
Control its behavior via `DLV_*` environment variables. See the `Makefile` source
|
||||
for details.
|
||||
|
||||
```
|
||||
DLV_ARGS= make dlv
|
||||
```
|
||||
|
||||
#### Remote debugging
|
||||
|
||||
To use remote debugging, pass additional args like this.
|
||||
|
||||
```
|
||||
DLV_ARGS=--listen=:2345 make dlv
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE.txt` for licensing information of this project.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 bsguest
|
||||
|
||||
import (
|
||||
"github.com/libregraph/lico/bootstrap"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/managers"
|
||||
)
|
||||
|
||||
// Identity managers.
|
||||
const (
|
||||
identityManagerName = "guest"
|
||||
)
|
||||
|
||||
func Register() error {
|
||||
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
|
||||
}
|
||||
|
||||
func MustRegister() {
|
||||
if err := Register(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
|
||||
config := bs.Config()
|
||||
|
||||
logger := config.Config.Logger
|
||||
|
||||
identityManagerConfig := &identity.Config{
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
guestIdentityManager := managers.NewGuestIdentityManager(identityManagerConfig)
|
||||
|
||||
return guestIdentityManager, nil
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 bsldap
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/libregraph/lico/bootstrap"
|
||||
"github.com/libregraph/lico/identifier"
|
||||
"github.com/libregraph/lico/identifier/backends/ldap"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/identity/managers"
|
||||
)
|
||||
|
||||
// Identity managers.
|
||||
const (
|
||||
identityManagerName = "ldap"
|
||||
)
|
||||
|
||||
func Register() error {
|
||||
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
|
||||
}
|
||||
|
||||
func MustRegister() {
|
||||
if err := Register(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
|
||||
config := bs.Config()
|
||||
|
||||
logger := config.Config.Logger
|
||||
|
||||
if config.AuthorizationEndpointURI.String() != "" {
|
||||
return nil, fmt.Errorf("ldap backend is incompatible with authorization-endpoint-uri parameter")
|
||||
}
|
||||
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
|
||||
|
||||
if config.EndSessionEndpointURI.String() != "" {
|
||||
return nil, fmt.Errorf("ldap backend is incompatible with endsession-endpoint-uri parameter")
|
||||
}
|
||||
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
|
||||
|
||||
if config.SignInFormURI.EscapedPath() == "" {
|
||||
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
|
||||
}
|
||||
|
||||
if config.SignedOutURI.EscapedPath() == "" {
|
||||
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
|
||||
}
|
||||
|
||||
// Default LDAP attribute mappings.
|
||||
attributeMapping := map[string]string{
|
||||
ldap.AttributeLogin: os.Getenv("LDAP_LOGIN_ATTRIBUTE"),
|
||||
ldap.AttributeEmail: os.Getenv("LDAP_EMAIL_ATTRIBUTE"),
|
||||
ldap.AttributeName: os.Getenv("LDAP_NAME_ATTRIBUTE"),
|
||||
ldap.AttributeFamilyName: os.Getenv("LDAP_FAMILY_NAME_ATTRIBUTE"),
|
||||
ldap.AttributeGivenName: os.Getenv("LDAP_GIVEN_NAME_ATTRIBUTE"),
|
||||
ldap.AttributeUUID: os.Getenv("LDAP_UUID_ATTRIBUTE"),
|
||||
fmt.Sprintf("%s_type", ldap.AttributeUUID): os.Getenv("LDAP_UUID_ATTRIBUTE_TYPE"),
|
||||
}
|
||||
// Add optional LDAP attribute mappings.
|
||||
if numericUIDAttribute := os.Getenv("LDAP_UIDNUMBER_ATTRIBUTE"); numericUIDAttribute != "" {
|
||||
attributeMapping[ldap.AttributeNumericUID] = numericUIDAttribute
|
||||
}
|
||||
// Sub from LDAP attribute mappings.
|
||||
var subMapping []string
|
||||
if subMappingString := os.Getenv("LDAP_SUB_ATTRIBUTES"); subMappingString != "" {
|
||||
subMapping = strings.Split(subMappingString, " ")
|
||||
}
|
||||
|
||||
// Use a clone here to avoid changing the config of other possible users of the config.
|
||||
tlsConfig := config.TLSClientConfig.Clone()
|
||||
if caCertFile := os.Getenv("LDAP_TLS_CACERT"); caCertFile != "" {
|
||||
if pemBytes, err := ioutil.ReadFile(caCertFile); err == nil {
|
||||
rpool, _ := x509.SystemCertPool()
|
||||
if rpool.AppendCertsFromPEM(pemBytes) {
|
||||
tlsConfig.RootCAs = rpool
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to append CA certificate(s) from '%s' to pool", caCertFile)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to read CA certificate(s) from '%s': %w", caCertFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
identifierBackend, identifierErr := ldap.NewLDAPIdentifierBackend(
|
||||
config.Config,
|
||||
tlsConfig,
|
||||
os.Getenv("LDAP_URI"),
|
||||
os.Getenv("LDAP_BINDDN"),
|
||||
os.Getenv("LDAP_BINDPW"),
|
||||
os.Getenv("LDAP_BASEDN"),
|
||||
os.Getenv("LDAP_SCOPE"),
|
||||
os.Getenv("LDAP_FILTER"),
|
||||
subMapping,
|
||||
attributeMapping,
|
||||
)
|
||||
if identifierErr != nil {
|
||||
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
|
||||
}
|
||||
|
||||
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
|
||||
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
|
||||
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
|
||||
|
||||
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
|
||||
Config: config.Config,
|
||||
|
||||
BaseURI: config.IssuerIdentifierURI,
|
||||
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
|
||||
StaticFolder: config.IdentifierClientPath,
|
||||
ScopesConf: config.IdentifierScopesConf,
|
||||
WebAppDisabled: config.IdentifierClientDisabled,
|
||||
|
||||
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
|
||||
LogonCookieSameSite: config.CookieSameSite,
|
||||
|
||||
ConsentCookieSameSite: config.CookieSameSite,
|
||||
|
||||
StateCookieSameSite: config.CookieSameSite,
|
||||
|
||||
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
|
||||
SignedOutEndpointURI: fullSignedOutEndpointURL,
|
||||
|
||||
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
|
||||
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
|
||||
DefaultSignInPageLogoURI: config.IdentifierDefaultLogoTargetURI,
|
||||
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
|
||||
UILocales: config.IdentifierUILocales,
|
||||
|
||||
Backend: identifierBackend,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create identifier: %v", err)
|
||||
}
|
||||
err = activeIdentifier.SetKey(config.EncryptionSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
|
||||
}
|
||||
|
||||
identityManagerConfig := &identity.Config{
|
||||
SignInFormURI: fullSignInFormURL,
|
||||
SignedOutURI: fullSignedOutEndpointURL,
|
||||
|
||||
Logger: logger,
|
||||
|
||||
ScopesSupported: config.Config.AllowedScopes,
|
||||
}
|
||||
|
||||
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
|
||||
logger.Infoln("using identifier backed identity manager")
|
||||
|
||||
return identifierIdentityManager, nil
|
||||
}
|
||||
Generated
Vendored
+158
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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 bslibregraph
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cevaris/ordered_map"
|
||||
|
||||
"github.com/libregraph/lico/bootstrap"
|
||||
"github.com/libregraph/lico/identifier"
|
||||
"github.com/libregraph/lico/identifier/backends/libregraph"
|
||||
"github.com/libregraph/lico/identity"
|
||||
identityClients "github.com/libregraph/lico/identity/clients"
|
||||
"github.com/libregraph/lico/identity/managers"
|
||||
)
|
||||
|
||||
// Identity managers.
|
||||
const (
|
||||
identityManagerName = "libregraph"
|
||||
)
|
||||
|
||||
func Register() error {
|
||||
return bootstrap.RegisterIdentityManager(identityManagerName, NewIdentityManager)
|
||||
}
|
||||
|
||||
func MustRegister() {
|
||||
if err := Register(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) {
|
||||
config := bs.Config()
|
||||
|
||||
logger := config.Config.Logger
|
||||
|
||||
if config.AuthorizationEndpointURI.String() != "" {
|
||||
return nil, fmt.Errorf("libregraph backend is incompatible with authorization-endpoint-uri parameter")
|
||||
}
|
||||
config.AuthorizationEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/authorize")
|
||||
|
||||
if config.EndSessionEndpointURI.String() != "" {
|
||||
return nil, fmt.Errorf("libregraph backend is incompatible with endsession-endpoint-uri parameter")
|
||||
}
|
||||
config.EndSessionEndpointURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier/_/endsession")
|
||||
|
||||
if config.SignInFormURI.EscapedPath() == "" {
|
||||
config.SignInFormURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/identifier")
|
||||
}
|
||||
|
||||
if config.SignedOutURI.EscapedPath() == "" {
|
||||
config.SignedOutURI.Path = bs.MakeURIPath(bootstrap.APITypeSignin, "/goodbye")
|
||||
}
|
||||
|
||||
defaultURI := os.Getenv("LIBREGRAPH_URI")
|
||||
|
||||
var scopedURIs *ordered_map.OrderedMap
|
||||
if scopedURIsString := os.Getenv("LIBREGRAPH_SCOPED_URIS"); scopedURIsString != "" {
|
||||
scopedURIs = ordered_map.NewOrderedMap()
|
||||
// Format is <scope>:<url>,<scope>:<url>,...
|
||||
for _, v := range strings.Split(scopedURIsString, ",") {
|
||||
parts := strings.SplitN(v, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("failed to parse scoped URIs, format invalid")
|
||||
}
|
||||
scopedURIs.Set(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
var clients *identityClients.Registry
|
||||
if clientsRecord, ok := bs.Managers().Get("clients"); ok {
|
||||
clients = clientsRecord.(*identityClients.Registry)
|
||||
} else {
|
||||
return nil, fmt.Errorf("clients manager not found but is required")
|
||||
}
|
||||
|
||||
identifierBackend, identifierErr := libregraph.NewLibreGraphIdentifierBackend(
|
||||
config.Config,
|
||||
config.TLSClientConfig,
|
||||
defaultURI,
|
||||
scopedURIs,
|
||||
clients,
|
||||
)
|
||||
if identifierErr != nil {
|
||||
return nil, fmt.Errorf("failed to create identifier backend: %v", identifierErr)
|
||||
}
|
||||
|
||||
fullAuthorizationEndpointURL := bootstrap.WithSchemeAndHost(config.AuthorizationEndpointURI, config.IssuerIdentifierURI)
|
||||
fullSignInFormURL := bootstrap.WithSchemeAndHost(config.SignInFormURI, config.IssuerIdentifierURI)
|
||||
fullSignedOutEndpointURL := bootstrap.WithSchemeAndHost(config.SignedOutURI, config.IssuerIdentifierURI)
|
||||
|
||||
activeIdentifier, err := identifier.NewIdentifier(&identifier.Config{
|
||||
Config: config.Config,
|
||||
|
||||
BaseURI: config.IssuerIdentifierURI,
|
||||
PathPrefix: bs.MakeURIPath(bootstrap.APITypeSignin, ""),
|
||||
StaticFolder: config.IdentifierClientPath,
|
||||
ScopesConf: config.IdentifierScopesConf,
|
||||
WebAppDisabled: config.IdentifierClientDisabled,
|
||||
|
||||
LogonCookieName: "__Secure-KKT", // Kopano-Konnect-Token
|
||||
LogonCookieSameSite: config.CookieSameSite,
|
||||
|
||||
ConsentCookieSameSite: config.CookieSameSite,
|
||||
|
||||
StateCookieSameSite: config.CookieSameSite,
|
||||
|
||||
AuthorizationEndpointURI: fullAuthorizationEndpointURL,
|
||||
SignedOutEndpointURI: fullSignedOutEndpointURL,
|
||||
|
||||
DefaultBannerLogo: config.IdentifierDefaultBannerLogo,
|
||||
DefaultSignInPageText: config.IdentifierDefaultSignInPageText,
|
||||
DefaultSignInPageLogoURI: config.IdentifierDefaultLogoTargetURI,
|
||||
DefaultUsernameHintText: config.IdentifierDefaultUsernameHintText,
|
||||
UILocales: config.IdentifierUILocales,
|
||||
|
||||
Backend: identifierBackend,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create identifier: %v", err)
|
||||
}
|
||||
err = activeIdentifier.SetKey(config.EncryptionSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err)
|
||||
}
|
||||
|
||||
identityManagerConfig := &identity.Config{
|
||||
SignInFormURI: fullSignInFormURL,
|
||||
SignedOutURI: fullSignedOutEndpointURL,
|
||||
|
||||
Logger: logger,
|
||||
|
||||
ScopesSupported: config.Config.AllowedScopes,
|
||||
}
|
||||
|
||||
identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier)
|
||||
logger.Infoln("using identifier backed identity manager")
|
||||
|
||||
return identifierIdentityManager, nil
|
||||
}
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* 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 bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/longsleep/rndm"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libregraph/lico/config"
|
||||
"github.com/libregraph/lico/encryption"
|
||||
"github.com/libregraph/lico/identity"
|
||||
"github.com/libregraph/lico/managers"
|
||||
oidcProvider "github.com/libregraph/lico/oidc/provider"
|
||||
"github.com/libregraph/lico/utils"
|
||||
)
|
||||
|
||||
// API types.
|
||||
type APIType string
|
||||
|
||||
const (
|
||||
APITypeKonnect APIType = "konnect"
|
||||
APITypeSignin APIType = "signin"
|
||||
)
|
||||
|
||||
// Defaults.
|
||||
const (
|
||||
DefaultSigningKeyID = "default"
|
||||
DefaultSigningKeyBits = 2048
|
||||
|
||||
DefaultGuestIdentityManagerName = "guest"
|
||||
DefaultCookieSameSite = http.SameSiteNoneMode
|
||||
)
|
||||
|
||||
// Bootstrap is a data structure to hold configuration required to start
|
||||
// konnectd.
|
||||
type Bootstrap interface {
|
||||
Config() *Config
|
||||
Managers() *managers.Managers
|
||||
|
||||
MakeURIPath(api APIType, subpath string) string
|
||||
}
|
||||
|
||||
// Implementation of the bootstrap interface.
|
||||
type bootstrap struct {
|
||||
config *Config
|
||||
|
||||
uriBasePath string
|
||||
|
||||
managers *managers.Managers
|
||||
}
|
||||
|
||||
// Config returns the bootstap configuration.
|
||||
func (bs *bootstrap) Config() *Config {
|
||||
return bs.config
|
||||
}
|
||||
|
||||
// Managers returns bootstrapped identity-managers.
|
||||
func (bs *bootstrap) Managers() *managers.Managers {
|
||||
return bs.managers
|
||||
}
|
||||
|
||||
// Boot is the main entry point to bootstrap the service after validating the
|
||||
// given configuration. The resulting Bootstrap struct can be used to retrieve
|
||||
// configured identity-managers and their respective http-handlers and config.
|
||||
//
|
||||
// This function should be used by consumers which want to embed this project
|
||||
// as a library.
|
||||
func Boot(ctx context.Context, settings *Settings, cfg *config.Config) (Bootstrap, error) {
|
||||
// NOTE(longsleep): Ensure to use same salt length as the hash size.
|
||||
// See https://www.ietf.org/mail-archive/web/jose/current/msg02901.html for
|
||||
// reference and https://github.com/golang-jwt/jwt/v4/issues/285 for
|
||||
// the issue in upstream jwt-go.
|
||||
for _, alg := range []string{jwt.SigningMethodPS256.Name, jwt.SigningMethodPS384.Name, jwt.SigningMethodPS512.Name} {
|
||||
sm := jwt.GetSigningMethod(alg)
|
||||
if signingMethodRSAPSS, ok := sm.(*jwt.SigningMethodRSAPSS); ok {
|
||||
signingMethodRSAPSS.Options.SaltLength = rsa.PSSSaltLengthEqualsHash
|
||||
}
|
||||
}
|
||||
|
||||
bs := &bootstrap{
|
||||
config: &Config{
|
||||
Config: cfg,
|
||||
Settings: settings,
|
||||
},
|
||||
}
|
||||
|
||||
err := bs.initialize(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = bs.setup(ctx, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// initialize, parsed parameters from commandline with validation and adds them
|
||||
// to the associated Bootstrap data.
|
||||
func (bs *bootstrap) initialize(settings *Settings) error {
|
||||
logger := bs.config.Config.Logger
|
||||
var err error
|
||||
|
||||
if settings.IdentityManager == "" {
|
||||
return fmt.Errorf("identity-manager argument missing, use one of kc, ldap, cookie, dummy")
|
||||
}
|
||||
|
||||
bs.config.IssuerIdentifierURI, err = url.Parse(settings.Iss)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid iss value, iss is not a valid URL), %v", err)
|
||||
} else if settings.Iss == "" {
|
||||
return fmt.Errorf("missing iss value, did you provide the --iss parameter?")
|
||||
} else if bs.config.IssuerIdentifierURI.Scheme != "https" {
|
||||
return fmt.Errorf("invalid iss value, URL must start with https://")
|
||||
} else if bs.config.IssuerIdentifierURI.Host == "" {
|
||||
return fmt.Errorf("invalid iss value, URL must have a host")
|
||||
}
|
||||
|
||||
bs.uriBasePath = settings.URIBasePath
|
||||
|
||||
bs.config.SignInFormURI, err = url.Parse(settings.SignInURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid sign-in URI, %v", err)
|
||||
}
|
||||
|
||||
bs.config.SignedOutURI, err = url.Parse(settings.SignedOutURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signed-out URI, %v", err)
|
||||
}
|
||||
|
||||
bs.config.AuthorizationEndpointURI, err = url.Parse(settings.AuthorizationEndpointURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid authorization-endpoint-uri, %v", err)
|
||||
}
|
||||
|
||||
bs.config.EndSessionEndpointURI, err = url.Parse(settings.EndsessionEndpointURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid endsession-endpoint-uri, %v", err)
|
||||
}
|
||||
|
||||
if settings.Insecure {
|
||||
// NOTE(longsleep): This disable http2 client support. See https://github.com/golang/go/issues/14275 for reasons.
|
||||
bs.config.TLSClientConfig = utils.InsecureSkipVerifyTLSConfig()
|
||||
logger.Warnln("insecure mode, TLS client connections are susceptible to man-in-the-middle attacks")
|
||||
} else {
|
||||
bs.config.TLSClientConfig = utils.DefaultTLSConfig()
|
||||
}
|
||||
|
||||
for _, trustedProxy := range settings.TrustedProxy {
|
||||
if ip := net.ParseIP(trustedProxy); ip != nil {
|
||||
bs.config.Config.TrustedProxyIPs = append(bs.config.Config.TrustedProxyIPs, &ip)
|
||||
continue
|
||||
}
|
||||
if _, ipNet, errParseCIDR := net.ParseCIDR(trustedProxy); errParseCIDR == nil {
|
||||
bs.config.Config.TrustedProxyNets = append(bs.config.Config.TrustedProxyNets, ipNet)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(bs.config.Config.TrustedProxyIPs) > 0 {
|
||||
logger.Infoln("trusted proxy IPs", bs.config.Config.TrustedProxyIPs)
|
||||
}
|
||||
if len(bs.config.Config.TrustedProxyNets) > 0 {
|
||||
logger.Infoln("trusted proxy networks", bs.config.Config.TrustedProxyNets)
|
||||
}
|
||||
|
||||
if len(settings.AllowScope) > 0 {
|
||||
bs.config.Config.AllowedScopes = settings.AllowScope
|
||||
logger.Infoln("using custom allowed OAuth 2 scopes", bs.config.Config.AllowedScopes)
|
||||
}
|
||||
|
||||
bs.config.Config.AllowClientGuests = settings.AllowClientGuests
|
||||
if bs.config.Config.AllowClientGuests {
|
||||
logger.Infoln("client controlled guests are enabled")
|
||||
}
|
||||
|
||||
bs.config.Config.AllowDynamicClientRegistration = settings.AllowDynamicClientRegistration
|
||||
if bs.config.Config.AllowDynamicClientRegistration {
|
||||
logger.Infoln("dynamic client registration is enabled")
|
||||
}
|
||||
|
||||
encryptionSecretFn := settings.EncryptionSecretFile
|
||||
|
||||
if encryptionSecretFn != "" {
|
||||
logger.WithField("file", encryptionSecretFn).Infoln("loading encryption secret from file")
|
||||
bs.config.EncryptionSecret, err = ioutil.ReadFile(encryptionSecretFn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load encryption secret from file: %v", err)
|
||||
}
|
||||
if len(bs.config.EncryptionSecret) != encryption.KeySize {
|
||||
return fmt.Errorf("invalid encryption secret size - must be %d bytes", encryption.KeySize)
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("missing --encryption-secret parameter, using random encyption secret with %d bytes", encryption.KeySize)
|
||||
bs.config.EncryptionSecret = rndm.GenerateRandomBytes(encryption.KeySize)
|
||||
}
|
||||
|
||||
bs.config.Config.ListenAddr = settings.Listen
|
||||
|
||||
bs.config.IdentifierClientDisabled = settings.IdentifierClientDisabled
|
||||
bs.config.IdentifierClientPath = settings.IdentifierClientPath
|
||||
|
||||
bs.config.IdentifierRegistrationConf = settings.IdentifierRegistrationConf
|
||||
if bs.config.IdentifierRegistrationConf != "" {
|
||||
bs.config.IdentifierRegistrationConf, _ = filepath.Abs(bs.config.IdentifierRegistrationConf)
|
||||
if _, errStat := os.Stat(bs.config.IdentifierRegistrationConf); errStat != nil {
|
||||
return fmt.Errorf("identifier-registration-conf file not found or unable to access: %v", errStat)
|
||||
}
|
||||
bs.config.IdentifierAuthoritiesConf = bs.config.IdentifierRegistrationConf
|
||||
}
|
||||
|
||||
bs.config.IdentifierScopesConf = settings.IdentifierScopesConf
|
||||
if bs.config.IdentifierScopesConf != "" {
|
||||
bs.config.IdentifierScopesConf, _ = filepath.Abs(bs.config.IdentifierScopesConf)
|
||||
if _, errStat := os.Stat(bs.config.IdentifierScopesConf); errStat != nil {
|
||||
return fmt.Errorf("identifier-scopes-conf file not found or unable to access: %v", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.IdentifierDefaultBannerLogo != "" {
|
||||
// Load from file.
|
||||
b, errRead := ioutil.ReadFile(settings.IdentifierDefaultBannerLogo)
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("identifier-default-banner-logo failed to open: %w", errRead)
|
||||
}
|
||||
bs.config.IdentifierDefaultBannerLogo = b
|
||||
}
|
||||
if settings.IdentifierDefaultSignInPageText != "" {
|
||||
bs.config.IdentifierDefaultSignInPageText = &settings.IdentifierDefaultSignInPageText
|
||||
}
|
||||
if settings.IdentifierDefaultLogoTargetURI != "" {
|
||||
bs.config.IdentifierDefaultLogoTargetURI = &settings.IdentifierDefaultLogoTargetURI
|
||||
}
|
||||
if settings.IdentifierDefaultUsernameHintText != "" {
|
||||
bs.config.IdentifierDefaultUsernameHintText = &settings.IdentifierDefaultUsernameHintText
|
||||
}
|
||||
bs.config.IdentifierUILocales = settings.IdentifierUILocales
|
||||
|
||||
bs.config.SigningKeyID = settings.SigningKid
|
||||
bs.config.Signers = make(map[string]crypto.Signer)
|
||||
bs.config.Validators = make(map[string]crypto.PublicKey)
|
||||
bs.config.Certificates = make(map[string][]*x509.Certificate)
|
||||
|
||||
signingMethodString := settings.SigningMethod
|
||||
bs.config.SigningMethod = jwt.GetSigningMethod(signingMethodString)
|
||||
if bs.config.SigningMethod == nil {
|
||||
return fmt.Errorf("unknown signing method: %s", signingMethodString)
|
||||
}
|
||||
|
||||
signingKeyFns := settings.SigningPrivateKeyFiles
|
||||
if len(signingKeyFns) > 0 {
|
||||
first := true
|
||||
for _, signingKeyFn := range signingKeyFns {
|
||||
logger.WithField("path", signingKeyFn).Infoln("loading signing key")
|
||||
err = addSignerWithIDFromFile(signingKeyFn, "", bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if first {
|
||||
// Also add key under the provided id.
|
||||
first = false
|
||||
err = addSignerWithIDFromFile(signingKeyFn, bs.config.SigningKeyID, bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//NOTE(longsleep): remove me - create keypair a random key pair.
|
||||
sm := jwt.SigningMethodPS256
|
||||
bs.config.SigningMethod = sm
|
||||
logger.WithField("alg", sm.Name).Warnf("missing --signing-private-key parameter, using random %d bit signing key", DefaultSigningKeyBits)
|
||||
signer, _ := rsa.GenerateKey(rand.Reader, DefaultSigningKeyBits)
|
||||
bs.config.Signers[bs.config.SigningKeyID] = signer
|
||||
}
|
||||
|
||||
// Ensure we have a signer for the things we need.
|
||||
err = validateSigners(bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validationKeysPath := settings.ValidationKeysPath
|
||||
if validationKeysPath != "" {
|
||||
logger.WithField("path", validationKeysPath).Infoln("loading validation keys")
|
||||
err = addValidatorsFromPath(validationKeysPath, bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
bs.config.Config.HTTPTransport = utils.HTTPTransportWithTLSClientConfig(bs.config.TLSClientConfig)
|
||||
|
||||
bs.config.AccessTokenDurationSeconds = settings.AccessTokenDurationSeconds
|
||||
if bs.config.AccessTokenDurationSeconds == 0 {
|
||||
bs.config.AccessTokenDurationSeconds = 60 * 10 // 10 Minutes
|
||||
}
|
||||
bs.config.IDTokenDurationSeconds = settings.IDTokenDurationSeconds
|
||||
if bs.config.IDTokenDurationSeconds == 0 {
|
||||
bs.config.IDTokenDurationSeconds = 60 * 60 // 1 Hour
|
||||
}
|
||||
bs.config.RefreshTokenDurationSeconds = settings.RefreshTokenDurationSeconds
|
||||
if bs.config.RefreshTokenDurationSeconds == 0 {
|
||||
bs.config.RefreshTokenDurationSeconds = 60 * 60 * 24 * 365 * 3 // 3 Years
|
||||
}
|
||||
bs.config.DyamicClientSecretDurationSeconds = settings.DyamicClientSecretDurationSeconds
|
||||
|
||||
// add setting to allow setting the same site attribute of the cookies
|
||||
bs.config.CookieSameSite = settings.CookieSameSite
|
||||
if bs.config.CookieSameSite == 0 {
|
||||
bs.config.CookieSameSite = DefaultCookieSameSite
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setup takes care of setting up the managers based on the associated
|
||||
// Bootstrap's data.
|
||||
func (bs *bootstrap) setup(ctx context.Context, settings *Settings) error {
|
||||
managers, err := newManagers(ctx, bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bs.managers = managers
|
||||
|
||||
identityManager, err := bs.setupIdentity(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managers.Set("identity", identityManager)
|
||||
|
||||
guestManager, err := bs.setupGuest(ctx, identityManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managers.Set("guest", guestManager)
|
||||
|
||||
oidcProvider, err := bs.setupOIDCProvider(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managers.Set("oidc", oidcProvider)
|
||||
managers.Set("handler", oidcProvider) // Use OIDC provider as default HTTP handler.
|
||||
|
||||
err = managers.Apply()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply managers: %v", err)
|
||||
}
|
||||
|
||||
// Final steps
|
||||
err = oidcProvider.InitializeMetadata()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize provider metadata: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bs *bootstrap) MakeURIPath(api APIType, subpath string) string {
|
||||
subpath = strings.TrimPrefix(subpath, "/")
|
||||
uriPath := ""
|
||||
|
||||
switch api {
|
||||
case APITypeKonnect:
|
||||
uriPath = fmt.Sprintf("%s/konnect/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
|
||||
case APITypeSignin:
|
||||
uriPath = fmt.Sprintf("%s/signin/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
|
||||
default:
|
||||
panic("unknown api type")
|
||||
}
|
||||
|
||||
if subpath == "" {
|
||||
uriPath = strings.TrimSuffix(uriPath, "/")
|
||||
}
|
||||
return uriPath
|
||||
}
|
||||
|
||||
func (bs *bootstrap) MakeURI(api APIType, subpath string) *url.URL {
|
||||
uriPath := bs.MakeURIPath(api, subpath)
|
||||
uri, _ := url.Parse(bs.config.IssuerIdentifierURI.String())
|
||||
uri.Path = uriPath
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
func (bs *bootstrap) setupIdentity(ctx context.Context, settings *Settings) (identity.Manager, error) {
|
||||
logger := bs.config.Config.Logger
|
||||
|
||||
if settings.IdentityManager == "" {
|
||||
return nil, fmt.Errorf("identity-manager argument missing")
|
||||
}
|
||||
|
||||
// Identity manager.
|
||||
identityManagerName := settings.IdentityManager
|
||||
identityManager, err := getIdentityManagerByName(identityManagerName, bs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.WithFields(logrus.Fields{
|
||||
"name": identityManagerName,
|
||||
"scopes": identityManager.ScopesSupported(nil),
|
||||
"claims": identityManager.ClaimsSupported(nil),
|
||||
}).Infoln("identity manager set up")
|
||||
|
||||
return identityManager, nil
|
||||
}
|
||||
|
||||
func (bs *bootstrap) setupGuest(ctx context.Context, identityManager identity.Manager) (identity.Manager, error) {
|
||||
if !bs.config.Config.AllowClientGuests {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
logger := bs.config.Config.Logger
|
||||
|
||||
guestManager, err := getIdentityManagerByName(DefaultGuestIdentityManagerName, bs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if guestManager != nil {
|
||||
logger.Infoln("identity guest manager set up")
|
||||
}
|
||||
return guestManager, nil
|
||||
}
|
||||
|
||||
func (bs *bootstrap) setupOIDCProvider(ctx context.Context) (*oidcProvider.Provider, error) {
|
||||
var err error
|
||||
logger := bs.config.Config.Logger
|
||||
|
||||
sessionCookiePath, err := getCommonURLPathPrefix(bs.config.AuthorizationEndpointURI.EscapedPath(), bs.config.EndSessionEndpointURI.EscapedPath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find common URL prefix for authorize and endsession: %v", err)
|
||||
}
|
||||
|
||||
var registrationPath = ""
|
||||
if bs.config.Config.AllowDynamicClientRegistration {
|
||||
registrationPath = bs.MakeURIPath(APITypeKonnect, "/register")
|
||||
}
|
||||
|
||||
provider, err := oidcProvider.NewProvider(&oidcProvider.Config{
|
||||
Config: bs.config.Config,
|
||||
|
||||
IssuerIdentifier: bs.config.IssuerIdentifierURI.String(),
|
||||
WellKnownPath: "/.well-known/openid-configuration",
|
||||
JwksPath: bs.MakeURIPath(APITypeKonnect, "/jwks.json"),
|
||||
AuthorizationPath: bs.config.AuthorizationEndpointURI.EscapedPath(),
|
||||
TokenPath: bs.MakeURIPath(APITypeKonnect, "/token"),
|
||||
UserInfoPath: bs.MakeURIPath(APITypeKonnect, "/userinfo"),
|
||||
EndSessionPath: bs.config.EndSessionEndpointURI.EscapedPath(),
|
||||
CheckSessionIframePath: bs.MakeURIPath(APITypeKonnect, "/session/check-session.html"),
|
||||
RegistrationPath: registrationPath,
|
||||
|
||||
BrowserStateCookiePath: bs.MakeURIPath(APITypeKonnect, "/session/"),
|
||||
BrowserStateCookieName: "__Secure-KKBS", // Kopano-Konnect-Browser-State
|
||||
BrowserStateCookieSameSite: bs.config.CookieSameSite,
|
||||
|
||||
SessionCookiePath: sessionCookiePath,
|
||||
SessionCookieName: "__Secure-KKCS", // Kopano-Konnect-Client-Session
|
||||
SessionCookieSameSite: bs.config.CookieSameSite,
|
||||
|
||||
AccessTokenDuration: time.Duration(bs.config.AccessTokenDurationSeconds) * time.Second,
|
||||
IDTokenDuration: time.Duration(bs.config.IDTokenDurationSeconds) * time.Second,
|
||||
RefreshTokenDuration: time.Duration(bs.config.RefreshTokenDurationSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create provider: %v", err)
|
||||
}
|
||||
if bs.config.SigningMethod != nil {
|
||||
err = provider.SetSigningMethod(bs.config.SigningMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set provider signing method: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// All add signers.
|
||||
for id, signer := range bs.config.Signers {
|
||||
if id == bs.config.SigningKeyID {
|
||||
err = provider.SetSigningKey(id, signer)
|
||||
// Always set default key.
|
||||
if id != DefaultSigningKeyID {
|
||||
provider.SetValidationKey(DefaultSigningKeyID, signer.Public())
|
||||
}
|
||||
} else {
|
||||
// Set non default signers as well.
|
||||
err = provider.SetSigningKey(id, signer)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Add all validators.
|
||||
for id, publicKey := range bs.config.Validators {
|
||||
err = provider.SetValidationKey(id, publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Add all certificates.
|
||||
for id, certificate := range bs.config.Certificates {
|
||||
err = provider.SetCertificate(id, certificate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
sk, ok := provider.GetSigningKey(bs.config.SigningMethod)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no signing key for selected signing method")
|
||||
}
|
||||
if bs.config.SigningKeyID == "" {
|
||||
// Ensure that there is a default signing Key ID even if none was set.
|
||||
provider.SetValidationKey(DefaultSigningKeyID, sk.PrivateKey.Public())
|
||||
}
|
||||
logger.WithFields(logrus.Fields{
|
||||
"id": sk.ID,
|
||||
"method": fmt.Sprintf("%T", sk.SigningMethod),
|
||||
"alg": sk.SigningMethod.Alg(),
|
||||
}).Infoln("oidc token signing default set up")
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 bootstrap
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/libregraph/lico/config"
|
||||
)
|
||||
|
||||
// Config is a typed application config which represents the active
|
||||
// bootstrap configuration.
|
||||
type Config struct {
|
||||
Config *config.Config
|
||||
Settings *Settings
|
||||
|
||||
SignInFormURI *url.URL
|
||||
SignedOutURI *url.URL
|
||||
AuthorizationEndpointURI *url.URL
|
||||
EndSessionEndpointURI *url.URL
|
||||
|
||||
TLSClientConfig *tls.Config
|
||||
|
||||
IssuerIdentifierURI *url.URL
|
||||
|
||||
IdentifierClientDisabled bool
|
||||
IdentifierClientPath string
|
||||
IdentifierRegistrationConf string
|
||||
IdentifierAuthoritiesConf string
|
||||
IdentifierScopesConf string
|
||||
IdentifierDefaultBannerLogo []byte
|
||||
IdentifierDefaultSignInPageText *string
|
||||
IdentifierDefaultLogoTargetURI *string
|
||||
IdentifierDefaultUsernameHintText *string
|
||||
IdentifierUILocales []string
|
||||
|
||||
EncryptionSecret []byte
|
||||
SigningMethod jwt.SigningMethod
|
||||
SigningKeyID string
|
||||
Signers map[string]crypto.Signer
|
||||
Validators map[string]crypto.PublicKey
|
||||
Certificates map[string][]*x509.Certificate
|
||||
|
||||
AccessTokenDurationSeconds uint64
|
||||
IDTokenDurationSeconds uint64
|
||||
RefreshTokenDurationSeconds uint64
|
||||
DyamicClientSecretDurationSeconds uint64
|
||||
|
||||
CookieSameSite http.SameSite
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/libregraph/lico/identity"
|
||||
identityAuthorities "github.com/libregraph/lico/identity/authorities"
|
||||
identityClients "github.com/libregraph/lico/identity/clients"
|
||||
identityManagers "github.com/libregraph/lico/identity/managers"
|
||||
"github.com/libregraph/lico/managers"
|
||||
codeManagers "github.com/libregraph/lico/oidc/code/managers"
|
||||
)
|
||||
|
||||
type IdentityManagerFactory func(Bootstrap) (identity.Manager, error)
|
||||
|
||||
var identityManagerRegistry = make(map[string]IdentityManagerFactory)
|
||||
|
||||
func RegisterIdentityManager(name string, f IdentityManagerFactory) error {
|
||||
identityManagerRegistry[name] = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIdentityManagerByName(name string, bs Bootstrap) (identity.Manager, error) {
|
||||
if f, found := identityManagerRegistry[name]; !found {
|
||||
return nil, fmt.Errorf("no identity manager with name %s registered", name)
|
||||
} else {
|
||||
return f(bs)
|
||||
}
|
||||
}
|
||||
|
||||
func newManagers(ctx context.Context, bs *bootstrap) (*managers.Managers, error) {
|
||||
logger := bs.config.Config.Logger
|
||||
|
||||
var err error
|
||||
mgrs := managers.New()
|
||||
|
||||
// Encryption manager.
|
||||
encryption, err := identityManagers.NewEncryptionManager(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create encryption manager: %v", err)
|
||||
}
|
||||
|
||||
err = encryption.SetKey(bs.config.EncryptionSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --encryption-secret parameter value for encryption: %v", err)
|
||||
}
|
||||
mgrs.Set("encryption", encryption)
|
||||
logger.Infof("encryption set up with %d key size", encryption.GetKeySize())
|
||||
|
||||
// OIDC code manage.
|
||||
code := codeManagers.NewMemoryMapManager(ctx)
|
||||
mgrs.Set("code", code)
|
||||
|
||||
// Identifier client registry manager.
|
||||
clients, err := identityClients.NewRegistry(ctx, bs.config.IssuerIdentifierURI, bs.config.IdentifierRegistrationConf, bs.config.Config.AllowDynamicClientRegistration, time.Duration(bs.config.DyamicClientSecretDurationSeconds)*time.Second, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client registry: %v", err)
|
||||
}
|
||||
mgrs.Set("clients", clients)
|
||||
|
||||
// Identifier authorities registry manager.
|
||||
authorities, err := identityAuthorities.NewRegistry(ctx, bs.MakeURI(APITypeSignin, ""), bs.config.IdentifierAuthoritiesConf, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create authorities registry: %v", err)
|
||||
}
|
||||
mgrs.Set("authorities", authorities)
|
||||
|
||||
return mgrs, nil
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 bootstrap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Settings is a typed application config which represents the user accessible
|
||||
// boostrap settings params.
|
||||
type Settings struct {
|
||||
Iss string
|
||||
IdentityManager string
|
||||
URIBasePath string
|
||||
SignInURI string
|
||||
SignedOutURI string
|
||||
AuthorizationEndpointURI string
|
||||
EndsessionEndpointURI string
|
||||
Insecure bool
|
||||
TrustedProxy []string
|
||||
AllowScope []string
|
||||
AllowClientGuests bool
|
||||
AllowDynamicClientRegistration bool
|
||||
EncryptionSecretFile string
|
||||
Listen string
|
||||
IdentifierClientDisabled bool
|
||||
IdentifierClientPath string
|
||||
IdentifierRegistrationConf string
|
||||
IdentifierScopesConf string
|
||||
IdentifierDefaultBannerLogo string
|
||||
IdentifierDefaultSignInPageText string
|
||||
IdentifierDefaultLogoTargetURI string
|
||||
IdentifierDefaultUsernameHintText string
|
||||
IdentifierUILocales []string
|
||||
SigningKid string
|
||||
SigningMethod string
|
||||
SigningPrivateKeyFiles []string
|
||||
ValidationKeysPath string
|
||||
CookieBackendURI string
|
||||
CookieNames []string
|
||||
CookieSameSite http.SameSite
|
||||
AccessTokenDurationSeconds uint64
|
||||
IDTokenDurationSeconds uint64
|
||||
RefreshTokenDurationSeconds uint64
|
||||
DyamicClientSecretDurationSeconds uint64
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func parseJSONWebKey(jsonBytes []byte) (*jose.JSONWebKey, error) {
|
||||
k := &jose.JSONWebKey{}
|
||||
if err := k.UnmarshalJSON(jsonBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// LoadSignerFromFile loads a private-key for signing
|
||||
//
|
||||
// Supports JSON (JWK/JWS) and PEM
|
||||
func LoadSignerFromFile(fn string) (string, crypto.Signer, error) {
|
||||
readBytes, errRead := ioutil.ReadFile(fn)
|
||||
if errRead != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse key file: %v", errRead)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fn)
|
||||
switch ext {
|
||||
case ".json":
|
||||
k, err := parseJSONWebKey(readBytes)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse key file as JWK: %v", err)
|
||||
}
|
||||
if !k.Valid() {
|
||||
return "", nil, fmt.Errorf("json file is not a valid JWK")
|
||||
}
|
||||
if k.IsPublic() {
|
||||
return "", nil, fmt.Errorf("JWK is a public key, private key required to use as signer")
|
||||
}
|
||||
signer, ok := k.Key.(crypto.Signer)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("JWS key type %T is not a signer", k.Key)
|
||||
}
|
||||
|
||||
return k.KeyID, signer, nil
|
||||
|
||||
case ".pem":
|
||||
fallthrough
|
||||
default:
|
||||
// Try PEM if not otherwise detected.
|
||||
signer, err := parsePEMSigner(readBytes)
|
||||
return "", signer, err
|
||||
}
|
||||
}
|
||||
|
||||
func parsePEMSigner(pemBytes []byte) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
|
||||
var signer crypto.Signer
|
||||
for {
|
||||
pkcs1Key, errParse1 := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if errParse1 == nil {
|
||||
signer = pkcs1Key
|
||||
break
|
||||
}
|
||||
|
||||
pkcs8Key, errParse2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if errParse2 == nil {
|
||||
signerSigner, ok := pkcs8Key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to use key as crypto signer")
|
||||
}
|
||||
signer = signerSigner
|
||||
break
|
||||
}
|
||||
|
||||
ecKey, errParse3 := x509.ParseECPrivateKey(block.Bytes)
|
||||
if errParse3 == nil {
|
||||
signer = ecKey
|
||||
break
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to parse signer key - valid PKCS#1, PKCS#8 ...? %v, %v, %v", errParse1, errParse2, errParse3)
|
||||
}
|
||||
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
// LoadValidatorFromFile loads a public-key used for validation.
|
||||
//
|
||||
// Supported formats are JSON-JWK and PEM
|
||||
func LoadValidatorFromFile(fn string) (string, crypto.PublicKey, error) {
|
||||
kid, _, key, err := loadValidatorFromFile(fn)
|
||||
return kid, key, err
|
||||
}
|
||||
|
||||
// LoadCertificatesAndValidatorFromFile loads chain of certificates and a
|
||||
// public-key used for validation.
|
||||
//
|
||||
// Supported formats are JSON-JWK and PEM
|
||||
func LoadCertificatesAndValidatorFromFile(fn string) (string, []*x509.Certificate, crypto.PublicKey, error) {
|
||||
return loadValidatorFromFile(fn)
|
||||
}
|
||||
|
||||
func loadValidatorFromFile(fn string) (string, []*x509.Certificate, crypto.PublicKey, error) {
|
||||
readBytes, errRead := ioutil.ReadFile(fn)
|
||||
if errRead != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to parse key file: %v", errRead)
|
||||
}
|
||||
|
||||
ext := filepath.Ext(fn)
|
||||
switch ext {
|
||||
case ".json":
|
||||
k, err := parseJSONWebKey(readBytes)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to parse key file as JWK: %v", err)
|
||||
}
|
||||
if !k.Valid() {
|
||||
return "", nil, nil, fmt.Errorf("json file is not a valid JWK")
|
||||
}
|
||||
if !k.IsPublic() {
|
||||
public := k.Public()
|
||||
k = &public
|
||||
}
|
||||
return k.KeyID, k.Certificates, k.Key, nil
|
||||
|
||||
case ".pem":
|
||||
fallthrough
|
||||
default:
|
||||
// Try PEM if not otherwise detected.
|
||||
certificates, validator, err := parsePEMValidator(readBytes)
|
||||
return "", certificates, validator, err
|
||||
}
|
||||
}
|
||||
|
||||
func parsePEMValidator(pemBytes []byte) ([]*x509.Certificate, crypto.PublicKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
|
||||
var certificates []*x509.Certificate
|
||||
var validator crypto.PublicKey
|
||||
for {
|
||||
pkixPubKey, errParse0 := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if errParse0 == nil {
|
||||
validator = pkixPubKey
|
||||
break
|
||||
}
|
||||
|
||||
pkcs1PubKey, errParse1 := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
if errParse1 == nil {
|
||||
validator = pkcs1PubKey
|
||||
break
|
||||
}
|
||||
|
||||
pkcs1PrivKey, errParse2 := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if errParse2 == nil {
|
||||
validator = pkcs1PrivKey.Public()
|
||||
break
|
||||
}
|
||||
|
||||
pkcs8Key, errParse3 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if errParse3 == nil {
|
||||
signerSigner, ok := pkcs8Key.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("failed to use key as crypto signer")
|
||||
}
|
||||
validator = signerSigner.Public()
|
||||
break
|
||||
}
|
||||
|
||||
ecKey, errParse4 := x509.ParseECPrivateKey(block.Bytes)
|
||||
if errParse4 == nil {
|
||||
validator = ecKey.Public()
|
||||
break
|
||||
}
|
||||
|
||||
certs, errParse5 := x509.ParseCertificates(block.Bytes)
|
||||
if errParse5 == nil {
|
||||
validator = certs[0].PublicKey
|
||||
certificates = append(certificates, certs...)
|
||||
break
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to parse validator key - valid PKCS#1, PKCS#8 ...? %v, %v, %v, %v, %v, %v", errParse0, errParse1, errParse2, errParse3, errParse4, errParse5)
|
||||
}
|
||||
|
||||
return certificates, validator, nil
|
||||
}
|
||||
|
||||
func addSignerWithIDFromFile(fn string, kid string, bs *bootstrap) error {
|
||||
fi, err := os.Lstat(fn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed load load signer key: %v", err)
|
||||
}
|
||||
|
||||
mode := fi.Mode()
|
||||
switch {
|
||||
case mode.IsDir():
|
||||
return fmt.Errorf("signer key must be a file")
|
||||
}
|
||||
|
||||
// Load file.
|
||||
signerKid, signer, err := LoadSignerFromFile(fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kid == "" {
|
||||
kid = signerKid
|
||||
}
|
||||
if kid == "" {
|
||||
// Get ID from file, following symbolic link.
|
||||
var real string
|
||||
if mode&os.ModeSymlink != 0 {
|
||||
real, err = os.Readlink(fn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, real = filepath.Split(real)
|
||||
} else {
|
||||
real = fi.Name()
|
||||
}
|
||||
|
||||
kid = getKeyIDFromFilename(real)
|
||||
}
|
||||
|
||||
if _, ok := bs.config.Signers[kid]; ok {
|
||||
bs.config.Config.Logger.WithFields(logrus.Fields{
|
||||
"path": fn,
|
||||
"kid": kid,
|
||||
}).Warnln("skipped as signer with same kid already loaded")
|
||||
return nil
|
||||
} else {
|
||||
bs.config.Config.Logger.WithFields(logrus.Fields{
|
||||
"path": fn,
|
||||
"kid": kid,
|
||||
}).Debugln("loaded signer key")
|
||||
}
|
||||
|
||||
bs.config.Signers[kid] = signer
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSigners(bs *bootstrap) error {
|
||||
haveRSA := false
|
||||
haveECDSA := false
|
||||
haveEd25519 := false
|
||||
for _, signer := range bs.config.Signers {
|
||||
switch s := signer.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
// Ensure the private key is not vulnerable with PKCS-1.5 signatures. See
|
||||
// https://paragonie.com/blog/2018/04/protecting-rsa-based-protocols-against-adaptive-chosen-ciphertext-attacks#rsa-anti-bb98
|
||||
// for details.
|
||||
if s.PublicKey.E < 65537 {
|
||||
return fmt.Errorf("RSA signing key with public exponent < 65537")
|
||||
}
|
||||
haveRSA = true
|
||||
case *ecdsa.PrivateKey:
|
||||
haveECDSA = true
|
||||
case ed25519.PrivateKey:
|
||||
haveEd25519 = true
|
||||
default:
|
||||
return fmt.Errorf("unsupported signer type: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate signing method
|
||||
switch bs.config.SigningMethod.(type) {
|
||||
case *jwt.SigningMethodRSA:
|
||||
if !haveRSA {
|
||||
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
|
||||
}
|
||||
case *jwt.SigningMethodRSAPSS:
|
||||
if !haveRSA {
|
||||
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
|
||||
}
|
||||
case *jwt.SigningMethodECDSA:
|
||||
if !haveECDSA {
|
||||
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
|
||||
}
|
||||
case *jwt.SigningMethodEd25519:
|
||||
if !haveEd25519 {
|
||||
return fmt.Errorf("no private key for signing method: %s", bs.config.SigningMethod.Alg())
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported signing method: %s", bs.config.SigningMethod.Alg())
|
||||
}
|
||||
|
||||
if !haveRSA {
|
||||
bs.config.Config.Logger.Warnln("no RSA signing private key, some clients might not be compatible")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addValidatorsFromPath(pn string, bs *bootstrap) error {
|
||||
fi, err := os.Lstat(pn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed load load validator keys: %v", err)
|
||||
}
|
||||
|
||||
switch mode := fi.Mode(); {
|
||||
case mode.IsDir():
|
||||
// OK.
|
||||
default:
|
||||
return fmt.Errorf("validator path must be a directory")
|
||||
}
|
||||
|
||||
// Load all files.
|
||||
files := []string{}
|
||||
if pemFiles, err := filepath.Glob(filepath.Join(pn, "*.pem")); err != nil {
|
||||
return fmt.Errorf("validator path err: %v", err)
|
||||
} else {
|
||||
files = append(files, pemFiles...)
|
||||
}
|
||||
if jsonFiles, err := filepath.Glob(filepath.Join(pn, "*.json")); err != nil {
|
||||
return fmt.Errorf("validator path err: %v", err)
|
||||
} else {
|
||||
files = append(files, jsonFiles...)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
kid, certificates, validator, err := loadValidatorFromFile(file)
|
||||
if err != nil {
|
||||
bs.config.Config.Logger.WithError(err).WithField("path", file).Warnln("failed to load validator key")
|
||||
continue
|
||||
}
|
||||
|
||||
// Get ID from file, without following symbolic links.
|
||||
if kid == "" {
|
||||
_, fn := filepath.Split(file)
|
||||
kid = getKeyIDFromFilename(fn)
|
||||
}
|
||||
if _, ok := bs.config.Validators[kid]; ok {
|
||||
bs.config.Config.Logger.WithFields(logrus.Fields{
|
||||
"path": file,
|
||||
"kid": kid,
|
||||
}).Warnln("skipped as validator with same kid already loaded")
|
||||
continue
|
||||
} else {
|
||||
bs.config.Config.Logger.WithFields(logrus.Fields{
|
||||
"path": file,
|
||||
"kid": kid,
|
||||
}).Debugln("loaded validator key")
|
||||
}
|
||||
bs.config.Validators[kid] = validator
|
||||
if certificates != nil {
|
||||
bs.config.Certificates[kid] = certificates
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithSchemeAndHost(u, base *url.URL) *url.URL {
|
||||
if u.Host != "" && u.Scheme != "" {
|
||||
return u
|
||||
}
|
||||
|
||||
r, _ := url.Parse(u.String())
|
||||
r.Scheme = base.Scheme
|
||||
r.Host = base.Host
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func getKeyIDFromFilename(fn string) string {
|
||||
ext := filepath.Ext(fn)
|
||||
return strings.TrimSuffix(fn, ext)
|
||||
}
|
||||
|
||||
func getCommonURLPathPrefix(p1, p2 string) (string, error) {
|
||||
parts1 := strings.Split(p1, "/")
|
||||
parts2 := strings.Split(p2, "/")
|
||||
|
||||
common := make([]string, 0)
|
||||
for idx, p := range parts1 {
|
||||
if idx >= len(parts2) {
|
||||
break
|
||||
}
|
||||
if p != parts2[idx] {
|
||||
break
|
||||
}
|
||||
common = append(common, p)
|
||||
}
|
||||
if len(common) == 0 {
|
||||
return "", errors.New("no common path prefix")
|
||||
}
|
||||
|
||||
return strings.Join(common, "/"), nil
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2017-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 lico
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/libregraph/lico/oidc"
|
||||
"github.com/libregraph/lico/oidc/payload"
|
||||
)
|
||||
|
||||
// Access token claims used.
|
||||
const (
|
||||
RefClaim = "lg.r"
|
||||
IdentityClaim = "lg.i"
|
||||
IdentityProviderClaim = "lg.p"
|
||||
ScopesClaim = "scp"
|
||||
)
|
||||
|
||||
// Identifier identity sub claims used.
|
||||
const (
|
||||
IdentifiedUserClaim = "us"
|
||||
IdentifiedUserIDClaim = "id"
|
||||
IdentifiedUsernameClaim = "un"
|
||||
IdentifiedDisplayNameClaim = "dn"
|
||||
IdentifiedData = "da"
|
||||
IdentifiedUserIsGuest = "gu"
|
||||
)
|
||||
|
||||
// Internal claim names used for special things.
|
||||
const (
|
||||
InternalExtraIDTokenClaimsClaim = "$lico.id.extra"
|
||||
InternalExtraAccessTokenClaimsClaim = "$lico.at.extra"
|
||||
)
|
||||
|
||||
// TokenType defines the token type value.
|
||||
type TokenTypeValue string
|
||||
|
||||
// Is compares the associated TokenTypeValue to the provided one.
|
||||
func (ttv TokenTypeValue) Is(value TokenTypeValue) bool {
|
||||
return ttv == value
|
||||
}
|
||||
|
||||
// The known token type values.
|
||||
const (
|
||||
TokenTypeIDToken TokenTypeValue = "" // Just a placeholder, not actually set in ID Tokens.
|
||||
TokenTypeAccessToken TokenTypeValue = "1"
|
||||
TokenTypeRefreshToken TokenTypeValue = "2"
|
||||
)
|
||||
|
||||
// AccessTokenClaims define the claims found in access tokens issued.
|
||||
type AccessTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
TokenType TokenTypeValue `json:"lg.t"`
|
||||
|
||||
AuthorizedClaimsRequest *payload.ClaimsRequest `json:"lg.acr,omitempty"`
|
||||
|
||||
AuthorizedScopesList payload.ScopesValue `json:"scp"`
|
||||
|
||||
IdentityClaims jwt.MapClaims `json:"lg.i"`
|
||||
IdentityProvider string `json:"lg.p,omitempty"`
|
||||
|
||||
*oidc.SessionClaims
|
||||
}
|
||||
|
||||
// Validate implements the jwt.ClaimsValidator interface.
|
||||
func (c AccessTokenClaims) Validate() error {
|
||||
if !c.TokenType.Is(TokenTypeAccessToken) {
|
||||
return errors.New("not an access token")
|
||||
}
|
||||
if len(c.Audience) != 1 {
|
||||
return errors.New("access token must have exactly one audience value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizedScopes returns a map with scope keys and true value of all scopes
|
||||
// set in the accociated access token.
|
||||
func (c AccessTokenClaims) AuthorizedScopes() map[string]bool {
|
||||
authorizedScopes := make(map[string]bool)
|
||||
for _, scope := range c.AuthorizedScopesList {
|
||||
authorizedScopes[scope] = true
|
||||
}
|
||||
|
||||
return authorizedScopes
|
||||
}
|
||||
|
||||
// RefreshTokenClaims define the claims used by refresh tokens.
|
||||
type RefreshTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
TokenType TokenTypeValue `json:"lg.t"`
|
||||
|
||||
ApprovedScopesList payload.ScopesValue `json:"scp"`
|
||||
|
||||
ApprovedClaimsRequest *payload.ClaimsRequest `json:"lg.acr,omitempty"`
|
||||
Ref string `json:"lg.r"`
|
||||
|
||||
IdentityClaims jwt.MapClaims `json:"lg.i"`
|
||||
IdentityProvider string `json:"lg.p,omitempty"`
|
||||
}
|
||||
|
||||
// Validate implements the jwt.ClaimsValidator interface.
|
||||
func (c RefreshTokenClaims) Validate() error {
|
||||
if !c.TokenType.Is(TokenTypeRefreshToken) {
|
||||
return errors.New("not a refresh token")
|
||||
}
|
||||
|
||||
if len(c.Audience) != 1 {
|
||||
return errors.New("refresh token must have exactly one audience value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NumericIDClaims define the claims used with the konnect/id scope.
|
||||
type NumericIDClaims struct {
|
||||
// NOTE(longsleep): Always keep these claims compatible with the GitLab API
|
||||
// https://docs.gitlab.com/ce/api/users.html#for-user.
|
||||
NumericID int64 `json:"id,omitempty"`
|
||||
NumericIDUsername string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
// Validate implements the jwt.ClaimsValidator interface.
|
||||
func (c NumericIDClaims) Validate() error {
|
||||
if c.NumericIDUsername == "" {
|
||||
return errors.New("username claim not valid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UniqueUserIDClaims define the claims used with the konnect/uuid scope.
|
||||
type UniqueUserIDClaims struct {
|
||||
UniqueUserID string `json:"lg.uuid,omitempty"`
|
||||
}
|
||||
|
||||
// Validate implements the jwt.ClaimsValidator interface.
|
||||
func (c UniqueUserIDClaims) Validate() error {
|
||||
if c.UniqueUserID == "" {
|
||||
return errors.New("lg.uuid claim not valid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 config
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Config defines a Server's configuration settings.
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
|
||||
WithMetrics bool
|
||||
|
||||
Logger logrus.FieldLogger
|
||||
HTTPTransport http.RoundTripper
|
||||
|
||||
TrustedProxyIPs []*net.IP
|
||||
TrustedProxyNets []*net.IPNet
|
||||
|
||||
AllowedScopes []string
|
||||
AllowClientGuests bool
|
||||
AllowDynamicClientRegistration bool
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 lico
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// key is an unexported type for keys defined in this package.
|
||||
// This prevents collisions with keys defined in other packages.
|
||||
type key int
|
||||
|
||||
// claimsKey is the key for claims in contexts. It is
|
||||
// unexported; clients use konnect.NewClaimsContext and
|
||||
// connect.FromClaimsContext instead of using this key directly.
|
||||
const (
|
||||
claimsKey key = iota
|
||||
requestKey
|
||||
)
|
||||
|
||||
// NewClaimsContext returns a new Context that carries value auth.
|
||||
func NewClaimsContext(ctx context.Context, claims jwt.Claims) context.Context {
|
||||
return context.WithValue(ctx, claimsKey, claims)
|
||||
}
|
||||
|
||||
// FromClaimsContext returns the AuthRecord value stored in ctx, if any.
|
||||
func FromClaimsContext(ctx context.Context) (jwt.Claims, bool) {
|
||||
claims, ok := ctx.Value(claimsKey).(jwt.Claims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// NewRequestContext returns a new Context that carries a request object.
|
||||
func NewRequestContext(ctx context.Context, req *http.Request) context.Context {
|
||||
return context.WithValue(ctx, requestKey, req)
|
||||
}
|
||||
|
||||
// FromRequestContext returns the Request object stored in ctx, if any.
|
||||
func FromRequestContext(ctx context.Context) (*http.Request, bool) {
|
||||
req, ok := ctx.Value(requestKey).(*http.Request)
|
||||
return req, ok
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 lico is a Go implementation of an OpenID Connect server with
|
||||
// flexibale authorization and authentication backends and consent screen.
|
||||
//
|
||||
// See README.md for more info.
|
||||
package lico // import "github.com/libregraph/lico"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 encryption
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeySize is the size of the keys created by GenerateKey()
|
||||
KeySize = 32
|
||||
// NonceSize is the size of the nonces created by GenerateNonce()
|
||||
NonceSize = 24
|
||||
)
|
||||
|
||||
// Encrypt generates a random nonce and encrypts the input using nacl.secretbox
|
||||
// package. We store the nonce in the first 24 bytes of the encrypted text.
|
||||
func Encrypt(msg []byte, key *[KeySize]byte) ([]byte, error) {
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return encryptWithNonce(msg, nonce, key)
|
||||
}
|
||||
|
||||
func encryptWithNonce(msg []byte, nonce *[NonceSize]byte, key *[KeySize]byte) ([]byte, error) {
|
||||
encrypted := secretbox.Seal(nonce[:], msg, nonce, key)
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Decrypt extracts the nonce from the encrypted text, and attempts to decrypt
|
||||
// with nacl.box.
|
||||
func Decrypt(msg []byte, key *[KeySize]byte) ([]byte, error) {
|
||||
if len(msg) < (NonceSize + secretbox.Overhead) {
|
||||
return nil, fmt.Errorf("wrong length of ciphertext")
|
||||
}
|
||||
|
||||
var nonce [NonceSize]byte
|
||||
copy(nonce[:], msg[:NonceSize])
|
||||
decrypted, ok := secretbox.Open(nil, msg[NonceSize:], &nonce, key)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("decryption failed")
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 encryption
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
)
|
||||
|
||||
// GenerateKey generates a new random secret key.
|
||||
func GenerateKey() (*[KeySize]byte, error) {
|
||||
key := new([KeySize]byte)
|
||||
_, err := io.ReadFull(rand.Reader, key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GenerateNonce creates a new random nonce.
|
||||
func GenerateNonce() (*[NonceSize]byte, error) {
|
||||
nonce := new([NonceSize]byte)
|
||||
_, err := io.ReadFull(rand.Reader, nonce[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nonce, nil
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
|
||||
# OpenID Connect client registry.
|
||||
clients:
|
||||
# - id: playground.js
|
||||
# name: OIDC Playground
|
||||
# application_type: web
|
||||
# redirect_uris:
|
||||
# - https://my-host:8509/
|
||||
# origins:
|
||||
# - https://my-host:8509
|
||||
|
||||
# - id: playground-trusted.js
|
||||
# name: Trusted OIDC Playground
|
||||
# trusted: yes
|
||||
# implicit_scopes:
|
||||
# - Implicitly.Added
|
||||
# application_type: web
|
||||
# redirect_uris:
|
||||
# - https://my-host:8509/
|
||||
# origins:
|
||||
# - https://my-host:8509
|
||||
|
||||
# - id: playground-trusted.js
|
||||
# name: Trusted Insecure OIDC Playground
|
||||
# trusted: yes
|
||||
# application_type: web
|
||||
# insecure: yes
|
||||
|
||||
# - id: client-with-keys
|
||||
# secret: super
|
||||
# application_type: native
|
||||
# redirect_uris:
|
||||
# - http://localhost
|
||||
# trusted_scopes:
|
||||
# - LibreGraph.GuestOK
|
||||
# - LibgreGraph.NumericID
|
||||
# jwks:
|
||||
# keys:
|
||||
# - kty: EC
|
||||
# use: sig
|
||||
# kid: client-with-keys-key-1
|
||||
# crv: P-256
|
||||
# x: RTZpWoRbjwX1YavmSHVBj6Cy3Yzdkkp6QLvTGB22D0c
|
||||
# y: jeavjwcX0xlDSchFcBMzXSU7wGs2VPpNxWCwmxFvmF0
|
||||
# request_object_signing_alg: ES256
|
||||
|
||||
# - id: first
|
||||
# secret: lala
|
||||
# application_type: native
|
||||
# redirect_uris:
|
||||
# - my://app
|
||||
|
||||
# - id: second
|
||||
# secret: lulu
|
||||
# application_type: native
|
||||
# redirect_uris:
|
||||
# - http://localhost
|
||||
|
||||
# External authority registry.
|
||||
authorities:
|
||||
# - id: my-univention-oidc
|
||||
# name: Univention
|
||||
# client_id: libregraph-lico
|
||||
# authority_type: oidc
|
||||
# jwks:
|
||||
# keys:
|
||||
# - kty: EC
|
||||
# use: sig
|
||||
# kid: example-key-1
|
||||
# crv: P-256
|
||||
# x: RTZpWoRbjwX1YavmSHVBj6Cy3Yzdkkp6QLvTGB22D0c
|
||||
# y: jeavjwcX0xlDSchFcBMzXSU7wGs2VPpNxWCwmxFvmF0
|
||||
# default: yes
|
||||
# authorization_endpoint: https://my-univention/signin/v1/identifier/_/authorize
|
||||
# response_type: id_token
|
||||
# scopes:
|
||||
# - openid
|
||||
# - profile
|
||||
# identity_claim_name: preferred_username
|
||||
# identity_aliases:
|
||||
# external-user-a: local-user-a
|
||||
# external-user-b: local-user-b
|
||||
# identity_alias_required: true
|
||||
|
||||
# - id: my-univention-saml2
|
||||
# name: Univention
|
||||
# entity_id: libregraph-lico
|
||||
# authority_type: saml2
|
||||
# default: yes
|
||||
# trusted: yes
|
||||
# discover: yes
|
||||
# metadata_endpoint: https://my-univention/simplesamlphp/saml2/idp/metadata.php
|
||||
# identity_claim_name: uid
|
||||
# identity_alias_required: false
|
||||
# end_session_enabled: true
|
||||
+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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user