diff --git a/.cursor/rules/plan.mdc b/.cursor/rules/plan.mdc index 37d1e41..9b29747 100644 --- a/.cursor/rules/plan.mdc +++ b/.cursor/rules/plan.mdc @@ -19,8 +19,8 @@ alwaysApply: true - [x] **1.1.1**: 在 `go.mod` 中添加 `golang.org/x/oauth2` 依赖。 - [x] **1.1.2**: 创建新的 HTTP 处理器用于处理 OAuth2 流程。 - [x] **1.1.3**: 在 `main.go` 中添加认证路由: - - [x] `GET /auth/github/login` - - [x] `GET /auth/github/callback` + - [x] `GET /auth/login` + - [x] `GET /auth/callback` - [x] **1.1.4**: 实现从 GitHub API 获取用户信息的逻辑。 - [x] **1.1.5**: 引入 JWT 库 (e.g., `github.com/golang-jwt/jwt/v5`)。 - [x] **1.1.6**: 实现用户登录成功后生成和颁发 JWT 的逻辑。 @@ -29,7 +29,7 @@ alwaysApply: true ### 前端 (React) - [x] **1.2.1**: 在 UI 中AppWelcomeScreen中添加"使用 GitHub 登录"按钮。在excalidraw\excalidraw-app\components\AppMainMenu.tsx中添加"登录"按钮。 -- [x] **1.2.2**: 添加api层,实现点击按钮后跳转到后端 `/auth/github/login` 的逻辑。 +- [x] **1.2.2**: 添加api层,实现点击按钮后跳转到后端 `/auth/login` 的逻辑。 - [x] **1.2.3**: 创建一个用于处理登录回调的组件/页面,能从 URL 中解析出 JWT。 - [x] **1.2.4**: 将获取到的 JWT 安全地存储在 `localStorage` 或 `sessionStorage` 中。 - [x] **1.2.5**: 创建一个全局 API 请求封装(如 Axios 拦截器),为所有请求自动附加 `Authorization` 头。 diff --git a/.env.example b/.env.example index ff79f77..03f9454 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ # GitHub OAuth 配置 GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx" GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx" -GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" # 或者你部署后的回调地址 +GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" # 或者你部署后的回调地址 # OIDC 配置 OIDC_ISSUER_URL="http://localhost:5556/.well-known/openid-configuration" # OIDC 提供商地址 OIDC_CLIENT_ID="excalidraw" OIDC_CLIENT_SECRET="excalidraw-secret" -OIDC_REDIRECT_URL="http://localhost:3002/auth/oidc/callback" +OIDC_REDIRECT_URL="http://localhost:3002/auth/callback" # JWT 配置 JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING" diff --git a/.env.example.dex b/.env.example.dex index 3d4b64c..5b03376 100644 --- a/.env.example.dex +++ b/.env.example.dex @@ -1,7 +1,7 @@ OIDC_ISSUER_URL=http://localhost:5556 OIDC_CLIENT_ID=excalidraw OIDC_CLIENT_SECRET=excalidraw-secret -OIDC_REDIRECT_URL=http://localhost:3000/auth/oidc/callback +OIDC_REDIRECT_URL=http://localhost:3000/auth/callback ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W' diff --git a/README.md b/README.md index 3c8f8b1..553728a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ You must configure GitHub OAuth and a JWT secret for the application to function - `GITHUB_CLIENT_ID`: Your GitHub OAuth App's Client ID. - `GITHUB_CLIENT_SECRET`: Your GitHub OAuth App's Client Secret. -- `GITHUB_REDIRECT_URL`: The callback URL. For local testing, this is `http://localhost:3002/auth/github/callback`. +- `GITHUB_REDIRECT_URL`: The callback URL. For local testing, this is `http://localhost:3002/auth/callback`. - `JWT_SECRET`: A strong, random string for signing session tokens. Generate one with `openssl rand -base64 32`. - `OPENAI_API_KEY`: Your secret key from OpenAI. - `OPENAI_BASE_URL`: (Optional) For using compatible APIs like Azure OpenAI. @@ -97,7 +97,7 @@ Create a `.env` file in the project root and add the following, filling in your # Get from https://github.com/settings/developers GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret -GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback +GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback # Generate with: openssl rand -base64 32 JWT_SECRET=your_super_secret_jwt_string @@ -129,7 +129,7 @@ docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile . docker run -p 3002:3002 \ -e GITHUB_CLIENT_ID="your_id" \ -e GITHUB_CLIENT_SECRET="your_secret" \ - -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" \ + -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \ -e JWT_SECRET="your_jwt_secret" \ -e STORAGE_TYPE="sqlite" \ -e DATA_SOURCE_NAME="excalidraw.db" \ diff --git a/README_zh.md b/README_zh.md index ae4c753..6e66c4e 100644 --- a/README_zh.md +++ b/README_zh.md @@ -63,7 +63,7 @@ docker compose up -d - `GITHUB_CLIENT_ID`: 您的 GitHub OAuth App 的 Client ID。 - `GITHUB_CLIENT_SECRET`: 您的 GitHub OAuth App 的 Client Secret。 -- `GITHUB_REDIRECT_URL`: 回调 URL。对于本地测试,这是 `http://localhost:3002/auth/github/callback`。 +- `GITHUB_REDIRECT_URL`: 回调 URL。对于本地测试,这是 `http://localhost:3002/auth/callback`。 - `JWT_SECRET`: 用于签署会话令牌的强随机字符串。使用 `openssl rand -base64 32` 生成一个。 - `OPENAI_API_KEY`: 您在 OpenAI 的秘密密钥。 - `OPENAI_BASE_URL`: (可选) 用于使用兼容的 API,如 Azure OpenAI。 @@ -97,7 +97,7 @@ docker compose up -d # 从 https://github.com/settings/developers 获取 GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret -GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback +GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback # 使用以下命令生成: openssl rand -base64 32 JWT_SECRET=your_super_secret_jwt_string @@ -129,7 +129,7 @@ docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile . docker run -p 3002:3002 \ -e GITHUB_CLIENT_ID="your_id" \ -e GITHUB_CLIENT_SECRET="your_secret" \ - -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" \ + -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \ -e JWT_SECRET="your_jwt_secret" \ -e STORAGE_TYPE="sqlite" \ -e DATA_SOURCE_NAME="excalidraw.db" \ diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml index 4233ec1..560c947 100644 --- a/docker-compose.dex.yml +++ b/docker-compose.dex.yml @@ -5,7 +5,7 @@ services: image: busybox:latest ports: - "5556:5556" # Dex - - "3002:3002" # Excalidraw + - "3004:3002" # Excalidraw command: ["sleep", "infinity"] networks: - excalidraw-network @@ -17,7 +17,7 @@ services: volumes: - ./config/dex.config.yaml:/etc/dex/config.yaml environment: - - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3002/auth/oidc/callback} + - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3002/auth/callback} - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret} - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-excalidraw} - OIDC_ISSUER=${OIDC_ISSUER:-http://localhost:5556} diff --git a/handlers/auth/auth.go b/handlers/auth/auth.go new file mode 100644 index 0000000..f79672b --- /dev/null +++ b/handlers/auth/auth.go @@ -0,0 +1,376 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "excalidraw-complete/core" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + + "encoding/hex" + + "github.com/coreos/go-oidc/v3/oidc" +) + +var ( + loginHandler http.HandlerFunc + callbackHandler http.HandlerFunc +) + +var ( + githubOauthConfig *oauth2.Config + jwtSecret []byte + + oidcOauthConfig *oauth2.Config + oidcProvider *oidc.Provider + verifier *oidc.IDTokenVerifier +) + +// AppClaims represents the custom claims for the JWT. +type AppClaims struct { + jwt.RegisteredClaims + Login string `json:"login"` + AvatarURL string `json:"avatarUrl"` + Name string `json:"name"` +} + +// OIDCClaims represents the claims from OIDC token +type OIDCClaims struct { + Email string `json:"email"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` + Sub string `json:"sub"` +} + +func InitAuth() { + oidcConfigured := os.Getenv("OIDC_ISSUER_URL") != "" && os.Getenv("OIDC_CLIENT_ID") != "" + githubConfigured := os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != "" + + if oidcConfigured { + logrus.Info("Initializing OIDC authentication provider.") + initOIDC() + loginHandler = HandleOIDCLogin + callbackHandler = HandleOIDCCallback + } else if githubConfigured { + logrus.Info("Initializing GitHub authentication provider.") + initGitHub() + loginHandler = HandleGitHubLogin + callbackHandler = HandleGitHubCallback + } else { + logrus.Warn("No authentication provider configured.") + dummyHandler := func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Authentication not configured", http.StatusInternalServerError) + } + loginHandler = dummyHandler + callbackHandler = dummyHandler + } + + jwtSecret = []byte(os.Getenv("JWT_SECRET")) + if len(jwtSecret) == 0 { + logrus.Warn("JWT_SECRET is not set. Authentication will not work.") + } +} + +func HandleLogin(w http.ResponseWriter, r *http.Request) { + if loginHandler != nil { + loginHandler(w, r) + } else { + http.Error(w, "Authentication not configured", http.StatusInternalServerError) + } +} + +func HandleCallback(w http.ResponseWriter, r *http.Request) { + if callbackHandler != nil { + callbackHandler(w, r) + } else { + http.Error(w, "Authentication not configured", http.StatusInternalServerError) + } +} + +func initGitHub() { + githubOauthConfig = &oauth2.Config{ + ClientID: os.Getenv("GITHUB_CLIENT_ID"), + ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"), + Scopes: []string{"read:user", "user:email"}, + Endpoint: github.Endpoint, + } + + if githubOauthConfig.ClientID == "" || githubOauthConfig.ClientSecret == "" { + logrus.Warn("GitHub OAuth credentials are not set. Authentication routes will not work.") + } +} + +func initOIDC() { + providerURL := os.Getenv("OIDC_ISSUER_URL") + clientID := os.Getenv("OIDC_CLIENT_ID") + clientSecret := os.Getenv("OIDC_CLIENT_SECRET") + redirectURL := os.Getenv("OIDC_REDIRECT_URL") + + if providerURL == "" || clientID == "" || clientSecret == "" { + logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.") + return + } + + var err error + oidcProvider, err = oidc.NewProvider(context.Background(), providerURL) + if err != nil { + logrus.Errorf("Failed to create OIDC provider: %s", err.Error()) + return + } + + oidcOauthConfig = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + Endpoint: oidcProvider.Endpoint(), + } + + logrus.Info("OIDC provider initialized") + + verifier = oidcProvider.Verifier(&oidc.Config{ + ClientID: clientID, + }) +} + +// Init function is deprecated, use InitAuth instead +func Init() { + initGitHub() + + jwtSecret = []byte(os.Getenv("JWT_SECRET")) + + if githubOauthConfig.ClientID == "" || githubOauthConfig.ClientSecret == "" { + logrus.Warn("GitHub OAuth credentials are not set. Authentication routes will not work.") + } + if len(jwtSecret) == 0 { + logrus.Warn("JWT_SECRET is not set. Authentication routes will not work.") + } +} + +func generateStateOauthCookie(w http.ResponseWriter) string { + b := make([]byte, 16) + rand.Read(b) + state := base64.URLEncoding.EncodeToString(b) + cookie := &http.Cookie{ + Name: "oauthstate", + Value: state, + Expires: time.Now().Add(10 * time.Minute), + HttpOnly: true, + } + http.SetCookie(w, cookie) + return state +} + +func HandleGitHubLogin(w http.ResponseWriter, r *http.Request) { + if githubOauthConfig.ClientID == "" { + http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) + return + } + state := generateStateOauthCookie(w) + url := githubOauthConfig.AuthCodeURL(state) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) { + if githubOauthConfig.ClientID == "" { + http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) + return + } + + token, err := githubOauthConfig.Exchange(context.Background(), r.FormValue("code")) + if err != nil { + logrus.Errorf("failed to exchange token: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + client := githubOauthConfig.Client(context.Background(), token) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + logrus.Errorf("failed to get user from github: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logrus.Errorf("failed to read github response body: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + var githubUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Name string `json:"name"` + } + + if err := json.Unmarshal(body, &githubUser); err != nil { + logrus.Errorf("failed to unmarshal github user: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + // Create user object using Subject instead of GitHubID + user := &core.User{ + Subject: fmt.Sprintf("github:%d", githubUser.ID), + Login: githubUser.Login, + AvatarURL: githubUser.AvatarURL, + Name: githubUser.Name, + } + + jwtToken, err := createJWT(user) + if err != nil { + logrus.Errorf("failed to create JWT: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + // Redirect to frontend with token + http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect) +} + +func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) { + if oidcOauthConfig == nil { + http.Error(w, "OIDC is not configured", http.StatusInternalServerError) + return + } + + // Generate random state + stateBytes := make([]byte, 16) + _, err := rand.Read(stateBytes) + if err != nil { + http.Error(w, "Failed to generate state for OIDC login", http.StatusInternalServerError) + return + } + state := hex.EncodeToString(stateBytes) + + // Set state in a cookie + http.SetCookie(w, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: "/", + Expires: time.Now().Add(10 * time.Minute), // 10 minutes expiry + HttpOnly: true, + Secure: r.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteLaxMode, + }) + + url := oidcOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if oidcOauthConfig == nil { + http.Error(w, "OIDC is not configured", http.StatusInternalServerError) + return + } + + code := r.FormValue("code") + if code == "" { + logrus.Error("no code in callback") + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + token, err := oidcOauthConfig.Exchange(context.Background(), code) + if err != nil { + logrus.Errorf("failed to exchange token: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + logrus.Error("no id_token in token response") + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + idToken, err := verifier.Verify(context.Background(), rawIDToken) + if err != nil { + logrus.Errorf("failed to verify ID token: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + var claims OIDCClaims + if err := idToken.Claims(&claims); err != nil { + logrus.Errorf("failed to extract claims from ID token: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + // Create user from OIDC claims + user := &core.User{ + Subject: claims.Sub, + Login: claims.PreferredUsername, + Email: claims.Email, + AvatarURL: claims.Picture, + Name: claims.Name, + } + + // If preferred_username is not available, use email + if user.Login == "" && user.Email != "" { + user.Login = user.Email + } + + jwtToken, err := createJWT(user) + if err != nil { + logrus.Errorf("failed to create JWT: %s", err.Error()) + http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + return + } + + // Redirect to frontend with token + http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect) +} + +func createJWT(user *core.User) (string, error) { + claims := AppClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: user.Subject, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + Login: user.Login, + AvatarURL: user.AvatarURL, + Name: user.Name, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +func ParseJWT(tokenString string) (*AppClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &AppClaims{}, 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 { + return nil, err + } + + if claims, ok := token.Claims.(*AppClaims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} diff --git a/handlers/auth/dex.go b/handlers/auth/dex.go deleted file mode 100644 index 0202f29..0000000 --- a/handlers/auth/dex.go +++ /dev/null @@ -1,200 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/hex" - "excalidraw-complete/core" - "fmt" - "net/http" - "os" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt/v5" - "github.com/sirupsen/logrus" - "golang.org/x/oauth2" -) - -var ( - oidcOauthConfig *oauth2.Config - oidcProvider *oidc.Provider - verifier *oidc.IDTokenVerifier -) - -// OIDCClaims represents the claims from OIDC token -type OIDCClaims struct { - Email string `json:"email"` - Name string `json:"name"` - PreferredUsername string `json:"preferred_username"` - Picture string `json:"picture"` - Sub string `json:"sub"` -} - -func InitDex() { - providerURL := os.Getenv("OIDC_ISSUER_URL") - clientID := os.Getenv("OIDC_CLIENT_ID") - clientSecret := os.Getenv("OIDC_CLIENT_SECRET") - redirectURL := os.Getenv("OIDC_REDIRECT_URL") - - if providerURL == "" || clientID == "" || clientSecret == "" { - logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.") - return - } - - var err error - oidcProvider, err = oidc.NewProvider(context.Background(), providerURL) - if err != nil { - logrus.Errorf("Failed to create OIDC provider: %s", err.Error()) - return - } - - oidcOauthConfig = &oauth2.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - RedirectURL: redirectURL, - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, - Endpoint: oidcProvider.Endpoint(), - } - - logrus.Info("OIDC provider initialized") - - verifier = oidcProvider.Verifier(&oidc.Config{ - ClientID: clientID, - }) -} - -func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) { - if oidcOauthConfig == nil { - http.Error(w, "OIDC is not configured", http.StatusInternalServerError) - return - } - - // Generate random state - stateBytes := make([]byte, 16) - _, err := rand.Read(stateBytes) - if err != nil { - http.Error(w, "Failed to generate state for OIDC login", http.StatusInternalServerError) - return - } - state := hex.EncodeToString(stateBytes) - - // Set state in a cookie - http.SetCookie(w, &http.Cookie{ - Name: "oidc_state", - Value: state, - Path: "/", - Expires: time.Now().Add(10 * time.Minute), // 10 minutes expiry - HttpOnly: true, - Secure: r.Header.Get("X-Forwarded-Proto") == "https", - SameSite: http.SameSiteLaxMode, - }) - - url := oidcOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) - http.Redirect(w, r, url, http.StatusTemporaryRedirect) -} - -func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) { - if oidcOauthConfig == nil { - http.Error(w, "OIDC is not configured", http.StatusInternalServerError) - return - } - - // Verify state cookie - stateCookie, err := r.Cookie("oidc_state") - if err != nil { - http.Error(w, "State cookie not found", http.StatusBadRequest) - return - } - - if r.URL.Query().Get("state") != stateCookie.Value { - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - - // Clear state cookie - http.SetCookie(w, &http.Cookie{ - Name: "oidc_state", - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - HttpOnly: true, - Secure: r.Header.Get("X-Forwarded-Proto") == "https", - SameSite: http.SameSiteLaxMode, - }) - - code := r.FormValue("code") - if code == "" { - logrus.Error("no code in callback") - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - token, err := oidcOauthConfig.Exchange(context.Background(), code) - if err != nil { - logrus.Errorf("failed to exchange token: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - logrus.Error("no id_token in token response") - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - idToken, err := verifier.Verify(context.Background(), rawIDToken) - if err != nil { - logrus.Errorf("failed to verify ID token: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - var claims OIDCClaims - if err := idToken.Claims(&claims); err != nil { - logrus.Errorf("failed to extract claims from ID token: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - // Create user from OIDC claims - user := &core.User{ - Subject: claims.Sub, - Login: claims.PreferredUsername, - Email: claims.Email, - AvatarURL: claims.Picture, - Name: claims.Name, - } - - // If preferred_username is not available, use email - if user.Login == "" && user.Email != "" { - user.Login = user.Email - } - - jwtToken, err := createOIDCJWT(user) - if err != nil { - logrus.Errorf("failed to create JWT: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - // Redirect to frontend with token - http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect) -} - -func createOIDCJWT(user *core.User) (string, error) { - claims := AppClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Subject: user.Subject, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - Login: user.Login, - AvatarURL: user.AvatarURL, - Name: user.Name, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} diff --git a/handlers/auth/github.go b/handlers/auth/github.go deleted file mode 100644 index 4986b8a..0000000 --- a/handlers/auth/github.go +++ /dev/null @@ -1,178 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "excalidraw-complete/core" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/sirupsen/logrus" - "golang.org/x/oauth2" - "golang.org/x/oauth2/github" -) - -var ( - githubOauthConfig *oauth2.Config - jwtSecret []byte -) - - -// AppClaims represents the custom claims for the JWT. -type AppClaims struct { - jwt.RegisteredClaims - Login string `json:"login"` - AvatarURL string `json:"avatarUrl"` - Name string `json:"name"` -} - -func Init() { - githubOauthConfig = &oauth2.Config{ - ClientID: os.Getenv("GITHUB_CLIENT_ID"), - ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), - RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"), - Scopes: []string{"read:user", "user:email"}, - Endpoint: github.Endpoint, - } - jwtSecret = []byte(os.Getenv("JWT_SECRET")) - - if githubOauthConfig.ClientID == "" || githubOauthConfig.ClientSecret == "" { - logrus.Warn("GitHub OAuth credentials are not set. Authentication routes will not work.") - } - if len(jwtSecret) == 0 { - logrus.Warn("JWT_SECRET is not set. Authentication routes will not work.") - } -} - -func generateStateOauthCookie(w http.ResponseWriter) string { - b := make([]byte, 16) - rand.Read(b) - state := base64.URLEncoding.EncodeToString(b) - cookie := &http.Cookie{ - Name: "oauthstate", - Value: state, - Expires: time.Now().Add(10 * time.Minute), - HttpOnly: true, - } - http.SetCookie(w, cookie) - return state -} - -func HandleGitHubLogin(w http.ResponseWriter, r *http.Request) { - if githubOauthConfig.ClientID == "" { - http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) - return - } - state := generateStateOauthCookie(w) - url := githubOauthConfig.AuthCodeURL(state) - http.Redirect(w, r, url, http.StatusTemporaryRedirect) -} - -func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) { - if githubOauthConfig.ClientID == "" { - http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) - return - } - - oauthState, _ := r.Cookie("oauthstate") - if r.FormValue("state") != oauthState.Value { - logrus.Error("invalid oauth github state") - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - token, err := githubOauthConfig.Exchange(context.Background(), r.FormValue("code")) - if err != nil { - logrus.Errorf("failed to exchange token: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - client := githubOauthConfig.Client(context.Background(), token) - resp, err := client.Get("https://api.github.com/user") - if err != nil { - logrus.Errorf("failed to get user from github: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - logrus.Errorf("failed to read github response body: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - var githubUser struct { - ID int64 `json:"id"` - Login string `json:"login"` - AvatarURL string `json:"avatar_url"` - Name string `json:"name"` - } - - if err := json.Unmarshal(body, &githubUser); err != nil { - logrus.Errorf("failed to unmarshal github user: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - // Create user object using Subject instead of GitHubID - user := &core.User{ - Subject: fmt.Sprintf("github:%d", githubUser.ID), - Login: githubUser.Login, - AvatarURL: githubUser.AvatarURL, - Name: githubUser.Name, - } - - jwtToken, err := createJWT(user) - if err != nil { - logrus.Errorf("failed to create JWT: %s", err.Error()) - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return - } - - // Redirect to frontend with token - http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect) -} - -func createJWT(user *core.User) (string, error) { - claims := AppClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Subject: user.Subject, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - Login: user.Login, - AvatarURL: user.AvatarURL, - Name: user.Name, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} - -func ParseJWT(tokenString string) (*AppClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &AppClaims{}, 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 { - return nil, err - } - - if claims, ok := token.Claims.(*AppClaims); ok && token.Valid { - return claims, nil - } - - return nil, fmt.Errorf("invalid token") -} diff --git a/main.go b/main.go index e601d72..a1ddd10 100644 --- a/main.go +++ b/main.go @@ -166,14 +166,9 @@ func setupRouter(store stores.Store) *chi.Mux { }) }) - r.Route("/auth/github", func(r chi.Router) { - r.Get("/login", auth.HandleGitHubLogin) - r.Get("/callback", auth.HandleGitHubCallback) - }) - - r.Route("/auth/oidc", func(r chi.Router) { - r.Get("/login", auth.HandleOIDCLogin) - r.Get("/callback", auth.HandleOIDCCallback) + r.Route("/auth", func(r chi.Router) { + r.Get("/login", auth.HandleLogin) + r.Get("/callback", auth.HandleCallback) }) return r @@ -311,8 +306,7 @@ func main() { FullTimestamp: true, }) - auth.Init() - auth.InitDex() + auth.InitAuth() openai.Init() store := stores.GetStore()