Basic logout workflow

This commit is contained in:
wpetit 2020-05-20 13:06:04 +02:00
parent 06285a206f
commit 5d672ad6e1
12 changed files with 206 additions and 56 deletions

View File

@ -1,7 +1,10 @@
package oidc package oidc
import ( import (
"bytes"
"net/http" "net/http"
"net/url"
"strings"
"github.com/coreos/go-oidc" "github.com/coreos/go-oidc"
"github.com/dchest/uniuri" "github.com/dchest/uniuri"
@ -41,10 +44,30 @@ func (c *Client) Login(w http.ResponseWriter, r *http.Request) {
panic(errors.Wrap(err, "could not save session")) panic(errors.Wrap(err, "could not save session"))
} }
http.Redirect(w, r, c.oauth2.AuthCodeURL(state), http.StatusFound) authCodeOptions := []oauth2.AuthCodeOption{}
rawIDToken, _ := RawIDToken(w, r)
if rawIDToken != "" {
authCodeOptions = append(
authCodeOptions,
oauth2.SetAuthURLParam("id_token_hint", rawIDToken),
)
}
authCodeURL := c.oauth2.AuthCodeURL(
state,
authCodeOptions...,
)
http.Redirect(w, r, authCodeURL, http.StatusFound)
} }
func (c *Client) Logout(w http.ResponseWriter, r *http.Request) { func (c *Client) Logout(w http.ResponseWriter, r *http.Request, postLogoutRedirectURL string) {
rawIDToken, err := RawIDToken(w, r)
if err != nil {
panic(errors.Wrap(err, "could not retrieve raw id token"))
}
ctn := container.Must(r.Context()) ctn := container.Must(r.Context())
sess, err := session.Must(ctn).Get(w, r) sess, err := session.Must(ctn).Get(w, r)
@ -55,50 +78,102 @@ func (c *Client) Logout(w http.ResponseWriter, r *http.Request) {
state := uniuri.New() state := uniuri.New()
sess.Set(SessionOIDCStateKey, state) sess.Set(SessionOIDCStateKey, state)
sess.Unset(SessionOIDCRawTokenKey)
sess.Unset(SessionOIDCTokenKey)
if err := sess.Save(w, r); err != nil { if err := sess.Save(w, r); err != nil {
panic(errors.Wrap(err, "could not save session")) panic(errors.Wrap(err, "could not save session"))
} }
http.Redirect(w, r, c.oauth2.AuthCodeURL(state), http.StatusFound) sessionEndURL, err := c.sessionEndURL(rawIDToken, state, postLogoutRedirectURL)
if err != nil {
panic(errors.Wrap(err, "could not retrieve session end url"))
}
if sessionEndURL != "" {
http.Redirect(w, r, sessionEndURL, http.StatusFound)
} else {
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
}
} }
func (c *Client) Validate(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) { func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string) (string, error) {
sessionEndEndpoint := &struct {
URL string `json:"end_session_endpoint"`
}{}
if err := c.provider.Claims(&sessionEndEndpoint); err != nil {
return "", errors.Wrap(err, "could not unmarshal claims")
}
if sessionEndEndpoint.URL == "" {
return "", nil
}
var buf bytes.Buffer
buf.WriteString(sessionEndEndpoint.URL)
v := url.Values{}
if idTokenHint != "" {
v.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectURL != "" {
v.Set("post_logout_redirect_uri", postLogoutRedirectURL)
}
if state != "" {
v.Set("state", state)
}
if strings.Contains(sessionEndEndpoint.URL, "?") {
buf.WriteByte('&')
} else {
buf.WriteByte('?')
}
buf.WriteString(v.Encode())
return buf.String(), nil
}
func (c *Client) Validate(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, string, error) {
ctx := r.Context() ctx := r.Context()
ctn := container.Must(ctx) ctn := container.Must(ctx)
sess, err := session.Must(ctn).Get(w, r) sess, err := session.Must(ctn).Get(w, r)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not retrieve session") return nil, "", errors.Wrap(err, "could not retrieve session")
} }
state, ok := sess.Get(SessionOIDCStateKey).(string) state, ok := sess.Get(SessionOIDCStateKey).(string)
if !ok { if !ok {
return nil, errors.New("invalid state") return nil, "", errors.New("invalid state")
} }
if r.URL.Query().Get("state") != state { if r.URL.Query().Get("state") != state {
return nil, errors.New("state mismatch") return nil, "", errors.New("state mismatch")
} }
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
token, err := c.oauth2.Exchange(ctx, code) token, err := c.oauth2.Exchange(ctx, code)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not exchange token") return nil, "", errors.Wrap(err, "could not exchange token")
} }
rawIDToken, ok := token.Extra("id_token").(string) rawIDToken, ok := token.Extra("id_token").(string)
if !ok { if !ok {
return nil, errors.New("could not find id token") return nil, "", errors.New("could not find id token")
} }
idToken, err := c.verifier.Verify(ctx, rawIDToken) idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "could not verify id token") return nil, "", errors.Wrap(err, "could not verify id token")
} }
return idToken, nil return idToken, rawIDToken, nil
} }
func NewClient(opts ...OptionFunc) *Client { func NewClient(opts ...OptionFunc) *Client {

View File

@ -9,7 +9,11 @@
</div> </div>
<div class="column"> <div class="column">
<div class="buttons is-right"> <div class="buttons is-right">
{{if .IDToken}}
<a class="button" href="/logout">Logout</a> <a class="button" href="/logout">Logout</a>
{{else}}
<a class="button is-primary" href="/login">Login</a>
{{end}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,8 +3,6 @@
<section class="home is-fullheight section"> <section class="home is-fullheight section">
<div class="container"> <div class="container">
{{template "header" .}} {{template "header" .}}
<h2 class="is-size-4">Jeton OpenID Connect</h2>
<pre>{{ .JSONIDToken }}</pre>
{{template "footer" .}} {{template "footer" .}}
</div> </div>
</section> </section>

View File

@ -0,0 +1,12 @@
{{define "title"}}Accueil{{end}}
{{define "body"}}
<section class="home is-fullheight section">
<div class="container">
{{template "header" .}}
<h2 class="is-size-4">Jeton OpenID Connect</h2>
<pre>{{ .JSONIDToken }}</pre>
{{template "footer" .}}
</div>
</section>
{{end}}
{{template "base" .}}

1
go.sum
View File

@ -34,6 +34,7 @@ github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CL
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=

28
internal/route/home.go Normal file
View File

@ -0,0 +1,28 @@
package route
import (
"net/http"
oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveHomePage(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
tmpl := template.Must(ctn)
idToken, _ := oidc.IDToken(w, r)
if idToken != nil {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
data := extendTemplateData(w, r, template.Data{})
if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}

View File

@ -1,41 +1,20 @@
package route package route
import ( import (
"encoding/json"
"net/http" "net/http"
oidc "forge.cadoles.com/wpetit/goweb-oidc" oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
"gitlab.com/wpetit/goweb/middleware/container" "gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
) )
func serveHomePage(w http.ResponseWriter, r *http.Request) { func handleLogin(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context()) ctn := container.Must(r.Context())
tmpl := template.Must(ctn) client := oidc.Must(ctn)
client.Login(w, r)
idToken, err := oidc.IDToken(w, r)
if err != nil {
panic(errors.Wrap(err, "could not retrieve idToken"))
}
jsonIDToken, err := json.MarshalIndent(idToken, "", " ")
if err != nil {
panic(errors.Wrap(err, "could not encode idToken"))
}
data := extendTemplateData(w, r, template.Data{
"IDToken": idToken,
"JSONIDToken": string(jsonIDToken),
})
if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
} }
func handleLogin(w http.ResponseWriter, r *http.Request) { func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
idToken, err := oidc.IDToken(w, r) idToken, err := oidc.IDToken(w, r)

View File

@ -23,5 +23,5 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
client := oidc.Must(ctn) client := oidc.Must(ctn)
client.Logout(w, r) client.Logout(w, r, "")
} }

View File

@ -9,14 +9,16 @@ import (
) )
func Mount(r *chi.Mux, config *config.Config) error { func Mount(r *chi.Mux, config *config.Config) error {
r.Group(func(r chi.Router) {
r.Use(oidc.Middleware)
r.With(oidc.HandleCallback).Get("/oauth2/callback", handleLoginCallback)
r.Get("/", serveHomePage) r.Get("/", serveHomePage)
})
r.With(oidc.HandleCallback).Get("/oauth2/callback", handleLogin)
r.Get("/logout", handleLogout) r.Get("/logout", handleLogout)
r.Get("/login", handleLogin)
r.Route("/profile", func(r chi.Router) {
r.Use(oidc.Middleware)
r.Get("/", serveProfilePage)
})
notFoundHandler := r.NotFoundHandler() notFoundHandler := r.NotFoundHandler()
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler)) r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))

