Refactor i18n, add templating for resource app

This commit is contained in:
Jan Dittberner 2021-01-01 09:20:49 +01:00
parent e4f17ca315
commit 161ea7fe0c
21 changed files with 432 additions and 152 deletions

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
/*.toml
/.idea/ /.idea/
/certs/ /certs/
/hydra.yaml /hydra.yaml
/idp.toml
/resource_app.toml
/sessions/ /sessions/

View file

@ -186,3 +186,33 @@ Now you can start Hydra, the IDP and the demo app in 3 terminal windows:
Visit https://app.cacert.localhost:4000/ in a Browser and you will be directed Visit https://app.cacert.localhost:4000/ in a Browser and you will be directed
through the OpenID connect authorization code flow. through the OpenID connect authorization code flow.
## Translations
This application uses [go-i18n](https://github.com/nicksnyder/go-i18n/) for internationalization (i18n) support.
The translation workflow needs the `go18n` binary which can be installed via
```
go get -u github.com/nicksnyder/go-i18n/v2/goi18n
```
To extract new messages from the code run
```
goi18n extract .
```
Then use
```
goi18n merge active.*.toml
```
to create TOML files for translation as `translate.<locale>.toml`. After translating the messages run
```
goi18n merge active.*.toml translate.*.toml
```
to merge the messages back into the active translation files. To add a new language you need to add the language code
to the languages configuration option (default is defined in the configmap in cmd/idp/main.go and cmd/app/main.go).

72
active.de.toml Normal file
View file

@ -0,0 +1,72 @@
[ErrorEmail]
hash = "sha1-d2306dd8970ff616631a3501791297f31475e416"
other = "Bitte gib eine gültige E-Mailadresse ein."
[ErrorEmailRequired]
hash = "sha1-e61b6eae7b3294d71eed349d7f1509d20ce35162"
other = "Bitte gib eine E-Mailadresse ein."
[ErrorPasswordRequired]
hash = "sha1-13c9d732467bf266a77ddfd1bbbe09dde518f9b0"
other = "Bitte gib ein Passwort ein."
[ErrorRequired]
hash = "sha1-31632fcec9d22a8463757f459e51c7c0eccd1f28"
other = "Dieses Feld wird benötigt."
[ErrorUnknown]
hash = "sha1-e5fd9aa24c9417e7332e6f25936ae2a6ec8f1524"
other = "Unbekannter Fehler"
[IndexGreeting]
hash = "sha1-d4a13058e497fa24143ea96d50d82b818455ef61"
other = "Hallo {{ .User }}"
[IndexIntroductionText]
hash = "sha1-c2c530e263fc9c38482338ed290aafb496794179"
other = "Das ist eine zugriffsgeschützte Resource"
[IndexTitle]
hash = "sha1-eccb2b889c068d3f25496c1dad3fb0f88d021bd9"
other = "Willkommen in der Demo-Anwendung"
[IntroConsentMoreInformation]
hash = "sha1-f58b8378238bd433deef3c3e6b0b70d0fd0dd59e"
other = "Auf der <a href=\"{{ .clientLink }}\">Beschreibungsseite</a> findest du mehr Informationen zu <strong>{{ .client }}</strong>."
[IntroConsentRequested]
hash = "sha1-cb8efc74b5b726201321e0924747bf38d39629a1"
other = "Die Anwendung <strong>{{ .client }}</strong> benötigt deine Einwilligungung für die angefragten Berechtigungen."
[LabelConsent]
hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6"
other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf."
[LabelSubmit]
hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea"
other = "Abschicken"
[LogoutLabel]
description = "A label on a logout button or link"
hash = "sha1-8acfdeb9a8286f00c8e5dd48471cfdc994807579"
other = "Ausloggen"
[Scope-email-Description]
hash = "sha1-e50e5ea384cad8fac6f918d698be373b1362b351"
other = "Zugriff auf deine primäre E-Mail-Adresse."
[Scope-offline_access-Description]
hash = "sha1-732881bf998daa62cbad8615b2e6feb7a053b123"
other = "Zugriff auf deine Informationen behalten, bis du diese Zustimmung widerrufst."
[Scope-openid-Description]
hash = "sha1-0ad714e7a22b97d8247b70254990256bffa2ef76"
other = "Informationen über deine Identität abfragen."
[Scope-profile-Description]
hash = "sha1-f12d1d8af26e41aa82a3c1d6a7b7f0dba0313be1"
other = "Deine Nutzerprofilinformationen inklusive Name, Geburtsdatum und Spracheinstellung abfragen."
[TitleRequestConsent]
hash = "sha1-14984a9e08cda5390677458a98408baa955f9f8b"
other = "Anwendung erbittet deine Zustimmung"

21
active.en.toml Normal file
View file

@ -0,0 +1,21 @@
ErrorEmail = "Please enter a valid email address."
ErrorEmailRequired = "Please enter an email address."
ErrorPasswordRequired = "Please enter a password."
ErrorRequired = "Please enter a value"
ErrorUnknown = "Unknown error"
IndexGreeting = "Hello {{ .User }}"
IndexIntroductionText = "This is an authorization protected resource"
IndexTitle = "Welcome to the Demo application"
IntroConsentMoreInformation = "You can find more information about <strong>{{ .client }}</strong> at <a href=\"{{ .clientLink }}\">its description page</a>."
IntroConsentRequested = "The <strong>{{ .client }}</strong> application wants your consent for the requested set of permissions."
LabelConsent = "I hereby agree that the application may get the requested permissions."
LabelSubmit = "Submit"
Scope-email-Description = "Access your primary email address."
Scope-offline_access-Description = "Keep access to your information until you revoke the permission."
Scope-openid-Description = "Request information about your identity."
Scope-profile-Description = "Access your user profile information including your name, birth date and locale."
TitleRequestConsent = "Application requests your consent"
[LogoutLabel]
description = "A label on a logout button or link"
other = "Logout"

View file

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"net/url" "net/url"
@ -8,7 +9,6 @@ import (
"github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt" "github.com/lestrrat-go/jwx/jwt"
"github.com/lestrrat-go/jwx/jwt/openid" "github.com/lestrrat-go/jwx/jwt/openid"
"golang.org/x/oauth2"
"git.cacert.org/oidc_login/app/services" "git.cacert.org/oidc_login/app/services"
commonServices "git.cacert.org/oidc_login/common/services" commonServices "git.cacert.org/oidc_login/common/services"
@ -16,7 +16,7 @@ import (
const sessionName = "resource_session" const sessionName = "resource_session"
func Authenticate(oauth2Config *oauth2.Config, clientId string) func(http.Handler) http.Handler { func Authenticate(ctx context.Context, clientId string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := services.GetSessionStore().Get(r, sessionName) session, err := services.GetSessionStore().Get(r, sessionName)
@ -34,7 +34,7 @@ func Authenticate(oauth2Config *oauth2.Config, clientId string) func(http.Handle
return return
} }
var authUrl *url.URL var authUrl *url.URL
if authUrl, err = url.Parse(oauth2Config.Endpoint.AuthURL); err != nil { if authUrl, err = url.Parse(commonServices.GetOAuth2Config(ctx).Endpoint.AuthURL); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View file

@ -1,20 +1,26 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
"github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwk"
"github.com/nicksnyder/go-i18n/v2/i18n"
"git.cacert.org/oidc_login/app/services" "git.cacert.org/oidc_login/app/services"
commonServices "git.cacert.org/oidc_login/common/services"
) )
type indexHandler struct { type indexHandler struct {
logoutUrl string bundle *i18n.Bundle
serverAddr string indexTemplate *template.Template
keySet *jwk.Set keySet *jwk.Set
logoutUrl string
messageCatalog *commonServices.MessageCatalog
serverAddr string
} }
func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
@ -26,23 +32,10 @@ func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reque
http.NotFound(writer, request) http.NotFound(writer, request)
return return
} }
accept := request.Header.Get("Accept-Language")
localizer := i18n.NewLocalizer(h.bundle, accept)
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
page, err := template.New("").Parse(`
<!DOCTYPE html>
<html lang="en">
<head><title>Auth test</title></head>
<body>
<h1>Hello {{ .User }}</h1>
<p>This is an authorization protected resource</p>
<a href="{{ .LogoutURL }}">Logout</a>
</body>
</html>
`)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
session, err := services.GetSessionStore().Get(request, sessionName) session, err := services.GetSessionStore().Get(request, sessionName)
if err != nil { if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError) http.Error(writer, err.Error(), http.StatusInternalServerError)
@ -72,8 +65,13 @@ func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reque
} }
writer.Header().Add("Content-Type", "text/html") writer.Header().Add("Content-Type", "text/html")
err = page.Execute(writer, map[string]interface{}{ err = h.indexTemplate.Lookup("base").Execute(writer, map[string]interface{}{
"Title": h.messageCatalog.LookupMessage("IndexTitle", nil, localizer),
"Greeting": h.messageCatalog.LookupMessage("IndexGreeting", map[string]interface{}{
"User": oidcToken.Name(), "User": oidcToken.Name(),
}, localizer),
"IntroductionText": h.messageCatalog.LookupMessage("IndexIntroductionText", nil, localizer),
"LogoutLabel": h.messageCatalog.LookupMessage("LogoutLabel", nil, localizer),
"LogoutURL": logoutUrl.String(), "LogoutURL": logoutUrl.String(),
}) })
if err != nil { if err != nil {
@ -82,6 +80,18 @@ func (h *indexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reque
} }
} }
func NewIndexHandler(logoutUrl string, serverAddr string, keySet *jwk.Set) *indexHandler { func NewIndexHandler(ctx context.Context, serverAddr string) (*indexHandler, error) {
return &indexHandler{logoutUrl: logoutUrl, serverAddr: serverAddr, keySet: keySet} indexTemplate, err := template.ParseFiles(
"templates/app/base.gohtml", "templates/app/index.gohtml")
if err != nil {
return nil, err
}
return &indexHandler{
bundle: commonServices.GetI18nBundle(ctx),
indexTemplate: indexTemplate,
keySet: commonServices.GetJwkSet(ctx),
logoutUrl: commonServices.GetOidcConfig(ctx).EndSessionEndpoint,
messageCatalog: commonServices.GetMessageCatalog(ctx),
serverAddr: serverAddr,
}, nil
} }

View file

@ -10,6 +10,7 @@ import (
"golang.org/x/oauth2" "golang.org/x/oauth2"
"git.cacert.org/oidc_login/app/services" "git.cacert.org/oidc_login/app/services"
commonServices "git.cacert.org/oidc_login/common/services"
) )
const ( const (
@ -112,6 +113,9 @@ func (c *oidcCallbackHandler) RenderErrorTemplate(w http.ResponseWriter, r *http
} }
} }
func NewCallbackHandler(keySet *jwk.Set, oauth2Config *oauth2.Config) *oidcCallbackHandler { func NewCallbackHandler(ctx context.Context) *oidcCallbackHandler {
return &oidcCallbackHandler{keySet: keySet, oauth2Config: oauth2Config} return &oidcCallbackHandler{
keySet: commonServices.GetJwkSet(ctx),
oauth2Config: commonServices.GetOAuth2Config(ctx),
}
} }

31
app/services/i18n.go Normal file
View file

@ -0,0 +1,31 @@
package services
import (
"context"
"github.com/nicksnyder/go-i18n/v2/i18n"
"git.cacert.org/oidc_login/common/services"
)
func AddMessages(ctx context.Context) {
messages := make(map[string]*i18n.Message)
messages["IndexGreeting"] = &i18n.Message{
ID: "IndexGreeting",
Other: "Hello {{ .User }}",
}
messages["IndexTitle"] = &i18n.Message{
ID: "IndexTitle",
Other: "Welcome to the Demo application",
}
messages["LogoutLabel"] = &i18n.Message{
ID: "LogoutLabel",
Description: "A label on a logout button or link",
Other: "Logout",
}
messages["IndexIntroductionText"] = &i18n.Message{
ID: "IndexIntroductionText",
Other: "This is an authorization protected resource",
}
services.GetMessageCatalog(ctx).AddMessages(messages)
}

View file

@ -18,10 +18,8 @@ import (
"github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/posflag"
"github.com/lestrrat-go/jwx/jwk"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"golang.org/x/oauth2"
"git.cacert.org/oidc_login/app/handlers" "git.cacert.org/oidc_login/app/handlers"
"git.cacert.org/oidc_login/app/services" "git.cacert.org/oidc_login/app/services"
@ -52,6 +50,7 @@ func main() {
"server.certificate": "certs/app.cacert.localhost.crt.pem", "server.certificate": "certs/app.cacert.localhost.crt.pem",
"oidc.server": "https://auth.cacert.localhost:4444/", "oidc.server": "https://auth.cacert.localhost:4444/",
"session.path": "sessions/app", "session.path": "sessions/app",
"i18n.languages": []string{"en", "de"},
}, "."), nil) }, "."), nil)
cFiles, _ := f.GetStringSlice("conf") cFiles, _ := f.GetStringSlice("conf")
for _, c := range cFiles { for _, c := range cFiles {
@ -77,6 +76,10 @@ func main() {
oidcClientId := config.MustString("oidc.client-id") oidcClientId := config.MustString("oidc.client-id")
oidcClientSecret := config.MustString("oidc.client-secret") oidcClientSecret := config.MustString("oidc.client-secret")
ctx := context.Background()
ctx = commonServices.InitI18n(ctx, logger, config.Strings("i18n.languages"))
services.AddMessages(ctx)
sessionPath := config.MustString("session.path") sessionPath := config.MustString("session.path")
sessionAuthKey, err := base64.StdEncoding.DecodeString(config.String("session.auth-key")) sessionAuthKey, err := base64.StdEncoding.DecodeString(config.String("session.auth-key"))
if err != nil { if err != nil {
@ -109,32 +112,26 @@ func main() {
log.Infof("put the following in your resource_app.toml:\n%s", string(tomlData)) log.Infof("put the following in your resource_app.toml:\n%s", string(tomlData))
} }
var discoveryResponse *commonServices.OpenIDConfiguration if ctx, err = commonServices.DiscoverOIDC(ctx, logger, &commonServices.OidcParams{
apiClient := &http.Client{} OidcServer: oidcServer,
if discoveryResponse, err = commonServices.DiscoverOIDC(logger, oidcServer, apiClient); err != nil { OidcClientId: oidcClientId,
OidcClientSecret: oidcClientSecret,
APIClient: &http.Client{},
}); err != nil {
log.Fatalf("OpenID Connect discovery failed: %s", err) 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)
}
services.InitSessionStore(logger, sessionPath, sessionAuthKey, sessionEncKey) services.InitSessionStore(logger, sessionPath, sessionAuthKey, sessionEncKey)
authMiddleware := handlers.Authenticate(oauth2Config, config.MustString("oidc.client-id")) authMiddleware := handlers.Authenticate(ctx, oidcClientId)
serverAddr := fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")) serverAddr := fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port"))
indexHandler := handlers.NewIndexHandler(discoveryResponse.EndSessionEndpoint, serverAddr, keySet)
callbackHandler := handlers.NewCallbackHandler(keySet, oauth2Config) indexHandler, err := handlers.NewIndexHandler(ctx, serverAddr)
if err != nil {
logger.Fatalf("could not initialize index handler: %v", err)
}
callbackHandler := handlers.NewCallbackHandler(ctx)
afterLogoutHandler := handlers.NewAfterLogoutHandler(logger) afterLogoutHandler := handlers.NewAfterLogoutHandler(logger)
router := http.NewServeMux() router := http.NewServeMux()
@ -172,7 +169,6 @@ func main() {
logger.Infoln("Server is shutting down...") logger.Infoln("Server is shutting down...")
atomic.StoreInt32(&commonHandlers.Healthy, 0) atomic.StoreInt32(&commonHandlers.Healthy, 0)
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()

View file

@ -26,6 +26,7 @@ import (
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
commonHandlers "git.cacert.org/oidc_login/common/handlers" commonHandlers "git.cacert.org/oidc_login/common/handlers"
commonServices "git.cacert.org/oidc_login/common/services"
"git.cacert.org/oidc_login/idp/handlers" "git.cacert.org/oidc_login/idp/handlers"
"git.cacert.org/oidc_login/idp/services" "git.cacert.org/oidc_login/idp/services"
) )
@ -52,6 +53,7 @@ func main() {
"server.key": "certs/idp.cacert.localhost.key", "server.key": "certs/idp.cacert.localhost.key",
"server.certificate": "certs/idp.cacert.localhost.crt.pem", "server.certificate": "certs/idp.cacert.localhost.crt.pem",
"admin.url": "https://hydra.cacert.localhost:4445/", "admin.url": "https://hydra.cacert.localhost:4445/",
"i18n.languages": []string{"en", "de"},
}, "."), nil) }, "."), nil)
cFiles, _ := f.GetStringSlice("conf") cFiles, _ := f.GetStringSlice("conf")
for _, c := range cFiles { for _, c := range cFiles {
@ -73,7 +75,9 @@ func main() {
logger.Infoln("Server is starting") logger.Infoln("Server is starting")
ctx := context.Background() ctx := context.Background()
ctx = services.InitI18n(ctx, logger) ctx = commonServices.InitI18n(ctx, logger, config.Strings("i18n.languages"))
services.AddMessages(ctx)
adminURL, err := url.Parse(config.MustString("admin.url")) adminURL, err := url.Parse(config.MustString("admin.url"))
if err != nil { if err != nil {
logger.Fatalf("error parsing admin URL: %v", err) logger.Fatalf("error parsing admin URL: %v", err)
@ -82,11 +86,11 @@ func main() {
adminClient := hydra.New(clientTransport, nil) adminClient := hydra.New(clientTransport, nil)
handlerContext := context.WithValue(ctx, handlers.CtxAdminClient, adminClient.Admin) handlerContext := context.WithValue(ctx, handlers.CtxAdminClient, adminClient.Admin)
loginHandler, err := handlers.NewLoginHandler(logger, handlerContext) loginHandler, err := handlers.NewLoginHandler(handlerContext, logger)
if err != nil { if err != nil {
logger.Fatalf("error initializing login handler: %v", err) logger.Fatalf("error initializing login handler: %v", err)
} }
consentHandler, err := handlers.NewConsentHandler(logger, handlerContext) consentHandler, err := handlers.NewConsentHandler(handlerContext, logger)
if err != nil { if err != nil {
logger.Fatalf("error initializing consent handler: %v", err) logger.Fatalf("error initializing consent handler: %v", err)
} }

102
common/services/i18n.go Normal file
View file

@ -0,0 +1,102 @@
package services
import (
"context"
"fmt"
"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
)
type MessageCatalog struct {
messages map[string]*i18n.Message
logger *log.Logger
}
func (m *MessageCatalog) AddMessages(messages map[string]*i18n.Message) {
for key, value := range messages {
m.messages[key] = value
}
}
func (m *MessageCatalog) LookupErrorMessage(tag string, field string, value interface{}, localizer *i18n.Localizer) string {
var message *i18n.Message
message, ok := m.messages[fmt.Sprintf("%s-%s", field, tag)]
if !ok {
m.logger.Infof("no specific error message %s-%s", field, tag)
message, ok = m.messages[tag]
if !ok {
m.logger.Infof("no specific error message %s", tag)
message, ok = m.messages["unknown"]
if !ok {
m.logger.Error("no default translation found")
return tag
}
}
}
translation, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: message,
TemplateData: map[string]interface{}{
"Value": value,
},
})
if err != nil {
m.logger.Error(err)
return tag
}
return translation
}
func (m *MessageCatalog) LookupMessage(id string, templateData map[string]interface{}, localizer *i18n.Localizer) string {
if message, ok := m.messages[id]; ok {
translation, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: message,
TemplateData: templateData,
})
if err != nil {
m.logger.Error(err)
return id
}
return translation
} else {
return id
}
}
func InitI18n(ctx context.Context, logger *log.Logger, languages []string) context.Context {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
for _, lang := range languages {
_, err := bundle.LoadMessageFile(fmt.Sprintf("active.%s.toml", lang))
if err != nil {
logger.Warnln("message bundle de.toml not found")
}
}
catalog := initMessageCatalog(logger)
ctx = context.WithValue(ctx, ctxI18nBundle, bundle)
ctx = context.WithValue(ctx, ctxI18nCatalog, catalog)
return ctx
}
func initMessageCatalog(logger *log.Logger) *MessageCatalog {
messages := make(map[string]*i18n.Message)
return &MessageCatalog{messages: messages, logger: logger}
}
func GetI18nBundle(ctx context.Context) *i18n.Bundle {
return ctx.Value(ctxI18nBundle).(*i18n.Bundle)
}
func GetMessageCatalog(ctx context.Context) *MessageCatalog {
return ctx.Value(ctxI18nCatalog).(*MessageCatalog)
}

View file

@ -2,11 +2,22 @@ package services
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/url" "net/url"
"github.com/lestrrat-go/jwx/jwk"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
type oidcContextKey int
const (
ctxOidcConfig oidcContextKey = iota
ctxOAuth2Config
ctxOidcJwks
) )
type OpenIDConfiguration struct { type OpenIDConfiguration struct {
@ -16,11 +27,19 @@ type OpenIDConfiguration struct {
EndSessionEndpoint string `json:"end_session_endpoint"` EndSessionEndpoint string `json:"end_session_endpoint"`
} }
func DiscoverOIDC(logger *log.Logger, oidcServer string, apiClient *http.Client) (o *OpenIDConfiguration, err error) { type OidcParams struct {
OidcServer string
OidcClientId string
OidcClientSecret string
APIClient *http.Client
}
func DiscoverOIDC(ctx context.Context, logger *log.Logger, params *OidcParams) (context.Context, error) {
var discoveryUrl *url.URL var discoveryUrl *url.URL
if discoveryUrl, err = url.Parse(oidcServer); err != nil { discoveryUrl, err := url.Parse(params.OidcServer)
logger.Fatalf("could not parse oidc.server parameter value %s: %s", oidcServer, err) if err != nil {
logger.Fatalf("could not parse oidc.server parameter value %s: %s", params.OidcServer, err)
} else { } else {
discoveryUrl.Path = "/.well-known/openid-configuration" discoveryUrl.Path = "/.well-known/openid-configuration"
} }
@ -29,23 +48,53 @@ func DiscoverOIDC(logger *log.Logger, oidcServer string, apiClient *http.Client)
var req *http.Request var req *http.Request
req, err = http.NewRequest(http.MethodGet, discoveryUrl.String(), bytes.NewBuffer(body)) req, err = http.NewRequest(http.MethodGet, discoveryUrl.String(), bytes.NewBuffer(body))
if err != nil { if err != nil {
return return nil, err
} }
req.Header = map[string][]string{ req.Header = map[string][]string{
"Accept": {"application/json"}, "Accept": {"application/json"},
} }
resp, err := apiClient.Do(req) resp, err := params.APIClient.Do(req)
if err != nil { if err != nil {
return return nil, err
} }
dec := json.NewDecoder(resp.Body) dec := json.NewDecoder(resp.Body)
o = &OpenIDConfiguration{} discoveryResponse := &OpenIDConfiguration{}
err = dec.Decode(o) err = dec.Decode(discoveryResponse)
if err != nil { if err != nil {
return return nil, err
} }
ctx = context.WithValue(ctx, ctxOidcConfig, discoveryResponse)
return oauth2Config := &oauth2.Config{
ClientID: params.OidcClientId,
ClientSecret: params.OidcClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: discoveryResponse.AuthorizationEndpoint,
TokenURL: discoveryResponse.TokenEndpoint,
},
Scopes: []string{"openid", "offline"},
}
ctx = context.WithValue(ctx, ctxOAuth2Config, oauth2Config)
keySet, err := jwk.FetchHTTP(discoveryResponse.JwksUri, jwk.WithHTTPClient(params.APIClient))
if err != nil {
log.Fatalf("could not fetch JWKs: %s", err)
}
ctx = context.WithValue(ctx, ctxOidcJwks, keySet)
return ctx, nil
}
func GetOidcConfig(ctx context.Context) *OpenIDConfiguration {
return ctx.Value(ctxOidcConfig).(*OpenIDConfiguration)
}
func GetOAuth2Config(ctx context.Context) *oauth2.Config {
return ctx.Value(ctxOAuth2Config).(*oauth2.Config)
}
func GetJwkSet(ctx context.Context) *jwk.Set {
return ctx.Value(ctxOidcJwks).(*jwk.Set)
} }

View file

@ -15,7 +15,7 @@ import (
"github.com/ory/hydra-client-go/models" "github.com/ory/hydra-client-go/models"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"git.cacert.org/oidc_login/idp/services" commonServices "git.cacert.org/oidc_login/common/services"
) )
type consentHandler struct { type consentHandler struct {
@ -23,7 +23,7 @@ type consentHandler struct {
bundle *i18n.Bundle bundle *i18n.Bundle
consentTemplate *template.Template consentTemplate *template.Template
logger *log.Logger logger *log.Logger
messageCatalog *services.MessageCatalog messageCatalog *commonServices.MessageCatalog
} }
type ConsentInformation struct { type ConsentInformation struct {
@ -151,17 +151,18 @@ func (h *consentHandler) mapRequestedScope(scope models.StringSlicePipeDelimiter
return result return result
} }
func NewConsentHandler(logger *log.Logger, ctx context.Context) (*consentHandler, error) { func NewConsentHandler(ctx context.Context, logger *log.Logger) (*consentHandler, error) {
consentTemplate, err := template.ParseFiles("templates/base.gohtml", "templates/consent.gohtml") consentTemplate, err := template.ParseFiles(
"templates/idp/base.gohtml", "templates/idp/consent.gohtml")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &consentHandler{ return &consentHandler{
adminClient: ctx.Value(CtxAdminClient).(*admin.Client), adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle), bundle: commonServices.GetI18nBundle(ctx),
consentTemplate: consentTemplate, consentTemplate: consentTemplate,
logger: logger, logger: logger,
messageCatalog: ctx.Value(services.CtxI18nCatalog).(*services.MessageCatalog), messageCatalog: commonServices.GetMessageCatalog(ctx),
}, nil }, nil
} }

View file

@ -15,7 +15,7 @@ import (
"github.com/ory/hydra-client-go/models" "github.com/ory/hydra-client-go/models"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"git.cacert.org/oidc_login/idp/services" commonServices "git.cacert.org/oidc_login/common/services"
) )
type loginHandler struct { type loginHandler struct {
@ -23,7 +23,7 @@ type loginHandler struct {
bundle *i18n.Bundle bundle *i18n.Bundle
logger *log.Logger logger *log.Logger
loginTemplate *template.Template loginTemplate *template.Template
messageCatalog *services.MessageCatalog messageCatalog *commonServices.MessageCatalog
} }
type acrType string type acrType string
@ -127,16 +127,17 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
func NewLoginHandler(logger *log.Logger, ctx context.Context) (*loginHandler, error) { func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) {
loginTemplate, err := template.ParseFiles("templates/base.gohtml", "templates/login.gohtml") loginTemplate, err := template.ParseFiles(
"templates/idp/base.gohtml", "templates/idp/login.gohtml")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &loginHandler{ return &loginHandler{
adminClient: ctx.Value(CtxAdminClient).(*admin.Client), adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
bundle: ctx.Value(services.CtxI18nBundle).(*i18n.Bundle), bundle: commonServices.GetI18nBundle(ctx),
logger: logger, logger: logger,
loginTemplate: loginTemplate, loginTemplate: loginTemplate,
messageCatalog: ctx.Value(services.CtxI18nCatalog).(*services.MessageCatalog), messageCatalog: commonServices.GetMessageCatalog(ctx),
}, nil }, nil
} }

View file

@ -2,85 +2,13 @@ package services
import ( import (
"context" "context"
"fmt"
"github.com/BurntSushi/toml"
"github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/i18n"
log "github.com/sirupsen/logrus"
"golang.org/x/text/language" "git.cacert.org/oidc_login/common/services"
) )
type contextKey int func AddMessages(ctx context.Context) {
const (
CtxI18nBundle contextKey = iota
CtxI18nCatalog
)
type MessageCatalog struct {
messages map[string]*i18n.Message
logger *log.Logger
}
func (m *MessageCatalog) LookupErrorMessage(tag string, field string, value interface{}, localizer *i18n.Localizer) string {
var message *i18n.Message
message, ok := m.messages[fmt.Sprintf("%s-%s", field, tag)]
if !ok {
m.logger.Infof("no specific error message %s-%s", field, tag)
message, ok = m.messages[tag]
if !ok {
m.logger.Infof("no specific error message %s", tag)
message, ok = m.messages["unknown"]
if !ok {
m.logger.Error("no default translation found")
return tag
}
}
}
translation, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: message,
TemplateData: map[string]interface{}{
"Value": value,
},
})
if err != nil {
m.logger.Error(err)
return tag
}
return translation
}
func (m *MessageCatalog) LookupMessage(id string, templateData map[string]interface{}, localizer *i18n.Localizer) string {
if message, ok := m.messages[id]; ok {
translation, err := localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: message,
TemplateData: templateData,
})
if err != nil {
m.logger.Error(err)
return id
}
return translation
} else {
return id
}
}
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(logger)
ctx = context.WithValue(ctx, CtxI18nBundle, bundle)
ctx = context.WithValue(ctx, CtxI18nCatalog, catalog)
return ctx
}
func initMessageCatalog(logger *log.Logger) *MessageCatalog {
messages := make(map[string]*i18n.Message) messages := make(map[string]*i18n.Message)
messages["unknown"] = &i18n.Message{ messages["unknown"] = &i18n.Message{
ID: "ErrorUnknown", ID: "ErrorUnknown",
@ -138,5 +66,5 @@ func initMessageCatalog(logger *log.Logger) *MessageCatalog {
ID: "Scope-email-Description", ID: "Scope-email-Description",
Other: "Access your primary email address.", Other: "Access your primary email address.",
} }
return &MessageCatalog{messages: messages, logger: logger} services.GetMessageCatalog(ctx).AddMessages(messages)
} }

View file

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

View file

@ -0,0 +1,5 @@
{{ define "content" }}
<h1>{{ .Greeting }}</h1>
<p>{{ .IntroductionText }}</p>
<a href="{{ .LogoutURL }}">{{ .LogoutLabel }}</a>
{{ end }}

16
translate.de.toml Normal file
View file

@ -0,0 +1,16 @@
[IndexGreeting]
hash = "sha1-d4a13058e497fa24143ea96d50d82b818455ef61"
other = "Hallo {{ .User }}"
[IndexIntroductionText]
hash = "sha1-c2c530e263fc9c38482338ed290aafb496794179"
other = "Das ist eine zugriffsgeschützte Resource"
[IndexTitle]
hash = "sha1-eccb2b889c068d3f25496c1dad3fb0f88d021bd9"
other = "Willkommen in der Demo-Anwendung"
[LogoutLabel]
description = "A label on a logout button or link"
hash = "sha1-8acfdeb9a8286f00c8e5dd48471cfdc994807579"
other = "Ausloggen"