sorare/graphql/authentication.go

173 lines
4.1 KiB
Go

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
}