oauth_github_handler.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. package api
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "net/url"
  7. "strconv"
  8. "strings"
  9. "github.com/porter-dev/porter/internal/models"
  10. "gorm.io/gorm"
  11. "github.com/go-chi/chi"
  12. "github.com/google/go-github/github"
  13. "github.com/porter-dev/porter/internal/oauth"
  14. "golang.org/x/oauth2"
  15. "github.com/porter-dev/porter/internal/models/integrations"
  16. )
  17. // HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
  18. func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
  19. state := oauth.CreateRandomState()
  20. err := app.populateOAuthSession(w, r, state, false)
  21. if err != nil {
  22. app.handleErrorDataRead(err, w)
  23. return
  24. }
  25. // specify access type offline to get a refresh token
  26. url := app.GithubUserConf.AuthCodeURL(state, oauth2.AccessTypeOnline)
  27. http.Redirect(w, r, url, 302)
  28. }
  29. // HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
  30. // In this handler, the project id gets written to the session (along with the oauth
  31. // state param), so that the correct project id can be identified in the callback.
  32. func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
  33. state := oauth.CreateRandomState()
  34. err := app.populateOAuthSession(w, r, state, true)
  35. if err != nil {
  36. app.handleErrorDataRead(err, w)
  37. return
  38. }
  39. // specify access type offline to get a refresh token
  40. url := app.GithubProjectConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
  41. http.Redirect(w, r, url, 302)
  42. }
  43. // HandleGithubOAuthCallback verifies the callback request by checking that the
  44. // state parameter has not been modified, and validates the token.
  45. // There is a difference between the oauth flow when logging a user in, and when
  46. // linking a repository.
  47. //
  48. // When logging a user in, the access token gets stored in the session, and no refresh
  49. // token is requested. We store the access token in the session because a user can be
  50. // logged in multiple times with a single access token.
  51. //
  52. // NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
  53. //
  54. // However, when linking a repository, the access token and refresh token are requested when
  55. // the flow has started. A project also gets linked to the session. After callback, a new
  56. // github config gets stored for the project, and the user will then get redirected to
  57. // a URL that allows them to select their repositories they'd like to link. We require a refresh
  58. // token because we need permanent access to the linked repository.
  59. func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
  60. session, err := app.Store.Get(r, app.ServerConf.CookieName)
  61. if err != nil {
  62. app.handleErrorDataRead(err, w)
  63. return
  64. }
  65. if _, ok := session.Values["state"]; !ok {
  66. app.sendExternalError(
  67. err,
  68. http.StatusForbidden,
  69. HTTPError{
  70. Code: http.StatusForbidden,
  71. Errors: []string{
  72. "Could not read cookie: are cookies enabled?",
  73. },
  74. },
  75. w,
  76. )
  77. return
  78. }
  79. if r.URL.Query().Get("state") != session.Values["state"] {
  80. http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
  81. return
  82. }
  83. token, err := app.GithubProjectConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
  84. if err != nil {
  85. http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
  86. return
  87. }
  88. if !token.Valid() {
  89. http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
  90. return
  91. }
  92. if session.Values["project_id"] != nil && session.Values["project_id"] != "" {
  93. userID, _ := session.Values["user_id"].(uint)
  94. projID, _ := session.Values["project_id"].(uint)
  95. app.updateProjectFromToken(projID, userID, token)
  96. } else {
  97. // otherwise, create the user if not exists
  98. user, err := app.upsertUserFromToken(token)
  99. if strings.Contains(err.Error(), "already registered") {
  100. http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
  101. return
  102. }
  103. if err != nil {
  104. http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
  105. return
  106. }
  107. // log the user in
  108. app.Logger.Info().Msgf("New user created: %d", user.ID)
  109. session.Values["authenticated"] = true
  110. session.Values["user_id"] = user.ID
  111. session.Values["email"] = user.Email
  112. session.Values["redirect"] = ""
  113. session.Save(r, w)
  114. }
  115. if session.Values["query_params"] != "" {
  116. http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
  117. } else {
  118. http.Redirect(w, r, "/dashboard", 302)
  119. }
  120. }
  121. func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
  122. session, err := app.Store.Get(r, app.ServerConf.CookieName)
  123. if err != nil {
  124. return err
  125. }
  126. // need state parameter to validate when redirected
  127. session.Values["state"] = state
  128. session.Values["query_params"] = r.URL.RawQuery
  129. if isProject {
  130. // read the project id and add it to the session
  131. projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
  132. if err != nil || projID == 0 {
  133. return fmt.Errorf("could not read project id")
  134. }
  135. session.Values["project_id"] = uint(projID)
  136. }
  137. if err := session.Save(r, w); err != nil {
  138. app.Logger.Warn().Err(err)
  139. }
  140. return nil
  141. }
  142. func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
  143. // determine if the user already exists
  144. client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
  145. githubUser, _, err := client.Users.Get(context.Background(), "")
  146. if err != nil {
  147. return nil, err
  148. }
  149. user, err := app.Repo.User.ReadUserByGithubUserID(*githubUser.ID)
  150. // if the user does not exist, create new user
  151. if err != nil && err == gorm.ErrRecordNotFound {
  152. emails, _, err := client.Users.ListEmails(context.Background(), &github.ListOptions{})
  153. if err != nil {
  154. return nil, err
  155. }
  156. primary := ""
  157. // get the primary email
  158. for _, email := range emails {
  159. if email.GetPrimary() {
  160. primary = email.GetEmail()
  161. break
  162. }
  163. }
  164. if primary == "" {
  165. return nil, fmt.Errorf("github user must have an email")
  166. }
  167. // check if a user with that email address already exists
  168. _, err = app.Repo.User.ReadUserByEmail(primary)
  169. if err == gorm.ErrRecordNotFound {
  170. user = &models.User{
  171. Email: primary,
  172. GithubUserID: githubUser.GetID(),
  173. }
  174. user, err = app.Repo.User.CreateUser(user)
  175. if err != nil {
  176. return nil, err
  177. }
  178. } else if err == nil {
  179. return nil, fmt.Errorf("email already registered")
  180. } else if err != nil {
  181. return nil, err
  182. }
  183. } else if err != nil {
  184. return nil, fmt.Errorf("unexpected error occurred:", err.Error())
  185. }
  186. return user, nil
  187. }
  188. // updates a project's repository clients with the token information.
  189. func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
  190. // get the list of repositories that this token has access to
  191. client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
  192. user, _, err := client.Users.Get(context.Background(), "")
  193. if err != nil {
  194. return err
  195. }
  196. oauthInt := &integrations.OAuthIntegration{
  197. Client: integrations.OAuthGithub,
  198. UserID: userID,
  199. ProjectID: projectID,
  200. AccessToken: []byte(tok.AccessToken),
  201. RefreshToken: []byte(tok.RefreshToken),
  202. }
  203. // create the oauth integration first
  204. oauthInt, err = app.Repo.OAuthIntegration.CreateOAuthIntegration(oauthInt)
  205. if err != nil {
  206. return err
  207. }
  208. // create the git repo
  209. gr := &models.GitRepo{
  210. ProjectID: projectID,
  211. RepoEntity: *user.Login,
  212. OAuthIntegrationID: oauthInt.ID,
  213. }
  214. gr, err = app.Repo.GitRepo.CreateGitRepo(gr)
  215. return err
  216. }