2026-01-09 12:05:25 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2026-04-20 18:28:44 +08:00
|
|
|
|
"crypto/hmac"
|
|
|
|
|
|
"crypto/sha256"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
2026-01-09 13:52:27 +08:00
|
|
|
|
"strconv"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
"unicode/utf8"
|
|
|
|
|
|
|
2026-04-20 21:31:05 +08:00
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
|
|
|
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
2026-04-20 18:28:44 +08:00
|
|
|
|
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
2026-01-09 19:32:06 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"github.com/imroc/req/v3"
|
|
|
|
|
|
"github.com/tidwall/gjson"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
2026-04-20 18:28:44 +08:00
|
|
|
|
linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo"
|
|
|
|
|
|
oauthBindAccessTokenCookiePath = "/api/v1/auth/oauth"
|
|
|
|
|
|
linuxDoOAuthStateCookieName = "linuxdo_oauth_state"
|
|
|
|
|
|
linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier"
|
|
|
|
|
|
linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect"
|
|
|
|
|
|
linuxDoOAuthIntentCookieName = "linuxdo_oauth_intent"
|
|
|
|
|
|
linuxDoOAuthBindUserCookieName = "linuxdo_oauth_bind_user"
|
|
|
|
|
|
oauthBindAccessTokenCookieName = "oauth_bind_access_token"
|
|
|
|
|
|
linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
|
|
|
|
|
|
linuxDoOAuthDefaultRedirectTo = "/dashboard"
|
|
|
|
|
|
linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
|
|
|
|
|
linuxDoOAuthMaxRedirectLen = 2048
|
|
|
|
|
|
linuxDoOAuthMaxFragmentValueLen = 512
|
|
|
|
|
|
linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
|
2026-04-20 18:28:44 +08:00
|
|
|
|
|
|
|
|
|
|
oauthIntentLogin = "login"
|
|
|
|
|
|
oauthIntentBindCurrentUser = "bind_current_user"
|
2026-01-09 12:05:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type linuxDoTokenResponse struct {
|
|
|
|
|
|
AccessToken string `json:"access_token"`
|
|
|
|
|
|
TokenType string `json:"token_type"`
|
|
|
|
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
|
|
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
|
|
|
|
Scope string `json:"scope,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
type linuxDoTokenExchangeError struct {
|
|
|
|
|
|
StatusCode int
|
|
|
|
|
|
ProviderError string
|
|
|
|
|
|
ProviderDescription string
|
|
|
|
|
|
Body string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (e *linuxDoTokenExchangeError) Error() string {
|
|
|
|
|
|
if e == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)}
|
|
|
|
|
|
if strings.TrimSpace(e.ProviderError) != "" {
|
|
|
|
|
|
parts = append(parts, "error="+strings.TrimSpace(e.ProviderError))
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(e.ProviderDescription) != "" {
|
|
|
|
|
|
parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription))
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(parts, " ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
|
2026-01-09 12:05:25 +08:00
|
|
|
|
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
|
|
|
|
|
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
2026-01-09 13:52:27 +08:00
|
|
|
|
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state, err := oauth.GenerateState()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
|
|
|
|
|
|
if redirectTo == "" {
|
|
|
|
|
|
redirectTo = linuxDoOAuthDefaultRedirectTo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
browserSessionKey, err := generateOAuthPendingBrowserSession()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 12:05:25 +08:00
|
|
|
|
secureCookie := isRequestHTTPS(c)
|
|
|
|
|
|
setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
|
|
|
|
|
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
2026-04-20 18:28:44 +08:00
|
|
|
|
intent := normalizeOAuthIntent(c.Query("intent"))
|
|
|
|
|
|
setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
2026-04-20 16:27:23 +08:00
|
|
|
|
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
|
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
2026-04-20 18:28:44 +08:00
|
|
|
|
if intent == oauthIntentBindCurrentUser {
|
|
|
|
|
|
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setCookie(c, linuxDoOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
|
|
|
|
|
}
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
verifier, err := oauth.GenerateCodeVerifier()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
|
|
|
|
|
|
return
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
2026-04-20 16:27:23 +08:00
|
|
|
|
codeChallenge := oauth.GenerateCodeChallenge(verifier)
|
|
|
|
|
|
setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
|
|
|
|
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
|
|
|
|
|
if redirectURI == "" {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured"))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
authURL, err := buildLinuxDoAuthorizeURL(cfg, state, codeChallenge, redirectURI)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
c.Redirect(http.StatusFound, authURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
|
2026-01-09 12:05:25 +08:00
|
|
|
|
// GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
|
|
|
|
|
|
func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
|
2026-01-09 13:52:27 +08:00
|
|
|
|
cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if cfgErr != nil {
|
|
|
|
|
|
response.ErrorFrom(c, cfgErr)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
|
|
|
|
|
|
if frontendCallback == "" {
|
|
|
|
|
|
frontendCallback = linuxDoOAuthDefaultFrontendCB
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
code := strings.TrimSpace(c.Query("code"))
|
|
|
|
|
|
state := strings.TrimSpace(c.Query("state"))
|
|
|
|
|
|
if code == "" || state == "" {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
secureCookie := isRequestHTTPS(c)
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
|
|
|
|
|
|
clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
|
|
|
|
|
|
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
|
2026-04-20 18:28:44 +08:00
|
|
|
|
clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
|
|
|
|
|
|
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
|
|
|
|
|
|
if err != nil || expectedState == "" || state != expectedState {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
redirectTo, _ := readCookieDecoded(c, linuxDoOAuthRedirectCookie)
|
|
|
|
|
|
redirectTo = sanitizeFrontendRedirectPath(redirectTo)
|
|
|
|
|
|
if redirectTo == "" {
|
|
|
|
|
|
redirectTo = linuxDoOAuthDefaultRedirectTo
|
|
|
|
|
|
}
|
2026-04-20 16:27:23 +08:00
|
|
|
|
browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
|
|
|
|
|
|
if strings.TrimSpace(browserSessionKey) == "" {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-20 18:28:44 +08:00
|
|
|
|
intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
|
|
|
|
|
|
intent = normalizeOAuthIntent(intent)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
codeVerifier, _ := readCookieDecoded(c, linuxDoOAuthVerifierCookie)
|
|
|
|
|
|
if codeVerifier == "" {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
|
|
|
|
|
|
return
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
|
|
|
|
|
if redirectURI == "" {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
|
|
|
|
|
|
if err != nil {
|
2026-01-09 13:52:27 +08:00
|
|
|
|
description := ""
|
|
|
|
|
|
var exchangeErr *linuxDoTokenExchangeError
|
|
|
|
|
|
if errors.As(err, &exchangeErr) && exchangeErr != nil {
|
|
|
|
|
|
log.Printf(
|
|
|
|
|
|
"[LinuxDo OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s",
|
|
|
|
|
|
exchangeErr.StatusCode,
|
|
|
|
|
|
exchangeErr.ProviderError,
|
|
|
|
|
|
exchangeErr.ProviderDescription,
|
|
|
|
|
|
truncateLogValue(exchangeErr.Body, 2048),
|
|
|
|
|
|
)
|
|
|
|
|
|
description = exchangeErr.Error()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("[LinuxDo OAuth] token exchange failed: %v", err)
|
|
|
|
|
|
description = err.Error()
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description))
|
2026-01-09 12:05:25 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
email, username, subject, displayName, avatarURL, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err)
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-20 21:31:05 +08:00
|
|
|
|
compatEmail := strings.TrimSpace(email)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
|
|
|
|
|
|
// 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
|
|
|
|
|
|
if subject != "" {
|
|
|
|
|
|
email = linuxDoSyntheticEmail(subject)
|
|
|
|
|
|
}
|
2026-04-20 19:30:09 +08:00
|
|
|
|
identityKey := service.PendingAuthIdentityKey{
|
|
|
|
|
|
ProviderType: "linuxdo",
|
|
|
|
|
|
ProviderKey: "linuxdo",
|
|
|
|
|
|
ProviderSubject: subject,
|
|
|
|
|
|
}
|
|
|
|
|
|
upstreamClaims := map[string]any{
|
|
|
|
|
|
"email": email,
|
|
|
|
|
|
"username": username,
|
|
|
|
|
|
"subject": subject,
|
|
|
|
|
|
"suggested_display_name": displayName,
|
|
|
|
|
|
"suggested_avatar_url": avatarURL,
|
|
|
|
|
|
}
|
2026-04-20 21:31:05 +08:00
|
|
|
|
if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
|
|
|
|
|
|
upstreamClaims["compat_email"] = compatEmail
|
|
|
|
|
|
}
|
2026-04-20 18:28:44 +08:00
|
|
|
|
if intent == oauthIntentBindCurrentUser {
|
|
|
|
|
|
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
2026-04-20 19:30:09 +08:00
|
|
|
|
Intent: oauthIntentBindCurrentUser,
|
|
|
|
|
|
Identity: identityKey,
|
|
|
|
|
|
TargetUserID: &targetUserID,
|
|
|
|
|
|
ResolvedEmail: email,
|
|
|
|
|
|
RedirectTo: redirectTo,
|
|
|
|
|
|
BrowserSessionKey: browserSessionKey,
|
|
|
|
|
|
UpstreamIdentityClaims: upstreamClaims,
|
2026-04-20 18:28:44 +08:00
|
|
|
|
CompletionResponse: map[string]any{
|
|
|
|
|
|
"redirect": redirectTo,
|
|
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-09 19:32:06 +08:00
|
|
|
|
|
2026-04-20 19:30:09 +08:00
|
|
|
|
existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityKey)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if existingIdentityUser != nil {
|
|
|
|
|
|
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), existingIdentityUser.Email, username, "")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
|
|
|
|
Intent: oauthIntentLogin,
|
|
|
|
|
|
Identity: identityKey,
|
|
|
|
|
|
TargetUserID: &user.ID,
|
|
|
|
|
|
ResolvedEmail: existingIdentityUser.Email,
|
|
|
|
|
|
RedirectTo: redirectTo,
|
|
|
|
|
|
BrowserSessionKey: browserSessionKey,
|
|
|
|
|
|
UpstreamIdentityClaims: upstreamClaims,
|
|
|
|
|
|
CompletionResponse: map[string]any{
|
|
|
|
|
|
"access_token": tokenPair.AccessToken,
|
|
|
|
|
|
"refresh_token": tokenPair.RefreshToken,
|
|
|
|
|
|
"expires_in": tokenPair.ExpiresIn,
|
|
|
|
|
|
"token_type": "Bearer",
|
|
|
|
|
|
"redirect": redirectTo,
|
|
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 21:31:05 +08:00
|
|
|
|
compatEmailUser, err := h.findLinuxDoCompatEmailUser(c.Request.Context(), compatEmail)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if compatEmailUser != nil {
|
|
|
|
|
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
|
|
|
|
|
Intent: "adopt_existing_user_by_email",
|
|
|
|
|
|
Identity: identityKey,
|
|
|
|
|
|
TargetUserID: &compatEmailUser.ID,
|
|
|
|
|
|
ResolvedEmail: compatEmailUser.Email,
|
|
|
|
|
|
RedirectTo: redirectTo,
|
|
|
|
|
|
BrowserSessionKey: browserSessionKey,
|
|
|
|
|
|
UpstreamIdentityClaims: upstreamClaims,
|
|
|
|
|
|
CompletionResponse: map[string]any{
|
|
|
|
|
|
"redirect": redirectTo,
|
|
|
|
|
|
"step": "bind_login_required",
|
|
|
|
|
|
"email": compatEmailUser.Email,
|
|
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 19:30:09 +08:00
|
|
|
|
if h.isForceEmailOnThirdPartySignup(c.Request.Context()) {
|
|
|
|
|
|
if err := h.createOAuthEmailRequiredPendingSession(c, identityKey, redirectTo, browserSessionKey, upstreamClaims); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 00:35:34 +08:00
|
|
|
|
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
|
2026-04-20 17:39:57 +08:00
|
|
|
|
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "")
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if err != nil {
|
2026-03-09 00:35:34 +08:00
|
|
|
|
if errors.Is(err, service.ErrOAuthInvitationRequired) {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
2026-04-20 19:30:09 +08:00
|
|
|
|
Intent: "login",
|
|
|
|
|
|
Identity: identityKey,
|
|
|
|
|
|
ResolvedEmail: email,
|
|
|
|
|
|
RedirectTo: redirectTo,
|
|
|
|
|
|
BrowserSessionKey: browserSessionKey,
|
|
|
|
|
|
UpstreamIdentityClaims: upstreamClaims,
|
2026-04-20 16:27:23 +08:00
|
|
|
|
CompletionResponse: map[string]any{
|
|
|
|
|
|
"error": "invitation_required",
|
|
|
|
|
|
"redirect": redirectTo,
|
|
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
2026-03-09 00:35:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-20 16:27:23 +08:00
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
2026-03-09 00:35:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// 避免把内部细节泄露给客户端;给前端保留结构化原因与提示信息即可。
|
2026-01-09 12:05:25 +08:00
|
|
|
|
redirectOAuthError(c, frontendCallback, "login_failed", infraerrors.Reason(err), infraerrors.Message(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
2026-04-20 19:30:09 +08:00
|
|
|
|
Intent: "login",
|
|
|
|
|
|
Identity: identityKey,
|
|
|
|
|
|
TargetUserID: &user.ID,
|
|
|
|
|
|
ResolvedEmail: email,
|
|
|
|
|
|
RedirectTo: redirectTo,
|
|
|
|
|
|
BrowserSessionKey: browserSessionKey,
|
|
|
|
|
|
UpstreamIdentityClaims: upstreamClaims,
|
2026-04-20 16:27:23 +08:00
|
|
|
|
CompletionResponse: map[string]any{
|
|
|
|
|
|
"access_token": tokenPair.AccessToken,
|
|
|
|
|
|
"refresh_token": tokenPair.RefreshToken,
|
|
|
|
|
|
"expires_in": tokenPair.ExpiresIn,
|
|
|
|
|
|
"token_type": "Bearer",
|
|
|
|
|
|
"redirect": redirectTo,
|
|
|
|
|
|
},
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectToFrontendCallback(c, frontendCallback)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 21:31:05 +08:00
|
|
|
|
func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
|
|
|
|
|
|
client := h.entClient()
|
|
|
|
|
|
if client == nil {
|
|
|
|
|
|
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
email = strings.TrimSpace(strings.ToLower(email))
|
|
|
|
|
|
if email == "" ||
|
|
|
|
|
|
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
|
|
|
|
|
|
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
|
|
|
|
|
|
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
userEntity, err := client.User.Query().
|
|
|
|
|
|
Where(dbuser.EmailEqualFold(email)).
|
|
|
|
|
|
Only(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if dbent.IsNotFound(err) {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return userEntity, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 00:35:34 +08:00
|
|
|
|
type completeLinuxDoOAuthRequest struct {
|
2026-04-20 17:39:57 +08:00
|
|
|
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
|
|
|
|
|
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
|
|
|
|
|
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
2026-03-09 00:35:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
|
|
|
|
|
|
// the invitation code and creating the user account.
|
|
|
|
|
|
// POST /api/v1/auth/oauth/linuxdo/complete-registration
|
|
|
|
|
|
func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
|
|
|
|
|
|
var req completeLinuxDoOAuthRequest
|
|
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
|
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
secureCookie := isRequestHTTPS(c)
|
|
|
|
|
|
sessionToken, err := readOAuthPendingSessionCookie(c)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
|
|
|
|
|
clearOAuthPendingBrowserCookie(c, secureCookie)
|
|
|
|
|
|
response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
browserSessionKey, err := readOAuthPendingBrowserCookie(c)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
|
|
|
|
|
clearOAuthPendingBrowserCookie(c, secureCookie)
|
|
|
|
|
|
response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingSvc, err := h.pendingIdentityService()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
|
2026-03-09 00:35:34 +08:00
|
|
|
|
if err != nil {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
|
|
|
|
|
clearOAuthPendingBrowserCookie(c, secureCookie)
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-21 01:45:25 +08:00
|
|
|
|
if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-20 16:27:23 +08:00
|
|
|
|
|
|
|
|
|
|
email := strings.TrimSpace(session.ResolvedEmail)
|
|
|
|
|
|
username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
|
|
|
|
|
|
if email == "" || username == "" {
|
|
|
|
|
|
response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
|
2026-03-09 00:35:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
2026-03-09 00:35:34 +08:00
|
|
|
|
if err != nil {
|
2026-03-09 01:18:49 +08:00
|
|
|
|
response.ErrorFrom(c, err)
|
2026-03-09 00:35:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-20 17:39:57 +08:00
|
|
|
|
decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
|
|
|
|
|
AdoptDisplayName: req.AdoptDisplayName,
|
|
|
|
|
|
AdoptAvatar: req.AdoptAvatar,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-21 00:11:03 +08:00
|
|
|
|
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, &user.ID); err != nil {
|
2026-04-20 17:39:57 +08:00
|
|
|
|
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-21 01:45:25 +08:00
|
|
|
|
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
2026-04-20 16:27:23 +08:00
|
|
|
|
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
|
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
|
|
|
|
|
clearOAuthPendingBrowserCookie(c, secureCookie)
|
|
|
|
|
|
response.ErrorFrom(c, err)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
clearOAuthPendingSessionCookie(c, secureCookie)
|
|
|
|
|
|
clearOAuthPendingBrowserCookie(c, secureCookie)
|
2026-03-09 00:35:34 +08:00
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
|
|
|
"access_token": tokenPair.AccessToken,
|
|
|
|
|
|
"refresh_token": tokenPair.RefreshToken,
|
|
|
|
|
|
"expires_in": tokenPair.ExpiresIn,
|
|
|
|
|
|
"token_type": "Bearer",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
|
|
|
|
|
|
if h != nil && h.settingSvc != nil {
|
|
|
|
|
|
return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
if h == nil || h.cfg == nil {
|
2026-01-09 12:05:25 +08:00
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
|
|
|
|
|
}
|
2026-01-09 13:52:27 +08:00
|
|
|
|
if !h.cfg.LinuxDo.Enabled {
|
2026-01-09 12:05:25 +08:00
|
|
|
|
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
|
|
|
|
|
|
}
|
2026-01-09 13:52:27 +08:00
|
|
|
|
return h.cfg.LinuxDo, nil
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func linuxDoExchangeCode(
|
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
|
cfg config.LinuxDoConnectConfig,
|
|
|
|
|
|
code string,
|
|
|
|
|
|
redirectURI string,
|
|
|
|
|
|
codeVerifier string,
|
|
|
|
|
|
) (*linuxDoTokenResponse, error) {
|
|
|
|
|
|
client := req.C().SetTimeout(30 * time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
form := url.Values{}
|
|
|
|
|
|
form.Set("grant_type", "authorization_code")
|
|
|
|
|
|
form.Set("client_id", cfg.ClientID)
|
|
|
|
|
|
form.Set("code", code)
|
|
|
|
|
|
form.Set("redirect_uri", redirectURI)
|
2026-04-20 16:27:23 +08:00
|
|
|
|
form.Set("code_verifier", codeVerifier)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
|
|
|
|
|
r := client.R().
|
|
|
|
|
|
SetContext(ctx).
|
|
|
|
|
|
SetHeader("Accept", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) {
|
|
|
|
|
|
case "", "client_secret_post":
|
|
|
|
|
|
form.Set("client_secret", cfg.ClientSecret)
|
|
|
|
|
|
case "client_secret_basic":
|
|
|
|
|
|
r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
|
|
|
|
|
|
case "none":
|
|
|
|
|
|
default:
|
|
|
|
|
|
return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("request token: %w", err)
|
|
|
|
|
|
}
|
2026-01-09 13:52:27 +08:00
|
|
|
|
body := strings.TrimSpace(resp.String())
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if !resp.IsSuccessState() {
|
2026-01-09 13:52:27 +08:00
|
|
|
|
providerErr, providerDesc := parseOAuthProviderError(body)
|
|
|
|
|
|
return nil, &linuxDoTokenExchangeError{
|
|
|
|
|
|
StatusCode: resp.StatusCode,
|
|
|
|
|
|
ProviderError: providerErr,
|
|
|
|
|
|
ProviderDescription: providerDesc,
|
|
|
|
|
|
Body: body,
|
|
|
|
|
|
}
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
2026-01-09 13:52:27 +08:00
|
|
|
|
|
|
|
|
|
|
tokenResp, ok := parseLinuxDoTokenResponse(body)
|
|
|
|
|
|
if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" {
|
|
|
|
|
|
return nil, &linuxDoTokenExchangeError{
|
|
|
|
|
|
StatusCode: resp.StatusCode,
|
|
|
|
|
|
Body: body,
|
|
|
|
|
|
}
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(tokenResp.TokenType) == "" {
|
|
|
|
|
|
tokenResp.TokenType = "Bearer"
|
|
|
|
|
|
}
|
2026-01-09 13:52:27 +08:00
|
|
|
|
return tokenResp, nil
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func linuxDoFetchUserInfo(
|
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
|
cfg config.LinuxDoConnectConfig,
|
|
|
|
|
|
token *linuxDoTokenResponse,
|
2026-04-20 16:27:23 +08:00
|
|
|
|
) (email string, username string, subject string, displayName string, avatarURL string, err error) {
|
2026-01-09 12:05:25 +08:00
|
|
|
|
client := req.C().SetTimeout(30 * time.Second)
|
|
|
|
|
|
authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
|
|
|
|
|
|
if err != nil {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return "", "", "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
resp, err := client.R().
|
|
|
|
|
|
SetContext(ctx).
|
|
|
|
|
|
SetHeader("Accept", "application/json").
|
|
|
|
|
|
SetHeader("Authorization", authorization).
|
|
|
|
|
|
Get(cfg.UserInfoURL)
|
|
|
|
|
|
if err != nil {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return "", "", "", "", "", fmt.Errorf("request userinfo: %w", err)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !resp.IsSuccessState() {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return "", "", "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return linuxDoParseUserInfo(resp.String(), cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, displayName string, avatarURL string, err error) {
|
2026-01-09 12:05:25 +08:00
|
|
|
|
email = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, cfg.UserInfoEmailPath),
|
|
|
|
|
|
getGJSON(body, "email"),
|
|
|
|
|
|
getGJSON(body, "user.email"),
|
|
|
|
|
|
getGJSON(body, "data.email"),
|
|
|
|
|
|
getGJSON(body, "attributes.email"),
|
|
|
|
|
|
)
|
|
|
|
|
|
username = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, cfg.UserInfoUsernamePath),
|
|
|
|
|
|
getGJSON(body, "username"),
|
|
|
|
|
|
getGJSON(body, "preferred_username"),
|
|
|
|
|
|
getGJSON(body, "name"),
|
|
|
|
|
|
getGJSON(body, "user.username"),
|
|
|
|
|
|
getGJSON(body, "user.name"),
|
|
|
|
|
|
)
|
|
|
|
|
|
subject = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, cfg.UserInfoIDPath),
|
|
|
|
|
|
getGJSON(body, "sub"),
|
|
|
|
|
|
getGJSON(body, "id"),
|
|
|
|
|
|
getGJSON(body, "user_id"),
|
|
|
|
|
|
getGJSON(body, "uid"),
|
|
|
|
|
|
getGJSON(body, "user.id"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
displayName = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, "name"),
|
|
|
|
|
|
getGJSON(body, "nickname"),
|
|
|
|
|
|
getGJSON(body, "display_name"),
|
|
|
|
|
|
getGJSON(body, "user.name"),
|
|
|
|
|
|
getGJSON(body, "user.username"),
|
|
|
|
|
|
username,
|
|
|
|
|
|
)
|
|
|
|
|
|
avatarURL = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, "avatar_url"),
|
|
|
|
|
|
getGJSON(body, "avatar"),
|
|
|
|
|
|
getGJSON(body, "picture"),
|
|
|
|
|
|
getGJSON(body, "profile_image_url"),
|
|
|
|
|
|
getGJSON(body, "user.avatar"),
|
|
|
|
|
|
getGJSON(body, "user.avatar_url"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-09 12:05:25 +08:00
|
|
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
|
|
if subject == "" {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return "", "", "", "", "", errors.New("userinfo missing id field")
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !isSafeLinuxDoSubject(subject) {
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return "", "", "", "", "", errors.New("userinfo returned invalid id field")
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
email = strings.TrimSpace(email)
|
|
|
|
|
|
if email == "" {
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。
|
|
|
|
|
|
email = linuxDoSyntheticEmail(subject)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
username = strings.TrimSpace(username)
|
|
|
|
|
|
if username == "" {
|
|
|
|
|
|
username = "linuxdo_" + subject
|
|
|
|
|
|
}
|
2026-04-20 16:27:23 +08:00
|
|
|
|
displayName = strings.TrimSpace(displayName)
|
|
|
|
|
|
if displayName == "" {
|
|
|
|
|
|
displayName = username
|
|
|
|
|
|
}
|
|
|
|
|
|
avatarURL = strings.TrimSpace(avatarURL)
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
2026-04-20 16:27:23 +08:00
|
|
|
|
return email, username, subject, displayName, avatarURL, nil
|
2026-01-09 12:05:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, codeChallenge string, redirectURI string) (string, error) {
|
|
|
|
|
|
u, err := url.Parse(cfg.AuthorizeURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("parse authorize_url: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
q := u.Query()
|
|
|
|
|
|
q.Set("response_type", "code")
|
|
|
|
|
|
q.Set("client_id", cfg.ClientID)
|
|
|
|
|
|
q.Set("redirect_uri", redirectURI)
|
|
|
|
|
|
if strings.TrimSpace(cfg.Scopes) != "" {
|
|
|
|
|
|
q.Set("scope", cfg.Scopes)
|
|
|
|
|
|
}
|
|
|
|
|
|
q.Set("state", state)
|
2026-04-20 16:27:23 +08:00
|
|
|
|
q.Set("code_challenge", codeChallenge)
|
|
|
|
|
|
q.Set("code_challenge_method", "S256")
|
2026-01-09 12:05:25 +08:00
|
|
|
|
|
|
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
|
|
return u.String(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func redirectOAuthError(c *gin.Context, frontendCallback string, code string, message string, description string) {
|
|
|
|
|
|
fragment := url.Values{}
|
|
|
|
|
|
fragment.Set("error", truncateFragmentValue(code))
|
|
|
|
|
|
if strings.TrimSpace(message) != "" {
|
|
|
|
|
|
fragment.Set("error_message", truncateFragmentValue(message))
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(description) != "" {
|
|
|
|
|
|
fragment.Set("error_description", truncateFragmentValue(description))
|
|
|
|
|
|
}
|
|
|
|
|
|
redirectWithFragment(c, frontendCallback, fragment)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
|
|
|
|
|
|
u, err := url.Parse(frontendCallback)
|
|
|
|
|
|
if err != nil {
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// 兜底:尽力跳转到默认页面,避免卡死在回调页。
|
2026-01-09 12:05:25 +08:00
|
|
|
|
c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
|
|
|
|
|
|
c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
u.Fragment = fragment.Encode()
|
|
|
|
|
|
c.Header("Cache-Control", "no-store")
|
|
|
|
|
|
c.Header("Pragma", "no-cache")
|
|
|
|
|
|
c.Redirect(http.StatusFound, u.String())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
|
v = strings.TrimSpace(v)
|
|
|
|
|
|
if v != "" {
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
func parseOAuthProviderError(body string) (providerErr string, providerDesc string) {
|
|
|
|
|
|
body = strings.TrimSpace(body)
|
|
|
|
|
|
if body == "" {
|
|
|
|
|
|
return "", ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
providerErr = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, "error"),
|
|
|
|
|
|
getGJSON(body, "code"),
|
|
|
|
|
|
getGJSON(body, "error.code"),
|
|
|
|
|
|
)
|
|
|
|
|
|
providerDesc = firstNonEmpty(
|
|
|
|
|
|
getGJSON(body, "error_description"),
|
|
|
|
|
|
getGJSON(body, "error.message"),
|
|
|
|
|
|
getGJSON(body, "message"),
|
|
|
|
|
|
getGJSON(body, "detail"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if providerErr != "" || providerDesc != "" {
|
|
|
|
|
|
return providerErr, providerDesc
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
values, err := url.ParseQuery(body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", ""
|
|
|
|
|
|
}
|
|
|
|
|
|
providerErr = firstNonEmpty(values.Get("error"), values.Get("code"))
|
|
|
|
|
|
providerDesc = firstNonEmpty(values.Get("error_description"), values.Get("error_message"), values.Get("message"))
|
|
|
|
|
|
return providerErr, providerDesc
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseLinuxDoTokenResponse(body string) (*linuxDoTokenResponse, bool) {
|
|
|
|
|
|
body = strings.TrimSpace(body)
|
|
|
|
|
|
if body == "" {
|
|
|
|
|
|
return nil, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
accessToken := strings.TrimSpace(getGJSON(body, "access_token"))
|
|
|
|
|
|
if accessToken != "" {
|
|
|
|
|
|
tokenType := strings.TrimSpace(getGJSON(body, "token_type"))
|
|
|
|
|
|
refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token"))
|
|
|
|
|
|
scope := strings.TrimSpace(getGJSON(body, "scope"))
|
|
|
|
|
|
expiresIn := gjson.Get(body, "expires_in").Int()
|
|
|
|
|
|
return &linuxDoTokenResponse{
|
|
|
|
|
|
AccessToken: accessToken,
|
|
|
|
|
|
TokenType: tokenType,
|
|
|
|
|
|
ExpiresIn: expiresIn,
|
|
|
|
|
|
RefreshToken: refreshToken,
|
|
|
|
|
|
Scope: scope,
|
|
|
|
|
|
}, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
values, err := url.ParseQuery(body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, false
|
|
|
|
|
|
}
|
|
|
|
|
|
accessToken = strings.TrimSpace(values.Get("access_token"))
|
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
return nil, false
|
|
|
|
|
|
}
|
|
|
|
|
|
expiresIn := int64(0)
|
|
|
|
|
|
if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" {
|
|
|
|
|
|
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
|
|
|
|
expiresIn = v
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return &linuxDoTokenResponse{
|
|
|
|
|
|
AccessToken: accessToken,
|
|
|
|
|
|
TokenType: strings.TrimSpace(values.Get("token_type")),
|
|
|
|
|
|
ExpiresIn: expiresIn,
|
|
|
|
|
|
RefreshToken: strings.TrimSpace(values.Get("refresh_token")),
|
|
|
|
|
|
Scope: strings.TrimSpace(values.Get("scope")),
|
|
|
|
|
|
}, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 12:05:25 +08:00
|
|
|
|
func getGJSON(body string, path string) string {
|
|
|
|
|
|
path = strings.TrimSpace(path)
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
res := gjson.Get(body, path)
|
|
|
|
|
|
if !res.Exists() {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return res.String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 13:52:27 +08:00
|
|
|
|
func truncateLogValue(value string, maxLen int) string {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" || maxLen <= 0 {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(value) <= maxLen {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
value = value[:maxLen]
|
|
|
|
|
|
for !utf8.ValidString(value) {
|
|
|
|
|
|
value = value[:len(value)-1]
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func singleLine(value string) string {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(strings.Fields(value), " ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 12:05:25 +08:00
|
|
|
|
func sanitizeFrontendRedirectPath(path string) string {
|
|
|
|
|
|
path = strings.TrimSpace(path)
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(path) > linuxDoOAuthMaxRedirectLen {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
2026-01-09 19:32:06 +08:00
|
|
|
|
// 只允许同源相对路径(避免开放重定向)。
|
2026-01-09 12:05:25 +08:00
|
|
|
|
if !strings.HasPrefix(path, "/") {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.HasPrefix(path, "//") {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(path, "://") {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.ContainsAny(path, "\r\n") {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return path
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isRequestHTTPS(c *gin.Context) bool {
|
|
|
|
|
|
if c.Request.TLS != nil {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
proto := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")))
|
|
|
|
|
|
return proto == "https"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func encodeCookieValue(value string) string {
|
|
|
|
|
|
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func decodeCookieValue(value string) (string, error) {
|
|
|
|
|
|
raw, err := base64.RawURLEncoding.DecodeString(value)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(raw), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func readCookieDecoded(c *gin.Context, name string) (string, error) {
|
|
|
|
|
|
ck, err := c.Request.Cookie(name)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
return decodeCookieValue(ck.Value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func setCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
|
|
|
|
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
Value: value,
|
|
|
|
|
|
Path: linuxDoOAuthCookiePath,
|
|
|
|
|
|
MaxAge: maxAgeSec,
|
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
|
Secure: secure,
|
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func clearCookie(c *gin.Context, name string, secure bool) {
|
|
|
|
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
Value: "",
|
|
|
|
|
|
Path: linuxDoOAuthCookiePath,
|
|
|
|
|
|
MaxAge: -1,
|
|
|
|
|
|
HttpOnly: true,
|
|
|
|
|
|
Secure: secure,
|
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
|
func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
|
|
|
|
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
|
|
|
|
Name: oauthBindAccessTokenCookieName,
|
|
|
|
|
|
Value: "",
|
|
|
|
|
|
Path: oauthBindAccessTokenCookiePath,
|
|
|
|
|
|
MaxAge: -1,
|
|
|
|
|
|
HttpOnly: false,
|
|
|
|
|
|
Secure: secure,
|
|
|
|
|
|
SameSite: http.SameSiteLaxMode,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 12:05:25 +08:00
|
|
|
|
func truncateFragmentValue(value string) string {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(value) > linuxDoOAuthMaxFragmentValueLen {
|
|
|
|
|
|
value = value[:linuxDoOAuthMaxFragmentValueLen]
|
|
|
|
|
|
for !utf8.ValidString(value) {
|
|
|
|
|
|
value = value[:len(value)-1]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildBearerAuthorization(tokenType, accessToken string) (string, error) {
|
|
|
|
|
|
tokenType = strings.TrimSpace(tokenType)
|
|
|
|
|
|
if tokenType == "" {
|
|
|
|
|
|
tokenType = "Bearer"
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.EqualFold(tokenType, "Bearer") {
|
|
|
|
|
|
return "", fmt.Errorf("unsupported token_type: %s", tokenType)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
accessToken = strings.TrimSpace(accessToken)
|
|
|
|
|
|
if accessToken == "" {
|
|
|
|
|
|
return "", errors.New("missing access_token")
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.ContainsAny(accessToken, " \t\r\n") {
|
|
|
|
|
|
return "", errors.New("access_token contains whitespace")
|
|
|
|
|
|
}
|
|
|
|
|
|
return "Bearer " + accessToken, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isSafeLinuxDoSubject(subject string) bool {
|
|
|
|
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
|
|
if subject == "" || len(subject) > linuxDoOAuthMaxSubjectLen {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, r := range subject {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case r >= '0' && r <= '9':
|
|
|
|
|
|
case r >= 'a' && r <= 'z':
|
|
|
|
|
|
case r >= 'A' && r <= 'Z':
|
|
|
|
|
|
case r == '_' || r == '-':
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
2026-01-09 19:32:06 +08:00
|
|
|
|
|
|
|
|
|
|
func linuxDoSyntheticEmail(subject string) string {
|
|
|
|
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
|
|
if subject == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
|
|
|
|
|
|
}
|
2026-04-20 18:28:44 +08:00
|
|
|
|
|
|
|
|
|
|
func normalizeOAuthIntent(raw string) string {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
|
|
|
|
case "", oauthIntentLogin:
|
|
|
|
|
|
return oauthIntentLogin
|
|
|
|
|
|
case "bind", oauthIntentBindCurrentUser:
|
|
|
|
|
|
return oauthIntentBindCurrentUser
|
|
|
|
|
|
default:
|
|
|
|
|
|
return oauthIntentLogin
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (string, error) {
|
|
|
|
|
|
userID, err := h.resolveOAuthBindTargetUserID(c)
|
|
|
|
|
|
if err != nil || userID == nil || *userID <= 0 {
|
|
|
|
|
|
return "", infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")
|
|
|
|
|
|
}
|
|
|
|
|
|
return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) {
|
|
|
|
|
|
if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
|
|
|
|
|
return &subject.UserID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if h == nil || h.authService == nil || h.userService == nil {
|
|
|
|
|
|
return nil, service.ErrInvalidToken
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ck, err := c.Request.Cookie(oauthBindAccessTokenCookieName)
|
|
|
|
|
|
clearOAuthBindAccessTokenCookie(c, isRequestHTTPS(c))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tokenString, err := url.QueryUnescape(strings.TrimSpace(ck.Value))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if tokenString == "" {
|
|
|
|
|
|
return nil, service.ErrInvalidToken
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
claims, err := h.authService.ValidateToken(tokenString)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
user, err := h.userService.GetByID(c.Request.Context(), claims.UserID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if user == nil || !user.IsActive() || claims.TokenVersion != user.TokenVersion {
|
|
|
|
|
|
return nil, service.ErrInvalidToken
|
|
|
|
|
|
}
|
|
|
|
|
|
return &user.ID, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) readOAuthBindUserIDFromCookie(c *gin.Context, cookieName string) (int64, error) {
|
|
|
|
|
|
value, err := readCookieDecoded(c, cookieName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseOAuthBindUserCookieValue(value, h.oauthBindCookieSecret())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) oauthBindCookieSecret() string {
|
|
|
|
|
|
if h == nil || h.cfg == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimSpace(h.cfg.JWT.Secret)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildOAuthBindUserCookieValue(userID int64, secret string) (string, error) {
|
|
|
|
|
|
secret = strings.TrimSpace(secret)
|
|
|
|
|
|
if userID <= 0 || secret == "" {
|
|
|
|
|
|
return "", errors.New("invalid oauth bind cookie input")
|
|
|
|
|
|
}
|
|
|
|
|
|
payload := strconv.FormatInt(userID, 10)
|
|
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
|
|
|
|
_, _ = mac.Write([]byte(payload))
|
|
|
|
|
|
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
|
|
return payload + "." + signature, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseOAuthBindUserCookieValue(value string, secret string) (int64, error) {
|
|
|
|
|
|
secret = strings.TrimSpace(secret)
|
|
|
|
|
|
if secret == "" {
|
|
|
|
|
|
return 0, errors.New("missing oauth bind cookie secret")
|
|
|
|
|
|
}
|
|
|
|
|
|
payload, signature, ok := strings.Cut(strings.TrimSpace(value), ".")
|
|
|
|
|
|
if !ok || payload == "" || signature == "" {
|
|
|
|
|
|
return 0, errors.New("invalid oauth bind cookie")
|
|
|
|
|
|
}
|
|
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
|
|
|
|
_, _ = mac.Write([]byte(payload))
|
|
|
|
|
|
expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
|
|
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
|
|
|
|
|
return 0, errors.New("invalid oauth bind cookie signature")
|
|
|
|
|
|
}
|
|
|
|
|
|
userID, err := strconv.ParseInt(payload, 10, 64)
|
|
|
|
|
|
if err != nil || userID <= 0 {
|
|
|
|
|
|
return 0, errors.New("invalid oauth bind cookie user")
|
|
|
|
|
|
}
|
|
|
|
|
|
return userID, nil
|
|
|
|
|
|
}
|