auth.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. package handlers
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "encoding/base64"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "log/slog"
  10. "mime"
  11. "net/http"
  12. "net/url"
  13. "strconv"
  14. "strings"
  15. "time"
  16. "github.com/mk6i/open-oscar-server/state"
  17. "github.com/mk6i/open-oscar-server/wire"
  18. )
  19. // AuthToken is the opaque credential the client presents on later requests.
  20. //
  21. // ExpiresIn is a string because that is the shape the client is given, even
  22. // though ClientLoginData.TokenExpiresIn carries the same quantity as a number.
  23. type AuthToken struct {
  24. A string `json:"a" xml:"a"`
  25. ExpiresIn string `json:"expiresIn" xml:"expiresIn"`
  26. }
  27. // GetTokenData is the getToken payload.
  28. type GetTokenData struct {
  29. Token AuthToken `json:"token" xml:"token"`
  30. UserData UserData `json:"userData" xml:"userData"`
  31. }
  32. // UserData wraps the attributes getToken reports about the account.
  33. type UserData struct {
  34. Attributes UserAttributes `json:"attributes" xml:"attributes"`
  35. }
  36. // UserAttributes names the account the token belongs to.
  37. type UserAttributes struct {
  38. LoginID string `json:"loginId" xml:"loginId"`
  39. }
  40. // ClientLoginData is the clientLogin payload.
  41. type ClientLoginData struct {
  42. Token AuthToken `json:"token" xml:"token"`
  43. LoginID string `json:"loginId" xml:"loginId"`
  44. ScreenName string `json:"screenName" xml:"screenName"`
  45. SessionSecret string `json:"sessionSecret" xml:"sessionSecret"`
  46. HostTime int64 `json:"hostTime" xml:"hostTime"`
  47. TokenExpiresIn int `json:"tokenExpiresIn" xml:"tokenExpiresIn"`
  48. }
  49. // RedirectData sends an unauthenticated client to the login page.
  50. type RedirectData struct {
  51. RedirectURL string `json:"redirectURL" xml:"redirectURL"`
  52. }
  53. // AuthHandler handles Web AIM API authentication endpoints.
  54. type AuthHandler struct {
  55. AuthService AuthService
  56. Logger *slog.Logger
  57. }
  58. type OServiceService interface {
  59. ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
  60. RateParamsSubAdd(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x01_0x08_OServiceRateParamsSubAdd)
  61. }
  62. // ClientLoginRequest represents the request body for clientLogin.
  63. type ClientLoginRequest struct {
  64. Username string `json:"username"`
  65. Password string `json:"password"`
  66. DevID string `json:"devId"`
  67. }
  68. // GetToken handles GET /auth/getToken requests.
  69. // The Web AIM client uses this JSONP endpoint to exchange SSO session cookies for an API token.
  70. func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
  71. ctx := r.Context()
  72. devID := r.URL.Query().Get("devId")
  73. // The cookie is spent either way: consumed on success, and cleared on failure
  74. // so a browser holding a dead token stops presenting it.
  75. clearBOSTokenCookie(w)
  76. loginID, authCookie, ok := h.resolveGetTokenSession(r)
  77. if !ok {
  78. h.Logger.DebugContext(ctx, "getToken: no token, returning redirect",
  79. "devId", devID,
  80. "host", r.Host)
  81. resp := BaseResponse{}
  82. resp.Response.StatusCode = 401
  83. resp.Response.StatusText = "Unauthorized"
  84. resp.Response.Data = &RedirectData{RedirectURL: h.loginRedirectURL(r)}
  85. SendResponse(w, r, resp, h.Logger)
  86. return
  87. }
  88. resp := BaseResponse{}
  89. resp.Response.StatusCode = 200
  90. resp.Response.StatusText = "OK"
  91. resp.Response.Data = &GetTokenData{
  92. Token: AuthToken{
  93. A: base64.URLEncoding.EncodeToString(authCookie),
  94. // A string, not a number: that is how the client is given it.
  95. ExpiresIn: strconv.Itoa(int(bosTokenTTL.Seconds())),
  96. },
  97. UserData: UserData{Attributes: UserAttributes{LoginID: string(loginID)}},
  98. }
  99. SendResponse(w, r, resp, h.Logger)
  100. h.Logger.InfoContext(ctx, "getToken succeeded", "loginId", loginID, "devId", devID)
  101. }
  102. // resolveGetTokenSession identifies the caller from the BOS token parked at
  103. // sign-in. That cookie is the only credential getToken ever receives: the client
  104. // sends no token of its own, only f, attributes, devId and r. A token past its
  105. // brief life fails to crack and reads the same as no token at all.
  106. func (h *AuthHandler) resolveGetTokenSession(r *http.Request) (state.DisplayScreenName, []byte, bool) {
  107. c, err := r.Cookie(bosTokenCookie)
  108. if err != nil || c.Value == "" {
  109. return "", nil, false
  110. }
  111. token, err := url.QueryUnescape(c.Value)
  112. if err != nil {
  113. token = c.Value
  114. }
  115. rawCookie, err := base64.URLEncoding.DecodeString(strings.TrimSpace(token))
  116. if err != nil {
  117. return "", nil, false
  118. }
  119. serverCookie, err := h.AuthService.CrackCookie(rawCookie)
  120. if err != nil {
  121. return "", nil, false
  122. }
  123. return serverCookie.ScreenName, rawCookie, true
  124. }
  125. // Web API status codes, which a client reads from the envelope rather than from
  126. // the HTTP status. A failed sign-in is a demand for better credentials, not an
  127. // error: statusMoreAuthRequired plus the detail code naming what was wrong is
  128. // what tells a client to say "incorrect password".
  129. const (
  130. statusMoreAuthRequired = 330
  131. statusMissingParameter = 460
  132. detailBadPassword = 3011
  133. )
  134. // errInvalidCredentials reports that the auth service rejected the screen name or
  135. // password, as opposed to failing to answer at all.
  136. var errInvalidCredentials = errors.New("invalid screen name or password")
  137. // authenticateCredentials verifies the credentials and returns the auth cookie minted
  138. // by the OSCAR auth service. It returns errInvalidCredentials when the credentials are
  139. // rejected.
  140. func (h *AuthHandler) authenticateCredentials(ctx context.Context, username, password, clientID string) ([]byte, error) {
  141. signonFrame := wire.FLAPSignonFrame{}
  142. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, username))
  143. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsPlaintextPassword, password))
  144. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsClientIdentity, clientID))
  145. signonFrame.Append(wire.NewTLVBE(wire.LoginTLVTagsMultiConnFlags, wire.MultiConnFlagsRecentClient))
  146. block, err := h.AuthService.FLAPLogin(ctx, signonFrame, "")
  147. if err != nil {
  148. return nil, fmt.Errorf("FLAPLogin: %w", err)
  149. }
  150. if block.HasTag(wire.LoginTLVTagsErrorSubcode) {
  151. return nil, errInvalidCredentials
  152. }
  153. authCookie, ok := block.Bytes(wire.LoginTLVTagsAuthorizationCookie)
  154. if !ok {
  155. return nil, fmt.Errorf("login response carries no authorization cookie")
  156. }
  157. return authCookie, nil
  158. }
  159. // clientIDForDevID names the client on the session for callers that only know the
  160. // Web API devId.
  161. func clientIDForDevID(devID string) string {
  162. if devID == "" {
  163. return "WebAIM"
  164. }
  165. return devID
  166. }
  167. func (h *AuthHandler) loginRedirectURL(r *http.Request) string {
  168. scheme := "http"
  169. if r.TLS != nil {
  170. scheme = "https"
  171. }
  172. return fmt.Sprintf("%s://%s/_cqr/login/login.psp", scheme, r.Host)
  173. }
  174. // Logout sends the browser to the login page. There is nothing to clear: the
  175. // token cookie is spent by the getToken that signed this client in, and nothing
  176. // else survives a request.
  177. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
  178. loginURL := h.loginRedirectURL(r)
  179. q := url.Values{}
  180. if devID := r.URL.Query().Get("devId"); devID != "" {
  181. q.Set("devId", devID)
  182. }
  183. if succURL := r.URL.Query().Get("succUrl"); succURL != "" {
  184. q.Set("succUrl", succURL)
  185. }
  186. if enc := q.Encode(); enc != "" {
  187. loginURL += "?" + enc
  188. }
  189. h.Logger.InfoContext(r.Context(), "logout", "devId", r.URL.Query().Get("devId"))
  190. http.Redirect(w, r, loginURL, http.StatusFound)
  191. }
  192. // ClientLogin handles POST /auth/clientLogin requests.
  193. // This endpoint authenticates users and returns an authentication token.
  194. func (h *AuthHandler) ClientLogin(w http.ResponseWriter, r *http.Request) {
  195. var username, password, devID string
  196. // The media type alone decides how to read the body: a caller that states a
  197. // charset sends "application/json; charset=utf-8", which is still JSON.
  198. mediaType, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
  199. if mediaType == "application/json" {
  200. // Parse JSON body
  201. var req ClientLoginRequest
  202. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  203. h.Logger.Error("failed to parse JSON clientLogin request", "error", err)
  204. SendError(w, r, http.StatusBadRequest, "invalid JSON format")
  205. return
  206. }
  207. username = req.Username
  208. password = req.Password
  209. devID = req.DevID
  210. } else {
  211. // Parse form-encoded or URL parameters
  212. if err := r.ParseForm(); err != nil {
  213. h.Logger.Error("failed to parse form data", "error", err)
  214. SendError(w, r, http.StatusBadRequest, "invalid form data")
  215. return
  216. }
  217. // Try form values first, then fall back to query parameters
  218. username = r.FormValue("s")
  219. if username == "" {
  220. username = r.FormValue("username")
  221. }
  222. password = r.FormValue("pwd")
  223. if password == "" {
  224. password = r.FormValue("password")
  225. }
  226. devID = r.FormValue("devId")
  227. h.Logger.Debug("form-encoded login attempt",
  228. "username", username,
  229. "has_password", password != "",
  230. "devId", devID,
  231. "form", r.Form)
  232. }
  233. // Validate required fields
  234. if username == "" || password == "" {
  235. SendErrorDetail(w, r, http.StatusBadRequest, statusMissingParameter, 0, "username and password required")
  236. return
  237. }
  238. authCookie, err := h.authenticateCredentials(r.Context(), username, password, clientIDForDevID(devID))
  239. if err != nil {
  240. h.Logger.DebugContext(r.Context(), "clientLogin failed", "username", username, "error", err)
  241. if errors.Is(err, errInvalidCredentials) {
  242. SendErrorDetail(w, r, http.StatusUnauthorized, statusMoreAuthRequired, detailBadPassword,
  243. "invalid screen name or password")
  244. return
  245. }
  246. SendError(w, r, http.StatusInternalServerError, "internal server error")
  247. return
  248. }
  249. // No cookie here: this endpoint's caller receives the token in the response
  250. // body and presents it to startSession itself.
  251. // Generate session secret (for signing subsequent requests)
  252. sessionSecret, err := h.generateToken()
  253. if err != nil {
  254. h.Logger.Error("failed to generate session secret", "error", err)
  255. SendError(w, r, http.StatusInternalServerError, "internal server error")
  256. return
  257. }
  258. // Build response
  259. resp := BaseResponse{}
  260. resp.Response.StatusCode = 200
  261. resp.Response.StatusText = "OK"
  262. resp.Response.Data = &ClientLoginData{
  263. Token: AuthToken{
  264. A: base64.URLEncoding.EncodeToString(authCookie),
  265. ExpiresIn: strconv.Itoa(int(bosTokenTTL.Seconds())),
  266. },
  267. LoginID: username,
  268. ScreenName: username,
  269. SessionSecret: sessionSecret,
  270. HostTime: time.Now().Unix(),
  271. // A number here where token.expiresIn is a string, as the client expects.
  272. TokenExpiresIn: int(bosTokenTTL.Seconds()),
  273. }
  274. // Send response in requested format (JSON, JSONP, XML, or AMF)
  275. SendResponse(w, r, resp, h.Logger)
  276. h.Logger.Info("user authenticated successfully",
  277. "username", username,
  278. "screenName", username)
  279. }
  280. // generateToken generates a secure random token.
  281. func (h *AuthHandler) generateToken() (string, error) {
  282. b := make([]byte, 32)
  283. if _, err := rand.Read(b); err != nil {
  284. return "", err
  285. }
  286. return base64.URLEncoding.EncodeToString(b), nil
  287. }