From 47b43cc022739dfd7aad66b0f4ebabdfae0f79e2 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 3 Apr 2021 13:49:45 +0000 Subject: [PATCH] fup: add fuphttp/fupoidc for doing OpenID Connect authn --- web/fup/default.nix | 2 +- web/fup/fuphttp/fupoidc/fupoidc.go | 235 +++++++++++++++++++++++++++++ web/fup/go.mod | 2 + web/fup/go.sum | 8 + 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 web/fup/fuphttp/fupoidc/fupoidc.go diff --git a/web/fup/default.nix b/web/fup/default.nix index d74952cd46..014ee62d11 100644 --- a/web/fup/default.nix +++ b/web/fup/default.nix @@ -21,7 +21,7 @@ pkgs.buildGoModule { wrapProgram $out/bin/fup --prefix PATH : ${lib.makeBinPath [ depot.third_party.cheddar ]} ''; - vendorSha256 = "sha256:08v3n5lrnbdbs36qpr5brqrainb34nwgm9z2pbxwpj85fxb8ky06"; + vendorSha256 = "sha256:0i2ag29jsv3dmd28q7h022z3ym6mh9mywpxy4i6d8fg7gai7xkph"; meta = with pkgs.lib; { description = "Simple file upload manager."; diff --git a/web/fup/fuphttp/fupoidc/fupoidc.go b/web/fup/fuphttp/fupoidc/fupoidc.go new file mode 100644 index 0000000000..97de1d3ace --- /dev/null +++ b/web/fup/fuphttp/fupoidc/fupoidc.go @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: 2021 Luke Granger-Brown +// +// SPDX-License-Identifier: Apache-2.0 + +package fupoidc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "hg.lukegb.com/lukegb/depot/web/fup/fuphttp" +) + +const ( + cookieOAuthState = "fup_state" + cookieToken = "fup_auth" +) + +type Middleware struct { + oidc *oidc.Provider + + verifier *oidc.IDTokenVerifier + + clientID string + clientSecret string + baseURL string +} + +type Config struct { + ClientID string + ClientSecret string + ProviderURL string + + BaseURL string +} + +func New(ctx context.Context, c Config) (*Middleware, error) { + provider, err := oidc.NewProvider(ctx, c.ProviderURL) + if err != nil { + return nil, fmt.Errorf("oidc.NewProvider: %w", err) + } + verifier := provider.Verifier(&oidc.Config{ + ClientID: c.ClientID, + }) + + return &Middleware{ + oidc: provider, + verifier: verifier, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + baseURL: c.BaseURL, + }, nil +} + +func (mw *Middleware) oauth2Config() *oauth2.Config { + redirectURL := mw.baseURL + if strings.HasSuffix(redirectURL, "/") { + redirectURL += "loginz" + } else { + redirectURL += "/loginz" + } + + return &oauth2.Config{ + ClientID: mw.clientID, + ClientSecret: mw.clientSecret, + RedirectURL: redirectURL, + + Endpoint: mw.oidc.Endpoint(), + + Scopes: []string{oidc.ScopeOpenID}, + } +} + +var errNoCredentials = errors.New("fupoidc: no auth credentials") + +func (mw *Middleware) isAuthenticated(r *http.Request) error { + // Check for credentials; either "Authorization: Bearer " or "Cookie: fup_auth=" + var token string + + if cookie, err := r.Cookie(cookieToken); err == nil { + token = cookie.Value + } + if authHeader := r.Header.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + token = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + } + + if token == "" { + return errNoCredentials + } + + // Validate the credentials. + ctx := r.Context() + _, err := mw.verifier.Verify(ctx, token) + return err +} + +func (mw *Middleware) setStateCookie(rw http.ResponseWriter, value string) { + cookie := &http.Cookie{ + Name: cookieOAuthState, + Value: value, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + } + if value == "" { + cookie.MaxAge = -1 + } + http.SetCookie(rw, cookie) +} + +func (mw *Middleware) redirectToProvider(rw http.ResponseWriter, r *http.Request) { + randBuf := make([]byte, 16) + _, err := rand.Read(randBuf) + if err != nil { + http.Error(rw, "rand.Read in redirectToProvider", http.StatusInternalServerError) + return + } + + state := hex.EncodeToString(randBuf) + mw.setStateCookie(rw, state) + http.Redirect(rw, r, mw.oauth2Config().AuthCodeURL(state), http.StatusFound) +} + +func (mw *Middleware) handleLoginzRequest(rw http.ResponseWriter, r *http.Request) { + stateCookie, err := r.Cookie(cookieOAuthState) + if err != nil { + http.Error(rw, "fup_state cookie missing or invalid", http.StatusBadRequest) + return + } + + stateQuery := r.URL.Query().Get("state") + if stateQuery == "" { + http.Error(rw, "state query param missing or invalid", http.StatusBadRequest) + return + } + if stateQuery != stateCookie.Value { + http.Error(rw, "state mismatch", http.StatusBadRequest) + return + } + + mw.setStateCookie(rw, "") // Clear state cookie. + + // State is valid. + ctx := r.Context() + oauth2Token, err := mw.oauth2Config().Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(rw, "code invalid", http.StatusBadRequest) + return + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(rw, "missing id_token", http.StatusBadRequest) + return + } + + idToken, err := mw.verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(rw, "id_token invalid", http.StatusBadRequest) + return + } + + var claims struct { + Username string `json:"preferred_username"` + Sub string `json:"sub"` + } + if err := idToken.Claims(&claims); err != nil { + http.Error(rw, "id_token claims invalid", http.StatusBadRequest) + return + } + log.Printf("%s (%s) logged in with token %s", claims.Username, claims.Sub, rawIDToken) + + // Set the cookie. + http.SetCookie(rw, &http.Cookie{ + Name: cookieToken, + Value: rawIDToken, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(rw, r, mw.baseURL, http.StatusFound) +} + +func (mw *Middleware) Middleware(handler http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/loginz") { + // We're up. + mw.handleLoginzRequest(rw, r) + return + } + + ctx := r.Context() + authnErr := mw.isAuthenticated(r) + switch { + case authnErr == nil: + // Authn successful. + handler.ServeHTTP(rw, r) + return + case authnErr == errNoCredentials && !fuphttp.IsMutate(ctx): + // No credentials provided, but none needed. + handler.ServeHTTP(rw, r) + return + case authnErr == errNoCredentials: + // No credentials provided, but required. + if fuphttp.IsAPIRequest(ctx) { + http.Error(rw, "need OAuth credentials", http.StatusUnauthorized) + } else { + mw.redirectToProvider(rw, r) + } + return + case authnErr != nil && !fuphttp.IsAPIRequest(ctx): + // Credential validation error. + log.Printf("invalid OIDC credential: %v", authnErr) + if fuphttp.IsAPIRequest(ctx) { + http.Error(rw, "oauth credentials invalid", http.StatusUnauthorized) + } else { + // try redirecting them through the login loop. + mw.redirectToProvider(rw, r) + } + return + default: + // No credentials + http.Error(rw, "request slipped through the auth cracks :(", http.StatusInternalServerError) + return + } + }) +} diff --git a/web/fup/go.mod b/web/fup/go.mod index 89cb9f7102..76dfbc525a 100644 --- a/web/fup/go.mod +++ b/web/fup/go.mod @@ -7,6 +7,7 @@ module hg.lukegb.com/lukegb/depot/web/fup go 1.16 require ( + github.com/coreos/go-oidc/v3 v3.0.0 // indirect github.com/coreos/go-systemd/v22 v22.3.0 github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 github.com/gabriel-vasile/mimetype v1.2.0 @@ -15,5 +16,6 @@ require ( github.com/spf13/cobra v1.1.3 github.com/spf13/viper v1.7.1 gocloud.dev v0.22.0 + golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 // indirect golang.org/x/text v0.3.5 // indirect ) diff --git a/web/fup/go.sum b/web/fup/go.sum index bf1b8e90d0..a7eef3dcf0 100644 --- a/web/fup/go.sum +++ b/web/fup/go.sum @@ -103,6 +103,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc/v3 v3.0.0 h1:/mAA0XMgYJw2Uqm7WKGCsKnjitE/+A0FFbOmiRJm7LQ= +github.com/coreos/go-oidc/v3 v3.0.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -408,6 +410,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -468,6 +471,7 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -489,6 +493,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201203001011-0b49973bad19 h1:ZD+2Sd/BnevwJp8PSli8WgGAGzb9IZtxBsv1iZMYeEA= golang.org/x/oauth2 v0.0.0-20201203001011-0b49973bad19/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc= +golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -720,6 +726,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=