auth_handler.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. package webapi
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/base64"
  6. "errors"
  7. "fmt"
  8. "html/template"
  9. "log/slog"
  10. "math"
  11. "net"
  12. "net/http"
  13. "net/url"
  14. "strconv"
  15. "strings"
  16. "time"
  17. "github.com/mk6i/open-oscar-server/config"
  18. "github.com/mk6i/open-oscar-server/state"
  19. "github.com/mk6i/open-oscar-server/wire"
  20. )
  21. // AuthToken is the opaque credential the client presents on later requests.
  22. //
  23. // ExpiresIn is a string because that is the shape the client is given, even
  24. // though ClientLoginData.TokenExpiresIn carries the same quantity as a number.
  25. type AuthToken struct {
  26. A string `json:"a" xml:"a"`
  27. ExpiresIn string `json:"expiresIn" xml:"expiresIn"`
  28. }
  29. // GetTokenData is the getToken payload.
  30. type GetTokenData struct {
  31. Token AuthToken `json:"token" xml:"token"`
  32. UserData UserData `json:"userData" xml:"userData"`
  33. }
  34. // UserData wraps the attributes getToken reports about the account.
  35. type UserData struct {
  36. Attributes UserAttributes `json:"attributes" xml:"attributes"`
  37. }
  38. // UserAttributes names the account the token belongs to.
  39. type UserAttributes struct {
  40. LoginID string `json:"loginId" xml:"loginId"`
  41. }
  42. // GetInfoData is the auth/getInfo payload.
  43. type GetInfoData struct {
  44. UserData GetInfoUser `json:"userData" xml:"userData"`
  45. }
  46. // GetInfoUser names the account the presented token belongs to. Clients read both
  47. // keys strictly, so neither is omitted when empty.
  48. type GetInfoUser struct {
  49. LoginID string `json:"loginId" xml:"loginId"`
  50. DisplayName string `json:"displayName" xml:"displayName"`
  51. }
  52. // ClientLoginData is the clientLogin payload.
  53. type ClientLoginData struct {
  54. Token AuthToken `json:"token" xml:"token"`
  55. LoginID string `json:"loginId" xml:"loginId"`
  56. ScreenName string `json:"screenName" xml:"screenName"`
  57. SessionSecret string `json:"sessionSecret" xml:"sessionSecret"`
  58. HostTime int64 `json:"hostTime" xml:"hostTime"`
  59. TokenExpiresIn int `json:"tokenExpiresIn" xml:"tokenExpiresIn"`
  60. }
  61. // RedirectData sends an unauthenticated client to the login page.
  62. type RedirectData struct {
  63. RedirectURL string `json:"redirectURL" xml:"redirectURL"`
  64. }
  65. // AuthHandler handles Web AIM API authentication endpoints.
  66. type AuthHandler struct {
  67. AuthService AuthService
  68. Logger *slog.Logger
  69. }
  70. // GetToken handles GET /auth/getToken requests.
  71. // The Web AIM client uses this JSONP endpoint to exchange SSO session cookies for an API token.
  72. func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
  73. ctx := r.Context()
  74. devID := r.URL.Query().Get("devId")
  75. // The cookie is spent either way: consumed on success, and cleared on failure
  76. // so a browser holding a dead token stops presenting it.
  77. clearBOSTokenCookie(w)
  78. loginID, authCookie, expiry, ok := h.resolveGetTokenSession(r)
  79. if !ok {
  80. h.Logger.DebugContext(ctx, "getToken: no token, returning redirect",
  81. "devId", devID,
  82. "host", r.Host)
  83. resp := BaseResponse{}
  84. resp.Response.StatusCode = 401
  85. resp.Response.StatusText = "Unauthorized"
  86. resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
  87. SendResponse(w, r, resp, h.Logger)
  88. return
  89. }
  90. SendOK(w, r, &GetTokenData{
  91. Token: AuthToken{
  92. A: base64.URLEncoding.EncodeToString(authCookie),
  93. ExpiresIn: strconv.Itoa(int(math.Round(time.Until(expiry).Seconds()))),
  94. },
  95. UserData: UserData{Attributes: UserAttributes{LoginID: string(loginID)}},
  96. }, h.Logger)
  97. h.Logger.InfoContext(ctx, "getToken succeeded", "loginId", loginID, "devId", devID)
  98. }
  99. // resolveGetTokenSession identifies the caller from the BOS token parked at
  100. // sign-in.
  101. func (h *AuthHandler) resolveGetTokenSession(r *http.Request) (state.DisplayScreenName, []byte, time.Time, bool) {
  102. c, err := r.Cookie(bosTokenCookie)
  103. if err != nil || c.Value == "" {
  104. return "", nil, time.Time{}, false
  105. }
  106. token, err := url.QueryUnescape(c.Value)
  107. if err != nil {
  108. token = c.Value
  109. }
  110. rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
  111. if err != nil {
  112. return "", nil, time.Time{}, false
  113. }
  114. serverCookie, expiry, err := h.AuthService.CrackCookie(rawCookie)
  115. if err != nil {
  116. return "", nil, time.Time{}, false
  117. }
  118. return serverCookie.ScreenName, rawCookie, expiry, true
  119. }
  120. // The lifetimes the clientLogin tokenType parameter names.
  121. const (
  122. shortTermTTL = 24 * time.Hour
  123. longTermTTL = 365 * 24 * time.Hour
  124. )
  125. // tokenTypeTTL resolves the clientLogin tokenType parameter to a token lifetime.
  126. // The parameter is "shortterm" (the default), "longterm", or a count of seconds,
  127. // and the server grants no more than longTermTTL either way.
  128. func tokenTypeTTL(tokenType string) (time.Duration, error) {
  129. tokenType = strings.TrimSpace(tokenType)
  130. switch strings.ToLower(tokenType) {
  131. case "", "shortterm":
  132. return shortTermTTL, nil
  133. case "longterm":
  134. return longTermTTL, nil
  135. }
  136. secs, err := strconv.ParseUint(tokenType, 10, 64)
  137. if err != nil {
  138. return 0, fmt.Errorf("tokenType %q is not shortterm, longterm, or a count of seconds", tokenType)
  139. }
  140. // bound the count before scaling it, so an absurd value is an error rather
  141. // than an overflowed duration
  142. maxSecs := uint64(longTermTTL / time.Second)
  143. if secs == 0 || secs > maxSecs {
  144. return 0, fmt.Errorf("tokenType %q is outside the range 1-%d seconds", tokenType, maxSecs)
  145. }
  146. return time.Duration(secs) * time.Second, nil
  147. }
  148. // errInvalidCredentials reports that the auth service rejected the screen name or
  149. // password, as opposed to failing to answer at all.
  150. var errInvalidCredentials = errors.New("invalid screen name or password")
  151. // authenticateCredentials verifies the credentials and returns the auth cookie minted
  152. // by the OSCAR auth service. It returns errInvalidCredentials when the credentials are
  153. // rejected.
  154. func (h *AuthHandler) authenticateCredentials(ctx context.Context, username, password, clientID string, ttl time.Duration) ([]byte, error) {
  155. signonFrame := wire.FLAPSignonFrame{}
  156. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, username))
  157. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
  158. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsClientIdentity, clientID))
  159. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
  160. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsTokenTTL, uint32(ttl.Seconds())))
  161. block, err := h.AuthService.FLAPLogin(ctx, signonFrame, config.Endpoint{})
  162. if err != nil {
  163. return nil, fmt.Errorf("FLAPLogin: %w", err)
  164. }
  165. if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
  166. return nil, errInvalidCredentials
  167. }
  168. authCookie, ok := block.Bytes(wire.LoginTLVTagsAuthorizationCookie)
  169. if !ok {
  170. return nil, fmt.Errorf("login response carries no authorization cookie")
  171. }
  172. return authCookie, nil
  173. }
  174. // clientIDForDevID names the client on the session for callers that only know the
  175. // Web API devId.
  176. func clientIDForDevID(devID string) string {
  177. if devID == "" {
  178. return "WebAIM"
  179. }
  180. return devID
  181. }
  182. func (h *AuthHandler) loginRedirectURL(r *http.Request) string {
  183. // requestScheme, not r.TLS: TLS is terminated upstream, so the scheme survives
  184. // only in the header.
  185. return fmt.Sprintf("%s://%s/_cqr/login/login.psp", requestScheme(r), r.Host)
  186. }
  187. // Logout clears the token cookie and sends the browser to the login page. A
  188. // sign-in whose getToken never ran leaves a token in the browser for the rest of
  189. // its life, so signing out has to spend it rather than trust that something
  190. // else already did.
  191. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
  192. clearBOSTokenCookie(w)
  193. loginURL := h.loginRedirectURL(r)
  194. q := url.Values{}
  195. if devID := r.URL.Query().Get("devId"); devID != "" {
  196. q.Set("devId", devID)
  197. }
  198. if succURL := r.URL.Query().Get("succUrl"); succURL != "" {
  199. q.Set("succUrl", succURL)
  200. }
  201. if enc := q.Encode(); enc != "" {
  202. loginURL += "?" + enc
  203. }
  204. h.Logger.InfoContext(r.Context(), "logout", "devId", r.URL.Query().Get("devId"))
  205. http.Redirect(w, r, loginURL, http.StatusFound)
  206. }
  207. // ClientLogin handles POST /auth/clientLogin requests.
  208. // This endpoint authenticates users and returns an authentication token.
  209. func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
  210. if err := r.ParseForm(); err != nil {
  211. h.Logger.Error("failed to parse form data", "error", err)
  212. SendError(w, r, http.StatusBadRequest, "invalid form data")
  213. return
  214. }
  215. username := r.PostFormValue("s")
  216. if username == "" {
  217. username = r.PostFormValue("username")
  218. }
  219. password := r.PostFormValue("pwd")
  220. if password == "" {
  221. password = r.PostFormValue("password")
  222. }
  223. devID := r.PostFormValue("devId")
  224. tokenType := r.PostFormValue("tokenType")
  225. h.Logger.Debug("clientLogin attempt",
  226. "username", username,
  227. "has_password", password != "",
  228. "devId", devID,
  229. "form", r.Form)
  230. // Validate required fields
  231. if username == "" || password == "" {
  232. SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0, "username and password required")
  233. return
  234. }
  235. ttl, err := tokenTypeTTL(tokenType)
  236. if err != nil {
  237. h.Logger.DebugContext(r.Context(), "clientLogin rejected tokenType", "tokenType", tokenType, "error", err)
  238. SendErrorDetail(w, r, http.StatusBadRequest, statusParameterError, 0, err.Error())
  239. return
  240. }
  241. authCookie, err := h.authenticateCredentials(r.Context(), username, password, clientIDForDevID(devID), ttl)
  242. if err != nil {
  243. h.Logger.DebugContext(r.Context(), "clientLogin failed", "username", username, "error", err)
  244. if errors.Is(err, errInvalidCredentials) {
  245. SendErrorDetail(w, r, http.StatusUnauthorized, statusMoreAuthRequired, detailBadPassword,
  246. "invalid screen name or password")
  247. return
  248. }
  249. SendError(w, r, http.StatusInternalServerError, "internal server error")
  250. return
  251. }
  252. // No cookie here: this endpoint's caller receives the token in the response
  253. // body and presents it to startSession itself.
  254. // Generate session secret (for signing subsequent requests)
  255. sessionSecret, err := h.generateToken()
  256. if err != nil {
  257. h.Logger.Error("failed to generate session secret", "error", err)
  258. SendError(w, r, http.StatusInternalServerError, "internal server error")
  259. return
  260. }
  261. // Build response
  262. // Send response in requested format (JSON, JSONP, XML, or AMF)
  263. SendOK(w, r, &ClientLoginData{
  264. Token: AuthToken{
  265. A: base64.URLEncoding.EncodeToString(authCookie),
  266. ExpiresIn: strconv.Itoa(int(ttl.Seconds())),
  267. },
  268. LoginID: username,
  269. ScreenName: username,
  270. SessionSecret: sessionSecret,
  271. HostTime: time.Now().Unix(),
  272. // A number here where token.expiresIn is a string, as the client expects.
  273. TokenExpiresIn: int(ttl.Seconds()),
  274. }, h.Logger)
  275. h.Logger.Info("user authenticated successfully",
  276. "username", username,
  277. "screenName", username,
  278. "tokenTTL", ttl)
  279. }
  280. // defaultAuthFreshness is the reqAuthFreshness applied when a caller names none.
  281. const defaultAuthFreshness = 24 * time.Hour
  282. // GetInfo handles GET and POST /auth/getInfo, reporting which account the token in
  283. // "a" belongs to. It mints nothing: a caller that cannot be answered is told to
  284. // authenticate again.
  285. func (h *AuthHandler) GetInfo(w http.ResponseWriter, r *http.Request) {
  286. ctx := r.Context()
  287. // This endpoint does not renew. Refusing gives a client expecting a token some
  288. // status to react to; a 200 with no token gives it nothing.
  289. if isTrueParam(param(r, "renewToken")) {
  290. h.Logger.DebugContext(ctx, "getInfo: refused a renewal request")
  291. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  292. return
  293. }
  294. token := param(r, "a")
  295. if token == "" {
  296. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  297. return
  298. }
  299. rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
  300. if err != nil {
  301. h.Logger.DebugContext(ctx, "getInfo: token is not valid base64", "error", err)
  302. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  303. return
  304. }
  305. serverCookie, expiry, err := h.AuthService.CrackCookie(rawCookie)
  306. if err != nil {
  307. h.Logger.DebugContext(ctx, "getInfo: token rejected", "error", err)
  308. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  309. return
  310. }
  311. if serverCookie.Service != wire.BOS || serverCookie.ChatCookie != "" || serverCookie.TokenTTL == 0 {
  312. h.Logger.WarnContext(ctx, "getInfo: rejected a cookie that is not a login token",
  313. "service", serverCookie.Service, "loginId", serverCookie.ScreenName)
  314. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  315. return
  316. }
  317. freshness, ok := authFreshness(r)
  318. if !ok {
  319. SendEnvelopeStatus(w, r, statusParameterError, "reqAuthFreshness is not a count of seconds", h.Logger)
  320. return
  321. }
  322. if age := authAge(serverCookie, expiry); age > freshness {
  323. h.Logger.DebugContext(ctx, "getInfo: authentication is too stale",
  324. "loginId", serverCookie.ScreenName, "age", age, "required", freshness)
  325. h.sendGetInfoRedirect(w, r, statusMoreAuthRequired, "More authentication required")
  326. return
  327. }
  328. SendOK(w, r, &GetInfoData{
  329. UserData: GetInfoUser{
  330. LoginID: serverCookie.ScreenName.String(),
  331. DisplayName: serverCookie.ScreenName.String(),
  332. },
  333. }, h.Logger)
  334. }
  335. // sendGetInfoRedirect refuses a getInfo, naming where to authenticate instead.
  336. // SendEnvelopeStatus cannot be used: it sends no data, so the client has nowhere to
  337. // go.
  338. func (h *AuthHandler) sendGetInfoRedirect(w http.ResponseWriter, r *http.Request, statusCode int, statusText string) {
  339. resp := BaseResponse{}
  340. resp.Response.StatusCode = statusCode
  341. resp.Response.StatusText = statusText
  342. resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
  343. SendResponse(w, r, resp, h.Logger)
  344. }
  345. // authFreshness reads the reqAuthFreshness parameter, reporting false when it is
  346. // present but unusable. Absent, it is the spec's 24 hours.
  347. func authFreshness(r *http.Request) (time.Duration, bool) {
  348. raw := strings.TrimSpace(param(r, "reqAuthFreshness"))
  349. if raw == "" {
  350. return defaultAuthFreshness, true
  351. }
  352. secs, err := strconv.ParseUint(raw, 10, 32)
  353. if err != nil {
  354. return 0, false
  355. }
  356. return time.Duration(secs) * time.Second, true
  357. }
  358. func authAge(cookie state.ServerCookie, expiry time.Time) time.Duration {
  359. issued := expiry.Add(-time.Duration(cookie.TokenTTL) * time.Second)
  360. return max(time.Since(issued), 0)
  361. }
  362. // generateToken generates a secure random token.
  363. func (h *AuthHandler) generateToken() (string, error) {
  364. b := make([]byte, 32)
  365. if _, err := rand.Read(b); err != nil {
  366. return "", err
  367. }
  368. return base64.URLEncoding.EncodeToString(b), nil
  369. }
  370. // bosTokenCookie is the cookie the browser presents to getToken. The name is the
  371. // one AIM's own client knows, kept so a client running against the non-Web API
  372. // path finds what it expects.
  373. const bosTokenCookie = "oldAimToken"
  374. var loginPSPPage = template.Must(template.New("login.psp").Parse(`<!DOCTYPE html>
  375. <html lang="en">
  376. <head>
  377. <meta charset="utf-8">
  378. <meta name="viewport" content="width=device-width, initial-scale=1">
  379. <title>Sign in to AIM</title>
  380. <style>
  381. body { font-family: Arial, Helvetica, sans-serif; background: #0e95ad; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
  382. .card { background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); width: 360px; padding: 32px; }
  383. h1 { margin: 0 0 8px; font-size: 24px; color: #222; }
  384. p { margin: 0 0 20px; color: #666; font-size: 14px; }
  385. label { display: block; font-size: 13px; font-weight: bold; margin-bottom: 6px; color: #333; }
  386. input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: 10px 12px; margin-bottom: 16px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
  387. button { width: 100%; padding: 12px; border: 0; border-radius: 4px; background: #ff6600; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; }
  388. button:hover { background: #e55c00; }
  389. .error { background: #fdecea; color: #b42318; border: 1px solid #f5c2c0; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 13px; }
  390. </style>
  391. </head>
  392. <body>
  393. <form class="card" method="post" action="/_cqr/login/login.psp">
  394. <h1>AIM Sign In</h1>
  395. <p>Sign in with your Open OSCAR account.</p>
  396. {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
  397. <label for="loginId">Screen name</label>
  398. <input id="loginId" name="loginId" type="text" autocomplete="username" value="{{.LoginID}}" required>
  399. <label for="password">Password</label>
  400. <input id="password" name="password" type="password" autocomplete="current-password" required>
  401. <input type="hidden" name="devId" value="{{.DevID}}">
  402. <input type="hidden" name="supportedIdType" value="{{.SupportedIDType}}">
  403. <input type="hidden" name="succUrl" value="{{.SuccURL}}">
  404. <input type="hidden" name="r" value="{{.R}}">
  405. <button type="submit">Sign In</button>
  406. </form>
  407. </body>
  408. </html>`))
  409. type loginPSPPageData struct {
  410. Error string
  411. LoginID string
  412. DevID string
  413. SupportedIDType string
  414. SuccURL string
  415. R string
  416. }
  417. // LoginPSP handles GET and POST /_cqr/login/login.psp for Web AIM SSO login.
  418. func (h *AuthHandler) LoginPSP(w http.ResponseWriter, r *http.Request) {
  419. switch r.Method {
  420. case http.MethodGet:
  421. h.renderLoginPSP(w, r, loginPSPPageData{
  422. DevID: r.URL.Query().Get("devId"),
  423. SupportedIDType: r.URL.Query().Get("supportedIdType"),
  424. SuccURL: r.URL.Query().Get("succUrl"),
  425. R: r.URL.Query().Get("r"),
  426. })
  427. case http.MethodPost:
  428. if err := r.ParseForm(); err != nil {
  429. http.Error(w, "invalid form", http.StatusBadRequest)
  430. return
  431. }
  432. loginID := strings.TrimSpace(r.FormValue("loginId"))
  433. if loginID == "" {
  434. loginID = strings.TrimSpace(r.FormValue("s"))
  435. }
  436. password := r.FormValue("password")
  437. if password == "" {
  438. password = r.FormValue("pwd")
  439. }
  440. data := loginPSPPageData{
  441. LoginID: loginID,
  442. DevID: r.FormValue("devId"),
  443. SupportedIDType: r.FormValue("supportedIdType"),
  444. SuccURL: r.FormValue("succUrl"),
  445. R: r.FormValue("r"),
  446. }
  447. if loginID == "" || password == "" {
  448. data.Error = "Screen name and password are required."
  449. h.renderLoginPSP(w, r, data)
  450. return
  451. }
  452. authCookie, err := h.authenticateCredentials(r.Context(), loginID, password, clientIDForDevID(data.DevID), shortTermTTL)
  453. if err != nil {
  454. if errors.Is(err, errInvalidCredentials) {
  455. h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID)
  456. data.Error = "Invalid screen name or password."
  457. h.renderLoginPSP(w, r, data)
  458. return
  459. }
  460. h.Logger.ErrorContext(r.Context(), "login.psp could not authenticate", "loginId", loginID, "error", err)
  461. http.Error(w, "internal server error", http.StatusInternalServerError)
  462. return
  463. }
  464. setBOSTokenCookie(w, authCookie)
  465. redirectURL := safeLoginRedirectURL(r, data.SuccURL)
  466. h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", loginID, "redirect", redirectURL)
  467. http.Redirect(w, r, redirectURL, http.StatusFound)
  468. default:
  469. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  470. }
  471. }
  472. func (h *AuthHandler) renderLoginPSP(w http.ResponseWriter, r *http.Request, data loginPSPPageData) {
  473. if data.SuccURL == "" {
  474. data.SuccURL = defaultLoginSuccURL(r)
  475. }
  476. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  477. if err := loginPSPPage.Execute(w, data); err != nil {
  478. h.Logger.ErrorContext(r.Context(), "failed to render login.psp", "error", err)
  479. http.Error(w, "internal server error", http.StatusInternalServerError)
  480. }
  481. }
  482. // setBOSTokenCookie hands the BOS token from the login response to the browser.
  483. func setBOSTokenCookie(w http.ResponseWriter, authCookie []byte) {
  484. http.SetCookie(w, &http.Cookie{
  485. Name: bosTokenCookie,
  486. Value: base64.URLEncoding.EncodeToString(authCookie),
  487. Path: "/",
  488. Expires: time.Now().Add(shortTermTTL),
  489. MaxAge: int(shortTermTTL.Seconds()),
  490. HttpOnly: true,
  491. SameSite: http.SameSiteLaxMode,
  492. })
  493. }
  494. // clearBOSTokenCookie expires the token cookie. getToken calls it on every
  495. // request, spending the token whether or not it was any good, so a reload finds
  496. // nothing to sign in with.
  497. func clearBOSTokenCookie(w http.ResponseWriter) {
  498. http.SetCookie(w, &http.Cookie{
  499. Name: bosTokenCookie,
  500. Value: "",
  501. Path: "/",
  502. Expires: time.Unix(0, 0),
  503. MaxAge: -1,
  504. HttpOnly: true,
  505. SameSite: http.SameSiteLaxMode,
  506. })
  507. }
  508. func defaultLoginSuccURL(r *http.Request) string {
  509. return requestScheme(r) + "://" + r.Host + "/"
  510. }
  511. func safeLoginRedirectURL(r *http.Request, succURL string) string {
  512. succURL = strings.TrimSpace(succURL)
  513. if succURL == "" {
  514. return defaultLoginSuccURL(r)
  515. }
  516. target, err := url.Parse(succURL)
  517. if err != nil {
  518. return defaultLoginSuccURL(r)
  519. }
  520. if target.Host == "" {
  521. return succURL
  522. }
  523. reqHost := hostnameOnly(r.Host)
  524. targetHost := hostnameOnly(target.Host)
  525. if targetHost == reqHost || targetHost == "localhost" || targetHost == "127.0.0.1" {
  526. return succURL
  527. }
  528. return defaultLoginSuccURL(r)
  529. }
  530. func hostnameOnly(hostport string) string {
  531. host, _, err := net.SplitHostPort(hostport)
  532. if err != nil {
  533. return hostport
  534. }
  535. return host
  536. }