From c0e9e88dba892bda021ea73f3fcaf0167430da33 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Wed, 30 Dec 2020 19:31:10 +0100 Subject: [PATCH] Implement login flow --- .gitignore | 2 + cmd/runapp/main.go | 344 ++++++++++++++++++++++++++++++++++--------- go.mod | 10 +- go.sum | 40 +++++ main.go | 301 +++++++++++++++++++++++++++++++++---- templates/base.html | 10 ++ templates/login.html | 12 ++ 7 files changed, 624 insertions(+), 95 deletions(-) create mode 100644 templates/base.html create mode 100644 templates/login.html diff --git a/.gitignore b/.gitignore index 85e7c1d..5673f56 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /.idea/ +resourceapp.json +authapp.json diff --git a/cmd/runapp/main.go b/cmd/runapp/main.go index c648486..bce2ad8 100644 --- a/cmd/runapp/main.go +++ b/cmd/runapp/main.go @@ -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(` Auth test -

Hello World

-Login +

Hello {{ .User }}

+

This is an authorization protected resource

+Logout -`)) +`) 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} } diff --git a/go.mod b/go.mod index 0a76c9a..f3ce1e0 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a264163..1be64c9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index beb9099..0e0f540 100644 --- a/main.go +++ b/main.go @@ -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 } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..40d0b04 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,10 @@ +{{ define "base" }} + + +{{ .Title }} + +

{{ .Title }}

+{{ template "content" . }} + + +{{ end }} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..7f797a3 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,12 @@ +{{ define "content" }} +
+ {{ .csrfField }} + {{ if .errors.Email }}

{{ .errors.Email }}

{{ end }} + +
+ {{ if .errors.Password }}

{{ .errors.Password }}

{{ end }} + +
+ +
+{{ end }} \ No newline at end of file