Add separate protected resource page

This commit adds a separate protected resource page to demonstrate how
to selectively require logins.

Add code to improve client performance by providing modification timestamps
and Cache-Control headers for embedded static files.
This commit is contained in:
Jan Dittberner 2023-08-03 16:46:28 +02:00
parent 0a4cc75bd3
commit 7ec9e393e0
19 changed files with 628 additions and 154 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
/resource_app*.toml
/sessions
/static
/translations/translate.*.toml
/ui/css/
/ui/images/
/ui/js/

View file

@ -18,13 +18,12 @@ go.sum: go.mod
go mod tidy -v
translations: $(TRANSLATIONS) $(GOFILES)
cd translations ; \
goi18n extract .. ; \
goi18n merge active.*.toml ; \
if translate.*.toml 2>/dev/null; then \
if [ ! -z "$(wildcard translations/translate.*.toml)" ]; then \
echo "missing translations"; \
goi18n merge active.*.toml translate.*.toml; \
fi
goi18n merge -outdir translations translations/active.*.toml translations/translate.*.toml; \
fi ; \
goi18n extract -outdir translations . ; \
goi18n merge -outdir translations translations/active.*.toml
lint: $(GOFILES)
golangci-lint run --verbose

View file

@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- let the session expire when the token expires
- link from logo to start page
- move common page header to templates/base.gohtml
- implement caching for static resources
### Added
- add identity output to index page
- add a separate protected resource page
## [0.2.0]
### Changed

View file

