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:
parent
0a4cc75bd3
commit
7ec9e393e0
19 changed files with 628 additions and 154 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -5,6 +5,7 @@
|
|||
/resource_app*.toml
|
||||
/sessions
|
||||
/static
|
||||
/translations/translate.*.toml
|
||||
/ui/css/
|
||||
/ui/images/
|
||||
/ui/js/
|
||||
|
|
11
Makefile
11
Makefile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
117
cmd/app/main.go
117
cmd/app/main.go
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
51
internal/handlers/login.go
Normal file
51
internal/handlers/login.go
Normal 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{}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
155
internal/handlers/protected.go
Normal file
155
internal/handlers/protected.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
100
internal/services/token.go
Normal 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")
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 }}
|
8
ui/templates/protected.gohtml
Normal file
8
ui/templates/protected.gohtml
Normal 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 }}
|
Loading…
Reference in a new issue