From 4c24aeecc352b0d1cd350a14da9b82fe3b324549 Mon Sep 17 00:00:00 2001 From: Zen Kiet Date: Tue, 24 Mar 2026 23:25:27 +0700 Subject: [PATCH] feat(slack): implement slack oauth2 service --- .env.example | 5 ++ backend/cmd/api/main.go | 20 +++---- backend/config/config.go | 27 ++++++---- backend/docs/docs.go | 54 +++++++++++++++++++ backend/docs/swagger.json | 54 +++++++++++++++++++ backend/docs/swagger.yaml | 33 ++++++++++++ backend/go.mod | 2 + backend/go.sum | 4 ++ backend/handler/handler.go | 17 +++--- backend/handler/slack_auth.go | 45 ++++++++++++++++ .../database/migrations/001_init_schema.sql | 27 +++++----- backend/pkg/slack/oauth.go | 41 ++++++++++++++ backend/route/router.go | 15 ++++-- backend/service/auth_service.go | 44 +++++++++++++++ 14 files changed, 339 insertions(+), 49 deletions(-) create mode 100644 backend/handler/slack_auth.go create mode 100644 backend/pkg/slack/oauth.go create mode 100644 backend/service/auth_service.go diff --git a/.env.example b/.env.example index 440a0a3..10116bf 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,11 @@ REDIS_PORT=6379 REDIS_PASSWORD=zenreply REDIS_DB=0 +# --- Slack --- +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +SLACK_SIGNING_SECRET= + # --- JWT --- JWT_SECRET=6ef0c2d5179f616e653915dc66844a763b80ef4ddac87953d4631290fe30de8a JWT_EXPIRATION=24h diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 20683ef..c9564f8 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -21,18 +21,8 @@ // // @tag.name system // @tag.description System health and diagnostics -// @tag.name auth -// @tag.description Slack OAuth 2.0 authentication flow -// @tag.name users -// @tag.description User profile management -// @tag.name deep-work -// @tag.description Deep work session management -// @tag.name settings -// @tag.description User auto-reply configuration -// @tag.name logs -// @tag.description Auto-reply message history // @tag.name slack -// @tag.description Slack Events API webhook +// @tag.description Slack authentication flow package main @@ -49,7 +39,9 @@ import ( "github.com/kietle/zenreply/handler" "github.com/kietle/zenreply/pkg/database" "github.com/kietle/zenreply/pkg/logger" + "github.com/kietle/zenreply/pkg/slack" "github.com/kietle/zenreply/route" + "github.com/kietle/zenreply/service" ) func main() { @@ -87,8 +79,12 @@ func main() { } log.Info("migrations applied successfully") + // --- Service --- + slackOAuth := slack.NewSlackOAuth(cfg) + authSvc := service.NewOAuthService(cfg, rdb, slackOAuth) + // --- Handler --- - h := handler.New(cfg, db, rdb) + h := handler.New(cfg, db, rdb, authSvc) // --- HTTP Server --- router := route.Setup(cfg, h, log) diff --git a/backend/config/config.go b/backend/config/config.go index cd34c2b..331c97a 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -1,12 +1,10 @@ package config import ( - "log" + "fmt" "os" "strconv" "time" - - "github.com/joho/godotenv" ) type AppConfig struct { @@ -44,21 +42,22 @@ type RedisConfig struct { URL string } +type SlackConfig struct { + ClientID string + ClientSecret string + SigningSecret string + RedirectURL string +} + type Config struct { App AppConfig Postgres PostgresConfig Redis RedisConfig JWT JWTConfig + Slack SlackConfig } func Load() *Config { - if os.Getenv("ENV") == "development" { - err := godotenv.Load() - if err != nil { - log.Println(".env not found") - } - } - return &Config{ App: AppConfig{ Port: getEnv("APP_PORT", "8080"), @@ -72,7 +71,7 @@ func Load() *Config { Port: getEnv("POSTGRES_PORT", "5432"), User: getEnv("POSTGRES_USER", "postgres"), Password: getEnv("POSTGRES_PASSWORD", "password"), - DB: getEnv("POSTGRES_DB", "attendance_db"), + DB: getEnv("POSTGRES_DB", "zenreply"), }, Redis: RedisConfig{ Host: getEnv("REDIS_HOST", "localhost"), @@ -84,6 +83,12 @@ func Load() *Config { Secret: getEnv("JWT_SECRET", "secret"), Expiry: getEnvAsDuration("JWT_EXPIRY", 3600*time.Second), }, + Slack: SlackConfig{ + ClientID: getEnv("SLACK_CLIENT_ID", ""), + ClientSecret: getEnv("SLACK_CLIENT_SECRET", ""), + SigningSecret: getEnv("SLACK_SIGNING_SECRET", ""), + RedirectURL: fmt.Sprintf("%s/api/v1/slack/callback", getEnv("APP_BASE_URL", "https://localhost:8080")), + }, } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 34d8db6..8ed1f20 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -64,6 +64,47 @@ const docTemplate = `{ } } } + }, + "/slack/auth": { + "get": { + "description": "Generate a Slack OAuth URL for the user login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "slack" + ], + "summary": "Generate Slack OAuth URL", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_kietle_zenreply_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.SlackAuthURLResponse" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_kietle_zenreply_pkg_response.Response" + } + } + } + } } }, "definitions": { @@ -144,6 +185,19 @@ const docTemplate = `{ "example": "1.0.0" } } + }, + "handler.SlackAuthURLResponse": { + "type": "object", + "properties": { + "state": { + "type": "string", + "example": "123456" + }, + "url": { + "type": "string", + "example": "https://slack.com/oauth/v2/authorize?client_id=1234567890\u0026scope=chat:write,im:write,channels:read,groups:read,users:read,reactions:read\u0026redirect_uri=https://localhost:8080/api/v1/slack/auth/callback" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 70a91a9..fbcf165 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -62,6 +62,47 @@ } } } + }, + "/slack/auth": { + "get": { + "description": "Generate a Slack OAuth URL for the user login", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "slack" + ], + "summary": "Generate Slack OAuth URL", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/github_com_kietle_zenreply_pkg_response.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.SlackAuthURLResponse" + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/github_com_kietle_zenreply_pkg_response.Response" + } + } + } + } } }, "definitions": { @@ -142,6 +183,19 @@ "example": "1.0.0" } } + }, + "handler.SlackAuthURLResponse": { + "type": "object", + "properties": { + "state": { + "type": "string", + "example": "123456" + }, + "url": { + "type": "string", + "example": "https://slack.com/oauth/v2/authorize?client_id=1234567890\u0026scope=chat:write,im:write,channels:read,groups:read,users:read,reactions:read\u0026redirect_uri=https://localhost:8080/api/v1/slack/auth/callback" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index bd8d971..f21672b 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -53,6 +53,15 @@ definitions: example: 1.0.0 type: string type: object + handler.SlackAuthURLResponse: + properties: + state: + example: "123456" + type: string + url: + example: https://slack.com/oauth/v2/authorize?client_id=1234567890&scope=chat:write,im:write,channels:read,groups:read,users:read,reactions:read&redirect_uri=https://localhost:8080/api/v1/slack/auth/callback + type: string + type: object host: localhost:8080 info: contact: @@ -91,6 +100,30 @@ paths: summary: Health check tags: - system + /slack/auth: + get: + consumes: + - application/json + description: Generate a Slack OAuth URL for the user login + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/github_com_kietle_zenreply_pkg_response.Response' + - properties: + data: + $ref: '#/definitions/handler.SlackAuthURLResponse' + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_kietle_zenreply_pkg_response.Response' + summary: Generate Slack OAuth URL + tags: + - slack schemes: - http - https diff --git a/backend/go.mod b/backend/go.mod index 7240cd6..b0c2703 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -25,6 +25,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/gorilla/websocket v1.5.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -58,6 +59,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.18.0 + github.com/slack-go/slack v0.20.0 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 diff --git a/backend/go.sum b/backend/go.sum index c5c0f55..7fae254 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -48,6 +48,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk 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= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -91,6 +93,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/slack-go/slack v0.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU= +github.com/slack-go/slack v0.20.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/backend/handler/handler.go b/backend/handler/handler.go index bf2bd8e..2274631 100644 --- a/backend/handler/handler.go +++ b/backend/handler/handler.go @@ -3,19 +3,22 @@ package handler import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/kietle/zenreply/config" + "github.com/kietle/zenreply/service" "github.com/redis/go-redis/v9" ) type Handler struct { - config *config.Config - db *pgxpool.Pool - rdb *redis.Client + config *config.Config + db *pgxpool.Pool + rdb *redis.Client + authSvc *service.AuthService } -func New(cfg *config.Config, db *pgxpool.Pool, rdb *redis.Client) *Handler { +func New(cfg *config.Config, db *pgxpool.Pool, rdb *redis.Client, authSvc *service.AuthService) *Handler { return &Handler{ - config: cfg, - db: db, - rdb: rdb, + config: cfg, + db: db, + rdb: rdb, + authSvc: authSvc, } } diff --git a/backend/handler/slack_auth.go b/backend/handler/slack_auth.go new file mode 100644 index 0000000..597ab83 --- /dev/null +++ b/backend/handler/slack_auth.go @@ -0,0 +1,45 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/kietle/zenreply/pkg/response" +) + +type SlackAuthURLResponse struct { + URL string `json:"url" example:"https://slack.com/oauth/v2/authorize?client_id=1234567890&scope=chat:write,im:write,channels:read,groups:read,users:read,reactions:read&redirect_uri=https://localhost:8080/api/v1/slack/auth/callback"` + State string `json:"state" example:"123456"` +} + +type SlackAuthCallbackRequest struct { + Token string `json:"token" example:"xoxb-1234567890"` + UserID string `json:"user_id" example:"U0123456789"` + SlackID string `json:"slack_id" example:"U0123456789"` + Name string `json:"name" example:"Zen Kiet"` + Email string `json:"email" example:"zenkiet0906@gmail.com"` + Avatar string `json:"avatar" example:"https://avatars.slack-edge.com/2026-03-24/1234567890_1234567890_1234567890.png"` +} + +// SlackAuthURL godoc +// @Summary Generate Slack OAuth URL +// @Description Generate a Slack OAuth URL for the user login +// @Tags slack +// @Accept json +// @Produce json +// +// @Success 200 {object} response.Response{data=SlackAuthURLResponse} +// @Failure 500 {object} response.Response +// @Router /slack/auth [get] +func (h *Handler) SlackAuthURL(c *gin.Context) { + ctx := c.Request.Context() + + url, state, err := h.authSvc.BuildAuthURL(ctx) + if err != nil { + response.InternalServerError(c, err.Error()) + return + } + + response.OK(c, "Slack authentication URL generated successfully", SlackAuthURLResponse{ + URL: url, + State: state, + }) +} diff --git a/backend/pkg/database/migrations/001_init_schema.sql b/backend/pkg/database/migrations/001_init_schema.sql index eb564f1..6d163f8 100644 --- a/backend/pkg/database/migrations/001_init_schema.sql +++ b/backend/pkg/database/migrations/001_init_schema.sql @@ -11,9 +11,7 @@ CREATE TABLE IF NOT EXISTS users ( slack_name VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(255) NOT NULL DEFAULT '', avatar_url TEXT NOT NULL DEFAULT '', - -- access_token is stored encrypted (AES-256-GCM) access_token TEXT NOT NULL DEFAULT '', - bot_token TEXT NOT NULL DEFAULT '', token_scope TEXT NOT NULL DEFAULT '', is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -27,19 +25,18 @@ CREATE INDEX IF NOT EXISTS idx_users_slack_team_id ON users(slack_team_id); -- TABLE: user_settings -- ============================================================ CREATE TABLE IF NOT EXISTS user_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - default_message TEXT NOT NULL DEFAULT 'I am currently in a deep work session and will reply as soon as I am available.', - default_reason VARCHAR(255) NOT NULL DEFAULT 'Deep Work', - cooldown_minutes INT NOT NULL DEFAULT 3, - -- whitelist and blacklist stored as JSON arrays of Slack user IDs - whitelist JSONB NOT NULL DEFAULT '[]', - blacklist JSONB NOT NULL DEFAULT '[]', - reply_in_thread BOOLEAN NOT NULL DEFAULT TRUE, - notify_on_resume BOOLEAN NOT NULL DEFAULT FALSE, - auto_reply_enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + default_message TEXT NOT NULL DEFAULT 'I am currently in a deep work session and will reply as soon as I am available.', + default_reason VARCHAR(255) NOT NULL DEFAULT 'Deep Work', + cooldown_minutes INT NOT NULL DEFAULT 3, + whitelist JSONB NOT NULL DEFAULT '[]', + blacklist JSONB NOT NULL DEFAULT '[]', + reply_in_thread BOOLEAN NOT NULL DEFAULT TRUE, + notify_on_resume BOOLEAN NOT NULL DEFAULT FALSE, + auto_reply_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); diff --git a/backend/pkg/slack/oauth.go b/backend/pkg/slack/oauth.go new file mode 100644 index 0000000..eb6beae --- /dev/null +++ b/backend/pkg/slack/oauth.go @@ -0,0 +1,41 @@ +package slack + +import ( + "fmt" + + "github.com/kietle/zenreply/config" +) + +const userScopes = "chat:write,im:history,im:read,mpim:history,mpim:read,channels:history,groups:history,users:read,users:read.email" + +type SlackOAuth struct { + cfg *config.Config +} + +type SlackAuthResult struct { + AccessToken string `json:"access_token"` + BotToken string `json:"bot_token"` + TokenScope string `json:"token_scope"` + SlackUserID string `json:"slack_user_id"` + SlackTeamID string `json:"slack_team_id"` + SlackName string `json:"slack_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func NewSlackOAuth(cfg *config.Config) *SlackOAuth { + return &SlackOAuth{ + cfg: cfg, + } +} + +// https://docs.slack.dev/authentication/installing-with-oauth/ +func (s *SlackOAuth) BuildAuthURL(state string) string { + return fmt.Sprintf( + "https://slack.com/oauth/v2/authorize?client_id=%s&user_scope=%s&redirect_uri=%s&state=%s", + s.cfg.Slack.ClientID, + userScopes, + s.cfg.Slack.RedirectURL, + state, + ) +} diff --git a/backend/route/router.go b/backend/route/router.go index c88afac..e54d8d1 100644 --- a/backend/route/router.go +++ b/backend/route/router.go @@ -32,12 +32,19 @@ func Setup(cfg *config.Config, h *handler.Handler, logger *slog.Logger) *gin.Eng c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, scalarHTML(cfg.App.BaseURL)) }) + r.GET("/openapi.json", func(c *gin.Context) { + c.File("./docs/swagger.json") + }) - // --- Routes --- + // --- Systems --- r.GET("/health", h.HealthCheck) - r.GET("/ping", func(c *gin.Context) { - response.OK(c, "ping", nil) - }) + + // --- API Routes --- + v1 := r.Group("/api/v1") + auth := v1.Group("/slack") + { + auth.GET("/auth", h.SlackAuthURL) + } //--- Not Found Handler --- r.NoRoute(func(c *gin.Context) { diff --git a/backend/service/auth_service.go b/backend/service/auth_service.go new file mode 100644 index 0000000..f8c72c3 --- /dev/null +++ b/backend/service/auth_service.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/kietle/zenreply/config" + "github.com/kietle/zenreply/pkg/slack" + "github.com/redis/go-redis/v9" +) + +type AuthService struct { + cfg *config.Config + slackOAuth *slack.SlackOAuth + rdb *redis.Client +} + +func NewOAuthService(cfg *config.Config, rdb *redis.Client, slackOAuth *slack.SlackOAuth) *AuthService { + return &AuthService{ + cfg: cfg, + rdb: rdb, + slackOAuth: slackOAuth, + } +} + +func (s *AuthService) ValidateOAuthState(ctx context.Context, state string) (bool, error) { + value, err := s.rdb.Get(ctx, fmt.Sprintf("oauth:state:%s", state)).Result() + if err != nil { + return false, fmt.Errorf("failed to get OAuth state: %w", err) + } + return value == state, nil +} + +func (s *AuthService) BuildAuthURL(ctx context.Context) (string, string, error) { + // Generate a new OAuth state + state := uuid.New().String() + s.rdb.Set(ctx, fmt.Sprintf("oauth:state:%s", state), state, time.Hour*24) + + // Build the Slack OAuth URL + url := s.slackOAuth.BuildAuthURL(state) + return url, state, nil +}