initial commit
This commit is contained in:
commit
6658817311
|
@ -0,0 +1,29 @@
|
||||||
|
# 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>, September 2018
|
||||||
|
|
||||||
|
run:
|
||||||
|
test: true
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
govet:
|
||||||
|
check-shadowing: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- deadcode
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
- golint
|
||||||
|
- varcheck
|
||||||
|
- structcheck
|
||||||
|
- megacheck
|
||||||
|
- ineffassign
|
||||||
|
- interfacer
|
||||||
|
- unconvert
|
||||||
|
- govet
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Changelog
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] - 2019-02-18
|
||||||
|
### Added
|
||||||
|
- Add unit tests for server's logic.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The url /auth/login accepts the POST parameter login_challenge instead of challenge.
|
||||||
|
- The OIDC claim roles is enabled in the scope http://i-core.ru/claims/roles only.
|
||||||
|
- Use go.uber.org/zap without any facade.
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
FROM golang:1.11-alpine AS build
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG GOPROXY
|
||||||
|
|
||||||
|
WORKDIR /opt/build
|
||||||
|
|
||||||
|
RUN adduser -D -g '' appuser
|
||||||
|
RUN apk --update add ca-certificates
|
||||||
|
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}" ./...
|
||||||
|
|
||||||
|
FROM scratch AS final
|
||||||
|
COPY --from=build /etc/passwd /etc/passwd
|
||||||
|
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
COPY --from=build /go/bin/werther /werther
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
ENTRYPOINT ["/werther"]
|
|
@ -0,0 +1,131 @@
|
||||||
|
[![GoDoc](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther?status.svg)](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther)
|
||||||
|
|
||||||
|
# Werther
|
||||||
|
|
||||||
|
Werther is a login provider for ORY Hydra that is an OAuth2 provider.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
1. Create a network:
|
||||||
|
```
|
||||||
|
docker network create hydra-net
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
You can learn additional properties with help command:
|
||||||
|
```
|
||||||
|
docker run -it --rm oryd/hydra:$HYDRA_VERSION 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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" \
|
||||||
|
-e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \
|
||||||
|
hub.das.i-core.ru/p/base-werther
|
||||||
|
```
|
||||||
|
|
||||||
|
For all options see option help:
|
||||||
|
```
|
||||||
|
docker run -it --rm hub.das.i-core.ru/p/base-werther -help
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start an authentication process in a browser:
|
||||||
|
```
|
||||||
|
open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Get user info:
|
||||||
|
```
|
||||||
|
http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, you can get the next output:
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Length: 218
|
||||||
|
Content-Type: application/json
|
||||||
|
Date: Tue, 31 Jul 2018 17:17:51 GMT
|
||||||
|
Vary: Origin
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "klepa@i-core.ru",
|
||||||
|
"family_name": "Lepa",
|
||||||
|
"given_name": "Konstantin",
|
||||||
|
"http://i-core.ru/claims/roles": {
|
||||||
|
"HeraldTest1": [
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "Konstantin Lepa",
|
||||||
|
"sub": "CN=Konstantin Lepa,OU=Domain Users,DC=icore,DC=local"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
```
|
||||||
|
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:
|
||||||
|
```
|
||||||
|
open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
|
||||||
|
```
|
||||||
|
|
||||||
|
9. (Optional) Sniff TCP packets between Hydra and Werther
|
||||||
|
```
|
||||||
|
docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
|
||||||
|
```
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
- image: golang:1.11-alpine
|
||||||
|
shell: go test -v ./...
|
||||||
|
- image: golangci/golangci-lint
|
||||||
|
shell: golangci-lint -v run
|
|
@ -0,0 +1,65 @@
|
||||||
|
/*
|
||||||
|
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"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.i-core.ru/werther/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verflag := flag.Bool("version", false, "print a version")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *verflag {
|
||||||
|
fmt.Println("werther", server.Version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cnf server.Config
|
||||||
|
if err := envconfig.Process("werther", &cnf); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logFunc := zap.NewProduction
|
||||||
|
if cnf.DevMode {
|
||||||
|
logFunc = zap.NewDevelopment
|
||||||
|
}
|
||||||
|
log, err := logFunc()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to create logger: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := server.New(cnf, log)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
// +build tools
|
||||||
|
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
|
||||||
|
import _ "github.com/kevinburke/go-bindata"
|
|
@ -0,0 +1,32 @@
|
||||||
|
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/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/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/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.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
|
||||||
|
gopkg.in/ldap.v2 v2.5.1
|
||||||
|
)
|
|
@ -0,0 +1,56 @@
|
||||||
|
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/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A=
|
||||||
|
github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4=
|
||||||
|
github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
|
||||||
|
github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk=
|
||||||
|
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||||
|
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||||
|
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/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=
|
||||||
|
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8=
|
||||||
|
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 h1:zL6nij8mNcIiohuVdHfNDSVZOlVTSsUZXkb5v1Kudqk=
|
||||||
|
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/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/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
|
||||||
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
go.uber.org/atomic v1.2.0 h1:yVVGhClJ8Xi1y4TxhJZE6QFPrz76BrzhWA01n47mSFk=
|
||||||
|
go.uber.org/atomic v1.2.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
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.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=
|
||||||
|
gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk=
|
|
@ -0,0 +1,291 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coocood/freecache"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gopkg.i-core.ru/werther/internal/logger"
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a LDAP client (compatible with Active Directory).
|
||||||
|
type Client struct {
|
||||||
|
Config
|
||||||
|
cache *freecache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new LDAP client.
|
||||||
|
func New(cnf Config) *Client {
|
||||||
|
return &Client{
|
||||||
|
Config: cnf,
|
||||||
|
cache: freecache.NewCache(cnf.CacheSize * 1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate authenticates a user with a username and password.
|
||||||
|
// If no username or password in LDAP it returns false and no error.
|
||||||
|
func (cli *Client) Authenticate(ctx context.Context, username, password string) (bool, error) {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
cn, ok := <-cli.dialTCP(ctx)
|
||||||
|
cancel()
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("connection timeout")
|
||||||
|
}
|
||||||
|
defer cn.Close()
|
||||||
|
|
||||||
|
// Find a user DN by his or her username.
|
||||||
|
details, err := cli.findBasicUserDetails(cn, username, []string{"dn"})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if details == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cn.Bind(details["dn"].(string), password); err != nil {
|
||||||
|
if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Debug("Cleared user's OIDC claims in the cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
ch = make(chan *ldap.Conn)
|
||||||
|
)
|
||||||
|
wg.Add(len(cli.Endpoints))
|
||||||
|
for _, addr := range cli.Endpoints {
|
||||||
|
go func(addr string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
log := logger.FromContext(ctx)
|
||||||
|
log = log.With("address", addr)
|
||||||
|
|
||||||
|
d := net.Dialer{Timeout: ldap.DefaultTimeout}
|
||||||
|
tcpcn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugw("Failed to create a LDAP connection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ldapcn := ldap.NewConn(tcpcn, false)
|
||||||
|
ldapcn.Start()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ldapcn.Close()
|
||||||
|
log.Debugw("a LDAP connection is cancelled")
|
||||||
|
return
|
||||||
|
case ch <- ldapcn:
|
||||||
|
}
|
||||||
|
}(addr)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
|
||||||
|
func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []string) (map[string]interface{}, error) {
|
||||||
|
if cli.BindDN != "" {
|
||||||
|
// We need to login to a LDAP server with a service account for retrieving user data.
|
||||||
|
if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+
|
||||||
|
"(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", username)
|
||||||
|
entries, err := cli.searchEntries(cn, cli.BaseDN, query, attrs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
// We didn't find the user.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
entry = entries[0]
|
||||||
|
details = make(map[string]interface{})
|
||||||
|
)
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if v, ok := entry[attr]; ok {
|
||||||
|
details[attr] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOIDCClaims finds all OIDC claims for a user.
|
||||||
|
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
|
||||||
|
log := logger.FromContext(ctx)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Debug("Retrieved user's OIDC claims from the cache", "claims", claims)
|
||||||
|
return claims, nil
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to make multiple TCP connections to the LDAP server for getting claims.
|
||||||
|
// Accept the first one, and cancel others.
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
cn, ok := <-cli.dialTCP(ctx)
|
||||||
|
cancel()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("connection timeout")
|
||||||
|
}
|
||||||
|
defer cn.Close()
|
||||||
|
|
||||||
|
// We need to find LDAP attribute's names for all required claims.
|
||||||
|
attrs := []string{"dn"}
|
||||||
|
for k := range cli.AttrClaims {
|
||||||
|
attrs = append(attrs, k)
|
||||||
|
}
|
||||||
|
// Find the attributes in the LDAP server.
|
||||||
|
details, err := cli.findBasicUserDetails(cn, username, attrs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if details == nil {
|
||||||
|
return nil, errors.New("unknown username")
|
||||||
|
}
|
||||||
|
log.Infow("Retrieved user's info from LDAP", "details", details)
|
||||||
|
|
||||||
|
// Transform the retrived attributes to corresponding claims.
|
||||||
|
claims := make(map[string]interface{})
|
||||||
|
for attr, v := range details {
|
||||||
|
if claim, ok := cli.AttrClaims[attr]; ok {
|
||||||
|
claims[claim] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User's roles is stored in LDAP as groups. We find all groups in a role's DN
|
||||||
|
// that include the user as a member.
|
||||||
|
query := fmt.Sprintf("(&(objectClass=group)(member=%s))", details["dn"])
|
||||||
|
entries, err := cli.searchEntries(cn, cli.RoleBaseDN, query, "dn", cli.RoleAttr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := make(map[string][]string)
|
||||||
|
for _, entry := range entries {
|
||||||
|
roleDN := entry["dn"].(string)
|
||||||
|
if roleDN == "" {
|
||||||
|
log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry[cli.RoleAttr] == nil {
|
||||||
|
log.Infow("No required LDAP attribute for a role", "ldapAttribute", cli.RoleAttr, "roleDN", roleDN)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that a role's DN is inside of the role's base DN.
|
||||||
|
// It's sufficient to compare the DN's suffix with the base DN.
|
||||||
|
n, k := len(roleDN), len(cli.RoleBaseDN)
|
||||||
|
if n < k || !strings.EqualFold(roleDN[n-k:], cli.RoleBaseDN) {
|
||||||
|
panic("You should never see that")
|
||||||
|
}
|
||||||
|
// The DN without the role's base DN must contain a CN and OU
|
||||||
|
// where the CN is for uniqueness only, and the OU is an application id.
|
||||||
|
v := strings.Split(roleDN[:n-k-1], ",")
|
||||||
|
if len(v) != 2 {
|
||||||
|
log.Infow("A role's DN without the role's base DN must contain two nodes only",
|
||||||
|
"roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := v[1][len("OU="):]
|
||||||
|
roles[appID] = append(roles[appID], entry[cli.RoleAttr].(string))
|
||||||
|
}
|
||||||
|
claims[cli.RoleClaim] = roles
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes.
|
||||||
|
func (cli *Client) searchEntries(cn *ldap.Conn, baseDN, query string, attrs ...string) ([]map[string]interface{}, error) {
|
||||||
|
res, err := cn.Search(ldap.NewSearchRequest(
|
||||||
|
baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []map[string]interface{}
|
||||||
|
for _, v := range res.Entries {
|
||||||
|
entry := map[string]interface{}{"dn": v.DN}
|
||||||
|
for _, attr := range v.Attributes {
|
||||||
|
// We need the first value only for the named attribute.
|
||||||
|
entry[attr.Name] = attr.Values[0]
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsentRequest creates a ConsentRequest.
|
||||||
|
func NewConsentReqDoer(hydraURL string) *ConsentReqDoer {
|
||||||
|
return &ConsentReqDoer{hydraURL: hydraURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitiateRequest fetches information on the OAuth2 request.
|
||||||
|
func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*oauth2.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) {
|
||||||
|
type session struct {
|
||||||
|
IDToken interface{} `json:"id_token,omitempty"`
|
||||||
|
}
|
||||||
|
data := struct {
|
||||||
|
GrantScope []string `json:"grant_scope"`
|
||||||
|
Remember bool `json:"remember"`
|
||||||
|
RememberFor int `json:"remember_for"`
|
||||||
|
Session session `json:"session,omitempty"`
|
||||||
|
}{
|
||||||
|
GrantScope: grantScope,
|
||||||
|
Remember: remember,
|
||||||
|
RememberFor: rememberFor,
|
||||||
|
Session: session{
|
||||||
|
IDToken: idToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
redirectURI, err := acceptRequest(consent, crd.hydraURL, challenge, data)
|
||||||
|
return redirectURI, errors.Wrap(err, "failed to accept consent request")
|
||||||
|
}
|
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
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 (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"gopkg.i-core.ru/werther/internal/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type reqType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
login reqType = "login"
|
||||||
|
consent reqType = "consent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, error) {
|
||||||
|
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s", string(typ), challenge))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u, err := parseURL(hydraURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u = u.ResolveReference(ref)
|
||||||
|
|
||||||
|
resp, err := http.Get(u.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = checkResponse(resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ri oauth2.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))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
u, err := parseURL(hydraURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
u = u.ResolveReference(ref)
|
||||||
|
|
||||||
|
body, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := checkResponse(resp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
RedirectTo string `json:"redirect_to"`
|
||||||
|
}
|
||||||
|
var rs result
|
||||||
|
dec := json.NewDecoder(resp.Body)
|
||||||
|
if err := dec.Decode(&rs); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return rs.RedirectTo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseURL(s string) (*url.URL, error) {
|
||||||
|
if len(s) > 0 && s[len(s)-1] != '/' {
|
||||||
|
s += "/"
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginRequest creates a LoginRequest.
|
||||||
|
func NewLoginReqDoer(hydraURL string) *LoginReqDoer {
|
||||||
|
return &LoginReqDoer{hydraURL: hydraURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitiateRequest fetches information on the OAuth2 request.
|
||||||
|
func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*oauth2.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) {
|
||||||
|
data := struct {
|
||||||
|
Remember bool `json:"remember"`
|
||||||
|
RememberFor int `json:"remember_for"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}{
|
||||||
|
Remember: remember,
|
||||||
|
RememberFor: rememberFor,
|
||||||
|
Subject: subject,
|
||||||
|
}
|
||||||
|
redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data)
|
||||||
|
return redirectURI, errors.Wrap(err, "failed to accept login request")
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
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"`
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,405 @@
|
||||||
|
/*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,500 @@
|
||||||
|
/*
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,321 @@
|
||||||
|
// Code generated by go-bindata. DO NOT EDIT.
|
||||||
|
// sources:
|
||||||
|
// templates/login.tmpl (1.216kB)
|
||||||
|
// templates/static/script.js (1.24kB)
|
||||||
|
// templates/static/style.css (4.316kB)
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func bindataRead(data []byte, name string) ([]byte, error) {
|
||||||
|
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_, err = io.Copy(&buf, gz)
|
||||||
|
clErr := gz.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %q: %v", name, err)
|
||||||
|
}
|
||||||
|
if clErr != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type asset struct {
|
||||||
|
bytes []byte
|
||||||
|
info os.FileInfo
|
||||||
|
digest [sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type bindataFileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi bindataFileInfo) Name() string {
|
||||||
|
return fi.name
|
||||||
|
}
|
||||||
|
func (fi bindataFileInfo) Size() int64 {
|
||||||
|
return fi.size
|
||||||
|
}
|
||||||
|
func (fi bindataFileInfo) Mode() os.FileMode {
|
||||||
|
return fi.mode
|
||||||
|
}
|
||||||
|
func (fi bindataFileInfo) ModTime() time.Time {
|
||||||
|
return fi.modTime
|
||||||
|
}
|
||||||
|
func (fi bindataFileInfo) IsDir() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func (fi bindataFileInfo) Sys() interface{} {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _loginTmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x84\x93\x4f\x6f\xdb\x3c\x0c\xc6\xcf\xce\xa7\x20\x74\x78\x6f\x8d\x3f\xc0\x6b\xe7\x52\x6c\x40\x81\x02\x2b\xda\x0e\x3b\x16\xb2\x44\xc7\x4a\x64\xc9\x20\x15\xaf\x81\xe1\xef\x3e\xc8\xb6\xf2\x67\x4d\xb0\x93\xa5\x87\x0f\x7f\x22\x25\x7a\x18\x40\x63\x6d\x1c\x82\x08\x26\x58\x14\x30\x8e\xab\xec\xd9\x6f\x8d\x83\x17\xf2\xbd\xd1\x48\xf0\x0b\x29\x34\x48\xab\x61\x00\x74\x3a\x3a\x56\x17\x79\x1c\x8e\x29\xaf\xb0\xc6\xed\x81\xd0\x96\xb3\xca\x0d\x62\x10\xd0\x10\xd6\x51\x91\xc1\xa8\x7c\x0a\xac\x15\xb3\xd8\xdc\x01\xee\x78\xa1\xb1\x22\xd3\x05\x08\xc7\x0e\x4b\x11\xf0\x33\xe4\x3b\xd9\xcb\x59\x15\xc0\xa4\xce\xd0\x49\x5b\xef\x58\x6c\x8a\x65\x73\x8f\xae\xbc\x0b\xe8\xc2\x72\x84\x36\x3d\x28\x2b\x99\x4b\x61\x63\xd3\x0f\x9d\xdc\xa2\xd8\xac\xb2\xab\x50\xed\xa9\x9d\xc4\xac\xe8\x92\xd6\x22\x73\xf2\x66\xc3\x00\xa6\x86\xf5\x13\x3f\xb9\x5e\x5a\xa3\x1f\x09\x35\xba\x60\xa4\xe5\xe9\x9c\x2c\xcb\x96\x08\x1c\x18\xc9\xc9\x16\xc1\x13\x74\x92\xf9\xb7\x27\xbd\x20\xd0\x32\x9e\x38\x21\xda\xec\x37\x22\x4f\x67\xc4\x2c\x02\x23\xf5\x48\x80\x31\x78\x99\xbb\xf8\xfe\x73\x15\x77\xff\xa7\xc0\x7c\x05\xb1\xf6\xbc\x9b\x7b\x88\xed\x5c\x77\x3d\x35\x08\x52\x05\xe3\x5d\x29\x86\x01\xd6\xd3\x08\xfc\x7c\x7d\x86\x71\x14\xd0\x62\x68\xbc\x2e\xc5\xcb\x8f\xb7\xf7\xb9\xe1\xac\x30\xae\x3b\xa4\xb7\x69\x8c\xd6\xe8\x04\xc4\xbe\x4a\xa1\x98\xea\x8f\xe0\xf7\x51\xe9\xa5\x3d\x60\x19\x81\x8f\x6f\xaf\xdf\xdf\xa3\x08\xe3\xf8\x4f\xc4\x54\xd5\x87\x6a\xa4\xb5\xe8\xb6\x78\xc5\x49\xe2\xc4\xf9\x0a\x8a\x73\x22\xa0\xb3\x52\x61\xe3\xad\x46\x2a\x45\xba\xf2\x44\x3f\xed\xf3\x1b\x85\xa4\x37\xf9\x8b\x71\x96\x67\xc6\x69\x9f\xa7\x22\x2e\xe6\x45\x35\xa8\xf6\x95\xff\x04\xc2\x16\xdb\x0a\xe9\x21\x4e\x9d\x34\x0e\x69\xb9\xbe\x9b\xf6\x07\xdf\x23\x59\x79\x4c\x9e\xeb\xc2\x92\x2b\x55\x90\xd8\x02\xf2\x93\xff\x16\xf4\xcb\xd1\xf7\x7c\x71\xd1\x4a\xda\xc7\x7f\x48\x9b\xfe\x04\xbd\xda\x58\x59\xa1\x85\xda\xd3\x45\x01\x9b\xd7\x65\x05\x2d\x16\xf9\xe4\x48\x5d\x9e\x73\x2f\x97\xd5\x21\x04\xef\x96\xbe\xf8\x50\xb5\x26\x88\xcd\xf4\xe6\x45\x3e\xc7\xe6\x41\xcd\xe3\x5c\x4e\x3f\xe3\x92\xbc\x7c\xcf\x63\xfd\x27\x00\x00\xff\xff\x7b\xe3\xda\xaf\xc0\x04\x00\x00")
|
||||||
|
|
||||||
|
func loginTmplBytes() ([]byte, error) {
|
||||||
|
return bindataRead(
|
||||||
|
_loginTmpl,
|
||||||
|
"login.tmpl",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginTmpl() (*asset, error) {
|
||||||
|
bytes, err := loginTmplBytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1550576890, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _staticScriptJs = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x52\xcb\x6e\xdb\x30\x10\xbc\xeb\x2b\x16\xba\x44\x0e\x54\xe6\x92\x53\x0d\xdd\x9a\xa2\x05\xd2\x53\xda\x53\xd1\x03\x23\x8e\x54\xa2\x7c\x38\x7c\xc8\x28\x6a\xff\x7b\x41\xdb\x7a\xf7\x11\x98\x07\x81\x10\x77\x66\x67\x67\xe7\xee\x96\x3a\xa9\xdf\x92\x47\x50\x84\x40\xc1\x57\xf7\xe4\xcf\xdf\x7d\x75\x4f\xb7\x77\xd9\x5e\x1a\x61\xf7\xcc\x1a\x65\xb9\xa0\x8a\x9a\x68\xea\x20\xad\x29\x36\xf4\x2b\x23\x22\xea\xb8\xa3\xe8\xe1\x1e\x14\x34\x55\x24\x6c\x1d\x35\x4c\x60\x2f\x11\xee\xe7\x13\x14\xea\x60\x5d\x91\x33\x65\x5b\x69\xde\x34\xd6\x69\x92\x66\x17\xc3\x57\xc3\x35\xaa\x9b\x04\x4d\xb7\x9b\x6f\xf9\xa6\x3c\x11\xa6\xb3\xe3\xde\x5f\x49\x98\xa0\x7b\xeb\xc4\x9c\xd0\x41\xe3\x4a\xc2\x04\xd5\xcf\x70\x73\xc2\x53\xf1\xfb\x54\xfb\x2a\xc6\x7c\xb3\xcd\x4e\xd8\xde\x2a\xd6\x71\x15\x41\x15\x79\x78\x2f\xad\x79\x0a\xd6\xf1\x16\xac\x45\xf8\x18\xa0\x8b\xd1\x98\xcd\x36\x9b\x4e\xc0\xea\xef\xa8\x7f\x40\xfc\x03\x3a\x28\xee\x9b\xca\x86\x8a\x65\xe3\x8a\x4c\x54\x8a\x0e\x87\x95\xa4\x8a\xf2\xbc\xdf\xee\x4c\x72\x63\xeb\xe8\x8b\x8b\x9e\x23\x41\x79\x4c\xca\xfa\x9d\x2d\xca\xce\x0a\x06\xbb\x18\x17\xe2\xa1\x83\x09\x8f\xd2\x07\x18\xb8\x22\xf7\xf1\x59\xcb\x90\x97\x63\xb6\x30\x6d\x9f\x02\xa6\x7d\xfb\x9f\xed\xed\x98\x86\xf7\xbc\xc5\xe0\xf4\x75\x83\xd3\xe1\x30\xa0\x67\x53\xad\xd0\xeb\x97\xb9\x6d\xe9\x5c\x74\x33\x69\x0c\xdc\x87\xcf\x9f\x1e\xa9\xa2\xfc\xcb\x65\xb3\xc4\x8d\xa0\x3e\xae\xc4\x1d\xc8\xe1\x25\x4a\x07\x91\x6f\x67\x2c\x60\x3b\x87\xe4\xd9\x3b\x34\x3c\xaa\xd0\x7b\x3b\x86\x3b\x44\x67\xc6\x7f\xc7\xe1\xb6\x48\x88\x5f\x85\xab\x5c\x98\x30\x61\x4e\xde\x2d\x43\xb7\x9c\xef\x6f\xfc\x43\x02\xcb\x55\x6e\x27\x1d\x56\x11\xfa\x03\xa5\x83\xb6\x1d\xd6\xb9\x1e\x67\x3d\xc7\xac\xa4\x86\x2b\x9f\xf4\x1f\xb7\xd9\xef\x00\x00\x00\xff\xff\xf0\x3d\xdb\x34\xd8\x04\x00\x00")
|
||||||
|
|
||||||
|
func staticScriptJsBytes() ([]byte, error) {
|
||||||
|
return bindataRead(
|
||||||
|
_staticScriptJs,
|
||||||
|
"static/script.js",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticScriptJs() (*asset, error) {
|
||||||
|
bytes, err := staticScriptJsBytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var _staticStyleCss = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x57\xfb\x6e\xa3\xb8\x17\xfe\x9f\xa7\x38\xea\x4f\x95\x9a\x9f\x02\x63\x20\xe9\x24\x44\xbb\x3b\xcf\xb0\x6f\x60\xc0\x80\x55\xb0\x91\xed\x4c\x49\x57\xdd\x67\x5f\xf9\xc2\xb5\x24\xcd\xac\xaa\x4d\x66\x2a\x6c\x8e\xcf\xe5\x3b\x9f\x3f\x3b\x3f\x68\xd3\x72\xa1\xe0\x2c\xea\xa7\x4a\xa9\x56\x26\xdf\xbe\x15\x9c\x29\x19\x94\x9c\x97\x35\xc1\x2d\x95\x41\xc6\x9b\x6f\x99\x94\x7f\x14\xb8\xa1\xf5\xe5\xb7\x3f\x79\xca\x15\x4f\x62\x84\x36\x27\xcf\xab\x54\x53\xc3\x5f\x1e\x40\xca\x3b\x5f\xd2\x37\xca\xca\x04\x52\x2e\x72\x22\xfc\x94\x77\x27\xef\xdd\xf3\xfe\xbf\xf5\x92\x04\x17\x8a\x08\xfd\x90\x92\x82\x0b\xf2\x61\x0d\x65\x15\x11\x54\x99\x05\x41\xcd\x4b\xca\xfc\x16\x97\xd6\xee\x95\xe6\xaa\x4a\x20\x7e\x46\x6d\x77\xf2\x00\x5a\x9c\xe7\x66\xd1\xe1\x11\x10\x20\x3d\xd5\x60\x51\x52\x96\x00\x3e\x2b\x6e\x7c\xfc\x68\x48\x4e\x31\x3c\x35\xb8\xf3\xdd\xfa\xdd\x01\xb5\xdd\xc6\x78\x5c\x46\x18\x62\x84\x08\x3d\x9e\xcc\xc4\x34\xc8\xfe\xd1\x46\x79\x37\xd9\x15\x5c\x34\x66\x55\xcb\x25\x55\x94\xb3\x04\x04\xa9\xb1\xa2\x3f\x89\x36\x7a\xf3\x29\xcb\x49\x97\x40\xa8\x47\x29\xce\x5e\x4a\xc1\xcf\x2c\x4f\xe0\x7f\x85\xf9\xd8\x7c\x87\xb4\x86\xb2\xfa\x1a\x90\xa9\x42\xa7\xb2\x28\x37\x0a\x0e\xe1\x9e\x34\x7a\x4e\x91\x4e\xf9\xb8\xa6\x25\x4b\x20\x23\x4c\x11\x71\xea\x01\xad\x70\xce\x5f\xb5\x17\x04\x11\x6a\x3b\x40\x20\xca\x14\x3f\xa1\x2d\xb8\x7f\x41\xb4\xd9\x02\x82\x7d\xdb\x99\xff\x2b\xef\x77\x9b\x7b\x30\x1c\x70\x18\x53\x0f\xf7\x8f\x36\x79\x74\x72\xf3\xc3\xca\x15\x64\x9d\xd1\x34\x6b\xc6\x19\x59\x20\x4d\x59\x7b\x56\x26\x8e\xa6\xa6\x6f\x69\x98\xc0\x83\x25\xe2\xc3\x16\x24\x66\xd2\x97\x44\x50\x83\x2c\x3f\xab\x9a\x32\xe2\xbc\xcf\xe1\x8f\xf4\xf7\xe4\x7d\xe8\xb6\x25\x6c\x32\xa7\x92\x06\x30\xdc\x2f\x5a\xd0\x4f\x5c\xa3\xbb\x4b\x52\xd2\x37\x92\x40\xb8\x6b\xbb\xbb\x81\x1c\xcb\x9c\xfb\x08\x5c\xc7\x27\x88\xa4\x67\xa5\x38\xbb\x1f\x12\x43\x16\x25\x30\x93\x7a\x79\x02\xe7\xb6\x25\x22\xc3\x92\x7c\x82\x57\x94\xe2\xe7\x2c\xfd\x0c\xaf\x11\x9b\x00\x1d\x6c\xae\x19\xaf\xb9\x98\xf1\xfd\x03\x2a\x00\xfe\x2b\x49\x5f\xa8\x4b\xcc\xed\x23\x5c\xd7\x80\x82\x18\x88\x4b\xee\xd6\xbb\xec\x2c\xa4\x8e\xd2\x72\x6a\xf9\x7f\x27\xd0\x13\xf4\xa6\x5c\x0c\x5c\xee\x0b\xf4\xe3\x2b\xe8\x27\x15\xff\x49\x84\x95\xb1\x19\x66\xdf\x8f\xfb\xf4\xfb\xe9\x83\x39\xce\xb4\x3e\xac\xd8\xc7\x87\x1d\x8e\x4f\x30\x2e\x08\x1a\x22\x65\x2f\x4c\x6b\x64\x1c\xd1\x7d\x3e\x3e\x87\x4b\x74\xf7\xbf\xc0\xb9\x59\x28\x80\x86\x32\xbf\x22\xb4\xac\x54\x02\xf1\x2a\x1a\x33\x2e\x66\x9c\x29\x4c\x99\x43\xe1\x1e\x31\x9c\xaa\x1e\x5a\x55\xbd\xd3\xdc\xb3\x3b\x2d\xb6\xd3\x29\x73\x92\x98\x90\x7a\x8e\x30\x95\xc0\xc3\x83\xf6\x94\x53\xd9\xd6\xf8\x92\x40\x5a\xf3\xec\xc5\x20\x55\x13\x2c\xf4\x06\x55\xd5\xc2\x31\x04\x94\x15\x7c\x06\xf1\x5e\x8b\xa5\xcd\x61\x5d\x5f\xd7\x1c\x54\xe1\xd5\x36\xcd\x65\x6e\x02\x63\xfc\xdc\x8e\x4a\xf1\xda\xe3\x8d\xd0\xb4\xb7\x21\xd6\xdf\xf5\x98\xb2\xc5\xcc\xd5\x6f\x8d\x77\xb9\xfe\x2e\x89\x10\x39\x22\xac\xaf\xc7\x33\x0f\xc8\x7c\x86\xc2\x73\x92\x71\x81\x6d\x37\xad\x22\x5f\xf5\x13\x14\x73\x4f\xa4\x88\xd3\xd8\x26\x9e\xf2\xfc\x72\xbf\x48\xf5\x72\x60\x4b\x68\x38\x57\x95\x41\x0f\x33\x45\x71\x4d\xb1\x24\xa6\x42\xbf\xe1\x6f\x3e\x97\xdd\x07\xbb\x52\xe0\x8b\xcc\x70\xed\x92\x15\xa4\x21\x4d\x4a\x84\xbf\x60\xa9\x6d\x8a\x9f\x72\xa5\x78\x93\x40\x64\x09\x3d\xc1\xed\xa8\x25\xce\x94\x5b\x91\xec\x25\xe5\x9d\x2d\x6f\x45\x6c\x46\x8b\x9c\xfe\x1c\x06\xbe\xd6\x85\x1a\x5f\x6e\x6c\x0a\x6d\x51\xd4\xfa\xb8\xab\x68\x9e\x13\xb6\xf0\x36\x9e\x04\xbc\xc5\x19\x55\x97\x5e\x68\x07\x67\x38\x95\xbc\x3e\xab\x75\x19\x04\xa8\x49\xa1\x96\x87\x99\xe9\x2d\x6f\xdd\xd3\x7c\x5b\x2e\xd4\xbd\x97\x00\x3b\x9e\xa5\x56\xe3\x94\xd8\xfb\x5e\x51\x73\xac\x12\x13\xca\x84\xa4\x8c\x0c\xda\x41\x19\xd5\x3d\x5b\x00\x8b\x82\xa3\x05\x7b\xa2\xb9\xe1\x8e\x34\x10\xea\xe9\xcf\x15\x6b\x25\x89\xa9\xff\x30\x88\xf6\xbd\x66\xf5\x11\x50\x10\x1d\x48\x33\x6a\xfb\xfb\x8d\xb6\xcd\x79\x32\x2b\xef\x9e\x45\x93\x39\xfd\xd0\x60\xf1\x72\x83\x01\xe3\x11\xe0\x0f\x1b\x87\x90\x49\x2f\x22\xa7\x8e\x3d\xa4\x76\x7c\x23\x91\x3e\x68\x32\xd1\xc7\x41\x10\xfb\x5b\xd5\x15\x06\xcd\x65\xb4\x3f\xdc\x25\xaf\x69\x0e\xaf\x15\xb5\x56\xb3\xe3\xda\xde\x23\x04\x57\x58\x91\xa7\xdd\x3e\x27\xe5\xc6\x6e\x4e\x79\xf3\xfd\xad\x77\x03\xeb\x22\x5b\xb9\x03\xc2\x69\xa5\xa5\xf4\x77\x3b\x30\x3c\x8e\xfb\x8b\x98\xb9\x7c\x39\x6b\x04\x51\xdb\xe9\x57\x9a\xe6\xbf\x40\xa9\x5f\x6c\xea\xf8\x93\xc4\xdd\x65\xc6\xf4\xfb\x99\x77\xef\x5f\x06\x98\xf4\x6f\xda\xfe\x60\x88\x34\xec\xd6\x20\xea\xa7\x2c\x3a\x61\x18\x1c\xfb\x19\x03\xd1\xb0\x64\x09\x52\xdc\x5f\xfd\x57\x36\x85\x51\x9f\xc4\x0c\x49\x0e\x7f\xc3\x5a\xda\xbf\x5f\x45\x66\x85\xd8\xe3\x2d\x72\x72\x6d\xfc\x9a\x90\x6b\x5c\x1f\xc4\xe7\x7a\x08\x77\x77\xfb\x2f\x6a\xf3\x60\x2d\xb4\x07\xeb\xc1\x3d\xf8\xb4\x4e\xd7\x4a\x97\x80\xdb\x9d\x2b\xa5\x7e\x59\x89\x79\x9e\x7f\x4d\x80\x9b\xcd\x5a\x16\x66\x7e\x92\x86\x47\xb4\x85\xc9\x9f\xcd\xc4\xce\x9e\x65\x33\x0d\x70\xc7\x9e\x13\xca\x7f\x02\x00\x00\xff\xff\x23\x57\xc6\x0d\xdc\x10\x00\x00")
|
||||||
|
|
||||||
|
func staticStyleCssBytes() ([]byte, error) {
|
||||||
|
return bindataRead(
|
||||||
|
_staticStyleCss,
|
||||||
|
"static/style.css",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func staticStyleCss() (*asset, error) {
|
||||||
|
bytes, err := staticStyleCssBytes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset loads and returns the asset for the given name.
|
||||||
|
// It returns an error if the asset could not be found or
|
||||||
|
// could not be loaded.
|
||||||
|
func Asset(name string) ([]byte, error) {
|
||||||
|
canonicalName := strings.Replace(name, "\\", "/", -1)
|
||||||
|
if f, ok := _bindata[canonicalName]; ok {
|
||||||
|
a, err := f()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
|
||||||
|
}
|
||||||
|
return a.bytes, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("Asset %s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetString returns the asset contents as a string (instead of a []byte).
|
||||||
|
func AssetString(name string) (string, error) {
|
||||||
|
data, err := Asset(name)
|
||||||
|
return string(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustAsset is like Asset but panics when Asset would return an error.
|
||||||
|
// It simplifies safe initialization of global variables.
|
||||||
|
func MustAsset(name string) []byte {
|
||||||
|
a, err := Asset(name)
|
||||||
|
if err != nil {
|
||||||
|
panic("asset: Asset(" + name + "): " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustAssetString is like AssetString but panics when Asset would return an
|
||||||
|
// error. It simplifies safe initialization of global variables.
|
||||||
|
func MustAssetString(name string) string {
|
||||||
|
return string(MustAsset(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetInfo loads and returns the asset info for the given name.
|
||||||
|
// It returns an error if the asset could not be found or
|
||||||
|
// could not be loaded.
|
||||||
|
func AssetInfo(name string) (os.FileInfo, error) {
|
||||||
|
canonicalName := strings.Replace(name, "\\", "/", -1)
|
||||||
|
if f, ok := _bindata[canonicalName]; ok {
|
||||||
|
a, err := f()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
|
||||||
|
}
|
||||||
|
return a.info, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("AssetInfo %s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetDigest returns the digest of the file with the given name. It returns an
|
||||||
|
// error if the asset could not be found or the digest could not be loaded.
|
||||||
|
func AssetDigest(name string) ([sha256.Size]byte, error) {
|
||||||
|
canonicalName := strings.Replace(name, "\\", "/", -1)
|
||||||
|
if f, ok := _bindata[canonicalName]; ok {
|
||||||
|
a, err := f()
|
||||||
|
if err != nil {
|
||||||
|
return [sha256.Size]byte{}, fmt.Errorf("AssetDigest %s can't read by error: %v", name, err)
|
||||||
|
}
|
||||||
|
return a.digest, nil
|
||||||
|
}
|
||||||
|
return [sha256.Size]byte{}, fmt.Errorf("AssetDigest %s not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digests returns a map of all known files and their checksums.
|
||||||
|
func Digests() (map[string][sha256.Size]byte, error) {
|
||||||
|
mp := make(map[string][sha256.Size]byte, len(_bindata))
|
||||||
|
for name := range _bindata {
|
||||||
|
a, err := _bindata[name]()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mp[name] = a.digest
|
||||||
|
}
|
||||||
|
return mp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetNames returns the names of the assets.
|
||||||
|
func AssetNames() []string {
|
||||||
|
names := make([]string, 0, len(_bindata))
|
||||||
|
for name := range _bindata {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||||
|
var _bindata = map[string]func() (*asset, error){
|
||||||
|
"login.tmpl": loginTmpl,
|
||||||
|
|
||||||
|
"static/script.js": staticScriptJs,
|
||||||
|
|
||||||
|
"static/style.css": staticStyleCss,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetDir returns the file names below a certain
|
||||||
|
// directory embedded in the file by go-bindata.
|
||||||
|
// For example if you run go-bindata on data/... and data contains the
|
||||||
|
// following hierarchy:
|
||||||
|
// data/
|
||||||
|
// foo.txt
|
||||||
|
// img/
|
||||||
|
// a.png
|
||||||
|
// b.png
|
||||||
|
// then AssetDir("data") would return []string{"foo.txt", "img"},
|
||||||
|
// AssetDir("data/img") would return []string{"a.png", "b.png"},
|
||||||
|
// AssetDir("foo.txt") and AssetDir("notexist") would return an error, and
|
||||||
|
// AssetDir("") will return []string{"data"}.
|
||||||
|
func AssetDir(name string) ([]string, error) {
|
||||||
|
node := _bintree
|
||||||
|
if len(name) != 0 {
|
||||||
|
canonicalName := strings.Replace(name, "\\", "/", -1)
|
||||||
|
pathList := strings.Split(canonicalName, "/")
|
||||||
|
for _, p := range pathList {
|
||||||
|
node = node.Children[p]
|
||||||
|
if node == nil {
|
||||||
|
return nil, fmt.Errorf("Asset %s not found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if node.Func != nil {
|
||||||
|
return nil, fmt.Errorf("Asset %s not found", name)
|
||||||
|
}
|
||||||
|
rv := make([]string, 0, len(node.Children))
|
||||||
|
for childName := range node.Children {
|
||||||
|
rv = append(rv, childName)
|
||||||
|
}
|
||||||
|
return rv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type bintree struct {
|
||||||
|
Func func() (*asset, error)
|
||||||
|
Children map[string]*bintree
|
||||||
|
}
|
||||||
|
|
||||||
|
var _bintree = &bintree{nil, map[string]*bintree{
|
||||||
|
"login.tmpl": &bintree{loginTmpl, map[string]*bintree{}},
|
||||||
|
"static": &bintree{nil, map[string]*bintree{
|
||||||
|
"script.js": &bintree{staticScriptJs, map[string]*bintree{}},
|
||||||
|
"style.css": &bintree{staticStyleCss, map[string]*bintree{}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
// RestoreAsset restores an asset under the given directory.
|
||||||
|
func RestoreAsset(dir, name string) error {
|
||||||
|
data, err := Asset(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := AssetInfo(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreAssets restores an asset under the given directory recursively.
|
||||||
|
func RestoreAssets(dir, name string) error {
|
||||||
|
children, err := AssetDir(name)
|
||||||
|
// File
|
||||||
|
if err != nil {
|
||||||
|
return RestoreAsset(dir, name)
|
||||||
|
}
|
||||||
|
// Dir
|
||||||
|
for _, child := range children {
|
||||||
|
err = RestoreAssets(dir, filepath.Join(name, child))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func _filePath(dir, name string) string {
|
||||||
|
canonicalName := strings.Replace(name, "\\", "/", -1)
|
||||||
|
return filepath.Join(append([]string{dir}, strings.Split(canonicalName, "/")...)...)
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
{{ define "title" }}
|
||||||
|
Login Provider Werther
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "style" }}
|
||||||
|
<link rel="stylesheet" href="static/style.css">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "js" }}
|
||||||
|
<script type="text/javascript" src="static/script.js"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="form">
|
||||||
|
<p class="message">
|
||||||
|
{{ if .IsInvalidCredentials }}
|
||||||
|
Invalid username or password
|
||||||
|
{{ else if .IsInternalError }}
|
||||||
|
Internal server error
|
||||||
|
{{ else }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
<form class="login-form" action="{{ .LoginURL }}" method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value={{ .CSRFToken }}>
|
||||||
|
<input type="hidden" name="login_challenge" value={{ .Challenge }}>
|
||||||
|
|
||||||
|
<input type="text" placeholder="username" name="username"/>
|
||||||
|
<input type="password" placeholder="password" name="password"/>
|
||||||
|
|
||||||
|
<div class="checkbox remember-container">
|
||||||
|
<div class="checkbox-overlay">
|
||||||
|
<input type="checkbox" name="remember" />
|
||||||
|
<div class="checkbox-container">
|
||||||
|
<div class="checkbox-checkmark"></div>
|
||||||
|
</div>
|
||||||
|
<label for="remember">Remember me</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,34 @@
|
||||||
|
/* vim: setl et ts=4 sts=4 sw=4 */
|
||||||
|
window.onload = function() {
|
||||||
|
var userElem = document.querySelector(".login-form input[name='username']"),
|
||||||
|
passElem = document.querySelector(".login-form input[name='password']"),
|
||||||
|
remeElem = document.querySelector(".login-form input[name='remember']"),
|
||||||
|
loginForm = document.querySelector(".login-form");
|
||||||
|
|
||||||
|
userElem.value = sessionStorage.getItem('username');
|
||||||
|
remeElem.checked = sessionStorage.getItem('remember');
|
||||||
|
|
||||||
|
if (userElem.value == null || userElem.value == "") {
|
||||||
|
userElem.focus();
|
||||||
|
} else {
|
||||||
|
passElem.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginForm.addEventListener("submit", function(e) {
|
||||||
|
var msgElem = document.querySelector("p.message");
|
||||||
|
|
||||||
|
if (userElem.value == null || userElem.value == "" ||
|
||||||
|
passElem.value == null || passElem.value == "") {
|
||||||
|
msgElem.innerHTML = "Username and password are required";
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStorage.setItem('username', userElem.value);
|
||||||
|
if (remeElem.checked) {
|
||||||
|
sessionStorage.setItem('remember', remeElem.checked);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('remember');
|
||||||
|
}
|
||||||
|
|
||||||
|
}, false);
|
||||||
|
};
|
|
@ -0,0 +1,266 @@
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Roboto:300);
|
||||||
|
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
width: 360px;
|
||||||
|
padding: 8% 0 0;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-page {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8% 5% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: #ffffff;
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto 100px;
|
||||||
|
padding: 2.815em;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form {
|
||||||
|
margin: 15% auto 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input {
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
outline: 0;
|
||||||
|
background: #f2f2f2;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form input {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form button {
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
outline: 0;
|
||||||
|
background: #2ba6cb;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 1.08em;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-transition: all 0.3 ease;
|
||||||
|
transition: all 0.3 ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form button {
|
||||||
|
padding: 0.8em;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form button:hover {
|
||||||
|
background: #2795b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form button:active {
|
||||||
|
background: #2384a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form .message {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #ff6961;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.form .message {
|
||||||
|
min-height: 3em;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:before,
|
||||||
|
.container:after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .info {
|
||||||
|
margin: 50px auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .info h1 {
|
||||||
|
margin: 0 0 15px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .info span {
|
||||||
|
color: #4d4d4d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .info span a {
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .info span .fa {
|
||||||
|
color: #ef3b3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Roboto", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-container {
|
||||||
|
padding-bottom: 2em;
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox div.checkbox-overlay {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
left: 0;
|
||||||
|
margin: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox label {
|
||||||
|
float: left;
|
||||||
|
line-height: initial;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.14em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.checkbox label {
|
||||||
|
font-size:1.25em;
|
||||||
|
padding:0.28em 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox div.checkbox-container {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox div.checkbox-container .checkbox-checkmark {
|
||||||
|
position: relative;
|
||||||
|
background-color: #eee;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox div.checkbox-checkmark::after {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
border: solid white;
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
height: 12px;
|
||||||
|
width: 6px;
|
||||||
|
left: 7px;
|
||||||
|
top: 3px;
|
||||||
|
border-width: 0 2px 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.checkbox div.checkbox-container .checkbox-checkmark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox div.checkbox-container .checkbox-checkmark:after {
|
||||||
|
height: 20.4px;
|
||||||
|
width: 10.2px;
|
||||||
|
left: 11.9px;
|
||||||
|
top: 4px;
|
||||||
|
border-width: 0 3px 5px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input:checked ~ .checkbox-container > .checkbox-checkmark {
|
||||||
|
background-color: #2ba6cb;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input:checked ~ .checkbox-container > .checkbox-checkmark:after {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input:checked:hover ~ .checkbox-container > .checkbox-checkmark {
|
||||||
|
background-color: #2ba6cb;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox
|
||||||
|
input:checked:hover
|
||||||
|
~ .checkbox-container
|
||||||
|
> .checkbox-checkmark:after {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input:hover ~ .checkbox-container > .checkbox-checkmark {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox input:hover ~ .checkbox-container > .checkbox-checkmark:after {
|
||||||
|
display: initial;
|
||||||
|
border-color: rgba(190, 190, 190, 1);
|
||||||
|
border-top: 0px;
|
||||||
|
border-left: 0px;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
/*
|
||||||
|
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 }}
|
||||||
|
`
|
Loading…
Reference in New Issue