4
0

auth.go 11 KB

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