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.
main
Jan Dittberner 10 months ago
parent 0a4cc75bd3
commit 7ec9e393e0

1
.gitignore vendored

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

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

@ -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 - let the session expire when the token expires
- link from logo to start page - link from logo to start page
- move common page header to templates/base.gohtml - move common page header to templates/base.gohtml
- implement caching for static resources
### Added ### Added
- add identity output to index page - add identity output to index page
- add a separate protected resource page
## [0.2.0] ## [0.2.0]
### Changed ### Changed

@ -53,6 +53,37 @@ var (
date = "unknown" 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() { func main() {
logger := log.New() logger := log.New()
@ -91,22 +122,7 @@ func main() {
services.AddMessages(catalog) services.AddMessages(catalog)
tlsClientConfig := &tls.Config{ tlsClientConfig := getTLSConfig(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
}
apiTransport := &http.Transport{TLSClientConfig: tlsClientConfig} apiTransport := &http.Transport{TLSClientConfig: tlsClientConfig}
apiClient := &http.Client{Transport: apiTransport} apiClient := &http.Client{Transport: apiTransport}
@ -118,7 +134,7 @@ func main() {
APIClient: apiClient, APIClient: apiClient,
}) })
if err != nil { if err != nil {
log.Fatalf("OpenID Connect discovery failed: %s", err) logger.WithError(err).Fatal("OpenID Connect discovery failed")
} }
sessionPath, sessionAuthKey, sessionEncKey := configureSessionParameters(config) sessionPath, sessionAuthKey, sessionEncKey := configureSessionParameters(config)
@ -128,23 +144,38 @@ func main() {
publicURL := buildPublicURL(config.MustString("server.name"), config.MustInt("server.port")) 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 { if err != nil {
logger.WithError(err).Fatal("could not initialize index handler") 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) callbackHandler := handlers.NewCallbackHandler(logger, oidcInfo.KeySet, oidcInfo.OAuth2Config)
afterLogoutHandler := handlers.NewAfterLogoutHandler(logger) afterLogoutHandler := handlers.NewAfterLogoutHandler(logger)
staticFiles := http.FileServer(http.FS(ui.Static))
staticFiles := staticFileHandler(logger)
router := http.NewServeMux() 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("/callback", callbackHandler)
router.Handle("/after-logout", afterLogoutHandler) router.Handle("/after-logout", afterLogoutHandler)
router.Handle("/health", handlers.NewHealthHandler()) router.Handle("/health", handlers.NewHealthHandler())
router.Handle("/images/", staticFiles) router.HandleFunc("/images/", staticFiles)
router.Handle("/css/", staticFiles) router.HandleFunc("/css/", staticFiles)
router.Handle("/js/", staticFiles) router.HandleFunc("/js/", staticFiles)
nextRequestID := func() string { nextRequestID := func() string {
return fmt.Sprintf("%d", time.Now().UnixNano()) return fmt.Sprintf("%d", time.Now().UnixNano())
@ -176,6 +207,46 @@ func main() {
handlers.StartApplication(context.Background(), logger, server, publicURL, config) 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 { func buildPublicURL(hostname string, port int) string {
const defaultHTTPSPort = 443 const defaultHTTPSPort = 443

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

@ -21,14 +21,12 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"github.com/lestrrat-go/jwx/jwk" "github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/jwt" "github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/lestrrat-go/jwx/jwt/openid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -37,28 +35,27 @@ import (
) )
const ( const (
sessionName = "resource_session"
oauth2RedirectStateLength = 8 oauth2RedirectStateLength = 8
sessionName = "resource_app"
) )
func Authenticate(logger *log.Logger, oauth2Config *oauth2.Config, clientID string) func(http.Handler) http.Handler { func Authenticate(logger *log.Logger, oauth2Config *oauth2.Config, 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 := GetSession(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
if _, ok := session.Values[sessionKeyIDToken]; ok { if _, ok := session.Values[services.SessionIDToken]; ok {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
session.Values[sessionRedirectTarget] = r.URL.String() session.Values[services.SessionRedirectTarget] = r.URL.String()
if err = session.Save(r, w); err != nil { if err = session.Save(r, w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -106,19 +103,34 @@ func getRequestedClaims(logger *log.Logger) string {
return buf.String() return buf.String()
} }
func ParseIDToken(token string, keySet jwk.Set) (openid.Token, error) { func GetSession(r *http.Request) (*sessions.Session, error) {
var ( session, err := services.GetSessionStore().Get(r, sessionName)
parsedIDToken jwt.Token if err != nil {
err error return nil, fmt.Errorf("could not get session")
)
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 session, nil
return v, 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 nil, errors.New("ID token is no OpenID Connect Identity Token") return data
} }

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

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

@ -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{}
}

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

@ -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
}

@ -37,14 +37,23 @@ func AddMessages(catalog *MessageCatalog) {
Other: "The identity provider authenticated your identity as {{ .Name }}" + Other: "The identity provider authenticated your identity as {{ .Name }}" +
" with the email address {{ .Email }}.", " with the email address {{ .Email }}.",
} }
messages["IndexGreeting"] = &i18n.Message{ messages["GreetingAnonymous"] = &i18n.Message{
ID: "IndexGreeting", ID: "GreetingAnonymous",
Other: "Hello {{ .User }}", Other: "Hello",
}
messages["GreetingAuthenticated"] = &i18n.Message{
ID: "GreetingAuthenticated",
Other: "Hello {{ .Name }}",
} }
messages["IndexTitle"] = &i18n.Message{ messages["IndexTitle"] = &i18n.Message{
ID: "IndexTitle", ID: "IndexTitle",
Other: "Welcome to the Demo application", 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{ messages["LogoutLabel"] = &i18n.Message{
ID: "LogoutLabel", ID: "LogoutLabel",
Description: "A label on a logout button or link", Description: "A label on a logout button or link",
@ -52,7 +61,21 @@ func AddMessages(catalog *MessageCatalog) {
} }
messages["IndexIntroductionText"] = &i18n.Message{ messages["IndexIntroductionText"] = &i18n.Message{
ID: "IndexIntroductionText", 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) catalog.AddMessages(messages)

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

@ -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")
}

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

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

@ -29,6 +29,14 @@
<body class="resource-app d-flex flex-column h-100"> <body class="resource-app d-flex flex-column h-100">
<header class="container flex-shrink-0"> <header class="container flex-shrink-0">
<a href="/"><img src="/images/CAcert-logo.svg" width="300" height="68" alt="CAcert" class="mb-4"></a> <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> </header>
{{ template "content" . }} {{ template "content" . }}
<footer class="footer mt-auto py-3"> <footer class="footer mt-auto py-3">

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

@ -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 }}
Loading…
Cancel
Save