login_psp.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. package handlers
  2. import (
  3. "fmt"
  4. "html/template"
  5. "net"
  6. "net/http"
  7. "net/url"
  8. "strings"
  9. "time"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. const loginPSPCookieMaxAge = 86400
  14. var loginPSPPage = template.Must(template.New("login.psp").Parse(`<!DOCTYPE html>
  15. <html lang="en">
  16. <head>
  17. <meta charset="utf-8">
  18. <meta name="viewport" content="width=device-width, initial-scale=1">
  19. <title>Sign in to AIM</title>
  20. <style>
  21. body { font-family: Arial, Helvetica, sans-serif; background: #0e95ad; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
  22. .card { background: #fff; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); width: 360px; padding: 32px; }
  23. h1 { margin: 0 0 8px; font-size: 24px; color: #222; }
  24. p { margin: 0 0 20px; color: #666; font-size: 14px; }
  25. label { display: block; font-size: 13px; font-weight: bold; margin-bottom: 6px; color: #333; }
  26. 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; }
  27. button { width: 100%; padding: 12px; border: 0; border-radius: 4px; background: #ff6600; color: #fff; font-size: 15px; font-weight: bold; cursor: pointer; }
  28. button:hover { background: #e55c00; }
  29. .error { background: #fdecea; color: #b42318; border: 1px solid #f5c2c0; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 13px; }
  30. </style>
  31. </head>
  32. <body>
  33. <form class="card" method="post" action="/_cqr/login/login.psp">
  34. <h1>AIM Sign In</h1>
  35. <p>Sign in with your Open OSCAR account.</p>
  36. {{if .Error}}<div class="error">{{.Error}}</div>{{end}}
  37. <label for="loginId">Screen name</label>
  38. <input id="loginId" name="loginId" type="text" autocomplete="username" value="{{.LoginID}}" required>
  39. <label for="password">Password</label>
  40. <input id="password" name="password" type="password" autocomplete="current-password" required>
  41. <input type="hidden" name="devId" value="{{.DevID}}">
  42. <input type="hidden" name="supportedIdType" value="{{.SupportedIDType}}">
  43. <input type="hidden" name="succUrl" value="{{.SuccURL}}">
  44. <input type="hidden" name="r" value="{{.R}}">
  45. <button type="submit">Sign In</button>
  46. </form>
  47. </body>
  48. </html>`))
  49. type loginPSPPageData struct {
  50. Error string
  51. LoginID string
  52. DevID string
  53. SupportedIDType string
  54. SuccURL string
  55. R string
  56. }
  57. // LoginPSP handles GET and POST /_cqr/login/login.psp for Web AIM SSO login.
  58. func (h *AuthHandler) LoginPSP(w http.ResponseWriter, r *http.Request) {
  59. switch r.Method {
  60. case http.MethodGet:
  61. h.renderLoginPSP(w, r, loginPSPPageData{
  62. DevID: r.URL.Query().Get("devId"),
  63. SupportedIDType: r.URL.Query().Get("supportedIdType"),
  64. SuccURL: r.URL.Query().Get("succUrl"),
  65. R: r.URL.Query().Get("r"),
  66. })
  67. case http.MethodPost:
  68. if err := r.ParseForm(); err != nil {
  69. http.Error(w, "invalid form", http.StatusBadRequest)
  70. return
  71. }
  72. loginID := strings.TrimSpace(r.FormValue("loginId"))
  73. if loginID == "" {
  74. loginID = strings.TrimSpace(r.FormValue("s"))
  75. }
  76. password := r.FormValue("password")
  77. if password == "" {
  78. password = r.FormValue("pwd")
  79. }
  80. data := loginPSPPageData{
  81. LoginID: loginID,
  82. DevID: r.FormValue("devId"),
  83. SupportedIDType: r.FormValue("supportedIdType"),
  84. SuccURL: r.FormValue("succUrl"),
  85. R: r.FormValue("r"),
  86. }
  87. if loginID == "" || password == "" {
  88. data.Error = "Screen name and password are required."
  89. h.renderLoginPSP(w, r, data)
  90. return
  91. }
  92. if err := h.authenticateCredentials(r, loginID, password); err != nil {
  93. h.Logger.DebugContext(r.Context(), "login.psp failed", "loginId", loginID, "error", err)
  94. data.Error = "Invalid screen name or password."
  95. h.renderLoginPSP(w, r, data)
  96. return
  97. }
  98. screenName := state.DisplayScreenName(loginID)
  99. h.setLoginPSPCookies(w, screenName)
  100. redirectURL := safeLoginRedirectURL(r, data.SuccURL)
  101. h.Logger.InfoContext(r.Context(), "login.psp succeeded", "loginId", screenName, "redirect", redirectURL)
  102. http.Redirect(w, r, redirectURL, http.StatusFound)
  103. default:
  104. http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  105. }
  106. }
  107. func (h *AuthHandler) renderLoginPSP(w http.ResponseWriter, r *http.Request, data loginPSPPageData) {
  108. if data.SuccURL == "" {
  109. data.SuccURL = defaultLoginSuccURL(r)
  110. }
  111. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  112. if err := loginPSPPage.Execute(w, data); err != nil {
  113. h.Logger.ErrorContext(r.Context(), "failed to render login.psp", "error", err)
  114. http.Error(w, "internal server error", http.StatusInternalServerError)
  115. }
  116. }
  117. func (h *AuthHandler) authenticateCredentials(r *http.Request, username, password string) error {
  118. signonFrame := wire.FLAPSignonFrame{}
  119. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, username))
  120. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
  121. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
  122. block, err := h.AuthService.FLAPLogin(r.Context(), signonFrame, "")
  123. if err != nil {
  124. return err
  125. }
  126. if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
  127. return fmt.Errorf("login failed")
  128. }
  129. return nil
  130. }
  131. func (h *AuthHandler) setLoginPSPCookies(w http.ResponseWriter, screenName state.DisplayScreenName) {
  132. loginID := string(screenName)
  133. expires := time.Now().Add(loginPSPCookieMaxAge * time.Second)
  134. cookie := func(name, value string) *http.Cookie {
  135. return &http.Cookie{
  136. Name: name,
  137. Value: value,
  138. Path: "/",
  139. Expires: expires,
  140. MaxAge: loginPSPCookieMaxAge,
  141. HttpOnly: false,
  142. SameSite: http.SameSiteLaxMode,
  143. }
  144. }
  145. http.SetCookie(w, cookie("RSP_USER", loginID))
  146. http.SetCookie(w, cookie("RSP_LOCAL", loginID))
  147. http.SetCookie(w, cookie("localAuthUser", loginID+"||"+loginID))
  148. }
  149. // clearLoginPSPCookies expires the SSO cookies set by setLoginPSPCookies (plus
  150. // the oldAimToken cookie honored by getToken) so the browser is logged out.
  151. func (h *AuthHandler) clearLoginPSPCookies(w http.ResponseWriter) {
  152. for _, name := range []string{"RSP_USER", "RSP_LOCAL", "localAuthUser", "oldAimToken"} {
  153. http.SetCookie(w, &http.Cookie{
  154. Name: name,
  155. Value: "",
  156. Path: "/",
  157. Expires: time.Unix(0, 0),
  158. MaxAge: -1,
  159. HttpOnly: false,
  160. SameSite: http.SameSiteLaxMode,
  161. })
  162. }
  163. }
  164. func defaultLoginSuccURL(r *http.Request) string {
  165. scheme := "http"
  166. if r.TLS != nil {
  167. scheme = "https"
  168. }
  169. return scheme + "://" + r.Host + "/"
  170. }
  171. func safeLoginRedirectURL(r *http.Request, succURL string) string {
  172. succURL = strings.TrimSpace(succURL)
  173. if succURL == "" {
  174. return defaultLoginSuccURL(r)
  175. }
  176. target, err := url.Parse(succURL)
  177. if err != nil {
  178. return defaultLoginSuccURL(r)
  179. }
  180. if target.Host == "" {
  181. return succURL
  182. }
  183. reqHost := hostnameOnly(r.Host)
  184. targetHost := hostnameOnly(target.Host)
  185. if targetHost == reqHost || targetHost == "localhost" || targetHost == "127.0.0.1" {
  186. return succURL
  187. }
  188. return defaultLoginSuccURL(r)
  189. }
  190. func hostnameOnly(hostport string) string {
  191. host, _, err := net.SplitHostPort(hostport)
  192. if err != nil {
  193. return hostport
  194. }
  195. return host
  196. }