package api import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "flag" "fmt" "io" "log" "net/http" "net/url" "os" "path" "strings" "time" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "golang.org/x/oauth2" adminapi "srs.epita.fr/fic-server/admin/api" "srs.epita.fr/fic-server/libfic" ) var ( gitlabBaseURL = "https://gitlab.cri.epita.fr" gitlabClientID = "" gitlabSecret = "" gitlaboauth2Config *oauth2.Config oidcRedirectURL = "https://fic.srs.epita.fr" ) func init() { if v, ok := os.LookupEnv("FIC_OIDC_REDIRECT"); ok { oidcRedirectURL = v } flag.StringVar(&oidcRedirectURL, "oidc-redirect", oidcRedirectURL, "Base URL for the redirect after connection") flag.StringVar(&gitlabBaseURL, "gitlab-baseurl", gitlabBaseURL, "Base URL of the Gitlab instance") flag.StringVar(&gitlabClientID, "gitlab-clientid", os.Getenv("FIC_GITLAB_CLIENT_ID"), "ClientID for GitLab's OIDC") flag.StringVar(&gitlabSecret, "gitlab-secret", os.Getenv("FIC_GITLAB_CLIENT_SECRET"), "Secret for GitLab's OIDC") } func InitializeGitLabOIDC(baseURL string) { if gitlabClientID != "" && gitlabSecret != "" { gitlaboauth2Config = &oauth2.Config{ ClientID: gitlabClientID, ClientSecret: gitlabSecret, RedirectURL: oidcRedirectURL + baseURL + "/callback/gitlab/complete", // Discovery returns the OAuth2 endpoints. Endpoint: oauth2.Endpoint{ AuthURL: gitlabBaseURL + "/oauth/authorize", TokenURL: gitlabBaseURL + "/oauth/token", }, // "openid" is a required scope for OpenID Connect flows. Scopes: []string{"api", "email"}, } } } func declareGitlabRoutes(router *gin.RouterGroup, authrouter *gin.RouterGroup) { if gitlaboauth2Config != nil { router.GET("/auth/gitlab", redirectOAuth_GitLab) router.GET("/callback/gitlab/complete", GitLab_OAuth_complete) tokenRoutes := authrouter.Group("") tokenRoutes.Use(gitlabHandler) tokenRoutes.GET("/gitlab/token", GitLab_GetMyToken) } } func declareGitlabExportRoutes(router *gin.RouterGroup) { if gitlaboauth2Config != nil { tokenRoutes := router.Group("") tokenRoutes.Use(gitlabHandler) if adminLink == "" { log.Println("-admin-link should be defined to be able to export to GitLab!") } else { tokenRoutes.POST("/gitlab_export", GitLab_ExportQA) } } } func gitlabHandler(c *gin.Context) { session := sessions.Default(c) var oauth2Token *oauth2.Token if tok, ok := session.Get("gitlab-token").([]byte); ok { err := json.Unmarshal(tok, &oauth2Token) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } } else { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "No token defined"}) return } err := prepareOAuth2Token(c.Request.Context(), oauth2Token) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } c.Set("gitlab-token", oauth2Token) c.Next() } func prepareOAuth2Token(ctx context.Context, tk *oauth2.Token) error { tsource := oauth2.ReuseTokenSource(tk, gitlaboauth2Config.TokenSource(ctx, tk)) _, err := tsource.Token() if err != nil { log.Println("Unable to regenerate GitLab token:", err) } return err } func redirectOAuth_GitLab(c *gin.Context) { session := sessions.Default(c) // Save next parameter if len(c.Request.URL.Query().Get("next")) > 0 { session.Set("gitlab-oidc-source", c.Request.URL.Query().Get("next")) } b := make([]byte, 32) _, err := rand.Read(b) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } session.Set("oidc-state", b) session.Save() c.Redirect(http.StatusFound, gitlaboauth2Config.AuthCodeURL(hex.EncodeToString(b))) } func GitLab_OAuth_complete(c *gin.Context) { session := sessions.Default(c) state, err := hex.DecodeString(c.Request.URL.Query().Get("state")) if err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) return } if session_state, ok := session.Get("oidc-state").([]byte); !ok { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No OIDC session in progress."}) return } else if !bytes.Equal(state, session_state) { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad session state."}) return } oauth2Token, err := gitlaboauth2Config.Exchange(c.Request.Context(), c.Request.URL.Query().Get("code")) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Failed to exchange token: " + err.Error()}) return } jsonToken, err := json.Marshal(oauth2Token) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) } session.Set("gitlab-token", jsonToken) prepareOAuth2Token(context.Background(), oauth2Token) session.Save() log.Println("New GitLab OAuth2 session opened") baseURL := c.MustGet("baseurl").(string) if source, ok := session.Get("gitlab-oidc-source").(string); ok { session.Delete("gitlab-oidc-source") c.Redirect(http.StatusFound, baseURL+source) } else { c.Redirect(http.StatusFound, baseURL) } } func GitLab_GetMyToken(c *gin.Context) { oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token) c.JSON(http.StatusOK, oauth2Token) } type GitLabIssue struct { Id int64 `json:"id,omitempty"` IId int64 `json:"iid,omitempty"` ProjectId int64 `json:"project_id,omitempty"` Title string `json:"title"` Description string `json:"description"` WebURL string `json:"web_url"` } func gitlab_newIssue(ctx context.Context, token *oauth2.Token, exerciceid string, issue *GitLabIssue) (*GitLabIssue, error) { client := gitlaboauth2Config.Client(ctx, token) enc, err := json.Marshal(issue) if err != nil { return nil, err } req, err := http.NewRequest("POST", gitlabBaseURL+"/api/v4/projects/"+url.QueryEscape(exerciceid)+"/issues", bytes.NewBuffer(enc)) if err != nil { return nil, err } req.Header.Add("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { str, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("Bad status code from the API: %d %s", resp.StatusCode, string(str)) } var issueCreated GitLabIssue dec := json.NewDecoder(resp.Body) err = dec.Decode(&issueCreated) if err != nil { return nil, fmt.Errorf("Unable to decode issue JSON: %s", err.Error()) } return &issueCreated, nil } func GitLab_getExerciceId(exercice *fic.Exercice) (string, error) { // Get the forge link var adminEx adminapi.Exercice err := fwdAdmin("GET", fmt.Sprintf("/api/exercices/%d", exercice.Id), nil, &adminEx) if err != nil { return "", err } forgelink, err := url.Parse(adminEx.ForgeLink) if err != nil { return "", fmt.Errorf("forge_link not found") } forgelink.Path = forgelink.Path[:strings.Index(forgelink.Path, "/-/tree/")] return strings.TrimPrefix(forgelink.Path, "/"), nil } func GitLab_ExportQA(c *gin.Context) { query := c.MustGet("qa").(*fic.QAQuery) exercice, err := fic.GetExercice(query.IdExercice) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } gitlabid, err := GitLab_getExerciceId(exercice) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } comments, err := query.GetComments() if err != nil { log.Println("Unable to GetComments: ", err.Error()) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to list comments: %s", err.Error())}) return } description := "<" + oidcRedirectURL + path.Join(path.Clean("/"+c.MustGet("baseurl").(string)), "exercices", fmt.Sprintf("%d", exercice.Id), fmt.Sprintf("%d", query.Id)) + ">" if len(comments) > 0 { for i, comment := range comments { if i > 0 { description += "\n\n---" } description += fmt.Sprintf("\n\n%s\n\nPar %s, le %s", comment.Content, comment.User, comment.Date.Format(time.UnixDate)) } } // Format the issue issue := GitLabIssue{ Title: "[QA] " + query.Subject, Description: description, } // Create the issue on GitLab oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token) iid, err := gitlab_newIssue(c.Request.Context(), oauth2Token, gitlabid, &issue) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) return } query.Exported = &iid.IId _, err = query.Update() if err != nil { log.Println("Unable to update QAquery in GitLab_ExportQA:", err.Error()) } c.JSON(http.StatusOK, iid) }