move to GitHub.
This commit is contained in:
parent
d761ad579a
commit
3bbac7bb74
2
.codecov.yml
Normal file
2
.codecov.yml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ignore:
|
||||||
|
- "internal/web/templates.go"
|
BIN
.github/media/screenshot.gif
vendored
Normal file
BIN
.github/media/screenshot.gif
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 272 KiB |
0
.gitignore
vendored
0
.gitignore
vendored
@ -1,7 +1,7 @@
|
|||||||
# Copyright (C) JSC iCore - All Rights Reserved
|
# Copyright (c) JSC iCore.
|
||||||
#
|
|
||||||
# Unauthorized copying of this file, via any medium is strictly prohibited
|
# This source code is licensed under the MIT license found in the
|
||||||
# Proprietary and confidential
|
# LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
test: true
|
test: true
|
||||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -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.
|
|
10
Dockerfile
10
Dockerfile
@ -1,7 +1,7 @@
|
|||||||
# Copyright (C) JSC iCore - All Rights Reserved
|
# Copyright (c) JSC iCore.
|
||||||
#
|
|
||||||
# Unauthorized copying of this file, via any medium is strictly prohibited
|
# This source code is licensed under the MIT license found in the
|
||||||
# Proprietary and confidential
|
# LICENSE file in the root directory of this source tree.
|
||||||
|
|
||||||
FROM golang:1.12-alpine AS build
|
FROM golang:1.12-alpine AS build
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ COPY go.mod .
|
|||||||
COPY go.sum .
|
COPY go.sum .
|
||||||
COPY cmd cmd
|
COPY cmd cmd
|
||||||
COPY internal internal
|
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
|
FROM scratch AS final
|
||||||
COPY --from=build /etc/passwd /etc/passwd
|
COPY --from=build /etc/passwd /etc/passwd
|
||||||
|
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
328
README.md
328
README.md
@ -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!**
|
![screenshot](.github/media/screenshot.gif)
|
||||||
**The current version is compatible with ORY Hydra v1.0.0-rc.12 or higher.**
|
|
||||||
|
|
||||||
## 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 ./...
|
go install ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Usage
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
1. Create a network:
|
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 \
|
docker run --network hydra-net -d --restart always --name hydra \
|
||||||
-p 4444:4444 \
|
-p 4444:4444 \
|
||||||
-p 4445:4445 \
|
-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_ISSUER=http://localhost:4444 \
|
||||||
-e URLS_SELF_PUBLIC=http://localhost:4444 \
|
-e URLS_SELF_PUBLIC=http://localhost:4444 \
|
||||||
-e URLS_LOGIN=http://$MY_HOST:3000/auth/login \
|
-e URLS_LOGIN=http://localhost:8080/auth/login \
|
||||||
-e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \
|
-e URLS_CONSENT=http://localhost:8080/auth/consent \
|
||||||
-e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \
|
-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 \
|
-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:
|
Look for details in [ORY Hydra Configuration][hydra-doc-config] and [ORY Hydra Documentation][hydra-doc].
|
||||||
```
|
|
||||||
docker run -it --rm oryd/hydra:v1.0.0-rc.12 serve --help
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Register a client:
|
3. Run Werther:
|
||||||
```
|
```
|
||||||
docker run -it --rm --network hydra-net \
|
docker run --network hydra-net -d --restart always --name werther \
|
||||||
-e HYDRA_ADMIN_URL=http://hydra:4445 \
|
-p 8080:8080 \
|
||||||
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 \
|
|
||||||
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
|
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
|
||||||
-e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
|
-e WERTHER_LDAP_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
|
||||||
-e WERTHER_LDAP_BINDDN=<BINDDN> \
|
-e WERTHER_LDAP_BINDDN=<BINDDN> \
|
||||||
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
|
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
|
||||||
-e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \
|
-e WERTHER_LDAP_BASEDN="DC=example,DC=local" \
|
||||||
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \
|
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=example,DC=local" \
|
||||||
hub.das.i-core.ru/p/base-werther
|
icoreru/werther
|
||||||
```
|
```
|
||||||
|
|
||||||
For all options see option help:
|
## Configuration
|
||||||
```
|
|
||||||
docker run -it --rm hub.das.i-core.ru/p/base-werther -help
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start an authentication process in a browser to get an access token:
|
The application is configured via environment variables.
|
||||||
```
|
Names of the environment variables starts with prefix `WERTHER_`.
|
||||||
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678
|
See a list of the environment variables using the command:
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Get user info:
|
```
|
||||||
```
|
werther -h
|
||||||
http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>"
|
```
|
||||||
```
|
|
||||||
|
|
||||||
For example, you can get the next output:
|
## User roles
|
||||||
```
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Length: 218
|
|
||||||
Content-Type: application/json
|
|
||||||
Date: Tue, 31 Jul 2018 17:17:51 GMT
|
|
||||||
Vary: Origin
|
|
||||||
|
|
||||||
|
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",
|
"https://github.com/i-core/werther/claims/roles": {
|
||||||
"family_name": "Lepa",
|
"App1": ["role1", "role2"],
|
||||||
"given_name": "Konstantin",
|
"App2": ["role1", "role2"]
|
||||||
"http://i-core.ru/claims/roles": {
|
}
|
||||||
"HeraldTest1": [
|
}
|
||||||
"user"
|
```
|
||||||
]
|
* when the environment variable `WERTHER_LDAP_ROLE_DN` equals to `OU=Dev,OU=AppRoles,OU=Domain Groups,DC=local`:
|
||||||
},
|
```json
|
||||||
"name": "Konstantin Lepa",
|
{
|
||||||
"sub": "CN=Konstantin Lepa,OU=Domain Users,DC=icore,DC=local"
|
"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:
|
Werther uses the Go templates to render UI pages.
|
||||||
```
|
To customize the UI you should create a directory that contains UI pages' templates.
|
||||||
http --session u1 -F -v get \
|
After that you should set the directory path to the environment variable `WERTHER_WEB_DIR`:
|
||||||
"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>"
|
|
||||||
```
|
|
||||||
|
|
||||||
9. Delete a user's session from a browser:
|
```bash
|
||||||
```
|
docker run --network hydra-net -d --restart always --name werther \
|
||||||
open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
|
-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:
|
### Custom login page
|
||||||
```
|
|
||||||
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".
|
|
||||||
|
|
||||||
|
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
|
When a login page's template contains static resources (like styles, scripts, and images)
|
||||||
```
|
they must be placed in a subdirectory called `static`.
|
||||||
docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
|
|
||||||
```
|
|
||||||
|
|
||||||
|
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
|
@ -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
|
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
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 (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
@ -13,27 +13,27 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"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/justinas/nosurf"
|
||||||
"github.com/kelseyhightower/envconfig"
|
"github.com/kelseyhightower/envconfig"
|
||||||
"go.uber.org/zap"
|
"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.
|
// version will be filled at compile time.
|
||||||
var Version = ""
|
var version = ""
|
||||||
|
|
||||||
// Config is a server's configuration.
|
// Config is a server's configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DevMode bool `envconfig:"dev_mode" default:"false" desc:"a development mode"`
|
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>)"`
|
Listen string `default:":8080" desc:"a host and port to listen on (<host>:<port>)"`
|
||||||
Web web.Config
|
|
||||||
Identp identp.Config
|
Identp identp.Config
|
||||||
LDAP ldapclient.Config
|
LDAP ldapclient.Config
|
||||||
|
Web web.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -49,7 +49,7 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *verflag {
|
if *verflag {
|
||||||
fmt.Println("werther", Version)
|
fmt.Println("werther", version)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,12 +77,12 @@ func main() {
|
|||||||
|
|
||||||
ldap := ldapclient.New(cnf.LDAP)
|
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(web.NewStaticHandler(cnf.Web), "/static")
|
||||||
router.AddRoutes(identp.NewHandler(cnf.Identp, ldap, htmlRenderer), "/auth")
|
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 = 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)))
|
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router)))
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
// +build tools
|
// +build tools
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Copyright (C) JSC iCore - All Rights Reserved
|
Copyright (c) JSC iCore.
|
||||||
|
|
||||||
Unauthorized copying of this file, via any medium is strictly prohibited
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
15
go.mod
15
go.mod
@ -1,4 +1,4 @@
|
|||||||
module gopkg.i-core.ru/werther
|
module github.com/i-core/werther
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/OneOfOne/xxhash v1.2.2 // indirect
|
github.com/OneOfOne/xxhash v1.2.2 // indirect
|
||||||
@ -7,22 +7,15 @@ require (
|
|||||||
github.com/coocood/freecache v1.0.1
|
github.com/coocood/freecache v1.0.1
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/elazarl/go-bindata-assetfs v1.0.0
|
github.com/elazarl/go-bindata-assetfs v1.0.0
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible // indirect
|
github.com/i-core/rlog v1.0.0
|
||||||
github.com/julienschmidt/httprouter v1.2.0 // indirect
|
github.com/i-core/routegroup v1.0.0
|
||||||
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect
|
|
||||||
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
|
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
|
||||||
github.com/kelseyhightower/envconfig v1.3.0
|
github.com/kelseyhightower/envconfig v1.3.0
|
||||||
github.com/kevinburke/go-bindata v3.13.0+incompatible
|
github.com/kevinburke/go-bindata v3.13.0+incompatible
|
||||||
github.com/pkg/errors v0.8.1
|
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/sergi/go-diff v1.0.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
|
||||||
github.com/stretchr/testify v1.2.2 // indirect
|
go.uber.org/zap v1.10.0
|
||||||
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
|
|
||||||
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
|
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
|
||||||
gopkg.in/ldap.v2 v2.5.1
|
gopkg.in/ldap.v2 v2.5.1
|
||||||
)
|
)
|
||||||
|
22
go.sum
22
go.sum
@ -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/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 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
|
||||||
github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk=
|
||||||
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
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 h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
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 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
|
||||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
|
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/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 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
go.uber.org/atomic v1.2.0 h1:yVVGhClJ8Xi1y4TxhJZE6QFPrz76BrzhWA01n47mSFk=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
go.uber.org/atomic v1.2.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
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 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
|
||||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
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.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=
|
||||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.10.0/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=
|
|
||||||
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY=
|
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/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
|
||||||
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=
|
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=
|
||||||
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package hydra
|
package hydra
|
||||||
|
240
internal/hydra/consent_test.go
Normal file
240
internal/hydra/consent_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package hydra
|
package hydra
|
||||||
@ -18,6 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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 is an error that happens when authentication is failed.
|
||||||
ErrUnauthenticated = errors.New("unauthenticated")
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
// ErrChallengeNotFound is an error that happens when an unknown challenge is used.
|
// 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) {
|
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))
|
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s?%[1]s_challenge=%s", string(typ), challenge))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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))
|
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s/accept?%[1]s_challenge=%s", string(typ), challenge))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package hydra
|
package hydra
|
||||||
|
246
internal/hydra/login_test.go
Normal file
246
internal/hydra/login_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package hydra
|
package hydra
|
||||||
|
177
internal/hydra/logout_test.go
Normal file
177
internal/hydra/logout_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
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)
|
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
|
||||||
@ -16,20 +16,20 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/i-core/rlog"
|
||||||
|
"github.com/i-core/werther/internal/hydra"
|
||||||
"github.com/justinas/nosurf"
|
"github.com/justinas/nosurf"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.i-core.ru/logutil"
|
|
||||||
"gopkg.i-core.ru/werther/internal/hydra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const loginTmplName = "login.tmpl"
|
const loginTmplName = "login.tmpl"
|
||||||
|
|
||||||
// Config is a Hydra configuration.
|
// Config is a Hydra configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
HydraURL string `envconfig:"hydra_url" required:"true" desc:"a server admin URL of ORY Hydra"`
|
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 session TTL"`
|
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%2Fi-core.ru%2Fclaims%2Froles:roles" desc:"a mapping of OIDC claims to scopes (all claims are URL encoded)"`
|
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.
|
// 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 {
|
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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")
|
challenge := r.URL.Query().Get("login_challenge")
|
||||||
if challenge == "" {
|
if challenge == "" {
|
||||||
log.Debug("No login challenge that is needed by the OAuth2 provider")
|
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 {
|
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := logutil.FromContext(r.Context()).Sugar()
|
log := rlog.FromContext(r.Context()).Sugar()
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
|
|
||||||
challenge := r.Form.Get("login_challenge")
|
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 {
|
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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")
|
challenge := r.URL.Query().Get("consent_challenge")
|
||||||
if challenge == "" {
|
if challenge == "" {
|
||||||
@ -297,7 +297,7 @@ type oa2LogoutReqProcessor interface {
|
|||||||
|
|
||||||
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
|
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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")
|
challenge := r.URL.Query().Get("logout_challenge")
|
||||||
if challenge == "" {
|
if challenge == "" {
|
||||||
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package identp
|
package identp
|
||||||
@ -18,9 +18,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/i-core/werther/internal/hydra"
|
||||||
"github.com/justinas/nosurf"
|
"github.com/justinas/nosurf"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gopkg.i-core.ru/werther/internal/hydra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHandleLoginStart(t *testing.T) {
|
func TestHandleLoginStart(t *testing.T) {
|
||||||
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package ldapclient
|
package ldapclient
|
||||||
@ -17,22 +17,44 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/coocood/freecache"
|
"github.com/coocood/freecache"
|
||||||
|
"github.com/i-core/rlog"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.i-core.ru/logutil"
|
|
||||||
ldap "gopkg.in/ldap.v2"
|
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.
|
// Config is a LDAP configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
|
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"`
|
BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"`
|
||||||
BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"`
|
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"`
|
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"`
|
RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP group's attribute that contains a role's name"`
|
||||||
RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list
|
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"`
|
||||||
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"`
|
|
||||||
CacheSize int `envconfig:"cache_size" default:"512" desc:"a user info cache's size in KiB"`
|
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"`
|
CacheTTL time.Duration `envconfig:"cache_ttl" default:"30m" desc:"a user info cache TTL"`
|
||||||
}
|
}
|
||||||
@ -40,16 +62,15 @@ type Config struct {
|
|||||||
// Client is a LDAP client (compatible with Active Directory).
|
// Client is a LDAP client (compatible with Active Directory).
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Config
|
Config
|
||||||
|
connector connector
|
||||||
cache *freecache.Cache
|
cache *freecache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new LDAP client.
|
// New creates a new LDAP client.
|
||||||
func New(cnf Config) *Client {
|
func New(cnf Config) *Client {
|
||||||
if cnf.RoleClaim == "" {
|
|
||||||
cnf.RoleClaim = "http://i-core.ru/claims/roles"
|
|
||||||
}
|
|
||||||
return &Client{
|
return &Client{
|
||||||
Config: cnf,
|
Config: cnf,
|
||||||
|
connector: &ldapConnector{BaseDN: cnf.BaseDN, RoleBaseDN: cnf.RoleBaseDN},
|
||||||
cache: freecache.NewCache(cnf.CacheSize * 1024),
|
cache: freecache.NewCache(cnf.CacheSize * 1024),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,10 +85,10 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
|
|||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
ctx, cancel = context.WithCancel(ctx)
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
cn, ok := <-cli.dialTCP(ctx)
|
cn, ok := <-cli.connect(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, errors.New("connection timeout")
|
return false, errConnectionTimeout
|
||||||
}
|
}
|
||||||
defer cn.Close()
|
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 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, nil
|
||||||
}
|
}
|
||||||
return false, err
|
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.
|
// 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 {
|
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")
|
log.Debug("Cleared user's OIDC claims in the cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
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.
|
// FindOIDCClaims finds all OIDC claims for a user.
|
||||||
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
|
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.
|
// 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 {
|
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
|
var cancel context.CancelFunc
|
||||||
ctx, cancel = context.WithCancel(ctx)
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
cn, ok := <-cli.dialTCP(ctx)
|
cn, ok := <-cli.connect(ctx)
|
||||||
cancel()
|
cancel()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("connection timeout")
|
return nil, errConnectionTimeout
|
||||||
}
|
}
|
||||||
defer cn.Close()
|
defer cn.Close()
|
||||||
|
|
||||||
@ -208,11 +164,11 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if details == nil {
|
if details == nil {
|
||||||
return nil, errors.New("unknown username")
|
return nil, errUnknownUsername
|
||||||
}
|
}
|
||||||
log.Infow("Retrieved user's info from LDAP", "details", details)
|
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{})
|
claims := make(map[string]interface{})
|
||||||
for attr, v := range details {
|
for attr, v := range details {
|
||||||
if claim, ok := cli.AttrClaims[attr]; ok {
|
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
|
// 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.
|
// that include the user as a member.
|
||||||
query := fmt.Sprintf("(&(objectClass=group)(member=%s))", details["dn"])
|
entries, err := cn.SearchUserRoles(fmt.Sprintf("%s", details["dn"]), "dn", cli.RoleAttr)
|
||||||
entries, err := cli.searchEntries(cn, cli.RoleBaseDN, query, "dn", cli.RoleAttr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
roles := make(map[string][]string)
|
roles := make(map[string]interface{})
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
roleDN := entry["dn"].(string)
|
roleDN, ok := entry["dn"].(string)
|
||||||
if roleDN == "" {
|
if !ok || roleDN == "" {
|
||||||
log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry)
|
log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry)
|
||||||
continue
|
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
|
// 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.
|
// where the CN is for uniqueness only, and the OU is an application id.
|
||||||
v := strings.Split(roleDN[:n-k-1], ",")
|
path := strings.Split(roleDN[:n-k-1], ",")
|
||||||
if len(v) != 2 {
|
if len(path) != 2 {
|
||||||
log.Infow("A role's DN without the role's base DN must contain two nodes only",
|
log.Infow("A role's DN without the role's base DN must contain two nodes only",
|
||||||
"roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN)
|
"roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
appID := v[1][len("OU="):]
|
appID := path[1][len("OU="):]
|
||||||
roles[appID] = append(roles[appID], entry[cli.RoleAttr].(string))
|
|
||||||
|
var appRoles []interface{}
|
||||||
|
if v := roles[appID]; v != nil {
|
||||||
|
appRoles = v.([]interface{})
|
||||||
|
}
|
||||||
|
roles[appID] = append(appRoles, entry[cli.RoleAttr])
|
||||||
}
|
}
|
||||||
claims[cli.RoleClaim] = roles
|
claims[cli.RoleClaim] = roles
|
||||||
|
|
||||||
@ -271,11 +231,114 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
|
|||||||
return claims, nil
|
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.
|
// 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) {
|
func (c *ldapConn) searchEntries(baseDN, query string, attrs []string) ([]map[string]interface{}, error) {
|
||||||
res, err := cn.Search(ldap.NewSearchRequest(
|
req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil)
|
||||||
baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil,
|
res, err := c.Search(req)
|
||||||
))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
588
internal/ldapclient/ldapclient_test.go
Normal file
588
internal/ldapclient/ldapclient_test.go
Normal 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
|
||||||
|
}
|
@ -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
|
package stat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/i-core/rlog"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gopkg.i-core.ru/logutil"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler provides HTTP handlers for health checking and versioning.
|
// 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.
|
// 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)) {
|
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/alive", newHealthHandler())
|
||||||
apply(http.MethodGet, "/health/ready", newHealthAliveAndReadyHandler())
|
apply(http.MethodGet, "/health/ready", newHealthHandler())
|
||||||
apply(http.MethodGet, "/version", newVersionHandler(h.version))
|
apply(http.MethodGet, "/version", newVersionHandler(h.version))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newHealthAliveAndReadyHandler() http.HandlerFunc {
|
func newHealthHandler() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := logutil.FromContext(r.Context())
|
log := rlog.FromContext(r.Context())
|
||||||
resp := struct {
|
resp := struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}{
|
}{
|
||||||
@ -44,7 +51,7 @@ func newHealthAliveAndReadyHandler() http.HandlerFunc {
|
|||||||
|
|
||||||
func newVersionHandler(version string) http.HandlerFunc {
|
func newVersionHandler(version string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := logutil.FromContext(r.Context())
|
log := rlog.FromContext(r.Context())
|
||||||
resp := struct {
|
resp := struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}{
|
}{
|
||||||
|
64
internal/stat/stat_test.go
Normal file
64
internal/stat/stat_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
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/...
|
//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"
|
"path"
|
||||||
|
|
||||||
assetfs "github.com/elazarl/go-bindata-assetfs"
|
assetfs "github.com/elazarl/go-bindata-assetfs"
|
||||||
|
"github.com/i-core/routegroup"
|
||||||
"github.com/pkg/errors"
|
"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.
|
// 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.
|
// 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.
|
// "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 "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.
|
// 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)) {
|
func (h *StaticHandler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
|
||||||
fileServer := http.FileServer(h.fs)
|
fileServer := http.FileServer(h.fs)
|
||||||
apply(http.MethodGet, "/*filepath", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
fileServer.ServeHTTP(w, r)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -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
|
This source code is licensed under the MIT license found in the
|
||||||
Proprietary and confidential
|
LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package web
|
package web
|
||||||
@ -17,7 +17,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/andreyvit/diff"
|
"github.com/andreyvit/diff"
|
||||||
"gopkg.i-core.ru/httputil"
|
"github.com/i-core/routegroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHTMLRenderer(t *testing.T) {
|
func TestHTMLRenderer(t *testing.T) {
|
||||||
@ -170,7 +170,7 @@ func TestStaticHandler(t *testing.T) {
|
|||||||
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
|
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
router := httputil.NewRouter()
|
router := routegroup.NewRouter()
|
||||||
router.AddRoutes(NewStaticHandler(cnf), "/static")
|
router.AddRoutes(NewStaticHandler(cnf), "/static")
|
||||||
router.ServeHTTP(rr, r)
|
router.ServeHTTP(rr, r)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user