move to GitHub.

This commit is contained in:
Nikolay Stupak 2019-05-24 16:13:15 +03:00
parent d761ad579a
commit 3bbac7bb74
28 changed files with 1840 additions and 336 deletions

2
.codecov.yml Normal file
View File

@ -0,0 +1,2 @@
ignore:
- "internal/web/templates.go"

BIN
.github/media/screenshot.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

0
.gitignore vendored
View File

View File

@ -1,7 +1,7 @@
# Copyright (C) JSC iCore - All Rights Reserved
#
# Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential
# Copyright (c) JSC iCore.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
run:
test: true

View File

@ -1,29 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.1] - 2019-05-15
### Added
- Add gopkg.i-core.ru/logutil as a logger middleware.
- Add gopkg.i-core.ru/httputil as a HTTP router.
### Changed
- Move to Golang 1.12 when build the application in Docker.
- Update golangci-lint config.
- Update the copyright.
### Removed
- Remove the HTTP handler of Prometheus's metrics.
## [1.1.0] - 2019-05-15
### Added
- Add support of logout flow.
## [1.0.0] - 2019-02-18
### Added
- Add unit tests for server's logic.
### Changed
- The url /auth/login accepts the POST parameter login_challenge instead of challenge.
- The OIDC claim roles is enabled in the scope http://i-core.ru/claims/roles only.
- Use go.uber.org/zap without any facade.

View File

@ -1,7 +1,7 @@
# Copyright (C) JSC iCore - All Rights Reserved
#
# Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential
# Copyright (c) JSC iCore.
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
FROM golang:1.12-alpine AS build
@ -16,7 +16,7 @@ COPY go.mod .
COPY go.sum .
COPY cmd cmd
COPY internal internal
RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/cmd/werther.Version=${VERSION}" ./...
RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X main.version=${VERSION}" ./...
FROM scratch AS final
COPY --from=build /etc/passwd /etc/passwd

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 JSC iCore
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

332
README.md
View File

