auth_handler.go 17 KB

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