Files
2026-05-13 12:19:36 +08:00

962 lines
28 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}