@ -53,6 +53,37 @@ var (
date = "unknown"
)
type StaticFSWrapper struct {
http.FileSystem
ModTime time.Time
}
func (f *StaticFSWrapper) Open(name string) (http.File, error) {
file, err := f.FileSystem.Open(name)
return &StaticFileWrapper{File: file, fixedModTime: f.ModTime}, err //nolint:wrapcheck
}
type StaticFileWrapper struct {
http.File
fixedModTime time.Time
}
func (f *StaticFileWrapper) Stat() (os.FileInfo, error) {
fileInfo, err := f.File.Stat()
return &StaticFileInfoWrapper{FileInfo: fileInfo, fixedModTime: f.fixedModTime}, err //nolint:wrapcheck
}
type StaticFileInfoWrapper struct {
os.FileInfo
fixedModTime time.Time
}
func (f *StaticFileInfoWrapper) ModTime() time.Time {
return f.fixedModTime
}
func main() {
logger := log.New()
@ -91,22 +122,7 @@ func main() {
services.AddMessages(catalog)
tlsClientConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if config.Exists("api-client.rootCAs") {
rootCAFile := config.MustString("api-client.rootCAs")
caCertPool := x509.NewCertPool()
pemBytes, err := os.ReadFile(rootCAFile)
if err != nil {
log.Fatalf("could not read CA certificate file: %v", err)
}
caCertPool.AppendCertsFromPEM(pemBytes)
tlsClientConfig.RootCAs = caCertPool
}
tlsClientConfig := getTLSConfig(config)
apiTransport := &http.Transport{TLSClientConfig: tlsClientConfig}
apiClient := &http.Client{Transport: apiTransport}
@ -118,7 +134,7 @@ func main() {
APIClient: apiClient,
})
if err != nil {
log.Fatalf("OpenID Connect discovery failed: %s", err)
logger.WithError(err).Fatal("OpenID Connect discovery failed")
}
sessionPath, sessionAuthKey, sessionEncKey := configureSessionParameters(config)
@ -128,23 +144,38 @@ func main() {
publicURL := buildPublicURL(config.MustString("server.name"), config.MustInt("server.port"))
indexHandler, err := handlers.NewIndexHandler(logger, bundle, catalog, oidcInfo, publicURL)
tokenInfoService, err := services.InitTokenInfoService(logger, oidcInfo)
if err != nil {
logger.WithError(err).Fatal("could not initialize token info service")
}
indexHandler, err := handlers.NewIndexHandler(logger, bundle, catalog, oidcInfo, publicURL, tokenInfoService)
if err != nil {
logger.WithError(err).Fatal("could not initialize index handler")
}
protectedResource, err := handlers.NewProtectedResourceHandler(
logger, bundle, catalog, oidcInfo, publicURL, tokenInfoService,
)
if err != nil {
logger.WithError(err).Fatal("could not initialize protected resource handler")
}
callbackHandler := handlers.NewCallbackHandler(logger, oidcInfo.KeySet, oidcInfo.OAuth2Config)
afterLogoutHandler := handlers.NewAfterLogoutHandler(logger)
staticFiles := http.FileServer(http.FS(ui.Static))
staticFiles := staticFileHandler(logger)
router := http.NewServeMux()
router.Handle("/", authMiddleware(indexHandler))
router.Handle("/", indexHandler)
router.Handle("/login", authMiddleware(handlers.NewLoginHandler()))
router.Handle("/protected", authMiddleware(protectedResource))
router.Handle("/callback", callbackHandler)
router.Handle("/after-logout", afterLogoutHandler)
router.Handle("/health", handlers.NewHealthHandler())
router.Handle("/images/", staticFiles)
router.Handle("/css/", staticFiles)
router.Handle("/js/", staticFiles)
router.HandleFunc("/images/", staticFiles)
router.HandleFunc("/css/", staticFiles)
router.HandleFunc("/js/", staticFiles)
nextRequestID := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
@ -176,6 +207,46 @@ func main() {
handlers.StartApplication(context.Background(), logger, server, publicURL, config)
}
func staticFileHandler(logger *log.Logger) func(w http.ResponseWriter, r *http.Request) {
stat, err := os.Stat(os.Args[0])
if err != nil {
logger.WithError(err).Fatal("could not use stat on binary")
}
fileServer := http.FileServer(&StaticFSWrapper{FileSystem: http.FS(ui.Static), ModTime: stat.ModTime()})
staticFiles := func(w http.ResponseWriter, r *http.Request) {
w.Header().Del("Expires")
w.Header().Del("Pragma")
w.Header().Set("Cache-Control", "max-age=3600")
fileServer.ServeHTTP(w, r)
}
return staticFiles
}
func getTLSConfig(config *koanf.Koanf) *tls.Config {
tlsClientConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if config.Exists("api-client.rootCAs") {
rootCAFile := config.MustString("api-client.rootCAs")
caCertPool := x509.NewCertPool()
pemBytes, err := os.ReadFile(rootCAFile)
if err != nil {
log.Fatalf("could not read CA certificate file: %v", err)
}
caCertPool.AppendCertsFromPEM(pemBytes)
tlsClientConfig.RootCAs = caCertPool
}
return tlsClientConfig
}
func buildPublicURL(hostname string, port int) string {
const defaultHTTPSPort = 443

View file

@ -21,8 +21,6 @@ import (
"net/http"
"github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-demo-app/internal/services"
)
type AfterLogoutHandler struct {
@ -30,7 +28,7 @@ type AfterLogoutHandler struct {
}
func (h *AfterLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
session, err := services.GetSessionStore().Get(r, sessionName)
session, err := GetSession(r)
if err != nil {
h.logger.WithError(err).Error("could not get session")
http.Error(w, err.Error(), http.StatusInternalServerError)

View file

@ -21,14 +21,12 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
"github.com/lestrrat-go/jwx/jwt/openid"
"github.com/gorilla/sessions"
"github.com/nicksnyder/go-i18n/v2/i18n"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
@ -37,28 +35,27 @@ import (
)
const (
sessionName = "resource_session"
oauth2RedirectStateLength = 8
sessionName = "resource_app"
)
func Authenticate(logger *log.Logger, oauth2Config *oauth2.Config, clientID string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := services.GetSessionStore().Get(r, sessionName)
session, err := GetSession(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, ok := session.Values[sessionKeyIDToken]; ok {
if _, ok := session.Values[services.SessionIDToken]; ok {
next.ServeHTTP(w, r)
return
}
session.Values[sessionRedirectTarget] = r.URL.String()
session.Values[services.SessionRedirectTarget] = r.URL.String()
if err = session.Save(r, w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -106,19 +103,34 @@ func getRequestedClaims(logger *log.Logger) string {
return buf.String()
}
func ParseIDToken(token string, keySet jwk.Set) (openid.Token, error) {
var (
parsedIDToken jwt.Token
err error
)
if parsedIDToken, err = jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithToken(openid.New())); err != nil {
return nil, fmt.Errorf("could not parse ID token: %w", err)
func GetSession(r *http.Request) (*sessions.Session, error) {
session, err := services.GetSessionStore().Get(r, sessionName)
if err != nil {
return nil, fmt.Errorf("could not get session")
}
if v, ok := parsedIDToken.(openid.Token); ok {
return v, nil
}
return nil, errors.New("ID token is no OpenID Connect Identity Token")
return session, nil
}
type I18NHandler interface {
GetBundle() *i18n.Bundle
GetCatalog() *services.MessageCatalog
}
func GetLocalizer(h I18NHandler, r *http.Request) *i18n.Localizer {
accept := r.Header.Get("Accept-Language")
localizer := i18n.NewLocalizer(h.GetBundle(), accept)
return localizer
}
func BaseTemplateData(h I18NHandler, localizer *i18n.Localizer) map[string]interface{} {
msg := h.GetCatalog().LookupMessage
data := map[string]interface{}{
"WelcomeNavLabel": msg("IndexNavLabel", nil, localizer),
"ProtectedNavLabel": msg("ProtectedNavLabel", nil, localizer),
}
return data
}

View file

@ -52,19 +52,27 @@ type ErrorBucket struct {
messageCatalog *services.MessageCatalog
}
func (b *ErrorBucket) GetBundle() *i18n.Bundle {
return b.bundle
}
func (b *ErrorBucket) GetCatalog() *services.MessageCatalog {
return b.messageCatalog
}
func (b *ErrorBucket) serveHTTP(w http.ResponseWriter, r *http.Request) {
if b.errorDetails != nil {
accept := r.Header.Get("Accept-Language")
localizer := i18n.NewLocalizer(b.bundle, accept)
localizer := GetLocalizer(b, r)
err := b.templates.Lookup("base").Execute(w, map[string]interface{}{
"Title": b.messageCatalog.LookupMessage(
"ErrorTitle",
nil,
localizer,
),
"details": b.errorDetails,
})
data := BaseTemplateData(b, localizer)
data["Title"] = b.messageCatalog.LookupMessage(
"ErrorTitle",
nil,
localizer,
)
data["details"] = b.errorDetails
err := b.templates.Lookup("base").Execute(w, data)
if err != nil {
log.WithError(err).Error("error rendering error template")
http.Error(

View file

@ -23,7 +23,6 @@ import (
"net/http"
"net/url"
"github.com/lestrrat-go/jwx/jwk"
"github.com/nicksnyder/go-i18n/v2/i18n"
log "github.com/sirupsen/logrus"
@ -36,98 +35,95 @@ type IndexHandler struct {
bundle *i18n.Bundle
indexTemplate *template.Template
logger *log.Logger
keySet jwk.Set
logoutURL string
messageCatalog *services.MessageCatalog
publicURL string
tokenInfo *services.TokenInfoService
}
func (h *IndexHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
func (h *IndexHandler) GetBundle() *i18n.Bundle {
return h.bundle
}
func (h *IndexHandler) GetCatalog() *services.MessageCatalog {
return h.messageCatalog
}
func (h *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if request.URL.Path != "/" {
http.NotFound(writer, request)
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
accept := request.Header.Get("Accept-Language")
localizer := i18n.NewLocalizer(h.bundle, accept)
writer.WriteHeader(http.StatusOK)
session, err := services.GetSessionStore().Get(request, sessionName)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
localizer := GetLocalizer(h, r)
logoutURL, err := url.Parse(h.logoutURL)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var (
accessToken string
refreshToken string
idToken string
ok bool
)
if accessToken, ok = session.Values[sessionKeyAccessToken].(string); ok {
h.logger.WithField("access_token", accessToken).Info("found access token in session")
}
if refreshToken, ok = session.Values[refreshToken].(string); ok {
h.logger.WithField("refresh_token", refreshToken).Info("found refresh token in session")
}
if idToken, ok = session.Values[sessionKeyIDToken].(string); ok {
logoutURL.RawQuery = url.Values{
"id_token_hint": []string{idToken},
"post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)},
}.Encode()
} else {
return
}
oidcToken, err := ParseIDToken(idToken, h.keySet)
session, err := GetSession(r)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
expires := oidcToken.Expiration()
tokenInfo, err := h.tokenInfo.GetTokenInfo(session)
if err != nil {
h.logger.WithError(err).Error("failed to get token info for request")
h.logger.WithField("expires", expires).Info("id token expires at")
http.Error(w, err.Error(), http.StatusInternalServerError)
writer.Header().Add("Content-Type", "text/html")
return
}
if !tokenInfo.Expires.IsZero() {
h.logger.WithField("expires", tokenInfo.Expires).Info("id token expires at")
}
w.Header().Add("Content-Type", "text/html")
msgLookup := h.messageCatalog.LookupMessage
err = h.indexTemplate.Lookup("base").Execute(writer, map[string]interface{}{
"Title": msgLookup("IndexTitle", nil, localizer),
"Greeting": msgLookup("IndexGreeting", map[string]interface{}{
"User": oidcToken.Name(),
}, localizer),
"IntroductionText": msgLookup("IndexIntroductionText", nil, localizer),
"LogoutLabel": msgLookup("LogoutLabel", nil, localizer),
"LogoutURL": logoutURL.String(),
"AuthenticatedAs": msgLookup("AuthenticatedAs", map[string]interface{}{
"Name": oidcToken.Name(),
"Email": oidcToken.Email(),
}, localizer),
})
data := BaseTemplateData(h, localizer)
data["Title"] = msgLookup("IndexTitle", nil, localizer)
data["Greeting"] = msgLookup("GreetingAnonymous", nil, localizer)
data["LoginLabel"] = msgLookup("LoginLabel", nil, localizer)
data["IntroductionText"] = msgLookup("IndexIntroductionText", nil, localizer)
data["IsAuthenticated"] = false
if tokenInfo.IDToken != "" {
logoutURL.RawQuery = url.Values{
"id_token_hint": []string{tokenInfo.IDToken},
"post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)},
}.Encode()
data["Greeting"] = msgLookup(
"GreetingAuthenticated", map[string]interface{}{"Name": tokenInfo.Name}, localizer,
)
data["LogoutLabel"] = msgLookup("LogoutLabel", nil, localizer)
data["LogoutURL"] = logoutURL.String()
data["IsAuthenticated"] = true
data["AuthenticatedAs"] = msgLookup("AuthenticatedAs", map[string]interface{}{
"Name": tokenInfo.Name,
"Email": tokenInfo.Email,
}, localizer)
}
err = h.indexTemplate.Lookup("base").Execute(w, data)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -139,6 +135,7 @@ func NewIndexHandler(
catalog *services.MessageCatalog,
oidcInfo *services.OIDCInformation,
publicURL string,
tokenInfoService *services.TokenInfoService,
) (*IndexHandler, error) {
indexTemplate, err := template.ParseFS(
ui.Templates,
@ -151,8 +148,8 @@ func NewIndexHandler(
logger: logger,
bundle: bundle,
indexTemplate: indexTemplate,
keySet: oidcInfo.KeySet,
logoutURL: oidcInfo.OIDCConfiguration.EndSessionEndpoint,
tokenInfo: tokenInfoService,
messageCatalog: catalog,
publicURL: publicURL,
}, nil

View file

@ -0,0 +1,51 @@
/*
Copyright 2020-2023 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"net/http"
)
type LoginHandler struct{}
func (l *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/login" {
http.NotFound(w, r)
return
}
next := r.URL.Query().Get("next")
if next != "" {
http.Redirect(w, r, next, http.StatusFound)
return
}
http.Redirect(w, r, "/", http.StatusFound)
}
func NewLoginHandler() *LoginHandler {
return &LoginHandler{}
}

View file

@ -30,13 +30,6 @@ import (
"code.cacert.org/cacert/oidc-demo-app/internal/services"
)
const (
sessionKeyAccessToken = iota
sessionKeyRefreshToken
sessionKeyIDToken
sessionRedirectTarget
)
type OidcCallbackHandler struct {
keySet jwk.Set
logger *log.Logger
@ -72,9 +65,9 @@ func (c *OidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
session, err := services.GetSessionStore().Get(r, "resource_session")
session, err := GetSession(r)
if err != nil {
c.logger.WithError(err).Error("could not get session store")
c.logger.WithError(err).Error("could not get session")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -96,7 +89,7 @@ func (c *OidcCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
if redirectTarget, ok := session.Values[sessionRedirectTarget]; ok {
if redirectTarget, ok := session.Values[services.SessionRedirectTarget]; ok {
if v, ok := redirectTarget.(string); ok {
w.Header().Set("Location", v)
w.WriteHeader(http.StatusFound)
@ -131,8 +124,8 @@ func (c *OidcCallbackHandler) storeTokens(
session *sessions.Session,
tok *oauth2.Token,
) error {
session.Values[sessionKeyAccessToken] = tok.AccessToken
session.Values[sessionKeyRefreshToken] = tok.RefreshToken
session.Values[services.SessionAccessToken] = tok.AccessToken
session.Values[services.SessionRefreshToken] = tok.RefreshToken
idTokenValue := tok.Extra("id_token")
@ -141,11 +134,11 @@ func (c *OidcCallbackHandler) storeTokens(
return fmt.Errorf("ID token value %v is not a string", idTokenValue)
}
session.Values[sessionKeyIDToken] = idToken
session.Values[services.SessionIDToken] = idToken
session.Options.MaxAge = int(time.Until(tok.Expiry).Seconds())
oidcToken, err := ParseIDToken(idToken, c.keySet)
oidcToken, err := services.ParseIDToken(idToken, c.keySet)
if err != nil {
return fmt.Errorf("could not parse ID token: %w", err)
}

View file

@ -0,0 +1,155 @@
/*
Copyright 2020-2023 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handlers
import (
"fmt"
"html/template"
"net/http"
"net/url"
"github.com/nicksnyder/go-i18n/v2/i18n"
log "github.com/sirupsen/logrus"
"code.cacert.org/cacert/oidc-demo-app/internal/services"
"code.cacert.org/cacert/oidc-demo-app/ui"
)
type ProtectedResource struct {
bundle *i18n.Bundle
logger *log.Logger
protectedTemplate *template.Template
logoutURL string
tokenInfo *services.TokenInfoService
messageCatalog *services.MessageCatalog
publicURL string
}
func (h *ProtectedResource) GetBundle() *i18n.Bundle {
return h.bundle
}
func (h *ProtectedResource) GetCatalog() *services.MessageCatalog {
return h.messageCatalog
}
func (h *ProtectedResource) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if r.URL.Path != "/protected" {
http.NotFound(w, r)
return
}
localizer := GetLocalizer(h, r)
logoutURL, err := url.Parse(h.logoutURL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
session, err := GetSession(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tokenInfo, err := h.tokenInfo.GetTokenInfo(session)
if err != nil {
h.logger.WithError(err).Error("failed to get token info for request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !tokenInfo.Expires.IsZero() {
h.logger.WithField("expires", tokenInfo.Expires).Info("id token expires at")
}
if tokenInfo.IDToken == "" {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
w.Header().Add("Content-Type", "text/html")
msgLookup := h.messageCatalog.LookupMessage
logoutURL.RawQuery = url.Values{
"id_token_hint": []string{tokenInfo.IDToken},
"post_logout_redirect_uri": []string{fmt.Sprintf("%s/after-logout", h.publicURL)},
}.Encode()
data := BaseTemplateData(h, localizer)
data["Title"] = msgLookup("IndexTitle", nil, localizer)
data["Greeting"] = msgLookup(
"GreetingAuthenticated", map[string]interface{}{"Name": tokenInfo.Name}, localizer,
)
data["LoginLabel"] = msgLookup("LoginLabel", nil, localizer)
data["IntroductionText"] = msgLookup("ProtectedIntroductionText", nil, localizer)
data["IsAuthenticated"] = true
data["LogoutLabel"] = msgLookup("LogoutLabel", nil, localizer)
data["LogoutURL"] = logoutURL.String()
data["AuthenticatedAs"] = msgLookup("AuthenticatedAs", map[string]interface{}{
"Name": tokenInfo.Name,
"Email": tokenInfo.Email,
}, localizer)
err = h.protectedTemplate.Lookup("base").Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func NewProtectedResourceHandler(
logger *log.Logger,
bundle *i18n.Bundle,
catalog *services.MessageCatalog,
oidcInfo *services.OIDCInformation,
publicURL string, tokenInfoService *services.TokenInfoService,
) (*ProtectedResource, error) {
protectedTemplate, err := template.ParseFS(
ui.Templates,
"templates/base.gohtml", "templates/protected.gohtml")
if err != nil {
return nil, fmt.Errorf("could not parse templates: %w", err)
}
return &ProtectedResource{
logger: logger,
bundle: bundle,
protectedTemplate: protectedTemplate,
logoutURL: oidcInfo.OIDCConfiguration.EndSessionEndpoint,
tokenInfo: tokenInfoService,
messageCatalog: catalog,
publicURL: publicURL,
}, nil
}

View file

@ -37,14 +37,23 @@ func AddMessages(catalog *MessageCatalog) {
Other: "The identity provider authenticated your identity as {{ .Name }}" +
" with the email address {{ .Email }}.",
}
messages["IndexGreeting"] = &i18n.Message{
ID: "IndexGreeting",
Other: "Hello {{ .User }}",
messages["GreetingAnonymous"] = &i18n.Message{
ID: "GreetingAnonymous",
Other: "Hello",
}
messages["GreetingAuthenticated"] = &i18n.Message{
ID: "GreetingAuthenticated",
Other: "Hello {{ .Name }}",
}
messages["IndexTitle"] = &i18n.Message{
ID: "IndexTitle",
Other: "Welcome to the Demo application",
}
messages["LoginLabel"] = &i18n.Message{
ID: "LoginLabel",
Description: "A label on a login button or link",
Other: "Login",
}
messages["LogoutLabel"] = &i18n.Message{
ID: "LogoutLabel",
Description: "A label on a logout button or link",
@ -52,7 +61,21 @@ func AddMessages(catalog *MessageCatalog) {
}
messages["IndexIntroductionText"] = &i18n.Message{
ID: "IndexIntroductionText",
Other: "This is an authorization protected resource",
Other: "This is a public resource.",
}
messages["IndexNavLabel"] = &i18n.Message{
ID: "IndexNavLabel",
Description: "Label for the index page in the top navigation",
Other: "Welcome",
}
messages["ProtectedIntroductionText"] = &i18n.Message{
ID: "ProtectedIntroductionText",
Other: "This is an authorization protected resource.",
}
messages["ProtectedNavLabel"] = &i18n.Message{
ID: "ProtectedNavLabel",
Description: "Label for the protected resource page in the top navigation",
Other: "Protected area",
}
catalog.AddMessages(messages)

View file

@ -26,6 +26,13 @@ import (
var store *sessions.FilesystemStore
const (
SessionAccessToken = iota
SessionRefreshToken
SessionIDToken
SessionRedirectTarget
)
func InitSessionStore(logger *log.Logger, sessionPath string, keys ...[]byte) {
store = sessions.NewFilesystemStore(sessionPath, keys...)

100
internal/services/token.go Normal file
View file

@ -0,0 +1,100 @@
/*
Copyright 2023 CAcert Inc.
SPDX-License-Identifier: Apache-2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package services
import (
"errors"
"fmt"
"time"
"github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
"github.com/lestrrat-go/jwx/jwt/openid"
log "github.com/sirupsen/logrus"
)
type OIDCTokenInfo struct {
AccessToken string
IDToken string
RefreshToken string
Expires time.Time
Name string
Email string
}
type TokenInfoService struct {
logger *log.Logger
keySet jwk.Set
}
func (s *TokenInfoService) GetTokenInfo(session *sessions.Session) (*OIDCTokenInfo, error) {
tokenInfo := &OIDCTokenInfo{}
var ok bool
if tokenInfo.AccessToken, ok = session.Values[SessionAccessToken].(string); ok {
s.logger.WithField("access_token", tokenInfo.AccessToken).Debug("found access token in session")
}
if tokenInfo.RefreshToken, ok = session.Values[SessionRefreshToken].(string); ok {
s.logger.WithField("refresh_token", tokenInfo.RefreshToken).Debug("found refresh token in session")
}
if tokenInfo.IDToken, ok = session.Values[SessionIDToken].(string); ok {
s.logger.WithField("id_token", tokenInfo.IDToken).Debug("found ID token in session")
}
if tokenInfo.IDToken == "" {
return tokenInfo, nil
}
oidcToken, err := ParseIDToken(tokenInfo.IDToken, s.keySet)
if err != nil {
return nil, fmt.Errorf("could not parse ID token: %w", err)
}
tokenInfo.Expires = oidcToken.Expiration()
tokenInfo.Name = oidcToken.Name()
tokenInfo.Email = oidcToken.Email()
return tokenInfo, nil
}
func InitTokenInfoService(logger *log.Logger, oidcInfo *OIDCInformation) (*TokenInfoService, error) {
return &TokenInfoService{logger: logger, keySet: oidcInfo.KeySet}, nil
}
func ParseIDToken(token string, keySet jwk.Set) (openid.Token, error) {
var (
parsedIDToken jwt.Token
err error
)
if parsedIDToken, err = jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithToken(openid.New())); err != nil {
return nil, fmt.Errorf("could not parse ID token: %w", err)
}
if v, ok := parsedIDToken.(openid.Token); ok {
return v, nil
}
return nil, errors.New("ID token is no OpenID Connect Identity Token")
}

View file

@ -6,19 +6,42 @@ other = "Der Identity-Provider hat dich als {{ .Name }} mit der E-Mail-Adresse {
hash = "sha1-736aec25a98f5ec5b71400bb0163f891f509b566"
other = "Es ist ein Fehler aufgetreten"
[IndexGreeting]
hash = "sha1-d4a13058e497fa24143ea96d50d82b818455ef61"
other = "Hallo {{ .User }}"
[GreetingAnonymous]
hash = "sha1-f7ff9e8b7bb2e09b70935a5d785e0cc5d9d0abf0"
other = "Hallo"
[GreetingAuthenticated]
hash = "sha1-22e08bfee49f285ac06df7d582c2a65bab86fa35"
other = "Hallo {{ .Name }}"
[IndexIntroductionText]
hash = "sha1-c2c530e263fc9c38482338ed290aafb496794179"
other = "Das ist eine zugriffsgeschützte Resource"
hash = "sha1-e190189ce0b76d957315332b6ca336f90a4d3d8c"
other = "Das ist eine öffentliche Resource."
[IndexNavLabel]
description = "Label for the index page in the top navigation"
hash = "sha1-f99709e3c9205f21ca31811feec86519b6c1b452"
other = "Willkommen"
[IndexTitle]
hash = "sha1-eccb2b889c068d3f25496c1dad3fb0f88d021bd9"
other = "Willkommen in der Demo-Anwendung"
[LoginLabel]
description = "A label on a login button or link"
hash = "sha1-2049c9ee0610e70f7316f98415755b58067e68ee"
other = "Anmelden"
[LogoutLabel]
description = "A label on a logout button or link"
hash = "sha1-8acfdeb9a8286f00c8e5dd48471cfdc994807579"
other = "Ausloggen"
[ProtectedIntroductionText]
hash = "sha1-87cd7874f28dfcebcb5460d30b6c2c78dff8f6a4"
other = "Das ist eine zugriffsgeschützte Resource."
[ProtectedNavLabel]
description = "Label for the protected resource page in the top navigation"
hash = "sha1-185c4cf5675f44de72ff76f16e6cab2a82afa752"
other = "Geschützter Bereich"

View file

@ -1,9 +1,23 @@
AuthenticatedAs = "The identity provider authenticated your identity as {{ .Name }} with the email address {{ .Email }}."
ErrorTitle = "An error has occurred"
IndexGreeting = "Hello {{ .User }}"
IndexIntroductionText = "This is an authorization protected resource"
GreetingAnonymous = "Hello"
GreetingAuthenticated = "Hello {{ .Name }}"
IndexIntroductionText = "This is a public resource."
IndexTitle = "Welcome to the Demo application"
ProtectedIntroductionText = "This is an authorization protected resource."
[IndexNavLabel]
description = "Label for the index page in the top navigation"
other = "Welcome"
[LoginLabel]
description = "A label on a login button or link"
other = "Login"
[LogoutLabel]
description = "A label on a logout button or link"
other = "Logout"
[ProtectedNavLabel]
description = "Label for the protected resource page in the top navigation"
other = "Protected area"

View file

@ -29,6 +29,14 @@
<body class="resource-app d-flex flex-column h-100">
<header class="container flex-shrink-0">
<a href="/"><img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"></a>
<ul class="nav">
<li class="nav-item">
<a class="nav-link" href="/">{{ .WelcomeNavLabel }}</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/protected">{{ .ProtectedNavLabel }}</a>
</li>
</ul>
</header>
{{ template "content" . }}
<footer class="footer mt-auto py-3">

View file

@ -2,7 +2,11 @@
<main class="container">
<h1>{{ .Greeting }}</h1>
<p>{{ .IntroductionText }}</p>
<p>{{ .AuthenticatedAs }}</p>
<a class="btn btn-outline-primary" href="{{ .LogoutURL }}">{{ .LogoutLabel }}</a>
{{ if .IsAuthenticated }}
<p>{{ .AuthenticatedAs }}</p>
<a class="btn btn-outline-primary" href="{{ .LogoutURL }}">{{ .LogoutLabel }}</a>
{{ else }}
<a class="btn btn-primary" href="/login">{{ .LoginLabel }}</a>
{{ end }}
</main>
{{ end }}

View file

@ -0,0 +1,8 @@
{{ define "content" }}
<main class="container">
<h1>{{ .Greeting }}</h1>
<p>{{ .IntroductionText }}</p>
<p>{{ .AuthenticatedAs }}</p>
<a class="btn btn-outline-primary" href="{{ .LogoutURL }}">{{ .LogoutLabel }}</a>
</main>
{{ end }}