| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269 |
- package handler
- import (
- "context"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "errors"
- "fmt"
- "log"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
- "unicode/utf8"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- "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"
- servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/imroc/req/v3"
- "github.com/tidwall/gjson"
- )
- const (
- 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"
- linuxDoOAuthMaxRedirectLen = 2048
- linuxDoOAuthMaxFragmentValueLen = 512
- linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
- oauthIntentLogin = "login"
- oauthIntentBindCurrentUser = "bind_current_user"
- )
- 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"`
- }
- 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, " ")
- }
- // LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
- // GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
- func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
- if !h.requireActionCaptchaForOAuthLoginStart(c) {
- return
- }
- cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
- 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
- }
- 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
- }
- secureCookie := isRequestHTTPS(c)
- setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
- setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
- intent := normalizeOAuthIntent(c.Query("intent"))
- setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
- captureOAuthPromoCode(c, secureCookie)
- setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
- clearOAuthPendingSessionCookie(c, secureCookie)
- 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)
- }
- codeChallenge := ""
- if cfg.UsePKCE {
- verifier, err := oauth.GenerateCodeVerifier()
- if err != nil {
- response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
- return
- }
- codeChallenge = oauth.GenerateCodeChallenge(verifier)
- setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
- }
- 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
- }
- respondOAuthStart(c, authURL)
- }
- // LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
- // GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
- func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
- cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
- 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)
- clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
- clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
- clearOAuthPromoCodeCookie(c, secureCookie)
- }()
- 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
- }
- browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
- if strings.TrimSpace(browserSessionKey) == "" {
- redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
- return
- }
- intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
- intent = normalizeOAuthIntent(intent)
- codeVerifier := ""
- if cfg.UsePKCE {
- codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie)
- if codeVerifier == "" {
- redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
- return
- }
- }
- 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 {
- 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))
- return
- }
- email, username, subject, displayName, avatarURL, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
- if err != nil {
- log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err)
- redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
- return
- }
- compatEmail := strings.TrimSpace(email)
- // 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
- // 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
- if subject != "" {
- email = linuxDoSyntheticEmail(subject)
- }
- 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,
- }
- if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
- upstreamClaims["compat_email"] = compatEmail
- }
- 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{
- Intent: oauthIntentBindCurrentUser,
- Identity: identityKey,
- TargetUserID: &targetUserID,
- ResolvedEmail: email,
- RedirectTo: redirectTo,
- BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: map[string]any{
- "redirect": redirectTo,
- },
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- 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 {
- if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin,
- Identity: identityKey,
- TargetUserID: &existingIdentityUser.ID,
- ResolvedEmail: existingIdentityUser.Email,
- RedirectTo: redirectTo,
- BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: map[string]any{
- "redirect": redirectTo,
- },
- }); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- return
- }
- compatEmailUser, err := h.findLinuxDoCompatEmailUser(c.Request.Context(), compatEmail)
- if err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context())
- forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
- if compatEmailUser == nil && !emailVerificationRequired && !forceEmailOnSignup {
- if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
- c.Request.Context(),
- email,
- username,
- "",
- "",
- readOAuthPromoCode(c),
- "linuxdo",
- )
- if err == nil {
- if err := applyPendingOAuthBinding(
- c.Request.Context(),
- h.entClient(),
- h.authService,
- h.userService,
- &dbent.PendingAuthSession{
- Intent: oauthIntentLogin,
- ProviderType: identityKey.ProviderType,
- ProviderKey: identityKey.ProviderKey,
- ProviderSubject: identityKey.ProviderSubject,
- ResolvedEmail: email,
- UpstreamIdentityClaims: upstreamClaims,
- },
- nil,
- &user.ID,
- true,
- false,
- ); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", "failed to bind oauth identity", "")
- return
- }
- h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- redirectOAuthTokenPair(c, frontendCallback, tokenPair, redirectTo)
- return
- }
- if !errors.Is(err, service.ErrOAuthInvitationRequired) {
- redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
- return
- }
- }
- if err := h.createLinuxDoOAuthChoicePendingSession(
- c,
- identityKey,
- email,
- email,
- redirectTo,
- browserSessionKey,
- upstreamClaims,
- compatEmail,
- compatEmailUser,
- emailVerificationRequired,
- forceEmailOnSignup,
- ); err != nil {
- redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
- return
- }
- redirectToFrontendCallback(c, frontendCallback)
- }
- 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) ||
- strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) {
- return nil, nil
- }
- userEntity, err := client.User.Query().
- Where(userNormalizedEmailPredicate(email)).
- Order(dbent.Asc(dbuser.FieldID)).
- All(ctx)
- if err != nil {
- return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
- }
- switch len(userEntity) {
- case 0:
- return nil, nil
- case 1:
- return userEntity[0], nil
- default:
- return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
- }
- }
- func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
- c *gin.Context,
- identity service.PendingAuthIdentityKey,
- suggestedEmail string,
- resolvedEmail string,
- redirectTo string,
- browserSessionKey string,
- upstreamClaims map[string]any,
- compatEmail string,
- compatEmailUser *dbent.User,
- emailVerificationRequired bool,
- forceEmailOnSignup bool,
- ) error {
- suggestionEmail := strings.TrimSpace(suggestedEmail)
- canonicalEmail := strings.TrimSpace(resolvedEmail)
- if suggestionEmail == "" {
- suggestionEmail = canonicalEmail
- }
- completionResponse := map[string]any{
- "step": oauthPendingChoiceStep,
- "adoption_required": true,
- "redirect": strings.TrimSpace(redirectTo),
- "email": suggestionEmail,
- "resolved_email": canonicalEmail,
- "existing_account_email": "",
- "existing_account_bindable": false,
- "create_account_allowed": true,
- "force_email_on_signup": forceEmailOnSignup,
- "choice_reason": "third_party_signup",
- }
- if strings.TrimSpace(compatEmail) != "" {
- completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
- }
- resolvedChoiceEmail := suggestionEmail
- if compatEmailUser != nil {
- completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
- completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
- completionResponse["existing_account_bindable"] = true
- completionResponse["choice_reason"] = "compat_email_match"
- resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
- }
- if forceEmailOnSignup && compatEmailUser == nil {
- completionResponse["choice_reason"] = "force_email_on_signup"
- }
- if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
- completionResponse["step"] = "create_account_required"
- completionResponse["email_binding_required"] = true
- completionResponse["force_email_on_signup"] = true
- if emailVerificationRequired {
- completionResponse["choice_reason"] = "email_verification_required"
- }
- delete(completionResponse, "email")
- delete(completionResponse, "resolved_email")
- resolvedChoiceEmail = ""
- }
- var targetUserID *int64
- if compatEmailUser != nil && compatEmailUser.ID > 0 {
- targetUserID = &compatEmailUser.ID
- }
- return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
- Intent: oauthIntentLogin,
- Identity: identity,
- TargetUserID: targetUserID,
- ResolvedEmail: resolvedChoiceEmail,
- RedirectTo: redirectTo,
- BrowserSessionKey: browserSessionKey,
- UpstreamIdentityClaims: upstreamClaims,
- CompletionResponse: completionResponse,
- })
- }
- type completeLinuxDoOAuthRequest struct {
- InvitationCode string `json:"invitation_code" binding:"required"`
- AffCode string `json:"aff_code,omitempty"`
- AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
- AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
- }
- // 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
- }
- 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)
- if err != nil {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- response.ErrorFrom(c, err)
- return
- }
- if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
- response.ErrorFrom(c, err)
- return
- } else if handled {
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
- return
- } else {
- session = updatedSession
- }
- if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- 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"))
- return
- }
- client := h.entClient()
- if client == nil {
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
- return
- }
- if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
- AdoptDisplayName: req.AdoptDisplayName,
- AdoptAvatar: req.AdoptAvatar,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
- c.Request.Context(),
- email,
- username,
- req.InvitationCode,
- req.AffCode,
- pendingOAuthPromoCode(session),
- "linuxdo",
- )
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
- }
- 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 {
- return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
- }
- if !h.cfg.LinuxDo.Enabled {
- return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
- }
- return h.cfg.LinuxDo, nil
- }
- 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)
- if strings.TrimSpace(codeVerifier) != "" {
- form.Set("code_verifier", codeVerifier)
- }
- 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)
- }
- resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
- if err != nil {
- return nil, fmt.Errorf("request token: %w", err)
- }
- body := strings.TrimSpace(resp.String())
- if !resp.IsSuccessState() {
- providerErr, providerDesc := parseOAuthProviderError(body)
- return nil, &linuxDoTokenExchangeError{
- StatusCode: resp.StatusCode,
- ProviderError: providerErr,
- ProviderDescription: providerDesc,
- Body: body,
- }
- }
- tokenResp, ok := parseLinuxDoTokenResponse(body)
- if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" {
- return nil, &linuxDoTokenExchangeError{
- StatusCode: resp.StatusCode,
- Body: body,
- }
- }
- if strings.TrimSpace(tokenResp.TokenType) == "" {
- tokenResp.TokenType = "Bearer"
- }
- return tokenResp, nil
- }
- func linuxDoFetchUserInfo(
- ctx context.Context,
- cfg config.LinuxDoConnectConfig,
- token *linuxDoTokenResponse,
- ) (email string, username string, subject string, displayName string, avatarURL string, err error) {
- client := req.C().SetTimeout(30 * time.Second)
- authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
- if err != nil {
- return "", "", "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err)
- }
- resp, err := client.R().
- SetContext(ctx).
- SetHeader("Accept", "application/json").
- SetHeader("Authorization", authorization).
- Get(cfg.UserInfoURL)
- if err != nil {
- return "", "", "", "", "", fmt.Errorf("request userinfo: %w", err)
- }
- if !resp.IsSuccessState() {
- return "", "", "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode)
- }
- return linuxDoParseUserInfo(resp.String(), cfg)
- }
- func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, displayName string, avatarURL string, err error) {
- 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"),
- )
- 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"),
- )
- subject = strings.TrimSpace(subject)
- if subject == "" {
- return "", "", "", "", "", errors.New("userinfo missing id field")
- }
- if !isSafeLinuxDoSubject(subject) {
- return "", "", "", "", "", errors.New("userinfo returned invalid id field")
- }
- email = strings.TrimSpace(email)
- if email == "" {
- // LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。
- email = linuxDoSyntheticEmail(subject)
- }
- username = strings.TrimSpace(username)
- if username == "" {
- username = "linuxdo_" + subject
- }
- displayName = strings.TrimSpace(displayName)
- if displayName == "" {
- displayName = username
- }
- avatarURL = strings.TrimSpace(avatarURL)
- return email, username, subject, displayName, avatarURL, nil
- }
- 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)
- if strings.TrimSpace(codeChallenge) != "" {
- q.Set("code_challenge", codeChallenge)
- q.Set("code_challenge_method", "S256")
- }
- 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 redirectOAuthTokenPair(c *gin.Context, frontendCallback string, tokenPair *service.TokenPair, redirectTo string) {
- fragment := url.Values{}
- if tokenPair != nil {
- fragment.Set("access_token", truncateFragmentValue(tokenPair.AccessToken))
- fragment.Set("refresh_token", truncateFragmentValue(tokenPair.RefreshToken))
- fragment.Set("expires_in", strconv.Itoa(tokenPair.ExpiresIn))
- fragment.Set("token_type", "Bearer")
- }
- if redirect := strings.TrimSpace(redirectTo); redirect != "" {
- originalRedirect := redirect
- for range 2 {
- decoded, err := url.QueryUnescape(redirect)
- if err != nil || decoded == redirect {
- break
- }
- redirect = decoded
- }
- if redirect != originalRedirect {
- if sanitized := sanitizeFrontendRedirectPath(redirect); sanitized != "" {
- redirect = sanitized
- } else {
- redirect = originalRedirect
- }
- }
- fragment.Set("redirect", truncateFragmentValue(redirect))
- }
- redirectWithFragment(c, frontendCallback, fragment)
- }
- func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
- u, err := url.Parse(frontendCallback)
- if err != nil {
- // 兜底:尽力跳转到默认页面,避免卡死在回调页。
- 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 ""
- }
- 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
- }
- 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()
- }
- 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), " ")
- }
- func sanitizeFrontendRedirectPath(path string) string {
- path = strings.TrimSpace(path)
- if path == "" {
- return ""
- }
- if len(path) > linuxDoOAuthMaxRedirectLen {
- return ""
- }
- // 只允许同源相对路径(避免开放重定向)。
- 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,
- })
- }
- func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthBindAccessTokenCookieName,
- Value: "",
- Path: oauthBindAccessTokenCookiePath,
- MaxAge: -1,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func setOAuthBindAccessTokenCookie(c *gin.Context, token string, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthBindAccessTokenCookieName,
- Value: url.QueryEscape(strings.TrimSpace(token)),
- Path: oauthBindAccessTokenCookiePath,
- MaxAge: linuxDoOAuthCookieMaxAgeSec,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- 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
- }
- func linuxDoSyntheticEmail(subject string) string {
- subject = strings.TrimSpace(subject)
- if subject == "" {
- return ""
- }
- return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
- }
- 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) PrepareOAuthBindAccessTokenCookie(c *gin.Context) {
- const bearerPrefix = "Bearer "
- authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
- if !strings.HasPrefix(strings.ToLower(authHeader), strings.ToLower(bearerPrefix)) {
- response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required"))
- return
- }
- token := strings.TrimSpace(authHeader[len(bearerPrefix):])
- if token == "" {
- response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required"))
- return
- }
- setOAuthBindAccessTokenCookie(c, token, isRequestHTTPS(c))
- c.Status(http.StatusNoContent)
- c.Writer.WriteHeaderNow()
- }
- 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
- }
|