diff --git a/.gitignore b/.gitignore index 3434c45..c3c0b13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ atsebay.t bindata.go -vendor \ No newline at end of file +vendor +.gitlab-oauth-token \ No newline at end of file diff --git a/api.go b/api.go index a5c700e..7336023 100644 --- a/api.go +++ b/api.go @@ -15,6 +15,12 @@ func declareAPIRoutes(router *gin.Engine) { declareAPISurveysRoutes(apiRoutes) declareAPIWorksRoutes(apiRoutes) + authRoutes := router.Group("") + authRoutes.Use(authMiddleware(loggedUser)) + adminRoutes := router.Group("") + adminRoutes.Use(authMiddleware(adminRestricted)) + initializeGitLabOIDC(router, authRoutes, adminRoutes) + apiAuthRoutes := router.Group("/api") apiAuthRoutes.Use(authMiddleware(loggedUser)) @@ -43,6 +49,7 @@ func declareAPIRoutes(router *gin.Engine) { declareAPIAuthGradesRoutes(apiAdminRoutes) declareAPIAdminHelpRoutes(apiAdminRoutes) declareAPIAdminQuestionsRoutes(apiAdminRoutes) + declareAPIAuthRepositoriesRoutes(apiAdminRoutes) declareAPIAdminSurveysRoutes(apiAdminRoutes) declareAPIAdminUsersRoutes(apiAdminRoutes) declareAPIAdminWorksRoutes(apiAdminRoutes) diff --git a/db.go b/db.go index 1352077..14335c6 100644 --- a/db.go +++ b/db.go @@ -223,6 +223,20 @@ CREATE TABLE IF NOT EXISTS user_work_grades( FOREIGN KEY(id_user) REFERENCES users(id_user), FOREIGN KEY(id_work) REFERENCES works(id_work) ) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; +`); err != nil { + return err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS user_work_repositories( + id_repository INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_user INTEGER NOT NULL, + id_work INTEGER NOT NULL, + uri VARCHAR(255) NOT NULL, + last_check TIMESTAMP, + FOREIGN KEY(id_user) REFERENCES users(id_user), + FOREIGN KEY(id_work) REFERENCES works(id_work), + UNIQUE one_repo_per_work (id_user, id_work) +) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; `); err != nil { return err } diff --git a/gitlab.go b/gitlab.go new file mode 100644 index 0000000..e9e0ea7 --- /dev/null +++ b/gitlab.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "flag" + "log" + "net/http" + "os" + + "golang.org/x/oauth2" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" +) + +const ( + OAUTH_GITLAB_FILE = ".gitlab-oauth-token" +) + +var ( + gitlabBaseURL = "https://gitlab.cri.epita.fr" + gitlabClientID = "" + gitlabSecret = "" + gitlaboauth2Config oauth2.Config + gitlaboidcVerifier *oidc.IDTokenVerifier + gitlabToken *oauth2.Token +) + +func init() { + flag.StringVar(&gitlabClientID, "gitlab-clientid", gitlabClientID, "ClientID for GitLab's OIDC") + flag.StringVar(&gitlabSecret, "gitlab-secret", gitlabSecret, "Secret for GitLab's OIDC") +} + +func initializeGitLabOIDC(router *gin.Engine, authrouter *gin.RouterGroup, adminrouter *gin.RouterGroup) { + adminrouter.GET("/auth/gitlabcri", redirectOAuth_GitLab) + router.GET("/callback/gitlabcri/complete", GitLab_OAuth_complete) + + if _, err := os.Stat(OAUTH_GITLAB_FILE); err == nil { + gitlabToken, err = loadOAuth2Token(OAUTH_GITLAB_FILE) + if err != nil { + log.Println("Unable to load OAuth2 Token:", err.Error()) + } + } + + if gitlabClientID != "" && gitlabSecret != "" { + provider, err := oidc.NewProvider(context.Background(), gitlabBaseURL) + if err != nil { + log.Fatal("Unable to setup oidc:", err) + } + + gitlaboauth2Config = oauth2.Config{ + ClientID: gitlabClientID, + ClientSecret: gitlabSecret, + RedirectURL: oidcRedirectURL + baseURL + "/callback/gitlabcri/complete", + + // Discovery returns the OAuth2 endpoints. + Endpoint: provider.Endpoint(), + + // "openid" is a required scope for OpenID Connect flows. + Scopes: []string{"api", "read_repository", "email"}, + } + + oidcConfig := oidc.Config{ + ClientID: gitlabClientID, + } + gitlaboidcVerifier = provider.Verifier(&oidcConfig) + + authrouter.GET("/api/gitlabcri/repositories", GitLab_getRepositories) + } +} + +func loadOAuth2Token(file string) (*oauth2.Token, error) { + fd, err := os.Open(file) + if err != nil { + return nil, err + } + defer fd.Close() + + var tok oauth2.Token + err = json.NewDecoder(fd).Decode(&tok) + + return &tok, err +} + +func saveOAuth2Token(file string, tok *oauth2.Token) error { + fd, err := os.Create(file) + if err != nil { + return err + } + defer fd.Close() + + return json.NewEncoder(fd).Encode(tok) +} + +func redirectOAuth_GitLab(c *gin.Context) { + session := c.MustGet("Session").(*Session) + + // Save next parameter + if len(c.Request.URL.Query().Get("next")) > 0 { + session.SetKey("gitlab-oidc-source", c.Request.URL.Query().Get("next")) + } + + c.Redirect(http.StatusFound, gitlaboauth2Config.AuthCodeURL(hex.EncodeToString(session.Id))) +} + +func GitLab_OAuth_complete(c *gin.Context) { + idsession, err := hex.DecodeString(c.Request.URL.Query().Get("state")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + session, err := getSession(idsession) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + 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 + } + + gitlabToken = oauth2Token + err = saveOAuth2Token(OAUTH_GITLAB_FILE, oauth2Token) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to save OAuth2 token: " + err.Error()}) + return + + } + log.Println("New GitLab OAuth2 session opened") + + if source, ok := session.GetKey("gitlab-oidc-source"); ok { + session.DeleteKey("gitlab-oidc-source") + c.Redirect(http.StatusFound, baseURL+source.(string)) + } else { + c.Redirect(http.StatusFound, baseURL+"/works") + } + session.Update() +} + +func GitLab_getRepositories(c *gin.Context) { + client := gitlaboauth2Config.Client(c.Request.Context(), gitlabToken) + + req, err := http.NewRequest("GET", gitlabBaseURL+"/api/v4/projects", nil) + if err != nil { + log.Println("Unable to create NewRequest before GitLab call: ", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when performing the GitLab request."}) + return + } + + resp, err := client.Do(req) + if err != nil { + log.Println("Unable to perform the GitLab request: ", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when performing the GitLab request."}) + return + } + + c.DataFromReader(resp.StatusCode, resp.ContentLength, resp.Header.Get("content-type"), resp.Body, nil) +} diff --git a/repositories.go b/repositories.go new file mode 100644 index 0000000..2df5341 --- /dev/null +++ b/repositories.go @@ -0,0 +1,207 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +func declareAPIAuthRepositoriesRoutes(router *gin.RouterGroup) { + router.GET("/repositories", func(c *gin.Context) { + var u *User + if user, ok := c.Get("user"); ok { + u = user.(*User) + } else { + u = c.MustGet("LoggedUser").(*User) + } + + repositories, err := u.GetRepositories() + if err != nil { + log.Println("Unable to GetRepositories:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve your repositories. Please try again in a few moment."}) + return + } + + c.JSON(http.StatusOK, repositories) + }) + router.POST("/repositories", func(c *gin.Context) { + var u *User + if user, ok := c.Get("user"); ok { + u = user.(*User) + } else { + u = c.MustGet("LoggedUser").(*User) + } + + var repository Repository + if err := c.ShouldBindJSON(&repository); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + w := c.MustGet("work").(*Work) + k, err := u.NewRepository(w, repository.URI) + if err != nil { + log.Println("Unable to NewRepository:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to register your public repository. Please try again in a few moment."}) + return + } + + c.JSON(http.StatusOK, k) + }) + + repositoriesRoutes := router.Group("/repositories/:rid") + repositoriesRoutes.Use(repositoryHandler) + + repositoriesRoutes.GET("", func(c *gin.Context) { + repo := c.MustGet("repository").(*Repository) + + c.JSON(http.StatusOK, repo) + }) + repositoriesRoutes.PUT("", func(c *gin.Context) { + current := c.MustGet("repository").(*Repository) + + var new Repository + if err := c.ShouldBindJSON(&new); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + new.Id = current.Id + + u := c.MustGet("LoggedUser").(*User) + if new.IdUser != current.IdUser && !u.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "Operation not allowed."}) + return + } + + if repository, err := new.Update(); err != nil { + log.Println("Unable to Update repository:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during repository updation: %s", err.Error())}) + return + } else { + c.JSON(http.StatusOK, repository) + } + }) + repositoriesRoutes.DELETE("", func(c *gin.Context) { + repository := c.MustGet("repository").(*Repository) + + if _, err := repository.Delete(); err != nil { + log.Println("Unable to Delete repository:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during repository deletion: %s", err.Error())}) + return + } else { + c.JSON(http.StatusOK, nil) + } + }) +} + +func repositoryHandler(c *gin.Context) { + var u *User + if user, ok := c.Get("user"); ok { + u = user.(*User) + } else { + u = c.MustGet("LoggedUser").(*User) + } + + if rid, err := strconv.Atoi(string(c.Param("rid"))); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad repository identifier."}) + return + } else if u.IsAdmin { + if repository, err := getRepository(rid); err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Repository not found."}) + return + } else { + c.Set("repository", repository) + c.Next() + } + } else if repository, err := u.getRepository(rid); err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Repository not found."}) + return + } else { + c.Set("repository", repository) + c.Next() + } +} + +type Repository struct { + Id int64 `json:"id"` + IdUser int64 `json:"id_user"` + IdWork int64 `json:"id_work"` + URI string `json:"uri"` + LastCheck *time.Time `json:"last_check"` +} + +func (u *User) GetRepositories() (repositories []*Repository, err error) { + if rows, errr := DBQuery("SELECT id_repository, id_user, id_work, uri, last_check FROM user_work_repositories WHERE id_user=?", u.Id); errr != nil { + return nil, errr + } else { + defer rows.Close() + + for rows.Next() { + var repo Repository + if err = rows.Scan(&repo.Id, &repo.IdUser, &repo.IdWork, &repo.URI, &repo.LastCheck); err != nil { + return + } + repositories = append(repositories, &repo) + } + if err = rows.Err(); err != nil { + return + } + + return + } +} + +func getRepository(id int) (r *Repository, err error) { + r = new(Repository) + err = DBQueryRow("SELECT id_repository, id_user, id_work, uri, lastcheck FROM user_work_repositories WHERE id_repository=?", id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.LastCheck) + return +} + +func (u *User) getRepository(id int) (r *Repository, err error) { + r = new(Repository) + err = DBQueryRow("SELECT id_repository, id_user, id_work, uri FROM user_work_repositories WHERE id_repository=? AND id_user=?", id, u.Id).Scan(&r.Id, &r.IdUser, &r.IdWork, &r.URI, &r.LastCheck) + return +} + +func (u *User) NewRepository(w *Work, uri string) (*Repository, error) { + if res, err := DBExec("INSERT INTO user_work_repositories (id_user, id_work, uri) VALUES (?, ?, ?)", u.Id, w.Id, uri); err != nil { + return nil, err + } else if rid, err := res.LastInsertId(); err != nil { + return nil, err + } else { + return &Repository{rid, u.Id, w.Id, uri, nil}, nil + } +} + +func (r *Repository) Update() (*Repository, error) { + if _, err := DBExec("UPDATE user_work_repositories SET id_user = ?, id_work = ?, uri = ?, last_check = ? WHERE id_repository = ?", r.IdUser, r.IdWork, r.URI, r.LastCheck, r.Id); err != nil { + return nil, err + } else { + return r, err + } +} + +func (r Repository) Delete() (int64, error) { + if res, err := DBExec("DELETE FROM user_work_repositories WHERE id_repository = ?", r.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +func ClearRepositories() (int64, error) { + if res, err := DBExec("DELETE FROM user_work_repositories"); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} diff --git a/ui/src/routes/users/index.svelte b/ui/src/routes/users/index.svelte index c7c6980..c20c526 100644 --- a/ui/src/routes/users/index.svelte +++ b/ui/src/routes/users/index.svelte @@ -13,7 +13,14 @@ {#if $user && $user.is_admin} - + + + OAuth GitLab + + {#await getPromos() then promos} diff --git a/works.go b/works.go index 13d93a7..f660ed9 100644 --- a/works.go +++ b/works.go @@ -217,6 +217,8 @@ func declareAPIAuthWorksRoutes(router *gin.RouterGroup) { c.JSON(http.StatusOK, g) } }) + + declareAPIAuthRepositoriesRoutes(worksRoutes) } func workHandler(c *gin.Context) {