fup: add fuphttp/fupoidc for doing OpenID Connect authn
This commit is contained in:
parent
bde26b1889
commit
47b43cc022
4 changed files with 246 additions and 1 deletions
|
@ -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.";
|
||||
|
|
235
web/fup/fuphttp/fupoidc/fupoidc.go
Normal file
235
web/fup/fuphttp/fupoidc/fupoidc.go
Normal file
|
@ -0,0 +1,235 @@
|
|||
// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
|
||||
//
|
||||
// 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 <token>" 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
||||
|
|
Loading…
Reference in a new issue