@ -1,21 +1,61 @@
[![GoDoc](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther?status.svg)](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther)
# Werther <sup>[1](#myfootnote1)</sup>
# Werther
[![GoDoc][doc-img]][doc] [![Build Status][build-img]][build] [![codecov][codecov-img]][codecov]
Werther is an identity provider for ORY Hydra that is an OAuth2 provider.
Werther is an Identity Provider for [ORY Hydra][hydra] over [LDAP][ldap].
It implements [Login And Consent Flow][hydra-login-consent] and provides basic UI.
**Important!**
**The current version is compatible with ORY Hydra v1.0.0-rc.12 or higher.**
![screenshot](.github/media/screenshot.gif)
## Build
**Features**
- Support [Active Directory][ad];
- Mapping LDAP attributes to OpenID Connect claims;
- Mapping LDAP groups to user roles;
- OAuth 2.0 scopes;
- Caching users roles;
- UI customization.
**Limitations**
- Werther grants all requested permissions to a client without displaying the consent page;
- Werther confirms a logout request without displaying the logout confirmation page.
**Requirements**
ORY Hydra v1.0.0-rc.12 or higher.
**Table of Contents**
<!-- To generate the table use the command "npx doctoc --maxlevel 2 README.md" -->
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Installing](#installing)
- [Usage](#usage)
- [Configuration](#configuration)
- [User roles](#user-roles)
- [UI customization](#ui-customization)
- [Resources](#resources)
- [Footnotes](#footnotes)
- [Contributing](#contributing)
- [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Installing
### From Docker
```bash
docker pull icoreru/werter
```
### From sources
```bash
go install ./...
```
## Development
Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port for OAuth2 Provider Hydra,
3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in ten minutes.
## Usage
1. Create a network:
```
@ -27,120 +67,206 @@ Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port f
docker run --network hydra-net -d --restart always --name hydra \
-p 4444:4444 \
-p 4445:4445 \
-e OAUTH2_EXPOSE_INTERNAL_ERRORS=true \
-e LOG_LEVEL=debug \
-e TTL_ACCESS_TOKEN=10m \
-e TTL_ID_TOKEN=10m \
-e SERVE_PUBLIC_CORS_ENABLED=true \
-e SERVE_PUBLIC_CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \
-e SERVE_PUBLIC_CORS_ALLOW_CREDENTIALS=true \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e URLS_SELF_ISSUER=http://localhost:4444 \
-e URLS_SELF_PUBLIC=http://localhost:4444 \
-e URLS_LOGIN=http://$MY_HOST:3000/auth/login \
-e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \
-e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \
-e URLS_LOGIN=http://localhost:8080/auth/login \
-e URLS_CONSENT=http://localhost:8080/auth/consent \
-e URLS_LOGOUT=http://localhost:8080/auth/logout \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e DSN=memory \
oryd/hydra:v1.0.0-rc.12 serve all --dangerous-force-http
oryd/hydra:v1.0.0-rc.12 serve all
```
You can learn additional properties with help command:
```
docker run -it --rm oryd/hydra:v1.0.0-rc.12 serve --help
```
Look for details in [ORY Hydra Configuration][hydra-doc-config] and [ORY Hydra Documentation][hydra-doc].
3. Register a client:
3. Run Werther:
```
docker run -it --rm --network hydra-net \
-e HYDRA_ADMIN_URL=http://hydra:4445 \
oryd/hydra:$HYDRA_VERSION clients create \
--skip-tls-verify \
--id test-client \
--secret test-secret \
--response-types id_token,token,"id_token token" \
--grant-types implicit \
--scope openid,profile,email \
--callbacks http://$MY_HOST:8080 \
--post-logout-callbacks http://$MY_HOST:8080/post-logout-callback
```
4. Run Werther:
```
docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \
docker run --network hydra-net -d --restart always --name werther \
-p 8080:8080 \
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
-e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
-e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \
hub.das.i-core.ru/p/base-werther
-e WERTHER_LDAP_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
-e WERTHER_LDAP_BASEDN="DC=example,DC=local" \
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=example,DC=local" \
icoreru/werther
```
For all options see option help:
```
docker run -it --rm hub.das.i-core.ru/p/base-werther -help
```
## Configuration
5. Start an authentication process in a browser to get an access token:
```
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678
```
6. Start an authentication process in a browser to get an access token and id token:
```
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=id_token%20token&scope=openid%20profile%20email&state=12345678&nonce=87654321
```
The application is configured via environment variables.
Names of the environment variables starts with prefix `WERTHER_`.
See a list of the environment variables using the command:
7. Get user info:
```
http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>"
```
```
werther -h
```
For example, you can get the next output:
```
HTTP/1.1 200 OK
Content-Length: 218
Content-Type: application/json
Date: Tue, 31 Jul 2018 17:17:51 GMT
Vary: Origin
## User roles
In LDAP user's roles are groups in which a user is a member.
The environment variable `WERTHER_LDAP_ROLE_DN` is a DN for searching roles.
For example, create an OU that repserents an application, and then in the created OU
create groups that represent application's roles:
```
DC=local
|-- OU=Domain Groups
|-- OU=AppRoles
|-- OU=App1
|-- CN=app1_role1 (objectClass="group", description="role1")
|-- CN=app1_role2 (objectClass="group", description="role2")
```
Run Werther with the environment variable `WERTHER_LDAP_ROLE_DN`
that equals to `OU=AppRoles,OU=Domain Groups,DC=local`.
In the above example Werther returns user's roles as a value
of the user role's claim `https://github.com/i-core/werther/claims/roles`.
```json
{
"https://github.com/i-core/werther/claims/roles": {
"App1": ["role1", "role2"],
}
}
```
To customize the roles claim's name you should set a value of the environment variable `WERTHER_LDAP_ROLE_CLAIM`.
For more details about claims naming see [OpenID Connect Core 1.0][oidc-spec-additional-claims].
**NB** There are cases when we need to create several roles with the same name in LDAP.
For example, when we want to configure multiple applications or several environments for the same application.
```
DC=local
|-- OU=Domain Groups
|-- OU=AppRoles
|-- OU=Test
|-- OU=App1
|-- CN=test_app1_role1 (objectClass="group", description="role1")
|-- CN=test_app1_role2 (objectClass="group", description="role2")
|-- OU=App2
|-- CN=test_app2_role1 (objectClass="group",description-"role1")
|-- CN=test_app2_role2 (objectClass="group",description-"role2")
|-- OU=Dev
|-- OU=App1
|-- CN=dev_app1_role1 (objectClass="group", description="role1")
|-- CN=dev_app1_role3 (objectClass="group", description="role3")
|-- OU=App2
|-- CN=dev_app2_role1 (objectClass="group",description-"role1")
|-- CN=dev_app2_role4 (objectClass="group",description-"role4")
```
Active Directory requires unique CNs in a domain. But in Active Directory
creating groups with the same CN in different OUs is difficult.
Because of it, Werther uses a LDAP attribute as a role's name instead of CN.
A name of a LDAP attribute is specified using the environment variable `WERTHER_LDAP_ROLE_ATTR`,
and has the default value `description`.
In the above example, Werther returns a response that contains the next roles:
* when the environment variable `WERTHER_LDAP_ROLE_DN` equals to `OU=Test,OU=AppRoles,OU=Domain Groups,DC=local`:
```json
{
"email": "klepa@i-core.ru",
"family_name": "Lepa",
"given_name": "Konstantin",
"http://i-core.ru/claims/roles": {
"HeraldTest1": [
"user"
]
},
"name": "Konstantin Lepa",
"sub": "CN=Konstantin Lepa,OU=Domain Users,DC=icore,DC=local"
"https://github.com/i-core/werther/claims/roles": {
"App1": ["role1", "role2"],
"App2": ["role1", "role2"]
}
}
```
* when the environment variable `WERTHER_LDAP_ROLE_DN` equals to `OU=Dev,OU=AppRoles,OU=Domain Groups,DC=local`:
```json
{
"https://github.com/i-core/werther/claims/roles": {
"App1": ["role1", "role3"],
"App2": ["role1", "role4"]
}
}
```
Look for details in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter).
## UI customization
8. Re-get a token by httpie:
```
http --session u1 -F -v get \
"http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile&state=12345678&prompt=none" \
"Cookie:<COOKIES_FROM_WERTHER_DOMAIN>"
```
Werther uses the Go templates to render UI pages.
To customize the UI you should create a directory that contains UI pages' templates.
After that you should set the directory path to the environment variable `WERTHER_WEB_DIR`:
9. Delete a user's session from a browser:
```
open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
```
```bash
docker run --network hydra-net -d --restart always --name werther \
-p 8080:8080 \
-v /opt/werther/web:/path/to/custom-login-page/dir \
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
-e WERTHER_LDAP_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
-e WERTHER_LDAP_BASEDN="DC=example,DC=local" \
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=example,DC=local" \
-e WERTHER_WEB_DIR=/opt/werther/web
icoreru/werther
```
10. Log a user out from a browser:
```
open http://$MY_HOST:4444/oauth2/sessions/logout?id_token_hint=<id_token>&post_logout_redirect_uri=http://$MY_HOST:8080/post-logout-callback&state=87654321
```
After a successful logout, a user will be redirected to the page "http://$MY_HOST:8080/post-logout-callback?state=87654321".
### Custom login page
A login page's template should contains blocks `title`, `style`, `script`, `content`.
Each block has access to data that is an object with the next properties:
- `CSRFToken` (string) - a CSRF token;
- `Challenge` (string) - a login challenge ID;
- `LoginURL` (string) - an endpoint that finishes the login process;
- `IsInvalidCredentials` (bool) - specifies that a user types an invalid username or password;
- `IsInternalError` (bool) specifies that an internal server error happens when finishing the login process.
11. (Optional) Sniff TCP packets between Hydra and Werther
```
docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
```
When a login page's template contains static resources (like styles, scripts, and images)
they must be placed in a subdirectory called `static`.
For a full example of a login page's template see [source code](internal/web/templates).
## Resources
- [Introduction to ORY Hydra, OAuth 2.0, and OpenID Connect][hydra-doc];
- [ORY Hydra: Integrating with (existing) User Management][hydra-login-consent];
- [Official User Login & Consent Example](https://github.com/ory/hydra-login-consent-node);
- [OpenID Connect Core 1.0][oidc-spec-core];
- [OpenID Connect Session Management 1.0][oidc-spec-session];
- [OpenID Connect Front-Channel Logout 1.0][oidc-spec-front-channel-logout];
- [OpenID Connect Back-Channel Logout 1.0][oidc-spec-back-channel-logout].
## Footnotes
1. <a name="myfootnote1"></a> Werther is named after robot Werther from [Guest from the Future](https://en.wikipedia.org/wiki/Guest_from_the_Future).
## Contributing
Thanks for your interest in contributing to this project.
Get started with our [Contributing Guide][contrib].
## License
The code in this project is licensed under [MIT license][license].
[doc-img]: https://godoc.org/github.com/i-core/werther?status.svg
[doc]: https://godoc.org/github.com/i-core/werther
[build-img]: https://travis-ci.com/i-core/werther.svg?branch=master
[build]: https://travis-ci.com/i-core/werther
[codecov-img]: https://codecov.io/gh/i-core/werther/branch/master/graph/badge.svg
[codecov]: https://codecov.io/gh/i-core/werther
[contrib]: https://github.com/i-core/.github/blob/master/CONTRIBUTING.md
[license]: LICENSE
[ldap]: https://ldap.com/
[ad]: https://docs.microsoft.com/ru-ru/windows/desktop/AD/active-directory-domain-services
[hydra]: https://www.ory.sh/
[hydra-doc]: https://www.ory.sh/docs/hydra/
[hydra-login-consent]: https://www.ory.sh/docs/hydra/oauth2
[hydra-doc-config]: https://www.ory.sh/docs/hydra/configuration
[oidc-spec-core]: https://openid.net/specs/openid-connect-core-1_0.html
[oidc-spec-additional-claims]: https://openid.net/specs/openid-connect-core-1_0.html#AdditionalClaims
[oidc-spec-session]: https://openid.net/specs/openid-connect-session-1_0.html
[oidc-spec-front-channel-logout]: https://openid.net/specs/openid-connect-frontchannel-1_0.html
[oidc-spec-back-channel-logout]: https://openid.net/specs/openid-connect-backchannel-1_0.html

View File

@ -1,4 +0,0 @@
- image: golang:1.11-alpine
shell: go test -v ./...
- image: golangci/golangci-lint:v1.16.0
shell: golangci-lint -v run

View File

@ -1,11 +1,11 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package main // import "gopkg.i-core.ru/werther/cmd/werther"
package main // import "github.com/i-core/werther/cmd/werther"
import (
"flag"
@ -13,27 +13,27 @@ import (
"net/http"
"os"
"github.com/i-core/rlog"
"github.com/i-core/routegroup"
"github.com/i-core/werther/internal/identp"
"github.com/i-core/werther/internal/ldapclient"
"github.com/i-core/werther/internal/stat"
"github.com/i-core/werther/internal/web"
"github.com/justinas/nosurf"
"github.com/kelseyhightower/envconfig"
"go.uber.org/zap"
"gopkg.i-core.ru/httputil"
"gopkg.i-core.ru/logutil"
"gopkg.i-core.ru/werther/internal/identp"
"gopkg.i-core.ru/werther/internal/ldapclient"
"gopkg.i-core.ru/werther/internal/stat"
"gopkg.i-core.ru/werther/internal/web"
)
// Version will be filled at compile time.
var Version = ""
// version will be filled at compile time.
var version = ""
// Config is a server's configuration.
type Config struct {
DevMode bool `envconfig:"dev_mode" default:"false" desc:"a development mode"`
Listen string `default:":8080" desc:"a host and port to listen on (<host>:<port>)"`
Web web.Config
Identp identp.Config
LDAP ldapclient.Config
Web web.Config
}
func main() {
@ -49,7 +49,7 @@ func main() {
flag.Parse()
if *verflag {
fmt.Println("werther", Version)
fmt.Println("werther", version)
os.Exit(0)
}
@ -77,12 +77,12 @@ func main() {
ldap := ldapclient.New(cnf.LDAP)
router := httputil.NewRouter(nosurf.NewPure, logutil.RequestLog(log))
router := routegroup.NewRouter(nosurf.NewPure, rlog.NewMiddleware(log))
router.AddRoutes(web.NewStaticHandler(cnf.Web), "/static")
router.AddRoutes(identp.NewHandler(cnf.Identp, ldap, htmlRenderer), "/auth")
router.AddRoutes(stat.NewHandler(Version), "/stat")
router.AddRoutes(stat.NewHandler(version), "/stat")
log = log.Named("main")
log.Info("Werther started", zap.Any("config", cnf), zap.String("version", Version))
log.Info("Werther started", zap.Any("config", cnf), zap.String("version", version))
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router)))
}

View File

@ -1,10 +1,10 @@
// +build tools
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package main

15
go.mod
View File

@ -1,4 +1,4 @@
module gopkg.i-core.ru/werther
module github.com/i-core/werther
require (
github.com/OneOfOne/xxhash v1.2.2 // indirect
@ -7,22 +7,15 @@ require (
github.com/coocood/freecache v1.0.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/elazarl/go-bindata-assetfs v1.0.0
github.com/gofrs/uuid v3.2.0+incompatible // indirect
github.com/julienschmidt/httprouter v1.2.0 // indirect
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect
github.com/i-core/rlog v1.0.0
github.com/i-core/routegroup v1.0.0
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
github.com/kelseyhightower/envconfig v1.3.0
github.com/kevinburke/go-bindata v3.13.0+incompatible
github.com/pkg/errors v0.8.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.0.0 // indirect
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
github.com/stretchr/testify v1.2.2 // indirect
go.uber.org/atomic v1.2.0 // indirect
go.uber.org/multierr v1.1.0 // indirect
go.uber.org/zap v1.9.1
gopkg.i-core.ru/httputil v1.0.0
gopkg.i-core.ru/logutil v1.0.0
go.uber.org/zap v1.10.0
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
gopkg.in/ldap.v2 v2.5.1
)

22
go.sum
View File

@ -6,12 +6,17 @@ github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A=
github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4=
github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/i-core/rlog v1.0.0 h1:8CY2rsqvm3Z9cfl3hroppn8LTBwbtL45+ho79JTz8Jg=
github.com/i-core/rlog v1.0.0/go.mod h1:wTQKCF9IKx2HlNQ2M7dUpP3zIOD5ayqF4X3uQFbwY3g=
github.com/i-core/routegroup v1.0.0 h1:kTFVBWTWoT2vbhpk0PDemW3GEKV/DwAkQ3qjKnTNygI=
github.com/i-core/routegroup v1.0.0/go.mod h1:wXq5xEjOOs8xuM2olbaAlxgUbP/u8mVaW0tM/09cmKU=
github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
@ -30,18 +35,15 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
go.uber.org/atomic v1.2.0 h1:yVVGhClJ8Xi1y4TxhJZE6QFPrz76BrzhWA01n47mSFk=
go.uber.org/atomic v1.2.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
gopkg.i-core.ru/httputil v1.0.0 h1:A+6RPcU8pNvA/Zf+0Oy9iozyrwycmvDGSCdqgea2/qo=
gopkg.i-core.ru/httputil v1.0.0/go.mod h1:OrmzAZNj0BuwD6hHQ9tUVQZXVhdm7H9OMP5jbN7D8ro=
gopkg.i-core.ru/logutil v1.0.0 h1:KsUIPn1D2UktdMgkiWzXeA2QqzTJIPAgdApJxQSeiOM=
gopkg.i-core.ru/logutil v1.0.0/go.mod h1:FD71nyLCA6P3gkV1WVyvfEtKtS3M+HQXpuUtQT11rrw=
go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package hydra

View File

@ -0,0 +1,240 @@
package hydra_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"github.com/i-core/werther/internal/hydra"
"github.com/pkg/errors"
)
func TestInitiateConsentRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
rememberFor int
reqInfo *hydra.ReqInfo
status int
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "challenge is not found",
challenge: "foo",
status: 404,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "challenge is expired",
challenge: "foo",
status: 409,
wantErr: hydra.ErrChallengeExpired,
},
{
name: "happy path",
challenge: "foo",
status: 200,
reqInfo: &hydra.ReqInfo{
Challenge: "foo",
RequestedScopes: []string{"profile", "email"},
Skip: true,
Subject: "testSubject",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testInitiateConsentHandler{reqInfo: tc.reqInfo, status: tc.status}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewConsentReqDoer(srv.URL, tc.rememberFor)
reqInfo, err := ldr.InitiateRequest(tc.challenge)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err != tc.wantErr {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
}
})
}
}
type testInitiateConsentHandler struct {
reqInfo *hydra.ReqInfo
status int
challenge string
}
func (h *testInitiateConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/consent" {
w.WriteHeader(http.StatusMethodNotAllowed)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
return
}
h.challenge = r.URL.Query().Get("consent_challenge")
w.WriteHeader(h.status)
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
}
}
func TestAcceptConsentRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
rememberFor int
remember bool
grantScope []interface{}
idToken string
status int
redirect string
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "challenge is not found",
challenge: "foo",
rememberFor: 10,
remember: true,
grantScope: []interface{}{"scope1", "scope2"},
idToken: "testToken",
status: http.StatusNotFound,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "happy path",
challenge: "foo",
rememberFor: 10,
remember: true,
grantScope: []interface{}{"scope1", "scope2"},
idToken: "testToken",
status: http.StatusOK,
redirect: "/test-redirect",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testAcceptConsentHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewConsentReqDoer(srv.URL, tc.rememberFor)
var grantScope []string
for _, v := range tc.grantScope {
grantScope = append(grantScope, v.(string))
}
redirect, err := ldr.AcceptConsentRequest(tc.challenge, tc.remember, grantScope, tc.idToken)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err.Error() != tc.wantErr.Error() {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
wantData := map[string]interface{}{
"grant_scope": tc.grantScope,
"remember": tc.remember,
"remember_for": tc.rememberFor,
"session": map[string]interface{}{"id_token": tc.idToken},
}
if !reflect.DeepEqual(h.data, wantData) {
t.Errorf("\ngot request data:\n\t%#v\nwant request data:\n\t%#v", h.data, wantData)
}
if redirect != tc.redirect {
t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
}
})
}
}
type testAcceptConsentHandler struct {
challenge string
data map[string]interface{}
status int
redirect string
}
func (h *testAcceptConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/consent/accept" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
h.challenge = r.URL.Query().Get("consent_challenge")
w.WriteHeader(h.status)
if r.Body != http.NoBody {
// Note: Go JSON Decoder decodes numbers as float64, but we need int.
// So we convert numbers to int manually.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
panic(fmt.Sprintf("accept request: failed to read request body: %s", err))
}
h.data = make(map[string]interface{}, len(raw))
for key, val := range raw {
s := string(val)
if i, err := strconv.Atoi(s); err == nil {
h.data[key] = i
continue
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
h.data[key] = f
continue
}
var v interface{}
if err := json.Unmarshal(val, &v); err == nil {
h.data[key] = v
continue
}
h.data[key] = val
}
}
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
panic(fmt.Sprintf("accept request: failed to write response: %s", err))
}
}
}

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package hydra
@ -18,6 +18,8 @@ import (
)
var (
// ErrChallengeMissed is an error that happens when a challenge is missed.
ErrChallengeMissed = errors.New("challenge missed")
// ErrUnauthenticated is an error that happens when authentication is failed.
ErrUnauthenticated = errors.New("unauthenticated")
// ErrChallengeNotFound is an error that happens when an unknown challenge is used.
@ -43,6 +45,9 @@ type ReqInfo struct {
}
func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error) {
if challenge == "" {
return nil, ErrChallengeMissed
}
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s?%[1]s_challenge=%s", string(typ), challenge))
if err != nil {
return nil, err
@ -72,6 +77,9 @@ func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error)
}
func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (string, error) {
if challenge == "" {
return "", ErrChallengeMissed
}
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s/accept?%[1]s_challenge=%s", string(typ), challenge))
if err != nil {
return "", err

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package hydra

View File

@ -0,0 +1,246 @@
package hydra_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
"github.com/i-core/werther/internal/hydra"
"github.com/pkg/errors"
)
func TestInitiateLoginRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
reqInfo *hydra.ReqInfo
status int
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "unauthenticated",
challenge: "foo",
status: 401,
wantErr: hydra.ErrUnauthenticated,
},
{
name: "challenge is not found",
challenge: "foo",
status: 404,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "challenge is expired",
challenge: "foo",
status: 409,
wantErr: hydra.ErrChallengeExpired,
},
{
name: "happy path",
challenge: "foo",
status: 200,
reqInfo: &hydra.ReqInfo{
Challenge: "foo",
RequestedScopes: []string{"profile", "email"},
Skip: true,
Subject: "testSubject",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testInitiateLoginHandler{reqInfo: tc.reqInfo, status: tc.status}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewLoginReqDoer(srv.URL, 0)
reqInfo, err := ldr.InitiateRequest(tc.challenge)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err != tc.wantErr {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
}
})
}
}
type testInitiateLoginHandler struct {
reqInfo *hydra.ReqInfo
status int
challenge string
}
func (h *testInitiateLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/login" {
w.WriteHeader(http.StatusMethodNotAllowed)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
return
}
h.challenge = r.URL.Query().Get("login_challenge")
w.WriteHeader(h.status)
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
}
}
func TestAcceptLoginRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
rememberFor int
remember bool
subject string
status int
redirect string
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "unauthenticated",
challenge: "foo",
rememberFor: 10,
remember: true,
subject: "testSubject",
status: http.StatusUnauthorized,
wantErr: hydra.ErrUnauthenticated,
},
{
name: "challenge is not found",
challenge: "foo",
rememberFor: 10,
remember: true,
subject: "testSubject",
status: http.StatusNotFound,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "happy path",
challenge: "foo",
rememberFor: 10,
remember: true,
subject: "testSubject",
status: http.StatusOK,
redirect: "/test-redirect",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testAcceptLoginHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewLoginReqDoer(srv.URL, tc.rememberFor)
redirect, err := ldr.AcceptLoginRequest(tc.challenge, tc.remember, tc.subject)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err.Error() != tc.wantErr.Error() {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
wantData := map[string]interface{}{
"remember": tc.remember,
"remember_for": tc.rememberFor,
"subject": tc.subject,
}
if !reflect.DeepEqual(h.data, wantData) {
t.Errorf("\ngot request data:\n\t%#v\nwant request data:\n\t%#v", h.data, wantData)
}
if redirect != tc.redirect {
t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
}
})
}
}
type testAcceptLoginHandler struct {
challenge string
data map[string]interface{}
status int
redirect string
}
func (h *testAcceptLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/login/accept" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
h.challenge = r.URL.Query().Get("login_challenge")
w.WriteHeader(h.status)
if r.Body != http.NoBody {
// Note: Go JSON Decoder decodes numbers as float64, but we need int.
// So we convert numbers to int manually.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
panic(fmt.Sprintf("accept request: failed to read request body: %s", err))
}
h.data = make(map[string]interface{}, len(raw))
for key, val := range raw {
s := string(val)
if i, err := strconv.Atoi(s); err == nil {
h.data[key] = i
continue
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
h.data[key] = f
continue
}
var v interface{}
if err := json.Unmarshal(val, &v); err == nil {
h.data[key] = v
continue
}
h.data[key] = val
}
}
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
panic(fmt.Sprintf("accept request: failed to write response: %s", err))
}
}
}

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package hydra

