This repository has been archived on 2022-07-28. You can view files and clone it, but cannot push or open issues or pull requests.
hydra_oidc_poc/main.go

340 lines
9.2 KiB
Go

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
}