diff --git a/.gitea/workflows/cd-back.yml b/.gitea/workflows/cd-back.yml index cd8fa68..9b607d3 100644 --- a/.gitea/workflows/cd-back.yml +++ b/.gitea/workflows/cd-back.yml @@ -23,7 +23,10 @@ jobs: secrets/site/prod/data/server SSH_KEY | SSH_KEY ; secrets/site/prod/data/server USER | SERVER_USER ; secrets/site/prod/data/server HOST | SERVER_HOST ; - secrets/site/prod/data/server PORT | SERVER_PORT + secrets/site/prod/data/server PORT | SERVER_PORT ; + secrets/site/prod/data/auth GITHUB_CLIENT_ID | GITHUB_CLIENT_ID ; + secrets/site/prod/data/auth GITHUB_CLIENT_SECRET | GITHUB_CLIENT_SECRET ; + secrets/site/prod/data/auth JWT_SECRET | JWT_SECRET - name: Login to registry run: echo "${{ steps.import-secrets.outputs.GITEA_TOKEN }}" | docker login gitea.d3m0k1d.ru -u d3m0k1d --password-stdin @@ -44,4 +47,10 @@ jobs: docker login -u d3m0k1d -p ${{ steps.import-secrets.outputs.GITEA_TOKEN }} gitea.d3m0k1d.ru docker pull gitea.d3m0k1d.ru/d3m0k1d/backend:latest docker rm -f d3m0k1d-backend || true - docker run --name d3m0k1d-backend -d -p 8080:8080 --restart unless-stopped gitea.d3m0k1d.ru/d3m0k1d/backend:latest + docker run --name d3m0k1d-backend -d -p 8080:8080 \ + -e JWT_SECRET="${{ steps.import-secrets.outputs.JWT_SECRET }}" \ + -e GITHUB_CLIENT_ID="${{ steps.import-secrets.outputs.GITHUB_CLIENT_ID }}" \ + -e GITHUB_CLIENT_SECRET="${{ steps.import-secrets.outputs.GITHUB_CLIENT_SECRET }}" \ + -e REDIRECT_URL="https://d3m0k1d.ru/api/v1/callback/github" \ + --restart unless-stopped \ + gitea.d3m0k1d.ru/d3m0k1d/backend:latest diff --git a/backend/.gitignore b/backend/.gitignore index 634b0a2..adef40a 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ /bin /data +.env diff --git a/backend/Dockerfile b/backend/Dockerfile index f9c0a02..8f70254 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,5 +15,5 @@ FROM alpine:3.23.0 COPY --from=builder /app/backend . EXPOSE 8080 - +HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1 CMD ["./backend"] diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 0ffb957..fb0a6ec 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -15,6 +15,32 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/callback/github": { + "get": { + "description": "Callback for oauth2 providers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Callback for oauth2 providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/posts": { "get": { "description": "Get all posts", diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 518167b..c52c036 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4,6 +4,32 @@ "contact": {} }, "paths": { + "/callback/github": { + "get": { + "description": "Callback for oauth2 providers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Callback for oauth2 providers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/posts": { "get": { "description": "Get all posts", diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index ff5bb35..3a1cd46 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -32,6 +32,23 @@ definitions: info: contact: {} paths: + /callback/github: + get: + consumes: + - application/json + description: Callback for oauth2 providers + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Callback for oauth2 providers + tags: + - auth /posts: get: consumes: diff --git a/backend/go.mod b/backend/go.mod index 4e7eb31..8341c4a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -31,6 +31,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -57,6 +58,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index da97b44..3a6e0ba 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -61,6 +61,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -138,6 +140,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..e836759 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,60 @@ +package auth + +import ( + "fmt" + "os" + "strings" + + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret = []byte(os.Getenv("JWT_SECRET")) + +func GenerateJWT(user storage.User) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ + "id": user.ID, + "email": user.Email, + "github_id": user.GithubID, + }) + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + return "", err + } + return tokenString, nil +} + +func JWTMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + c.AbortWithStatusJSON(401, gin.H{"error": "Bearer required"}) + return + } + + tokenString := strings.TrimPrefix(auth, "Bearer ") + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil || !token.Valid { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid claims"}) + return + } + + c.Set("user_id", int(claims["id"].(float64))) + c.Set("login", claims["login"].(string)) + c.Next() + } +} diff --git a/backend/internal/handlers/auth_handlers.go b/backend/internal/handlers/auth_handlers.go new file mode 100644 index 0000000..37d2dd2 --- /dev/null +++ b/backend/internal/handlers/auth_handlers.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "encoding/json" + "os" + + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger" + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/repositories" + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" +) + +type AuthHandlers struct { + repo repositories.AuthRepository + logger *logger.Logger + config *oauth2.Config +} + +func NewAuthHandlers(repo repositories.AuthRepository) *AuthHandlers { + clientID := os.Getenv("GITHUB_CLIENT_ID") + clientSecret := os.Getenv("GITHUB_CLIENT_SECRET") + redirectURL := os.Getenv("REDIRECT_URL") + + if clientID == "" || clientSecret == "" { + panic("GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be set") + } + if redirectURL == "" { + redirectURL = "http://localhost:8080/api/v1/callback/github" + } + + return &AuthHandlers{ + repo: repo, + logger: logger.New(false), + config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"user:email"}, + Endpoint: endpoints.GitHub, + }, + } +} + +// LoginGithub godoc +// @Summary Start GitHub OAuth login +// @Description Redirects to GitHub authorization +// @Tags auth +// @Success 302 +// @Router /api/v1/auth/github [get] +func (h *AuthHandlers) LoginGithub(c *gin.Context) { + url := h.config.AuthCodeURL("state", oauth2.AccessTypeOnline) + h.logger.Info("Redirect to GitHub: " + url) + c.Redirect(302, url) +} + +// CallbackGithub godoc +// @Summary GitHub OAuth callback +// @Description Exchanges authorization code for access token +// @Tags auth +// @Param code query string true "Authorization code" +// @Produce json +// @Success 200 {object} map[string]interface{} "Access token" +// @Failure 400 {object} map[string]string "Missing code" +// @Failure 500 {object} map[string]string "Exchange failed" +// @Router /callback/github [get] +func (h *AuthHandlers) CallbackGithub(c *gin.Context) { + h.logger.Info("CallbackGithub called") + + code := c.Query("code") + if code == "" { + h.logger.Error("missing code") + c.JSON(400, gin.H{"error": "missing code"}) + return + } + + h.logger.Info("Processing code: " + code[:10] + "...") + + token, err := h.config.Exchange(c.Request.Context(), code) + if err != nil { + h.logger.Error("Exchange failed: " + err.Error()) + c.JSON(500, gin.H{"error": "exchange failed", "details": err.Error()}) + return + } + client := h.config.Client(c.Request.Context(), token) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + h.logger.Error("Get failed: " + err.Error()) + c.JSON(500, gin.H{"error": "get request failed to github", "details": err.Error()}) + return + } + var ghUser storage.UserReg + err = json.NewDecoder(resp.Body).Decode(&ghUser) + if err != nil { + h.logger.Error("Decode failed: " + err.Error()) + c.JSON(500, gin.H{"error": "decode failed", "details": err.Error()}) + return + } + isreg, err := h.repo.IsRegistered(c.Request.Context(), ghUser.GithubID) + if err != nil { + c.JSON(500, gin.H{"error": "database error", "details": err.Error()}) + return + } + if isreg { + c.JSON(200, gin.H{"user": ghUser}) + + reg := h.repo.Register(c.Request.Context(), ghUser) + if reg != nil { + c.JSON(500, gin.H{"error": "database error", "details": reg.Error()}) + err := h.repo.Register(c.Request.Context(), ghUser) + if err != nil { + c.JSON(500, gin.H{"error": "database error", "details": err.Error()}) + h.logger.Error("Database eer in gh callback handler") + } + } + } + +} diff --git a/backend/internal/handlers/post_handlers.go b/backend/internal/handlers/post_handlers.go index 6bcfa63..0e074d4 100644 --- a/backend/internal/handlers/post_handlers.go +++ b/backend/internal/handlers/post_handlers.go @@ -10,15 +10,14 @@ import ( ) type PostHandlers struct { - repo repositories.PostRepository + repo repositories.PostRepository + logger *logger.Logger } -func NewPostHandler(repo repositories.PostRepository) *PostHandlers { - return &PostHandlers{repo: repo} +func NewPostHandlers(repo repositories.PostRepository) *PostHandlers { + return &PostHandlers{repo: repo, logger: logger.New(false)} } -var log = logger.New(false) - // GetPosts godoc // @Summary Get all posts // @Description Get all posts @@ -31,10 +30,10 @@ func (h *PostHandlers) GetPosts(c *gin.Context) { var result []storage.PostReq result, err := h.repo.GetAll(c.Request.Context()) if err != nil { - log.Error("error request: " + err.Error()) + h.logger.Error("error request: " + err.Error()) c.Status(500) } - log.Info("200 OK GET /posts") + h.logger.Info("200 OK GET /posts") c.JSON(200, result) } @@ -54,15 +53,15 @@ func (h *PostHandlers) GetPost(c *gin.Context) { id_p := c.Param("id") id, err := strconv.Atoi(id_p) if err != nil { - log.Error("error request: " + err.Error()) + h.logger.Error("error request: " + err.Error()) c.Status(500) } result, err = h.repo.GetByID(c.Request.Context(), id) if err != nil { - log.Error("error request: " + err.Error()) + h.logger.Error("error request: " + err.Error()) c.Status(500) } - log.Info("200 OK GET /posts/" + id_p) + h.logger.Info("200 OK GET /posts/" + id_p) c.JSON(200, result) // TODO: added validaton for 400 response } @@ -112,7 +111,7 @@ func (h *PostHandlers) UpdatePost(c *gin.Context) { id_p := c.Param("id") id, err := strconv.Atoi(id_p) if err != nil { - log.Error("error request: " + err.Error()) + h.logger.Error("error request: " + err.Error()) c.Status(500) } var req storage.Post @@ -127,7 +126,7 @@ func (h *PostHandlers) UpdatePost(c *gin.Context) { } c.JSON(200, req) - log.Info("200 OK PUT /posts/" + id_p) + h.logger.Info("200 OK PUT /posts/" + id_p) } // DeletePost godoc @@ -139,5 +138,4 @@ func (h *PostHandlers) UpdatePost(c *gin.Context) { // @Success 200 {object} storage.Post // @Router /posts/{id} [delete] func DeletePost(c *gin.Context) { - log.Info("DeletePost") } diff --git a/backend/internal/handlers/registry_handlers.go b/backend/internal/handlers/registry_handlers.go index 9394aac..2109afb 100644 --- a/backend/internal/handlers/registry_handlers.go +++ b/backend/internal/handlers/registry_handlers.go @@ -8,8 +8,12 @@ import ( ) func Register(router *gin.Engine, db *sql.DB) { - handler_posts := NewPostHandler(repositories.NewPostRepository(db)) + handler_posts := NewPostHandlers(repositories.NewPostRepository(db)) + handler_auth := NewAuthHandlers(repositories.NewAuthRepository(db)) + router.GET("/health", func(c *gin.Context) { c.Status(200) }) v1 := router.Group("api/v1") + v1.GET("/callback/github", handler_auth.CallbackGithub) + v1.GET("/auth/github", handler_auth.LoginGithub) posts := v1.Group("posts") { posts.GET("/", handler_posts.GetPosts) diff --git a/backend/internal/repositories/auth_repository.go b/backend/internal/repositories/auth_repository.go new file mode 100644 index 0000000..7ef2caf --- /dev/null +++ b/backend/internal/repositories/auth_repository.go @@ -0,0 +1,41 @@ +package repositories + +import ( + "context" + "database/sql" + + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/logger" + "gitea.d3m0k1d.ru/d3m0k1d/d3m0k1d.ru/backend/internal/storage" +) + +type authRepository struct { + db *sql.DB + logger *logger.Logger +} + +func NewAuthRepository(db *sql.DB) AuthRepository { + return &authRepository{ + db: db, + logger: logger.New(false), + } +} + +func (a *authRepository) Register(ctx context.Context, user storage.UserReg) error { + _, err := a.db.Exec( + "INSERT INTO users(email, github_id, github_login, avatar_url) VALUES(?, ?, ?, ?)", + ) + if err != nil { + a.logger.Error("error scan: " + err.Error()) + return err + } + a.logger.Info("User registered:", "email", user.Email) + return nil +} + +func (a *authRepository) IsRegistered(ctx context.Context, github_id int) (bool, error) { + row := a.db.QueryRow("SELECT id FROM users WHERE github_id = ?", github_id) + if row != nil { + return true, nil + } + return false, nil +} diff --git a/backend/internal/repositories/interface.go b/backend/internal/repositories/interface.go index c22dc8a..1c1cbcc 100644 --- a/backend/internal/repositories/interface.go +++ b/backend/internal/repositories/interface.go @@ -13,3 +13,8 @@ type PostRepository interface { Update(ctx context.Context, id int, post storage.Post) error Delete(ctx context.Context, id int) error } + +type AuthRepository interface { + Register(ctx context.Context, user storage.UserReg) error + IsRegistered(ctx context.Context, github_id int) (bool, error) +} diff --git a/backend/internal/storage/migrations.go b/backend/internal/storage/migrations.go index 5cdf8da..50cd1c3 100644 --- a/backend/internal/storage/migrations.go +++ b/backend/internal/storage/migrations.go @@ -6,5 +6,13 @@ CREATE TABLE IF NOT EXISTS posts( title TEXT NOT NULL, content TEXT NOT NULL, CREATED_AT DATETIME DEFAULT CURRENT_TIMESTAMP - ); +); + +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT, + github_id INTEGER, + github_login TEXT, + avatar_url TEXT +); ` diff --git a/backend/internal/storage/models.go b/backend/internal/storage/models.go index 69c8637..c9503b4 100644 --- a/backend/internal/storage/models.go +++ b/backend/internal/storage/models.go @@ -1,19 +1,34 @@ package storage type Post struct { - ID int `db:"id"` - Title string `db:"title"` - Content string `db:"content"` - CreatedAt string `db:"created_at"` + ID int `db:"id" json:"id"` + Title string `db:"title" json:"title"` + Content string `db:"content" json:"content"` + CreatedAt string `db:"created_at" json:"created_at"` } type PostReq struct { - ID int `json:"id"` - Title string `json:"title"` - Content string `json:"content"` + ID int `db:"id" json:"id"` + Title string `db:"title" json:"title"` + Content string `db:"content" json:"content"` } type PostCreate struct { - Title string `json:"title"` - Content string `json:"content"` + Title string `db:"title" json:"title"` + Content string `db:"content" json:"content"` +} + +type User struct { + ID int `db:"id" json:"id"` + Email string `db:"email" json:"email"` + GithubID int `db:"github_id" json:"github_id"` + GithubLogin string `db:"github_login" json:"github_login"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` +} + +type UserReg struct { + Email string `db:"email" json:"email"` + GithubID int `db:"github_id" json:"github_id"` + GithubLogin string `db:"github_login" json:"github_login"` + AvatarURL string `db:"avatar_url" json:"avatar_url"` } diff --git a/frontend/src/components/Skills.tsx b/frontend/src/components/Skills.tsx index 7e0fccb..cfc45d6 100644 --- a/frontend/src/components/Skills.tsx +++ b/frontend/src/components/Skills.tsx @@ -75,7 +75,7 @@ export default function About() {
Docker, k8s, LXC, Proxmox, Git, CI/CD (GitHub Actions, GitLab), - Ansible, nginx + Ansible, nginx, HashiCorp Vault