| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066 |
- package handler
- import (
- "context"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/url"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/authidentity"
- "github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
- "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
- "github.com/Wei-Shaw/sub2api/ent/predicate"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
- "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/service"
- entsql "entgo.io/ent/dialect/sql"
- "github.com/gin-gonic/gin"
- )
- const (
- oauthPendingBrowserCookiePath = "/api/v1/auth/oauth"
- oauthPendingBrowserCookieName = "oauth_pending_browser_session"
- oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
- oauthPendingSessionCookieName = "oauth_pending_session"
- oauthPromoCodeCookieName = "oauth_promo_code"
- oauthPendingCookieMaxAgeSec = 10 * 60
- oauthPendingChoiceStep = "choose_account_action_required"
- oauthCompletionResponseKey = "completion_response"
- oauthPromoCodeStateKey = "promo_code"
- )
- var pendingOAuthCreateAccountPreCommitHook func(context.Context, *dbent.PendingAuthSession) error
- type oauthPendingSessionPayload struct {
- Intent string
- Identity service.PendingAuthIdentityKey
- TargetUserID *int64
- ResolvedEmail string
- RedirectTo string
- BrowserSessionKey string
- UpstreamIdentityClaims map[string]any
- CompletionResponse map[string]any
- }
- type oauthAdoptionDecisionRequest struct {
- AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
- AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
- }
- type bindPendingOAuthLoginRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required"`
- AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
- AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
- }
- type createPendingOAuthAccountRequest struct {
- Email string `json:"email" binding:"required,email"`
- VerifyCode string `json:"verify_code,omitempty"`
- Password string `json:"password" binding:"required,min=6"`
- TurnstileToken string `json:"turnstile_token,omitempty"`
- TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
- TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
- InvitationCode string `json:"invitation_code,omitempty"`
- AffCode string `json:"aff_code,omitempty"`
- AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
- AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
- }
- type sendPendingOAuthVerifyCodeRequest struct {
- Email string `json:"email" binding:"required,email"`
- TurnstileToken string `json:"turnstile_token,omitempty"`
- TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
- TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
- PendingAuthToken string `json:"pending_auth_token,omitempty"`
- PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
- }
- func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest {
- return oauthAdoptionDecisionRequest{
- AdoptDisplayName: r.AdoptDisplayName,
- AdoptAvatar: r.AdoptAvatar,
- }
- }
- func (r createPendingOAuthAccountRequest) adoptionDecision() oauthAdoptionDecisionRequest {
- return oauthAdoptionDecisionRequest{
- AdoptDisplayName: r.AdoptDisplayName,
- AdoptAvatar: r.AdoptAvatar,
- }
- }
- func (h *AuthHandler) pendingIdentityService() (*service.AuthPendingIdentityService, error) {
- if h == nil || h.authService == nil || h.authService.EntClient() == nil {
- return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- return service.NewAuthPendingIdentityService(h.authService.EntClient()), nil
- }
- func generateOAuthPendingBrowserSession() (string, error) {
- return oauth.GenerateState()
- }
- func setOAuthPendingBrowserCookie(c *gin.Context, sessionKey string, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPendingBrowserCookieName,
- Value: encodeCookieValue(sessionKey),
- Path: oauthPendingBrowserCookiePath,
- MaxAge: oauthPendingCookieMaxAgeSec,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func clearOAuthPendingBrowserCookie(c *gin.Context, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPendingBrowserCookieName,
- Value: "",
- Path: oauthPendingBrowserCookiePath,
- MaxAge: -1,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func readOAuthPendingBrowserCookie(c *gin.Context) (string, error) {
- return readCookieDecoded(c, oauthPendingBrowserCookieName)
- }
- func setOAuthPendingSessionCookie(c *gin.Context, sessionToken string, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPendingSessionCookieName,
- Value: encodeCookieValue(sessionToken),
- Path: oauthPendingSessionCookiePath,
- MaxAge: oauthPendingCookieMaxAgeSec,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func clearOAuthPendingSessionCookie(c *gin.Context, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPendingSessionCookieName,
- Value: "",
- Path: oauthPendingSessionCookiePath,
- MaxAge: -1,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func readOAuthPendingSessionCookie(c *gin.Context) (string, error) {
- return readCookieDecoded(c, oauthPendingSessionCookieName)
- }
- func captureOAuthPromoCode(c *gin.Context, secure bool) {
- promoCode := strings.TrimSpace(c.Query("promo_code"))
- if promoCode == "" {
- clearOAuthPromoCodeCookie(c, secure)
- return
- }
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPromoCodeCookieName,
- Value: encodeCookieValue(promoCode),
- Path: oauthPendingBrowserCookiePath,
- MaxAge: oauthPendingCookieMaxAgeSec,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func clearOAuthPromoCodeCookie(c *gin.Context, secure bool) {
- http.SetCookie(c.Writer, &http.Cookie{
- Name: oauthPromoCodeCookieName,
- Value: "",
- Path: oauthPendingBrowserCookiePath,
- MaxAge: -1,
- HttpOnly: true,
- Secure: secure,
- SameSite: http.SameSiteLaxMode,
- })
- }
- func readOAuthPromoCode(c *gin.Context) string {
- if c == nil {
- return ""
- }
- promoCode, err := readCookieDecoded(c, oauthPromoCodeCookieName)
- if err != nil {
- return ""
- }
- return strings.TrimSpace(promoCode)
- }
- func pendingOAuthPromoCode(session *dbent.PendingAuthSession) string {
- if session == nil {
- return ""
- }
- return pendingSessionStringValue(session.LocalFlowState, oauthPromoCodeStateKey)
- }
- func redirectToFrontendCallback(c *gin.Context, frontendCallback string) {
- 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 = ""
- c.Header("Cache-Control", "no-store")
- c.Header("Pragma", "no-cache")
- c.Redirect(http.StatusFound, u.String())
- }
- func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPendingSessionPayload) error {
- svc, err := h.pendingIdentityService()
- if err != nil {
- return err
- }
- localFlowState := map[string]any{
- oauthCompletionResponseKey: payload.CompletionResponse,
- }
- if promoCode := readOAuthPromoCode(c); promoCode != "" {
- localFlowState[oauthPromoCodeStateKey] = promoCode
- }
- session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{
- Intent: strings.TrimSpace(payload.Intent),
- Identity: payload.Identity,
- TargetUserID: payload.TargetUserID,
- ResolvedEmail: strings.TrimSpace(payload.ResolvedEmail),
- RedirectTo: strings.TrimSpace(payload.RedirectTo),
- BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey),
- UpstreamIdentityClaims: payload.UpstreamIdentityClaims,
- LocalFlowState: localFlowState,
- })
- if err != nil {
- slog.Error("pending auth session create failed",
- "intent", strings.TrimSpace(payload.Intent),
- "provider_type", strings.TrimSpace(payload.Identity.ProviderType),
- "provider_key", strings.TrimSpace(payload.Identity.ProviderKey),
- "provider_subject_len", len(strings.TrimSpace(payload.Identity.ProviderSubject)),
- "resolved_email_len", len(strings.TrimSpace(payload.ResolvedEmail)),
- "has_target_user", payload.TargetUserID != nil,
- "error", err.Error())
- return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
- }
- setOAuthPendingSessionCookie(c, session.SessionToken, isRequestHTTPS(c))
- return nil
- }
- func readCompletionResponse(session map[string]any) (map[string]any, bool) {
- if len(session) == 0 {
- return nil, false
- }
- value, ok := session[oauthCompletionResponseKey]
- if !ok {
- return nil, false
- }
- result, ok := value.(map[string]any)
- if !ok {
- return nil, false
- }
- return result, true
- }
- func clonePendingMap(values map[string]any) map[string]any {
- if len(values) == 0 {
- return map[string]any{}
- }
- cloned := make(map[string]any, len(values))
- for key, value := range values {
- cloned[key] = value
- }
- return cloned
- }
- func mergePendingCompletionResponse(session *dbent.PendingAuthSession, overrides map[string]any) map[string]any {
- payload, _ := readCompletionResponse(session.LocalFlowState)
- merged := clonePendingMap(payload)
- if strings.TrimSpace(session.RedirectTo) != "" {
- if _, exists := merged["redirect"]; !exists {
- merged["redirect"] = session.RedirectTo
- }
- }
- for key, value := range overrides {
- if value == nil {
- delete(merged, key)
- continue
- }
- merged[key] = value
- }
- applySuggestedProfileToCompletionResponse(merged, session.UpstreamIdentityClaims)
- return merged
- }
- func pendingSessionStringValue(values map[string]any, key string) string {
- if len(values) == 0 {
- return ""
- }
- raw, ok := values[key]
- if !ok {
- return ""
- }
- value, ok := raw.(string)
- if !ok {
- return ""
- }
- return strings.TrimSpace(value)
- }
- func pendingSessionWantsInvitation(payload map[string]any) bool {
- return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "error")), "invitation_required")
- }
- // pendingSessionRequiresEmailCompletion 判断 callback 写入的 completion payload 是否处于"补邮箱"状态。
- // 钉钉跨组织/staff 邮箱缺失时进入此状态:前端跳到补邮箱页,exchange 不应走 adoption apply。
- func pendingSessionRequiresEmailCompletion(payload map[string]any) bool {
- if v, ok := payload["requires_email_completion"].(bool); ok && v {
- return true
- }
- return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "email_completion")
- }
- // pendingSessionRequiresBindLogin 判断 callback 写入的 completion payload 是否处于"必须绑定已有账户"状态。
- // 钉钉 signupBlocked=true(注册关 + 钉钉企业豁免关)时进入此状态:前端渲染 bind_login 表单,
- // exchange 不应消费 session,否则后续 /pending/bind-login 找不到 session。
- func pendingSessionRequiresBindLogin(payload map[string]any) bool {
- return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "bind_login_required")
- }
- func pendingOAuthCompletionCanIssueTokenPair(session *dbent.PendingAuthSession, payload map[string]any) bool {
- if session == nil {
- return false
- }
- if !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentLogin) {
- return false
- }
- if session.TargetUserID == nil || *session.TargetUserID <= 0 {
- return false
- }
- if pendingSessionWantsInvitation(payload) {
- return false
- }
- return strings.TrimSpace(pendingSessionStringValue(payload, "step")) == ""
- }
- func ensurePendingOAuthCompleteRegistrationSession(session *dbent.PendingAuthSession) error {
- if session == nil {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- if strings.TrimSpace(session.Intent) != oauthIntentLogin {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- if session.TargetUserID != nil && *session.TargetUserID > 0 {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- payload, _ := readCompletionResponse(session.LocalFlowState)
- if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "bind_login_required") {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- return nil
- }
- func buildLegacyCompleteRegistrationPendingResponse(
- session *dbent.PendingAuthSession,
- forceEmailOnSignup bool,
- emailVerificationRequired bool,
- ) map[string]any {
- completionResponse := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, map[string]any{
- "step": oauthPendingChoiceStep,
- "adoption_required": true,
- "create_account_allowed": true,
- "force_email_on_signup": forceEmailOnSignup,
- }))
- if email := strings.TrimSpace(session.ResolvedEmail); email != "" {
- if _, exists := completionResponse["email"]; !exists {
- completionResponse["email"] = email
- }
- if _, exists := completionResponse["resolved_email"]; !exists {
- completionResponse["resolved_email"] = email
- }
- }
- if _, exists := completionResponse["choice_reason"]; !exists {
- switch {
- case forceEmailOnSignup:
- completionResponse["choice_reason"] = "force_email_on_signup"
- case emailVerificationRequired:
- completionResponse["choice_reason"] = "email_verification_required"
- default:
- completionResponse["choice_reason"] = "third_party_signup"
- }
- }
- return completionResponse
- }
- func (h *AuthHandler) legacyCompleteRegistrationSessionStatus(
- c *gin.Context,
- session *dbent.PendingAuthSession,
- ) (*dbent.PendingAuthSession, bool, error) {
- if session == nil {
- return nil, false, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- payload := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, nil))
- if step := pendingSessionStringValue(payload, "step"); step != "" {
- return session, true, nil
- }
- emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context())
- forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
- if !emailVerificationRequired && !forceEmailOnSignup {
- return session, false, nil
- }
- client := h.entClient()
- if client == nil {
- return nil, false, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- updatedSession, err := updatePendingOAuthSessionProgress(
- c.Request.Context(),
- client,
- session,
- strings.TrimSpace(session.Intent),
- strings.TrimSpace(session.ResolvedEmail),
- nil,
- buildLegacyCompleteRegistrationPendingResponse(session, forceEmailOnSignup, emailVerificationRequired),
- )
- if err != nil {
- return nil, false, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err)
- }
- return updatedSession, true, nil
- }
- func (r oauthAdoptionDecisionRequest) hasDecision() bool {
- return r.AdoptDisplayName != nil || r.AdoptAvatar != nil
- }
- func bindOptionalOAuthAdoptionDecision(c *gin.Context) (oauthAdoptionDecisionRequest, error) {
- var req oauthAdoptionDecisionRequest
- if c == nil || c.Request == nil || c.Request.Body == nil {
- return req, nil
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- if errors.Is(err, io.EOF) {
- return req, nil
- }
- return req, err
- }
- return req, nil
- }
- func cloneOAuthMetadata(values map[string]any) map[string]any {
- if len(values) == 0 {
- return map[string]any{}
- }
- cloned := make(map[string]any, len(values))
- for key, value := range values {
- cloned[key] = value
- }
- return cloned
- }
- func mergeOAuthMetadata(base map[string]any, overlay map[string]any) map[string]any {
- merged := cloneOAuthMetadata(base)
- for key, value := range overlay {
- merged[key] = value
- }
- return merged
- }
- func normalizeAdoptedOAuthDisplayName(value string) string {
- value = strings.TrimSpace(value)
- if len([]rune(value)) > 100 {
- value = string([]rune(value)[:100])
- }
- return value
- }
- func (h *AuthHandler) entClient() *dbent.Client {
- if h == nil || h.authService == nil {
- return nil
- }
- return h.authService.EntClient()
- }
- func (h *AuthHandler) isForceEmailOnThirdPartySignup(ctx context.Context) bool {
- if h == nil || h.settingSvc == nil {
- return false
- }
- defaults, err := h.settingSvc.GetAuthSourceDefaultSettings(ctx)
- if err != nil || defaults == nil {
- return false
- }
- return defaults.ForceEmailOnThirdPartySignup
- }
- func (h *AuthHandler) findOAuthIdentityUser(ctx context.Context, identity service.PendingAuthIdentityKey) (*dbent.User, error) {
- client := h.entClient()
- if client == nil {
- return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- record, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(strings.TrimSpace(identity.ProviderType)),
- authidentity.ProviderKeyEQ(strings.TrimSpace(identity.ProviderKey)),
- authidentity.ProviderSubjectEQ(strings.TrimSpace(identity.ProviderSubject)),
- ).
- Only(ctx)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil, nil
- }
- return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
- }
- return findActiveUserByID(ctx, client, record.UserID)
- }
- func (h *AuthHandler) BindLinuxDoOAuthLogin(c *gin.Context) { h.bindPendingOAuthLogin(c, "linuxdo") }
- func (h *AuthHandler) BindOIDCOAuthLogin(c *gin.Context) { h.bindPendingOAuthLogin(c, "oidc") }
- func (h *AuthHandler) BindWeChatOAuthLogin(c *gin.Context) { h.bindPendingOAuthLogin(c, "wechat") }
- func (h *AuthHandler) BindPendingOAuthLogin(c *gin.Context) { h.bindPendingOAuthLogin(c, "") }
- func (h *AuthHandler) CreateLinuxDoOAuthAccount(c *gin.Context) {
- h.createPendingOAuthAccount(c, "linuxdo")
- }
- func (h *AuthHandler) CreateOIDCOAuthAccount(c *gin.Context) { h.createPendingOAuthAccount(c, "oidc") }
- func (h *AuthHandler) CreateWeChatOAuthAccount(c *gin.Context) {
- h.createPendingOAuthAccount(c, "wechat")
- }
- func (h *AuthHandler) CreatePendingOAuthAccount(c *gin.Context) {
- h.createPendingOAuthAccount(c, "")
- }
- // SendPendingOAuthVerifyCode sends a verification code for a browser-bound
- // pending OAuth account-creation flow.
- // POST /api/v1/auth/oauth/pending/send-verify-code
- func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
- var req sendPendingOAuthVerifyCodeRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
- if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- _, session, _, err := readPendingOAuthBrowserSession(c, h)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- client := h.entClient()
- if client == nil {
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
- return
- }
- email := strings.TrimSpace(strings.ToLower(req.Email))
- if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
- session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(session))
- return
- } else if err != nil && !errors.Is(err, service.ErrUserNotFound) {
- response.ErrorFrom(c, err)
- return
- }
- result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email, c.GetHeader("Accept-Language"))
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, SendVerifyCodeResponse{
- Message: "Verification code sent successfully",
- Countdown: result.Countdown,
- })
- }
- func (h *AuthHandler) upsertPendingOAuthAdoptionDecision(
- c *gin.Context,
- sessionID int64,
- req oauthAdoptionDecisionRequest,
- ) (*dbent.IdentityAdoptionDecision, error) {
- client := h.entClient()
- if client == nil {
- return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- existing, err := client.IdentityAdoptionDecision.Query().
- Where(identityadoptiondecision.PendingAuthSessionIDEQ(sessionID)).
- Only(c.Request.Context())
- if err != nil && !dbent.IsNotFound(err) {
- return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_LOAD_FAILED", "failed to load oauth profile adoption decision").WithCause(err)
- }
- if existing != nil && !req.hasDecision() {
- return existing, nil
- }
- if existing == nil && !req.hasDecision() {
- return nil, nil
- }
- input := service.PendingIdentityAdoptionDecisionInput{
- PendingAuthSessionID: sessionID,
- }
- if existing != nil {
- input.AdoptDisplayName = existing.AdoptDisplayName
- input.AdoptAvatar = existing.AdoptAvatar
- input.IdentityID = existing.IdentityID
- }
- if req.AdoptDisplayName != nil {
- input.AdoptDisplayName = *req.AdoptDisplayName
- }
- if req.AdoptAvatar != nil {
- input.AdoptAvatar = *req.AdoptAvatar
- }
- svc, err := h.pendingIdentityService()
- if err != nil {
- return nil, err
- }
- decision, err := svc.UpsertAdoptionDecision(c.Request.Context(), input)
- if err != nil {
- return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err)
- }
- return decision, nil
- }
- func (h *AuthHandler) ensurePendingOAuthAdoptionDecision(
- c *gin.Context,
- sessionID int64,
- req oauthAdoptionDecisionRequest,
- ) (*dbent.IdentityAdoptionDecision, error) {
- decision, err := h.upsertPendingOAuthAdoptionDecision(c, sessionID, req)
- if err != nil {
- return nil, err
- }
- if decision != nil {
- return decision, nil
- }
- svc, err := h.pendingIdentityService()
- if err != nil {
- return nil, err
- }
- decision, err = svc.UpsertAdoptionDecision(c.Request.Context(), service.PendingIdentityAdoptionDecisionInput{
- PendingAuthSessionID: sessionID,
- })
- if err != nil {
- return nil, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_SAVE_FAILED", "failed to save oauth profile adoption decision").WithCause(err)
- }
- return decision, nil
- }
- func updatePendingOAuthSessionProgress(
- ctx context.Context,
- client *dbent.Client,
- session *dbent.PendingAuthSession,
- intent string,
- resolvedEmail string,
- targetUserID *int64,
- completionResponse map[string]any,
- ) (*dbent.PendingAuthSession, error) {
- if client == nil || session == nil {
- return nil, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth session is invalid")
- }
- localFlowState := clonePendingMap(session.LocalFlowState)
- localFlowState[oauthCompletionResponseKey] = clonePendingMap(completionResponse)
- update := client.PendingAuthSession.UpdateOneID(session.ID).
- SetIntent(strings.TrimSpace(intent)).
- SetResolvedEmail(strings.TrimSpace(resolvedEmail)).
- SetLocalFlowState(localFlowState)
- if targetUserID != nil && *targetUserID > 0 {
- update = update.SetTargetUserID(*targetUserID)
- } else {
- update = update.ClearTargetUserID()
- }
- return update.Save(ctx)
- }
- func resolvePendingOAuthTargetUserID(ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession) (int64, error) {
- if session == nil {
- return 0, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth session is invalid")
- }
- if session.TargetUserID != nil && *session.TargetUserID > 0 {
- return *session.TargetUserID, nil
- }
- email := strings.TrimSpace(session.ResolvedEmail)
- if email == "" {
- return 0, infraerrors.BadRequest("PENDING_AUTH_TARGET_USER_MISSING", "pending auth target user is missing")
- }
- userEntity, err := findUserByNormalizedEmail(ctx, client, email)
- if err != nil {
- if errors.Is(err, service.ErrUserNotFound) {
- return 0, infraerrors.InternalServer("PENDING_AUTH_TARGET_USER_NOT_FOUND", "pending auth target user was not found")
- }
- return 0, err
- }
- return userEntity.ID, nil
- }
- func userNormalizedEmailPredicate(email string) predicate.User {
- normalized := strings.ToLower(strings.TrimSpace(email))
- if normalized == "" {
- return dbuser.EmailEQ(email)
- }
- return predicate.User(func(s *entsql.Selector) {
- s.Where(entsql.P(func(b *entsql.Builder) {
- b.WriteString("LOWER(TRIM(").
- Ident(s.C(dbuser.FieldEmail)).
- WriteString(")) = ").
- Arg(normalized)
- }))
- })
- }
- func findUserByNormalizedEmail(ctx context.Context, client *dbent.Client, email string) (*dbent.User, error) {
- if client == nil {
- return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- matches, err := client.User.Query().
- Where(userNormalizedEmailPredicate(email)).
- Order(dbent.Asc(dbuser.FieldID)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- if len(matches) == 0 {
- return nil, service.ErrUserNotFound
- }
- if len(matches) > 1 {
- return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
- }
- return matches[0], nil
- }
- func ensurePendingOAuthRegistrationIdentityAvailable(ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession) error {
- if client == nil || session == nil {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(strings.TrimSpace(session.ProviderType)),
- authidentity.ProviderKeyEQ(strings.TrimSpace(session.ProviderKey)),
- authidentity.ProviderSubjectEQ(strings.TrimSpace(session.ProviderSubject)),
- ).
- Only(ctx)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil
- }
- return err
- }
- if identity == nil || identity.UserID <= 0 {
- return nil
- }
- activeOwner, err := findActiveUserByID(ctx, client, identity.UserID)
- if err != nil {
- return err
- }
- if activeOwner != nil {
- return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
- }
- return nil
- }
- func oauthIdentityIssuer(session *dbent.PendingAuthSession) *string {
- if session == nil {
- return nil
- }
- switch strings.TrimSpace(session.ProviderType) {
- case "oidc":
- issuer := strings.TrimSpace(session.ProviderKey)
- if issuer == "" {
- issuer = pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer")
- }
- if issuer == "" {
- return nil
- }
- return &issuer
- default:
- issuer := pendingSessionStringValue(session.UpstreamIdentityClaims, "issuer")
- if issuer == "" {
- return nil
- }
- return &issuer
- }
- }
- func ensurePendingOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, session *dbent.PendingAuthSession, userID int64) (*dbent.AuthIdentity, error) {
- if session != nil && strings.EqualFold(strings.TrimSpace(session.ProviderType), "wechat") {
- return ensurePendingWeChatOAuthIdentityForUser(ctx, tx, session, userID)
- }
- client := tx.Client()
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(strings.TrimSpace(session.ProviderType)),
- authidentity.ProviderKeyEQ(strings.TrimSpace(session.ProviderKey)),
- authidentity.ProviderSubjectEQ(strings.TrimSpace(session.ProviderSubject)),
- ).
- Only(ctx)
- if err != nil && !dbent.IsNotFound(err) {
- return nil, err
- }
- if identity != nil {
- if identity.UserID != userID {
- activeOwner, err := findActiveUserByID(ctx, client, identity.UserID)
- if err != nil {
- return nil, err
- }
- if activeOwner != nil {
- return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
- }
- return client.AuthIdentity.UpdateOneID(identity.ID).
- SetUserID(userID).
- Save(ctx)
- }
- return identity, nil
- }
- create := client.AuthIdentity.Create().
- SetUserID(userID).
- SetProviderType(strings.TrimSpace(session.ProviderType)).
- SetProviderKey(strings.TrimSpace(session.ProviderKey)).
- SetProviderSubject(strings.TrimSpace(session.ProviderSubject)).
- SetMetadata(cloneOAuthMetadata(session.UpstreamIdentityClaims))
- if issuer := oauthIdentityIssuer(session); issuer != nil {
- create = create.SetIssuer(strings.TrimSpace(*issuer))
- }
- return create.Save(ctx)
- }
- func ensurePendingWeChatOAuthIdentityForUser(ctx context.Context, tx *dbent.Tx, session *dbent.PendingAuthSession, userID int64) (*dbent.AuthIdentity, error) {
- client := tx.Client()
- providerType := strings.TrimSpace(session.ProviderType)
- providerKey := strings.TrimSpace(session.ProviderKey)
- providerSubject := strings.TrimSpace(session.ProviderSubject)
- providerKeys := wechatCompatibleProviderKeys(providerKey)
- channel := strings.TrimSpace(pendingSessionStringValue(session.UpstreamIdentityClaims, "channel"))
- channelAppID := strings.TrimSpace(pendingSessionStringValue(session.UpstreamIdentityClaims, "channel_app_id"))
- channelSubject := strings.TrimSpace(pendingSessionStringValue(session.UpstreamIdentityClaims, "channel_subject"))
- metadata := cloneOAuthMetadata(session.UpstreamIdentityClaims)
- identityRecords, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(providerType),
- authidentity.ProviderKeyIn(providerKeys...),
- authidentity.ProviderSubjectEQ(providerSubject),
- ).
- All(ctx)
- if err != nil {
- return nil, err
- }
- identity, hasCanonicalKey, err := chooseWeChatIdentityForUser(ctx, client, identityRecords, userID, providerKey)
- if err != nil {
- return nil, err
- }
- var legacyOpenIDIdentity *dbent.AuthIdentity
- if channelSubject != "" && channelSubject != providerSubject {
- legacyOpenIDRecords, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(providerType),
- authidentity.ProviderKeyIn(providerKeys...),
- authidentity.ProviderSubjectEQ(channelSubject),
- ).
- All(ctx)
- if err != nil {
- return nil, err
- }
- legacyOpenIDIdentity, _, err = chooseWeChatIdentityForUser(ctx, client, legacyOpenIDRecords, userID, providerKey)
- if err != nil {
- return nil, err
- }
- }
- switch {
- case identity != nil:
- update := client.AuthIdentity.UpdateOneID(identity.ID).
- SetMetadata(mergeOAuthMetadata(identity.Metadata, metadata))
- if identity.UserID != userID {
- update = update.SetUserID(userID)
- }
- if !strings.EqualFold(strings.TrimSpace(identity.ProviderKey), providerKey) && !hasCanonicalKey {
- update = update.SetProviderKey(providerKey)
- }
- if issuer := oauthIdentityIssuer(session); issuer != nil {
- update = update.SetIssuer(strings.TrimSpace(*issuer))
- }
- identity, err = update.Save(ctx)
- if err != nil {
- return nil, err
- }
- case legacyOpenIDIdentity != nil:
- update := client.AuthIdentity.UpdateOneID(legacyOpenIDIdentity.ID).
- SetProviderKey(providerKey).
- SetProviderSubject(providerSubject).
- SetMetadata(mergeOAuthMetadata(legacyOpenIDIdentity.Metadata, metadata))
- if issuer := oauthIdentityIssuer(session); issuer != nil {
- update = update.SetIssuer(strings.TrimSpace(*issuer))
- }
- identity, err = update.Save(ctx)
- if err != nil {
- return nil, err
- }
- default:
- create := client.AuthIdentity.Create().
- SetUserID(userID).
- SetProviderType(providerType).
- SetProviderKey(providerKey).
- SetProviderSubject(providerSubject).
- SetMetadata(metadata)
- if issuer := oauthIdentityIssuer(session); issuer != nil {
- create = create.SetIssuer(strings.TrimSpace(*issuer))
- }
- identity, err = create.Save(ctx)
- if err != nil {
- return nil, err
- }
- }
- if channel == "" || channelAppID == "" || channelSubject == "" {
- return identity, nil
- }
- channelRecords, err := client.AuthIdentityChannel.Query().
- Where(
- authidentitychannel.ProviderTypeEQ(providerType),
- authidentitychannel.ProviderKeyIn(providerKeys...),
- authidentitychannel.ChannelEQ(channel),
- authidentitychannel.ChannelAppIDEQ(channelAppID),
- authidentitychannel.ChannelSubjectEQ(channelSubject),
- ).
- WithIdentity().
- All(ctx)
- if err != nil {
- return nil, err
- }
- channelRecord, hasCanonicalChannelKey, err := chooseWeChatChannelForUser(ctx, client, channelRecords, userID, providerKey)
- if err != nil {
- return nil, err
- }
- channelMetadata := mergeOAuthMetadata(channelRecordMetadata(channelRecord), metadata)
- if channelRecord == nil {
- if _, err := client.AuthIdentityChannel.Create().
- SetIdentityID(identity.ID).
- SetProviderType(providerType).
- SetProviderKey(providerKey).
- SetChannel(channel).
- SetChannelAppID(channelAppID).
- SetChannelSubject(channelSubject).
- SetMetadata(channelMetadata).
- Save(ctx); err != nil {
- return nil, err
- }
- return identity, nil
- }
- updateChannel := client.AuthIdentityChannel.UpdateOneID(channelRecord.ID).
- SetIdentityID(identity.ID).
- SetMetadata(channelMetadata)
- if !strings.EqualFold(strings.TrimSpace(channelRecord.ProviderKey), providerKey) && !hasCanonicalChannelKey {
- updateChannel = updateChannel.SetProviderKey(providerKey)
- }
- _, err = updateChannel.Save(ctx)
- if err != nil {
- return nil, err
- }
- return identity, nil
- }
- func chooseWeChatIdentityForUser(ctx context.Context, client *dbent.Client, records []*dbent.AuthIdentity, userID int64, preferredProviderKey string) (*dbent.AuthIdentity, bool, error) {
- var preferred *dbent.AuthIdentity
- var fallback *dbent.AuthIdentity
- hasCanonicalKey := false
- for _, record := range records {
- if record == nil {
- continue
- }
- if record.UserID != userID {
- activeOwner, err := findActiveUserByID(ctx, client, record.UserID)
- if err != nil {
- return nil, false, err
- }
- if activeOwner != nil {
- return nil, false, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
- }
- }
- if strings.EqualFold(strings.TrimSpace(record.ProviderKey), preferredProviderKey) {
- hasCanonicalKey = true
- if preferred == nil {
- preferred = record
- }
- continue
- }
- if fallback == nil {
- fallback = record
- }
- }
- if preferred != nil {
- return preferred, hasCanonicalKey, nil
- }
- return fallback, hasCanonicalKey, nil
- }
- func chooseWeChatChannelForUser(ctx context.Context, client *dbent.Client, records []*dbent.AuthIdentityChannel, userID int64, preferredProviderKey string) (*dbent.AuthIdentityChannel, bool, error) {
- var preferred *dbent.AuthIdentityChannel
- var fallback *dbent.AuthIdentityChannel
- hasCanonicalKey := false
- for _, record := range records {
- if record == nil {
- continue
- }
- if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID {
- activeOwner, err := findActiveUserByID(ctx, client, record.Edges.Identity.UserID)
- if err != nil {
- return nil, false, err
- }
- if activeOwner != nil {
- return nil, false, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user")
- }
- }
- if strings.EqualFold(strings.TrimSpace(record.ProviderKey), preferredProviderKey) {
- hasCanonicalKey = true
- if preferred == nil {
- preferred = record
- }
- continue
- }
- if fallback == nil {
- fallback = record
- }
- }
- if preferred != nil {
- return preferred, hasCanonicalKey, nil
- }
- return fallback, hasCanonicalKey, nil
- }
- func findActiveUserByID(ctx context.Context, client *dbent.Client, userID int64) (*dbent.User, error) {
- if client == nil || userID <= 0 {
- return nil, nil
- }
- userEntity, err := client.User.Get(ctx, userID)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil, nil
- }
- return nil, infraerrors.InternalServer("AUTH_IDENTITY_USER_LOOKUP_FAILED", "failed to load auth identity user").WithCause(err)
- }
- if !strings.EqualFold(strings.TrimSpace(userEntity.Status), service.StatusActive) {
- return nil, service.ErrUserNotActive
- }
- return userEntity, nil
- }
- func channelRecordMetadata(channel *dbent.AuthIdentityChannel) map[string]any {
- if channel == nil {
- return map[string]any{}
- }
- return cloneOAuthMetadata(channel.Metadata)
- }
- func shouldBindPendingOAuthIdentity(session *dbent.PendingAuthSession, decision *dbent.IdentityAdoptionDecision) bool {
- if session == nil || decision == nil {
- return false
- }
- switch strings.ToLower(strings.TrimSpace(session.Intent)) {
- case "bind_current_user", "login", "adopt_existing_user_by_email":
- return true
- default:
- return decision.AdoptDisplayName || decision.AdoptAvatar
- }
- }
- func shouldSkipAvatarAdoption(err error) bool {
- return errors.Is(err, service.ErrAvatarInvalid) ||
- errors.Is(err, service.ErrAvatarTooLarge) ||
- errors.Is(err, service.ErrAvatarNotImage)
- }
- func applyPendingOAuthBinding(
- ctx context.Context,
- client *dbent.Client,
- authService *service.AuthService,
- userService *service.UserService,
- session *dbent.PendingAuthSession,
- decision *dbent.IdentityAdoptionDecision,
- overrideUserID *int64,
- forceBind bool,
- applyFirstBindDefaults bool,
- ) error {
- if client == nil || session == nil {
- return nil
- }
- if !forceBind && !shouldBindPendingOAuthIdentity(session, decision) {
- return nil
- }
- if tx := dbent.TxFromContext(ctx); tx != nil {
- return applyPendingOAuthBindingTx(ctx, tx, authService, userService, session, decision, overrideUserID, forceBind, applyFirstBindDefaults)
- }
- tx, err := client.Tx(ctx)
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(ctx, tx)
- if err := applyPendingOAuthBindingTx(txCtx, tx, authService, userService, session, decision, overrideUserID, forceBind, applyFirstBindDefaults); err != nil {
- return err
- }
- return tx.Commit()
- }
- func applyPendingOAuthBindingTx(
- ctx context.Context,
- tx *dbent.Tx,
- authService *service.AuthService,
- userService *service.UserService,
- session *dbent.PendingAuthSession,
- decision *dbent.IdentityAdoptionDecision,
- overrideUserID *int64,
- forceBind bool,
- applyFirstBindDefaults bool,
- ) error {
- if tx == nil || session == nil {
- return nil
- }
- if !forceBind && !shouldBindPendingOAuthIdentity(session, decision) {
- return nil
- }
- targetUserID := int64(0)
- if overrideUserID != nil && *overrideUserID > 0 {
- targetUserID = *overrideUserID
- } else {
- resolvedUserID, err := resolvePendingOAuthTargetUserID(ctx, tx.Client(), session)
- if err != nil {
- return err
- }
- targetUserID = resolvedUserID
- }
- adoptedDisplayName := ""
- if decision != nil && decision.AdoptDisplayName {
- adoptedDisplayName = normalizeAdoptedOAuthDisplayName(pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name"))
- }
- adoptedAvatarURL := ""
- if decision != nil && decision.AdoptAvatar {
- adoptedAvatarURL = pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url")
- }
- shouldAdoptAvatar := false
- if decision != nil && decision.AdoptAvatar && adoptedAvatarURL != "" {
- if err := service.ValidateUserAvatar(adoptedAvatarURL); err == nil {
- shouldAdoptAvatar = true
- } else if !shouldSkipAvatarAdoption(err) {
- return err
- }
- }
- if decision != nil && decision.AdoptDisplayName && adoptedDisplayName != "" {
- if err := tx.Client().User.UpdateOneID(targetUserID).
- SetUsername(adoptedDisplayName).
- Exec(ctx); err != nil {
- return err
- }
- }
- identity, err := ensurePendingOAuthIdentityForUser(ctx, tx, session, targetUserID)
- if err != nil {
- return err
- }
- metadata := cloneOAuthMetadata(identity.Metadata)
- for key, value := range session.UpstreamIdentityClaims {
- metadata[key] = value
- }
- if decision != nil && decision.AdoptDisplayName && adoptedDisplayName != "" {
- metadata["display_name"] = adoptedDisplayName
- }
- if shouldAdoptAvatar {
- metadata["avatar_url"] = adoptedAvatarURL
- }
- updateIdentity := tx.Client().AuthIdentity.UpdateOneID(identity.ID).SetMetadata(metadata)
- if issuer := oauthIdentityIssuer(session); issuer != nil {
- updateIdentity = updateIdentity.SetIssuer(strings.TrimSpace(*issuer))
- }
- if _, err := updateIdentity.Save(ctx); err != nil {
- return err
- }
- if decision != nil && (decision.IdentityID == nil || *decision.IdentityID != identity.ID) {
- if _, err := tx.Client().IdentityAdoptionDecision.Update().
- Where(
- identityadoptiondecision.IdentityIDEQ(identity.ID),
- identityadoptiondecision.IDNEQ(decision.ID),
- ).
- ClearIdentityID().
- Save(ctx); err != nil {
- return err
- }
- if _, err := tx.Client().IdentityAdoptionDecision.UpdateOneID(decision.ID).
- SetIdentityID(identity.ID).
- Save(ctx); err != nil {
- return err
- }
- }
- if applyFirstBindDefaults && authService != nil {
- if err := authService.ApplyProviderDefaultSettingsOnFirstBind(ctx, targetUserID, session.ProviderType); err != nil {
- return err
- }
- }
- if shouldAdoptAvatar && userService != nil {
- if _, err := userService.SetAvatar(ctx, targetUserID, adoptedAvatarURL); err != nil {
- return err
- }
- }
- return nil
- }
- func consumePendingOAuthBrowserSessionTx(
- ctx context.Context,
- tx *dbent.Tx,
- session *dbent.PendingAuthSession,
- ) error {
- if tx == nil || session == nil {
- return service.ErrPendingAuthSessionNotFound
- }
- storedSession, err := tx.Client().PendingAuthSession.Get(ctx, session.ID)
- if err != nil {
- if dbent.IsNotFound(err) {
- return service.ErrPendingAuthSessionNotFound
- }
- return err
- }
- now := time.Now().UTC()
- if storedSession.ConsumedAt != nil {
- return service.ErrPendingAuthSessionConsumed
- }
- if !storedSession.ExpiresAt.IsZero() && now.After(storedSession.ExpiresAt) {
- return service.ErrPendingAuthSessionExpired
- }
- if strings.TrimSpace(storedSession.BrowserSessionKey) != "" &&
- strings.TrimSpace(storedSession.BrowserSessionKey) != strings.TrimSpace(session.BrowserSessionKey) {
- return service.ErrPendingAuthBrowserMismatch
- }
- if _, err := tx.Client().PendingAuthSession.UpdateOneID(storedSession.ID).
- SetConsumedAt(now).
- SetCompletionCodeHash("").
- ClearCompletionCodeExpiresAt().
- Save(ctx); err != nil {
- return err
- }
- return nil
- }
- func applyPendingOAuthAdoptionAndConsumeSession(
- ctx context.Context,
- client *dbent.Client,
- authService *service.AuthService,
- userService *service.UserService,
- session *dbent.PendingAuthSession,
- decision *dbent.IdentityAdoptionDecision,
- userID int64,
- ) error {
- if client == nil {
- return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
- }
- if session == nil || userID <= 0 {
- return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
- }
- tx, err := client.Tx(ctx)
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(ctx, tx)
- if err := applyPendingOAuthAdoption(txCtx, client, authService, userService, session, decision, &userID); err != nil {
- return err
- }
- if err := consumePendingOAuthBrowserSessionTx(txCtx, tx, session); err != nil {
- return err
- }
- return tx.Commit()
- }
- func applyPendingOAuthAdoption(
- ctx context.Context,
- client *dbent.Client,
- authService *service.AuthService,
- userService *service.UserService,
- session *dbent.PendingAuthSession,
- decision *dbent.IdentityAdoptionDecision,
- overrideUserID *int64,
- ) error {
- return applyPendingOAuthBinding(
- ctx,
- client,
- authService,
- userService,
- session,
- decision,
- overrideUserID,
- false,
- strings.EqualFold(strings.TrimSpace(session.Intent), "bind_current_user"),
- )
- }
- func applySuggestedProfileToCompletionResponse(payload map[string]any, upstream map[string]any) {
- if len(payload) == 0 || len(upstream) == 0 {
- return
- }
- displayName := pendingSessionStringValue(upstream, "suggested_display_name")
- avatarURL := pendingSessionStringValue(upstream, "suggested_avatar_url")
- if displayName != "" {
- if _, exists := payload["suggested_display_name"]; !exists {
- payload["suggested_display_name"] = displayName
- }
- }
- if avatarURL != "" {
- if _, exists := payload["suggested_avatar_url"]; !exists {
- payload["suggested_avatar_url"] = avatarURL
- }
- }
- if displayName != "" || avatarURL != "" {
- payload["adoption_required"] = true
- }
- }
- func pendingOAuthIdentityExistsForUser(
- ctx context.Context,
- client *dbent.Client,
- session *dbent.PendingAuthSession,
- userID int64,
- ) (bool, error) {
- if client == nil || session == nil || userID <= 0 {
- return false, nil
- }
- providerType := strings.TrimSpace(session.ProviderType)
- providerKey := strings.TrimSpace(session.ProviderKey)
- providerSubject := strings.TrimSpace(session.ProviderSubject)
- if providerType == "" || providerSubject == "" {
- return false, nil
- }
- query := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ(providerType),
- authidentity.ProviderSubjectEQ(providerSubject),
- authidentity.UserIDEQ(userID),
- )
- if strings.EqualFold(providerType, "wechat") {
- query = query.Where(authidentity.ProviderKeyIn(wechatCompatibleProviderKeys(providerKey)...))
- } else if providerKey != "" {
- query = query.Where(authidentity.ProviderKeyEQ(providerKey))
- }
- count, err := query.Count(ctx)
- if err != nil {
- return false, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
- }
- return count > 0, nil
- }
- func (h *AuthHandler) shouldSkipPendingOAuthAdoptionPrompt(
- ctx context.Context,
- session *dbent.PendingAuthSession,
- payload map[string]any,
- ) (bool, error) {
- if session == nil || len(payload) == 0 {
- return false, nil
- }
- if !pendingOAuthCompletionCanIssueTokenPair(session, payload) {
- return false, nil
- }
- if pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_display_name") == "" &&
- pendingSessionStringValue(session.UpstreamIdentityClaims, "suggested_avatar_url") == "" {
- return false, nil
- }
- return pendingOAuthIdentityExistsForUser(ctx, h.entClient(), session, *session.TargetUserID)
- }
- func readPendingOAuthBrowserSession(c *gin.Context, h *AuthHandler) (*service.AuthPendingIdentityService, *dbent.PendingAuthSession, func(), error) {
- secureCookie := isRequestHTTPS(c)
- clearCookies := func() {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- }
- sessionToken, err := readOAuthPendingSessionCookie(c)
- if err != nil || strings.TrimSpace(sessionToken) == "" {
- clearCookies()
- return nil, nil, clearCookies, service.ErrPendingAuthSessionNotFound
- }
- browserSessionKey, err := readOAuthPendingBrowserCookie(c)
- if err != nil || strings.TrimSpace(browserSessionKey) == "" {
- clearCookies()
- return nil, nil, clearCookies, service.ErrPendingAuthBrowserMismatch
- }
- svc, err := h.pendingIdentityService()
- if err != nil {
- clearCookies()
- return nil, nil, clearCookies, err
- }
- session, err := svc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
- if err != nil {
- clearCookies()
- return nil, nil, clearCookies, err
- }
- return svc, session, clearCookies, nil
- }
- func (h *AuthHandler) consumePendingOAuthSessionOnLogout(c *gin.Context) {
- if c == nil || c.Request == nil {
- return
- }
- sessionToken, err := readOAuthPendingSessionCookie(c)
- if err != nil || strings.TrimSpace(sessionToken) == "" {
- return
- }
- browserSessionKey, err := readOAuthPendingBrowserCookie(c)
- if err != nil || strings.TrimSpace(browserSessionKey) == "" {
- return
- }
- svc, err := h.pendingIdentityService()
- if err != nil {
- return
- }
- _, _ = svc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
- }
- func clearOAuthLogoutCookies(c *gin.Context) {
- secureCookie := isRequestHTTPS(c)
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- clearOAuthBindAccessTokenCookie(c, secureCookie)
- clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
- clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
- clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
- clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
- clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
- oidcClearCookie(c, oidcOAuthStateCookieName, secureCookie)
- oidcClearCookie(c, oidcOAuthVerifierCookie, secureCookie)
- oidcClearCookie(c, oidcOAuthRedirectCookie, secureCookie)
- oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
- oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
- oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
- wechatClearCookie(c, wechatOAuthStateCookieName, secureCookie)
- wechatClearCookie(c, wechatOAuthRedirectCookieName, secureCookie)
- wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
- wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
- wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
- wechatPaymentClearCookie(c, wechatPaymentOAuthStateName, secureCookie)
- wechatPaymentClearCookie(c, wechatPaymentOAuthRedirect, secureCookie)
- wechatPaymentClearCookie(c, wechatPaymentOAuthContextName, secureCookie)
- wechatPaymentClearCookie(c, wechatPaymentOAuthScope, secureCookie)
- }
- func buildPendingOAuthSessionStatusPayload(session *dbent.PendingAuthSession) gin.H {
- completionResponse := normalizePendingOAuthCompletionResponse(mergePendingCompletionResponse(session, nil))
- payload := gin.H{
- "auth_result": "pending_session",
- "provider": strings.TrimSpace(session.ProviderType),
- "intent": strings.TrimSpace(session.Intent),
- }
- for key, value := range completionResponse {
- payload[key] = value
- }
- if email := strings.TrimSpace(session.ResolvedEmail); email != "" {
- payload["email"] = email
- }
- return payload
- }
- func normalizePendingOAuthCompletionResponse(payload map[string]any) map[string]any {
- normalized := clonePendingMap(payload)
- for _, key := range []string{"access_token", "refresh_token", "expires_in", "token_type"} {
- delete(normalized, key)
- }
- step := strings.ToLower(strings.TrimSpace(pendingSessionStringValue(normalized, "step")))
- // 把多种 choice 别名归一为 oauthPendingChoiceStep;bind_login_required 是独立终态
- // (前端渲染 needsBindLogin 而非 needsChooser),故不能并入归一化列表。
- switch step {
- case "choice", "choose_account_action", "choose_account", "choose", "email_required":
- normalized["step"] = oauthPendingChoiceStep
- }
- if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(normalized, "step")), oauthPendingChoiceStep) {
- normalized["adoption_required"] = true
- }
- if _, exists := normalized["adoption_required"]; !exists {
- if _, hasChoiceFields := normalized["email_binding_required"]; hasChoiceFields {
- normalized["adoption_required"] = true
- }
- }
- return normalized
- }
- func pendingOAuthChoiceCompletionResponse(session *dbent.PendingAuthSession, email string) map[string]any {
- response := mergePendingCompletionResponse(session, map[string]any{
- "step": oauthPendingChoiceStep,
- "adoption_required": true,
- "force_email_on_signup": true,
- "email_binding_required": true,
- "existing_account_bindable": true,
- })
- if email = strings.TrimSpace(email); email != "" {
- response["email"] = email
- response["resolved_email"] = email
- }
- return response
- }
- func (h *AuthHandler) transitionPendingOAuthAccountToChoiceState(
- c *gin.Context,
- client *dbent.Client,
- session *dbent.PendingAuthSession,
- targetUser *dbent.User,
- email string,
- ) (*dbent.PendingAuthSession, error) {
- completionResponse := pendingOAuthChoiceCompletionResponse(session, email)
- var targetUserID *int64
- if targetUser != nil && targetUser.ID > 0 {
- targetUserID = &targetUser.ID
- }
- session, err := updatePendingOAuthSessionProgress(
- c.Request.Context(),
- client,
- session,
- strings.TrimSpace(session.Intent),
- email,
- targetUserID,
- completionResponse,
- )
- if err != nil {
- return nil, infraerrors.InternalServer("PENDING_AUTH_SESSION_UPDATE_FAILED", "failed to update pending oauth session").WithCause(err)
- }
- return session, nil
- }
- func writeOAuthTokenPairResponse(c *gin.Context, tokenPair *service.TokenPair) {
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
- }
- func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
- var req bindPendingOAuthLoginRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- pendingSvc, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if strings.TrimSpace(provider) != "" && !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
- response.BadRequest(c, "Pending oauth session provider mismatch")
- return
- }
- user, err := h.authService.ValidatePasswordCredentials(c.Request.Context(), strings.TrimSpace(req.Email), req.Password)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if session.TargetUserID != nil && *session.TargetUserID > 0 && user.ID != *session.TargetUserID {
- response.ErrorFrom(c, infraerrors.Conflict("PENDING_AUTH_TARGET_USER_MISMATCH", "pending oauth session must be completed by the targeted user"))
- return
- }
- if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, req.adoptionDecision())
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.totpService != nil && h.settingSvc.IsTotpEnabled(c.Request.Context()) && user.TotpEnabled {
- tempToken, err := h.totpService.CreatePendingOAuthBindLoginSession(
- c.Request.Context(),
- user.ID,
- user.Email,
- session.SessionToken,
- session.BrowserSessionKey,
- )
- if err != nil {
- response.InternalError(c, "Failed to create 2FA session")
- return
- }
- response.Success(c, TotpLoginResponse{
- Requires2FA: true,
- TempToken: tempToken,
- UserEmailMasked: service.MaskEmail(user.Email),
- })
- return
- }
- if err := applyPendingOAuthBinding(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, &user.ID, true, true); err != nil {
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
- // bindPendingOAuthLogin = 绑定已有账户登录,不动 users.username(用户已有自己的名字)
- h.maybeSyncDingTalkAfterLogin(c.Request.Context(), session, user.ID)
- tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
- if err != nil {
- response.InternalError(c, "Failed to generate token pair")
- return
- }
- if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), session.SessionToken, session.BrowserSessionKey); err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- clearCookies()
- writeOAuthTokenPairResponse(c, tokenPair)
- }
- func respondPendingOAuthBindingApplyError(c *gin.Context, err error) {
- if code := infraerrors.Code(err); code >= http.StatusBadRequest && code < http.StatusInternalServerError {
- response.ErrorFrom(c, err)
- return
- }
- response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to bind pending oauth identity").WithCause(err))
- }
- func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) {
- var req createPendingOAuthAccountRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- _, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if strings.TrimSpace(provider) != "" && !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
- response.BadRequest(c, "Pending oauth session provider mismatch")
- return
- }
- client := h.entClient()
- if client == nil {
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
- return
- }
- email := strings.TrimSpace(strings.ToLower(req.Email))
- existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
- if err != nil {
- switch {
- case errors.Is(err, service.ErrUserNotFound):
- existingUser = nil
- case infraerrors.Code(err) >= http.StatusBadRequest && infraerrors.Code(err) < http.StatusInternalServerError:
- response.ErrorFrom(c, err)
- return
- default:
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable"))
- return
- }
- }
- if existingUser != nil {
- session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(session))
- return
- }
- if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
- if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- tokenPair, user, err := h.authService.RegisterOAuthEmailAccount(
- c.Request.Context(),
- email,
- req.Password,
- strings.TrimSpace(req.VerifyCode),
- strings.TrimSpace(req.InvitationCode),
- strings.TrimSpace(session.ProviderType),
- )
- if err != nil {
- if errors.Is(err, service.ErrEmailExists) {
- existingUser, lookupErr := findUserByNormalizedEmail(c.Request.Context(), client, email)
- if lookupErr != nil {
- response.ErrorFrom(c, lookupErr)
- return
- }
- session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(session))
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- rollbackCreatedUser := func(originalErr error) bool {
- if user == nil || user.ID <= 0 {
- return false
- }
- if rollbackErr := h.authService.RollbackOAuthEmailAccountCreation(
- c.Request.Context(),
- user.ID,
- strings.TrimSpace(req.InvitationCode),
- ); rollbackErr != nil {
- response.ErrorFrom(c, infraerrors.InternalServer(
- "PENDING_AUTH_ACCOUNT_ROLLBACK_FAILED",
- "failed to rollback pending oauth account creation",
- ).WithCause(fmt.Errorf("original error: %w; rollback error: %v", originalErr, rollbackErr)))
- return true
- }
- user = nil
- return false
- }
- decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, req.adoptionDecision())
- if err != nil {
- if rollbackCreatedUser(err) {
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- tx, err := client.Tx(c.Request.Context())
- if err != nil {
- if rollbackCreatedUser(err) {
- return
- }
- response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to bind pending oauth identity").WithCause(err))
- return
- }
- defer func() { _ = tx.Rollback() }()
- txCtx := dbent.NewTxContext(c.Request.Context(), tx)
- if err := applyPendingOAuthBinding(txCtx, client, h.authService, h.userService, session, decision, &user.ID, true, false); err != nil {
- _ = tx.Rollback()
- if rollbackCreatedUser(err) {
- return
- }
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- if err := h.authService.FinalizeOAuthEmailAccount(
- txCtx,
- user,
- strings.TrimSpace(req.InvitationCode),
- strings.TrimSpace(session.ProviderType),
- strings.TrimSpace(req.AffCode),
- ); err != nil {
- _ = tx.Rollback()
- if rollbackCreatedUser(err) {
- return
- }
- response.ErrorFrom(c, err)
- return
- }
- if err := consumePendingOAuthBrowserSessionTx(txCtx, tx, session); err != nil {
- _ = tx.Rollback()
- if rollbackCreatedUser(err) {
- return
- }
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- if pendingOAuthCreateAccountPreCommitHook != nil {
- if err := pendingOAuthCreateAccountPreCommitHook(txCtx, session); err != nil {
- _ = tx.Rollback()
- if rollbackCreatedUser(err) {
- return
- }
- respondPendingOAuthBindingApplyError(c, err)
- return
- }
- }
- if err := tx.Commit(); err != nil {
- if rollbackCreatedUser(err) {
- return
- }
- response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to bind pending oauth identity").WithCause(err))
- return
- }
- h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
- h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
- // createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值
- h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID)
- clearCookies()
- writeOAuthTokenPairResponse(c, tokenPair)
- }
- // ExchangePendingOAuthCompletion redeems a pending OAuth browser session into a frontend-safe payload.
- // POST /api/v1/auth/oauth/pending/exchange
- func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
- secureCookie := isRequestHTTPS(c)
- clearCookies := func() {
- clearOAuthPendingSessionCookie(c, secureCookie)
- clearOAuthPendingBrowserCookie(c, secureCookie)
- }
- adoptionDecision, err := bindOptionalOAuthAdoptionDecision(c)
- if err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- sessionToken, err := readOAuthPendingSessionCookie(c)
- if err != nil || strings.TrimSpace(sessionToken) == "" {
- clearCookies()
- response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
- return
- }
- browserSessionKey, err := readOAuthPendingBrowserCookie(c)
- if err != nil || strings.TrimSpace(browserSessionKey) == "" {
- clearCookies()
- response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
- return
- }
- svc, err := h.pendingIdentityService()
- if err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- session, err := svc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
- if err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- payload, ok := readCompletionResponse(session.LocalFlowState)
- if !ok {
- clearCookies()
- response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_COMPLETION_INVALID", "pending auth completion payload is invalid"))
- return
- }
- payload = normalizePendingOAuthCompletionResponse(payload)
- if strings.TrimSpace(session.RedirectTo) != "" {
- if _, exists := payload["redirect"]; !exists {
- payload["redirect"] = session.RedirectTo
- }
- }
- applySuggestedProfileToCompletionResponse(payload, session.UpstreamIdentityClaims)
- canIssueTokenPair := pendingOAuthCompletionCanIssueTokenPair(session, payload)
- var loginUser *service.User
- if canIssueTokenPair {
- loginUser, err = h.userService.GetByID(c.Request.Context(), *session.TargetUserID)
- if err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- if err := ensureLoginUserActive(loginUser); err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- if err := h.ensureBackendModeAllowsUser(c.Request.Context(), loginUser); err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- }
- skipAdoptionPrompt, err := h.shouldSkipPendingOAuthAdoptionPrompt(c.Request.Context(), session, payload)
- if err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- if skipAdoptionPrompt {
- delete(payload, "adoption_required")
- }
- if pendingSessionWantsInvitation(payload) {
- if adoptionDecision.hasDecision() {
- decision, err := h.upsertPendingOAuthAdoptionDecision(c, session.ID, adoptionDecision)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- _ = decision
- }
- response.Success(c, payload)
- return
- }
- if pendingSessionRequiresEmailCompletion(payload) {
- response.Success(c, payload)
- return
- }
- if pendingSessionRequiresBindLogin(payload) {
- response.Success(c, payload)
- return
- }
- // ─── 安全修复(账号接管 0day)────────────────────────────────────────────
- // 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自
- // 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode
- // 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、
- // 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的
- // applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID,
- // 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。
- // 只有两类 session 允许在此处执行 adoption/binding:
- // 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户;
- // 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。
- // 其余状态一律只返回 payload,不绑定、不消费 session。
- if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) {
- response.Success(c, payload)
- return
- }
- if !adoptionDecision.hasDecision() {
- adoptionRequired, _ := payload["adoption_required"].(bool)
- if adoptionRequired {
- response.Success(c, payload)
- return
- }
- }
- decisionReq := adoptionDecision
- if !decisionReq.hasDecision() {
- adoptDisplayName := false
- adoptAvatar := false
- decisionReq = oauthAdoptionDecisionRequest{
- AdoptDisplayName: &adoptDisplayName,
- AdoptAvatar: &adoptAvatar,
- }
- }
- decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, decisionReq)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, session.TargetUserID); err != nil {
- response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
- return
- }
- if _, err := svc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
- clearCookies()
- response.ErrorFrom(c, err)
- return
- }
- if canIssueTokenPair {
- tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), loginUser, "")
- if err != nil {
- clearCookies()
- response.InternalError(c, "Failed to generate token pair")
- return
- }
- h.authService.RecordSuccessfulLogin(c.Request.Context(), loginUser.ID)
- payload["access_token"] = tokenPair.AccessToken
- payload["refresh_token"] = tokenPair.RefreshToken
- payload["expires_in"] = tokenPair.ExpiresIn
- payload["token_type"] = "Bearer"
- }
- clearCookies()
- response.Success(c, payload)
- }
|