diff options
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | main.go | 80 | ||||
| -rw-r--r-- | pkg/auth/oidc.go | 162 | ||||
| -rw-r--r-- | pkg/session/session.go | 14 | ||||
| -rw-r--r-- | startup.txt | 50 | ||||
| -rw-r--r-- | web/handler/auth.go | 149 | ||||
| -rw-r--r-- | web/handler/deploy.go | 2 | ||||
| -rw-r--r-- | web/handler/index.go | 4 | ||||
| -rw-r--r-- | web/handler/instance.go | 6 | ||||
| -rw-r--r-- | web/middleware/auth.go | 40 | ||||
| -rw-r--r-- | web/views/auth.html | 21 | ||||
| -rw-r--r-- | web/views/f_auth_error.html | 3 | ||||
| -rw-r--r-- | web/views/index.html | 2 | ||||
| -rw-r--r-- | web/views/problem.html | 36 | ||||
| -rw-r--r-- | web/web.go | 10 |
16 files changed, 472 insertions, 116 deletions
@@ -7,10 +7,12 @@ require ( github.com/caarlos0/env/v11 v11.3.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect @@ -25,6 +27,7 @@ require ( go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.38.0 // indirect tailscale.com v1.92.5 // indirect ) @@ -6,6 +6,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= @@ -14,6 +16,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -45,6 +49,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= @@ -2,9 +2,11 @@ package main import ( "context" + _ "embed" "log/slog" "net/http" + "git.leonardobishop.net/instancer/pkg/auth" "git.leonardobishop.net/instancer/pkg/deployer" "git.leonardobishop.net/instancer/pkg/janitor" "git.leonardobishop.net/instancer/pkg/registry" @@ -21,71 +23,18 @@ type Config struct { ImagePrefix string `env:"IMAGE_PREFIX"` ProxyContainerName string `env:"PROXY_CONTAINER_NAME"` -} - -const startupMessage = ` - , . - , . . . - . . . . - what's the worst that i can say?... - . . - . . . - . . - . ...things are better if i stay... - . . . - . , - . , . . , - . . . - ! , - ! . - , . ^ - / \ . - . /___\ , - . |= =| , - . | | - | | , -, | | - | | . - | | - . . | | , - , | | . - | | - | | . - /|##!##|\ - . / |##!##| \ - / |##!##| \ , - | / ^ | ^ \ | - . | / ( | ) \ | , - , . |/ ( | ) \| - (( )) - (( : )) . - (( : )) - , (( )) . - . (( )) , - ( ) - . - . . . - , . , - - _ __ - ___| |_ / _| - / __| __| |_ - _ | (__| |_| _| - (_)_ __ ___| \___|\__|_|_ ___ ___ _ __ - | | '_ \/ __| __/ _` + "`" + ` | '_ \ / __/ _ \ '__| - | | | | \__ \ || (_| | | | | (_| __/ | - |_|_| |_|___/\__\__,_|_| |_|\___\___|_| -____^/\___^--____/\____O______________/\/\-- - /\^ ^ ^ ^ ^^ ^ '\ - -- - -- - - -- __ ___-- ^ ^ + OidcClientId string `env:"OIDC_CLIENT_ID"` + OidcClientSecret string `env:"OIDC_CLIENT_SECRET"` + OidcDiscoveryEndpoint string `env:"OIDC_DISCOVERY_ENDPOINT"` + OidcIdPName string `env:"OIDC_IDP_NAME" envDefault:"OIDC"` + OidcCallbackProtocol string `env:"OIDC_CALLBACK_PROTOCOL" envDefault:"https"` +} -` +//go:embed startup.txt +var startupMessage string func main() { - slog.Info(startupMessage) - var config Config if err := env.Parse(&config); err != nil { @@ -110,11 +59,18 @@ func main() { panic(err) } + oidcAuthProvider, err := auth.NewOIDCAuthProvider(config.OidcIdPName, config.OidcClientId, config.OidcClientSecret, config.OidcDiscoveryEndpoint, config.OidcCallbackProtocol+"://"+config.InstancerDomain+"/auth/callback") + if err != nil { + panic(err) + } + + slog.Info(startupMessage) + slog.Info("staring janitor job") go janitor.StartJanitor(context.Background(), &dockerDeployer) slog.Info("starting http server") - err = http.ListenAndServe(":8080", web.NewMux(®istryClient, &dockerDeployer)) + err = http.ListenAndServe(":8080", web.NewMux(®istryClient, &dockerDeployer, &oidcAuthProvider)) slog.Error("http server closing", "reason", err.Error()) slog.Info("so long and goodnight; so long and goodnight...") } diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 0000000..674332e --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,162 @@ +// Adapted from +// https://git.leonardobishop.net/confplanner/plain/pkg/auth/oauth.go + +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "sync" + "time" + + "git.leonardobishop.net/instancer/pkg/session" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type OIDCAuthProvider struct { + Name string + + oauthConfig *oauth2.Config + oidcProvider *oidc.Provider + oidcVerifier *oidc.IDTokenVerifier + + states map[string]*oidcState + lock sync.RWMutex +} + +type oidcState struct { + expiry time.Time + ip string + userAgent string +} + +var ( + ErrStateVerificationFailed = errors.New("state verification failed") + ErrInvalidState = errors.New("invalid state") + ErrMissingIDToken = errors.New("missing ID token") + ErrInvalidIDToken = errors.New("invalid ID token") + ErrNotAuthorised = errors.New("not authorised") + ErrUserSyncFailed = errors.New("user sync failed") + + ErrInvalidToken = errors.New("invalid token") +) + +func NewOIDCAuthProvider(name, clientID, clientSecret, endpoint, callbackURL string) (OIDCAuthProvider, error) { + provider, err := oidc.NewProvider(context.Background(), endpoint) + if err != nil { + return OIDCAuthProvider{}, err + } + + return OIDCAuthProvider{ + Name: name, + oauthConfig: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: callbackURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + }, + oidcProvider: provider, + oidcVerifier: provider.Verifier(&oidc.Config{ClientID: clientID}), + states: make(map[string]*oidcState), + }, nil +} + +func (p *OIDCAuthProvider) StartJourney(ip string, userAgent string) (string, error) { + b := make([]byte, 50) + if _, err := rand.Read(b); err != nil { + return "", err + } + + state := base64.URLEncoding.EncodeToString(b) + + p.lock.Lock() + defer p.lock.Unlock() + + p.states[state] = &oidcState{ + expiry: time.Now().Add(time.Minute * 5), + ip: ip, + userAgent: userAgent, + } + + return p.oauthConfig.AuthCodeURL(state), nil +} + +func (p *OIDCAuthProvider) CompleteJourney(ctx context.Context, authCode string, state string, ip string, userAgent string, session *session.UserSession) error { + var s *oidcState + + p.lock.Lock() + s = p.states[state] + delete(p.states, state) + p.lock.Unlock() + + if s == nil { + return ErrInvalidState + } + + //if time.Now().After(s.expiry) || s.ip != ip || s.userAgent != userAgent { + // return nil, ErrStateVerificationFailed + //} + if time.Now().After(s.expiry) || s.userAgent != userAgent { + return ErrStateVerificationFailed + } + + oauth2Token, err := p.oauthConfig.Exchange(ctx, authCode) + if err != nil { + return err + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return ErrMissingIDToken + } + + idToken, err := p.oidcVerifier.Verify(ctx, rawIDToken) + if err != nil { + return ErrInvalidIDToken + } + + var claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil { + return ErrInvalidIDToken + } + + session.OAuthTokenSource = p.oauthConfig.TokenSource(context.Background(), oauth2Token) + session.Subject = claims.Subject + session.Email = claims.Email + session.Name = claims.Name + + return nil +} + +func (p *OIDCAuthProvider) UpdateUserInfo(ctx context.Context, session *session.UserSession) error { + userInfo, err := p.oidcProvider.UserInfo(ctx, session.OAuthTokenSource) + if err != nil { + return ErrInvalidToken + } + + var claims struct { + Name string `json:"name"` + TeamID string `json:"team_id"` + TeamName string `json:"team_name"` + } + err = userInfo.Claims(&claims) + if err != nil { + return err + } + + session.Subject = userInfo.Subject + session.Email = userInfo.Email + session.Name = claims.Name + session.TeamID = claims.TeamID + session.TeamName = claims.TeamName + + return nil +} diff --git a/pkg/session/session.go b/pkg/session/session.go index 87306ac..b60fef6 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -5,11 +5,20 @@ import ( "encoding/base64" "fmt" "sync" + + "golang.org/x/oauth2" ) type UserSession struct { Token string - Team string + + OAuthTokenSource oauth2.TokenSource + Subject string + Name string + Email string + + TeamID string + TeamName string } // implemtation adapted from @@ -38,7 +47,7 @@ func (s *MemoryStore) GetByToken(token string) *UserSession { return s.sessions[token] } -func (s *MemoryStore) Create(team string) (*UserSession, error) { +func (s *MemoryStore) Create() (*UserSession, error) { s.lock.Lock() defer s.lock.Unlock() @@ -56,7 +65,6 @@ func (s *MemoryStore) Create(team string) (*UserSession, error) { session := &UserSession{ Token: token, - Team: team, } s.sessions[token] = session diff --git a/startup.txt b/startup.txt new file mode 100644 index 0000000..9d9ef6c --- /dev/null +++ b/startup.txt @@ -0,0 +1,50 @@ + + + . . . + . , + . , . . , + . . . + ! , + ! . + , . ^ + / \ . + . /___\ , + . |= =| , + . | | + | | , +, | | + | | . + | | + . . | | , + , | | . + | | + | | . + /|##!##|\ + . / |##!##| \ + / |##!##| \ , + | / ^ | ^ \ | + . | / ( | ) \ | , + , . |/ ( | ) \| + (( )) + (( : )) . + (( : )) + , (( )) . + . (( )) , + ( ) + . + . . . + , . , + + _ __ + ___| |_ / _| + / __| __| |_ + _ | (__| |_| _| + (_)_ __ ___| \___|\__|_|_ ___ ___ _ __ + | | '_ \/ __| __/ _` | '_ \ / __/ _ \ '__| + | | | | \__ \ || (_| | | | | (_| __/ | + |_|_| |_|___/\__\__,_|_| |_|\___\___|_| + +____^/\___^--____/\____O______________/\/\-- + /\^ ^ ^ ^ ^^ ^ '\ + -- - -- - + -- __ ___-- ^ ^ diff --git a/web/handler/auth.go b/web/handler/auth.go index 38b87b6..29bd47c 100644 --- a/web/handler/auth.go +++ b/web/handler/auth.go @@ -1,81 +1,164 @@ package handler import ( + "errors" "html/template" "log/slog" "net/http" - "strconv" "time" + "git.leonardobishop.net/instancer/pkg/auth" "git.leonardobishop.net/instancer/pkg/session" ) -func GetAuth(tmpl *template.Template) http.HandlerFunc { +func GetAuth(tmpl *template.Template, authProvider *auth.OIDCAuthProvider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tmpl.ExecuteTemplate(w, "auth.html", nil) + w.Header().Add("HX-Redirect", "/auth") + tmpl.ExecuteTemplate(w, "auth.html", struct { + Error string + OidcIdPName string + }{ + Error: "", + OidcIdPName: authProvider.Name, + }) } } -func PostAuth(tmpl *template.Template, session *session.MemoryStore) http.HandlerFunc { +func PostAuth(tmpl *template.Template, session *session.MemoryStore, authProvider *auth.OIDCAuthProvider) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { - Message string + url, err := authProvider.StartJourney(r.RemoteAddr, r.UserAgent()) + if err != nil { + slog.Error("oidc start journey failed", "cause", err) + tmpl.ExecuteTemplate(w, "auth.html", struct { + Error string + OidcIdPName string }{ - Message: "Invalid form data", + Error: "Failed to start OIDC journey", + OidcIdPName: authProvider.Name, }) return } - team := r.FormValue("team") - if team == "" { - tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { - Message string - }{ - Message: "No team entered", - }) - return + http.Redirect(w, r, url, http.StatusFound) + } +} + +func GetAuthCallback(tmpl *template.Template, session *session.MemoryStore, authProvider *auth.OIDCAuthProvider) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + type pageParams struct { + Error string + OidcIdPName string } + params := pageParams{OidcIdPName: authProvider.Name} + query := r.URL.Query() - if _, err := strconv.Atoi(team); err != nil { - tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { - Message string - }{ - Message: "Team ID must be number", - }) + s, err := session.Create() + if err != nil { + params.Error = "Failed to create session" + slog.Error("session creation failed", "cause", err) + w.Header().Add("HX-Push-Url", "/auth") + tmpl.ExecuteTemplate(w, "auth.html", params) return } - session, err := session.Create(team) + err = authProvider.CompleteJourney(r.Context(), query.Get("code"), query.Get("state"), r.RemoteAddr, r.UserAgent(), s) + if err != nil { - slog.Error("could not create session", "cause", err) - tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { - Message string - }{ - Message: "Could not create session", - }) + if errors.Is(err, auth.ErrNotAuthorised) { + params.Error = "You are not authorised to use this service" + goto done + } else if errors.Is(err, auth.ErrInvalidState) { + params.Error = "Invalid state" + goto done + } else if errors.Is(err, auth.ErrStateVerificationFailed) { + params.Error = "State verification failed" + goto done + } else if errors.Is(err, auth.ErrInvalidIDToken) { + params.Error = "Invalid ID token" + goto done + } + params.Error = "Failed to complete OIDC journey" + slog.Error("oidc complete journey failed", "cause", err) + done: + w.Header().Add("HX-Push-Url", "/auth") + tmpl.ExecuteTemplate(w, "auth.html", params) + session.Destroy(s.Token) return } http.SetCookie(w, &http.Cookie{ - Name: "session", - Value: session.Token, + Name: "instancer-session", + Value: s.Token, Path: "/", Secure: true, SameSite: http.SameSiteStrictMode, HttpOnly: true, }) - w.Header().Add("HX-Redirect", "/") + http.Redirect(w, r, "/", http.StatusFound) } } +//func PostAuth(tmpl *template.Template, session *session.MemoryStore) http.HandlerFunc { +// return func(w http.ResponseWriter, r *http.Request) { +// if err := r.ParseForm(); err != nil { +// tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { +// Message string +// }{ +// Message: "Invalid form data", +// }) +// return +// } +// +// team := r.FormValue("team") +// if team == "" { +// tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { +// Message string +// }{ +// Message: "No team entered", +// }) +// return +// } +// +// if _, err := strconv.Atoi(team); err != nil { +// tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { +// Message string +// }{ +// Message: "Team ID must be number", +// }) +// return +// } +// +// session, err := session.Create(team) +// if err != nil { +// slog.Error("could not create session", "cause", err) +// tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { +// Message string +// }{ +// Message: "Could not create session", +// }) +// return +// } +// +// http.SetCookie(w, &http.Cookie{ +// Name: "session", +// Value: session.Token, +// +// Path: "/", +// Secure: true, +// SameSite: http.SameSiteStrictMode, +// HttpOnly: true, +// }) +// w.Header().Add("HX-Redirect", "/") +// } +//} + func GetLogout(session *session.MemoryStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { //TODO expire session here http.SetCookie(w, &http.Cookie{ - Name: "session", + Name: "instancer-session", Value: "", Expires: time.Unix(0, 0), diff --git a/web/handler/deploy.go b/web/handler/deploy.go index 24e0f95..f0c40fc 100644 --- a/web/handler/deploy.go +++ b/web/handler/deploy.go @@ -24,7 +24,7 @@ func PostDeploy(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer } session := r.Context().Value("session").(*session.UserSession) - deployKey := dockerDeployer.StartDeploy(challenge, session.Team) + deployKey := dockerDeployer.StartDeploy(challenge, session.TeamID) tmpl.ExecuteTemplate(w, "f_deploy.html", struct { DeployKey string diff --git a/web/handler/index.go b/web/handler/index.go index 5cf44cf..9279151 100644 --- a/web/handler/index.go +++ b/web/handler/index.go @@ -22,10 +22,12 @@ func GetIndex(tmpl *template.Template, registryClient *registry.RegistryClient) if err := tmpl.ExecuteTemplate(w, "index.html", struct { Challenges []string + Name string Team string }{ Challenges: challenges, - Team: session.Team, + Name: session.Name, + Team: session.TeamName, }); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return diff --git a/web/handler/instance.go b/web/handler/instance.go index 0222a42..e5e4c53 100644 --- a/web/handler/instance.go +++ b/web/handler/instance.go @@ -15,9 +15,9 @@ func GetInstances(tmpl *template.Template, dockerDeployer *deployer.DockerDeploy return func(w http.ResponseWriter, r *http.Request) { session := r.Context().Value("session").(*session.UserSession) - instances, err := dockerDeployer.GetTeamInstances(r.Context(), session.Team) + instances, err := dockerDeployer.GetTeamInstances(r.Context(), session.TeamID) if err != nil { - slog.Error("could not get team instances", "team", session.Team, "error", err) + slog.Error("could not get team instances", "team", session.TeamID, "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -70,7 +70,7 @@ func PostStopInstance(tmpl *template.Template, dockerDeployer *deployer.DockerDe session := r.Context().Value("session").(*session.UserSession) deployKey := r.PathValue("deployKey") - err := dockerDeployer.StopInstance(r.Context(), deployKey, session.Team) + err := dockerDeployer.StopInstance(r.Context(), deployKey, session.TeamID) if err != nil { tmpl.ExecuteTemplate(w, "f_instance_result.html", struct { State string diff --git a/web/middleware/auth.go b/web/middleware/auth.go index fcba3b7..c0257e2 100644 --- a/web/middleware/auth.go +++ b/web/middleware/auth.go @@ -2,28 +2,60 @@ package middleware import ( "context" + "errors" + "html/template" + "log/slog" "net/http" + "git.leonardobishop.net/instancer/pkg/auth" "git.leonardobishop.net/instancer/pkg/session" ) -func MustAuthenticate(store *session.MemoryStore) func(http.HandlerFunc) http.HandlerFunc { +func MustAuthenticate(tmpl *template.Template, store *session.MemoryStore, authProvider *auth.OIDCAuthProvider) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - sessionCookie, err := r.Cookie("session") + sessionCookie, err := r.Cookie("instancer-session") if err != nil { - w.Header().Add("HX-Redirect", "/auth") http.Redirect(w, r, "/auth", http.StatusFound) return } s := store.GetByToken(sessionCookie.Value) if s == nil { - w.Header().Add("HX-Redirect", "/auth") http.Redirect(w, r, "/auth", http.StatusFound) return } + err = authProvider.UpdateUserInfo(r.Context(), s) + if err != nil { + if errors.Is(err, auth.ErrInvalidToken) { + http.Redirect(w, r, "/auth", http.StatusFound) + return + } + slog.Error("error updating user info", "cause", err) + w.Header().Add("HX-Redirect", "/problem") + tmpl.ExecuteTemplate(w, "problem.html", struct { + Error string + ShowLogout bool + }{ + Error: "There was a problem fetching your user info. Try again later.", + ShowLogout: true, + }) + return + } + + if s.TeamID == "" || s.TeamName == "" { + w.Header().Add("HX-Redirect", "/problem") + tmpl.ExecuteTemplate(w, "problem.html", struct { + Error string + ShowLogout bool + }{ + Error: "You are not part of a team. Please join a team and then refresh this page.", + ShowLogout: true, + }) + return + } + ctx := context.WithValue(r.Context(), "session", s) next(w, r.WithContext(ctx)) diff --git a/web/views/auth.html b/web/views/auth.html index 995815a..17ca83d 100644 --- a/web/views/auth.html +++ b/web/views/auth.html @@ -18,6 +18,25 @@ <div class="col-12 col-md-6 col-lg-4"> <div class="card mx-auto"> <div class="card-body"> + <h4 class="card-title mb-3">Welcome</h4> + + <p>Please authenticate to spawn challenge instances.</p> + + {{if .Error}} + <div class="alert alert-danger" role="alert"> + {{.Error}} + </div> + {{end}} + + <form method="POST"> + <button type="submit" class="btn btn-primary w-100"> + Login with {{ .OidcIdPName }} + </button> + </form> + </div> + </div> +<!-- <div class="card mx-auto"> + <div class="card-body"> <h4 class="card-title mb-3">Enter a team ID</h4> <div id="auth-response"></div> @@ -33,7 +52,7 @@ </div> </form> </div> - </div> + </div> --> <div class="card mt-4 mx-auto"> <div class="card-body"> diff --git a/web/views/f_auth_error.html b/web/views/f_auth_error.html deleted file mode 100644 index 4ebdac3..0000000 --- a/web/views/f_auth_error.html +++ /dev/null @@ -1,3 +0,0 @@ -<div class="alert alert-danger" role="alert"> - {{.Message}} -</div> diff --git a/web/views/index.html b/web/views/index.html index 97edc88..81a144d 100644 --- a/web/views/index.html +++ b/web/views/index.html @@ -86,7 +86,7 @@ </div> <div class="mt-2 px-3"> <small class="text-muted"> - Logged in as <b>{{.Team}}</b>. + Logged in as <b>{{ .Name }}</b> ({{ .Team }}). <a href="/logout">Not you</a>? </small> </div> diff --git a/web/views/problem.html b/web/views/problem.html new file mode 100644 index 0000000..202f651 --- /dev/null +++ b/web/views/problem.html @@ -0,0 +1,36 @@ + +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Challenge Instancer</title> + + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> + <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script> +</head> +<body class="bg-light"> + +<div class="container py-5"> + <div class="row justify-content-center"> + <div class="col-12 col-md-6 col-lg-4"> + <div class="card mx-auto"> + <div class="card-body"> + <p class="card-text"> + {{.Error}} + </p> + </div> + </div> + {{if .ShowLogout}} + <div class="mt-2 px-3"> + <small class="text-muted"> + Alternatively, <a href="/logout">log out</a>. + </small> + </div> + {{end}} + </div> + </div> +</div> + +</body> +</html> @@ -5,6 +5,7 @@ import ( "html/template" "net/http" + "git.leonardobishop.net/instancer/pkg/auth" "git.leonardobishop.net/instancer/pkg/deployer" "git.leonardobishop.net/instancer/pkg/registry" "git.leonardobishop.net/instancer/pkg/session" @@ -15,7 +16,7 @@ import ( //go:embed views var views embed.FS -func NewMux(registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer) *http.ServeMux { +func NewMux(registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer, oidcAuthProvider *auth.OIDCAuthProvider) *http.ServeMux { tmpl, err := template.ParseFS(views, "views/*.html") if err != nil { panic(err) @@ -23,10 +24,11 @@ func NewMux(registryClient *registry.RegistryClient, dockerDeployer *deployer.Do mux := http.NewServeMux() store := session.NewMemoryStore() - mustAuthenticate := middleware.MustAuthenticate(store) + mustAuthenticate := middleware.MustAuthenticate(tmpl, store, oidcAuthProvider) - mux.HandleFunc("GET /auth", handler.GetAuth(tmpl)) - mux.HandleFunc("POST /auth", handler.PostAuth(tmpl, store)) + mux.HandleFunc("GET /auth", handler.GetAuth(tmpl, oidcAuthProvider)) + mux.HandleFunc("POST /auth", handler.PostAuth(tmpl, store, oidcAuthProvider)) + mux.HandleFunc("GET /auth/callback", handler.GetAuthCallback(tmpl, store, oidcAuthProvider)) mux.HandleFunc("GET /logout", handler.GetLogout(store)) mux.HandleFunc("GET /", mustAuthenticate(handler.GetIndex(tmpl, registryClient))) |
