logout: add support of logout flow

This commit is contained in:
Nikolay Stupak 2019-05-15 15:03:05 +03:00
parent 6658817311
commit d761ad579a
42 changed files with 1760 additions and 1356 deletions

View File

@ -2,17 +2,13 @@
#
# Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential
#
# Written by Konstantin Lepa <klepa@i-core.ru>, September 2018
run:
test: true
silent: true
linters-settings:
govet:
check-shadowing: true
linters:
disable-all: true
enable:
@ -27,3 +23,5 @@ linters:
- interfacer
- unconvert
- govet
issues:
exclude-use-default: false

View File

@ -4,6 +4,21 @@ 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.

View File

@ -2,10 +2,8 @@
#
# Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential
#
# Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
FROM golang:1.11-alpine AS build
FROM golang:1.12-alpine AS build
ARG VERSION
ARG GOPROXY
@ -18,7 +16,7 @@ COPY go.mod .
COPY go.sum .
COPY cmd cmd
COPY internal internal
RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/internal/server.Version=${VERSION}" ./...
RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/cmd/werther.Version=${VERSION}" ./...
FROM scratch AS final
COPY --from=build /etc/passwd /etc/passwd

103
README.md
View File

@ -2,7 +2,10 @@
# Werther
Werther is a login provider for ORY Hydra that is an OAuth2 provider.
Werther is an identity provider for ORY Hydra that is an OAuth2 provider.
**Important!**
**The current version is compatible with ORY Hydra v1.0.0-rc.12 or higher.**
## Build
```
@ -12,8 +15,7 @@ go install ./...
## Development
Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port for OAuth2 Provider Hydra,
3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in one minute.
There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
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:
```
@ -22,53 +24,55 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
2. Run ORY Hydra:
```
docker run --network hydra-net -d --restart always --name hydra \
-p 4444:4444 \
-p 4445:4445 \
-e OAUTH2_SHARE_ERROR_DEBUG=1 \
-e LOG_LEVEL=debug \
-e ACCESS_TOKEN_LIFESPAN=10m \
-e ID_TOKEN_LIFESPAN=10m \
-e CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \
-e CORS_ALLOWED_CREDENTIALS=true \
-e OIDC_DISCOVERY_SCOPES_SUPPORTED=profile,email,phone \
-e OIDC_DISCOVERY_CLAIMS_SUPPORTED=name,family_name,given_name,nickname,email,phone_number \
-e OAUTH2_CONSENT_URL=http://$MY_HOST:3000/auth/consent \
-e OAUTH2_LOGIN_URL=http://$MY_HOST:3000/auth/login \
-e OAUTH2_ISSUER_URL=http://$MY_HOST:4444 \
-e DATABASE_URL=memory \
oryd/hydra:$HYDRA_VERSION serve all --dangerous-force-http
docker run --network hydra-net -d --restart always --name hydra \
-p 4444:4444 \
-p 4445:4445 \
-e OAUTH2_EXPOSE_INTERNAL_ERRORS=true \
-e LOG_LEVEL=debug \
-e TTL_ACCESS_TOKEN=10m \
-e TTL_ID_TOKEN=10m \
-e SERVE_PUBLIC_CORS_ENABLED=true \
-e SERVE_PUBLIC_CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \
-e SERVE_PUBLIC_CORS_ALLOW_CREDENTIALS=true \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
-e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e URLS_SELF_ISSUER=http://localhost:4444 \
-e URLS_SELF_PUBLIC=http://localhost:4444 \
-e URLS_LOGIN=http://$MY_HOST:3000/auth/login \
-e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \
-e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \
-e DSN=memory \
oryd/hydra:v1.0.0-rc.12 serve all --dangerous-force-http
```
You can learn additional properties with help command:
```
docker run -it --rm oryd/hydra:$HYDRA_VERSION serve --help
docker run -it --rm oryd/hydra:v1.0.0-rc.12 serve --help
```
3. Register a client:
```
docker run -it --rm --network hydra-net \
-e HYDRA_ADMIN_URL=http://hydra:4445 \
oryd/hydra:$HYDRA_VERSION clients create \
--skip-tls-verify \
--id test-client \
--secret test-secret \
--response-types id_token,token,"id_token token" \
--grant-types implicit \
--scope openid,profile,email \
--callbacks http://$MY_HOST:8080
docker run -it --rm --network hydra-net \
-e HYDRA_ADMIN_URL=http://hydra:4445 \
oryd/hydra:$HYDRA_VERSION clients create \
--skip-tls-verify \
--id test-client \
--secret test-secret \
--response-types id_token,token,"id_token token" \
--grant-types implicit \
--scope openid,profile,email \
--callbacks http://$MY_HOST:8080 \
--post-logout-callbacks http://$MY_HOST:8080/post-logout-callback
```
4. Run Werther:
```
docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \
-e WERTHER_LOG_FORMAT=console \
-e WERTHER_HYDRA_ADMIN_URL=http://hydra:4445 \
-e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
-e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \
docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
-e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
-e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \
hub.das.i-core.ru/p/base-werther
```
@ -78,12 +82,16 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
docker run -it --rm hub.das.i-core.ru/p/base-werther -help
```
5. Start an authentication process in a browser:
5. Start an authentication process in a browser to get an access token:
```
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678
```
6. Start an authentication process in a browser to get an access token and id token:
```
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=id_token%20token&scope=openid%20profile%20email&state=12345678&nonce=87654321
```
6. Get user info:
7. Get user info:
```
http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>"
```
@ -95,7 +103,7 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
Content-Type: application/json
Date: Tue, 31 Jul 2018 17:17:51 GMT
Vary: Origin
{
"email": "klepa@i-core.ru",
"family_name": "Lepa",
@ -112,19 +120,26 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
Look for details in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter).
7. Re-get a token by httpie:
8. Re-get a token by httpie:
```
http --session u1 -F -v get \
"http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile&state=12345678&prompt=none" \
"Cookie:<COOKIES_FROM_WERTHER_DOMAIN>"
```
8. Delete a user's session from a browser:
9. Delete a user's session from a browser:
```
open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
```
9. (Optional) Sniff TCP packets between Hydra and Werther
10. Log a user out from a browser:
```
open http://$MY_HOST:4444/oauth2/sessions/logout?id_token_hint=<id_token>&post_logout_redirect_uri=http://$MY_HOST:8080/post-logout-callback&state=87654321
```
After a successful logout, a user will be redirected to the page "http://$MY_HOST:8080/post-logout-callback?state=87654321".
11. (Optional) Sniff TCP packets between Hydra and Werther
```
docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
```

View File

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

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package main // import "gopkg.i-core.ru/werther/cmd/werther"
@ -15,17 +13,35 @@ import (
"net/http"
"os"
"github.com/justinas/nosurf"
"github.com/kelseyhightower/envconfig"
"go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/server"
"gopkg.i-core.ru/httputil"
"gopkg.i-core.ru/logutil"
"gopkg.i-core.ru/werther/internal/identp"
"gopkg.i-core.ru/werther/internal/ldapclient"
"gopkg.i-core.ru/werther/internal/stat"
"gopkg.i-core.ru/werther/internal/web"
)
// Version will be filled at compile time.
var Version = ""
// Config is a server's configuration.
type Config struct {
DevMode bool `envconfig:"dev_mode" default:"false" desc:"a development mode"`
Listen string `default:":8080" desc:"a host and port to listen on (<host>:<port>)"`
Web web.Config
Identp identp.Config
LDAP ldapclient.Config
}
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\n")
if err := envconfig.Usagef("werther", &server.Config{}, flag.CommandLine.Output(), envconfig.DefaultListFormat); err != nil {
if err := envconfig.Usagef("werther", &Config{}, flag.CommandLine.Output(), envconfig.DefaultListFormat); err != nil {
panic(err)
}
}
@ -33,11 +49,11 @@ func main() {
flag.Parse()
if *verflag {
fmt.Println("werther", server.Version)
fmt.Println("werther", Version)
os.Exit(0)
}
var cnf server.Config
var cnf Config
if err := envconfig.Process("werther", &cnf); err != nil {
fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err)
os.Exit(1)
@ -53,13 +69,20 @@ func main() {
os.Exit(1)
}
srv, err := server.New(cnf, log)
htmlRenderer, err := web.NewHTMLRenderer(cnf.Web)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err)
os.Exit(1)
}
ldap := ldapclient.New(cnf.LDAP)
router := httputil.NewRouter(nosurf.NewPure, logutil.RequestLog(log))
router.AddRoutes(web.NewStaticHandler(cnf.Web), "/static")
router.AddRoutes(identp.NewHandler(cnf.Identp, ldap, htmlRenderer), "/auth")
router.AddRoutes(stat.NewHandler(Version), "/stat")
log = log.Named("main")
log.Info("Werther started", zap.Any("config", cnf), zap.String("version", server.Version))
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, srv)))
log.Info("Werther started", zap.Any("config", cnf), zap.String("version", Version))
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router)))
}

View File

@ -5,8 +5,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package main

18
go.mod
View File

@ -2,31 +2,27 @@ module gopkg.i-core.ru/werther
require (
github.com/OneOfOne/xxhash v1.2.2 // indirect
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 // indirect
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883
github.com/cespare/xxhash v1.0.0 // indirect
github.com/coocood/freecache v1.0.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/elazarl/go-bindata-assetfs v1.0.0
github.com/gofrs/uuid v3.2.0+incompatible
github.com/golang/protobuf v1.2.0 // indirect
github.com/julienschmidt/httprouter v1.2.0
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da
github.com/gofrs/uuid v3.2.0+incompatible // indirect
github.com/julienschmidt/httprouter v1.2.0 // indirect
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
github.com/kelseyhightower/envconfig v1.3.0
github.com/kevinburke/go-bindata v3.13.0+incompatible
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/pkg/errors v0.8.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v0.8.0
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 // indirect
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e // indirect
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 // indirect
github.com/sergi/go-diff v1.0.0 // indirect
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
github.com/stretchr/testify v1.2.2 // indirect
go.uber.org/atomic v1.2.0 // indirect
go.uber.org/multierr v1.1.0 // indirect
go.uber.org/zap v1.9.1
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
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/ldap.v2 v2.5.1
)

26
go.sum
View File

@ -1,7 +1,7 @@
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A=
github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4=
github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
@ -12,8 +12,6 @@ github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3C
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
@ -22,22 +20,14 @@ github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 h1:zL6nij8mNcIiohu
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4/go.mod h1:Aucr5I5chr4OCuuVB4LTuHVrKHBuyRSo7vM2hqrcb7E=
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kevinburke/go-bindata v3.13.0+incompatible h1:+1zvIFm0AWO3wJmjRIcKV1cuwF1urt7WsJBhordma/k=
github.com/kevinburke/go-bindata v3.13.0+incompatible h1:hThDhUBH4KjTyhfXfOgacEPfFBNjltnzl/xzfLfrPoQ=
github.com/kevinburke/go-bindata v3.13.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8=
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54=
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 h1:agujYaXJSxSo18YNX3jzl+4G6Bstwt+kqv47GS12uL0=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
@ -48,8 +38,10 @@ go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=

View File

@ -3,35 +3,33 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package hydra
import (
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
)
// ConsentReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process.
type ConsentReqDoer struct {
hydraURL string
hydraURL string
rememberFor int
}
// NewConsentRequest creates a ConsentRequest.
func NewConsentReqDoer(hydraURL string) *ConsentReqDoer {
return &ConsentReqDoer{hydraURL: hydraURL}
// NewConsentReqDoer creates a ConsentRequest.
func NewConsentReqDoer(hydraURL string, rememberFor int) *ConsentReqDoer {
return &ConsentReqDoer{hydraURL: hydraURL, rememberFor: rememberFor}
}
// InitiateRequest fetches information on the OAuth2 request.
func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(consent, crd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate consent request")
}
// Accept accepts the requested authentication process, and returns redirect URI.
func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) {
// AcceptConsentRequest accepts the requested authentication process, and returns redirect URI.
func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
type session struct {
IDToken interface{} `json:"id_token,omitempty"`
}
@ -43,7 +41,7 @@ func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool,
}{
GrantScope: grantScope,
Remember: remember,
RememberFor: rememberFor,
RememberFor: crd.rememberFor,
Session: session{
IDToken: idToken,
},

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package hydra
@ -12,12 +10,20 @@ package hydra
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
)
"gopkg.i-core.ru/werther/internal/oauth2"
var (
// ErrUnauthenticated is an error that happens when authentication is failed.
ErrUnauthenticated = errors.New("unauthenticated")
// ErrChallengeNotFound is an error that happens when an unknown challenge is used.
ErrChallengeNotFound = errors.New("challenge not found")
// ErrChallengeExpired is an error that happens when a challenge is already used.
ErrChallengeExpired = errors.New("challenge expired")
)
type reqType string
@ -25,10 +31,19 @@ type reqType string
const (
login reqType = "login"
consent reqType = "consent"
logout reqType = "logout"
)
func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, error) {
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s", string(typ), challenge))
// ReqInfo contains information on an ongoing login or consent request.
type ReqInfo struct {
Challenge string `json:"challenge"`
RequestedScopes []string `json:"requested_scope"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
}
func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error) {
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s?%[1]s_challenge=%s", string(typ), challenge))
if err != nil {
return nil, err
}
@ -49,43 +64,15 @@ func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo,
if err != nil {
return nil, err
}
var ri oauth2.ReqInfo
var ri ReqInfo
if err := json.Unmarshal(data, &ri); err != nil {
return nil, err
}
return &ri, nil
}
func checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 302 {
return nil
}
if resp.StatusCode == 404 {
return oauth2.ErrChallengeNotFound
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
type errorResult struct {
Message string `json:"error"`
}
var rs errorResult
if err := json.Unmarshal(data, &rs); err != nil {
return err
}
switch resp.StatusCode {
case 401:
return oauth2.ErrUnauthenticated
case 409:
return oauth2.ErrChallengeExpired
default:
return fmt.Errorf("bad HTTP status code %d", resp.StatusCode)
}
}
func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (string, error) {
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s/accept", 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 {
return "", err
}
@ -95,9 +82,11 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
}
u = u.ResolveReference(ref)
body, err := json.Marshal(data)
if err != nil {
return "", err
var body []byte
if data != nil {
if body, err = json.Marshal(data); err != nil {
return "", err
}
}
r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body))
@ -113,10 +102,9 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
if err := checkResponse(resp); err != nil {
return "", err
}
type result struct {
var rs struct {
RedirectTo string `json:"redirect_to"`
}
var rs result
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&rs); err != nil {
return "", err
@ -124,6 +112,33 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
return rs.RedirectTo, nil
}
func checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 302 {
return nil
}
switch resp.StatusCode {
case 401:
return ErrUnauthenticated
case 404:
return ErrChallengeNotFound
case 409:
return ErrChallengeExpired
default:
var rs struct {
Message string `json:"error"`
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.Unmarshal(data, &rs); err != nil {
return err
}
return fmt.Errorf("bad HTTP status code %d with message %q", resp.StatusCode, rs.Message)
}
}
func parseURL(s string) (*url.URL, error) {
if len(s) > 0 && s[len(s)-1] != '/' {
s += "/"

View File

@ -3,42 +3,40 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package hydra
import (
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
)
// LoginReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process.
type LoginReqDoer struct {
hydraURL string
hydraURL string
rememberFor int
}
// NewLoginRequest creates a LoginRequest.
func NewLoginReqDoer(hydraURL string) *LoginReqDoer {
return &LoginReqDoer{hydraURL: hydraURL}
// NewLoginReqDoer creates a LoginRequest.
func NewLoginReqDoer(hydraURL string, rememberFor int) *LoginReqDoer {
return &LoginReqDoer{hydraURL: hydraURL, rememberFor: rememberFor}
}
// InitiateRequest fetches information on the OAuth2 request.
func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(login, lrd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate login request")
}
// Accept accepts the requested authentication process, and returns redirect URI.
func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error) {
// AcceptLoginRequest accepts the requested authentication process, and returns redirect URI.
func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) {
data := struct {
Remember bool `json:"remember"`
RememberFor int `json:"remember_for"`
Subject string `json:"subject"`
}{
Remember: remember,
RememberFor: rememberFor,
RememberFor: lrd.rememberFor,
Subject: subject,
}
redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data)

34
internal/hydra/logout.go Normal file
View File

@ -0,0 +1,34 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package hydra
import (
"github.com/pkg/errors"
)
// LogoutReqDoer fetches information on the OAuth2 request and then accepts or rejects the requested logout process.
type LogoutReqDoer struct {
hydraURL string
}
// NewLogoutReqDoer creates a LogoutRequest.
func NewLogoutReqDoer(hydraURL string) *LogoutReqDoer {
return &LogoutReqDoer{hydraURL: hydraURL}
}
// InitiateRequest fetches information on the OAuth2 request.
func (lrd *LogoutReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(logout, lrd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate logout request")
}
// AcceptLogoutRequest accepts the requested logout process, and returns redirect URI.
func (lrd *LogoutReqDoer) AcceptLogoutRequest(challenge string) (string, error) {
redirectURI, err := acceptRequest(logout, lrd.hydraURL, challenge, nil)
return redirectURI, errors.Wrap(err, "failed to accept logout request")
}

332
internal/identp/identp.go Normal file
View File

@ -0,0 +1,332 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
package identp
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
"gopkg.i-core.ru/werther/internal/hydra"
)
const loginTmplName = "login.tmpl"
// Config is a Hydra configuration.
type Config struct {
HydraURL string `envconfig:"hydra_url" required:"true" desc:"a server admin URL of ORY Hydra"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Fi-core.ru%2Fclaims%2Froles:roles" desc:"a mapping of OIDC claims to scopes (all claims are URL encoded)"`
}
// UserManager is an interface that is used for authentication and providing user's claims.
type UserManager interface {
authenticator
oidcClaimsFinder
}
// authenticator is an interface that is used for a user authentication.
//
// Authenticate returns false if the username or password is invalid.
type authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
type oidcClaimsFinder interface {
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
}
// TemplateRenderer renders a template with data and writes it to a http.ResponseWriter.
type TemplateRenderer interface {
RenderTemplate(w http.ResponseWriter, name string, data interface{}) error
}
// LoginTmplData is a data that is needed for rendering the login page.
type LoginTmplData struct {
CSRFToken string
Challenge string
LoginURL string
IsInvalidCredentials bool
IsInternalError bool
}
// Handler provides HTTP handlers that implement [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
type Handler struct {
Config
um UserManager
tr TemplateRenderer
}
// NewHandler creates a new Handler.
//
// The template's renderer must be able to render a template with name "login.tmpl".
// The template is a template of the login page. It receives struct LoginTmplData as template's data.
func NewHandler(cnf Config, um UserManager, tr TemplateRenderer) *Handler {
return &Handler{Config: cnf, um: um, tr: tr}
}
// AddRoutes registers all required routes for Login & Consent Provider.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
sessionTTL := int(h.SessionTTL.Seconds())
apply(http.MethodGet, "/login", newLoginStartHandler(hydra.NewLoginReqDoer(h.HydraURL, 0), h.tr))
apply(http.MethodPost, "/login", newLoginEndHandler(hydra.NewLoginReqDoer(h.HydraURL, sessionTTL), h.um, h.tr))
apply(http.MethodGet, "/consent", newConsentHandler(hydra.NewConsentReqDoer(h.HydraURL, sessionTTL), h.um, h.ClaimScopes))
apply(http.MethodGet, "/logout", newLogoutHandler(hydra.NewLogoutReqDoer(h.HydraURL)))
}
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
type oa2LoginReqAcceptor interface {
AcceptLoginRequest(challenge string, remember bool, subject string) (string, error)
}
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2LoginReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
oa2LoginReqAcceptor
}
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown login challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Login challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to initiate an OAuth2 login request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
if ri.Skip {
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, ri.Subject)
if err != nil {
log.Infow("Failed to accept an OAuth login request", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
}
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
r.ParseForm()
challenge := r.Form.Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: r.URL.String(),
}
username, password := r.Form.Get("username"), r.Form.Get("password")
switch ok, err := auther.Authenticate(r.Context(), username, password); {
case err != nil:
data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
case !ok:
data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
remember := r.Form.Get("remember") != ""
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, username)
if err != nil {
data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err))
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2ConsentReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error)
}
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
log.Debug("No consent challenge that is needed by the OAuth2 provider")
http.Error(w, "No consent challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown consent challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Consent challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 consent request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
if err != nil {
log.Infow("Failed to find user's OIDC claims", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Found user's OIDC claims", "claims", claims)
// Remove claims that are not in the requested scopes.
for claim := range claims {
var found bool
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
// It is because of config option's format compatibility.
if scope, ok := claimScopes[url.QueryEscape(claim)]; ok {
for _, rscope := range ri.RequestedScopes {
if rscope == scope {
found = true
break
}
}
}
if !found {
delete(claims, claim)
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
}
}
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, ri.RequestedScopes, claims)
if err != nil {
log.Infow("Failed to accept a consent request to the OAuth2 provider", zap.Error(err), "scopes", ri.RequestedScopes, "claims", claims)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2LogoutReqProcessor is an interface that is used for creating and accepting an OAuth2 logout request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
type oa2LogoutReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptLogoutRequest(challenge string) (string, error)
}
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("logout_challenge")
if challenge == "" {
log.Debug("No logout challenge that is needed by the OAuth2 provider")
http.Error(w, "No logout challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown logout challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown logout challenge", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 logout request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A logout request is initiated", "challenge", challenge, "username", ri.Subject)
redirectTo, err := rproc.AcceptLogoutRequest(challenge)
if err != nil {
log.Infow("Failed to accept the logout request to the OAuth2 provider", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the logout request to the OAuth2 provider")
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}

View File

@ -0,0 +1,568 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package identp
import (
"context"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"testing"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/hydra"
)
func TestHandleLoginStart(t *testing.T) {
testCases := []struct {
name string
challenge string
scopes []string
skip bool
subject string
redirect string
wantStatus int
wantInitErr error
wantAcceptErr error
wantLoc string
wantBody string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
wantStatus: http.StatusOK,
wantBody: `
LoginURL: login?login_challenge=foo;
CSRFToken: true;
Challenge: foo;
`,
},
{
name: "skip",
challenge: "foo",
skip: true,
wantLoc: "/",
wantStatus: http.StatusFound,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "init error",
challenge: "foo",
wantInitErr: errors.New("init error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept error",
challenge: "foo",
skip: true,
wantAcceptErr: errors.New("accept error"),
wantStatus: http.StatusInternalServerError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
if tc.challenge != "" {
url += "?login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken }} true {{- else -}} false {{- end }};
Challenge: {{ .Challenge }};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl.Execute(w, data)
},
}
rproc := testLoginReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{
Challenge: tc.challenge,
RequestedScopes: tc.scopes,
Skip: tc.skip,
Subject: tc.subject,
}, tc.wantInitErr
},
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
},
}
handler := nosurf.New(newLoginStartHandler(rproc, tmplRenderer))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
func noindent(s string) string {
wsRe := regexp.MustCompile(`(?:^\s+|(;)\s+)`)
return wsRe.ReplaceAllString(s, "$1 ")
}
type testLoginReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string, bool, string) (string, error)
}
func (lrp testLoginReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return lrp.initReqFunc(challenge)
}
func (lrp testLoginReqProc) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) {
return lrp.acceptReqFunc(challenge, remember, subject)
}
func TestHandleLoginEnd(t *testing.T) {
testCases := []struct {
name string
challenge string
subject string
webBasePath string
redirect string
wantStatus int
wantAcceptErr error
wantAuthErr error
wantInvAuth bool
wantLoc string
wantBody string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
{
name: "auth unknown error",
challenge: "foo",
subject: "joe",
webBasePath: "testBasePath",
wantStatus: http.StatusOK,
wantInvAuth: false,
wantAuthErr: errors.New("Unknown error"),
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
{
name: "unauth error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: true,
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: T;
IsIntErr: F;
`,
},
{
name: "accept error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantAcceptErr: errors.New("accept error"),
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
ps := "username=joe&password=pass"
if tc.challenge != "" {
ps += "&login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, strings.NewReader(ps))
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken -}} T {{- else -}} F {{- end }};
Challenge: {{ .Challenge }};
InvCreds: {{ if .IsInvalidCredentials -}} T {{- else -}} F {{- end }};
IsIntErr: {{ if .IsInternalError -}} T {{- else -}} F {{- end}};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl.Execute(w, data)
},
}
rproc := testLoginReqProc{
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
},
}
auther := testAuthenticator{
authnFunc: func(ctx context.Context, username, password string) (bool, error) {
if username == "" {
t.Error("unexpected empty username")
}
if password == "" {
t.Error("unexpected empty password")
}
return !tc.wantInvAuth, tc.wantAuthErr
},
}
handler := nosurf.New(newLoginEndHandler(rproc, auther, tmplRenderer))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testTemplateRenderer struct {
renderTmplFunc func(w http.ResponseWriter, name string, data interface{}) error
}
func (tl *testTemplateRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
return tl.renderTmplFunc(w, name, data)
}
type testAuthenticator struct {
authnFunc func(context.Context, string, string) (bool, error)
}
func (au testAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
return au.authnFunc(ctx, username, password)
}
func TestHandleConsent(t *testing.T) {
testCases := []struct {
name string
challenge string
redirect string
subject string
skip bool
claims map[string]interface{}
scopes []string
wantStatus int
wantAcceptErr error
wantInitErr error
wantFindErr error
wantLoc string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
claims: map[string]interface{}{"a": "foo", "b": "bar", "c": "baz"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/consent"
if tc.challenge != "" {
url += "?consent_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
rproc := testConsentReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{
Challenge: tc.challenge,
Subject: tc.subject,
RequestedScopes: tc.scopes,
}, tc.wantInitErr
},
acceptReqFunc: func(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember == tc.skip {
t.Error("unexpected enabled remember flag")
}
if len(grantScope) != len(tc.scopes) {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
} else {
for i := range grantScope {
if grantScope[i] != tc.scopes[i] {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
break
}
}
}
if !reflect.DeepEqual(idToken, tc.claims) {
t.Errorf("wrong an id token while accepting the request: got %q; want %q", idToken, tc.claims)
}
return tc.redirect, tc.wantAcceptErr
},
}
cfinder := testOIDCClaimsFinder{
findFunc: func(ctx context.Context, username string) (map[string]interface{}, error) {
if username == "" {
t.Error("unexpected empty username")
}
return tc.claims, tc.wantFindErr
},
}
handler := nosurf.New(newConsentHandler(rproc, cfinder, nil))
handler.ExemptPath("/consent")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testConsentReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string, bool, []string, interface{}) (string, error)
}
func (crp testConsentReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return crp.initReqFunc(challenge)
}
func (crp testConsentReqProc) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
return crp.acceptReqFunc(challenge, remember, grantScope, idToken)
}
type testOIDCClaimsFinder struct {
findFunc func(context.Context, string) (map[string]interface{}, error)
}
func (cf testOIDCClaimsFinder) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
return cf.findFunc(ctx, username)
}
func TestLogoutHandler(t *testing.T) {
testCases := []struct {
name string
challenge string
initErr error
acceptErr error
redirectTo string
wantStatus int
wantLoc string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
initErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "init logout request error",
challenge: "foo",
initErr: fmt.Errorf("init logout request error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept logout request error",
challenge: "foo",
acceptErr: fmt.Errorf("accept logout request error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "happy path",
challenge: "foo",
redirectTo: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/logout"
if tc.challenge != "" {
url += "?logout_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
rproc := &testLogoutReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{}, tc.initErr
},
acceptReqFunc: func(challenge string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
return tc.redirectTo, tc.acceptErr
},
}
handler := newLogoutHandler(rproc)
handler.ServeHTTP(rr, r)
if rr.Code != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", rr.Code, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testLogoutReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string) (string, error)
}
func (p *testLogoutReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return p.initReqFunc(challenge)
}
func (p *testLogoutReqProc) AcceptLogoutRequest(challenge string) (string, error) {
return p.acceptReqFunc(challenge)
}

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package ldapclient
@ -20,21 +18,23 @@ import (
"github.com/coocood/freecache"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/logger"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
ldap "gopkg.in/ldap.v2"
)
// Config is a LDAP configuration.
type Config struct {
Endpoints []string // are LDAP servers
BaseDN string // is a base DN for searching users
BindDN, BindPass string // is needed for authentication
RoleBaseDN string // is a base DN for searching roles
RoleAttr string // is LDAP attribute's name for a role's name
RoleClaim string // is custom OIDC claim name for roles' list
AttrClaims map[string]string // maps a LDAP attribute's name onto an OIDC claim
CacheSize int // is a size of claims' cache in KiB
CacheTTL time.Duration // is a TTL of claims' cache
Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"`
BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"`
RoleBaseDN string `envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP attribute for role's name"`
RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list
AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"`
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"`
}
// Client is a LDAP client (compatible with Active Directory).
@ -45,6 +45,9 @@ type Client struct {
// New creates a new LDAP client.
func New(cnf Config) *Client {
if cnf.RoleClaim == "" {
cnf.RoleClaim = "http://i-core.ru/claims/roles"
}
return &Client{
Config: cnf,
cache: freecache.NewCache(cnf.CacheSize * 1024),
@ -86,7 +89,7 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
// Clear the claims' cache because of possible re-authentication. We don't want stale claims after re-login.
if ok := cli.cache.Del([]byte(username)); ok {
log := logger.FromContext(ctx)
log := logutil.FromContext(ctx)
log.Debug("Cleared user's OIDC claims in the cache")
}
@ -103,13 +106,12 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
go func(addr string) {
defer wg.Done()
log := logger.FromContext(ctx)
log = log.With("address", addr)
log := logutil.FromContext(ctx).Sugar()
d := net.Dialer{Timeout: ldap.DefaultTimeout}
tcpcn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
log.Debugw("Failed to create a LDAP connection")
log.Debug("Failed to create a LDAP connection", "address", addr)
return
}
ldapcn := ldap.NewConn(tcpcn, false)
@ -117,7 +119,7 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
select {
case <-ctx.Done():
ldapcn.Close()
log.Debugw("a LDAP connection is cancelled")
log.Debug("a LDAP connection is cancelled", "address", addr)
return
case ch <- ldapcn:
}
@ -165,14 +167,14 @@ func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []
// FindOIDCClaims finds all OIDC claims for a user.
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
log := logger.FromContext(ctx)
log := logutil.FromContext(ctx).Sugar()
// Retrieving from LDAP is slow. So, we try to get claims for the given username from the cache.
switch cdata, err := cli.cache.Get([]byte(username)); err {
case nil:
var claims map[string]interface{}
if err = json.Unmarshal(cdata, &claims); err != nil {
log.Infow("Failed to unmarshal user's OIDC claims", "error", err, "data", cdata)
log.Info("Failed to unmarshal user's OIDC claims", zap.Error(err), "data", cdata)
return nil, err
}
log.Debug("Retrieved user's OIDC claims from the cache", "claims", claims)
@ -180,7 +182,7 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
case freecache.ErrNotFound:
log.Debug("User's OIDC claims is not found in the cache")
default:
log.Infow("Failed to retrieve user's OIDC claims from the cache", "error", err)
log.Infow("Failed to retrieve user's OIDC claims from the cache", zap.Error(err))
}
// Try to make multiple TCP connections to the LDAP server for getting claims.
@ -260,10 +262,10 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
// Save the claims in the cache for future queries.
cdata, err := json.Marshal(claims)
if err != nil {
log.Infow("Failed to marshal user's OIDC claims for caching", "error", err, "claims", claims)
log.Infow("Failed to marshal user's OIDC claims for caching", zap.Error(err), "claims", claims)
}
if err = cli.cache.Set([]byte(username), cdata, int(cli.CacheTTL.Seconds())); err != nil {
log.Infow("Failed to store user's OIDC claims into the cache", "error", err, "claims", claims)
log.Infow("Failed to store user's OIDC claims into the cache", zap.Error(err), "claims", claims)
}
return claims, nil

View File

@ -1,35 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package logger
import (
"context"
"go.uber.org/zap"
)
type requestLogCtxKey int
// requestLogKey is a context's key to store a request's logger.
const requestLogKey requestLogCtxKey = iota
// FromContext returns a request's logger stored in a context.
func FromContext(ctx context.Context) *zap.SugaredLogger {
v := ctx.Value(requestLogKey)
if v == nil {
return zap.NewNop().Sugar()
}
return v.(*zap.SugaredLogger)
}
// WithLogger returns context.Context with a logger's instance.
func WithLogger(ctx context.Context, log *zap.SugaredLogger) context.Context {
return context.WithValue(ctx, requestLogKey, log)
}

View File

@ -1,29 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package oauth2
import "errors"
var (
// ErrUnauthenticated is an error that happens when authentication is failed.
ErrUnauthenticated = errors.New("unauthenticated")
// ErrChallengeNotFound is an error that happens when an unknown challenge is used.
ErrChallengeNotFound = errors.New("challenge not found")
// ErrChallengeExpired is an error that happens when a challenge is already used.
ErrChallengeExpired = errors.New("challenge expired")
)
// ReqInfo contains information on an ongoing login or consent request.
type ReqInfo struct {
Challenge string `json:"challenge"`
RequestedScopes []string `json:"requested_scope"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
}

View File

@ -1,48 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package server
import (
"net/http"
"time"
"github.com/gofrs/uuid"
"go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/logger"
)
type traceResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (w *traceResponseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
// logw returns a middleware that places a request's ID and logger to a request's context, and logs the request.
func logw(log *zap.SugaredLogger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
log = log.With("requestID", uuid.Must(uuid.NewV4()).String())
ctx = logger.WithLogger(r.Context(), log)
)
log.Infow("New request", "method", r.Method, "url", r.URL.String())
start := time.Now()
tw := &traceResponseWriter{w, http.StatusOK}
next.ServeHTTP(w, r.WithContext(ctx))
log.Debugw("The request is handled", "httpStatus", tw.statusCode, "duration", time.Since(start))
})
}
}

View File

@ -1,32 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTraceResponseWriter(t *testing.T) {
wantStatus := http.StatusBadRequest
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(wantStatus)
})
r, err := http.NewRequest("GET", "http://foo.bar", http.NoBody)
if err != nil {
t.Fatal(err)
}
tw := &traceResponseWriter{ResponseWriter: httptest.NewRecorder()}
h.ServeHTTP(tw, r)
if tw.statusCode != wantStatus {
t.Errorf("invalid HTTP status code %d; want %d", tw.statusCode, wantStatus)
}
}

View File

@ -1,405 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package server
import (
"bufio"
"bytes"
"context"
"encoding/json"
"html/template"
"net/http"
"net/url"
"path"
"strings"
"time"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/julienschmidt/httprouter"
"github.com/justinas/alice"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/ldapclient"
"gopkg.i-core.ru/werther/internal/logger"
"gopkg.i-core.ru/werther/internal/oauth2"
"gopkg.i-core.ru/werther/internal/oauth2/hydra"
)
// Version will be filled at compile time.
var Version = ""
const internalServerErrorMessage = "Internal Server Error"
// Config is a server's configuration.
type Config struct {
DevMode bool `envconfig:"dev_mode" default:"false" desc:"a development mode"`
Listen string `default:":8080" desc:"a host and port to listen on (<host>:<port>)"`
LDAPEndpoints []string `envconfig:"ldap_endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
LDAPBaseDN string `envconfig:"ldap_basedn" required:"true" desc:"a LDAP base DN"`
LDAPBindDN string `envconfig:"ldap_binddn" desc:"a LDAP bind DN"`
LDAPBindPW string `envconfig:"ldap_bindpw" json:"-" desc:"a LDAP bind password"`
LDAPRoleBaseDN string `envconfig:"ldap_role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
LDAPRoleAttr string `envconfig:"ldap_role_attr" default:"description" desc:"a LDAP attribute for role's name"`
LDAPAttrClaims map[string]string `envconfig:"ldap_attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"`
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)"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
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"`
HydraAdminURL string `envconfig:"hydra_admin_url" required:"true" desc:"a server admin URL of ORY Hydra"`
WebDir string `envconfig:"web_dir" desc:"a path to an external web directory"`
WebBasePath string `envconfig:"web_base_path" default:"/" desc:"a base path of web pages"`
}
// Server is a HTTP server that is a login provider.
type Server struct {
Config
router http.Handler
webldr interface {
loadTemplate(name string) (*template.Template, error)
}
}
// New creates a new Server instance.
func New(cnf Config, log *zap.Logger) (*Server, error) {
srv := &Server{Config: cnf}
var err error
if cnf.WebDir != "" {
srv.webldr, err = newExtWebLoader(cnf.WebDir)
} else {
srv.webldr, err = newIntWebLoader()
}
if err != nil {
return nil, errors.Wrap(err, "failed to init server")
}
ldapcnf := ldapclient.Config{
Endpoints: srv.LDAPEndpoints,
BaseDN: srv.LDAPBaseDN,
BindDN: srv.LDAPBindDN,
BindPass: srv.LDAPBindPW,
RoleBaseDN: srv.LDAPRoleBaseDN,
RoleAttr: srv.LDAPRoleAttr,
RoleClaim: "http://i-core.ru/claims/roles",
AttrClaims: srv.LDAPAttrClaims,
CacheTTL: srv.CacheTTL,
CacheSize: srv.CacheSize,
}
ldap := ldapclient.New(ldapcnf)
router := httprouter.New()
router.Handler(http.MethodGet, "/auth/login", srv.handleLoginStart(hydra.NewLoginReqDoer(cnf.HydraAdminURL)))
router.Handler(http.MethodPost, "/auth/login", srv.handleLoginEnd(hydra.NewLoginReqDoer(cnf.HydraAdminURL), ldap))
router.Handler(http.MethodGet, "/auth/consent", srv.handleConsent(hydra.NewConsentReqDoer(cnf.HydraAdminURL), ldap))
router.Handler(http.MethodGet, "/health/alive", srv.handleHealthAliveAndReady())
router.Handler(http.MethodGet, "/health/ready", srv.handleHealthAliveAndReady())
router.Handler(http.MethodGet, "/version", srv.handleVersion())
router.Handler(http.MethodGet, "/metrics/prometheus", promhttp.Handler())
var fs http.FileSystem = http.Dir(path.Join(cnf.WebDir, "static"))
if cnf.WebDir == "" { // Use embedded web templates
fs = &assetfs.AssetFS{
Asset: Asset,
AssetDir: AssetDir,
AssetInfo: AssetInfo,
Prefix: "static",
}
}
router.ServeFiles("/static/*filepath", fs)
srv.router = alice.New(nosurf.NewPure, logw(log.Sugar())).Then(router)
return srv, nil
}
// ServeHTTP implements the http.Handler interface.
func (srv *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
srv.router.ServeHTTP(w, r)
}
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
type oa2LoginReqAcceptor interface {
AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error)
}
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
//
// InitiateRequest returns oauth2.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns oauth2.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2LoginReqProcessor interface {
InitiateRequest(challenge string) (*oauth2.ReqInfo, error)
oa2LoginReqAcceptor
}
// loginTmplData is a data that is needed for rendering the login page.
type loginTmplData struct {
CSRFToken string
Challenge string
LoginURL string
WebBasePath string
IsInvalidCredentials bool
IsInternalError bool
}
func (srv *Server) handleLoginStart(rproc oa2LoginReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
log = log.With("challenge", challenge)
switch errors.Cause(err) {
case oauth2.ErrChallengeNotFound:
log.Debugw("Unknown login challenge in the OAuth2 provider", "error", err)
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
return
case oauth2.ErrChallengeExpired:
log.Debugw("Login challenge has been used already in the OAuth2 provider", "error", err)
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to initiate an OAuth2 login request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
if ri.Skip {
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, 0, ri.Subject)
if err != nil {
log.Infow("Failed to accept an OAuth login request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
return
}
data := loginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
WebBasePath: srv.WebBasePath,
}
if err := srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}
// authenticator is an interface that is used for a user authentication.
//
// Authenticate returns false if the username or password is invalid.
type authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
func (srv *Server) handleLoginEnd(ra oa2LoginReqAcceptor, auther authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
r.ParseForm()
challenge := r.Form.Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
data := loginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: r.URL.String(),
WebBasePath: srv.WebBasePath,
}
username, password := r.Form.Get("username"), r.Form.Get("password")
switch ok, err := auther.Authenticate(r.Context(), username, password); {
case err != nil:
data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
"error", err, "challenge", challenge, "username", username)
if err = srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
case !ok:
data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", "error", err, "challenge", challenge, "username", username)
if err = srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
}
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
remember := r.Form.Get("remember") != ""
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, int(srv.SessionTTL.Seconds()), username)
if err != nil {
data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", "error", err)
if err := srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
//
// InitiateRequest returns oauth2.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns oauth2.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2ConsentReqProcessor interface {
InitiateRequest(challenge string) (*oauth2.ReqInfo, error)
AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error)
}
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
type oidcClaimsFinder interface {
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
}
func (srv *Server) handleConsent(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
log.Debug("No consent challenge that is needed by the OAuth2 provider")
http.Error(w, "No consent challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
log = log.With("challenge", challenge)
switch errors.Cause(err) {
case oauth2.ErrChallengeNotFound:
log.Debugw("Unknown consent challenge in the OAuth2 provider", "error", err)
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
return
case oauth2.ErrChallengeExpired:
log.Debugw("Consent challenge has been used already in the OAuth2 provider", "error", err)
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 consent request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
if err != nil {
log.Infow("Failed to find user's OIDC claims", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Debugw("Found user's OIDC claims", "claims", claims)
// Remove claims that are not in the requested scopes.
for claim := range claims {
var found bool
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
// It is because of config option's format compatibility.
if scope, ok := srv.ClaimScopes[url.QueryEscape(claim)]; ok {
for _, rscope := range ri.RequestedScopes {
if rscope == scope {
found = true
break
}
}
}
if !found {
delete(claims, claim)
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
}
}
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, int(srv.SessionTTL.Seconds()), ri.RequestedScopes, claims)
if err != nil {
log.Infow("Failed to accept a consent request to the OAuth2 provider", "error", err, "scopes", ri.RequestedScopes, "claims", claims)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
func (srv *Server) renderLoginTemplate(w http.ResponseWriter, data interface{}) error {
t, err := srv.webldr.loadTemplate("login.tmpl")
if err != nil {
return err
}
var (
buf bytes.Buffer
bw = bufio.NewWriter(&buf)
)
if err := t.Execute(bw, data); err != nil {
return err
}
if err := bw.Flush(); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
return nil
}
func (srv *Server) handleHealthAliveAndReady() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
Status: "ok",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Infow("Failed to marshal health liveness and readiness status", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}
func (srv *Server) handleVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{
Version: Version,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Infow("Failed to marshal version", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}

View File

@ -1,500 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, December 2018
*/
package server
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"testing"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
)
func TestHandleLoginStart(t *testing.T) {
testCases := []struct {
name string
challenge string
scopes []string
skip bool
subject string
redirect string
wantStatus int
wantInitErr error
wantAcceptErr error
wantLoc string
wantBody string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
wantStatus: http.StatusOK,
wantBody: `
WebBasePath: ;
LoginURL: login?login_challenge=foo;
CSRFToken: true;
Challenge: foo;
`,
},
{
name: "skip",
challenge: "foo",
skip: true,
wantLoc: "/",
wantStatus: http.StatusFound,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "init error",
challenge: "foo",
wantInitErr: errors.New("init error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept error",
challenge: "foo",
skip: true,
wantAcceptErr: errors.New("accept error"),
wantStatus: http.StatusInternalServerError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
if tc.challenge != "" {
url += "?login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
WebBasePath: {{ .WebBasePath }};
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken }} true {{- else -}} false {{- end }};
Challenge: {{ .Challenge }};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{webldr: ldr}
rproc := testLoginReqProc{}
rproc.initReqFunc = func(challenge string) (*oauth2.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &oauth2.ReqInfo{
Challenge: tc.challenge,
RequestedScopes: tc.scopes,
Skip: tc.skip,
Subject: tc.subject,
}, tc.wantInitErr
}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
}
handler := nosurf.New(srv.handleLoginStart(rproc))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
func noindent(s string) string {
wsRe := regexp.MustCompile(`(?:^\s+|(;)\s+)`)
return wsRe.ReplaceAllString(s, "$1 ")
}
type testLoginReqProc struct {
initReqFunc func(string) (*oauth2.ReqInfo, error)
acceptReqFunc func(string, bool, int, string) (string, error)
}
func (lrp testLoginReqProc) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
return lrp.initReqFunc(challenge)
}
func (lrp testLoginReqProc) AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error) {
return lrp.acceptReqFunc(challenge, remember, rememberFor, subject)
}
type testLoginWeb struct {
loadTmplFunc func(string) (*template.Template, error)
}
func (tl *testLoginWeb) loadTemplate(name string) (*template.Template, error) {
return tl.loadTmplFunc(name)
}
func TestHandleLoginEnd(t *testing.T) {
testCases := []struct {
name string
challenge string
subject string
redirect string
wantStatus int
wantAcceptErr error
wantAuthErr error
wantInvAuth bool
wantLoc string
wantBody string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
{
name: "auth unknown error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: false,
wantAuthErr: errors.New("Unknown error"),
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
{
name: "unauth error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: true,
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: T;
IsIntErr: F;
`,
},
{
name: "accept error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantAcceptErr: errors.New("accept error"),
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
ps := "username=joe&password=pass"
if tc.challenge != "" {
ps += "&login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, strings.NewReader(ps))
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
WebBasePath: {{ .WebBasePath }};
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken -}} T {{- else -}} F {{- end }};
Challenge: {{ .Challenge }};
InvCreds: {{ if .IsInvalidCredentials -}} T {{- else -}} F {{- end }};
IsIntErr: {{ if .IsInternalError -}} T {{- else -}} F {{- end}};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{
webldr: ldr,
}
rproc := testLoginReqProc{}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
}
auther := testAuthenticator{}
auther.authnFunc = func(ctx context.Context, username, password string) (bool, error) {
if username == "" {
t.Error("unexpected empty username")
}
if password == "" {
t.Error("unexpected empty password")
}
return !tc.wantInvAuth, tc.wantAuthErr
}
handler := nosurf.New(srv.handleLoginEnd(rproc, auther))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testAuthenticator struct {
authnFunc func(context.Context, string, string) (bool, error)
}
func (au testAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
return au.authnFunc(ctx, username, password)
}
func TestHandleConsent(t *testing.T) {
testCases := []struct {
name string
challenge string
redirect string
subject string
skip bool
claims map[string]interface{}
scopes []string
wantStatus int
wantAcceptErr error
wantInitErr error
wantFindErr error
wantLoc string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
claims: map[string]interface{}{"a": "foo", "b": "bar", "c": "baz"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/consent"
if tc.challenge != "" {
url += "?consent_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = ""
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{webldr: ldr}
rproc := testConsentReqProc{}
rproc.initReqFunc = func(challenge string) (*oauth2.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &oauth2.ReqInfo{
Challenge: tc.challenge,
Subject: tc.subject,
RequestedScopes: tc.scopes,
}, tc.wantInitErr
}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember == tc.skip {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if len(grantScope) != len(tc.scopes) {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
} else {
for i := range grantScope {
if grantScope[i] != tc.scopes[i] {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
break
}
}
}
if !reflect.DeepEqual(idToken, tc.claims) {
t.Errorf("wrong an id token while accepting the request: got %q; want %q", idToken, tc.claims)
}
return tc.redirect, tc.wantAcceptErr
}
cfinder := testOIDCClaimsFinder{}
cfinder.findFunc = func(ctx context.Context, username string) (map[string]interface{}, error) {
if username == "" {
t.Error("unexpected empty username")
}
return tc.claims, tc.wantFindErr
}
handler := nosurf.New(srv.handleConsent(rproc, cfinder))
handler.ExemptPath("/consent")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testConsentReqProc struct {
initReqFunc func(string) (*oauth2.ReqInfo, error)
acceptReqFunc func(string, bool, int, []string, interface{}) (string, error)
}
func (crp testConsentReqProc) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
return crp.initReqFunc(challenge)
}
func (crp testConsentReqProc) AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) {
return crp.acceptReqFunc(challenge, remember, rememberFor, grantScope, idToken)
}
type testOIDCClaimsFinder struct {
findFunc func(context.Context, string) (map[string]interface{}, error)
}
func (cf testOIDCClaimsFinder) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
return cf.findFunc(ctx, username)
}

View File

@ -1,127 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, December 2018
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg server -prefix templates/ templates/...
package server
import (
"fmt"
"html/template"
"os"
"path"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// intWebLoader is a loader that is used for serving embedded HTML/JS/CSS static files.
// They are embedded in a generated Go code.
type intWebLoader struct {
tmpls map[string]*template.Template
}
// newIntWebLoader creates an internal web loader's instance.
func newIntWebLoader() (*intWebLoader, error) {
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to parse the main template")
}
tmpls := make(map[string]*template.Template)
for _, name := range AssetNames() {
t, err := mainTmpl.Clone()
if err != nil {
return nil, errors.Wrap(err, "failed to clone the main template")
}
asset, err := Asset(name)
if err != nil {
return nil, errors.Wrapf(err, "failed to load asset %q", name)
}
tmpls[path.Base(name)] = template.Must(t.Parse(string(asset)))
}
return &intWebLoader{tmpls: tmpls}, nil
}
func (wl *intWebLoader) loadTemplate(name string) (*template.Template, error) {
t, ok := wl.tmpls[name]
if !ok {
return nil, fmt.Errorf("the template %q does not exist", name)
}
return t, nil
}
// extWebLoader is a loader that is used for serving HTML/JS/CSS static files.
// The files must be provided at startup.
type extWebLoader struct {
root *template.Template
paths map[string]string
}
// newExtWebLoader creates an external web loader's instance.
// The implementation affords to replace static files without restart of the app.
func newExtWebLoader(webDir string) (*extWebLoader, error) {
if _, err := os.Stat(webDir); err != nil {
return nil, errors.Wrapf(err, "failed to load web dir %q", webDir)
}
files, err := filepath.Glob(path.Join(webDir, "*.tmpl"))
if err != nil {
return nil, errors.Wrapf(err, "failed to load templates from web dir %q", webDir)
}
for i, f := range files {
if !strings.HasSuffix(f, ".tmpl") {
files = append(files[:i], files[i+1:]...)
}
}
for i, f := range files {
files[i] = path.Join("web", f)
}
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to parse the main template")
}
paths := make(map[string]string)
for _, f := range files {
paths[path.Base(f)] = f
}
return &extWebLoader{root: mainTmpl, paths: paths}, nil
}
func (wl *extWebLoader) loadTemplate(name string) (*template.Template, error) {
p, ok := wl.paths[name]
if !ok {
return nil, fmt.Errorf("the template %q does not exist", name)
}
t, err := wl.root.Clone()
if err != nil {
return nil, errors.Wrapf(err, "failed to clone the template %q", name)
}
return t.ParseFiles(p)
}
const mainT = `{{ define "main" }}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ end }}</title>
<base href={{ .WebBasePath }}>
{{ block "style". }}{{ end }}
</head>
<body>
{{ block "content" . }}<h1>NO CONTENT</h1>{{ end }}
{{ block "js" . }}{{ end }}
</body>
</html>
{{ end }}
`

60
internal/stat/stat.go Normal file
View File

@ -0,0 +1,60 @@
package stat
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
)
// Handler provides HTTP handlers for health checking and versioning.
type Handler struct {
version string
}
// NewHandler creates a new Handler.
func NewHandler(version string) *Handler {
return &Handler{version: version}
}
// AddRoutes registers all required routes for the package stat.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
apply(http.MethodGet, "/health/alive", newHealthAliveAndReadyHandler())
apply(http.MethodGet, "/health/ready", newHealthAliveAndReadyHandler())
apply(http.MethodGet, "/version", newVersionHandler(h.version))
}
func newHealthAliveAndReadyHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
Status: "ok",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Info("Failed to marshal health liveness and readiness status", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}
func newVersionHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{
Version: version,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Info("Failed to marshal version", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}

View File

@ -4,7 +4,7 @@
// templates/static/script.js (1.24kB)
// templates/static/style.css (4.316kB)
package server
package web
import (
"bytes"
@ -86,7 +86,7 @@ func loginTmpl() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1550576890, 0)}
info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x57, 0xfc, 0x84, 0x28, 0x53, 0xbc, 0x7e, 0xe8, 0xf7, 0x63, 0x72, 0x3, 0x51, 0x21, 0xda, 0x48, 0x1f, 0x45, 0x63, 0xe8, 0x32, 0x66, 0x1b, 0xfd, 0x47, 0xca, 0x33, 0x3c, 0x36, 0x79, 0xe4, 0xf9}}
return a, nil
}
@ -106,7 +106,7 @@ func staticScriptJs() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 0)}
info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x21, 0x83, 0x40, 0xc4, 0xb1, 0x4e, 0x2c, 0xf8, 0x84, 0x11, 0x9b, 0x80, 0xc2, 0xe6, 0xab, 0xb5, 0xf8, 0xd5, 0x3b, 0xc9, 0x2e, 0x5b, 0x12, 0x7, 0x29, 0x2f, 0x21, 0x5f, 0x59, 0x35, 0xf7, 0xad}}
return a, nil
}
@ -126,7 +126,7 @@ func staticStyleCss() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 0)}
info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0xf8, 0x53, 0xba, 0x33, 0x44, 0x16, 0x28, 0xdc, 0x9c, 0x4f, 0x69, 0xd7, 0x30, 0x5, 0x56, 0x8f, 0x1f, 0x78, 0xe3, 0x53, 0x41, 0xe6, 0x42, 0x95, 0x4, 0xaa, 0x5b, 0x40, 0xc, 0x30, 0x4d, 0x68}}
return a, nil
}

View File

@ -0,0 +1,34 @@
external template
WebBasePath: testBasePath;
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -0,0 +1,15 @@
{{- define "main" }}external template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{- end }}

View File

@ -0,0 +1,16 @@
{{ define "main" }}
external template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{ end }}

View File

@ -0,0 +1,34 @@
internal template
WebBasePath: testBasePath;
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -0,0 +1,15 @@
{{- define "main" }}internal template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{- end }}

View File

@ -0,0 +1,16 @@
{{ define "main" }}
internal template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{ end }}

View File

@ -0,0 +1 @@
The file is needed to commit the parent directory to Git.

View File

@ -0,0 +1 @@
The file is needed to commit the parent directory to Git.

155
internal/web/web.go Normal file
View File

@ -0,0 +1,155 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg web -prefix templates/ templates/...
package web
import (
"bufio"
"bytes"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"os"
"path"
assetfs "github.com/elazarl/go-bindata-assetfs"
"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 variables are needed to be able to override them in tests.
var (
intTmplsFS http.FileSystem = &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo}
intStaticFS http.FileSystem = &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: "static"}
)
// Config is a configuration of a template's renderer and HTTP handler for static files.
type Config struct {
Dir string `envconfig:"dir" desc:"a path to an external web directory"`
BasePath string `envconfig:"base_path" default:"/" desc:"a base path of web pages"`
}
// HTMLRenderer renders a HTML page from a Go template.
//
// A template's source for a HTML page should contains four blocks:
// "title", "style", "js", "content". Block "title" should contain the content of the "title" HTML tag.
// Block "style" should contain "link" HTML tags that are injected to the head of the page.
// Block "js" should contain "script" HTML tags that are injected to the bottom of the page's body.
// Block "content" should contain HTML content that is injected to the start of the page's body.
// Each block has access to data that is specified using the method "RenderTemplate" of HTMLRenderer.
//
// By default, HTMLRenderer loads a template's source from the application's internal assets.
// The application's internal assets include the login page's template only (template with name "login.tmpl").
//
// Besides it, HTMLRenderer can load templates' sources from an external directory.
// The external directory is specified via a config.
//
// Templates can contain links to resources (styles and scripts). In that case, the template's directory has to
// contain directory "static" with these resources. To provide these resources to a user you should register
// StaticHandler in the application's HTTP router with path "/static".
type HTMLRenderer struct {
Config
mainTmpl *template.Template
fs http.FileSystem
}
// NewHTMLRenderer returns a new instance of HTMLRenderer.
func NewHTMLRenderer(cnf Config) (*HTMLRenderer, error) {
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to create template's renderer")
}
fs := intTmplsFS
if cnf.Dir != "" {
fs = http.Dir(cnf.Dir)
}
return &HTMLRenderer{Config: cnf, mainTmpl: mainTmpl, fs: fs}, nil
}
// RenderTemplate renders a HTML page from a template with the specified name using the specified data.
func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
f, err := r.fs.Open(name)
if err != nil {
if v, ok := err.(*os.PathError); ok {
if os.IsNotExist(v.Err) {
return fmt.Errorf("the template %q does not exist", name)
}
}
return fmt.Errorf("failed to open template %q: %s", name, err)
}
b, err := ioutil.ReadAll(f)
if err != nil {
return fmt.Errorf("failed to read template %q: %s", name, err)
}
t, err := r.mainTmpl.Clone()
if err != nil {
return errors.Wrapf(err, "failed to clone the main template for template %q: %s", name, err)
}
t, err = t.Parse(string(b))
if err != nil {
return errors.Wrapf(err, "failed to parse template %q: %s", name, err)
}
var (
buf bytes.Buffer
bw = bufio.NewWriter(&buf)
)
if err = t.Execute(bw, map[string]interface{}{"WebBasePath": r.BasePath, "Data": data}); err != nil {
return err
}
if err = bw.Flush(); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err = buf.WriteTo(w)
return err
}
var mainT = `{{ define "main" }}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" .Data }}{{ end }}</title>
<base href={{ .WebBasePath }}>
{{ block "style" .Data }}{{ end }}
</head>
<body>
{{ block "content" .Data }}<h1>NO CONTENT</h1>{{ end }}
{{ block "js" .Data }}{{ end }}
</body>
</html>
{{ end }}
`
// StaticHandler provides HTTP handler that serves static files.
type StaticHandler struct {
fs http.FileSystem
}
// NewStaticHandler creates a new instance of StaticHandler.
func NewStaticHandler(cnf Config) *StaticHandler {
fs := intStaticFS
if cnf.Dir != "" {
fs = http.Dir(path.Join(cnf.Dir, "static"))
}
return &StaticHandler{fs: fs}
}
// AddRoutes registers a route that serves static files.
func (h *StaticHandler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
fileServer := http.FileServer(h.fs)
apply(http.MethodGet, "/*filepath", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = httputil.PathParam(r.Context(), "filepath")
fileServer.ServeHTTP(w, r)
}))
}

187
internal/web/web_test.go Normal file
View File

@ -0,0 +1,187 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package web
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"github.com/andreyvit/diff"
"gopkg.i-core.ru/httputil"
)
func TestHTMLRenderer(t *testing.T) {
testCases := []struct {
name string
ext bool
basePath string
data interface{}
wantErr error
}{
{
name: "internal template not found",
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "internal template happy path",
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
{
name: "external template not found",
ext: true,
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "external template happy path",
ext: true,
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tstDir := path.Join("testdata", t.Name())
// Read the main template.
var originMainT = mainT
defer func() { mainT = originMainT }()
f, err := os.Open(path.Join(tstDir, "main.tmpl"))
if err != nil {
t.Fatalf("failed to open main template: %s", err)
}
fc, err := ioutil.ReadAll(f)
if err != nil {
t.Fatalf("failed to read main template: %s", err)
}
mainT = string(fc)
// Create the template renderer.
cnf := Config{BasePath: tc.basePath}
if tc.ext {
cnf.Dir = tstDir
} else {
origin := intTmplsFS
defer func() { intTmplsFS = origin }()
intTmplsFS = http.Dir(tstDir)
}
r, err := NewHTMLRenderer(cnf)
if err != nil {
t.Fatalf("failed to create the template renderer: %s", err)
}
rr := httptest.NewRecorder()
err = r.RenderTemplate(rr, "login.tmpl", tc.data)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot not errors\nwant error\n\t%s", tc.wantErr)
}
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)
}
f, err = os.Open(path.Join(tstDir, "golden.file"))
if err != nil {
t.Fatalf("failed to open golden file: %s", err)
}
fc, err = ioutil.ReadAll(f)
if err != nil {
t.Fatalf("failed to read golden file: %s", err)
}
if got, want := rr.Body.String(), string(fc); got != want {
t.Errorf("\nbody diff (-want +got):\n%s", diff.LineDiff(want, got))
}
})
}
}
func TestStaticHandler(t *testing.T) {
testCases := []struct {
name string
ext bool
file string
wantStatus int
wantBody string
}{
{
name: "internal resource not found",
file: "not.found",
wantStatus: http.StatusNotFound,
},
{
name: "internal resource happy path",
file: "test.file",
wantStatus: http.StatusOK,
wantBody: "test",
},
{
name: "external resource not found",
ext: true,
file: "not.found",
wantStatus: http.StatusNotFound,
},
{
name: "external resource happy path",
ext: true,
file: "test.file",
wantStatus: http.StatusOK,
wantBody: "test",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tstDir := path.Join("testdata", t.Name())
cnf := Config{}
if tc.ext {
cnf.Dir = tstDir
} else {
origin := intStaticFS
defer func() { intStaticFS = origin }()
intStaticFS = http.Dir(path.Join(tstDir, "static"))
}
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
rr := httptest.NewRecorder()
router := httputil.NewRouter()
router.AddRoutes(NewStaticHandler(cnf), "/static")
router.ServeHTTP(rr, r)
if rr.Code != tc.wantStatus {
t.Errorf("got status %d, want status %d", rr.Code, tc.wantStatus)
}
if tc.wantBody != "" {
if got := rr.Body.String(); got != tc.wantBody {
t.Errorf("got body %q, want body %q", got, tc.wantBody)
}
}
})
}
}