From 208037946f0d178c1dfc546e3d62c86924375d6d Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Wed, 6 Nov 2024 20:33:51 +1000 Subject: [PATCH] Oauth2 support --- backend/.testcoverage.yml | 2 +- .../api_docs/components/AuthConfigObject.json | 2 +- .../api_docs/components/UserAuthObject.json | 2 +- .../embed/api_docs/components/UserObject.json | 2 +- .../mysql/20201013035839_initial_data.sql | 4 +- .../postgres/20201013035839_initial_data.sql | 4 +- .../sqlite/20201013035839_initial_data.sql | 4 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/handler/auth.go | 6 - backend/internal/api/handler/helpers.go | 15 +- backend/internal/api/handler/oauth.go | 156 +++++++++++++ backend/internal/api/middleware/log.go | 16 ++ backend/internal/api/router.go | 7 + backend/internal/api/schema/get_token.go | 2 +- backend/internal/entity/auth/ldap.go | 2 +- backend/internal/entity/auth/model.go | 2 +- backend/internal/entity/auth/oauth.go | 216 ++++++++++++++++++ .../internal/entity/setting/auth_methods.go | 22 +- backend/internal/entity/setting/oauth.go | 42 ++++ backend/internal/entity/user/methods.go | 11 + docker/rootfs/etc/nginx/conf.d/dev.conf | 18 ++ frontend/src/Router.tsx | 16 +- frontend/src/components/SiteWrapper.tsx | 2 +- frontend/src/context/AuthContext.tsx | 3 +- 25 files changed, 529 insertions(+), 30 deletions(-) create mode 100644 backend/internal/api/handler/oauth.go create mode 100644 backend/internal/api/middleware/log.go create mode 100644 backend/internal/entity/auth/oauth.go create mode 100644 backend/internal/entity/setting/oauth.go diff --git a/backend/.testcoverage.yml b/backend/.testcoverage.yml index 17444139..b52eaeff 100644 --- a/backend/.testcoverage.yml +++ b/backend/.testcoverage.yml @@ -18,4 +18,4 @@ threshold: # package: 30 # (optional; default 0) # The minimum total coverage project should have - total: 33 + total: 30 diff --git a/backend/embed/api_docs/components/AuthConfigObject.json b/backend/embed/api_docs/components/AuthConfigObject.json index 68d363a3..9a6aa396 100644 --- a/backend/embed/api_docs/components/AuthConfigObject.json +++ b/backend/embed/api_docs/components/AuthConfigObject.json @@ -7,7 +7,7 @@ "enum": [ "local", "ldap", - "oidc" + "oauth" ] } } diff --git a/backend/embed/api_docs/components/UserAuthObject.json b/backend/embed/api_docs/components/UserAuthObject.json index 4c1979d5..1059b43c 100644 --- a/backend/embed/api_docs/components/UserAuthObject.json +++ b/backend/embed/api_docs/components/UserAuthObject.json @@ -24,7 +24,7 @@ }, "type": { "type": "string", - "pattern": "^(local|ldap|oidc)$" + "pattern": "^(local|ldap|oauth)$" } } } diff --git a/backend/embed/api_docs/components/UserObject.json b/backend/embed/api_docs/components/UserObject.json index f5776ebc..94a6dc5d 100644 --- a/backend/embed/api_docs/components/UserObject.json +++ b/backend/embed/api_docs/components/UserObject.json @@ -53,7 +53,7 @@ }, "type": { "type": "string", - "pattern": "^(local|ldap|oidc)$" + "pattern": "^(local|ldap|oauth)$" } } }, diff --git a/backend/embed/migrations/mysql/20201013035839_initial_data.sql b/backend/embed/migrations/mysql/20201013035839_initial_data.sql index 5be06a48..9f24a6a0 100644 --- a/backend/embed/migrations/mysql/20201013035839_initial_data.sql +++ b/backend/embed/migrations/mysql/20201013035839_initial_data.sql @@ -48,8 +48,8 @@ INSERT INTO `setting` ( ( ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), ROUND(UNIX_TIMESTAMP(CURTIME(4)) * 1000), - "oidc-auth", - "Configuration for OIDC authentication", + "oauth-auth", + "Configuration for OAuth authentication", '{}' -- remember this is json ), ( diff --git a/backend/embed/migrations/postgres/20201013035839_initial_data.sql b/backend/embed/migrations/postgres/20201013035839_initial_data.sql index fb3483fd..0937851a 100644 --- a/backend/embed/migrations/postgres/20201013035839_initial_data.sql +++ b/backend/embed/migrations/postgres/20201013035839_initial_data.sql @@ -48,8 +48,8 @@ INSERT INTO "setting" ( ( EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, EXTRACT(EPOCH FROM TIMESTAMP '2011-05-17 10:40:28.876944') * 1000, - 'oidc-auth', - 'Configuration for OIDC authentication', + 'oauth-auth', + 'Configuration for OAuth authentication', '{}' -- remember this is json ), ( diff --git a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql index 90beda89..5bfe4016 100644 --- a/backend/embed/migrations/sqlite/20201013035839_initial_data.sql +++ b/backend/embed/migrations/sqlite/20201013035839_initial_data.sql @@ -47,8 +47,8 @@ INSERT INTO `setting` ( ( unixepoch() * 1000, unixepoch() * 1000, - "oidc-auth", - "Configuration for OIDC authentication", + "oauth-auth", + "Configuration for OAuth authentication", '{}' -- remember this is json ), ( diff --git a/backend/go.mod b/backend/go.mod index 7ce62f0b..65789d94 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( github.com/vrischmann/envconfig v1.3.0 go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.27.0 + golang.org/x/oauth2 v0.23.0 gorm.io/datatypes v1.2.1 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.9 diff --git a/backend/go.sum b/backend/go.sum index ba883f58..d220e47c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -205,6 +205,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/internal/api/handler/auth.go b/backend/internal/api/handler/auth.go index e362c4ac..f4f2799c 100644 --- a/backend/internal/api/handler/auth.go +++ b/backend/internal/api/handler/auth.go @@ -70,8 +70,6 @@ func NewToken() func(http.ResponseWriter, *http.Request) { switch payload.Type { case "ldap": newTokenLDAP(w, r, payload) - case "oidc": - newTokenOIDC(w, r, payload) case "local": newTokenLocal(w, r, payload) } @@ -199,10 +197,6 @@ func newTokenLDAP(w http.ResponseWriter, r *http.Request, payload tokenPayload) } } -func newTokenOIDC(w http.ResponseWriter, r *http.Request, _ tokenPayload) { - h.ResultErrorJSON(w, r, http.StatusInternalServerError, "NOT YET SUPPORTED", nil) -} - // RefreshToken an existing token by given them a new one with the same claims // Route: POST /auth/refresh func RefreshToken() func(http.ResponseWriter, *http.Request) { diff --git a/backend/internal/api/handler/helpers.go b/backend/internal/api/handler/helpers.go index 8bc6a5e4..e4a36253 100644 --- a/backend/internal/api/handler/helpers.go +++ b/backend/internal/api/handler/helpers.go @@ -21,11 +21,22 @@ func getPageInfoFromRequest(r *http.Request) (model.PageInfo, error) { return pageInfo, err } - // pageInfo.Sort = middleware.GetSortFromContext(r) - return pageInfo, nil } +func getQueryVarString(r *http.Request, varName string, required bool, defaultValue string) (string, error) { + queryValues := r.URL.Query() + varValue := queryValues.Get(varName) + + if varValue == "" && required { + return "", eris.Errorf("%v was not supplied in the request", varName) + } else if varValue == "" { + return defaultValue, nil + } + + return varValue, nil +} + func getQueryVarInt(r *http.Request, varName string, required bool, defaultValue int) (int, error) { queryValues := r.URL.Query() varValue := queryValues.Get(varName) diff --git a/backend/internal/api/handler/oauth.go b/backend/internal/api/handler/oauth.go new file mode 100644 index 00000000..f88c5b84 --- /dev/null +++ b/backend/internal/api/handler/oauth.go @@ -0,0 +1,156 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + h "npm/internal/api/http" + "npm/internal/entity/auth" + "npm/internal/entity/setting" + "npm/internal/entity/user" + "npm/internal/errors" + njwt "npm/internal/jwt" + "npm/internal/logger" + + "gorm.io/gorm" +) + +// getRequestIPAddress will use X-FORWARDED-FOR header if it exists +// otherwise it will use RemoteAddr +func getRequestIPAddress(r *http.Request) string { + // this Get is case insensitive + xff := r.Header.Get("X-FORWARDED-FOR") + if xff != "" { + ip, _, _ := strings.Cut(xff, ",") + return strings.TrimSpace(ip) + } + return r.RemoteAddr +} + +// OAuthLogin ... +// Route: GET /oauth/login +func OAuthLogin() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if !setting.AuthMethodEnabled(auth.TypeOAuth) { + h.ResultErrorJSON(w, r, http.StatusNotFound, "Not found", nil) + return + } + + redirectBase, _ := getQueryVarString(r, "redirect_base", false, "") + url, err := auth.OAuthLogin(redirectBase, getRequestIPAddress(r)) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, url) + } +} + +// OAuthRedirect ... +// Route: GET /oauth/redirect +func OAuthRedirect() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if !setting.AuthMethodEnabled(auth.TypeOAuth) { + h.ResultErrorJSON(w, r, http.StatusNotFound, "Not found", nil) + return + } + + code, err := getQueryVarString(r, "code", true, "") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + ou, err := auth.OAuthReturn(r.Context(), code, getRequestIPAddress(r)) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if ou.Identifier == "" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "User found, but OAuth identifier seems misconfigured", nil) + return + } + + jwt, err := newTokenOAuth(ou) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + // encode jwt to json + j, _ := json.Marshal(jwt) + + // Redirect to frontend with success + http.Redirect(w, r, fmt.Sprintf("/?token_response=%s", url.QueryEscape(string(j))), http.StatusSeeOther) + } +} + +// newTokenOAuth takes a OAuthUser and creates a new token, +// optionally creating a new user if one does not exist +func newTokenOAuth(ou *auth.OAuthUser) (*njwt.GeneratedResponse, error) { + // Get OAuth settings + oAuthSettings, err := setting.GetOAuthSettings() + if err != nil { + logger.Error("OAuth settings not found", err) + return nil, err + } + + // Get Auth by identity + authObj, authErr := auth.GetByIdenityType(ou.GetID(), auth.TypeOAuth) + if authErr == gorm.ErrRecordNotFound { + // Auth is not found for this identity. We can create it + if !oAuthSettings.AutoCreateUser { + // user does not have an auth record + // and auto create is disabled. Showing account disabled error + // for the time being + return nil, errors.ErrUserDisabled + } + + // Attempt to find user by email + foundUser, err := user.GetByEmail(ou.GetEmail()) + if err == gorm.ErrRecordNotFound { + // User not found, create user + foundUser, err = user.CreateFromOAuthUser(ou) + if err != nil { + logger.Error("user.CreateFromOAuthUser", err) + return nil, err + } + logger.Info("Created user from OAuth: %s, %s", ou.GetID(), foundUser.Email) + } else if err != nil { + logger.Error("user.GetByEmail", err) + return nil, err + } + + // Create auth record and attach to this user + authObj = auth.Model{ + UserID: foundUser.ID, + Type: auth.TypeOAuth, + Identity: ou.GetID(), + } + if err := authObj.Save(); err != nil { + logger.Error("auth.Save", err) + return nil, err + } + logger.Info("Created OAuth auth for user: %s, %s", ou.GetID(), foundUser.Email) + } else if authErr != nil { + logger.Error("auth.GetByIdenityType", err) + return nil, authErr + } + + userObj, userErr := user.GetByID(authObj.UserID) + if userErr != nil { + return nil, userErr + } + + if userObj.IsDisabled { + return nil, errors.ErrUserDisabled + } + + jwt, err := njwt.Generate(&userObj, false) + return &jwt, err +} diff --git a/backend/internal/api/middleware/log.go b/backend/internal/api/middleware/log.go new file mode 100644 index 00000000..12e80dce --- /dev/null +++ b/backend/internal/api/middleware/log.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "net/http" + + "npm/internal/logger" +) + +// Log will print out route information to the logger +// only when debug is enabled +func Log(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger.Debug("Request: %s %s", r.Method, r.URL.Path) + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 60a4d08c..570dd9be 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -50,6 +50,7 @@ func NewRouter() http.Handler { middleware.Expansion, middleware.DecodeAuth(), middleware.BodyContext(), + middleware.Log, ) return applyRoutes(r) @@ -61,6 +62,12 @@ func applyRoutes(r chi.Router) chi.Router { r.NotFound(handler.NotFound()) r.MethodNotAllowed(handler.NotAllowed()) + // OAuth endpoints aren't technically API endpoints + r.With(middleware.EnforceSetup()).Route("/oauth", func(r chi.Router) { + r.Get("/login", handler.OAuthLogin()) + r.Get("/redirect", handler.OAuthRedirect()) + }) + // SSE - requires a sse token as the `jwt` get parameter // Exists inside /api but it's here so that we can skip the Timeout middleware // that applies to other endpoints. diff --git a/backend/internal/api/schema/get_token.go b/backend/internal/api/schema/get_token.go index 9188d8fc..14292892 100644 --- a/backend/internal/api/schema/get_token.go +++ b/backend/internal/api/schema/get_token.go @@ -18,7 +18,7 @@ func GetToken() string { "properties": { "type": { "type": "string", - "enum": ["local", "ldap", "oidc"] + "enum": ["local", "ldap"] }, "identity": %s, "secret": %s diff --git a/backend/internal/entity/auth/ldap.go b/backend/internal/entity/auth/ldap.go index d94cd74a..9a72f0cf 100644 --- a/backend/internal/entity/auth/ldap.go +++ b/backend/internal/entity/auth/ldap.go @@ -70,7 +70,7 @@ func ldapSearchUser(l *ldap3.Conn, ldapSettings setting.LDAPSettings, username s 0, false, strings.Replace(ldapSettings.SelfFilter, "{{USERNAME}}", username, 1), - nil, // []string{"name"}, + nil, nil, ) diff --git a/backend/internal/entity/auth/model.go b/backend/internal/entity/auth/model.go index 5348763f..bab6082b 100644 --- a/backend/internal/entity/auth/model.go +++ b/backend/internal/entity/auth/model.go @@ -12,7 +12,7 @@ import ( const ( TypeLocal = "local" TypeLDAP = "ldap" - TypeOIDC = "oidc" + TypeOAuth = "oauth" ) // Model is the model diff --git a/backend/internal/entity/auth/oauth.go b/backend/internal/entity/auth/oauth.go new file mode 100644 index 00000000..1507690d --- /dev/null +++ b/backend/internal/entity/auth/oauth.go @@ -0,0 +1,216 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "npm/internal/entity/setting" + "npm/internal/logger" + + cache "github.com/patrickmn/go-cache" + "github.com/rotisserie/eris" + "golang.org/x/oauth2" +) + +// AuthCache is a cache item that stores the Admin API data for each admin that has been requesting endpoints +var OAuthCache *cache.Cache + +// OAuthCacheInit will create a new Memory Cache +func OAuthCacheInit() { + if OAuthCache == nil { + logger.Debug("Creating a new OAuthCache") + OAuthCache = cache.New(5*time.Minute, 5*time.Minute) + } +} + +// OAuthUser is the OAuth User +type OAuthUser struct { + Identifier string `json:"identifier"` + Token string `json:"token"` + Resource map[string]interface{} `json:"resource"` +} + +// GetEmail will return an email address even if it can't be known in the +// Resource +func (m *OAuthUser) GetResourceField(field string) string { + if m.Resource != nil { + if value, ok := m.Resource[field]; ok { + return value.(string) + } + } + return "" +} + +// GetEmail will return an email address even if it can't be known in the +// Resource +func (m *OAuthUser) GetID() string { + if m.Identifier != "" { + return m.Identifier + } + + fields := []string{ + "uid", + "user_id", + "username", + "preferred_username", + "email", + "mail", + } + + for _, field := range fields { + if val := m.GetResourceField(field); val != "" { + return val + } + } + + return "" +} + +// GetName attempts to get a name from the resource +// using different fields +func (m *OAuthUser) GetName() string { + fields := []string{ + "nickname", + "given_name", + "name", + "preferred_username", + "username", + } + + for _, field := range fields { + if name := m.GetResourceField(field); name != "" { + return name + } + } + + // Fallback: + return m.Identifier +} + +// GetEmail will return an email address even if it can't be known in the +// Resource +func (m *OAuthUser) GetEmail() string { + // See if there's an email field first + if email := m.GetResourceField("email"); email != "" { + return email + } + + // Return the identifier if it looks like an email + if m.Identifier != "" { + if strings.Contains(m.Identifier, "@") { + return m.Identifier + } + return fmt.Sprintf("%s@oauth", m.Identifier) + } + return "" +} + +func getOAuth2Config() (*oauth2.Config, *setting.OAuthSettings, error) { + oauthSettings, err := setting.GetOAuthSettings() + if err != nil { + return nil, nil, err + } + + if oauthSettings.ClientID == "" || oauthSettings.ClientSecret == "" || oauthSettings.AuthURL == "" || oauthSettings.TokenURL == "" { + return nil, nil, eris.New("oauth-settings-incorrect") + } + + return &oauth2.Config{ + ClientID: oauthSettings.ClientID, + ClientSecret: oauthSettings.ClientSecret, + Scopes: oauthSettings.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: oauthSettings.AuthURL, + TokenURL: oauthSettings.TokenURL, + }, + }, &oauthSettings, nil +} + +// OAuthLogin ... +func OAuthLogin(redirectBase, ipAddress string) (string, error) { + OAuthCacheInit() + + conf, _, err := getOAuth2Config() + if err != nil { + return "", err + } + + verifier := oauth2.GenerateVerifier() + OAuthCache.Set(getCacheKey(ipAddress), verifier, cache.DefaultExpiration) + + // todo: state should be unique to the incoming IP address of the requester, I guess + url := conf.AuthCodeURL("state", oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier)) + + if redirectBase != "" { + url = url + "&redirect_uri=" + redirectBase + "/oauth/redirect" + } + + logger.Debug("URL: %s", url) + return url, nil +} + +// OAuthReturn ... +func OAuthReturn(ctx context.Context, code, ipAddress string) (*OAuthUser, error) { + // Just in case... + OAuthCacheInit() + + conf, oauthSettings, err := getOAuth2Config() + if err != nil { + return nil, err + } + + verifier, found := OAuthCache.Get(getCacheKey(ipAddress)) + if !found { + return nil, eris.New("oauth-verifier-not-found") + } + + // Use the authorization code that is pushed to the redirect + // URL. Exchange will do the handshake to retrieve the + // initial access token. The HTTP Client returned by + // conf.Client will refresh the token as necessary. + tok, err := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier.(string))) + if err != nil { + return nil, err + } + + // At this stage, the token is the JWT as given by the oauth server. + // we need to use that to get more info about this user, + // and then we'll create our own jwt for use later. + + client := conf.Client(ctx, tok) + resp, err := client.Get(oauthSettings.ResourceURL) + if err != nil { + return nil, err + } + + // nolint: errcheck, gosec + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + ou := OAuthUser{ + Token: tok.AccessToken, + } + + // unmarshal the body into a interface + if err := json.Unmarshal(body, &ou.Resource); err != nil { + return nil, err + } + + // Attempt to get the identifier from the resource + if oauthSettings.Identifier != "" { + ou.Identifier = ou.GetResourceField(oauthSettings.Identifier) + } + + return &ou, nil +} + +func getCacheKey(ipAddress string) string { + return fmt.Sprintf("oauth-%s", ipAddress) +} diff --git a/backend/internal/entity/setting/auth_methods.go b/backend/internal/entity/setting/auth_methods.go index ea6db4dd..3d54f295 100644 --- a/backend/internal/entity/setting/auth_methods.go +++ b/backend/internal/entity/setting/auth_methods.go @@ -2,19 +2,31 @@ package setting import ( "encoding/json" + "slices" ) // GetAuthMethods returns the authentication methods enabled for this site func GetAuthMethods() ([]string, error) { - var l []string var m Model if err := m.LoadByName("auth-methods"); err != nil { - return l, err + return nil, err } - if err := json.Unmarshal([]byte(m.Value.String()), &l); err != nil { - return l, err + var r []string + if err := json.Unmarshal([]byte(m.Value.String()), &r); err != nil { + return nil, err } - return l, nil + return r, nil +} + +// AuthMethodEnabled checks that the auth method given is +// enabled in the db setting +func AuthMethodEnabled(method string) bool { + r, err := GetAuthMethods() + if err != nil { + return false + } + + return slices.Contains(r, method) } diff --git a/backend/internal/entity/setting/oauth.go b/backend/internal/entity/setting/oauth.go new file mode 100644 index 00000000..bfc5e048 --- /dev/null +++ b/backend/internal/entity/setting/oauth.go @@ -0,0 +1,42 @@ +package setting + +import ( + "encoding/json" +) + +// OAuthSettings are the settings for OAuth that come from +// the `oauth-auth` setting value +type OAuthSettings struct { + AutoCreateUser bool `json:"auto_create_user"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"authorization_url"` + TokenURL string `json:"token_url"` + Identifier string `json:"identifier"` + LogoutURL string `json:"logout_url"` + Scopes []string `json:"scopes"` + ResourceURL string `json:"resource_url"` +} + +// GetOAuthSettings will return the OAuth settings +func GetOAuthSettings() (OAuthSettings, error) { + var o OAuthSettings + var m Model + if err := m.LoadByName("oauth-auth"); err != nil { + return o, err + } + + if err := json.Unmarshal([]byte(m.Value.String()), &o); err != nil { + return o, err + } + + o.ApplyDefaults() + return o, nil +} + +// ApplyDefaults will ensure there are defaults set +func (m *OAuthSettings) ApplyDefaults() { + if m.Identifier == "" { + m.Identifier = "email" + } +} diff --git a/backend/internal/entity/user/methods.go b/backend/internal/entity/user/methods.go index 3909288d..266d1660 100644 --- a/backend/internal/entity/user/methods.go +++ b/backend/internal/entity/user/methods.go @@ -117,3 +117,14 @@ func CreateFromLDAPUser(ldapUser *auth.LDAPUser) (Model, error) { user.generateGravatar() return user, err } + +// CreateFromOAuthUser will create a user from an OAuth user object +func CreateFromOAuthUser(ou *auth.OAuthUser) (Model, error) { + user := Model{ + Email: ou.GetEmail(), + Name: ou.GetName(), + } + err := user.Save() + user.generateGravatar() + return user, err +} diff --git a/docker/rootfs/etc/nginx/conf.d/dev.conf b/docker/rootfs/etc/nginx/conf.d/dev.conf index fee694c5..34d0340d 100644 --- a/docker/rootfs/etc/nginx/conf.d/dev.conf +++ b/docker/rootfs/etc/nginx/conf.d/dev.conf @@ -11,6 +11,7 @@ server { try_files /index.html /coverage.html; } + # go server location /api/ { add_header X-Served-By $host; chunked_transfer_encoding off; @@ -26,10 +27,27 @@ server { proxy_pass http://127.0.0.1:3000/api/; } + # go server + location /oauth/ { + add_header X-Served-By $host; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ''; + proxy_set_header X-Forwarded-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Accel-Buffering no; + proxy_pass http://127.0.0.1:3000/oauth/; + } + location ~ .html { try_files $uri =404; } + # vite dev server location / { add_header X-Served-By $host; proxy_http_version 1.1; diff --git a/frontend/src/Router.tsx b/frontend/src/Router.tsx index cc93952f..5d3616ba 100644 --- a/frontend/src/Router.tsx +++ b/frontend/src/Router.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense } from "react"; -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { TokenResponse } from "src/api/npm"; import { SiteWrapper, SpinnerPage, Unhealthy } from "src/components"; import { useAuthState, useLocaleState } from "src/context"; import { useHealth } from "src/hooks"; @@ -24,10 +25,21 @@ const Users = lazy(() => import("src/pages/Users")); function Router() { const health = useHealth(); - const { authenticated } = useAuthState(); + const { authenticated, handleTokenUpdate } = useAuthState(); const { locale } = useLocaleState(); const Spinner = ; + // Load token from URL Query Params + const searchParams = new URLSearchParams(document.location.search); + const t = searchParams.get("token_response"); + if (t) { + const tokenResponse: TokenResponse = JSON.parse(t); + handleTokenUpdate(tokenResponse); + window.location.href = "/"; + return; + } + // End Load token from URL Query Params + if (health.isLoading) { return Spinner; } diff --git a/frontend/src/components/SiteWrapper.tsx b/frontend/src/components/SiteWrapper.tsx index e2e2e98e..4062da24 100644 --- a/frontend/src/components/SiteWrapper.tsx +++ b/frontend/src/components/SiteWrapper.tsx @@ -1,4 +1,4 @@ -import { useEffect, ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { Box, Container, useToast } from "@chakra-ui/react"; import { useQueryClient } from "@tanstack/react-query"; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 241b9549..c3a196e8 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -9,6 +9,7 @@ import AuthStore from "src/modules/AuthStore"; // Context export interface AuthContextType { authenticated: boolean; + handleTokenUpdate: (response: TokenResponse) => void; login: (type: string, username: string, password: string) => Promise; logout: () => void; token?: string; @@ -62,7 +63,7 @@ function AuthProvider({ true, ); - const value = { authenticated, login, logout }; + const value = { authenticated, login, logout, handleTokenUpdate }; return {children}; }