auth.go 10 KB

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