package graphql import ( "context" "encoding/json" "net/http" "time" "github.com/pkg/errors" "github.com/xlzd/gotp" "git.lehouerou.net/laurent/sorare/bcrypt" ) const bCryptCost = 11 func (c *Client) getSaltFromEmail(ctx context.Context, email string) (string, error) { var response struct { Salt string `json:"salt"` } req, err := http.NewRequestWithContext( ctx, http.MethodGet, "https://api.sorare.com/api/v1/users/"+email, nil, ) if err != nil { return "", errors.Wrap(err, "creating request") } resp, err := c.httpClient.Do(req) if err != nil { return "", errors.Wrap(err, "executing request") } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errors.Errorf("executing request failed with status code %d", resp.StatusCode) } err = json.NewDecoder(resp.Body).Decode(&response) if err != nil { return "", errors.Wrap(err, "decoding response") } return response.Salt[7:], nil } func hashPasswordWithSalt(password, salt string) (string, error) { hashed, err := bcrypt.GenerateFromPasswordAndSalt([]byte(password), []byte(salt), bCryptCost) if err != nil { return "", err } return string(hashed), nil } type JwtToken struct { Token string ExpiredAt time.Time } type SignIn struct { CurrentUser struct { Slug string JwtToken JwtToken `graphql:"jwtToken(aud: $aud)"` } `graphql:"currentUser"` OtpSessionChallenge string Errors []struct { Message string } `graphql:"errors"` } type signInInput struct { Email string `json:"email,omitempty"` Password string `json:"password,omitempty"` OtpSessionChallenge string `json:"otpSessionChallenge,omitempty"` OtpAttempt string `json:"otpAttempt,omitempty"` } type SignInParams struct { Input signInInput `graphql:"input"` } type SignInMutation struct { *Mutation[SignIn, SignInParams] } func NewSignInMutation(c *Client, audience string) *SignInMutation { return &SignInMutation{ NewMutation[SignIn, SignInParams](c, "signIn").WithQueryParam("aud", audience), } } func (c *Client) getNewToken( ctx context.Context, email string, password string, audience string, otp string, ) (JwtToken, error) { salt, err := c.getSaltFromEmail(ctx, email) if err != nil { return JwtToken{}, errors.Wrap(err, "getting salt from email") } hashed, err := hashPasswordWithSalt(password, salt) if err != nil { return JwtToken{}, errors.Wrap(err, "hashing password with salt") } signInMutation := NewSignInMutation(c, audience) res, err := signInMutation.Execute(ctx, SignInParams{ Input: signInInput{ Email: email, Password: hashed, }, }) if err != nil { return JwtToken{}, errors.Wrap(err, "executing signin mutation with email and password") } if len(res.Errors) == 0 { if res.CurrentUser.JwtToken.Token == "" { return JwtToken{}, errors.New( "no errors & no jwt token returned on email/password mutation", ) } return res.CurrentUser.JwtToken, nil } if res.Errors[0].Message == "invalid" { return JwtToken{}, errors.New("invalid email or password") } if res.Errors[0].Message == "2fa_missing" { if otp == "" { return JwtToken{}, errors.New("2fa missing & no otp provided") } token := gotp.NewDefaultTOTP(otp).Now() signInMutation := NewSignInMutation(c, audience) resOtp, err := signInMutation.Execute(ctx, SignInParams{ Input: signInInput{ OtpSessionChallenge: res.OtpSessionChallenge, OtpAttempt: token, }, }) if err != nil { return JwtToken{}, err } if len(resOtp.Errors) == 0 { if resOtp.CurrentUser.JwtToken.Token == "" { return JwtToken{}, errors.New("no errors & no jwt token returned on otp mutation") } return resOtp.CurrentUser.JwtToken, nil } return JwtToken{}, errors.Errorf("validating otp: %s", resOtp.Errors[0].Message) } return JwtToken{}, errors.Errorf("unmanaged error: %s", res.Errors[0].Message) } func (c *Client) Authenticate( ctx context.Context, email string, password string, audience string, otp string, ) error { token, err := c.getNewToken(ctx, email, password, audience, otp) if err != nil { return errors.Wrap(err, "getting new jwt token") } c.SetJWTToken(token, audience) return nil }