leocov-dev / tadpoles-backup

Download images of your kids from Tadpoles and Bright Horizons via CLI, Docker, or Kubernetes
MIT License
14 stars 3 forks source link

BrightHorizons Login Failure: 405 HTTP Verb Not Allowed Error on POST Request #48

Open othrif opened 9 months ago

othrif commented 9 months ago

Describe the bug When attempting to use the tadpoles-backup tool with the stat command to log into Bright Horizons, the process fails with a "405 - HTTP verb used to access this page is not allowed" error. This occurs during the login attempt to Bright Horizons, suggesting an issue with the HTTP method used for the POST request to /mybrightday/login.

Note that I used the same username (not email) and password I used to login to: https://familyinfocenter.brighthorizons.com/mybrightday/login successfully.

To Reproduce Steps to reproduce the behavior:

  1. Execute the command ./tadpoles-backup stat -p brightHorizons.
  2. Provide brightHorizons login credentials when prompted (email and password).
  3. Encounter the error message during the login process.
  4. See error!

Expected behavior I expected the tadpoles-backup tool to successfully log into the Bright Horizons service and provide status information without encountering a server error.

Logs Logs from the console:

$ ./tadpoles-backup stat -p brightHorizons
Input           : brightHorizons login required...
Email           :   <USERNAME here>
Password        :  <PASSWORD here>
Cmd Error       : [Error] bright horizons login failed POST: /mybrightday/login => <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"/>
<title>405 - HTTP verb used to access this page is not allowed.</title>
<style type="text/css">
<!--
body{margin:0;font-size:.7em;font-family:Verdana, Arial, Helvetica, sans-serif;background:#EEEEEE;}
fieldset{padding:0 15px 10px 15px;}
h1{font-size:2.4em;margin:0;color:#FFF;}
h2{font-size:1.7em;margin:0;color:#CC0000;}
h3{font-size:1.2em;margin:10px 0 0 0;color:#000000;}
#header{width:96%;margin:0 0 0 0;padding:6px 2% 6px 2%;font-family:"trebuchet MS", Verdana, sans-serif;color:#FFF;
background-color:#555555;}
#content{margin:0 0 0 2%;position:relative;}
.content-container{background:#FFF;width:96%;margin-top:8px;padding:10px;position:relative;}
-->
</style>
</head>
<body>
<div id="header"><h1>Server Error</h1></div>
<div id="content">
 <div class="content-container"><fieldset>
  <h2>405 - HTTP verb used to access this page is not allowed.</h2>
  <h3>The page you are looking for cannot be displayed because an invalid method (HTTP verb) was used to attempt access.</h3>
 </fieldset></div>
</div>
</body>
</html>

System Info (please complete the following information):

leocov-dev commented 9 months ago

it looks like BrightHorizons has changed their login flow - the POST to the url in the code is no longer a valid way to authenticate. I will see if I can figure out the new flow.

othrif commented 9 months ago

Happy to assist in the debugging if there is anything I can help with. On 19. Jan 2024, at 13:07, Leo Covarrubias @.***> wrote: it looks like BrightHorizons has changed their login flow - the POST to the url in the code is no longer a valid way to authenticate. I will see if I can figure out the new flow.

—Reply to this email directly, view it on GitHub, or unsubscribe.You are receiving this because you authored the thread.Message ID: @.***>

leocov-dev commented 9 months ago

when I try to visit: https://familyinfocenter.brighthorizons.com/mybrightday/login
i get redirected to: https://bhlogin.brighthorizons.com?benefitid=5&fstargetid=1

the login form at this new url (as far as i can tell not being able to actually log in) posts to itself with a more complex request including a ephemeral verification token like so:

POST https://bhlogin.brighthorizons.com?benefitid=5&fstargetid=1
Content-Type: application/x-www-form-urlencoded

Form data:
__RequestVerificationToken=<random token>
benefitId=5
clientGuid=<not sure if this needs a value>
userType=<not sure if this needs a value>
fsTargetId=1
returnURL=<not sure if this needs a value>
JsonDfp=<json string describing the host machine and browser>
username=<username>
password=<password>

not sure how we would be able to generate the __RequestVerificationToken needs more digging. the benefitId and fsTargetId seem to indicate the target portal for a unified login system for BH's different services. the clientGuid value probably indicates something about the host like if the request is from the mobile app.

leocov-dev commented 9 months ago

in the repository the file api_docs/bright_horizons.http describes most of the requests the code tries to make. My main concern is that if they've changed the login flow they may have changed more about their API.

othrif commented 9 months ago

Indeed, it seems the login process of BH involves multiple steps, including redirections. From googling, the RequestVerificationToken is an anti-forgery token used to prevent CSRF attacks, and it is usually generated by the server and must be included in a subsequent POST request. The` RequestVerificationToken` can't be generated client-side; it must be issued by the server and is tied to the user's session.

From what i can see, when we visit https://familyinfocenter.brighthorizons.com/mybrightday/login, we are redirected to a centralized login system at https://bhlogin.brighthorizons.com. This system handles authentication for various Bright Horizons services and requires a benefitId and fsTargetId to route the login process correctly.

Upon submitting my login credentials, a POST request is made to https://bhlogin.brighthorizons.com, which includes __RequestVerificationToken alongside the benefitId, fsTargetId, and user credentials.

The browser is directed back to the Family Information Center https://familyinfocenter.brighthorizons.com/welcome/login, maybe indicating the completion of the authentication process.

As I see in the request named login, here is the request:

curl 'https://familyinfocenter.brighthorizons.com/welcome/login' \
  -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
  -H 'Accept-Language: en' \
  -H 'Cache-Control: no-cache' \
  -H 'Connection: keep-alive' \
  -H 'Cookie: <redacted>' \
  -H 'Pragma: no-cache' \
  -H 'Referer: https://bhlogin.brighthorizons.com/' \
  -H 'Sec-Fetch-Dest: document' \
  -H 'Sec-Fetch-Mode: navigate' \
  -H 'Sec-Fetch-Site: same-site' \
  -H 'Upgrade-Insecure-Requests: 1' \
  -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
  -H 'sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "macOS"' \
  --compressed

Let me know what i can check next.

leocov-dev commented 9 months ago

the __RequestVerificationToken is available in the initial html as a hidden form field it may be possible to do something like:

at this point on being redirected to https://familyinfocenter.brighthorizons.com/welcome/login either the new cookies are used for api request validation or there is some javascript that fetches additional data to eventually get an api key.

you can check a browsers dev tools once fully logged in and inspect the requests that fetch data to see what is authenticating the api - either the previous X-Api-Key header or something else (maybe only a cookie)

othrif commented 9 months ago

Extracting the __RequestVerificationToken from the initial HTML response seems like a good way to proceed.

I see a Set-Cookie header in the login Response Headers. I presume this means the server may be setting a session cookie that is used for authentication.

Request Headers

GET /welcome/login HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: en,en-US;q=0.9,fr-FR;q=0.8,fr;q=0.7,ar;q=0.6
Cache-Control: no-cache
Connection: keep-alive
**Cookie: <redacted>**
Host: familyinfocenter.brighthorizons.com
Pragma: no-cache
Referer: https://bhlogin.brighthorizons.com/
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-site
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

Response Headers

HTTP/1.1 200 OK
Content-Type: text/html
Content-Encoding: gzip
Last-Modified: Thu, 18 Jan 2024 09:52:06 GMT
Accept-Ranges: bytes
ETag: "0e7b4fef349da1:0"
Vary: Accept-Encoding
Server: 
Access-Control-Allow-Origin: *
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000
Content-Security-Policy: frame-ancestors 'none'
Date: Sat, 20 Jan 2024 03:42:59 GMT
Content-Length: 3209
Strict-Transport-Security: max-age=157680000
**Set-Cookie: <redacted>**
Strict-Transport-Security: max-age=31536000

I am attaching a screenshot of the other requests that are happening if you want to see more details about one of them.

Screenshot 2024-01-19 at 7 45 07 PM
leocov-dev commented 9 months ago

It's going to be very difficult for me to reverse engineer the login flow as it stands.

If you are comfortable with Python I've started a script attempting to execute the login flow in this branch: https://github.com/leocov-dev/tadpoles-backup/pull/49/files

executed as:

$ python api_docs/bright_horizons_login_flow.py <username> <password>

Perhaps you are able to make progress with this? As a side note it seems that 4 invalid login attempts triggers an account lock (seems they probably send an email to unlock).

Important indicators you can reference after login in Chrome dev tools network capture under Fetch/XHR are:

kristjan commented 6 months ago

I got this working today - after the CSRF hurdle, we also need to go through a SAML request, exchange the Bright Horizons token for a Tadpole token, and then we can exchange that for session cookies. Once those are available, I'm able to hit endpoints like /remote/v1/events. There are three different hosts in the flow, so maybe there's a simpler path, but this mimics what the browser does. Different data comes from different hosts, so I hide that a little behind a generic get method, but it'd surely be cleaner to pull things apart into multiple clients. At any rate, I hope this can help y'all port to the languages you want.

class Client
  BRIGHT_HORIZONS_BASE = 'https://bhlogin.brighthorizons.com'

  APIS = {
    bright_horizons: 'https://mbdwgateway.brighthorizons.com/api',
    tadpole: 'https://mybrightday.brighthorizons.com'
  }

  DEBUG = false

  attr_reader :username, :password, :bh_token, :tadpole_token, :tadpole_cookies

  def initialize(username, password)
    @username = username
    @password = password
  end

  def debug(*strs)
    puts(*strs) if DEBUG
  end

  def log_in
    bh_start_page = HTTP.get("#{BRIGHT_HORIZONS_BASE}/?benefitid=5&fstargetid=1")
    verification_token = Nokogiri::HTML(bh_start_page.body.to_s).css('input[name="__RequestVerificationToken"]').first['value']
    cookies = bh_start_page.cookies

    login_response = HTTP.cookies(cookies).post(
      "#{BRIGHT_HORIZONS_BASE}",
      form: {
        username: username,
        password: password,
        __RequestVerificationToken: verification_token,
        benefitid: 5,
        fstargetid: 1,
        userType: 0
      }
    )
    cookies = login_response.cookies
    redirect = login_response.headers['Location']

    bh_response = HTTP.cookies(cookies).get("#{BRIGHT_HORIZONS_BASE}#{redirect}")
    html = Nokogiri::HTML(bh_response.body.to_s)
    action = html.css('form').first['action']
    saml_response = html.css('input[name="SAMLResponse"]').first['value']

    finish_saml = HTTP.cookies(cookies).post(action, form: { SAMLResponse: saml_response })
    cookies = finish_saml.cookies
    @bh_token = cookies.find { |c| c.name == 'acs' }.value

    token_response = HTTP.headers({ Authorization: "Bearer #{@bh_token}" }).get("#{APIS[:bright_horizons]}/account/token2")
    @tadpole_token = token_response.parse(:json)['token']

    tadpole_login = HTTP.get("#{APIS[:tadpole]}/auth/jwt/redirect", params: { jwt: @tadpole_token })
    @tadpole_cookies = tadpole_login.cookies

    nil
  end

  def children
    @children ||= get(:bright_horizons, '/home/mychildren', {})['children']
  end

  def events(date)
    debug "Fetching events from #{date.to_time.utc.to_i} to #{(date + 1).to_time.utc.to_i}"
    page_size = 1000
    data = get(
      :tadpole,
      '/remote/v1/events',
      client: 'dashboard',
      direction: 'range',
      num_events: page_size, # They send this param but don't appear to use it
      earliest_event_time: date.to_time.utc.to_i,
      latest_event_time: (date + 1).to_time.utc.to_i
    )
    all_events = data['events']
    debug "Found #{all_events.size} events"
    all_events
  end

  def get(api, path, params)
    token, cookies = api == :bright_horizons ? [bh_token, {}] : [tadpole_token, tadpole_cookies]
    response = HTTP.cookies(cookies)
                   .get("#{APIS[api]}#{path}",
                        headers: { Authorization: "Bearer #{token}" },
                        params: params)
    response.parse(:json)
  end
end
leocov-dev commented 6 months ago

Thank you @kristjan for figuring this out and providing an example! It should be enough for me to update the login flow and get a branch out for testing.

leocov-dev commented 6 months ago

@kristjan after you get a tadpoles-token from calling https://mbdwgateway.brighthorizons.com/api//account/token2 are you able to call:

POST https://mybrightday.brighthorizons.com/api/v2/auth/jwt/validate
Content-Type: application/x-www-form-urlencoded

token=<tadpoles-token>

to get a bh-api-key and then call the v2 api endpoint such as:

GET https://mybrightday.brighthorizons.com/api/v2/user/profile
Accept: application/json
X-Api-Key: <bh-api-key>

I'd like to figure out how much modification i'll need to do besides the login flow.

kristjan commented 5 months ago

I think they've replaced /jwt/validate with /jwt/redirect. /use/profile returns Unauthorized, but their site is currently throwing a 500 so I can't see what that might have been changed to 😬 I'll try to poke at again when they come back up.

Other mybrightday.brighthorizons.com URLs like /remote/v1/events are working.

leocov-dev commented 5 months ago

I updated the branch: bright-horizons-new-login-flow / PR with changes to start to bring things in line with @kristjan ruby example.

I have no way to test this since I don't have a BH account so unless someone steps up to push this forward I think this is stuck. The contents of internal/api/bright_horizons/provider.go is what needs to be figured out.

I started writing some tests for parts that parse HTML here: internal/api/bright_horizons/login_test.go, but although these tests pass I don't know what the data actually looks like so it's not currently a valid test.

mvictoras commented 4 weeks ago

I tested it, and I am getting the following error: Cmd Error : error finding SAML response

mvictoras commented 3 weeks ago

More info:

DEBU[0003] Login...                                     
DEBU[0004] Login successful, Admit...                   
Cmd Error         : error finding SAML response

Even if the username/password is random characters

mvictoras commented 3 weeks ago

I debugged a little bit, and I think the problem is with the cookies.