auth_handler.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  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. // One key signs every cookie, so a service-transfer cookie verifies just like a
  312. // login token while recording no authentication. A non-zero TokenTTL is the only
  313. // positive evidence of a login: wire.BOS is 0x0000, so testing Service alone
  314. // admits the linked-account transfer cookie (foodgroup/oservice.go:662), which
  315. // then satisfies any freshness check.
  316. if serverCookie.Service != wire.BOS || serverCookie.ChatCookie != "" || serverCookie.TokenTTL == 0 {
  317. h.Logger.WarnContext(ctx, "getInfo: rejected a cookie that is not a login token",
  318. "service", serverCookie.Service, "loginId", serverCookie.ScreenName)
  319. h.sendGetInfoRedirect(w, r, http.StatusUnauthorized, "Unauthorized")
  320. return
  321. }
  322. freshness, ok := authFreshness(r)
  323. if !ok {
  324. SendEnvelopeStatus(w, r, statusParameterError, "reqAuthFreshness is not a count of seconds", h.Logger)
  325. return
  326. }
  327. if age := authAge(serverCookie, expiry); age > freshness {
  328. h.Logger.DebugContext(ctx, "getInfo: authentication is too stale",
  329. "loginId", serverCookie.ScreenName, "age", age, "required", freshness)
  330. h.sendGetInfoRedirect(w, r, statusMoreAuthRequired, "More authentication required")
  331. return
  332. }
  333. // The cookie's screen name carries the user's own capitalization; without a
  334. // session there is nothing better to look up.
  335. name := serverCookie.ScreenName.String()
  336. SendOK(w, r, &GetInfoData{
  337. UserData: GetInfoUser{LoginID: name, DisplayName: name},
  338. }, h.Logger)
  339. }
  340. // sendGetInfoRedirect refuses a getInfo, naming where to authenticate instead.
  341. // SendEnvelopeStatus cannot be used: it sends no data, so the client has nowhere to
  342. // go.
  343. func (h *AuthHandler) sendGetInfoRedirect(w http.ResponseWriter, r *http.Request, statusCode int, statusText string) {
  344. resp := BaseResponse{}
  345. resp.Response.StatusCode = statusCode
  346. resp.Response.StatusText = statusText
  347. resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
  348. SendResponse(w, r, resp, h.Logger)
  349. }
  350. // authFreshness reads the reqAuthFreshness parameter, reporting false when it is
  351. // present but unusable. Absent, it is the spec's 24 hours.
  352. func authFreshness(r *http.Request) (time.Duration, bool) {
  353. raw := strings.TrimSpace(param(r, "reqAuthFreshness"))
  354. if raw == "" {
  355. return defaultAuthFreshness, true
  356. }
  357. secs, err := strconv.ParseUint(raw, 10, 32)
  358. if err != nil {
  359. return 0, false
  360. }
  361. return time.Duration(secs) * time.Second, true
  362. }
  363. // authAge is how long ago the cookie's owner authenticated. Nothing records a login
  364. // time, but the granted lifetime subtracted from the expiry gives the issue instant.
  365. // Callers must reject a cookie with no TokenTTL first.
  366. //
  367. // CrackCookie has already rejected an expired cookie, so the age is always under the
  368. // granted lifetime: a freshness requirement at or above the grant never fires.
  369. func authAge(cookie state.ServerCookie, expiry time.Time) time.Duration {
  370. issued := expiry.Add(-time.Duration(cookie.TokenTTL) * time.Second)
  371. return max(time.Since(issued), 0)
  372. }
  373. // generateToken generates a secure random token.
  374. func (h *AuthHandler) generateToken() (string, error) {
  375. b := make([]byte, 32)
  376. if _, err := rand.Read(b); err != nil {
  377. return "", err
  378. }
  379. return base64.URLEncoding.EncodeToString(b), nil
  380. }
  381. // bosTokenCookie is the cookie the browser presents to getToken. The name is the
  382. // one AIM's own client knows, kept so a client running against the non-Web API
  383. // path finds what it expects.
  384. const bosTokenCookie = "oldAimToken"
  385. var loginPSPPage = template.Must(template.New("login.psp").Parse(`<!DOCTYPE html>
  386. <html lang="en">
  387. <head>
  388. <meta charset="utf-8">
  389. <meta name="viewport" content="width=device-width, initial-scale=1">
  390. <title>Sign in to AIM</title>
  391. <style>
  392. body { font-family: Arial, Helvetica, sans-serif; background: #0e95ad; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
  393. .card { background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); width: 360px; padding: 32px; }
  394. h1 { margin: 0 0 8px; font-size: 24px; color: #222; }
  395. p { margin: 0 0 20px; color: #666; font-size: 14px; }
  396. label { display: block; font-size: 13px; font-weight: bold; margin-bottom: 6px; color: #333; }
  397. 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; }
  398. button { width: 100%; padding: 12px; border: 0; border-radius: 4px; background: #ff6600; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; }
  399. button:hover { background: #e55c00; }
  400. .error { background: #fdecea; color: #b42318; border: 1px solid #f5c2c0; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 13px; }
  401. </style>
  402. </head>
  403. <body>
  404. <form class="card" method="post" action="/_cqr/login/login.psp">
  405. <h1>AIM Sign In</h1>
  406. <p>Sign in with your Open OSCAR account.</p>
  407. {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
  408. <label for="loginId">Screen name</label>
  409. <input id="loginId" name="loginId" type="text" autocomplete="username" value="{{.LoginID}}" required>
  410. <label for="password">Password</label>
  411. <input id="password" name="password" type="password" autocomplete="current-password" required>
  412. <input type="hidden" name="devId" value="{{.DevID}}">
  413. <input type="hidden" name="supportedIdType" value="{{.SupportedIDType}}">
  414. <input type="hidden" name="succUrl" value="{{.SuccURL}}">
  415. <input type="hidden" name="r" value="{{.R}}">
  416. <button type="submit">Sign In</button>
  417. </form>
  418. </body>
  419. </html>`))
  420. type loginPSPPageData struct {
  421. Error string
  422. LoginID string
  423. DevID string
  424. SupportedIDType string
  425. SuccURL string
  426. R string
  427. }
  428. // LoginPSP handles GET and POST /_cqr/login/login.psp for Web AIM SSO login.
  429. func (h *AuthHandler) LoginPSP(w http.ResponseWriter, r *http.Request) {
  430. switch r.Method {
  431. case http.MethodGet:
  432. h.renderLoginPSP(w, r, loginPSPPageData{
  433. DevID: r.URL.Query().Get("devId"),
  434. SupportedIDType: r.URL.Query().Get("supportedIdType"),
  435. SuccURL: r.URL.Query().Get("succUrl"),
  436. R: r.URL.Query().Get("r"),
  437. })
  438. case http.MethodPost:
  439. if err := r.ParseForm(); err != nil {
  440. http.Error(w, "invalid form", http.StatusBadRequest)
  441. return
  442. }
  443. loginID := strings.TrimSpace(r.FormValue("loginId"))
  444. if loginID == "" {
  445. loginID = strings.TrimSpace(r.FormValue("s"))
  446. }
  447. password := r.FormValue("password")
  448. if password == "" {
  449. password = r.FormValue("pwd")
  450. }
  451. data := loginPSPPageData{
  452. LoginID: loginID,
  453. DevID: r.FormValue("devId"),
  454. SupportedIDType: r.FormValue("supportedIdType"),
  455. SuccURL: r.FormValue("succUrl"),
  456. R: r.FormValue("r"),
  457. }
  458. if loginID == "" || password == "" {
  459. data.Error = "Screen name and password are required."
  460. h.renderLoginPSP(w, r, data)
  461. return
  462. }
  463. authCookie, err := h.authenticateCredentials(r.Context(), loginID, password, clientIDForDevID(data.DevID), shortTermTTL)
  464. if err != nil {
  465. if errors.Is(err, errInvalidCredentials) {
  466. h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID)
  467. data.Error = "Invalid screen name or password."
  468. h.renderLoginPSP(w, r, data)
  469. return
  470. }
  471. h.Logger.ErrorContext(r.Context(), "login.psp could not authenticate", "loginId", loginID, "error", err)
  472. http.Error(w, "internal server error", http.StatusInternalServerError)
  473. return
  474. }
  475. setBOSTokenCookie(w, authCookie)
  476. redirectURL := safeLoginRedirectURL(r, data.SuccURL)
  477. h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", loginID, "redirect", redirectURL)
  478. http.Redirect(w, r, redirectURL, http.StatusFound)
  479. default:
  480. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  481. }
  482. }
  483. func (h *AuthHandler) renderLoginPSP(w http.ResponseWriter, r *http.Request, data loginPSPPageData) {
  484. if data.SuccURL == "" {
  485. data.SuccURL = defaultLoginSuccURL(r)
  486. }
  487. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  488. if err := loginPSPPage.Execute(w, data); err != nil {
  489. h.Logger.ErrorContext(r.Context(), "failed to render login.psp", "error", err)
  490. http.Error(w, "internal server error", http.StatusInternalServerError)
  491. }
  492. }
  493. // setBOSTokenCookie hands the BOS token from the login response to the browser.
  494. func setBOSTokenCookie(w http.ResponseWriter, authCookie []byte) {
  495. http.SetCookie(w, &http.Cookie{
  496. Name: bosTokenCookie,
  497. Value: base64.URLEncoding.EncodeToString(authCookie),
  498. Path: "/",
  499. Expires: time.Now().Add(shortTermTTL),
  500. MaxAge: int(shortTermTTL.Seconds()),
  501. HttpOnly: true,
  502. SameSite: http.SameSiteLaxMode,
  503. })
  504. }
  505. // clearBOSTokenCookie expires the token cookie. getToken calls it on every
  506. // request, spending the token whether or not it was any good, so a reload finds
  507. // nothing to sign in with.
  508. func clearBOSTokenCookie(w http.ResponseWriter) {
  509. http.SetCookie(w, &http.Cookie{
  510. Name: bosTokenCookie,
  511. Value: "",
  512. Path: "/",
  513. Expires: time.Unix(0, 0),
  514. MaxAge: -1,
  515. HttpOnly: true,
  516. SameSite: http.SameSiteLaxMode,
  517. })
  518. }
  519. func defaultLoginSuccURL(r *http.Request) string {
  520. return requestScheme(r) + "://" + r.Host + "/"
  521. }
  522. func safeLoginRedirectURL(r *http.Request, succURL string) string {
  523. succURL = strings.TrimSpace(succURL)
  524. if succURL == "" {
  525. return defaultLoginSuccURL(r)
  526. }
  527. target, err := url.Parse(succURL)
  528. if err != nil {
  529. return defaultLoginSuccURL(r)
  530. }
  531. if target.Host == "" {
  532. return succURL
  533. }
  534. reqHost := hostnameOnly(r.Host)
  535. targetHost := hostnameOnly(target.Host)
  536. if targetHost == reqHost || targetHost == "localhost" || targetHost == "127.0.0.1" {
  537. return succURL
  538. }
  539. return defaultLoginSuccURL(r)
  540. }
  541. func hostnameOnly(hostport string) string {
  542. host, _, err := net.SplitHostPort(hostport)
  543. if err != nil {
  544. return hostport
  545. }
  546. return host
  547. }