Refactor IDP code
This commit is contained in:
parent
c0e9e88dba
commit
ce1fac0e68
11 changed files with 482 additions and 341 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,3 +1,3 @@
|
||||||
/.idea/
|
/.idea/
|
||||||
resourceapp.json
|
/*.toml
|
||||||
authapp.json
|
/certs/
|
||||||
|
|
149
cmd/idp/main.go
Normal file
149
cmd/idp/main.go
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-openapi/runtime/client"
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"github.com/knadh/koanf"
|
||||||
|
"github.com/knadh/koanf/parsers/json"
|
||||||
|
"github.com/knadh/koanf/parsers/toml"
|
||||||
|
"github.com/knadh/koanf/providers/confmap"
|
||||||
|
"github.com/knadh/koanf/providers/file"
|
||||||
|
"github.com/knadh/koanf/providers/posflag"
|
||||||
|
hydra "github.com/ory/hydra-client-go/client"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
flag "github.com/spf13/pflag"
|
||||||
|
|
||||||
|
commonHandlers "git.cacert.org/oidc_login/common/handlers"
|
||||||
|
"git.cacert.org/oidc_login/idp/handlers"
|
||||||
|
"git.cacert.org/oidc_login/idp/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f := flag.NewFlagSet("config", flag.ContinueOnError)
|
||||||
|
f.Usage = func() {
|
||||||
|
fmt.Println(f.FlagUsages())
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
f.StringSlice("conf", []string{"idp.toml"}, "path to one or more .toml files")
|
||||||
|
logger := log.New()
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if err = f.Parse(os.Args[1:]); err != nil {
|
||||||
|
logger.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := koanf.New(".")
|
||||||
|
|
||||||
|
cFiles, _ := f.GetStringSlice("conf")
|
||||||
|
for _, c := range cFiles {
|
||||||
|
if err := config.Load(file.Provider(c), toml.Parser()); err != nil {
|
||||||
|
logger.Fatalf("error loading config file: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = config.Load(confmap.Provider(map[string]interface{}{
|
||||||
|
"server.port": 3000,
|
||||||
|
"server.name": "login.cacert.localhost",
|
||||||
|
"server.key": "certs/idp.cacert.localhost.key",
|
||||||
|
"server.certificate": "certs/idp.cacert.localhost.crt.pem",
|
||||||
|
"admin.url": "https://hydra.cacert.localhost:4445/",
|
||||||
|
}, "."), nil)
|
||||||
|
_ = config.Load(file.Provider("idp.json"), json.Parser())
|
||||||
|
if err := config.Load(posflag.Provider(f, ".", config), nil); err != nil {
|
||||||
|
logger.Fatalf("error loading configuration: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infoln("Server is starting")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ctx = services.InitI18n(ctx, logger)
|
||||||
|
adminURL, err := url.Parse(config.MustString("admin.url"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("error parsing admin URL: %v", err)
|
||||||
|
}
|
||||||
|
clientTransport := client.New(adminURL.Host, adminURL.Path, []string{adminURL.Scheme})
|
||||||
|
adminClient := hydra.New(clientTransport, nil)
|
||||||
|
|
||||||
|
handlerContext := context.WithValue(ctx, handlers.CtxAdminClient, adminClient.Admin)
|
||||||
|
loginHandler, err := handlers.NewLoginHandler(handlerContext)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("error initializing login handler: %v", err)
|
||||||
|
}
|
||||||
|
consentHandler := handlers.NewConsentHandler(handlerContext)
|
||||||
|
|
||||||
|
router := http.NewServeMux()
|
||||||
|
router.Handle("/login", loginHandler)
|
||||||
|
router.Handle("/consent", consentHandler)
|
||||||
|
router.Handle("/health", commonHandlers.NewHealthHandler())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfKey, err := base64.StdEncoding.DecodeString(config.MustString("security.csrf.key"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatalf("could not parse CSRF key bytes: %v", err)
|
||||||
|
}
|
||||||
|
handler := csrf.Protect(csrfKey, csrf.Secure(true))(router)
|
||||||
|
|
||||||
|
nextRequestId := func() string {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: config.String("server.name"),
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")),
|
||||||
|
Handler: commonHandlers.Tracing(nextRequestId)(
|
||||||
|
commonHandlers.Logging(logger)(
|
||||||
|
commonHandlers.EnableHSTS()(handler),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 15 * time.Second,
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(&commonHandlers.Healthy, 0)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 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.Infof("Server is ready to handle requests at https://%s/", server.Addr)
|
||||||
|
atomic.StoreInt32(&commonHandlers.Healthy, 1)
|
||||||
|
if err := server.ListenAndServeTLS(
|
||||||
|
config.String("server.certificate"), config.String("server.key"),
|
||||||
|
); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.Fatalf("Could not listen on %s: %v\n", server.Addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
<-done
|
||||||
|
logger.Infoln("Server stopped")
|
||||||
|
}
|
56
common/handlers/observability.go
Normal file
56
common/handlers/observability.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type key int
|
||||||
|
|
||||||
|
const (
|
||||||
|
requestIdKey key = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Healthy int32
|
||||||
|
|
||||||
|
func NewHealthHandler() 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)
|
||||||
|
})
|
||||||
|
}
|
16
common/handlers/security.go
Normal file
16
common/handlers/security.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnableHSTS() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", int((time.Hour*24*180).Seconds())))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
1
go.mod
1
go.mod
|
@ -15,6 +15,7 @@ require (
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.1.1
|
github.com/nicksnyder/go-i18n/v2 v2.1.1
|
||||||
github.com/ory/hydra-client-go v1.8.5
|
github.com/ory/hydra-client-go v1.8.5
|
||||||
github.com/sirupsen/logrus v1.4.2
|
github.com/sirupsen/logrus v1.4.2
|
||||||
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/tidwall/pretty v1.0.1 // indirect
|
github.com/tidwall/pretty v1.0.1 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 // indirect
|
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 // indirect
|
||||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344 // indirect
|
||||||
|
|
7
idp/handlers/common.go
Normal file
7
idp/handlers/common.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
type handlerContextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CtxAdminClient handlerContextKey = iota
|
||||||
|
)
|
39
idp/handlers/consent.go
Normal file
39
idp/handlers/consent.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ory/hydra-client-go/client/admin"
|
||||||
|
"github.com/ory/hydra-client-go/models"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consentHandler struct {
|
||||||
|
adminClient *admin.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *consentHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
consentChallenge := request.URL.Query().Get("consent_challenge")
|
||||||
|
consentRequest, err := c.adminClient.AcceptConsentRequest(
|
||||||
|
admin.NewAcceptConsentRequestParams().WithConsentChallenge(consentChallenge).WithBody(
|
||||||
|
&models.AcceptConsentRequest{
|
||||||
|
GrantAccessTokenAudience: nil,
|
||||||
|
GrantScope: []string{"openid", "offline"},
|
||||||
|
HandledAt: models.NullTime(time.Now()),
|
||||||
|
Remember: true,
|
||||||
|
RememberFor: 86400,
|
||||||
|
}).WithTimeout(time.Second * 10))
|
||||||
|
if err != nil {
|
||||||
|
log.Panic(err)
|
||||||
|
}
|
||||||
|
writer.Header().Add("Location", *consentRequest.GetPayload().RedirectTo)
|
||||||
|
writer.WriteHeader(http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConsentHandler(ctx context.Context) *consentHandler {
|
||||||
|
return &consentHandler{
|
||||||
|
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
||||||
|
}
|
||||||
|
}
|
157
idp/handlers/login.go
Normal file
157
idp/handlers/login.go
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/admin"
|
||||||
|
"github.com/ory/hydra-client-go/models"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"git.cacert.org/oidc_login/idp/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginHandler struct {
|
||||||
|
loginTemplate *template.Template
|
||||||
|
bundle *i18n.Bundle
|
||||||
|
messageCatalog map[string]*i18n.Message
|
||||||
|
adminClient *admin.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInformation struct {
|
||||||
|
Email string `form:"email" validate:"required,email"`
|
||||||
|
Password string `form:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *loginHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
var err error
|
||||||
|
challenge := request.URL.Query().Get("login_challenge")
|
||||||
|
log.Debugf("received challenge %s\n", challenge)
|
||||||
|
validate := validator.New()
|
||||||
|
|
||||||
|
switch request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// GET should render login form
|
||||||
|
|
||||||
|
err = h.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
|
||||||
|
|
||||||
|
// 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()] = h.lookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(h.bundle, accept))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.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 := h.adminClient.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *loginHandler) lookupErrorMessage(tag string, field string, value interface{}, l *i18n.Localizer) string {
|
||||||
|
var message *i18n.Message
|
||||||
|
message, ok := h.messageCatalog[fmt.Sprintf("%s-%s", field, tag)]
|
||||||
|
if !ok {
|
||||||
|
log.Infof("no specific error message %s-%s", field, tag)
|
||||||
|
message, ok = h.messageCatalog[tag]
|
||||||
|
if !ok {
|
||||||
|
log.Infof("no specific error message %s", tag)
|
||||||
|
message, ok = h.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(ctx context.Context) (*loginHandler, error) {
|
||||||
|
loginTemplate, err := template.ParseFiles("templates/base.html", "templates/login.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &loginHandler{
|
||||||
|
loginTemplate: loginTemplate,
|
||||||
|
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle),
|
||||||
|
messageCatalog: ctx.Value(services.CtxI18nCatalog).(map[string]*i18n.Message),
|
||||||
|
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
||||||
|
}, nil
|
||||||
|
}
|
55
idp/services/i18n.go
Normal file
55
idp/services/i18n.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CtxI18nBundle contextKey = iota
|
||||||
|
CtxI18nCatalog
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitI18n(ctx context.Context, logger *log.Logger) context.Context {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
catalog := initMessageCatalog()
|
||||||
|
ctx = context.WithValue(ctx, CtxI18nBundle, bundle)
|
||||||
|
ctx = context.WithValue(ctx, CtxI18nCatalog, catalog)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func initMessageCatalog() map[string]*i18n.Message {
|
||||||
|
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.",
|
||||||
|
}
|
||||||
|
return messageCatalog
|
||||||
|
}
|
339
main.go
339
main.go
|
@ -1,339 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
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})
|
|
||||||
if err != nil {
|
|
||||||
log.Panic(err)
|
|
||||||
}
|
|
||||||
clientTransport := openApiClient.NewWithClient(adminURL.Host, adminURL.Path, []string{adminURL.Scheme}, apiClient)
|
|
||||||
adminClient = client.New(clientTransport, nil)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type consentHandler struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *consentHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
consentChallenge := request.URL.Query().Get("consent_challenge")
|
|
||||||
consentRequest, err := adminClient.Admin.AcceptConsentRequest(admin.NewAcceptConsentRequestParams().WithConsentChallenge(consentChallenge).WithBody(&models.AcceptConsentRequest{
|
|
||||||
GrantAccessTokenAudience: nil,
|
|
||||||
GrantScope: []string{"openid", "offline"},
|
|
||||||
HandledAt: models.NullTime(time.Now()),
|
|
||||||
Remember: true,
|
|
||||||
RememberFor: 86400,
|
|
||||||
}).WithTimeout(time.Second * 10))
|
|
||||||
if err != nil {
|
|
||||||
log.Panic(err)
|
|
||||||
}
|
|
||||||
writer.Header().Add("Location", *consentRequest.GetPayload().RedirectTo)
|
|
||||||
writer.WriteHeader(http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConsentHandler() *consentHandler {
|
|
||||||
return &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.Debugf("received challenge %s\n", challenge)
|
|
||||||
|
|
||||||
switch request.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
// GET should render login form
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
Reference in a new issue