Update SproutGate
This commit is contained in:
961
sproutgate-backend/internal/handlers/oauth.go
Normal file
961
sproutgate-backend/internal/handlers/oauth.go
Normal file
@@ -0,0 +1,961 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user