diff --git a/server/src/api/router.go b/server/src/api/router.go index 9d66fe1e21..83d72063a5 100644 --- a/server/src/api/router.go +++ b/server/src/api/router.go @@ -1,6 +1,8 @@ package api import ( + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/markbates/goth/gothic" "net/http" "os" @@ -8,7 +10,6 @@ import ( "github.com/go-chi/cors" "github.com/go-chi/httprate" - "github.com/go-chi/jwtauth/v5" "github.com/go-chi/render" "github.com/google/uuid" gorillaSessions "github.com/gorilla/sessions" @@ -18,9 +19,6 @@ import ( "scrumlr.io/server/logger" "scrumlr.io/server/realtime" "scrumlr.io/server/services" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" ) type Server struct { @@ -171,7 +169,7 @@ func (s *Server) publicRoutes(r chi.Router) chi.Router { func (s *Server) protectedRoutes(r chi.Router) { r.Group(func(r chi.Router) { r.Use(s.auth.Verifier()) - r.Use(jwtauth.Authenticator) + r.Use(s.auth.Authenticator()) r.Use(auth.AuthContext) r.With(s.BoardTemplateRateLimiter).Post("/templates", s.createBoardTemplate) diff --git a/server/src/auth/auth.go b/server/src/auth/auth.go index ba3757a17b..3f43158058 100644 --- a/server/src/auth/auth.go +++ b/server/src/auth/auth.go @@ -1,289 +1,294 @@ package auth import ( - "crypto/ecdsa" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "io" - "math" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/jwtauth/v5" - "github.com/google/uuid" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/markbates/goth" - "github.com/markbates/goth/gothic" - "github.com/markbates/goth/providers/apple" - "github.com/markbates/goth/providers/azureadv2" - "github.com/markbates/goth/providers/github" - "github.com/markbates/goth/providers/google" - "github.com/markbates/goth/providers/microsoftonline" - oidc "github.com/markbates/goth/providers/openidConnect" - "golang.org/x/crypto/ssh" - "scrumlr.io/server/auth/devkeys" - "scrumlr.io/server/common" - "scrumlr.io/server/database" - "scrumlr.io/server/database/types" - "scrumlr.io/server/logger" + "crypto/ecdsa" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "math" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/apple" + "github.com/markbates/goth/providers/azureadv2" + "github.com/markbates/goth/providers/github" + "github.com/markbates/goth/providers/google" + "github.com/markbates/goth/providers/microsoftonline" + oidc "github.com/markbates/goth/providers/openidConnect" + "golang.org/x/crypto/ssh" + "scrumlr.io/server/auth/devkeys" + "scrumlr.io/server/common" + "scrumlr.io/server/database" + "scrumlr.io/server/database/types" + "scrumlr.io/server/logger" ) type Auth interface { - Sign(map[string]interface{}) (string, error) - Verifier() func(http.Handler) http.Handler - Exists(accountType types.AccountType) bool - ExtractUserInformation(types.AccountType, *goth.User) (*UserInformation, error) + Sign(map[string]interface{}) (string, error) + Verifier() func(http.Handler) http.Handler + Authenticator() func(http.Handler) http.Handler + Exists(accountType types.AccountType) bool + ExtractUserInformation(types.AccountType, *goth.User) (*UserInformation, error) } type AuthProviderConfiguration struct { - TenantId string - ClientId string - ClientSecret string - RedirectUri string - DiscoveryUri string - UserIdentScope string - UserNameScope string + TenantId string + ClientId string + ClientSecret string + RedirectUri string + DiscoveryUri string + UserIdentScope string + UserNameScope string } type AuthConfiguration struct { - providers map[string]AuthProviderConfiguration - unsafePrivateKey string - privateKey string - unsafeAuth *jwtauth.JWTAuth - auth *jwtauth.JWTAuth - database *database.Database + providers map[string]AuthProviderConfiguration + unsafePrivateKey string + privateKey string + unsafeAuth *jwtauth.JWTAuth + auth *jwtauth.JWTAuth + database *database.Database } type UserInformation struct { - Provider types.AccountType - Ident, Name, AvatarURL string + Provider types.AccountType + Ident, Name, AvatarURL string } func NewAuthConfiguration(providers map[string]AuthProviderConfiguration, unsafePrivateKey, privateKey string, database *database.Database) (Auth, error) { - a := new(AuthConfiguration) - a.providers = providers - a.unsafePrivateKey = unsafePrivateKey - a.database = database - a.privateKey = privateKey - if err := a.initializeProviders(); err != nil { - return nil, err - } - if err := a.initializeJWTAuth(); err != nil { - return nil, err - } - - return a, nil + a := new(AuthConfiguration) + a.providers = providers + a.unsafePrivateKey = unsafePrivateKey + a.database = database + a.privateKey = privateKey + if err := a.initializeProviders(); err != nil { + return nil, err + } + if err := a.initializeJWTAuth(); err != nil { + return nil, err + } + + return a, nil } func (a *AuthConfiguration) initializeProviders() error { - providers := []goth.Provider{} - if provider, ok := a.providers[(string)(types.AccountTypeGoogle)]; ok { - p := google.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - "openid", - "profile", - ) - p.SetName(strings.ToLower((string)(types.AccountTypeGoogle))) - providers = append(providers, p) - } - if provider, ok := a.providers[(string)(types.AccountTypeGitHub)]; ok { - p := github.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - "user", - ) - p.SetName(strings.ToLower((string)(types.AccountTypeGitHub))) - providers = append(providers, p) - } - if provider, ok := a.providers[(string)(types.AccountTypeMicrosoft)]; ok { - p := microsoftonline.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - "User.Read", - ) - p.SetName(strings.ToLower((string)(types.AccountTypeMicrosoft))) - providers = append(providers, p) - } - if provider, ok := a.providers[(string)(types.AccountTypeAzureAd)]; ok { - p := azureadv2.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - azureadv2.ProviderOptions{ - Tenant: azureadv2.TenantType(provider.TenantId), - Scopes: []azureadv2.ScopeType{"User.Read"}, - }, - ) - p.SetName(strings.ToLower((string)(types.AccountTypeAzureAd))) - providers = append(providers, p) - } - if provider, ok := a.providers[(string)(types.AccountTypeApple)]; ok { - providers = append(providers, apple.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - nil, - apple.ScopeName, - apple.ScopeEmail, - )) - } - if provider, ok := a.providers[(string)(types.AccountTypeOIDC)]; ok { - p, err := oidc.New( - provider.ClientId, - provider.ClientSecret, - provider.RedirectUri, - provider.DiscoveryUri, - provider.UserIdentScope, - provider.UserNameScope, - ) - if err != nil { - logger.Get().Errorw("OIDC provider setup failed", "error", err) - } - - p.SetName(strings.ToLower((string)(types.AccountTypeOIDC))) - providers = append(providers, p) - } - goth.UseProviders(providers...) - gothic.GetProviderName = func(r *http.Request) (string, error) { - return chi.URLParam(r, "provider"), nil - } - gothic.SetState = func(r *http.Request) string { - nonceBytes := make([]byte, 64) - _, err := io.ReadFull(rand.Reader, nonceBytes) - if err != nil { - panic("gothic: source of randomness unavailable: " + err.Error()) - } - nonce := base64.URLEncoding.EncodeToString(nonceBytes) - - state := r.URL.Query().Get("state") - if len(state) > 0 { - return fmt.Sprintf("%s__%s", nonce, state) - } - - return nonce - } - - return nil + providers := []goth.Provider{} + if provider, ok := a.providers[(string)(types.AccountTypeGoogle)]; ok { + p := google.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + "openid", + "profile", + ) + p.SetName(strings.ToLower((string)(types.AccountTypeGoogle))) + providers = append(providers, p) + } + if provider, ok := a.providers[(string)(types.AccountTypeGitHub)]; ok { + p := github.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + "user", + ) + p.SetName(strings.ToLower((string)(types.AccountTypeGitHub))) + providers = append(providers, p) + } + if provider, ok := a.providers[(string)(types.AccountTypeMicrosoft)]; ok { + p := microsoftonline.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + "User.Read", + ) + p.SetName(strings.ToLower((string)(types.AccountTypeMicrosoft))) + providers = append(providers, p) + } + if provider, ok := a.providers[(string)(types.AccountTypeAzureAd)]; ok { + p := azureadv2.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + azureadv2.ProviderOptions{ + Tenant: azureadv2.TenantType(provider.TenantId), + Scopes: []azureadv2.ScopeType{"User.Read"}, + }, + ) + p.SetName(strings.ToLower((string)(types.AccountTypeAzureAd))) + providers = append(providers, p) + } + if provider, ok := a.providers[(string)(types.AccountTypeApple)]; ok { + providers = append(providers, apple.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + nil, + apple.ScopeName, + apple.ScopeEmail, + )) + } + if provider, ok := a.providers[(string)(types.AccountTypeOIDC)]; ok { + p, err := oidc.New( + provider.ClientId, + provider.ClientSecret, + provider.RedirectUri, + provider.DiscoveryUri, + provider.UserIdentScope, + provider.UserNameScope, + ) + if err != nil { + logger.Get().Errorw("OIDC provider setup failed", "error", err) + } + + p.SetName(strings.ToLower((string)(types.AccountTypeOIDC))) + providers = append(providers, p) + } + goth.UseProviders(providers...) + gothic.GetProviderName = func(r *http.Request) (string, error) { + return chi.URLParam(r, "provider"), nil + } + gothic.SetState = func(r *http.Request) string { + nonceBytes := make([]byte, 64) + _, err := io.ReadFull(rand.Reader, nonceBytes) + if err != nil { + panic("gothic: source of randomness unavailable: " + err.Error()) + } + nonce := base64.URLEncoding.EncodeToString(nonceBytes) + + state := r.URL.Query().Get("state") + if len(state) > 0 { + return fmt.Sprintf("%s__%s", nonce, state) + } + + return nonce + } + + return nil } func (a *AuthConfiguration) Sign(claims map[string]interface{}) (string, error) { - _, token, err := a.auth.Encode(claims) - return token, err + _, token, err := a.auth.Encode(claims) + return token, err } func (a *AuthConfiguration) Verifier() func(http.Handler) http.Handler { - if a.unsafeAuth != nil { - return func(next http.Handler) http.Handler { - hfn := func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var token jwt.Token - var err error - - if token, err = jwtauth.VerifyRequest(a.unsafeAuth, r, jwtauth.TokenFromCookie); err == nil { - // check if user tries to authenticate by a prior authentication key - // attempt to migrate JWT to new key - userID := token.PrivateClaims()["id"].(string) - var user uuid.UUID - user, err = uuid.Parse(userID) - - if err == nil { - var ok bool - if ok, err = a.database.IsUserAvailableForKeyMigration(user); ok { - // prepare new JWT - tokenString, _ := a.Sign(map[string]interface{}{"id": user}) - cookie := http.Cookie{Name: "jwt", Value: tokenString, Path: "/", HttpOnly: true, MaxAge: math.MaxInt32} - common.SealCookie(r, &cookie) - http.SetCookie(w, &cookie) - - // update rotation flag in database for user, ignore errors - _, _ = a.database.SetKeyMigration(user) - } else { - err = errors.New("not permitted to access key rotation") - } - } - } else { - // attempt to verify request by new key - token, err = jwtauth.VerifyRequest(a.auth, r, jwtauth.TokenFromCookie) - } - - ctx = jwtauth.NewContext(ctx, token, err) - next.ServeHTTP(w, r.WithContext(ctx)) - } - return http.HandlerFunc(hfn) - } - } - return jwtauth.Verifier(a.auth) + if a.unsafeAuth != nil { + return func(next http.Handler) http.Handler { + hfn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var token jwt.Token + var err error + + if token, err = jwtauth.VerifyRequest(a.unsafeAuth, r, jwtauth.TokenFromCookie); err == nil { + // check if user tries to authenticate by a prior authentication key + // attempt to migrate JWT to new key + userID := token.PrivateClaims()["id"].(string) + var user uuid.UUID + user, err = uuid.Parse(userID) + + if err == nil { + var ok bool + if ok, err = a.database.IsUserAvailableForKeyMigration(user); ok { + // prepare new JWT + tokenString, _ := a.Sign(map[string]interface{}{"id": user}) + cookie := http.Cookie{Name: "jwt", Value: tokenString, Path: "/", HttpOnly: true, MaxAge: math.MaxInt32} + common.SealCookie(r, &cookie) + http.SetCookie(w, &cookie) + + // update rotation flag in database for user, ignore errors + _, _ = a.database.SetKeyMigration(user) + } else { + err = errors.New("not permitted to access key rotation") + } + } + } else { + // attempt to verify request by new key + token, err = jwtauth.VerifyRequest(a.auth, r, jwtauth.TokenFromCookie) + } + + ctx = jwtauth.NewContext(ctx, token, err) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(hfn) + } + } + return jwtauth.Verifier(a.auth) +} + +func (a *AuthConfiguration) Authenticator() func(http.Handler) http.Handler { + return jwtauth.Authenticator(a.auth) } func (a *AuthConfiguration) Exists(accountType types.AccountType) bool { - if _, ok := a.providers[string(accountType)]; ok { - return true - } - return false + if _, ok := a.providers[string(accountType)]; ok { + return true + } + return false } func (a *AuthConfiguration) ExtractUserInformation(accountType types.AccountType, user *goth.User) (*UserInformation, error) { - ident := user.UserID - name := user.NickName - avatar := user.AvatarURL - - if ident == "" { - return nil, fmt.Errorf("unable to extract identifier information for user") - } - - if name == "" { - name = user.Name - } - - if name == "" { - return nil, fmt.Errorf("unable to extract name information for user %q", ident) - } - - result := &UserInformation{ - Provider: accountType, - Ident: ident, - Name: name, - AvatarURL: avatar, - } - - return result, nil + ident := user.UserID + name := user.NickName + avatar := user.AvatarURL + + if ident == "" { + return nil, fmt.Errorf("unable to extract identifier information for user") + } + + if name == "" { + name = user.Name + } + + if name == "" { + return nil, fmt.Errorf("unable to extract name information for user %q", ident) + } + + result := &UserInformation{ + Provider: accountType, + Ident: ident, + Name: name, + AvatarURL: avatar, + } + + return result, nil } func (a *AuthConfiguration) initializeJWTAuth() error { - if a.privateKey == "" { - logger.Get().Warnw("invalid keypair config, falling back to dev keys!") - a.privateKey = devkeys.PrivateKey - } - - if a.unsafePrivateKey != "" { - unsafeKey, err := ssh.ParseRawPrivateKey([]byte(a.unsafePrivateKey)) - if err != nil { - return fmt.Errorf("unable parse unsafe auth keys: %w", err) - } - unsafePrivateKey, ok := unsafeKey.(*ecdsa.PrivateKey) - if !ok { - return errors.New("the provided unsafe keys are no ecdsa keys") - } - a.unsafeAuth = jwtauth.New("ES512", unsafePrivateKey, unsafePrivateKey.PublicKey) - } - - key, err := ssh.ParseRawPrivateKey([]byte(a.privateKey)) - if err != nil { - return fmt.Errorf("unable to parse auth keys: %w", err) - } - privateKey, ok := key.(*ecdsa.PrivateKey) - if !ok { - return errors.New("the provided keys are no ecdsa keys") - } - - a.auth = jwtauth.New("ES512", privateKey, privateKey.PublicKey) - return nil + if a.privateKey == "" { + logger.Get().Warnw("invalid keypair config, falling back to dev keys!") + a.privateKey = devkeys.PrivateKey + } + + if a.unsafePrivateKey != "" { + unsafeKey, err := ssh.ParseRawPrivateKey([]byte(a.unsafePrivateKey)) + if err != nil { + return fmt.Errorf("unable parse unsafe auth keys: %w", err) + } + unsafePrivateKey, ok := unsafeKey.(*ecdsa.PrivateKey) + if !ok { + return errors.New("the provided unsafe keys are no ecdsa keys") + } + a.unsafeAuth = jwtauth.New("ES512", unsafePrivateKey, unsafePrivateKey.PublicKey) + } + + key, err := ssh.ParseRawPrivateKey([]byte(a.privateKey)) + if err != nil { + return fmt.Errorf("unable to parse auth keys: %w", err) + } + privateKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return errors.New("the provided keys are no ecdsa keys") + } + + a.auth = jwtauth.New("ES512", privateKey, privateKey.PublicKey) + return nil } diff --git a/server/src/go.mod b/server/src/go.mod index 362b4ec90b..fa889c7759 100644 --- a/server/src/go.mod +++ b/server/src/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-chi/chi/v5 v5.2.0 github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.14.1 - github.com/go-chi/jwtauth/v5 v5.1.1 + github.com/go-chi/jwtauth/v5 v5.3.2 github.com/go-chi/render v1.0.3 github.com/go-redis/redis/v8 v8.11.5 github.com/golang-migrate/migrate/v4 v4.18.1 diff --git a/server/src/go.sum b/server/src/go.sum index 67bd7e9168..01c5009ee4 100644 --- a/server/src/go.sum +++ b/server/src/go.sum @@ -68,8 +68,8 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= -github.com/go-chi/jwtauth/v5 v5.1.1 h1:Pjixqu5YkjE9sCLpzE01L0Q4sQzJIPdo7uz9r8ftp/c= -github.com/go-chi/jwtauth/v5 v5.1.1/go.mod h1:CYP1WSbzD4MPuKCr537EM3kfFhSQgpUEtMJFuYJjqWU= +github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs= +github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= diff --git a/src/components/SettingsDialog/Appearance/__tests__/__snapshots__/Appearance.test.tsx.snap b/src/components/SettingsDialog/Appearance/__tests__/__snapshots__/Appearance.test.tsx.snap index 0549d64105..6ab2868bf4 100644 --- a/src/components/SettingsDialog/Appearance/__tests__/__snapshots__/Appearance.test.tsx.snap +++ b/src/components/SettingsDialog/Appearance/__tests__/__snapshots__/Appearance.test.tsx.snap @@ -110,24 +110,6 @@ exports[`Appearance should render all Settings correctly 1`] = ` -
- -