auth_linuxdo_oauth.go 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269
  1. package handler
  2. import (
  3. "context"
  4. "crypto/hmac"
  5. "crypto/sha256"
  6. "encoding/base64"
  7. "errors"
  8. "fmt"
  9. "log"
  10. "net/http"
  11. "net/url"
  12. "strconv"
  13. "strings"
  14. "time"
  15. "unicode/utf8"
  16. dbent "github.com/Wei-Shaw/sub2api/ent"
  17. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  18. "github.com/Wei-Shaw/sub2api/internal/config"
  19. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  20. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  21. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  22. servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  23. "github.com/Wei-Shaw/sub2api/internal/service"
  24. "github.com/gin-gonic/gin"
  25. "github.com/imroc/req/v3"
  26. "github.com/tidwall/gjson"
  27. )
  28. const (
  29. linuxDoOAuthCookiePath = "/api/v1/auth/oauth/linuxdo"
  30. oauthBindAccessTokenCookiePath = "/api/v1/auth/oauth"
  31. linuxDoOAuthStateCookieName = "linuxdo_oauth_state"
  32. linuxDoOAuthVerifierCookie = "linuxdo_oauth_verifier"
  33. linuxDoOAuthRedirectCookie = "linuxdo_oauth_redirect"
  34. linuxDoOAuthIntentCookieName = "linuxdo_oauth_intent"
  35. linuxDoOAuthBindUserCookieName = "linuxdo_oauth_bind_user"
  36. oauthBindAccessTokenCookieName = "oauth_bind_access_token"
  37. linuxDoOAuthCookieMaxAgeSec = 10 * 60 // 10 minutes
  38. linuxDoOAuthDefaultRedirectTo = "/dashboard"
  39. linuxDoOAuthDefaultFrontendCB = "/auth/linuxdo/callback"
  40. linuxDoOAuthMaxRedirectLen = 2048
  41. linuxDoOAuthMaxFragmentValueLen = 512
  42. linuxDoOAuthMaxSubjectLen = 64 - len("linuxdo-")
  43. oauthIntentLogin = "login"
  44. oauthIntentBindCurrentUser = "bind_current_user"
  45. )
  46. type linuxDoTokenResponse struct {
  47. AccessToken string `json:"access_token"`
  48. TokenType string `json:"token_type"`
  49. ExpiresIn int64 `json:"expires_in"`
  50. RefreshToken string `json:"refresh_token,omitempty"`
  51. Scope string `json:"scope,omitempty"`
  52. }
  53. type linuxDoTokenExchangeError struct {
  54. StatusCode int
  55. ProviderError string
  56. ProviderDescription string
  57. Body string
  58. }
  59. func (e *linuxDoTokenExchangeError) Error() string {
  60. if e == nil {
  61. return ""
  62. }
  63. parts := []string{fmt.Sprintf("token exchange status=%d", e.StatusCode)}
  64. if strings.TrimSpace(e.ProviderError) != "" {
  65. parts = append(parts, "error="+strings.TrimSpace(e.ProviderError))
  66. }
  67. if strings.TrimSpace(e.ProviderDescription) != "" {
  68. parts = append(parts, "error_description="+strings.TrimSpace(e.ProviderDescription))
  69. }
  70. return strings.Join(parts, " ")
  71. }
  72. // LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
  73. // GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
  74. func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
  75. if !h.requireActionCaptchaForOAuthLoginStart(c) {
  76. return
  77. }
  78. cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
  79. if err != nil {
  80. response.ErrorFrom(c, err)
  81. return
  82. }
  83. state, err := oauth.GenerateState()
  84. if err != nil {
  85. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  86. return
  87. }
  88. redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
  89. if redirectTo == "" {
  90. redirectTo = linuxDoOAuthDefaultRedirectTo
  91. }
  92. browserSessionKey, err := generateOAuthPendingBrowserSession()
  93. if err != nil {
  94. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
  95. return
  96. }
  97. secureCookie := isRequestHTTPS(c)
  98. setCookie(c, linuxDoOAuthStateCookieName, encodeCookieValue(state), linuxDoOAuthCookieMaxAgeSec, secureCookie)
  99. setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
  100. intent := normalizeOAuthIntent(c.Query("intent"))
  101. setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
  102. captureOAuthPromoCode(c, secureCookie)
  103. setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
  104. clearOAuthPendingSessionCookie(c, secureCookie)
  105. if intent == oauthIntentBindCurrentUser {
  106. bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
  107. if err != nil {
  108. response.ErrorFrom(c, err)
  109. return
  110. }
  111. setCookie(c, linuxDoOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), linuxDoOAuthCookieMaxAgeSec, secureCookie)
  112. } else {
  113. clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
  114. }
  115. codeChallenge := ""
  116. if cfg.UsePKCE {
  117. verifier, err := oauth.GenerateCodeVerifier()
  118. if err != nil {
  119. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_PKCE_GEN_FAILED", "failed to generate pkce verifier").WithCause(err))
  120. return
  121. }
  122. codeChallenge = oauth.GenerateCodeChallenge(verifier)
  123. setCookie(c, linuxDoOAuthVerifierCookie, encodeCookieValue(verifier), linuxDoOAuthCookieMaxAgeSec, secureCookie)
  124. }
  125. redirectURI := strings.TrimSpace(cfg.RedirectURL)
  126. if redirectURI == "" {
  127. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured"))
  128. return
  129. }
  130. authURL, err := buildLinuxDoAuthorizeURL(cfg, state, codeChallenge, redirectURI)
  131. if err != nil {
  132. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
  133. return
  134. }
  135. respondOAuthStart(c, authURL)
  136. }
  137. // LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
  138. // GET /api/v1/auth/oauth/linuxdo/callback?code=...&state=...
  139. func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
  140. cfg, cfgErr := h.getLinuxDoOAuthConfig(c.Request.Context())
  141. if cfgErr != nil {
  142. response.ErrorFrom(c, cfgErr)
  143. return
  144. }
  145. frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
  146. if frontendCallback == "" {
  147. frontendCallback = linuxDoOAuthDefaultFrontendCB
  148. }
  149. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  150. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  151. return
  152. }
  153. code := strings.TrimSpace(c.Query("code"))
  154. state := strings.TrimSpace(c.Query("state"))
  155. if code == "" || state == "" {
  156. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  157. return
  158. }
  159. secureCookie := isRequestHTTPS(c)
  160. defer func() {
  161. clearCookie(c, linuxDoOAuthStateCookieName, secureCookie)
  162. clearCookie(c, linuxDoOAuthVerifierCookie, secureCookie)
  163. clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
  164. clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
  165. clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
  166. clearOAuthPromoCodeCookie(c, secureCookie)
  167. }()
  168. expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
  169. if err != nil || expectedState == "" || state != expectedState {
  170. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
  171. return
  172. }
  173. redirectTo, _ := readCookieDecoded(c, linuxDoOAuthRedirectCookie)
  174. redirectTo = sanitizeFrontendRedirectPath(redirectTo)
  175. if redirectTo == "" {
  176. redirectTo = linuxDoOAuthDefaultRedirectTo
  177. }
  178. browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
  179. if strings.TrimSpace(browserSessionKey) == "" {
  180. redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing oauth browser session", "")
  181. return
  182. }
  183. intent, _ := readCookieDecoded(c, linuxDoOAuthIntentCookieName)
  184. intent = normalizeOAuthIntent(intent)
  185. codeVerifier := ""
  186. if cfg.UsePKCE {
  187. codeVerifier, _ = readCookieDecoded(c, linuxDoOAuthVerifierCookie)
  188. if codeVerifier == "" {
  189. redirectOAuthError(c, frontendCallback, "missing_verifier", "missing pkce verifier", "")
  190. return
  191. }
  192. }
  193. redirectURI := strings.TrimSpace(cfg.RedirectURL)
  194. if redirectURI == "" {
  195. redirectOAuthError(c, frontendCallback, "config_error", "oauth redirect url not configured", "")
  196. return
  197. }
  198. tokenResp, err := linuxDoExchangeCode(c.Request.Context(), cfg, code, redirectURI, codeVerifier)
  199. if err != nil {
  200. description := ""
  201. var exchangeErr *linuxDoTokenExchangeError
  202. if errors.As(err, &exchangeErr) && exchangeErr != nil {
  203. log.Printf(
  204. "[LinuxDo OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s",
  205. exchangeErr.StatusCode,
  206. exchangeErr.ProviderError,
  207. exchangeErr.ProviderDescription,
  208. truncateLogValue(exchangeErr.Body, 2048),
  209. )
  210. description = exchangeErr.Error()
  211. } else {
  212. log.Printf("[LinuxDo OAuth] token exchange failed: %v", err)
  213. description = err.Error()
  214. }
  215. redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(description))
  216. return
  217. }
  218. email, username, subject, displayName, avatarURL, err := linuxDoFetchUserInfo(c.Request.Context(), cfg, tokenResp)
  219. if err != nil {
  220. log.Printf("[LinuxDo OAuth] userinfo fetch failed: %v", err)
  221. redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch user info", "")
  222. return
  223. }
  224. compatEmail := strings.TrimSpace(email)
  225. // 安全考虑:不要把第三方返回的 email 直接映射到本地账号(可能与本地邮箱用户冲突导致账号被接管)。
  226. // 统一使用基于 subject 的稳定合成邮箱来做账号绑定。
  227. if subject != "" {
  228. email = linuxDoSyntheticEmail(subject)
  229. }
  230. identityKey := service.PendingAuthIdentityKey{
  231. ProviderType: "linuxdo",
  232. ProviderKey: "linuxdo",
  233. ProviderSubject: subject,
  234. }
  235. upstreamClaims := map[string]any{
  236. "email": email,
  237. "username": username,
  238. "subject": subject,
  239. "suggested_display_name": displayName,
  240. "suggested_avatar_url": avatarURL,
  241. }
  242. if compatEmail != "" && !strings.EqualFold(strings.TrimSpace(compatEmail), strings.TrimSpace(email)) {
  243. upstreamClaims["compat_email"] = compatEmail
  244. }
  245. if intent == oauthIntentBindCurrentUser {
  246. targetUserID, err := h.readOAuthBindUserIDFromCookie(c, linuxDoOAuthBindUserCookieName)
  247. if err != nil {
  248. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth bind target", "")
  249. return
  250. }
  251. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  252. Intent: oauthIntentBindCurrentUser,
  253. Identity: identityKey,
  254. TargetUserID: &targetUserID,
  255. ResolvedEmail: email,
  256. RedirectTo: redirectTo,
  257. BrowserSessionKey: browserSessionKey,
  258. UpstreamIdentityClaims: upstreamClaims,
  259. CompletionResponse: map[string]any{
  260. "redirect": redirectTo,
  261. },
  262. }); err != nil {
  263. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth bind", "")
  264. return
  265. }
  266. redirectToFrontendCallback(c, frontendCallback)
  267. return
  268. }
  269. existingIdentityUser, err := h.findOAuthIdentityUser(c.Request.Context(), identityKey)
  270. if err != nil {
  271. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  272. return
  273. }
  274. if existingIdentityUser != nil {
  275. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  276. Intent: oauthIntentLogin,
  277. Identity: identityKey,
  278. TargetUserID: &existingIdentityUser.ID,
  279. ResolvedEmail: existingIdentityUser.Email,
  280. RedirectTo: redirectTo,
  281. BrowserSessionKey: browserSessionKey,
  282. UpstreamIdentityClaims: upstreamClaims,
  283. CompletionResponse: map[string]any{
  284. "redirect": redirectTo,
  285. },
  286. }); err != nil {
  287. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  288. return
  289. }
  290. redirectToFrontendCallback(c, frontendCallback)
  291. return
  292. }
  293. compatEmailUser, err := h.findLinuxDoCompatEmailUser(c.Request.Context(), compatEmail)
  294. if err != nil {
  295. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  296. return
  297. }
  298. emailVerificationRequired := h != nil && h.authService != nil && h.authService.IsEmailVerifyEnabled(c.Request.Context())
  299. forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
  300. if compatEmailUser == nil && !emailVerificationRequired && !forceEmailOnSignup {
  301. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  302. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  303. return
  304. }
  305. tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
  306. c.Request.Context(),
  307. email,
  308. username,
  309. "",
  310. "",
  311. readOAuthPromoCode(c),
  312. "linuxdo",
  313. )
  314. if err == nil {
  315. if err := applyPendingOAuthBinding(
  316. c.Request.Context(),
  317. h.entClient(),
  318. h.authService,
  319. h.userService,
  320. &dbent.PendingAuthSession{
  321. Intent: oauthIntentLogin,
  322. ProviderType: identityKey.ProviderType,
  323. ProviderKey: identityKey.ProviderKey,
  324. ProviderSubject: identityKey.ProviderSubject,
  325. ResolvedEmail: email,
  326. UpstreamIdentityClaims: upstreamClaims,
  327. },
  328. nil,
  329. &user.ID,
  330. true,
  331. false,
  332. ); err != nil {
  333. redirectOAuthError(c, frontendCallback, "session_error", "failed to bind oauth identity", "")
  334. return
  335. }
  336. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  337. clearOAuthPendingSessionCookie(c, secureCookie)
  338. clearOAuthPendingBrowserCookie(c, secureCookie)
  339. redirectOAuthTokenPair(c, frontendCallback, tokenPair, redirectTo)
  340. return
  341. }
  342. if !errors.Is(err, service.ErrOAuthInvitationRequired) {
  343. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  344. return
  345. }
  346. }
  347. if err := h.createLinuxDoOAuthChoicePendingSession(
  348. c,
  349. identityKey,
  350. email,
  351. email,
  352. redirectTo,
  353. browserSessionKey,
  354. upstreamClaims,
  355. compatEmail,
  356. compatEmailUser,
  357. emailVerificationRequired,
  358. forceEmailOnSignup,
  359. ); err != nil {
  360. redirectOAuthError(c, frontendCallback, "session_error", "failed to continue oauth login", "")
  361. return
  362. }
  363. redirectToFrontendCallback(c, frontendCallback)
  364. }
  365. func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
  366. client := h.entClient()
  367. if client == nil {
  368. return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  369. }
  370. email = strings.TrimSpace(strings.ToLower(email))
  371. if email == "" ||
  372. strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
  373. strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
  374. strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) ||
  375. strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) {
  376. return nil, nil
  377. }
  378. userEntity, err := client.User.Query().
  379. Where(userNormalizedEmailPredicate(email)).
  380. Order(dbent.Asc(dbuser.FieldID)).
  381. All(ctx)
  382. if err != nil {
  383. return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
  384. }
  385. switch len(userEntity) {
  386. case 0:
  387. return nil, nil
  388. case 1:
  389. return userEntity[0], nil
  390. default:
  391. return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
  392. }
  393. }
  394. func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
  395. c *gin.Context,
  396. identity service.PendingAuthIdentityKey,
  397. suggestedEmail string,
  398. resolvedEmail string,
  399. redirectTo string,
  400. browserSessionKey string,
  401. upstreamClaims map[string]any,
  402. compatEmail string,
  403. compatEmailUser *dbent.User,
  404. emailVerificationRequired bool,
  405. forceEmailOnSignup bool,
  406. ) error {
  407. suggestionEmail := strings.TrimSpace(suggestedEmail)
  408. canonicalEmail := strings.TrimSpace(resolvedEmail)
  409. if suggestionEmail == "" {
  410. suggestionEmail = canonicalEmail
  411. }
  412. completionResponse := map[string]any{
  413. "step": oauthPendingChoiceStep,
  414. "adoption_required": true,
  415. "redirect": strings.TrimSpace(redirectTo),
  416. "email": suggestionEmail,
  417. "resolved_email": canonicalEmail,
  418. "existing_account_email": "",
  419. "existing_account_bindable": false,
  420. "create_account_allowed": true,
  421. "force_email_on_signup": forceEmailOnSignup,
  422. "choice_reason": "third_party_signup",
  423. }
  424. if strings.TrimSpace(compatEmail) != "" {
  425. completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
  426. }
  427. resolvedChoiceEmail := suggestionEmail
  428. if compatEmailUser != nil {
  429. completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
  430. completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
  431. completionResponse["existing_account_bindable"] = true
  432. completionResponse["choice_reason"] = "compat_email_match"
  433. resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
  434. }
  435. if forceEmailOnSignup && compatEmailUser == nil {
  436. completionResponse["choice_reason"] = "force_email_on_signup"
  437. }
  438. if (emailVerificationRequired || forceEmailOnSignup) && compatEmailUser == nil {
  439. completionResponse["step"] = "create_account_required"
  440. completionResponse["email_binding_required"] = true
  441. completionResponse["force_email_on_signup"] = true
  442. if emailVerificationRequired {
  443. completionResponse["choice_reason"] = "email_verification_required"
  444. }
  445. delete(completionResponse, "email")
  446. delete(completionResponse, "resolved_email")
  447. resolvedChoiceEmail = ""
  448. }
  449. var targetUserID *int64
  450. if compatEmailUser != nil && compatEmailUser.ID > 0 {
  451. targetUserID = &compatEmailUser.ID
  452. }
  453. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  454. Intent: oauthIntentLogin,
  455. Identity: identity,
  456. TargetUserID: targetUserID,
  457. ResolvedEmail: resolvedChoiceEmail,
  458. RedirectTo: redirectTo,
  459. BrowserSessionKey: browserSessionKey,
  460. UpstreamIdentityClaims: upstreamClaims,
  461. CompletionResponse: completionResponse,
  462. })
  463. }
  464. type completeLinuxDoOAuthRequest struct {
  465. InvitationCode string `json:"invitation_code" binding:"required"`
  466. AffCode string `json:"aff_code,omitempty"`
  467. AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
  468. AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
  469. }
  470. // CompleteLinuxDoOAuthRegistration completes a pending OAuth registration by validating
  471. // the invitation code and creating the user account.
  472. // POST /api/v1/auth/oauth/linuxdo/complete-registration
  473. func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
  474. var req completeLinuxDoOAuthRequest
  475. if err := c.ShouldBindJSON(&req); err != nil {
  476. c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
  477. return
  478. }
  479. secureCookie := isRequestHTTPS(c)
  480. sessionToken, err := readOAuthPendingSessionCookie(c)
  481. if err != nil {
  482. clearOAuthPendingSessionCookie(c, secureCookie)
  483. clearOAuthPendingBrowserCookie(c, secureCookie)
  484. response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
  485. return
  486. }
  487. browserSessionKey, err := readOAuthPendingBrowserCookie(c)
  488. if err != nil {
  489. clearOAuthPendingSessionCookie(c, secureCookie)
  490. clearOAuthPendingBrowserCookie(c, secureCookie)
  491. response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
  492. return
  493. }
  494. pendingSvc, err := h.pendingIdentityService()
  495. if err != nil {
  496. response.ErrorFrom(c, err)
  497. return
  498. }
  499. session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
  500. if err != nil {
  501. clearOAuthPendingSessionCookie(c, secureCookie)
  502. clearOAuthPendingBrowserCookie(c, secureCookie)
  503. response.ErrorFrom(c, err)
  504. return
  505. }
  506. if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
  507. response.ErrorFrom(c, err)
  508. return
  509. }
  510. if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
  511. response.ErrorFrom(c, err)
  512. return
  513. } else if handled {
  514. c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
  515. return
  516. } else {
  517. session = updatedSession
  518. }
  519. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  520. response.ErrorFrom(c, err)
  521. return
  522. }
  523. email := strings.TrimSpace(session.ResolvedEmail)
  524. username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
  525. if email == "" || username == "" {
  526. response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
  527. return
  528. }
  529. client := h.entClient()
  530. if client == nil {
  531. response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
  532. return
  533. }
  534. if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
  535. respondPendingOAuthBindingApplyError(c, err)
  536. return
  537. }
  538. decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
  539. AdoptDisplayName: req.AdoptDisplayName,
  540. AdoptAvatar: req.AdoptAvatar,
  541. })
  542. if err != nil {
  543. response.ErrorFrom(c, err)
  544. return
  545. }
  546. tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
  547. c.Request.Context(),
  548. email,
  549. username,
  550. req.InvitationCode,
  551. req.AffCode,
  552. pendingOAuthPromoCode(session),
  553. "linuxdo",
  554. )
  555. if err != nil {
  556. response.ErrorFrom(c, err)
  557. return
  558. }
  559. if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
  560. respondPendingOAuthBindingApplyError(c, err)
  561. return
  562. }
  563. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  564. clearOAuthPendingSessionCookie(c, secureCookie)
  565. clearOAuthPendingBrowserCookie(c, secureCookie)
  566. c.JSON(http.StatusOK, gin.H{
  567. "access_token": tokenPair.AccessToken,
  568. "refresh_token": tokenPair.RefreshToken,
  569. "expires_in": tokenPair.ExpiresIn,
  570. "token_type": "Bearer",
  571. })
  572. }
  573. func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
  574. if h != nil && h.settingSvc != nil {
  575. return h.settingSvc.GetLinuxDoConnectOAuthConfig(ctx)
  576. }
  577. if h == nil || h.cfg == nil {
  578. return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  579. }
  580. if !h.cfg.LinuxDo.Enabled {
  581. return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
  582. }
  583. return h.cfg.LinuxDo, nil
  584. }
  585. func linuxDoExchangeCode(
  586. ctx context.Context,
  587. cfg config.LinuxDoConnectConfig,
  588. code string,
  589. redirectURI string,
  590. codeVerifier string,
  591. ) (*linuxDoTokenResponse, error) {
  592. client := req.C().SetTimeout(30 * time.Second)
  593. form := url.Values{}
  594. form.Set("grant_type", "authorization_code")
  595. form.Set("client_id", cfg.ClientID)
  596. form.Set("code", code)
  597. form.Set("redirect_uri", redirectURI)
  598. if strings.TrimSpace(codeVerifier) != "" {
  599. form.Set("code_verifier", codeVerifier)
  600. }
  601. r := client.R().
  602. SetContext(ctx).
  603. SetHeader("Accept", "application/json")
  604. switch strings.ToLower(strings.TrimSpace(cfg.TokenAuthMethod)) {
  605. case "", "client_secret_post":
  606. form.Set("client_secret", cfg.ClientSecret)
  607. case "client_secret_basic":
  608. r.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
  609. case "none":
  610. default:
  611. return nil, fmt.Errorf("unsupported token_auth_method: %s", cfg.TokenAuthMethod)
  612. }
  613. resp, err := r.SetFormDataFromValues(form).Post(cfg.TokenURL)
  614. if err != nil {
  615. return nil, fmt.Errorf("request token: %w", err)
  616. }
  617. body := strings.TrimSpace(resp.String())
  618. if !resp.IsSuccessState() {
  619. providerErr, providerDesc := parseOAuthProviderError(body)
  620. return nil, &linuxDoTokenExchangeError{
  621. StatusCode: resp.StatusCode,
  622. ProviderError: providerErr,
  623. ProviderDescription: providerDesc,
  624. Body: body,
  625. }
  626. }
  627. tokenResp, ok := parseLinuxDoTokenResponse(body)
  628. if !ok || strings.TrimSpace(tokenResp.AccessToken) == "" {
  629. return nil, &linuxDoTokenExchangeError{
  630. StatusCode: resp.StatusCode,
  631. Body: body,
  632. }
  633. }
  634. if strings.TrimSpace(tokenResp.TokenType) == "" {
  635. tokenResp.TokenType = "Bearer"
  636. }
  637. return tokenResp, nil
  638. }
  639. func linuxDoFetchUserInfo(
  640. ctx context.Context,
  641. cfg config.LinuxDoConnectConfig,
  642. token *linuxDoTokenResponse,
  643. ) (email string, username string, subject string, displayName string, avatarURL string, err error) {
  644. client := req.C().SetTimeout(30 * time.Second)
  645. authorization, err := buildBearerAuthorization(token.TokenType, token.AccessToken)
  646. if err != nil {
  647. return "", "", "", "", "", fmt.Errorf("invalid token for userinfo request: %w", err)
  648. }
  649. resp, err := client.R().
  650. SetContext(ctx).
  651. SetHeader("Accept", "application/json").
  652. SetHeader("Authorization", authorization).
  653. Get(cfg.UserInfoURL)
  654. if err != nil {
  655. return "", "", "", "", "", fmt.Errorf("request userinfo: %w", err)
  656. }
  657. if !resp.IsSuccessState() {
  658. return "", "", "", "", "", fmt.Errorf("userinfo status=%d", resp.StatusCode)
  659. }
  660. return linuxDoParseUserInfo(resp.String(), cfg)
  661. }
  662. func linuxDoParseUserInfo(body string, cfg config.LinuxDoConnectConfig) (email string, username string, subject string, displayName string, avatarURL string, err error) {
  663. email = firstNonEmpty(
  664. getGJSON(body, cfg.UserInfoEmailPath),
  665. getGJSON(body, "email"),
  666. getGJSON(body, "user.email"),
  667. getGJSON(body, "data.email"),
  668. getGJSON(body, "attributes.email"),
  669. )
  670. username = firstNonEmpty(
  671. getGJSON(body, cfg.UserInfoUsernamePath),
  672. getGJSON(body, "username"),
  673. getGJSON(body, "preferred_username"),
  674. getGJSON(body, "name"),
  675. getGJSON(body, "user.username"),
  676. getGJSON(body, "user.name"),
  677. )
  678. subject = firstNonEmpty(
  679. getGJSON(body, cfg.UserInfoIDPath),
  680. getGJSON(body, "sub"),
  681. getGJSON(body, "id"),
  682. getGJSON(body, "user_id"),
  683. getGJSON(body, "uid"),
  684. getGJSON(body, "user.id"),
  685. )
  686. displayName = firstNonEmpty(
  687. getGJSON(body, "name"),
  688. getGJSON(body, "nickname"),
  689. getGJSON(body, "display_name"),
  690. getGJSON(body, "user.name"),
  691. getGJSON(body, "user.username"),
  692. username,
  693. )
  694. avatarURL = firstNonEmpty(
  695. getGJSON(body, "avatar_url"),
  696. getGJSON(body, "avatar"),
  697. getGJSON(body, "picture"),
  698. getGJSON(body, "profile_image_url"),
  699. getGJSON(body, "user.avatar"),
  700. getGJSON(body, "user.avatar_url"),
  701. )
  702. subject = strings.TrimSpace(subject)
  703. if subject == "" {
  704. return "", "", "", "", "", errors.New("userinfo missing id field")
  705. }
  706. if !isSafeLinuxDoSubject(subject) {
  707. return "", "", "", "", "", errors.New("userinfo returned invalid id field")
  708. }
  709. email = strings.TrimSpace(email)
  710. if email == "" {
  711. // LinuxDo Connect 的 userinfo 可能不提供 email。为兼容现有用户模型(email 必填且唯一),使用稳定的合成邮箱。
  712. email = linuxDoSyntheticEmail(subject)
  713. }
  714. username = strings.TrimSpace(username)
  715. if username == "" {
  716. username = "linuxdo_" + subject
  717. }
  718. displayName = strings.TrimSpace(displayName)
  719. if displayName == "" {
  720. displayName = username
  721. }
  722. avatarURL = strings.TrimSpace(avatarURL)
  723. return email, username, subject, displayName, avatarURL, nil
  724. }
  725. func buildLinuxDoAuthorizeURL(cfg config.LinuxDoConnectConfig, state string, codeChallenge string, redirectURI string) (string, error) {
  726. u, err := url.Parse(cfg.AuthorizeURL)
  727. if err != nil {
  728. return "", fmt.Errorf("parse authorize_url: %w", err)
  729. }
  730. q := u.Query()
  731. q.Set("response_type", "code")
  732. q.Set("client_id", cfg.ClientID)
  733. q.Set("redirect_uri", redirectURI)
  734. if strings.TrimSpace(cfg.Scopes) != "" {
  735. q.Set("scope", cfg.Scopes)
  736. }
  737. q.Set("state", state)
  738. if strings.TrimSpace(codeChallenge) != "" {
  739. q.Set("code_challenge", codeChallenge)
  740. q.Set("code_challenge_method", "S256")
  741. }
  742. u.RawQuery = q.Encode()
  743. return u.String(), nil
  744. }
  745. func redirectOAuthError(c *gin.Context, frontendCallback string, code string, message string, description string) {
  746. fragment := url.Values{}
  747. fragment.Set("error", truncateFragmentValue(code))
  748. if strings.TrimSpace(message) != "" {
  749. fragment.Set("error_message", truncateFragmentValue(message))
  750. }
  751. if strings.TrimSpace(description) != "" {
  752. fragment.Set("error_description", truncateFragmentValue(description))
  753. }
  754. redirectWithFragment(c, frontendCallback, fragment)
  755. }
  756. func redirectOAuthTokenPair(c *gin.Context, frontendCallback string, tokenPair *service.TokenPair, redirectTo string) {
  757. fragment := url.Values{}
  758. if tokenPair != nil {
  759. fragment.Set("access_token", truncateFragmentValue(tokenPair.AccessToken))
  760. fragment.Set("refresh_token", truncateFragmentValue(tokenPair.RefreshToken))
  761. fragment.Set("expires_in", strconv.Itoa(tokenPair.ExpiresIn))
  762. fragment.Set("token_type", "Bearer")
  763. }
  764. if redirect := strings.TrimSpace(redirectTo); redirect != "" {
  765. originalRedirect := redirect
  766. for range 2 {
  767. decoded, err := url.QueryUnescape(redirect)
  768. if err != nil || decoded == redirect {
  769. break
  770. }
  771. redirect = decoded
  772. }
  773. if redirect != originalRedirect {
  774. if sanitized := sanitizeFrontendRedirectPath(redirect); sanitized != "" {
  775. redirect = sanitized
  776. } else {
  777. redirect = originalRedirect
  778. }
  779. }
  780. fragment.Set("redirect", truncateFragmentValue(redirect))
  781. }
  782. redirectWithFragment(c, frontendCallback, fragment)
  783. }
  784. func redirectWithFragment(c *gin.Context, frontendCallback string, fragment url.Values) {
  785. u, err := url.Parse(frontendCallback)
  786. if err != nil {
  787. // 兜底:尽力跳转到默认页面,避免卡死在回调页。
  788. c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
  789. return
  790. }
  791. if u.Scheme != "" && !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
  792. c.Redirect(http.StatusFound, linuxDoOAuthDefaultRedirectTo)
  793. return
  794. }
  795. u.Fragment = fragment.Encode()
  796. c.Header("Cache-Control", "no-store")
  797. c.Header("Pragma", "no-cache")
  798. c.Redirect(http.StatusFound, u.String())
  799. }
  800. func firstNonEmpty(values ...string) string {
  801. for _, v := range values {
  802. v = strings.TrimSpace(v)
  803. if v != "" {
  804. return v
  805. }
  806. }
  807. return ""
  808. }
  809. func parseOAuthProviderError(body string) (providerErr string, providerDesc string) {
  810. body = strings.TrimSpace(body)
  811. if body == "" {
  812. return "", ""
  813. }
  814. providerErr = firstNonEmpty(
  815. getGJSON(body, "error"),
  816. getGJSON(body, "code"),
  817. getGJSON(body, "error.code"),
  818. )
  819. providerDesc = firstNonEmpty(
  820. getGJSON(body, "error_description"),
  821. getGJSON(body, "error.message"),
  822. getGJSON(body, "message"),
  823. getGJSON(body, "detail"),
  824. )
  825. if providerErr != "" || providerDesc != "" {
  826. return providerErr, providerDesc
  827. }
  828. values, err := url.ParseQuery(body)
  829. if err != nil {
  830. return "", ""
  831. }
  832. providerErr = firstNonEmpty(values.Get("error"), values.Get("code"))
  833. providerDesc = firstNonEmpty(values.Get("error_description"), values.Get("error_message"), values.Get("message"))
  834. return providerErr, providerDesc
  835. }
  836. func parseLinuxDoTokenResponse(body string) (*linuxDoTokenResponse, bool) {
  837. body = strings.TrimSpace(body)
  838. if body == "" {
  839. return nil, false
  840. }
  841. accessToken := strings.TrimSpace(getGJSON(body, "access_token"))
  842. if accessToken != "" {
  843. tokenType := strings.TrimSpace(getGJSON(body, "token_type"))
  844. refreshToken := strings.TrimSpace(getGJSON(body, "refresh_token"))
  845. scope := strings.TrimSpace(getGJSON(body, "scope"))
  846. expiresIn := gjson.Get(body, "expires_in").Int()
  847. return &linuxDoTokenResponse{
  848. AccessToken: accessToken,
  849. TokenType: tokenType,
  850. ExpiresIn: expiresIn,
  851. RefreshToken: refreshToken,
  852. Scope: scope,
  853. }, true
  854. }
  855. values, err := url.ParseQuery(body)
  856. if err != nil {
  857. return nil, false
  858. }
  859. accessToken = strings.TrimSpace(values.Get("access_token"))
  860. if accessToken == "" {
  861. return nil, false
  862. }
  863. expiresIn := int64(0)
  864. if raw := strings.TrimSpace(values.Get("expires_in")); raw != "" {
  865. if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
  866. expiresIn = v
  867. }
  868. }
  869. return &linuxDoTokenResponse{
  870. AccessToken: accessToken,
  871. TokenType: strings.TrimSpace(values.Get("token_type")),
  872. ExpiresIn: expiresIn,
  873. RefreshToken: strings.TrimSpace(values.Get("refresh_token")),
  874. Scope: strings.TrimSpace(values.Get("scope")),
  875. }, true
  876. }
  877. func getGJSON(body string, path string) string {
  878. path = strings.TrimSpace(path)
  879. if path == "" {
  880. return ""
  881. }
  882. res := gjson.Get(body, path)
  883. if !res.Exists() {
  884. return ""
  885. }
  886. return res.String()
  887. }
  888. func truncateLogValue(value string, maxLen int) string {
  889. value = strings.TrimSpace(value)
  890. if value == "" || maxLen <= 0 {
  891. return ""
  892. }
  893. if len(value) <= maxLen {
  894. return value
  895. }
  896. value = value[:maxLen]
  897. for !utf8.ValidString(value) {
  898. value = value[:len(value)-1]
  899. }
  900. return value
  901. }
  902. func singleLine(value string) string {
  903. value = strings.TrimSpace(value)
  904. if value == "" {
  905. return ""
  906. }
  907. return strings.Join(strings.Fields(value), " ")
  908. }
  909. func sanitizeFrontendRedirectPath(path string) string {
  910. path = strings.TrimSpace(path)
  911. if path == "" {
  912. return ""
  913. }
  914. if len(path) > linuxDoOAuthMaxRedirectLen {
  915. return ""
  916. }
  917. // 只允许同源相对路径(避免开放重定向)。
  918. if !strings.HasPrefix(path, "/") {
  919. return ""
  920. }
  921. if strings.HasPrefix(path, "//") {
  922. return ""
  923. }
  924. if strings.Contains(path, "://") {
  925. return ""
  926. }
  927. if strings.ContainsAny(path, "\r\n") {
  928. return ""
  929. }
  930. return path
  931. }
  932. func isRequestHTTPS(c *gin.Context) bool {
  933. if c.Request.TLS != nil {
  934. return true
  935. }
  936. proto := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")))
  937. return proto == "https"
  938. }
  939. func encodeCookieValue(value string) string {
  940. return base64.RawURLEncoding.EncodeToString([]byte(value))
  941. }
  942. func decodeCookieValue(value string) (string, error) {
  943. raw, err := base64.RawURLEncoding.DecodeString(value)
  944. if err != nil {
  945. return "", err
  946. }
  947. return string(raw), nil
  948. }
  949. func readCookieDecoded(c *gin.Context, name string) (string, error) {
  950. ck, err := c.Request.Cookie(name)
  951. if err != nil {
  952. return "", err
  953. }
  954. return decodeCookieValue(ck.Value)
  955. }
  956. func setCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
  957. http.SetCookie(c.Writer, &http.Cookie{
  958. Name: name,
  959. Value: value,
  960. Path: linuxDoOAuthCookiePath,
  961. MaxAge: maxAgeSec,
  962. HttpOnly: true,
  963. Secure: secure,
  964. SameSite: http.SameSiteLaxMode,
  965. })
  966. }
  967. func clearCookie(c *gin.Context, name string, secure bool) {
  968. http.SetCookie(c.Writer, &http.Cookie{
  969. Name: name,
  970. Value: "",
  971. Path: linuxDoOAuthCookiePath,
  972. MaxAge: -1,
  973. HttpOnly: true,
  974. Secure: secure,
  975. SameSite: http.SameSiteLaxMode,
  976. })
  977. }
  978. func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
  979. http.SetCookie(c.Writer, &http.Cookie{
  980. Name: oauthBindAccessTokenCookieName,
  981. Value: "",
  982. Path: oauthBindAccessTokenCookiePath,
  983. MaxAge: -1,
  984. HttpOnly: true,
  985. Secure: secure,
  986. SameSite: http.SameSiteLaxMode,
  987. })
  988. }
  989. func setOAuthBindAccessTokenCookie(c *gin.Context, token string, secure bool) {
  990. http.SetCookie(c.Writer, &http.Cookie{
  991. Name: oauthBindAccessTokenCookieName,
  992. Value: url.QueryEscape(strings.TrimSpace(token)),
  993. Path: oauthBindAccessTokenCookiePath,
  994. MaxAge: linuxDoOAuthCookieMaxAgeSec,
  995. HttpOnly: true,
  996. Secure: secure,
  997. SameSite: http.SameSiteLaxMode,
  998. })
  999. }
  1000. func truncateFragmentValue(value string) string {
  1001. value = strings.TrimSpace(value)
  1002. if value == "" {
  1003. return ""
  1004. }
  1005. if len(value) > linuxDoOAuthMaxFragmentValueLen {
  1006. value = value[:linuxDoOAuthMaxFragmentValueLen]
  1007. for !utf8.ValidString(value) {
  1008. value = value[:len(value)-1]
  1009. }
  1010. }
  1011. return value
  1012. }
  1013. func buildBearerAuthorization(tokenType, accessToken string) (string, error) {
  1014. tokenType = strings.TrimSpace(tokenType)
  1015. if tokenType == "" {
  1016. tokenType = "Bearer"
  1017. }
  1018. if !strings.EqualFold(tokenType, "Bearer") {
  1019. return "", fmt.Errorf("unsupported token_type: %s", tokenType)
  1020. }
  1021. accessToken = strings.TrimSpace(accessToken)
  1022. if accessToken == "" {
  1023. return "", errors.New("missing access_token")
  1024. }
  1025. if strings.ContainsAny(accessToken, " \t\r\n") {
  1026. return "", errors.New("access_token contains whitespace")
  1027. }
  1028. return "Bearer " + accessToken, nil
  1029. }
  1030. func isSafeLinuxDoSubject(subject string) bool {
  1031. subject = strings.TrimSpace(subject)
  1032. if subject == "" || len(subject) > linuxDoOAuthMaxSubjectLen {
  1033. return false
  1034. }
  1035. for _, r := range subject {
  1036. switch {
  1037. case r >= '0' && r <= '9':
  1038. case r >= 'a' && r <= 'z':
  1039. case r >= 'A' && r <= 'Z':
  1040. case r == '_' || r == '-':
  1041. default:
  1042. return false
  1043. }
  1044. }
  1045. return true
  1046. }
  1047. func linuxDoSyntheticEmail(subject string) string {
  1048. subject = strings.TrimSpace(subject)
  1049. if subject == "" {
  1050. return ""
  1051. }
  1052. return "linuxdo-" + subject + service.LinuxDoConnectSyntheticEmailDomain
  1053. }
  1054. func normalizeOAuthIntent(raw string) string {
  1055. switch strings.ToLower(strings.TrimSpace(raw)) {
  1056. case "", oauthIntentLogin:
  1057. return oauthIntentLogin
  1058. case "bind", oauthIntentBindCurrentUser:
  1059. return oauthIntentBindCurrentUser
  1060. default:
  1061. return oauthIntentLogin
  1062. }
  1063. }
  1064. func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (string, error) {
  1065. userID, err := h.resolveOAuthBindTargetUserID(c)
  1066. if err != nil || userID == nil || *userID <= 0 {
  1067. return "", infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")
  1068. }
  1069. return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret())
  1070. }
  1071. func (h *AuthHandler) PrepareOAuthBindAccessTokenCookie(c *gin.Context) {
  1072. const bearerPrefix = "Bearer "
  1073. authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
  1074. if !strings.HasPrefix(strings.ToLower(authHeader), strings.ToLower(bearerPrefix)) {
  1075. response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required"))
  1076. return
  1077. }
  1078. token := strings.TrimSpace(authHeader[len(bearerPrefix):])
  1079. if token == "" {
  1080. response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required"))
  1081. return
  1082. }
  1083. setOAuthBindAccessTokenCookie(c, token, isRequestHTTPS(c))
  1084. c.Status(http.StatusNoContent)
  1085. c.Writer.WriteHeaderNow()
  1086. }
  1087. func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) {
  1088. if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
  1089. return &subject.UserID, nil
  1090. }
  1091. if h == nil || h.authService == nil || h.userService == nil {
  1092. return nil, service.ErrInvalidToken
  1093. }
  1094. ck, err := c.Request.Cookie(oauthBindAccessTokenCookieName)
  1095. clearOAuthBindAccessTokenCookie(c, isRequestHTTPS(c))
  1096. if err != nil {
  1097. return nil, err
  1098. }
  1099. tokenString, err := url.QueryUnescape(strings.TrimSpace(ck.Value))
  1100. if err != nil {
  1101. return nil, err
  1102. }
  1103. if tokenString == "" {
  1104. return nil, service.ErrInvalidToken
  1105. }
  1106. claims, err := h.authService.ValidateToken(tokenString)
  1107. if err != nil {
  1108. return nil, err
  1109. }
  1110. user, err := h.userService.GetByID(c.Request.Context(), claims.UserID)
  1111. if err != nil {
  1112. return nil, err
  1113. }
  1114. if user == nil || !user.IsActive() || claims.TokenVersion != user.TokenVersion {
  1115. return nil, service.ErrInvalidToken
  1116. }
  1117. return &user.ID, nil
  1118. }
  1119. func (h *AuthHandler) readOAuthBindUserIDFromCookie(c *gin.Context, cookieName string) (int64, error) {
  1120. value, err := readCookieDecoded(c, cookieName)
  1121. if err != nil {
  1122. return 0, err
  1123. }
  1124. return parseOAuthBindUserCookieValue(value, h.oauthBindCookieSecret())
  1125. }
  1126. func (h *AuthHandler) oauthBindCookieSecret() string {
  1127. if h == nil || h.cfg == nil {
  1128. return ""
  1129. }
  1130. return strings.TrimSpace(h.cfg.JWT.Secret)
  1131. }
  1132. func buildOAuthBindUserCookieValue(userID int64, secret string) (string, error) {
  1133. secret = strings.TrimSpace(secret)
  1134. if userID <= 0 || secret == "" {
  1135. return "", errors.New("invalid oauth bind cookie input")
  1136. }
  1137. payload := strconv.FormatInt(userID, 10)
  1138. mac := hmac.New(sha256.New, []byte(secret))
  1139. _, _ = mac.Write([]byte(payload))
  1140. signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  1141. return payload + "." + signature, nil
  1142. }
  1143. func parseOAuthBindUserCookieValue(value string, secret string) (int64, error) {
  1144. secret = strings.TrimSpace(secret)
  1145. if secret == "" {
  1146. return 0, errors.New("missing oauth bind cookie secret")
  1147. }
  1148. payload, signature, ok := strings.Cut(strings.TrimSpace(value), ".")
  1149. if !ok || payload == "" || signature == "" {
  1150. return 0, errors.New("invalid oauth bind cookie")
  1151. }
  1152. mac := hmac.New(sha256.New, []byte(secret))
  1153. _, _ = mac.Write([]byte(payload))
  1154. expectedSignature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  1155. if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
  1156. return 0, errors.New("invalid oauth bind cookie signature")
  1157. }
  1158. userID, err := strconv.ParseInt(payload, 10, 64)
  1159. if err != nil || userID <= 0 {
  1160. return 0, errors.New("invalid oauth bind cookie user")
  1161. }
  1162. return userID, nil
  1163. }