Spaces:
Paused
Paused
| // Copyright 2018 Drone.IO Inc. | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| package api | |
| import ( | |
| "encoding/base32" | |
| "errors" | |
| "fmt" | |
| "net/http" | |
| "net/url" | |
| "strconv" | |
| "time" | |
| "github.com/gin-gonic/gin" | |
| "github.com/rs/zerolog/log" | |
| "github.com/tink-crypto/tink-go/v2/subtle/random" | |
| "go.woodpecker-ci.org/woodpecker/v3/server" | |
| "go.woodpecker-ci.org/woodpecker/v3/server/forge" | |
| forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" | |
| "go.woodpecker-ci.org/woodpecker/v3/server/model" | |
| "go.woodpecker-ci.org/woodpecker/v3/server/store" | |
| "go.woodpecker-ci.org/woodpecker/v3/server/store/types" | |
| "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" | |
| "go.woodpecker-ci.org/woodpecker/v3/shared/token" | |
| "go.woodpecker-ci.org/woodpecker/v3/shared/utils" | |
| ) | |
| const ( | |
| stateTokenDuration = time.Minute * 5 | |
| perPage = 50 | |
| maxPage = 10000 | |
| ) | |
| func HandleAuth(c *gin.Context) { | |
| // TODO: check if this is really needed | |
| c.Writer.Header().Del("Content-Type") | |
| // redirect when getting oauth error from forge to login page | |
| if err := c.Request.FormValue("error"); err != "" { | |
| query := url.Values{} | |
| query.Set("error", err) | |
| if errorDescription := c.Request.FormValue("error_description"); errorDescription != "" { | |
| query.Set("error_description", errorDescription) | |
| } | |
| if errorURI := c.Request.FormValue("error_uri"); errorURI != "" { | |
| query.Set("error_uri", errorURI) | |
| } | |
| c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/login?%s", server.Config.Server.RootPath, query.Encode())) | |
| return | |
| } | |
| _store := store.FromContext(c) | |
| code := c.Request.FormValue("code") | |
| state := c.Request.FormValue("state") | |
| isCallback := code != "" && state != "" | |
| var forgeID int64 | |
| if isCallback { // validate the state token | |
| stateToken, err := token.Parse([]token.Type{token.OAuthStateToken}, state, func(_ *token.Token) (string, error) { | |
| return server.Config.Server.JWTSecret, nil | |
| }) | |
| if err != nil { | |
| log.Error().Err(err).Msg("cannot verify state token") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") | |
| return | |
| } | |
| _forgeID := stateToken.Get("forge-id") | |
| forgeID, err = strconv.ParseInt(_forgeID, 10, 64) | |
| if err != nil { | |
| log.Error().Err(err).Msg("forge-id of state token invalid") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") | |
| return | |
| } | |
| } else { // only generate a state token if not a callback | |
| var err error | |
| _forgeID := c.Request.FormValue("forge_id") | |
| if _forgeID == "" { | |
| forgeID = 1 // fallback to main forge | |
| } else { | |
| forgeID, err = strconv.ParseInt(_forgeID, 10, 64) | |
| if err != nil { | |
| log.Error().Err(err).Msg("forge-id of state token invalid") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") | |
| return | |
| } | |
| } | |
| jwtSecret := server.Config.Server.JWTSecret | |
| exp := time.Now().Add(stateTokenDuration).Unix() | |
| stateToken := token.New(token.OAuthStateToken) | |
| stateToken.Set("forge-id", strconv.FormatInt(forgeID, 10)) | |
| state, err = stateToken.SignExpires(jwtSecret, exp) | |
| if err != nil { | |
| log.Error().Err(err).Msg("cannot create state token") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| } | |
| _forge, err := server.Config.Services.Manager.ForgeByID(forgeID) | |
| if err != nil { | |
| log.Error().Err(err).Msgf("cannot get forge by id %d", forgeID) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| userFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{ | |
| Code: c.Request.FormValue("code"), | |
| State: state, | |
| }) | |
| if err != nil { | |
| log.Error().Err(err).Msg("cannot authenticate user") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=oauth_error") | |
| return | |
| } | |
| // The user is not authorized yet -> redirect | |
| if userFromForge == nil { | |
| http.Redirect(c.Writer, c.Request, redirectURL, http.StatusSeeOther) | |
| return | |
| } | |
| // if organization filter is enabled, we need to check if the user is a member of one | |
| // of the configured organizations | |
| if server.Config.Permissions.Orgs.IsConfigured { | |
| isMember := false | |
| for page := 1; page <= maxPage; page++ { | |
| teams, terr := _forge.Teams(c, userFromForge, &model.ListOptions{ | |
| Page: page, | |
| PerPage: perPage, | |
| }) | |
| if errors.Is(terr, forge_types.ErrNotImplemented) { | |
| log.Debug().Msg("Could not fetch membership of user as forge adapter did not implement it") | |
| } else if terr != nil { | |
| log.Error().Err(terr).Msgf("cannot verify team membership for %s", userFromForge.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| if server.Config.Permissions.Orgs.IsMember(teams) { | |
| isMember = true | |
| break | |
| } | |
| } | |
| if !isMember { | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=org_access_denied") | |
| return | |
| } | |
| } | |
| var user *model.User | |
| // get the user from the database | |
| user, err = _store.GetUserByRemoteID(forgeID, userFromForge.ForgeRemoteID) | |
| if err != nil && !errors.Is(err, types.RecordNotExist) { | |
| log.Error().Err(err).Msgf("cannot get user %s", userFromForge.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| // update user login (in case forge supports renaming) | |
| if user != nil { | |
| user.Login = userFromForge.Login | |
| } | |
| // re-try with login name | |
| if user == nil || errors.Is(err, types.RecordNotExist) { | |
| user, err = _store.GetUserByLogin(forgeID, userFromForge.Login) | |
| if err != nil && !errors.Is(err, types.RecordNotExist) { | |
| log.Error().Err(err).Msgf("cannot get user %s", userFromForge.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| } | |
| if user == nil || errors.Is(err, types.RecordNotExist) { | |
| // if self-registration is disabled we should return a not authorized error | |
| if !server.Config.Permissions.Open && !server.Config.Permissions.Admins.IsAdmin(userFromForge) { | |
| log.Error().Msgf("cannot register %s. registration closed", userFromForge.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=registration_closed") | |
| return | |
| } | |
| // create the user account | |
| user = &model.User{ | |
| ForgeID: forgeID, | |
| ForgeRemoteID: userFromForge.ForgeRemoteID, | |
| Login: userFromForge.Login, | |
| AccessToken: userFromForge.AccessToken, | |
| RefreshToken: userFromForge.RefreshToken, | |
| Expiry: userFromForge.Expiry, | |
| Email: userFromForge.Email, | |
| Avatar: userFromForge.Avatar, | |
| Hash: base32.StdEncoding.EncodeToString( | |
| random.GetRandomBytes(32), | |
| ), | |
| } | |
| // insert the user into the database | |
| if err := _store.CreateUser(user); err != nil { | |
| log.Error().Err(err).Msgf("cannot insert %s", user.Login) | |
| log.Trace().Msgf("user was: %#v", user) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| } | |
| // create or set the user's organization if it isn't linked yet | |
| if user.OrgID == 0 { | |
| // check if an org with the same name exists already and assign it to the user if it does | |
| org, err := _store.OrgFindByName(user.Login, forgeID) | |
| if err != nil && !errors.Is(err, types.RecordNotExist) { | |
| log.Error().Err(err).Msgf("cannot get org for user %s", user.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| // if an org with the same name exists => assign org to the user | |
| if err == nil && org != nil { | |
| org.IsUser = true | |
| user.OrgID = org.ID | |
| if err := _store.OrgUpdate(org); err != nil { | |
| log.Error().Err(err).Msgf("cannot assign user %s to existing org %d", user.Login, org.ID) | |
| } | |
| } | |
| // if still no org with the same name exists => create a new org | |
| if user.OrgID == 0 || errors.Is(err, types.RecordNotExist) { | |
| org := &model.Org{ | |
| Name: user.Login, | |
| IsUser: true, | |
| Private: false, | |
| ForgeID: user.ForgeID, | |
| } | |
| if err := _store.OrgCreate(org); err != nil { | |
| log.Error().Err(err).Msgf("cannot create org for user %s", user.Login) | |
| } | |
| user.OrgID = org.ID | |
| } | |
| } else { | |
| // update org name if necessary | |
| org, err := _store.OrgGet(user.OrgID) | |
| if err != nil { | |
| log.Error().Err(err).Msgf("cannot get org %d for user %s", user.OrgID, user.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| if org != nil && org.Name != user.Login { | |
| org.Name = user.Login | |
| if err := _store.OrgUpdate(org); err != nil { | |
| log.Error().Err(err).Msgf("cannot update org %d name to user name %s", org.ID, user.Login) | |
| } | |
| } | |
| } | |
| // update the user meta data and authorization data. | |
| user.AccessToken = userFromForge.AccessToken | |
| user.RefreshToken = userFromForge.RefreshToken | |
| user.Email = userFromForge.Email | |
| user.Avatar = userFromForge.Avatar | |
| user.ForgeID = forgeID | |
| user.ForgeRemoteID = userFromForge.ForgeRemoteID | |
| user.Login = userFromForge.Login | |
| user.Admin = user.Admin || server.Config.Permissions.Admins.IsAdmin(userFromForge) | |
| if err := _store.UpdateUser(user); err != nil { | |
| log.Error().Err(err).Msgf("cannot update user %s", user.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| exp := time.Now().Add(server.Config.Server.SessionExpires).Unix() | |
| _token := token.New(token.SessToken) | |
| _token.Set("user-id", strconv.FormatInt(user.ID, 10)) | |
| tokenString, err := _token.SignExpires(user.Hash, exp) | |
| if err != nil { | |
| log.Error().Msgf("cannot create token for user %s", user.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| err = updateRepoPermissions(c, user, _store, _forge, forgeID) | |
| if err != nil { | |
| log.Error().Err(err).Msgf("cannot update repo permissions for user %s", user.Login) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") | |
| return | |
| } | |
| httputil.SetCookie(c.Writer, c.Request, "user_sess", tokenString) | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") | |
| } | |
| func updateRepoPermissions(c *gin.Context, user *model.User, _store store.Store, _forge forge.Forge, forgeID int64) error { | |
| repos, err := utils.Paginate(func(page int) ([]*model.Repo, error) { | |
| return _forge.Repos(c, user, &model.ListOptions{ | |
| Page: page, | |
| PerPage: perPage, | |
| }) | |
| }, maxPage) | |
| if err != nil { | |
| return err | |
| } | |
| var repoIDs []int64 | |
| for _, forgeRepo := range repos { | |
| // make sure forgeID is set | |
| forgeRepo.ForgeID = forgeID | |
| dbRepo, err := _store.GetRepoForgeID(forgeID, forgeRepo.ForgeRemoteID) | |
| if err != nil && errors.Is(err, types.RecordNotExist) { | |
| continue | |
| } | |
| if err != nil { | |
| return err | |
| } | |
| if !dbRepo.IsActive { | |
| continue | |
| } | |
| log.Debug().Msgf("synced user permission for user %s and repo %s", user.Login, dbRepo.FullName) | |
| perm := forgeRepo.Perm | |
| perm.RepoID = dbRepo.ID | |
| perm.UserID = user.ID | |
| perm.Synced = time.Now().Unix() | |
| if err := _store.PermUpsert(perm); err != nil { | |
| return err | |
| } | |
| repoIDs = append(repoIDs, dbRepo.ID) | |
| } | |
| if err := _store.PermPrune(user.ID, repoIDs); err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| func GetLogout(c *gin.Context) { | |
| httputil.DelCookie(c.Writer, c.Request, "user_sess") | |
| httputil.DelCookie(c.Writer, c.Request, "user_last") | |
| c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") | |
| } | |