Implement login flow

This commit is contained in:
Jan Dittberner 2020-12-30 19:31:10 +01:00
parent 027ed72fdc
commit c0e9e88dba
7 changed files with 624 additions and 95 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
/.idea/
resourceapp.json
authapp.json

View File

@ -3,76 +3,182 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"html/template"
"net/http"
"net/url"
"os"
"strings"
client2 "github.com/go-openapi/runtime/client"
"github.com/go-openapi/runtime/client"
"github.com/gorilla/sessions"
"github.com/knadh/koanf"
jsonParser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
var oauth2Config *oauth2.Config
type OpenIDConfiguration struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
JwksUri string `json:"jwks_uri"`
EndSessionEndpoint string `json:"end_session_endpoint"`
}
var (
sessionStore *sessions.FilesystemStore
k = koanf.New(".")
)
const (
sessionKeyAccessToken = iota
sessionKeyRefreshToken
sessionKeyIdToken
sessionKeyUserId
sessionKeyRoles
sessionKeyEmail
sessionKeyUsername
sessionRedirectTarget
)
func main() {
headers := map[string][]string{
"Accept": {"application/json"},
if err := k.Load(file.Provider("resourceapp.json"), jsonParser.Parser()); err != nil && !os.IsNotExist(err) {
log.Fatalf("error loading config: %v", err)
}
const prefix = "RESOURCEAPP_"
if err := k.Load(env.Provider(prefix, ".", func(s string) string {
return strings.Replace(strings.ToLower(
strings.TrimPrefix(s, prefix)), "_", ".", -1)
}), nil); err != nil {
log.Fatalf("error loading config: %v", err)
}
var body []byte
oidcServer := k.MustString("oidc.server")
oidcClientId := k.MustString("oidc.client-id")
oidcClientSecret := k.MustString("oidc.client-secret")
req, err := http.NewRequest(http.MethodGet, "https://localhost:4444/.well-known/openid-configuration", bytes.NewBuffer(body))
sessionPath := k.MustString("session.path")
sessionAuthKey, err := base64.StdEncoding.DecodeString(k.String("session.auth-key"))
if err != nil {
log.Panic(err)
log.Fatalf("could not decode session auth key: %s", err)
}
req.Header = headers
client, err := client2.TLSClient(client2.TLSClientOptions{InsecureSkipVerify: true})
sessionEncKey, err := base64.StdEncoding.DecodeString(k.String("session.enc-key"))
if err != nil {
log.Panic(err)
}
resp, err := client.Do(req)
if err != nil {
log.Panic(err)
log.Fatalf("could not decode session encryption key: %s", err)
}
dec := json.NewDecoder(resp.Body)
discoveryResponse := &OpenIDConfiguration{}
err = dec.Decode(discoveryResponse)
if err != nil {
log.Panic(err)
generated := false
if len(sessionAuthKey) != 64 {
sessionAuthKey = generateKey(64)
generated = true
}
if len(sessionEncKey) != 32 {
sessionEncKey = generateKey(32)
generated = true
}
oauth2Config = &oauth2.Config{
ClientID: "local-test-app",
ClientSecret: "uzvTqaCvUSBMd0aVjECmD-egAJ",
if generated {
_ = k.Load(confmap.Provider(map[string]interface{}{
"session.auth-key": sessionAuthKey,
"session.enc-key": sessionEncKey,
}, "."), nil)
jsonData, err := k.Marshal(jsonParser.Parser())
if err != nil {
log.Fatalf("could not encode session config")
}
log.Infof("put the following in your resourceapp.json:\n%s", string(jsonData))
}
var discoveryResponse OpenIDConfiguration
var discoveryUrl *url.URL
if discoveryUrl, err = url.Parse(oidcServer); err != nil {
log.Fatalf("could not parse oidc.server parameter value %s: %s", oidcServer, err)
} else {
discoveryUrl.Path = "/.well-known/openid-configuration"
}
apiClient, err := client.TLSClient(client.TLSClientOptions{InsecureSkipVerify: true})
if err := discoverOidc(discoveryUrl, apiClient, &discoveryResponse); err != nil {
log.Fatalf("OpenID Connect discovery failed: %s", err)
}
oauth2Config := &oauth2.Config{
ClientID: oidcClientId,
ClientSecret: oidcClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: discoveryResponse.AuthorizationEndpoint,
TokenURL: discoveryResponse.TokenEndpoint,
},
Scopes: []string{"openid", "offline"},
}
keySet, err := jwk.FetchHTTP(discoveryResponse.JwksUri, jwk.WithHTTPClient(apiClient))
if err != nil {
log.Fatalf("could not fetch JWKs: %s", err)
}
http.Handle("/", NewIndexPage())
http.Handle("/callback", NewCallbackHandler(discoveryResponse.JWKSUri))
if _, err = os.Stat(sessionPath); err != nil {
if os.IsNotExist(err) {
if err = os.MkdirAll(sessionPath, 0700); err != nil {
log.Fatalf("could not create session store directory: %s", err)
}
}
}
sessionStore = sessions.NewFilesystemStore(sessionPath, sessionAuthKey, sessionEncKey)
http.Handle("/", authenticate(oauth2Config)(NewIndexPage(discoveryResponse.EndSessionEndpoint)))
http.Handle("/callback", NewCallbackHandler(keySet, oauth2Config))
err = http.ListenAndServe(":4000", http.DefaultServeMux)
if err != nil {
log.Panic(err)
log.Fatal(err)
}
}
func generateKey(length int) []byte {
key := make([]byte, length)
read, err := rand.Read(key)
if err != nil {
log.Fatalf("could not generate key: %s", err)
}
if read != length {
log.Fatalf("read %d bytes, expected %d bytes", read, length)
}
return key
}
func discoverOidc(discoveryUrl *url.URL, apiClient *http.Client, o *OpenIDConfiguration) error {
var body []byte
req, err := http.NewRequest(http.MethodGet, discoveryUrl.String(), bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header = map[string][]string{
"Accept": {"application/json"},
}
resp, err := apiClient.Do(req)
if err != nil {
return err
}
dec := json.NewDecoder(resp.Body)
err = dec.Decode(o)
if err != nil {
return err
}
return nil
}
type callbackHandler struct {
JwksUri string
keySet *jwk.Set
oauth2Config *oauth2.Config
}
func (c *callbackHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@ -86,55 +192,121 @@ func (c *callbackHandler) ServeHTTP(writer http.ResponseWriter, request *http.Re
}
code := request.URL.Query().Get("code")
scope := request.URL.Query().Get("scope")
state := request.URL.Query().Get("state")
ctx := context.Background()
httpClient, err := client2.TLSClient(client2.TLSClientOptions{InsecureSkipVerify: true})
httpClient, err := client.TLSClient(client.TLSClientOptions{InsecureSkipVerify: true})
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
tok, err := oauth2Config.Exchange(ctx, code)
tok, err := c.oauth2Config.Exchange(ctx, code)
if err != nil {
log.Print(err)
log.Error(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
accessToken := tok.AccessToken
refreshToken := tok.RefreshToken
session, err := sessionStore.Get(request, "resource_session")
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
session.Values[sessionKeyAccessToken] = tok.AccessToken
session.Values[sessionKeyRefreshToken] = tok.RefreshToken
session.Values[sessionKeyIdToken] = tok.Extra("id_token").(string)
idToken := tok.Extra("id_token")
if parsedIdToken, err := jwt.ParseString(idToken.(string), jwt.WithKeySet(c.keySet), jwt.WithOpenIDClaims()); err != nil {
log.Error(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
} else {
log.Infof(`
ID Token
========
keySet, err := jwk.FetchHTTP(c.JwksUri, jwk.WithHTTPClient(httpClient))
if err != nil {
log.Print(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
Subject: %s
Audience: %s
Issued at: %s
Issued by: %s
Not valid before: %s
Not valid after: %s
`,
parsedIdToken.Subject(),
parsedIdToken.Audience(),
parsedIdToken.IssuedAt(),
parsedIdToken.Issuer(),
parsedIdToken.NotBefore(),
parsedIdToken.Expiration(),
)
session.Values[sessionKeyUserId] = parsedIdToken.Subject()
if roles, ok := parsedIdToken.Get("Groups"); ok {
session.Values[sessionKeyRoles] = roles
}
if username, ok := parsedIdToken.Get("Username"); ok {
session.Values[sessionKeyUsername] = username
}
if email, ok := parsedIdToken.Get("Email"); ok {
session.Values[sessionKeyEmail] = email
}
}
parsedIdToken, err := jwt.Parse(strings.NewReader(idToken.(string)), jwt.WithKeySet(keySet))
if err != nil {
log.Print(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
if err = session.Save(request, writer); err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
}
if redirectTarget, ok := session.Values[sessionRedirectTarget]; ok {
writer.Header().Set("Location", redirectTarget.(string))
} else {
writer.Header().Set("Location", "/")
}
_, err = fmt.Fprintf(
writer,
"scope: %s, state: %s\ncode %s -> token %+v\n\naccess: %+v\nrefresh: %+v\nid: %+v\n\nParsed id token:\n%+v",
code, scope, state, tok, accessToken, refreshToken, idToken, parsedIdToken)
if err != nil {
log.Print(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
writer.WriteHeader(http.StatusFound)
}
func NewCallbackHandler(keySet *jwk.Set, oauth2Config *oauth2.Config) *callbackHandler {
return &callbackHandler{keySet: keySet, oauth2Config: oauth2Config}
}
func authenticate(oauth2Config *oauth2.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := sessionStore.Get(r, "resource_session")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, ok := session.Values[sessionKeyUserId]; ok {
next.ServeHTTP(w, r)
return
}
session.Values[sessionRedirectTarget] = r.URL.String()
if err = session.Save(r, w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var authUrl *url.URL
if authUrl, err = url.Parse(oauth2Config.Endpoint.AuthURL); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
queryValues := authUrl.Query()
queryValues.Set("client_id", k.String("oidc.client-id"))
queryValues.Set("response_type", "code")
queryValues.Set("scope", "openid offline")
queryValues.Set("state", base64.URLEncoding.EncodeToString(generateKey(8)))
authUrl.RawQuery = queryValues.Encode()
w.Header().Set("Location", authUrl.String())
w.WriteHeader(http.StatusFound)
})
}
}
func NewCallbackHandler(jwksUri string) *callbackHandler {
return &callbackHandler{JwksUri: jwksUri}
type indexHandler struct {
logoutUrl string
}
type indexHandler struct{}
func (i indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
@ -145,22 +317,56 @@ func (i indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques
return
}
writer.WriteHeader(http.StatusOK)
writer.Header().Add("Content-Type", "text/html")
_, err := writer.Write([]byte(`
page, err := template.New("").Parse(`
<!DOCTYPE html>
<html lang="en">
<head><title>Auth test</title></head>
<body>
<h1>Hello World</h1>
<a href="https://localhost:4444/oauth2/auth?client_id=local-test-app&response_type=code&scope=openid%20offline&state=12345678">Login</a>
<h1>Hello {{ .User }}</h1>
<p>This is an authorization protected resource</p>
<a href="{{ .LogoutURL }}">Logout</a>
</body>
</html>
`))
`)
if err != nil {
log.Panic(err)
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
session, err := sessionStore.Get(request, "resource_session")
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
logoutUrl, err := url.Parse(i.logoutUrl)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
var user string
var ok bool
if user, ok = session.Values[sessionKeyUsername].(string); ok {
}
if idToken, ok := session.Values[sessionKeyIdToken].(string); ok {
logoutUrl.RawQuery = url.Values{
"id_token_hint": []string{idToken},
"post_logout_redirect_uri": []string{"/logged_out"},
}.Encode()
}
writer.Header().Add("Content-Type", "text/html")
err = page.Execute(writer, map[string]interface{}{
"User": user,
"LogoutURL": logoutUrl.String(),
})
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
}
func NewIndexPage() *indexHandler {
return &indexHandler{}
func NewIndexPage(logoutUrl string) *indexHandler {
return &indexHandler{logoutUrl: logoutUrl}
}

10
go.mod
View File

@ -3,14 +3,22 @@ module git.cacert.org/oidc_login
go 1.14
require (
github.com/BurntSushi/toml v0.3.1
github.com/go-openapi/runtime v0.19.22
github.com/go-playground/form/v4 v4.1.1
github.com/go-playground/validator/v10 v10.4.1
github.com/golang/protobuf v1.4.0 // indirect
github.com/gorilla/csrf v1.7.0
github.com/gorilla/sessions v1.2.1
github.com/knadh/koanf v0.14.0
github.com/lestrrat-go/jwx v1.0.6
github.com/nicksnyder/go-i18n/v2 v2.1.1
github.com/ory/hydra-client-go v1.8.5
github.com/sirupsen/logrus v1.4.2
github.com/tidwall/pretty v1.0.1 // indirect
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 // indirect
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/text v0.3.3 // indirect
golang.org/x/text v0.3.3
google.golang.org/appengine v1.6.5 // indirect
)

40
go.sum
View File

@ -1,4 +1,5 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@ -19,6 +20,9 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI=
@ -88,6 +92,16 @@ github.com/go-openapi/validate v0.19.3/go.mod h1:90Vh6jjkTn+OT1Eefm0ZixWNFjhtOH7
github.com/go-openapi/validate v0.19.10/go.mod h1:RKEZTUWDkxKQxN2jDT7ZnZi2bhZlbNMAuKvKB+IaGx8=
github.com/go-openapi/validate v0.19.11 h1:8lCr0b9lNWKjVjW/hSZZvltUy+bULl7vbnCTsOzlhPo=
github.com/go-openapi/validate v0.19.11/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0waH08tGe6kAQ4=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/form/v4 v4.1.1 h1:1T9lGt3WRHuDwT5uwx7RYQZfxVwWZhF0DC1ovKyNnWY=
github.com/go-playground/form/v4 v4.1.1/go.mod h1:q1a2BY+AQUUzhl6xA/6hBetay6dEIhMHjgvJiGo6K7U=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
@ -131,6 +145,14 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y=
github.com/gorilla/csrf v1.7.0/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
@ -139,7 +161,10 @@ github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaR
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/knadh/koanf v0.14.0 h1:h9XeG4wEiEuxdxqv/SbY7TEK+7vzrg/dOaGB+S6+mPo=
github.com/knadh/koanf v0.14.0/go.mod h1:H5mEFsTeWizwFXHKtsITL5ipsLTuAMQoGuQpp+1JL9U=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -147,6 +172,8 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911 h1:FvnrqecqX4zT0wOIbYK1gNgTm0677INEWiFY8UEYggY=
github.com/lestrrat-go/iter v0.0.0-20200422075355-fc1769541911/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.0.6 h1:0absmJ/XlsxNkXr9syeIHjCJnu3rZa+DKzdCI6QfYgU=
@ -162,9 +189,12 @@ github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg=
github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/nicksnyder/go-i18n/v2 v2.1.1 h1:ATCOanRDlrfKVB4WHAdJnLEqZtDmKYsweqsOUYflnBU=
github.com/nicksnyder/go-i18n/v2 v2.1.1/go.mod h1:d++QJC9ZVf7pa48qrsRWhMJ5pSHIPmS3OLqK1niyLxs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/ory/hydra-client-go v1.8.5 h1:YyoI07CnL1kikTElbSEg9EsZyT0mtl+2fiXLgwvs7XU=
@ -172,21 +202,27 @@ github.com/ory/hydra-client-go v1.8.5/go.mod h1:85NsQeF1Lof8B77fJjEf6J8mbafW5Wxl
github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.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/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
@ -251,7 +287,11 @@ golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=

301
main.go
View File

@ -1,37 +1,188 @@
package main
import (
"log"
"context"
"flag"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"os/signal"
"sync/atomic"
"time"
"github.com/BurntSushi/toml"
openApiClient "github.com/go-openapi/runtime/client"
"github.com/go-playground/form/v4"
"github.com/go-playground/validator/v10"
"github.com/gorilla/csrf"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/ory/hydra-client-go/client"
"github.com/ory/hydra-client-go/client/admin"
"github.com/ory/hydra-client-go/models"
log "github.com/sirupsen/logrus"
"golang.org/x/text/language"
)
var adminClient *client.OryHydra
type key int
const (
requestIdKey key = iota
)
var (
adminClient *client.OryHydra
listenAddr string
healthy int32
validate *validator.Validate
bundle *i18n.Bundle
messageCatalog map[string]*i18n.Message
)
func main() {
flag.StringVar(&listenAddr, "listen-addr", ":3000", "server listen address")
flag.Parse()
logger := log.New()
logger.Infoln("Server is starting")
validate = validator.New()
router := http.NewServeMux()
loginHandler, err := NewLoginHandler()
router.Handle("/login", loginHandler)
router.Handle("/consent", NewConsentHandler())
router.Handle("/health", health())
adminURL, err := url.Parse("https://localhost:4445/")
if err != nil {
log.Panic(err)
}
apiclient, err := openApiClient.TLSClient(openApiClient.TLSClientOptions{InsecureSkipVerify: true})
apiClient, err := openApiClient.TLSClient(openApiClient.TLSClientOptions{InsecureSkipVerify: true})
if err != nil {
log.Panic(err)
}
clientTransport := openApiClient.NewWithClient(adminURL.Host, adminURL.Path, []string{adminURL.Scheme}, apiclient)
clientTransport := openApiClient.NewWithClient(adminURL.Host, adminURL.Path, []string{adminURL.Scheme}, apiClient)
adminClient = client.New(clientTransport, nil)
http.Handle("/login", NewLoginHandler())
http.Handle("/consent", NewConsentHandler())
err = http.ListenAndServe(":3000", http.DefaultServeMux)
if err != nil {
log.Panic(err)
log.Fatal(err)
}
csrfKey := []byte("abcdefghijklmnopqrstuvwxyz012345")
handler := csrf.Protect(csrfKey)(router)
nextRequestId := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
bundle = i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
_, err = bundle.LoadMessageFile("de.toml")
if err != nil {
logger.Warnln("message bundle de.toml not found")
}
initMessageCatalog()
server := &http.Server{
Addr: ":3000",
Handler: tracing(nextRequestId)(logging(logger)(handler)),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
done := make(chan bool)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
go func() {
<-quit
logger.Infoln("Server is shutting down...")
atomic.StoreInt32(&healthy, 0)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
}
close(done)
}()
logger.Infoln("Server is ready to handle requests at", listenAddr)
atomic.StoreInt32(&healthy, 1)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Could not listen on %s: %v\n", listenAddr, err)
}
<-done
logger.Infoln("Server stopped")
}
func initMessageCatalog() {
messageCatalog = make(map[string]*i18n.Message)
messageCatalog["unknown"] = &i18n.Message{
ID: "ErrorUnknown",
Other: "Unknown error",
}
messageCatalog["email"] = &i18n.Message{
ID: "ErrorEmail",
Other: "Please enter a valid email address.",
}
messageCatalog["Email-required"] = &i18n.Message{
ID: "ErrorEmailRequired",
Other: "Please enter an email address.",
}
messageCatalog["required"] = &i18n.Message{
ID: "ErrorRequired",
Other: "Please enter a value",
}
messageCatalog["Password-required"] = &i18n.Message{
ID: "ErrorPasswordRequired",
Other: "Please enter a password.",
}
}
func health() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.LoadInt32(&healthy) == 1 {
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
})
}
func logging(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
requestId, ok := r.Context().Value(requestIdKey).(string)
if !ok {
requestId = "unknown"
}
logger.Infoln(requestId, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent())
}()
next.ServeHTTP(w, r)
})
}
}
func tracing(nextRequestId func() string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestId := r.Header.Get("X-Request-Id")
if requestId == "" {
requestId = nextRequestId()
}
ctx := context.WithValue(r.Context(), requestIdKey, requestId)
w.Header().Set("X-Request-Id", requestId)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
@ -59,30 +210,130 @@ func NewConsentHandler() *consentHandler {
}
type loginHandler struct {
loginTemplate *template.Template
}
type LoginInformation struct {
Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"`
}
func (l *loginHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var err error
challenge := request.URL.Query().Get("login_challenge")
log.Printf("received challenge %s\n", challenge)
log.Debugf("received challenge %s\n", challenge)
// GET should render login form
switch request.Method {
case http.MethodGet:
// GET should render login form
// POST should perform the action
err = l.loginTemplate.Lookup("base").Execute(writer, map[string]interface{}{
"Title": "Title",
csrf.TemplateTag: csrf.TemplateField(request),
"LabelEmail": "Email",
"LabelPassword": "Password",
"LabelLogin": "Login",
"errors": map[string]string{},
})
if err != nil {
log.Error(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
break
case http.MethodPost:
// POST should perform the action
var loginInfo LoginInformation
subject := "a-user-with-an-id"
loginRequest, err := adminClient.Admin.AcceptLoginRequest(admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{
Acr: "no-creds",
Remember: true,
RememberFor: 0,
Subject: &subject,
}).WithTimeout(time.Second * 10))
if err != nil {
log.Panic(err)
// validate input
decoder := form.NewDecoder()
err = decoder.Decode(&loginInfo, request.Form)
if err != nil {
log.Error(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
err := validate.Struct(&loginInfo)
if err != nil {
errors := make(map[string]string)
for _, err := range err.(validator.ValidationErrors) {
accept := request.Header.Get("Accept-Language")
errors[err.Field()] = lookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(bundle, accept))
}
err = l.loginTemplate.Lookup("base").Execute(writer, map[string]interface{}{
"Title": "Title",
csrf.TemplateTag: csrf.TemplateField(request),
"LabelEmail": "Email",
"LabelPassword": "Password",
"LabelLogin": "Login",
"Email": loginInfo.Email,
"errors": errors,
})
if err != nil {
log.Error(err)
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
return
}
// GET user data
// finish login and redirect to target
// TODO: get or generate a user id
subject := "a-user-with-an-id"
loginRequest, err := adminClient.Admin.AcceptLoginRequest(
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{
Acr: "no-creds",
Remember: true,
RememberFor: 0,
Subject: &subject,
}).WithTimeout(time.Second * 10))
if err != nil {
log.Panic(err)
}
writer.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
writer.WriteHeader(http.StatusFound)
break
default:
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
writer.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
writer.WriteHeader(http.StatusFound)
}
func NewLoginHandler() *loginHandler {
return &loginHandler{}
func lookupErrorMessage(tag string, field string, value interface{}, l *i18n.Localizer) string {
var message *i18n.Message
message, ok := messageCatalog[fmt.Sprintf("%s-%s", field, tag)]
if !ok {
log.Infof("no specific error message %s-%s", field, tag)
message, ok = messageCatalog[tag]
if !ok {
log.Infof("no specific error message %s", tag)
message, ok = messageCatalog["unknown"]
if !ok {
log.Error("no default translation found")
return tag
}
}
}
translation, err := l.Localize(&i18n.LocalizeConfig{
DefaultMessage: message,
TemplateData: map[string]interface{}{
"Value": value,
},
})
if err != nil {
log.Error(err)
return tag
}
return translation
}
func NewLoginHandler() (*loginHandler, error) {
loginTemplate, err := template.ParseFiles("templates/base.html", "templates/login.html")
if err != nil {
return nil, err
}
return &loginHandler{loginTemplate: loginTemplate}, nil
}

10
templates/base.html Normal file
View File

@ -0,0 +1,10 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head><title>{{ .Title }}</title></head>
<body>
<h1>{{ .Title }}</h1>
{{ template "content" . }}
</body>
</html>
{{ end }}

12
templates/login.html Normal file
View File

@ -0,0 +1,12 @@
{{ define "content" }}
<form method="post">
{{ .csrfField }}
{{ if .errors.Email }}<p>{{ .errors.Email }}</p>{{ end }}
<label for="email">{{ .LabelEmail }}</label>
<input type="text" id="email" name="email" value="{{ .Email }}"/><br/>
{{ if .errors.Password }}<p>{{ .errors.Password }}</p>{{ end }}
<label for="password">{{ .LabelPassword }}</label>
<input type="password" id="password" name="password" value=""/><br/>
<button type="submit">{{ .LabelLogin }}</button>
</form>
{{ end }}