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.

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