github.com/friedemannf/reviewdog@v0.14.0/doghouse/appengine/github.go (about) 1 package main 2 3 import ( 4 "bytes" 5 "context" 6 "crypto/rand" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net/http" 13 "net/url" 14 "strings" 15 16 "github.com/google/go-github/v37/github" 17 "github.com/justinas/nosurf" 18 "github.com/vvakame/sdlog/aelog" 19 "golang.org/x/oauth2" 20 21 "github.com/friedemannf/reviewdog/doghouse/server" 22 "github.com/friedemannf/reviewdog/doghouse/server/cookieman" 23 "github.com/friedemannf/reviewdog/doghouse/server/storage" 24 ) 25 26 type GitHubHandler struct { 27 clientID string 28 clientSecret string 29 30 tokenStore *cookieman.CookieStore 31 redirURLStore *cookieman.CookieStore // Redirect URL after login. 32 authStateStore *cookieman.CookieStore 33 34 repoTokenStore storage.GitHubRepositoryTokenStore 35 36 privateKey []byte 37 integrationID int 38 } 39 40 func NewGitHubHandler(clientID, clientSecret string, c *cookieman.CookieMan, privateKey []byte, integrationID int) *GitHubHandler { 41 return &GitHubHandler{ 42 clientID: clientID, 43 clientSecret: clientSecret, 44 tokenStore: c.NewCookieStore("github-token", nil), 45 redirURLStore: c.NewCookieStore("github-redirect-url", nil), 46 authStateStore: c.NewCookieStore("github-auth-state", nil), 47 repoTokenStore: &storage.GitHubRepoTokenDatastore{}, 48 integrationID: integrationID, 49 privateKey: privateKey, 50 } 51 } 52 53 type ghTopTmplData struct { 54 Title string 55 User tmplUser 56 57 App struct { 58 Name string 59 HTMLURL string 60 } 61 62 Installations []tmplInstallation 63 } 64 65 type tmplInstallation struct { 66 Account string 67 AccountHTMLURL string 68 AccountIconURL string 69 HTMLURL string 70 } 71 72 type ghRepoTmplData struct { 73 Title string 74 Token string 75 User tmplUser 76 Repo tmplRepo 77 CSRFToken string 78 } 79 80 type tmplUser struct { 81 Name string 82 IconURL string 83 GitHubURL string 84 } 85 86 type tmplRepo struct { 87 Owner string 88 Name string 89 GitHubURL string 90 } 91 92 func (g *GitHubHandler) buildGithubAuthURL(r *http.Request, state string) string { 93 redirURL := *r.URL 94 redirURL.Path = "/gh/_auth/callback" 95 redirURL.RawQuery = "" 96 redirURL.Fragment = "" 97 const baseURL = "https://github.com/login/oauth/authorize" 98 authURL := fmt.Sprintf("%s?client_id=%s&redirect_url=%s&state=%s", 99 baseURL, g.clientID, redirURL.RequestURI(), state) 100 return authURL 101 } 102 103 func (g *GitHubHandler) HandleAuthCallback(w http.ResponseWriter, r *http.Request) { 104 ctx := r.Context() 105 code, state := r.FormValue("code"), r.FormValue("state") 106 if code == "" || state == "" { 107 w.WriteHeader(http.StatusBadRequest) 108 fmt.Fprintln(w, "code and state param is empty") 109 return 110 } 111 112 // Verify state. 113 cookieState, err := g.authStateStore.Get(r) 114 if err != nil || state != string(cookieState) { 115 w.WriteHeader(http.StatusBadRequest) 116 fmt.Fprintln(w, "state is invalid") 117 return 118 } 119 g.authStateStore.Clear(w) 120 121 // Request and save access token. 122 token, err := g.requestAccessToken(ctx, code, state) 123 if err != nil { 124 aelog.Errorf(ctx, "failed to get access token: %v", err) 125 w.WriteHeader(http.StatusBadRequest) 126 fmt.Fprintln(w, "failed to get GitHub access token") 127 return 128 } 129 g.tokenStore.Set(w, []byte(token)) 130 131 // Redirect. 132 redirURL := "/gh/" 133 if r, err := g.redirURLStore.Get(r); err == nil { 134 redirURL = string(r) 135 g.redirURLStore.Clear(w) 136 } 137 http.Redirect(w, r, redirURL, http.StatusFound) 138 } 139 140 func (g *GitHubHandler) HandleLogout(w http.ResponseWriter, r *http.Request) { 141 g.tokenStore.Clear(w) 142 http.Redirect(w, r, "/", http.StatusFound) 143 } 144 145 func (g *GitHubHandler) LogInHandler(h http.Handler) http.Handler { 146 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 147 if g.isLoggedIn(r) { 148 h.ServeHTTP(w, r) 149 return 150 } 151 // Not logged in yet. 152 aelog.Debugf(r.Context(), "Not logged in yet.") 153 state := securerandom(16) 154 g.redirURLStore.Set(w, []byte(r.URL.RequestURI())) 155 g.authStateStore.Set(w, []byte(state)) 156 http.Redirect(w, r, g.buildGithubAuthURL(r, state), http.StatusFound) 157 }) 158 } 159 160 func (g *GitHubHandler) isLoggedIn(r *http.Request) bool { 161 ok, _ := g.token(r) 162 return ok 163 } 164 165 func securerandom(n int) string { 166 b := make([]byte, n) 167 io.ReadFull(rand.Reader, b) 168 return fmt.Sprintf("%x", b) 169 } 170 171 // https://developer.github.com/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps/#2-users-are-redirected-back-to-your-site-by-github 172 // POST https://github.com/login/oauth/access_token 173 func (g *GitHubHandler) requestAccessToken(ctx context.Context, code, state string) (string, error) { 174 const u = "https://github.com/login/oauth/access_token" 175 cli := &http.Client{} 176 data := url.Values{} 177 data.Set("client_id", g.clientID) 178 data.Set("client_secret", g.clientSecret) 179 data.Set("code", code) 180 data.Set("state", state) 181 182 req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(data.Encode())) 183 if err != nil { 184 return "", fmt.Errorf("failed to create request: %w", err) 185 } 186 req = req.WithContext(ctx) 187 req.Header.Add("Accept", "application/json") 188 req.Header.Add("Accept", "application/vnd.github.machine-man-preview+json") 189 req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 190 191 res, err := cli.Do(req) 192 if err != nil { 193 return "", fmt.Errorf("failed to request access token: %w", err) 194 } 195 defer res.Body.Close() 196 197 b, _ := ioutil.ReadAll(res.Body) 198 199 var token struct { 200 AccessToken string `json:"access_token"` 201 } 202 if err := json.NewDecoder(bytes.NewReader(b)).Decode(&token); err != nil { 203 return "", fmt.Errorf("failed to decode response: %w", err) 204 } 205 206 if token.AccessToken == "" { 207 aelog.Errorf(ctx, "response doesn't contain token (response: %s)", b) 208 return "", errors.New("response doesn't contain GitHub access token") 209 } 210 211 return token.AccessToken, nil 212 } 213 214 func (g *GitHubHandler) token(r *http.Request) (bool, string) { 215 b, err := g.tokenStore.Get(r) 216 if err != nil { 217 return false, "" 218 } 219 return true, string(b) 220 } 221 222 func (g *GitHubHandler) HandleGitHubTop(w http.ResponseWriter, r *http.Request) { 223 ctx := r.Context() 224 225 ok, token := g.token(r) 226 if !ok { 227 w.WriteHeader(http.StatusUnauthorized) 228 return 229 } 230 231 ts := oauth2.StaticTokenSource( 232 &oauth2.Token{AccessToken: token}, 233 ) 234 ghcli := github.NewClient(NewAuthClient(ctx, http.DefaultTransport, ts)) 235 236 // /gh/{owner}/{repo} 237 paths := strings.Split(strings.Trim(r.URL.Path, "/"), "/") 238 switch len(paths) { 239 case 1: 240 g.handleTop(ctx, ghcli, w) 241 case 3: 242 g.handleRepo(ctx, ghcli, w, r, paths[1], paths[2]) 243 default: 244 notfound(w) 245 } 246 } 247 248 func notfound(w http.ResponseWriter) { 249 w.WriteHeader(http.StatusNotFound) 250 fmt.Fprintln(w, "404 Not Found") 251 } 252 253 func (g *GitHubHandler) getUserOrBadRequest(ctx context.Context, ghcli *github.Client, w http.ResponseWriter) (bool, *github.User) { 254 u, _, err := ghcli.Users.Get(ctx, "") 255 if err != nil { 256 // Token seems invalid. Clear it before returning BadRequest status. 257 g.tokenStore.Clear(w) 258 w.WriteHeader(http.StatusBadRequest) 259 fmt.Fprintf(w, "Cannot get GitHub authenticated user. Please reload the page again.") 260 return false, nil 261 } 262 return true, u 263 } 264 265 func (g *GitHubHandler) handleTop(ctx context.Context, ghcli *github.Client, w http.ResponseWriter) { 266 ok, u := g.getUserOrBadRequest(ctx, ghcli, w) 267 if !ok { 268 return 269 } 270 271 data := &ghTopTmplData{ 272 Title: "GitHub - reviewdog", 273 User: tmplUser{ 274 Name: u.GetName(), 275 IconURL: u.GetAvatarURL(), 276 GitHubURL: u.GetHTMLURL(), 277 }, 278 } 279 280 ghAppCli, err := server.NewGitHubClient(ctx, &server.NewGitHubClientOption{ 281 Client: &http.Client{}, 282 IntegrationID: g.integrationID, 283 PrivateKey: g.privateKey, 284 }) 285 if err != nil { 286 w.WriteHeader(http.StatusInternalServerError) 287 fmt.Fprintln(w, err) 288 return 289 } 290 app, _, err := ghAppCli.Apps.Get(ctx, "") 291 if err != nil { 292 w.WriteHeader(http.StatusInternalServerError) 293 fmt.Fprintln(w, err) 294 return 295 } 296 data.App.Name = app.GetName() 297 data.App.HTMLURL = app.GetHTMLURL() 298 299 installations, _, err := ghcli.Apps.ListUserInstallations(ctx, nil) 300 if err != nil { 301 w.WriteHeader(http.StatusInternalServerError) 302 fmt.Fprintln(w, err) 303 return 304 } 305 for _, inst := range installations { 306 data.Installations = append(data.Installations, tmplInstallation{ 307 Account: inst.GetAccount().GetLogin(), 308 AccountHTMLURL: inst.GetAccount().GetHTMLURL(), 309 AccountIconURL: inst.GetAccount().GetAvatarURL(), 310 HTMLURL: inst.GetHTMLURL(), 311 }) 312 } 313 314 ghTopTmpl.ExecuteTemplate(w, "base", data) 315 } 316 317 func (g *GitHubHandler) handleRepo(ctx context.Context, ghcli *github.Client, w http.ResponseWriter, r *http.Request, owner, repoName string) { 318 repo, _, err := ghcli.Repositories.Get(ctx, owner, repoName) 319 if err != nil { 320 if err, ok := err.(*github.ErrorResponse); ok { 321 if err.Response.StatusCode == http.StatusNotFound { 322 notfound(w) 323 return 324 } 325 } 326 w.WriteHeader(http.StatusBadRequest) 327 fmt.Fprintf(w, "failed to get repo: %#v", err) 328 return 329 } 330 331 if !repo.GetPermissions()["push"] { 332 w.WriteHeader(http.StatusUnauthorized) 333 fmt.Fprintf(w, "You don't have write permission for %s.", repo.GetHTMLURL()) 334 return 335 } 336 337 ok, u := g.getUserOrBadRequest(ctx, ghcli, w) 338 if !ok { 339 return 340 } 341 342 // Regenerate Token. 343 if r.Method == http.MethodPost { 344 if _, err := server.RegenerateRepoToken(ctx, g.repoTokenStore, repo.Owner.GetLogin(), repo.GetName(), repo.GetID()); err != nil { 345 w.WriteHeader(http.StatusInternalServerError) 346 fmt.Fprintf(w, "failed to update repository token: %v", err) 347 return 348 } 349 http.Redirect(w, r, r.URL.String(), http.StatusFound) 350 } 351 352 repoToken, err := server.GetOrGenerateRepoToken(ctx, g.repoTokenStore, repo.Owner.GetLogin(), repo.GetName(), repo.GetID()) 353 if err != nil { 354 w.WriteHeader(http.StatusInternalServerError) 355 fmt.Fprintf(w, "failed to get repository token for %s.", repo.GetHTMLURL()) 356 return 357 } 358 359 ghRepoTmpl.ExecuteTemplate(w, "base", &ghRepoTmplData{ 360 Title: fmt.Sprintf("%s/%s - reviewdog", repo.Owner.GetLogin(), repo.GetName()), 361 Token: repoToken, 362 User: tmplUser{ 363 Name: u.GetName(), 364 IconURL: u.GetAvatarURL(), 365 GitHubURL: u.GetHTMLURL(), 366 }, 367 Repo: tmplRepo{ 368 Owner: repo.Owner.GetLogin(), 369 Name: repo.GetName(), 370 GitHubURL: repo.GetHTMLURL(), 371 }, 372 CSRFToken: nosurf.Token(r), 373 }) 374 } 375 376 func NewAuthClient(ctx context.Context, base http.RoundTripper, token oauth2.TokenSource) *http.Client { 377 tc := oauth2.NewClient(ctx, token) 378 tr := tc.Transport.(*oauth2.Transport) 379 tr.Base = base 380 return tc 381 }