View File

@ -0,0 +1,177 @@
package hydra_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/i-core/werther/internal/hydra"
"github.com/pkg/errors"
)
func TestInitiateLogoutRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
reqInfo *hydra.ReqInfo
status int
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "challenge is not found",
challenge: "foo",
status: 404,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "happy path",
challenge: "foo",
status: 200,
reqInfo: &hydra.ReqInfo{
Challenge: "foo",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testInitiateLogoutHandler{reqInfo: tc.reqInfo, status: tc.status}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewLogoutReqDoer(srv.URL)
reqInfo, err := ldr.InitiateRequest(tc.challenge)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err != tc.wantErr {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
}
})
}
}
type testInitiateLogoutHandler struct {
reqInfo *hydra.ReqInfo
status int
challenge string
}
func (h *testInitiateLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/logout" {
w.WriteHeader(http.StatusMethodNotAllowed)
if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
return
}
h.challenge = r.URL.Query().Get("logout_challenge")
w.WriteHeader(h.status)
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
panic(fmt.Sprintf("initial request: failed to write response: %s", err))
}
}
}
func TestAcceptLogoutRequest(t *testing.T) {
testCases := []struct {
name string
challenge string
status int
redirect string
wantErr error
}{
{
name: "challenge is missed",
wantErr: hydra.ErrChallengeMissed,
},
{
name: "challenge is not found",
challenge: "foo",
status: http.StatusNotFound,
wantErr: hydra.ErrChallengeNotFound,
},
{
name: "happy path",
challenge: "foo",
status: http.StatusOK,
redirect: "/test-redirect",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
h := &testAcceptLogoutHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
srv := httptest.NewServer(h)
defer srv.Close()
ldr := hydra.NewLogoutReqDoer(srv.URL)
redirect, err := ldr.AcceptLogoutRequest(tc.challenge)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err.Error() != tc.wantErr.Error() {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if h.challenge != tc.challenge {
t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
}
if redirect != tc.redirect {
t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
}
})
}
}
type testAcceptLogoutHandler struct {
challenge string
status int
redirect string
}
func (h *testAcceptLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/logout/accept" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
h.challenge = r.URL.Query().Get("logout_challenge")
w.WriteHeader(h.status)
if h.status == http.StatusOK {
if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
panic(fmt.Sprintf("accept request: failed to write response: %s", err))
}
}
}

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
@ -16,20 +16,20 @@ import (
"strings"
"time"
"github.com/i-core/rlog"
"github.com/i-core/werther/internal/hydra"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
"gopkg.i-core.ru/werther/internal/hydra"
)
const loginTmplName = "login.tmpl"
// Config is a Hydra configuration.
type Config struct {
HydraURL string `envconfig:"hydra_url" required:"true" desc:"a server admin URL of ORY Hydra"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Fi-core.ru%2Fclaims%2Froles:roles" desc:"a mapping of OIDC claims to scopes (all claims are URL encoded)"`
HydraURL string `envconfig:"hydra_url" required:"true" desc:"an admin URL of ORY Hydra Server"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a user session's TTL"`
ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Ffithub.com%2Fi-core.ru%2Fwerther%2Fclaims%2Froles:roles" desc:"a mapping of OpenID Connect claims to scopes (all claims are URL encoded)"`
}
// UserManager is an interface that is used for authentication and providing user's claims.
@ -105,7 +105,7 @@ type oa2LoginReqProcessor interface {
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
@ -157,7 +157,7 @@ func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRende
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
log := rlog.FromContext(r.Context()).Sugar()
r.ParseForm()
challenge := r.Form.Get("login_challenge")
@ -223,7 +223,7 @@ type oa2ConsentReqProcessor interface {
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
@ -297,7 +297,7 @@ type oa2LogoutReqProcessor interface {
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("logout_challenge")
if challenge == "" {

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package identp
@ -18,9 +18,9 @@ import (
"strings"
"testing"
"github.com/i-core/werther/internal/hydra"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/hydra"
)
func TestHandleLoginStart(t *testing.T) {

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package ldapclient
@ -17,22 +17,44 @@ import (
"time"
"github.com/coocood/freecache"
"github.com/i-core/rlog"
"github.com/pkg/errors"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
ldap "gopkg.in/ldap.v2"
)
var (
// errInvalidCredentials is an error that happens when a user's password is invalid.
errInvalidCredentials = fmt.Errorf("invalid credentials")
// errConnectionTimeout is an error that happens when no one LDAP endpoint responds.
errConnectionTimeout = fmt.Errorf("connection timeout")
// errMissedUsername is an error that happens
errMissedUsername = errors.New("username is missed")
// errUnknownUsername is an error that happens
errUnknownUsername = errors.New("unknown username")
)
type conn interface {
Bind(bindDN, password string) error
SearchUser(user string, attrs ...string) ([]map[string]interface{}, error)
SearchUserRoles(user string, attrs ...string) ([]map[string]interface{}, error)
Close()
}
type connector interface {
Connect(ctx context.Context, addr string) (conn, error)
}
// Config is a LDAP configuration.
type Config struct {
Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"`
BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"`
BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OpenID connect claims"`
RoleBaseDN string `envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP attribute for role's name"`
RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list
AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"`
RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP group's attribute that contains a role's name"`
RoleClaim string `envconfig:"role_claim" default:"https://github.com/i-core/werther/claims/roles" desc:"a name of an OpenID Connect claim that contains user roles"`
CacheSize int `envconfig:"cache_size" default:"512" desc:"a user info cache's size in KiB"`
CacheTTL time.Duration `envconfig:"cache_ttl" default:"30m" desc:"a user info cache TTL"`
}
@ -40,17 +62,16 @@ type Config struct {
// Client is a LDAP client (compatible with Active Directory).
type Client struct {
Config
cache *freecache.Cache
connector connector
cache *freecache.Cache
}
// New creates a new LDAP client.
func New(cnf Config) *Client {
if cnf.RoleClaim == "" {
cnf.RoleClaim = "http://i-core.ru/claims/roles"
}
return &Client{
Config: cnf,
cache: freecache.NewCache(cnf.CacheSize * 1024),
Config: cnf,
connector: &ldapConnector{BaseDN: cnf.BaseDN, RoleBaseDN: cnf.RoleBaseDN},
cache: freecache.NewCache(cnf.CacheSize * 1024),
}
}
@ -64,10 +85,10 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cn, ok := <-cli.dialTCP(ctx)
cn, ok := <-cli.connect(ctx)
cancel()
if !ok {
return false, errors.New("connection timeout")
return false, errConnectionTimeout
}
defer cn.Close()
@ -81,7 +102,7 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
}
if err := cn.Bind(details["dn"].(string), password); err != nil {
if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
if err == errInvalidCredentials {
return false, nil
}
return false, err
@ -89,85 +110,20 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
// Clear the claims' cache because of possible re-authentication. We don't want stale claims after re-login.
if ok := cli.cache.Del([]byte(username)); ok {
log := logutil.FromContext(ctx)
log := rlog.FromContext(ctx)
log.Debug("Cleared user's OIDC claims in the cache")
}
return true, nil
}
func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
var (
wg sync.WaitGroup
ch = make(chan *ldap.Conn)
)
wg.Add(len(cli.Endpoints))
for _, addr := range cli.Endpoints {
go func(addr string) {
defer wg.Done()
log := logutil.FromContext(ctx).Sugar()
d := net.Dialer{Timeout: ldap.DefaultTimeout}
tcpcn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
log.Debug("Failed to create a LDAP connection", "address", addr)
return
}
ldapcn := ldap.NewConn(tcpcn, false)
ldapcn.Start()
select {
case <-ctx.Done():
ldapcn.Close()
log.Debug("a LDAP connection is cancelled", "address", addr)
return
case ch <- ldapcn:
}
}(addr)
}
go func() {
wg.Wait()
close(ch)
}()
return ch
}
// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []string) (map[string]interface{}, error) {
if cli.BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data.
if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil {
return nil, err
}
}
query := fmt.Sprintf(
"(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+
"(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", username)
entries, err := cli.searchEntries(cn, cli.BaseDN, query, attrs...)
if err != nil {
return nil, err
}
if len(entries) != 1 {
// We didn't find the user.
return nil, nil
}
var (
entry = entries[0]
details = make(map[string]interface{})
)
for _, attr := range attrs {
if v, ok := entry[attr]; ok {
details[attr] = v
}
}
return details, nil
}
// FindOIDCClaims finds all OIDC claims for a user.
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
log := logutil.FromContext(ctx).Sugar()
if username == "" {
return nil, errMissedUsername
}
log := rlog.FromContext(ctx).Sugar()
// Retrieving from LDAP is slow. So, we try to get claims for the given username from the cache.
switch cdata, err := cli.cache.Get([]byte(username)); err {
@ -190,10 +146,10 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cn, ok := <-cli.dialTCP(ctx)
cn, ok := <-cli.connect(ctx)
cancel()
if !ok {
return nil, errors.New("connection timeout")
return nil, errConnectionTimeout
}
defer cn.Close()
@ -208,11 +164,11 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
return nil, err
}
if details == nil {
return nil, errors.New("unknown username")
return nil, errUnknownUsername
}
log.Infow("Retrieved user's info from LDAP", "details", details)
// Transform the retrived attributes to corresponding claims.
// Transform the retrieved attributes to corresponding claims.
claims := make(map[string]interface{})
for attr, v := range details {
if claim, ok := cli.AttrClaims[attr]; ok {
@ -222,16 +178,15 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
// User's roles is stored in LDAP as groups. We find all groups in a role's DN
// that include the user as a member.
query := fmt.Sprintf("(&(objectClass=group)(member=%s))", details["dn"])
entries, err := cli.searchEntries(cn, cli.RoleBaseDN, query, "dn", cli.RoleAttr)
entries, err := cn.SearchUserRoles(fmt.Sprintf("%s", details["dn"]), "dn", cli.RoleAttr)
if err != nil {
return nil, err
}
roles := make(map[string][]string)
roles := make(map[string]interface{})
for _, entry := range entries {
roleDN := entry["dn"].(string)
if roleDN == "" {
roleDN, ok := entry["dn"].(string)
if !ok || roleDN == "" {
log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry)
continue
}
@ -248,14 +203,19 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
}
// The DN without the role's base DN must contain a CN and OU
// where the CN is for uniqueness only, and the OU is an application id.
v := strings.Split(roleDN[:n-k-1], ",")
if len(v) != 2 {
path := strings.Split(roleDN[:n-k-1], ",")
if len(path) != 2 {
log.Infow("A role's DN without the role's base DN must contain two nodes only",
"roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN)
continue
}
appID := v[1][len("OU="):]
roles[appID] = append(roles[appID], entry[cli.RoleAttr].(string))
appID := path[1][len("OU="):]
var appRoles []interface{}
if v := roles[appID]; v != nil {
appRoles = v.([]interface{})
}
roles[appID] = append(appRoles, entry[cli.RoleAttr])
}
claims[cli.RoleClaim] = roles
@ -271,11 +231,114 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
return claims, nil
}
func (cli *Client) connect(ctx context.Context) <-chan conn {
var (
wg sync.WaitGroup
ch = make(chan conn)
)
wg.Add(len(cli.Endpoints))
for _, addr := range cli.Endpoints {
go func(addr string) {
defer wg.Done()
log := rlog.FromContext(ctx).Sugar()
cn, err := cli.connector.Connect(ctx, addr)
if err != nil {
log.Debug("Failed to create a LDAP connection", "address", addr)
return
}
select {
case <-ctx.Done():
cn.Close()
log.Debug("a LDAP connection is cancelled", "address", addr)
return
case ch <- cn:
}
}(addr)
}
go func() {
wg.Wait()
close(ch)
}()
return ch
}
// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
func (cli *Client) findBasicUserDetails(cn conn, username string, attrs []string) (map[string]interface{}, error) {
if cli.BindDN != "" {
// We need to login to a LDAP server with a service account for retrieving user data.
if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil {
return nil, errors.Wrap(err, "failed to login to a LDAP woth a service account")
}
}
entries, err := cn.SearchUser(username, attrs...)
if err != nil {
return nil, err
}
if len(entries) != 1 {
// We didn't find the user.
return nil, nil
}
var (
entry = entries[0]
details = make(map[string]interface{})
)
for _, attr := range attrs {
if v, ok := entry[attr]; ok {
details[attr] = v
}
}
return details, nil
}
type ldapConnector struct {
BaseDN string
RoleBaseDN string
}
func (c *ldapConnector) Connect(ctx context.Context, addr string) (conn, error) {
d := net.Dialer{Timeout: ldap.DefaultTimeout}
tcpcn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
ldapcn := ldap.NewConn(tcpcn, false)
ldapcn.Start()
return &ldapConn{Conn: ldapcn, BaseDN: c.BaseDN, RoleBaseDN: c.RoleBaseDN}, nil
}
type ldapConn struct {
*ldap.Conn
BaseDN string
RoleBaseDN string
}
func (c *ldapConn) Bind(bindDN, password string) error {
err := c.Conn.Bind(bindDN, password)
if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
return errInvalidCredentials
}
return err
}
func (c *ldapConn) SearchUser(user string, attrs ...string) ([]map[string]interface{}, error) {
query := fmt.Sprintf(
"(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+
"(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", user)
return c.searchEntries(c.BaseDN, query, attrs)
}
func (c *ldapConn) SearchUserRoles(user string, attrs ...string) ([]map[string]interface{}, error) {
query := fmt.Sprintf("(&(objectClass=group)(member=%s))", user)
return c.searchEntries(c.RoleBaseDN, query, attrs)
}
// searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes.
func (cli *Client) searchEntries(cn *ldap.Conn, baseDN, query string, attrs ...string) ([]map[string]interface{}, error) {
res, err := cn.Search(ldap.NewSearchRequest(
baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil,
))
func (c *ldapConn) searchEntries(baseDN, query string, attrs []string) ([]map[string]interface{}, error) {
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil)
res, err := c.Search(req)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,588 @@
package ldapclient
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/pkg/errors"
)
var (
errBindUser = fmt.Errorf("bind user error")
errSearchUser = fmt.Errorf("search user error")
errSearchRoles = fmt.Errorf("search user roles error")
users = []map[string]interface{}{
{
"dn": "user1",
"pass": "user1",
"a": "valA",
"b": "valB",
"c": "valC",
},
{
"dn": "user2",
"pass": "user2",
"a": "valA",
"b": "valB",
"c": "valC",
"roles": []map[string]interface{}{
{"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
{"dn": "CN=role2,OU=app1,OU=test,DC=local", "test-roles-attr": "r2"},
},
},
{
"dn": "user3",
"pass": "user3",
"a": "valA",
"b": "valB",
"c": "valC",
"roles": []map[string]interface{}{
{"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
{"dn": "CN=role2,OU=app1,OU=test,DC=local", "test-roles-attr": "r2"},
{"dn": "CN=role3,OU=app2,OU=test,DC=local", "test-roles-attr": "r3"},
{"dn": "CN=role4,OU=app2,OU=test,DC=local", "test-roles-attr": "r4"},
},
},
{
"dn": "user4",
"pass": "user4",
"a": "valA",
"b": "valB",
"c": "valC",
"roles": []map[string]interface{}{
{"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
{"test-roles-attr": "r2"},
},
},
{
"dn": "user5",
"pass": "user5",
"a": "valA",
"b": "valB",
"c": "valC",
"roles": []map[string]interface{}{
{"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
{"dn": "CN=role2,OU=app1,OU=test,DC=local"},
},
},
{
"dn": "user6",
"pass": "user6",
"a": "valA",
"b": "valB",
"c": "valC",
"roles": []map[string]interface{}{
{"dn": "CN=role1,OU=test,DC=local", "test-roles-attr": "r1"},
},
},
{
"dn": "serviceUser",
"pass": "servicePass",
},
}
)
func TestAuthenticate(t *testing.T) {
testCases := []struct {
name string
connector *testConnector
bindDN string
bindPass string
user string
pass string
wantErr error
wantAuth bool
}{
{
name: "username is empty",
connector: newTestConnector("ep1", &testConn{users: users}),
},
{
name: "password is empty",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user1",
},
{
name: "connection timeout",
connector: newTestConnector("ep1", fmt.Errorf("failed to connect to endpoint")),
user: "user1",
pass: "user1",
wantErr: errConnectionTimeout,
},
{
name: "search user error",
connector: newTestConnector("ep1", &testConn{userErr: errSearchUser}),
user: "user1",
pass: "user1",
wantErr: errSearchUser,
},
{
name: "user is not found",
connector: newTestConnector("ep1", &testConn{}),
user: "user1",
pass: "user1",
},
{
name: "authentication error",
connector: newTestConnector("ep1", &testConn{users: users, bindErr: errBindUser}),
user: "user1",
pass: "user1",
wantErr: errBindUser,
},
{
name: "invalid password",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user1",
pass: "invalid",
},
{
name: "success auth",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user1",
pass: "user1",
wantAuth: true,
},
{
name: "auth with invalid service account",
connector: newTestConnector("ep1", &testConn{users: users}),
bindDN: "serviceUser",
bindPass: "invalid",
user: "user1",
pass: "user1",
wantErr: errInvalidCredentials,
},
{
name: "auth with valid service account",
connector: newTestConnector("ep1", &testConn{users: users}),
bindDN: "serviceUser",
bindPass: "servicePass",
user: "user1",
pass: "user1",
wantAuth: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client := New(Config{Endpoints: tc.connector.Endpoints(), BindDN: tc.bindDN, BindPass: tc.bindPass})
client.connector = tc.connector
ok, err := client.Authenticate(context.Background(), tc.user, tc.pass)
if ok != tc.wantAuth {
t.Errorf("got auth: %t, want auth: %t", ok, tc.wantAuth)
}
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err != tc.wantErr {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
})
}
}
func TestAuthenticateWhenMultipleEndpointsFailed(t *testing.T) {
connector := newTestConnector("ep1", fmt.Errorf("error"), "ep2", fmt.Errorf("error"))
client := New(Config{Endpoints: connector.Endpoints()})
client.connector = connector
_, err := client.Authenticate(context.Background(), "user1", "user1")
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", errConnectionTimeout)
}
err = errors.Cause(err)
if err != errConnectionTimeout {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, errConnectionTimeout)
}
}
func TestAuthenticateWhenOneEndpointFailedAndOneSuccess(t *testing.T) {
ep2 := &testConn{users: users}
connector := newTestConnector("ep1", fmt.Errorf("error"), "ep2", ep2)
client := New(Config{Endpoints: connector.Endpoints()})
client.connector = connector
ok, err := client.Authenticate(context.Background(), "user1", "user1")
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if !ok {
t.Errorf("got auth: %t, want auth: true", ok)
}
if !ep2.authRequest {
t.Error("\ngot: endpoint \"ep2\" is not called, want: endpoint \"ep2\" is called")
}
}
func TestAuthenticateWhenMultipleEndpointsSuccess(t *testing.T) {
ep1 := &testConn{users: users}
ep2 := &testConn{users: users}
connector := newTestConnector("ep1", ep1, "ep2", ep2)
client := New(Config{Endpoints: connector.Endpoints()})
client.connector = connector
ok, err := client.Authenticate(context.Background(), "user1", "user1")
// Wait for closing all opened LDAP connections.
time.Sleep(100 * time.Millisecond)
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if !ok {
t.Errorf("got auth: %t, want auth: true", ok)
}
switch {
case ep1.authRequest && ep2.authRequest:
t.Error("got: every endpoint is called, want: only one endpoint is called")
case !ep1.authRequest && !ep2.authRequest:
t.Error("got: no one endpoint is not called, want: only one endpoint is called")
}
var notClosed []string
if !ep1.closed {
notClosed = append(notClosed, "ep1")
}
if !ep2.closed {
notClosed = append(notClosed, "ep2")
}
if len(notClosed) > 0 {
t.Errorf("got: endpoints %s are not closed, want: all endpoints are closed", strings.Join(notClosed, ", "))
}
}
func TestFindOIDCClaims(t *testing.T) {
testCases := []struct {
name string
connector *testConnector
bindDN string
bindPass string
user string
attrClaims map[string]string
wantErr error
want map[string]interface{}
}{
{
name: "username is empty",
connector: newTestConnector("ep1", &testConn{users: users}),
wantErr: errMissedUsername,
},
{
name: "connection timeout",
connector: newTestConnector("ep1", fmt.Errorf("failed to connect to endpoint")),
user: "user1",
wantErr: errConnectionTimeout,
},
{
name: "search user error",
connector: newTestConnector("ep1", &testConn{userErr: errSearchUser}),
user: "user1",
wantErr: errSearchUser,
},
{
name: "user is not found",
connector: newTestConnector("ep1", &testConn{}),
user: "user1",
wantErr: errUnknownUsername,
},
{
name: "search roles error",
connector: newTestConnector("ep1", &testConn{users: users, rolesErr: errSearchRoles}),
user: "user1",
wantErr: errSearchRoles,
},
{
name: "extra attributes is filtered from claims",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user1",
attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
want: map[string]interface{}{"name": "user1", "claimA": "valA", "claimB": "valB", "roles": nil},
},
{
name: "skip claim if no attribute",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user1",
attrClaims: map[string]string{"dn": "name", "a": "claimA", "d": "claimD"},
want: map[string]interface{}{"name": "user1", "claimA": "valA", "roles": nil},
},
{
name: "claims with roles for one application",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user2",
attrClaims: map[string]string{"dn": "name"},
want: map[string]interface{}{"name": "user1", "test-roles-claim": map[string][]string{"app1": {"r1", "r2"}}},
},
{
name: "claims with roles for multiple applications",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user3",
attrClaims: map[string]string{"dn": "name"},
want: map[string]interface{}{"name": "user1", "test-roles-claim": map[string][]string{"app1": {"r1", "r2"}, "app2": {"r3", "r4"}}},
},
{
name: "skip role without DN",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user4",
attrClaims: map[string]string{"dn": "name"},
want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
},
{
name: "skip role without role attribute",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user5",
attrClaims: map[string]string{"dn": "name"},
want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
},
{
name: "skip invalid role without role base DN",
connector: newTestConnector("ep1", &testConn{users: users}),
user: "user6",
attrClaims: map[string]string{"dn": "name"},
want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
},
{
name: "auth with invalid service account",
connector: newTestConnector("ep1", &testConn{users: users}),
bindDN: "serviceUser",
bindPass: "invalid",
user: "user1",
attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
wantErr: errInvalidCredentials,
},
{
name: "auth with valid service account",
connector: newTestConnector("ep1", &testConn{users: users}),
bindDN: "serviceUser",
bindPass: "servicePass",
user: "user1",
attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
want: map[string]interface{}{"name": "user1", "claimA": "valA", "claimB": "valB", "roles": nil},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client := New(Config{
Endpoints: tc.connector.Endpoints(),
BindDN: tc.bindDN,
BindPass: tc.bindPass,
AttrClaims: tc.attrClaims,
RoleBaseDN: "OU=test,DC=local",
RoleClaim: "test-roles-claim",
RoleAttr: "test-roles-attr",
})
client.connector = tc.connector
got, err := client.FindOIDCClaims(context.Background(), tc.user)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
}
err = errors.Cause(err)
if err != tc.wantErr {
t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
}
if reflect.DeepEqual(got, tc.want) {
t.Errorf("\ngot claims:\n\t%v\nwant claims:\n\t%v", got, tc.want)
}
})
}
}
func TestClaimsCache(t *testing.T) {
ep := &testConn{users: users}
connector := newTestConnector("ep", ep)
client := New(Config{
Endpoints: connector.Endpoints(),
AttrClaims: map[string]string{"dn": "name", "a": "claimA", "d": "claimD"},
RoleBaseDN: "OU=test,DC=local",
RoleClaim: "test-roles-claim",
RoleAttr: "test-roles-attr",
})
client.connector = connector
ok, err := client.Authenticate(context.Background(), "user2", "user2")
if err != nil {
t.Fatalf("initial auth: unexpected error: %s", err)
}
if !ok {
t.Fatal("initial auth: got no auth, want auth")
}
claims1, err := client.FindOIDCClaims(context.Background(), "user2")
if err != nil {
t.Fatalf("claims request 1: unexpected error: %s", err)
}
if claims1 == nil {
t.Fatal("claims request 1: got no claims, want claims")
}
if !ep.claimsRequest {
t.Fatal("claims request 1: got claims from cache, want claims from ldap")
}
ep.claimsRequest = false
claims2, err := client.FindOIDCClaims(context.Background(), "user2")
if err != nil {
t.Fatalf("claims request 2: unexpected error: %s", err)
}
if claims2 == nil {
t.Fatal("claims request 2: got no claims, want claims")
}
if ep.claimsRequest {
t.Fatal("claims request 2: got claims from ldap, want claims from cache")
}
if !reflect.DeepEqual(claims1, claims2) {
t.Fatalf("claims request 2:\ngot claims:\n\t%v\nwant claims:\n\t%v", claims2, claims1)
}
ok, err = client.Authenticate(context.Background(), "user2", "user2")
if err != nil {
t.Fatalf("re-auth: unexpected error: %s", err)
}
if !ok {
t.Fatal("re-auth: got no auth, want auth")
}
claims3, err := client.FindOIDCClaims(context.Background(), "user2")
if err != nil {
t.Fatalf("claims request 3: unexpected error: %s", err)
}
if claims3 == nil {
t.Fatal("claims request 3: got no claims, want claims")
}
if !ep.claimsRequest {
t.Fatal("claims request 3: got claims from cache, want claims from ldap")
}
}
type testConnector struct {
conns map[string]interface{}
}
func newTestConnector(args ...interface{}) *testConnector {
if len(args)%2 != 0 {
panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
}
conns := make(map[string]interface{})
for i := 0; i < len(args)/2; i++ {
addr, ok := args[i*2].(string)
if !ok {
panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
}
switch arg := args[i*2+1].(type) {
case error, *testConn:
conns[addr] = arg
default:
panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
}
}
return &testConnector{conns: conns}
}
func (c *testConnector) Endpoints() []string {
var eps []string
for addr := range c.conns {
eps = append(eps, addr)
}
return eps
}
func (c *testConnector) Connect(ctx context.Context, addr string) (conn, error) {
switch v := c.conns[addr].(type) {
case error:
return nil, v
case *testConn:
return v, nil
default:
panic(fmt.Sprintf("Invalid config for endpoint %q", addr))
}
}
type testConn struct {
users []map[string]interface{}
bindErr error
userErr error
rolesErr error
authRequest bool
claimsRequest bool
closed bool
}
func (c *testConn) Bind(bindDN, password string) error {
c.authRequest = true
if c.bindErr != nil {
return c.bindErr
}
user := c.findUser(bindDN)
if user == nil {
return fmt.Errorf("user is not found")
}
if user["pass"] != password {
return errInvalidCredentials
}
return nil
}
func (c *testConn) SearchUser(bindDN string, attrs ...string) ([]map[string]interface{}, error) {
c.claimsRequest = true
if c.userErr != nil {
return nil, c.userErr
}
user := c.findUser(bindDN)
if user == nil {
return nil, nil
}
return []map[string]interface{}{user}, nil
}
func (c *testConn) SearchUserRoles(bindDN string, attrs ...string) ([]map[string]interface{}, error) {
if c.rolesErr != nil {
return nil, c.rolesErr
}
user := c.findUser(bindDN)
if user == nil {
return nil, fmt.Errorf("user is not found")
}
switch roles := user["roles"].(type) {
case nil:
return nil, nil
case []map[string]interface{}:
return roles, nil
default:
return nil, fmt.Errorf("invalid test roles")
}
}
func (c *testConn) findUser(bindDN string) map[string]interface{} {
for _, v := range c.users {
if v["dn"] == bindDN {
return v
}
}
return nil
}
func (c *testConn) Close() {
c.closed = true
}

View File

@ -1,11 +1,18 @@
/*
Copyright (c) JSC iCore.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package stat
import (
"encoding/json"
"net/http"
"github.com/i-core/rlog"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
)
// Handler provides HTTP handlers for health checking and versioning.
@ -20,14 +27,14 @@ func NewHandler(version string) *Handler {
// AddRoutes registers all required routes for the package stat.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
apply(http.MethodGet, "/health/alive", newHealthAliveAndReadyHandler())
apply(http.MethodGet, "/health/ready", newHealthAliveAndReadyHandler())
apply(http.MethodGet, "/health/alive", newHealthHandler())
apply(http.MethodGet, "/health/ready", newHealthHandler())
apply(http.MethodGet, "/version", newVersionHandler(h.version))
}
func newHealthAliveAndReadyHandler() http.HandlerFunc {
func newHealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
log := rlog.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
@ -44,7 +51,7 @@ func newHealthAliveAndReadyHandler() http.HandlerFunc {
func newVersionHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
log := rlog.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{

View File

@ -0,0 +1,64 @@
package stat
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/i-core/routegroup"
)
func TestHealthHandler(t *testing.T) {
rr := httptest.NewRecorder()
h := newHealthHandler()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil))
testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
}
func TestVersionHandler(t *testing.T) {
rr := httptest.NewRecorder()
h := newVersionHandler("test-version")
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil))
testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"})
}
func TestStatHandler(t *testing.T) {
var (
rr *httptest.ResponseRecorder
router = routegroup.NewRouter()
)
router.AddRoutes(NewHandler("test-version"), "/stat")
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/alive", nil))
testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/ready", nil))
testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/version", nil))
testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"})
}
func testResp(t *testing.T, rr *httptest.ResponseRecorder, wantStatus int, wantMime string, wantBody interface{}) {
if rr.Code != wantStatus {
t.Errorf("got status %d, want status %d", rr.Code, wantStatus)
}
if gotMime := rr.Header().Get("Content-Type"); gotMime != wantMime {
t.Errorf("got content type %q, want content type %q", gotMime, wantMime)
}
var gotBody interface{}
if err := json.NewDecoder(rr.Body).Decode(&gotBody); err != nil {
t.Fatalf("failed to decode the request body: %s", err)
}
if !reflect.DeepEqual(gotBody, wantBody) {
t.Errorf("got body %#v, want body %#v", gotBody, wantBody)
}
}

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg web -prefix templates/ templates/...
@ -20,8 +20,8 @@ import (
"path"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/i-core/routegroup"
"github.com/pkg/errors"
"gopkg.i-core.ru/httputil"
)
// The file systems provide templates and their resources that are stored in the application's internal assets.
@ -39,7 +39,7 @@ type Config struct {
// HTMLRenderer renders a HTML page from a Go template.
//
// A template's source for a HTML page should contains four blocks:
// A template's source for a HTML page should contain four blocks:
// "title", "style", "js", "content". Block "title" should contain the content of the "title" HTML tag.
// Block "style" should contain "link" HTML tags that are injected to the head of the page.
// Block "js" should contain "script" HTML tags that are injected to the bottom of the page's body.
@ -149,7 +149,7 @@ func NewStaticHandler(cnf Config) *StaticHandler {
func (h *StaticHandler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
fileServer := http.FileServer(h.fs)
apply(http.MethodGet, "/*filepath", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = httputil.PathParam(r.Context(), "filepath")
r.URL.Path = routegroup.PathParam(r.Context(), "filepath")
fileServer.ServeHTTP(w, r)
}))
}

View File

@ -1,8 +1,8 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Copyright (c) JSC iCore.
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
package web
@ -17,7 +17,7 @@ import (
"testing"
"github.com/andreyvit/diff"
"gopkg.i-core.ru/httputil"
"github.com/i-core/routegroup"
)
func TestHTMLRenderer(t *testing.T) {
@ -170,7 +170,7 @@ func TestStaticHandler(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
rr := httptest.NewRecorder()
router := httputil.NewRouter()
router := routegroup.NewRouter()
router.AddRoutes(NewStaticHandler(cnf), "/static")
router.ServeHTTP(rr, r)