initial commit

This commit is contained in:
Konstantin Lepa 2019-02-18 16:57:54 +03:00 committed by Nikolay Stupak
commit 6658817311
25 changed files with 2742 additions and 0 deletions

0
.gitignore vendored Normal file
View File

29
.golangci.yml Normal file
View File

@ -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

14
CHANGELOG.md Normal file
View File

@ -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.

29
Dockerfile Normal file
View File

@ -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"]

131
README.md Normal file
View File

@ -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
```

4
ci-testing.yaml Normal file
View File

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

65
cmd/werther/main.go Normal file
View File

@ -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)))
}

14
cmd/werther/tools.go Normal file
View File

@ -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"

32
go.mod Normal file
View File

@ -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
)

56
go.sum Normal file
View File

@ -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=

View File

@ -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
}

35
internal/logger/logger.go Normal file
View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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")
}

29
internal/oauth2/oauth2.go Normal file
View File

@ -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"`
}

48
internal/server/mw.go Normal file
View File

@ -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))
})
}
}

View File

@ -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)
}
}

405
internal/server/server.go Normal file
View File

@ -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
}
}
}

View File

@ -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)
}

View File

@ -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, "/")...)...)
}

View File

@ -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 }}
&nbsp;
{{ 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 }}

View File

@ -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);
};

View File

@ -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;
}

127
internal/server/web.go Normal file
View File

@ -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 }}
`