From f6e68fcfabe4d9716281c5f20089b31ee5db6a14 Mon Sep 17 00:00:00 2001 From: Luiz Vasconcelos Date: Sat, 26 Oct 2024 12:27:18 +0200 Subject: [PATCH] v0.1.0 --- .gitignore | 23 +++++++ .idea/.gitignore | 8 +++ .idea/GitlabLint.xml | 11 ++++ .idea/cauth.iml | 9 +++ .idea/modules.xml | 8 +++ .idea/vcs.xml | 6 ++ auth.go | 49 ++++++++++++++ go.mod | 28 ++++++++ go.sum | 61 +++++++++++++++++ handlers.go | 152 +++++++++++++++++++++++++++++++++++++++++++ middleware.go | 77 ++++++++++++++++++++++ session.go | 61 +++++++++++++++++ 12 files changed, 493 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/GitlabLint.xml create mode 100644 .idea/cauth.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 auth.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers.go create mode 100644 middleware.go create mode 100644 session.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78f591a --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/GitlabLint.xml b/.idea/GitlabLint.xml new file mode 100644 index 0000000..5f731e1 --- /dev/null +++ b/.idea/GitlabLint.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/.idea/cauth.iml b/.idea/cauth.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/cauth.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..181b7d8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..202bae7 --- /dev/null +++ b/auth.go @@ -0,0 +1,49 @@ +package cauth + +import ( + "context" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type Params struct { + ClientID string + ClientSecret string + RedirectURL string + AWSRegion string + UserPoolID string +} + +type Authenticator struct { + Provider *oidc.Provider + OAuth2Config *oauth2.Config + Verifier *oidc.IDTokenVerifier +} + +func NewAuthenticator(ctx context.Context, params Params) (*Authenticator, error) { + issuer := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", params.AWSRegion, params.UserPoolID) + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to get provider: %v", err) + } + + oauth2Config := &oauth2.Config{ + ClientID: params.ClientID, + ClientSecret: params.ClientSecret, + RedirectURL: params.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: params.ClientID, + }) + + return &Authenticator{ + Provider: provider, + OAuth2Config: oauth2Config, + Verifier: verifier, + }, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ca80e5 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module luiz.tec.br/cauth + +go 1.23 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + github.com/gorilla/sessions v1.4.0 + github.com/lestrrat-go/jwx v1.2.30 + github.com/rbcervilla/redisstore/v9 v9.0.0 + github.com/redis/go-redis/v9 v9.7.0 + golang.org/x/oauth2 v0.23.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/crypto v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ad5a3 --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +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/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/lestrrat-go/backoff/v2 v2.0.8 h1:oNb5E5isby2kiro9AgdHLv5N5tint1AnDVVf2E2un5A= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.30 h1:VKIFrmjYn0z2J51iLPadqoHIVLzvWNa1kCsTqNDHYPA= +github.com/lestrrat-go/jwx v1.2.30/go.mod h1:vMxrwFhunGZ3qddmfmEm2+uced8MSI6QFWGTKygjSzQ= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rbcervilla/redisstore/v9 v9.0.0 h1:wOPbBaydbdxzi1gTafDftCI/Z7vnsXw0QDPCuhiMG0g= +github.com/rbcervilla/redisstore/v9 v9.0.0/go.mod h1:q/acLpoKkTZzIsBYt0R4THDnf8W/BH6GjQYvxDSSfdI= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..55e904d --- /dev/null +++ b/handlers.go @@ -0,0 +1,152 @@ +package cauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "log" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type Handlers struct { + oauth2Config *oauth2.Config + session SessionStorer + verifier *oidc.IDTokenVerifier +} + +func NewHandler(oauth2Config *oauth2.Config, session SessionStorer, verifier *oidc.IDTokenVerifier) (*Handlers, error) { + return &Handlers{ + oauth2Config: oauth2Config, + session: session, + verifier: verifier, + }, nil +} + +type UserClaims struct { + Email string `json:"email"` + Verified bool `json:"email_verified"` + Name string `json:"given_name"` + Username string `json:"cognito:username"` + Picture string `json:"picture"` +} + +func generateState() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func (h *Handlers) SignIn(w http.ResponseWriter, r *http.Request) { + state, err := generateState() + if err != nil { + log.Println("Failed to generate state") + http.Error(w, "Something went wrong", http.StatusInternalServerError) + return + } + + session, err := h.session.Get(r) + if err != nil { + http.Error(w, "Failed to get session", http.StatusInternalServerError) + return + } + session.Values["state"] = state + err = session.Save(r, w) + if err != nil { + http.Error(w, "Failed to save session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, h.oauth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline), http.StatusFound) +} + +// CallbackHandler handles the OAuth2 callback from Cognito +func (h *Handlers) CallbackHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + + session, err := h.session.Get(r) + if err != nil { + http.Error(w, "Failed to get session", http.StatusInternalServerError) + return + } + + state, ok := session.Values["state"].(string) + if !ok || state != r.URL.Query().Get("state") { + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "Code not found", http.StatusBadRequest) + return + } + + oauth2Token, err := h.oauth2Config.Exchange(ctx, code) + if err != nil { + log.Printf("Failed to exchange token: %v", err) + http.Error(w, "Failed to exchange token", http.StatusInternalServerError) + return + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + http.Error(w, "No id_token field in oauth2 token", http.StatusInternalServerError) + return + } + + idToken, err := h.verifier.Verify(ctx, rawIDToken) + if err != nil { + log.Printf("Failed to verify ID Token: %v", err) + http.Error(w, "Failed to verify ID Token", http.StatusInternalServerError) + return + } + + var claims UserClaims + + if err := idToken.Claims(&claims); err != nil { + log.Printf("Failed to parse claims: %v", err) + http.Error(w, "Failed to parse claims", http.StatusInternalServerError) + return + } + + session.Values["access_token"] = oauth2Token.AccessToken + session.Values["user_info"] = claims + + fmt.Println(claims) + session.Options.MaxAge = int(oauth2Token.Expiry.Sub(time.Now()).Seconds()) + err = session.Save(r, w) + if err != nil { + log.Printf("Failed to save session: %v", err) + http.Error(w, "Failed to save session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +// LogoutHandler clears the session and logs the user out +func (h *Handlers) LogoutHandler(w http.ResponseWriter, r *http.Request) { + session, err := h.session.Get(r) + if err != nil { + http.Error(w, "Failed to get session", http.StatusInternalServerError) + return + } + + session.Options.MaxAge = -1 + err = session.Save(r, w) + if err != nil { + log.Printf("Failed to clear session: %v", err) + http.Error(w, "Failed to clear session", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..a0568a5 --- /dev/null +++ b/middleware.go @@ -0,0 +1,77 @@ +package cauth + +import ( + "context" + "fmt" + "github.com/lestrrat-go/jwx/jwk" + "log" + "net/http" +) + +type contextKey string + +const userContextKey = contextKey("user") + +type Middleware struct { + s SessionStorer + ck jwk.Set +} + +func NewMiddleware(s SessionStorer, cognitoUrl string) *Middleware { + cognitoKeySet, err := jwk.Fetch(context.Background(), cognitoUrl) + if err != nil { + log.Fatal(err) + } + return &Middleware{s, cognitoKeySet} +} + +func (m *Middleware) AddUserInfo(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := m.s.Get(r) + if err != nil { + next.ServeHTTP(w, r) + return + } + + token := session.Values["access_token"] + + if token == "" || token == nil { + next.ServeHTTP(w, r) + return + } + userInfo := session.Values["user_info"] + fmt.Println(userInfo) + + ctx := context.WithValue(r.Context(), userContextKey, userInfo) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (m *Middleware) ProtectedRoute(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := m.s.Get(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + token := session.Values["access_token"] + + if token == "" || token == nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + next.ServeHTTP(w, r) + }) +} + +func GetUserFromContext(r *http.Request) *UserClaims { + userOptional := r.Context().Value(userContextKey) + if userOptional != nil { + user := userOptional.(UserClaims) + return &user + } + + return &UserClaims{} +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..1afd695 --- /dev/null +++ b/session.go @@ -0,0 +1,61 @@ +package cauth + +import ( + "context" + "encoding/gob" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/sessions" + "github.com/rbcervilla/redisstore/v9" + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" + "log" + "net/http" +) + +const SESSION_NAME = "auth-session" + +type RedisSession struct { + store *redisstore.RedisStore +} + +type RedisSessionParams struct { + RedisAddress string + RedisPassword string + //SessionSecret []byte +} + +type SessionStorer interface { + Get(r *http.Request) (*sessions.Session, error) +} + +func NewRedisSessionStore(params RedisSessionParams) (SessionStorer, error) { + gob.Register(&oauth2.Token{}) + gob.Register(oidc.IDToken{}) + gob.Register(UserClaims{}) + client := redis.NewClient(&redis.Options{ + Addr: params.RedisAddress, + Password: params.RedisPassword, + }) + + store, err := redisstore.NewRedisStore(context.Background(), client) + if err != nil { + log.Fatal("failed to create redis store: ", err) + } + + store.KeyPrefix("session_") + store.Options(sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + return &RedisSession{ + store: store, + }, nil +} + +func (s *RedisSession) Get(r *http.Request) (*sessions.Session, error) { + return s.store.Get(r, SESSION_NAME) +}