login_psp.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. package handlers
  2. import (
  3. "encoding/base64"
  4. "errors"
  5. "html/template"
  6. "net"
  7. "net/http"
  8. "net/url"
  9. "strings"
  10. "time"
  11. )
  12. // bosTokenCookie is the cookie the browser presents to getToken. The name is the
  13. // one AIM's own client knows, kept so a client running against the non-Web API
  14. // path finds what it expects.
  15. const bosTokenCookie = "oldAimToken"
  16. // bosTokenTTL mirrors the expiry stamped by HMACCookieBaker.Issue
  17. // (state/cookie.go). The token only has to survive login.psp -> getToken ->
  18. // startSession, so its brief life is what makes every later visit sign in again.
  19. const bosTokenTTL = time.Minute
  20. var loginPSPPage = template.Must(template.New("login.psp").Parse(`<!DOCTYPE html>
  21. <html lang="en">
  22. <head>
  23. <meta charset="utf-8">
  24. <meta name="viewport" content="width=device-width, initial-scale=1">
  25. <title>Sign in to AIM</title>
  26. <style>
  27. body { font-family: Arial, Helvetica, sans-serif; background: #0e95ad; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
  28. .card { background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); width: 360px; padding: 32px; }
  29. h1 { margin: 0 0 8px; font-size: 24px; color: #222; }
  30. p { margin: 0 0 20px; color: #666; font-size: 14px; }
  31. label { display: block; font-size: 13px; font-weight: bold; margin-bottom: 6px; color: #333; }
  32. 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; }
  33. button { width: 100%; padding: 12px; border: 0; border-radius: 4px; background: #ff6600; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; }
  34. button:hover { background: #e55c00; }
  35. .error { background: #fdecea; color: #b42318; border: 1px solid #f5c2c0; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 13px; }
  36. </style>
  37. </head>
  38. <body>
  39. <form class="card" method="post" action="/_cqr/login/login.psp">
  40. <h1>AIM Sign In</h1>
  41. <p>Sign in with your Open OSCAR account.</p>
  42. {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
  43. <label for="loginId">Screen name</label>
  44. <input id="loginId" name="loginId" type="text" autocomplete="username" value="{{.LoginID}}" required>
  45. <label for="password">Password</label>
  46. <input id="password" name="password" type="password" autocomplete="current-password" required>
  47. <input type="hidden" name="devId" value="{{.DevID}}">
  48. <input type="hidden" name="supportedIdType" value="{{.SupportedIDType}}">
  49. <input type="hidden" name="succUrl" value="{{.SuccURL}}">
  50. <input type="hidden" name="r" value="{{.R}}">
  51. <button type="submit">Sign In</button>
  52. </form>
  53. </body>
  54. </html>`))
  55. type loginPSPPageData struct {
  56. Error string
  57. LoginID string
  58. DevID string
  59. SupportedIDType string
  60. SuccURL string
  61. R string
  62. }
  63. // LoginPSP handles GET and POST /_cqr/login/login.psp for Web AIM SSO login.
  64. func (h *AuthHandler) LoginPSP(w http.ResponseWriter, r *http.Request) {
  65. switch r.Method {
  66. case http.MethodGet:
  67. h.renderLoginPSP(w, r, loginPSPPageData{
  68. DevID: r.URL.Query().Get("devId"),
  69. SupportedIDType: r.URL.Query().Get("supportedIdType"),
  70. SuccURL: r.URL.Query().Get("succUrl"),
  71. R: r.URL.Query().Get("r"),
  72. })
  73. case http.MethodPost:
  74. if err := r.ParseForm(); err != nil {
  75. http.Error(w, "invalid form", http.StatusBadRequest)
  76. return
  77. }
  78. loginID := strings.TrimSpace(r.FormValue("loginId"))
  79. if loginID == "" {
  80. loginID = strings.TrimSpace(r.FormValue("s"))
  81. }
  82. password := r.FormValue("password")
  83. if password == "" {
  84. password = r.FormValue("pwd")
  85. }
  86. data := loginPSPPageData{
  87. LoginID: loginID,
  88. DevID: r.FormValue("devId"),
  89. SupportedIDType: r.FormValue("supportedIdType"),
  90. SuccURL: r.FormValue("succUrl"),
  91. R: r.FormValue("r"),
  92. }
  93. if loginID == "" || password == "" {
  94. data.Error = "Screen name and password are required."
  95. h.renderLoginPSP(w, r, data)
  96. return
  97. }
  98. authCookie, err := h.authenticateCredentials(r.Context(), loginID, password, clientIDForDevID(data.DevID))
  99. if err != nil {
  100. if errors.Is(err, errInvalidCredentials) {
  101. h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID)
  102. data.Error = "Invalid screen name or password."
  103. h.renderLoginPSP(w, r, data)
  104. return
  105. }
  106. h.Logger.ErrorContext(r.Context(), "login.psp could not authenticate", "loginId", loginID, "error", err)
  107. http.Error(w, "internal server error", http.StatusInternalServerError)
  108. return
  109. }
  110. setBOSTokenCookie(w, authCookie)
  111. redirectURL := safeLoginRedirectURL(r, data.SuccURL)
  112. h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", loginID, "redirect", redirectURL)
  113. http.Redirect(w, r, redirectURL, http.StatusFound)
  114. default:
  115. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  116. }
  117. }
  118. func (h *AuthHandler) renderLoginPSP(w http.ResponseWriter, r *http.Request, data loginPSPPageData) {
  119. if data.SuccURL == "" {
  120. data.SuccURL = defaultLoginSuccURL(r)
  121. }
  122. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  123. if err := loginPSPPage.Execute(w, data); err != nil {
  124. h.Logger.ErrorContext(r.Context(), "failed to render login.psp", "error", err)
  125. http.Error(w, "internal server error", http.StatusInternalServerError)
  126. }
  127. }
  128. // setBOSTokenCookie hands the BOS token from the login response to the browser,
  129. // which carries it as far as the getToken that follows the redirect. It is a
  130. // bearer credential, so HttpOnly keeps it out of reach of page scripts, and its
  131. // MaxAge matches the token's own life so the browser drops it on the same
  132. // schedule the server stops honouring it.
  133. func setBOSTokenCookie(w http.ResponseWriter, authCookie []byte) {
  134. http.SetCookie(w, &http.Cookie{
  135. Name: bosTokenCookie,
  136. Value: base64.URLEncoding.EncodeToString(authCookie),
  137. Path: "/",
  138. Expires: time.Now().Add(bosTokenTTL),
  139. MaxAge: int(bosTokenTTL.Seconds()),
  140. HttpOnly: true,
  141. SameSite: http.SameSiteLaxMode,
  142. })
  143. }
  144. // clearBOSTokenCookie expires the token cookie. getToken calls it on every
  145. // request, spending the token whether or not it was any good, so a reload finds
  146. // nothing to sign in with.
  147. func clearBOSTokenCookie(w http.ResponseWriter) {
  148. http.SetCookie(w, &http.Cookie{
  149. Name: bosTokenCookie,
  150. Value: "",
  151. Path: "/",
  152. Expires: time.Unix(0, 0),
  153. MaxAge: -1,
  154. HttpOnly: true,
  155. SameSite: http.SameSiteLaxMode,
  156. })
  157. }
  158. func defaultLoginSuccURL(r *http.Request) string {
  159. return requestScheme(r) + "://" + r.Host + "/"
  160. }
  161. func safeLoginRedirectURL(r *http.Request, succURL string) string {
  162. succURL = strings.TrimSpace(succURL)
  163. if succURL == "" {
  164. return defaultLoginSuccURL(r)
  165. }
  166. target, err := url.Parse(succURL)
  167. if err != nil {
  168. return defaultLoginSuccURL(r)
  169. }
  170. if target.Host == "" {
  171. return succURL
  172. }
  173. reqHost := hostnameOnly(r.Host)
  174. targetHost := hostnameOnly(target.Host)
  175. if targetHost == reqHost || targetHost == "localhost" || targetHost == "127.0.0.1" {
  176. return succURL
  177. }
  178. return defaultLoginSuccURL(r)
  179. }
  180. func hostnameOnly(hostport string) string {
  181. host, _, err := net.SplitHostPort(hostport)
  182. if err != nil {
  183. return hostport
  184. }
  185. return host
  186. }