package handlers import ( "context" "crypto/hmac" cryptorand "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/github" "golang.org/x/oauth2/google" "sproutgate-backend/internal/auth" "sproutgate-backend/internal/models" ) const oauthStateTTL = 20 * time.Minute func microsoftV2Endpoint(tenant string) oauth2.Endpoint { t := strings.TrimSpace(tenant) if t == "" { t = "common" } base := "https://login.microsoftonline.com/" + t return oauth2.Endpoint{ AuthURL: base + "/oauth2/v2.0/authorize", TokenURL: base + "/oauth2/v2.0/token", } } func isOAuthProvider(p string) bool { switch strings.ToLower(strings.TrimSpace(p)) { case "github", "gitea", "google", "microsoft", "linuxdo": return true default: return false } } type oauthStatePayload struct { Exp int64 `json:"exp"` Nonce string `json:"n"` Ret string `json:"r"` Mode string `json:"m"` // login | bind Acc string `json:"a"` Prov string `json:"p"` } func publicAPIBase(c *gin.Context) string { if b := os.Getenv("PUBLIC_API_BASE"); strings.TrimSpace(b) != "" { return strings.TrimRight(strings.TrimSpace(b), "/") } host := c.Request.Host if host == "" { return "" } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { scheme = "https" } return scheme + "://" + host } func (h *Handler) signOAuthState(p oauthStatePayload) (string, error) { if p.Exp == 0 { p.Exp = time.Now().Add(oauthStateTTL).Unix() } raw, err := json.Marshal(p) if err != nil { return "", err } mac := hmac.New(sha256.New, h.store.JWTSecret()) _, _ = mac.Write(raw) sig := mac.Sum(nil) return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil } func (h *Handler) parseOAuthState(s string) (oauthStatePayload, error) { parts := strings.Split(s, ".") if len(parts) != 2 { return oauthStatePayload{}, errors.New("invalid state") } raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return oauthStatePayload{}, err } wantSig, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return oauthStatePayload{}, err } mac := hmac.New(sha256.New, h.store.JWTSecret()) _, _ = mac.Write(raw) if !hmac.Equal(mac.Sum(nil), wantSig) { return oauthStatePayload{}, errors.New("bad signature") } var p oauthStatePayload if err := json.Unmarshal(raw, &p); err != nil { return oauthStatePayload{}, err } if time.Now().Unix() > p.Exp { return oauthStatePayload{}, errors.New("state expired") } return p, nil } func randomNonce() (string, error) { b := make([]byte, 16) if _, err := cryptorand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func (h *Handler) oAuth2Config(c *gin.Context, provider string) (oauth2.Config, error) { cfg := h.store.GetOAuthConfig() redirect := publicAPIBase(c) if redirect == "" { return oauth2.Config{}, errors.New("cannot resolve public API base (set PUBLIC_API_BASE)") } var oc oauth2.Config switch provider { case "github": if !cfg.GitHubEnabled { return oc, errors.New("github oauth disabled") } secret := strings.TrimSpace(os.Getenv("GITHUB_CLIENT_SECRET")) if secret == "" { secret = cfg.GitHubClientSecret } if strings.TrimSpace(cfg.GitHubClientID) == "" || secret == "" { return oc, errors.New("github oauth not configured") } oc = oauth2.Config{ ClientID: strings.TrimSpace(cfg.GitHubClientID), ClientSecret: secret, RedirectURL: redirect + "/api/auth/oauth/github/callback", Scopes: []string{"read:user", "user:email"}, Endpoint: github.Endpoint, } return oc, nil case "gitea": if !cfg.GiteaEnabled { return oc, errors.New("gitea oauth disabled") } secret := strings.TrimSpace(os.Getenv("GITEA_CLIENT_SECRET")) if secret == "" { secret = cfg.GiteaClientSecret } base := strings.TrimRight(cfg.GiteaBaseURL, "/") if base == "" { base = "https://git.shumengya.top" } if strings.TrimSpace(cfg.GiteaClientID) == "" || secret == "" { return oc, errors.New("gitea oauth not configured") } oc = oauth2.Config{ ClientID: strings.TrimSpace(cfg.GiteaClientID), ClientSecret: secret, RedirectURL: redirect + "/api/auth/oauth/gitea/callback", Scopes: []string{"read:user"}, Endpoint: oauth2.Endpoint{ AuthURL: base + "/login/oauth/authorize", TokenURL: base + "/login/oauth/access_token", }, } return oc, nil case "google": if !cfg.GoogleEnabled { return oc, errors.New("google oauth disabled") } secret := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_SECRET")) if secret == "" { secret = cfg.GoogleClientSecret } if strings.TrimSpace(cfg.GoogleClientID) == "" || secret == "" { return oc, errors.New("google oauth not configured") } oc = oauth2.Config{ ClientID: strings.TrimSpace(cfg.GoogleClientID), ClientSecret: secret, RedirectURL: redirect + "/api/auth/oauth/google/callback", Scopes: []string{"openid", "email", "profile"}, Endpoint: google.Endpoint, } return oc, nil case "microsoft": if !cfg.MicrosoftEnabled { return oc, errors.New("microsoft oauth disabled") } secret := strings.TrimSpace(os.Getenv("MICROSOFT_CLIENT_SECRET")) if secret == "" { secret = cfg.MicrosoftClientSecret } tenant := strings.TrimSpace(cfg.MicrosoftTenant) if tenant == "" { tenant = "common" } if strings.TrimSpace(cfg.MicrosoftClientID) == "" || secret == "" { return oc, errors.New("microsoft oauth not configured") } oc = oauth2.Config{ ClientID: strings.TrimSpace(cfg.MicrosoftClientID), ClientSecret: secret, RedirectURL: redirect + "/api/auth/oauth/microsoft/callback", Scopes: []string{"openid", "profile", "email", "https://graph.microsoft.com/User.Read"}, Endpoint: microsoftV2Endpoint(tenant), } return oc, nil case "linuxdo": if !cfg.LinuxdoEnabled { return oc, errors.New("linuxdo oauth disabled") } secret := strings.TrimSpace(os.Getenv("LINUXDO_CLIENT_SECRET")) if secret == "" { secret = cfg.LinuxdoClientSecret } base := strings.TrimRight(cfg.LinuxdoConnectBaseURL, "/") if base == "" { base = "https://connect.linux.do" } if strings.TrimSpace(cfg.LinuxdoClientID) == "" || secret == "" { return oc, errors.New("linuxdo oauth not configured") } oc = oauth2.Config{ ClientID: strings.TrimSpace(cfg.LinuxdoClientID), ClientSecret: secret, RedirectURL: redirect + "/api/auth/oauth/linuxdo/callback", Scopes: []string{"read:user"}, Endpoint: oauth2.Endpoint{ AuthURL: base + "/oauth2/authorize", TokenURL: base + "/oauth2/token", }, } return oc, nil default: return oc, errors.New("unknown provider") } } // OAuthStart // @Summary OAuth 登录起点(重定向 IdP) // @Tags oauth // @Produce json // @Param provider path string true "github|gitea|google|microsoft|linuxdo" // @Param return_to query string true "登录完成回跳 URL(须符合后台允许前缀)" // @Param turnstile_token query string false "Turnstile 开启时必填" // @Success 302 "重定向至 IdP" // @Failure 400 {object} map[string]interface{} // @Failure 404 {object} map[string]interface{} // @Failure 500 {object} map[string]interface{} // @Failure 503 {object} map[string]interface{} // @Router /api/auth/oauth/{provider}/start [get] func (h *Handler) OAuthStart(c *gin.Context) { provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) if !isOAuthProvider(provider) { c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) return } h.store.MaybeSyncRuntimeConfigFromDB() ret := strings.TrimSpace(c.Query("return_to")) cfg := h.store.GetOAuthConfig() if !cfg.IsReturnURLAllowed(ret) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid return_to (not allowed for OAuth redirect)"}) return } if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled { tok := strings.TrimSpace(c.Query("turnstile_token")) if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, tok, c.ClientIP()); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } nonce, err := randomNonce() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"}) return } st, err := h.signOAuthState(oauthStatePayload{ Exp: time.Now().Add(oauthStateTTL).Unix(), Nonce: nonce, Ret: ret, Mode: "login", Prov: provider, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"}) return } oac, err := h.oAuth2Config(c, provider) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } url := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce) c.Redirect(http.StatusFound, url) } // OAuthBindURL // @Summary 已登录用户绑定 OAuth(返回授权 URL) // @Tags oauth // @Accept json // @Produce json // @Security BearerAuth // @Param provider path string true "github|gitea|google|microsoft|linuxdo" // @Param body body OAuthBindURLRequest true "returnTo" // @Success 200 {object} map[string]interface{} "url" // @Failure 400 {object} map[string]interface{} // @Failure 401 {object} map[string]interface{} // @Failure 500 {object} map[string]interface{} // @Failure 503 {object} map[string]interface{} // @Router /api/auth/oauth/{provider}/bind [post] func (h *Handler) OAuthBindURL(c *gin.Context) { provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) if !isOAuthProvider(provider) { c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) return } tok := bearerToken(c.GetHeader("Authorization")) if tok == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } bindUser, bfound, berr := h.store.GetUser(claims.Account) if berr == nil && bfound && bindUser.Banned { writeBanJSON(c, bindUser.BanReason) return } var req OAuthBindURLRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } ret := strings.TrimSpace(req.ReturnTo) if ret == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "returnTo is required"}) return } if !h.store.GetOAuthConfig().IsReturnURLAllowed(ret) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid returnTo"}) return } nonce, err := randomNonce() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"}) return } st, err := h.signOAuthState(oauthStatePayload{ Exp: time.Now().Add(oauthStateTTL).Unix(), Nonce: nonce, Ret: ret, Mode: "bind", Acc: claims.Account, Prov: provider, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"}) return } oac, err := h.oAuth2Config(c, provider) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } authu := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce) c.JSON(http.StatusOK, gin.H{"url": authu}) } // OAuthUnlink // @Summary 解绑 OAuth 提供商 // @Tags oauth // @Accept json // @Produce json // @Security BearerAuth // @Param provider path string true "github|gitea|google|microsoft|linuxdo" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]interface{} // @Failure 401 {object} map[string]interface{} // @Failure 500 {object} map[string]interface{} // @Router /api/auth/oauth/{provider} [delete] func (h *Handler) OAuthUnlink(c *gin.Context) { provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) if !isOAuthProvider(provider) { c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) return } tok := bearerToken(c.GetHeader("Authorization")) if tok == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) return } claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } acc := claims.Account var err2 error switch provider { case "github": err2 = h.store.ClearUserGitHubID(acc) case "gitea": err2 = h.store.ClearUserGiteaID(acc) case "google": err2 = h.store.ClearUserGoogleID(acc) case "microsoft": err2 = h.store.ClearUserMicrosoftID(acc) case "linuxdo": err2 = h.store.ClearUserLinuxdoID(acc) } if err2 != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err2.Error()}) return } user, found, e := h.store.GetUser(acc) if e != nil || !found { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) return } if abortIfUserBanned(c, user) { return } if abortIfTokenEpochStale(c, claims, user) { return } c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()}) } // OAuthCallback // @Summary OAuth 回调(IdP 重定向) // @Description 成功时通常 302 回 return_to 并带 token;失败时 JSON 或重定向错误页。 // @Tags oauth // @Produce json // @Param provider path string true "提供商" // @Param code query string false "授权码" // @Param state query string false "state" // @Param error query string false "IdP 错误码" // @Success 302 "重定向至客户端" // @Success 200 {object} map[string]interface{} // @Failure 400 {object} map[string]interface{} // @Failure 404 {object} map[string]interface{} // @Router /api/auth/oauth/{provider}/callback [get] func (h *Handler) OAuthCallback(c *gin.Context) { provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) if !isOAuthProvider(provider) { c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) return } h.store.MaybeSyncRuntimeConfigFromDB() if errParam := strings.TrimSpace(c.Query("error")); errParam != "" { if st := strings.TrimSpace(c.Query("state")); st != "" { if p, perr := h.parseOAuthState(st); perr == nil { h.redirectOAuthError(c, p.Ret, c.Query("error_description")) return } } c.String(http.StatusBadRequest, "oauth error: %s", errParam) return } code := strings.TrimSpace(c.Query("code")) st := strings.TrimSpace(c.Query("state")) if code == "" || st == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"}) return } p, err := h.parseOAuthState(st) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) return } if p.Prov != provider { c.JSON(http.StatusBadRequest, gin.H{"error": "provider mismatch"}) return } if !h.store.GetOAuthConfig().IsReturnURLAllowed(p.Ret) { c.JSON(http.StatusBadRequest, gin.H{"error": "stale return url"}) return } oac, err := h.oAuth2Config(c, provider) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } ctx := c.Request.Context() t, err := oac.Exchange(ctx, code) if err != nil { h.redirectOAuthError(c, p.Ret, "token_exchange_failed") return } var extID, email, username, avatar string oCfg := h.store.GetOAuthConfig() switch provider { case "github": extID, email, username, avatar, err = h.fetchGitHubUser(ctx, t.AccessToken) case "gitea": extID, email, username, avatar, err = h.fetchGiteaUser(ctx, oCfg.GiteaBaseURL, t.AccessToken) case "google": extID, email, username, avatar, err = h.fetchGoogleUser(ctx, t.AccessToken) case "microsoft": extID, email, username, avatar, err = h.fetchMicrosoftUser(ctx, t.AccessToken) case "linuxdo": extID, email, username, avatar, err = h.fetchLinuxdoUser(ctx, oCfg.LinuxdoConnectBaseURL, t.AccessToken) } if err != nil || extID == "" { h.redirectOAuthError(c, p.Ret, "userinfo_failed") return } if p.Mode == "bind" { acc := strings.TrimSpace(p.Acc) if acc == "" { h.redirectOAuthError(c, p.Ret, "bind_no_account") return } switch provider { case "github": err = h.store.SetUserGitHubID(acc, extID) case "gitea": err = h.store.SetUserGiteaID(acc, extID) case "google": err = h.store.SetUserGoogleID(acc, extID) case "microsoft": err = h.store.SetUserMicrosoftID(acc, extID) case "linuxdo": err = h.store.SetUserLinuxdoID(acc, extID) } if err != nil { h.redirectOAuthError(c, p.Ret, err.Error()) return } h.redirectOAuthBindDone(c, p.Ret) return } // login var linkedUser models.UserRecord var linkedOK bool var dberr error switch provider { case "github": linkedUser, linkedOK, dberr = h.store.GetUserByGitHubID(extID) case "gitea": linkedUser, linkedOK, dberr = h.store.GetUserByGiteaID(extID) case "google": linkedUser, linkedOK, dberr = h.store.GetUserByGoogleID(extID) case "microsoft": linkedUser, linkedOK, dberr = h.store.GetUserByMicrosoftID(extID) case "linuxdo": linkedUser, linkedOK, dberr = h.store.GetUserByLinuxdoID(extID) } if dberr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } if linkedOK { if linkedUser.Banned { c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": linkedUser.BanReason}) return } h.issueTokenAndRedirect(c, p.Ret, linkedUser) return } if !h.store.OAuthSignUpAllowed() { h.redirectOAuthError(c, p.Ret, "oauth_signup_disabled") return } email = strings.TrimSpace(email) if email != "" { _, efound, eerr := h.store.GetUserByEmail(email) if eerr != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } if efound { h.redirectOAuthError(c, p.Ret, "email_already_registered") return } } acc, err := h.store.ProposeUniqueOAuthAccount() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "account allocation failed"}) return } if email == "" { email = acc + "@oauth.local.sproutgate" } rp := make([]byte, 40) _, _ = cryptorand.Read(rp) randPass := base64.RawURLEncoding.EncodeToString(rp) hash, err := bcrypt.GenerateFromPassword([]byte(randPass), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"}) return } rec := models.UserRecord{ Account: acc, PasswordHash: string(hash), Username: strings.TrimSpace(username), Email: email, Level: 0, SproutCoins: 0, AvatarURL: strings.TrimSpace(avatar), CreatedAt: models.NowISO(), UpdatedAt: models.NowISO(), } switch provider { case "github": rec.GitHubUserID = extID case "gitea": rec.GiteaUserID = extID case "google": rec.GoogleUserID = extID case "microsoft": rec.MicrosoftUserID = extID case "linuxdo": rec.LinuxdoUserID = extID } if err := h.store.CreateUser(rec); err != nil { h.redirectOAuthError(c, p.Ret, "create_user_failed") return } if u, found, e := h.store.GetUser(rec.Account); e == nil && found { h.issueTokenAndRedirect(c, p.Ret, u) } } func (h *Handler) issueTokenAndRedirect(c *gin.Context, returnTo string, u models.UserRecord) { if u.Banned { c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": u.BanReason}) return } token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), u.Account, u.TokenEpoch, 7*24*time.Hour) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "token failed"}) return } h.redirectOAuthToken(c, returnTo, token, expiresAt.Format(time.RFC3339)) } func (h *Handler) redirectOAuthToken(c *gin.Context, returnTo, token, expiresAt string) { frag := url.Values{} frag.Set("oauth_token", token) frag.Set("oauth_expires_at", expiresAt) u := returnTo if i := strings.IndexByte(u, '#'); i >= 0 { u = u[:i] } c.Redirect(http.StatusFound, u+"#"+frag.Encode()) } func (h *Handler) redirectOAuthBindDone(c *gin.Context, returnTo string) { frag := url.Values{} frag.Set("oauth_bound", "1") u := returnTo if i := strings.IndexByte(u, '#'); i >= 0 { u = u[:i] } c.Redirect(http.StatusFound, u+"#"+frag.Encode()) } func (h *Handler) redirectOAuthError(c *gin.Context, returnTo, desc string) { u := returnTo if i := strings.IndexByte(u, '#'); i >= 0 { u = u[:i] } frag := url.Values{} frag.Set("oauth_error", "1") if desc != "" { frag.Set("oauth_error_description", desc) } if u == "" { c.String(http.StatusBadRequest, "oauth error: %s", desc) return } c.Redirect(http.StatusFound, u+"#"+frag.Encode()) } func (h *Handler) fetchGitHubUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { type gh struct { ID int64 `json:"id"` Login string `json:"login"` Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) if err != nil { return "", "", "", "", err } req.Header.Set("Authorization", "Bearer "+access) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "sproutgate-oauth") res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", "", "", "", rerr } if res.StatusCode != http.StatusOK { return "", "", "", "", errors.New("github user api: " + string(b)) } var u gh if err := json.Unmarshal(b, &u); err != nil { return "", "", "", "", err } email = strings.TrimSpace(u.Email) username = strings.TrimSpace(u.Name) if username == "" { username = u.Login } if email == "" { email, err = h.fetchGitHubPrimaryEmail(ctx, access) if err != nil { email = "" } } avatar = strings.TrimSpace(u.AvatarURL) return strconv.FormatInt(u.ID, 10), strings.TrimSpace(email), username, avatar, nil } func (h *Handler) fetchGitHubPrimaryEmail(ctx context.Context, access string) (string, error) { type em struct { Email string `json:"email"` Primary bool `json:"primary"` } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+access) req.Header.Set("User-Agent", "sproutgate-oauth") res, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", rerr } if res.StatusCode != http.StatusOK { return "", errors.New("github emails") } var list []em if err := json.Unmarshal(b, &list); err != nil { return "", err } for _, e := range list { if e.Primary { return strings.TrimSpace(e.Email), nil } } if len(list) > 0 { return strings.TrimSpace(list[0].Email), nil } return "", nil } func (h *Handler) fetchGiteaUser(ctx context.Context, giteaBase, access string) (id, email, username, avatar string, err error) { base := strings.TrimRight(strings.TrimSpace(giteaBase), "/") if base == "" { base = "https://git.shumengya.top" } type gu struct { ID int64 `json:"id"` Login string `json:"login"` Email string `json:"email"` FullName string `json:"full_name"` AvatarURL string `json:"avatar_url"` } req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil) if err != nil { return "", "", "", "", err } req.Header.Set("Authorization", "token "+access) res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", "", "", "", rerr } if res.StatusCode != http.StatusOK { return "", "", "", "", errors.New("gitea user api") } var u gu if err := json.Unmarshal(b, &u); err != nil { return "", "", "", "", err } username = strings.TrimSpace(u.FullName) if username == "" { username = u.Login } avatar = strings.TrimSpace(u.AvatarURL) return strconv.FormatInt(u.ID, 10), strings.TrimSpace(u.Email), username, avatar, nil } func (h *Handler) fetchGoogleUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { type gu struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Picture string `json:"picture"` } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) if err != nil { return "", "", "", "", err } req.Header.Set("Authorization", "Bearer "+access) res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", "", "", "", rerr } if res.StatusCode != http.StatusOK { return "", "", "", "", errors.New("google userinfo: " + string(b)) } var u gu if err := json.Unmarshal(b, &u); err != nil { return "", "", "", "", err } id = strings.TrimSpace(u.ID) if id == "" { return "", "", "", "", errors.New("google userinfo: missing id") } email = strings.TrimSpace(u.Email) username = strings.TrimSpace(u.Name) avatar = strings.TrimSpace(u.Picture) return id, email, username, avatar, nil } func (h *Handler) fetchMicrosoftUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { type mu struct { ID string `json:"id"` Mail string `json:"mail"` UserPrincipalName string `json:"userPrincipalName"` DisplayName string `json:"displayName"` } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me", nil) if err != nil { return "", "", "", "", err } req.Header.Set("Authorization", "Bearer "+access) res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", "", "", "", rerr } if res.StatusCode != http.StatusOK { return "", "", "", "", errors.New("microsoft graph me: " + string(b)) } var u mu if err := json.Unmarshal(b, &u); err != nil { return "", "", "", "", err } id = strings.TrimSpace(u.ID) if id == "" { return "", "", "", "", errors.New("microsoft graph: missing id") } email = strings.TrimSpace(u.Mail) if email == "" { upn := strings.TrimSpace(u.UserPrincipalName) if strings.Contains(upn, "@") { email = upn } } username = strings.TrimSpace(u.DisplayName) return id, email, username, "", nil } // fetchLinuxdoUser 调用 LINUX DO Connect 用户接口(/api/user)。 // 文档: https://connect.linux.do 应用接入中给出的 User Endpoint。 func (h *Handler) fetchLinuxdoUser(ctx context.Context, connectBase, access string) (id, email, username, avatar string, err error) { base := strings.TrimRight(strings.TrimSpace(connectBase), "/") if base == "" { base = "https://connect.linux.do" } req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/user", nil) if err != nil { return "", "", "", "", err } req.Header.Set("Authorization", "Bearer "+access) req.Header.Set("Accept", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", "", err } defer res.Body.Close() b, rerr := readResponseBody(res) if rerr != nil { return "", "", "", "", rerr } if res.StatusCode != http.StatusOK { return "", "", "", "", fmt.Errorf("linuxdo user api: status %d", res.StatusCode) } var m map[string]any if err := json.Unmarshal(b, &m); err != nil { return "", "", "", "", err } if v, ok := m["id"]; ok && v != nil { id = fmt.Sprint(v) } if strings.TrimSpace(id) == "" { return "", "", "", "", errors.New("linuxdo user: missing id") } if s, ok := m["username"].(string); ok { username = strings.TrimSpace(s) } if s, ok := m["name"].(string); ok && username == "" { username = strings.TrimSpace(s) } if s, ok := m["email"].(string); ok { email = strings.TrimSpace(s) } if s, ok := m["avatar_url"].(string); ok { avatar = strings.TrimSpace(s) } if avatar == "" { if tpl, ok := m["avatar_template"].(string); ok && strings.TrimSpace(tpl) != "" { tpl = strings.TrimSpace(tpl) if strings.HasPrefix(tpl, "http://") || strings.HasPrefix(tpl, "https://") { avatar = tpl } else { avatar = strings.TrimRight(base, "/") + tpl } } } return id, email, username, avatar, nil } func readResponseBody(res *http.Response) ([]byte, error) { return io.ReadAll(io.LimitReader(res.Body, 1<<20)) }