logout: add support of logout flow
This commit is contained in:
parent
6658817311
commit
d761ad579a
@ -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
|
||||
|
15
CHANGELOG.md
15
CHANGELOG.md
@ -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.
|
||||
|
@ -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
103
README.md
@ -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
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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)))
|
||||
}
|
||||
|
@ -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
18
go.mod
@ -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
26
go.sum
@ -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=
|
||||
|
@ -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,
|
||||
},
|
@ -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 += "/"
|
@ -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
34
internal/hydra/logout.go
Normal 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
332
internal/identp/identp.go
Normal 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)
|
||||
}
|
||||
}
|
568
internal/identp/identp_test.go
Normal file
568
internal/identp/identp_test.go
Normal 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)
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
@ -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"`
|
||||
}
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
60
internal/stat/stat.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
34
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.file
vendored
Normal file
34
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.file
vendored
Normal 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;
|
31
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl
vendored
Normal file
31
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl
vendored
Normal 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 }}
|
15
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl
vendored
Normal file
15
internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl
vendored
Normal 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 }}
|
16
internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl
vendored
Normal file
16
internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl
vendored
Normal 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 }}
|
34
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.file
vendored
Normal file
34
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.file
vendored
Normal 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;
|
31
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl
vendored
Normal file
31
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl
vendored
Normal 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 }}
|
15
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl
vendored
Normal file
15
internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl
vendored
Normal 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 }}
|
16
internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl
vendored
Normal file
16
internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl
vendored
Normal 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 }}
|
1
internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file
vendored
Normal file
1
internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file
vendored
Normal file
@ -0,0 +1 @@
|
||||
test
|
1
internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file
vendored
Normal file
1
internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file
vendored
Normal file
@ -0,0 +1 @@
|
||||
The file is needed to commit the parent directory to Git.
|
1
internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file
vendored
Normal file
1
internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file
vendored
Normal file
@ -0,0 +1 @@
|
||||
test
|
1
internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file
vendored
Normal file
1
internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file
vendored
Normal file
@ -0,0 +1 @@
|
||||
The file is needed to commit the parent directory to Git.
|
155
internal/web/web.go
Normal file
155
internal/web/web.go
Normal 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
187
internal/web/web_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user