You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

307 lines
8.3 KiB

8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
8 years ago
  1. // Package github implements OAuth2 support for github.com
  2. package github
  3. import (
  4. "crypto/rand"
  5. "encoding/hex"
  6. "encoding/json"
  7. "io/ioutil"
  8. "net/http"
  9. "net/url"
  10. "github.com/google/go-github/github"
  11. "github.com/matrix-org/go-neb/database"
  12. "github.com/matrix-org/go-neb/services/github/client"
  13. "github.com/matrix-org/go-neb/types"
  14. log "github.com/sirupsen/logrus"
  15. )
  16. // RealmType of the Github Realm
  17. const RealmType = "github"
  18. // Realm can handle OAuth processes with github.com
  19. //
  20. // Example request:
  21. // {
  22. // "ClientSecret": "YOUR_CLIENT_SECRET",
  23. // "ClientID": "YOUR_CLIENT_ID"
  24. // }
  25. type Realm struct {
  26. id string
  27. redirectURL string
  28. // The client secret for this Github application.
  29. ClientSecret string
  30. // The client ID for this Github application.
  31. ClientID string
  32. // Optional. The URL to redirect the client to after authentication.
  33. StarterLink string
  34. }
  35. // Session represents an authenticated github session
  36. type Session struct {
  37. id string
  38. userID string
  39. realmID string
  40. // AccessToken is the github access token for the user
  41. AccessToken string
  42. // Scopes are the set of *ALLOWED* scopes (which may not be the same as the requested scopes)
  43. Scopes string
  44. // Optional. The client-supplied URL to redirect them to after the auth process is complete.
  45. ClientsRedirectURL string
  46. }
  47. // AuthRequest is a request for authenticating with github.com
  48. type AuthRequest struct {
  49. // Optional. The URL to redirect to after authentication.
  50. RedirectURL string
  51. }
  52. // AuthResponse is a response to an AuthRequest.
  53. type AuthResponse struct {
  54. // The URL to visit to perform OAuth on github.com
  55. URL string
  56. }
  57. // Authenticated returns true if the user has completed the auth process
  58. func (s *Session) Authenticated() bool {
  59. return s.AccessToken != ""
  60. }
  61. // Info returns a list of possible repositories that this session can integrate with.
  62. func (s *Session) Info() interface{} {
  63. logger := log.WithFields(log.Fields{
  64. "user_id": s.userID,
  65. "realm_id": s.realmID,
  66. })
  67. cli := client.New(s.AccessToken)
  68. var repos []client.TrimmedRepository
  69. opts := &github.RepositoryListOptions{
  70. Type: "all",
  71. ListOptions: github.ListOptions{
  72. PerPage: 100,
  73. },
  74. }
  75. for {
  76. // query for a list of possible projects
  77. rs, resp, err := cli.Repositories.List("", opts)
  78. if err != nil {
  79. logger.WithError(err).Print("Failed to query github projects on github.com")
  80. return nil
  81. }
  82. for _, r := range rs {
  83. repos = append(repos, client.TrimRepository(r))
  84. }
  85. if resp.NextPage == 0 {
  86. break
  87. }
  88. opts.ListOptions.Page = resp.NextPage
  89. logger.Print("Session.Info() Next => ", resp.NextPage)
  90. }
  91. logger.Print("Session.Info() Returning ", len(repos), " repos")
  92. return struct {
  93. Repos []client.TrimmedRepository
  94. }{repos}
  95. }
  96. // UserID returns the user_id who authorised with Github
  97. func (s *Session) UserID() string {
  98. return s.userID
  99. }
  100. // RealmID returns the realm ID of the realm which performed the authentication
  101. func (s *Session) RealmID() string {
  102. return s.realmID
  103. }
  104. // ID returns the session ID
  105. func (s *Session) ID() string {
  106. return s.id
  107. }
  108. // ID returns the realm ID
  109. func (r *Realm) ID() string {
  110. return r.id
  111. }
  112. // Type is github
  113. func (r *Realm) Type() string {
  114. return RealmType
  115. }
  116. // Init does nothing.
  117. func (r *Realm) Init() error {
  118. return nil
  119. }
  120. // Register does nothing.
  121. func (r *Realm) Register() error {
  122. return nil
  123. }
  124. // RequestAuthSession generates an OAuth2 URL for this user to auth with github via.
  125. // The request body is of type "github.AuthRequest". The response is of type "github.AuthResponse".
  126. //
  127. // Request example:
  128. // {
  129. // "RedirectURL": "https://optional-url.com/to/redirect/to/after/auth"
  130. // }
  131. //
  132. // Response example:
  133. // {
  134. // "URL": "https://github.com/login/oauth/authorize?client_id=abcdef&client_secret=acascacac...."
  135. // }
  136. func (r *Realm) RequestAuthSession(userID string, req json.RawMessage) interface{} {
  137. state, err := randomString(10)
  138. if err != nil {
  139. log.WithError(err).Print("Failed to generate state param")
  140. return nil
  141. }
  142. u, _ := url.Parse("https://github.com/login/oauth/authorize")
  143. q := u.Query()
  144. q.Set("client_id", r.ClientID)
  145. q.Set("client_secret", r.ClientSecret)
  146. q.Set("state", state)
  147. q.Set("redirect_uri", r.redirectURL)
  148. q.Set("scope", "admin:repo_hook,admin:org_hook,repo")
  149. u.RawQuery = q.Encode()
  150. session := &Session{
  151. id: state, // key off the state for redirects
  152. userID: userID,
  153. realmID: r.ID(),
  154. }
  155. // check if they supplied a redirect URL
  156. var reqBody AuthRequest
  157. if err = json.Unmarshal(req, &reqBody); err != nil {
  158. log.WithError(err).Print("Failed to decode request body")
  159. return nil
  160. }
  161. session.ClientsRedirectURL = reqBody.RedirectURL
  162. log.WithFields(log.Fields{
  163. "clients_redirect_url": session.ClientsRedirectURL,
  164. "redirect_url": u.String(),
  165. }).Print("RequestAuthSession: Performing redirect")
  166. _, err = database.GetServiceDB().StoreAuthSession(session)
  167. if err != nil {
  168. log.WithError(err).Print("Failed to store new auth session")
  169. return nil
  170. }
  171. return &AuthResponse{u.String()}
  172. }
  173. // OnReceiveRedirect processes OAuth redirect requests from Github
  174. func (r *Realm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
  175. // parse out params from the request
  176. code := req.URL.Query().Get("code")
  177. state := req.URL.Query().Get("state")
  178. logger := log.WithFields(log.Fields{
  179. "state": state,
  180. })
  181. logger.WithField("code", code).Print("GithubRealm: OnReceiveRedirect")
  182. if code == "" || state == "" {
  183. failWith(logger, w, 400, "code and state are required", nil)
  184. return
  185. }
  186. // load the session (we keyed off the state param)
  187. session, err := database.GetServiceDB().LoadAuthSessionByID(r.ID(), state)
  188. if err != nil {
  189. // most likely cause
  190. failWith(logger, w, 400, "Provided ?state= param is not recognised.", err)
  191. return
  192. }
  193. ghSession, ok := session.(*Session)
  194. if !ok {
  195. failWith(logger, w, 500, "Unexpected session found.", nil)
  196. return
  197. }
  198. logger.WithField("user_id", ghSession.UserID()).Print("Mapped redirect to user")
  199. if ghSession.AccessToken != "" && ghSession.Scopes != "" {
  200. r.redirectOr(w, 400, "You have already authenticated with Github", logger, ghSession)
  201. return
  202. }
  203. // exchange code for access_token
  204. res, err := http.PostForm("https://github.com/login/oauth/access_token",
  205. url.Values{"client_id": {r.ClientID}, "client_secret": {r.ClientSecret}, "code": {code}})
  206. if err != nil {
  207. failWith(logger, w, 502, "Failed to exchange code for token", err)
  208. return
  209. }
  210. defer res.Body.Close()
  211. body, err := ioutil.ReadAll(res.Body)
  212. if err != nil {
  213. failWith(logger, w, 502, "Failed to read token response", err)
  214. return
  215. }
  216. vals, err := url.ParseQuery(string(body))
  217. if err != nil {
  218. failWith(logger, w, 502, "Failed to parse token response", err)
  219. return
  220. }
  221. // update database and return
  222. ghSession.AccessToken = vals.Get("access_token")
  223. ghSession.Scopes = vals.Get("scope")
  224. logger.WithField("scope", ghSession.Scopes).Print("Scopes granted.")
  225. _, err = database.GetServiceDB().StoreAuthSession(ghSession)
  226. if err != nil {
  227. failWith(logger, w, 500, "Failed to persist session", err)
  228. return
  229. }
  230. r.redirectOr(
  231. w, 200, "You have successfully linked your Github account to "+ghSession.UserID(), logger, ghSession,
  232. )
  233. }
  234. func (r *Realm) redirectOr(w http.ResponseWriter, code int, msg string, logger *log.Entry, ghSession *Session) {
  235. if ghSession.ClientsRedirectURL != "" {
  236. w.Header().Set("Location", ghSession.ClientsRedirectURL)
  237. w.WriteHeader(302)
  238. // technically don't need a body but *shrug*
  239. w.Write([]byte(ghSession.ClientsRedirectURL))
  240. } else {
  241. failWith(logger, w, code, msg, nil)
  242. }
  243. }
  244. // AuthSession returns a Github Session for this user
  245. func (r *Realm) AuthSession(id, userID, realmID string) types.AuthSession {
  246. return &Session{
  247. id: id,
  248. userID: userID,
  249. realmID: realmID,
  250. }
  251. }
  252. func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, err error) {
  253. logger.WithError(err).Print(msg)
  254. w.WriteHeader(code)
  255. w.Write([]byte(msg))
  256. }
  257. // Generate a cryptographically secure pseudorandom string with the given number of bytes (length).
  258. // Returns a hex string of the bytes.
  259. func randomString(length int) (string, error) {
  260. b := make([]byte, length)
  261. _, err := rand.Read(b)
  262. if err != nil {
  263. return "", err
  264. }
  265. return hex.EncodeToString(b), nil
  266. }
  267. func init() {
  268. types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm {
  269. return &Realm{id: realmID, redirectURL: redirectURL}
  270. })
  271. }