35
internal/route/profile.go Normal file
View File

@ -0,0 +1,35 @@
package route
import (
"encoding/json"
"net/http"
oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveProfilePage(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
tmpl := template.Must(ctn)
idToken, err := oidc.IDToken(w, r)
if err != nil {
panic(errors.Wrap(err, "could not retrieve idToken"))
}
jsonIDToken, err := json.MarshalIndent(idToken, "", " ")
if err != nil {
panic(errors.Wrap(err, "could not encode idToken"))
}
data := extendTemplateData(w, r, template.Data{
"IDToken": idToken,
"JSONIDToken": string(jsonIDToken),
})
if err := tmpl.RenderPage(w, "profile.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}

View File

@ -14,6 +14,7 @@ import (
const ( const (
SessionOIDCTokenKey = "oidc-token" SessionOIDCTokenKey = "oidc-token"
SessionOIDCRawTokenKey = "oidc-raw-token"
SessionOIDCStateKey = "oidc-state" SessionOIDCStateKey = "oidc-state"
) )
@ -47,7 +48,7 @@ func HandleCallback(next http.Handler) http.Handler {
ctn := container.Must(ctx) ctn := container.Must(ctx)
client := Must(ctn) client := Must(ctn)
idToken, err := client.Validate(w, r) idToken, rawIDToken, err := client.Validate(w, r)
if err != nil { if err != nil {
logger.Error(ctx, "could not validate oidc token", logger.E(err)) logger.Error(ctx, "could not validate oidc token", logger.E(err))
@ -62,6 +63,7 @@ func HandleCallback(next http.Handler) http.Handler {
} }
sess.Set(SessionOIDCTokenKey, idToken) sess.Set(SessionOIDCTokenKey, idToken)
sess.Set(SessionOIDCRawTokenKey, rawIDToken)
if err := sess.Save(w, r); err != nil { if err := sess.Save(w, r); err != nil {
panic(errors.Wrap(err, "could not save session")) panic(errors.Wrap(err, "could not save session"))
@ -73,14 +75,6 @@ func HandleCallback(next http.Handler) http.Handler {
return http.HandlerFunc(fn) return http.HandlerFunc(fn)
} }
func Logout(w http.ResponseWriter, r *http.Request) {
// ctx := r.Context()
// ctn := container.Must(ctx)
// client := Must(ctn)
// client
}
func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) { func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
ctn := container.Must(r.Context()) ctn := container.Must(r.Context())
@ -96,3 +90,19 @@ func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) {
return idToken, nil return idToken, nil
} }
func RawIDToken(w http.ResponseWriter, r *http.Request) (string, error) {
ctn := container.Must(r.Context())
sess, err := session.Must(ctn).Get(w, r)
if err != nil {
return "", errors.Wrap(err, "could not retrieve session")
}
rawIDToken, ok := sess.Get(SessionOIDCRawTokenKey).(string)
if !ok || rawIDToken == "" {
return "", errors.New("invalid raw id token")
}
return rawIDToken, nil
}

View File

@ -16,6 +16,12 @@ type Option struct {
Scopes []string Scopes []string
} }
func WithRedirectURL(url string) OptionFunc {
return func(opt *Option) {
opt.RedirectURL = url
}
}
func WithCredentials(clientID, clientSecret string) OptionFunc { func WithCredentials(clientID, clientSecret string) OptionFunc {
return func(opt *Option) { return func(opt *Option) {
opt.ClientID = clientID opt.ClientID = clientID