Skip to content

Commit f5ea2c5

Browse files
committed
add redis authenticator, split authn out
1 parent 9ed792a commit f5ea2c5

7 files changed

Lines changed: 300 additions & 83 deletions

File tree

auth.go

Lines changed: 11 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
package irdata
22

33
import (
4-
"bytes"
54
"crypto/sha256"
65
"encoding/base64"
7-
"encoding/json"
86
"errors"
97
"fmt"
10-
"io"
118
"net/http"
129
"net/http/cookiejar"
1310
"strings"
@@ -67,6 +64,10 @@ func Login(email string, password string, options ...Options) (IRData, error) {
6764
},
6865
}
6966

67+
data.authenticator = &DefaultAuthenticator{
68+
irdata: data,
69+
}
70+
7071
for _, option := range options {
7172
err := option.Apply(data)
7273
if err != nil {
@@ -79,97 +80,26 @@ func Login(email string, password string, options ...Options) (IRData, error) {
7980
}
8081
}
8182

82-
err := data.Authenticate()
83+
err := data.Authenticate(false)
8384
if err != nil {
8485
return nil, err
8586
}
8687

8788
return data, nil
8889
}
8990

90-
func (d *irdata) Authenticate() error {
91-
/**
92-
* data := url.Values{}
93-
* data.Set("username", d.email)
94-
* data.Set("password", d.passwordHash)
95-
* resp, err := d.client.Post(fmt.Sprintf("%s/Login", d.membersUrl), "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
96-
* if err != nil {
97-
* return &ConfigurationError{Msg: "unable to make request", Trigger: err}
98-
* }
99-
*/
100-
101-
requestBody := AuthRequest{
102-
Email: d.email,
103-
Password: d.passwordHash,
104-
}
105-
106-
body, err := json.Marshal(requestBody)
107-
if err != nil {
108-
return &ConfigurationError{Msg: "unable to marshal request body", Trigger: err}
109-
}
110-
111-
bodyReader := bytes.NewReader(body)
112-
113-
resp, err := d.client.Post(fmt.Sprintf("%s/auth", d.membersUrl), "application/json", bodyReader)
91+
func (d *irdata) Authenticate(force bool) error {
92+
cookieUrl, cookies, expiration, err := d.authenticator.Authenticate(d.email, d.passwordHash, force)
11493
if err != nil {
115-
return &ConfigurationError{Msg: "unable to make request", Trigger: err}
116-
}
117-
118-
defer resp.Body.Close()
119-
120-
if err != nil {
121-
return &ConfigurationError{Msg: "unable to read authentication response", Trigger: err}
122-
}
123-
124-
err = d.RateLimit().update(resp)
125-
if err != nil {
126-
return &ConfigurationError{Msg: "unable to update rate limit", Trigger: err}
127-
}
128-
129-
if resp.StatusCode == 401 {
130-
return &AuthenticationError{Msg: "invalid credentials"}
131-
} else if resp.StatusCode == 503 {
132-
return &ServiceUnavailableError{Msg: "service unavailable"}
133-
} else if resp.StatusCode == 209 {
134-
return &RateLimitExceededError{Msg: "too many requests"}
135-
} else if resp.StatusCode != 200 {
136-
return &ServiceUnavailableError{Msg: "unexpected error", Trigger: errors.New(resp.Status)}
137-
}
138-
139-
r, err := io.ReadAll(resp.Body)
140-
if err != nil {
141-
return &ConfigurationError{Msg: "unable to read authentication response body", Trigger: err}
142-
}
143-
144-
responseBody := AuthResponse{}
145-
err = json.Unmarshal(r, &responseBody)
146-
if err != nil {
147-
return &ConfigurationError{Msg: "unable to unmarshal authentication response body", Trigger: err}
148-
}
149-
150-
if responseBody.AuthCode == 0 || responseBody.AuthCode == float64(0) {
151-
return &AuthenticationError{Msg: "authentication failed", Trigger: errors.New(responseBody.Message)}
152-
}
153-
154-
membersCookie := false
155-
d.cookies = resp.Cookies()
156-
for _, cookie := range d.cookies {
157-
if cookie.Name == "authtoken_members" {
158-
d.expiration = cookie.Expires
159-
membersCookie = true
160-
break
161-
}
94+
return err
16295
}
16396

97+
d.cookies = cookies
98+
d.expiration = expiration
16499
if d.client.Jar == nil {
165100
d.client.Jar, _ = cookiejar.New(&cookiejar.Options{})
166101
}
167102

168-
d.client.Jar.SetCookies(resp.Request.URL, d.cookies)
169-
170-
if !membersCookie {
171-
return &AuthenticationError{Msg: "unable to find 'authtoken_members' cookie"}
172-
}
173-
103+
d.client.Jar.SetCookies(cookieUrl, d.cookies)
174104
return nil
175105
}

authenticator.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package irdata
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
)
13+
14+
type Authenticator interface {
15+
Authenticate(username string, password string, force bool) (*url.URL, []*http.Cookie, time.Time, error)
16+
}
17+
18+
type DefaultAuthenticator struct {
19+
Authenticator
20+
21+
irdata IRData
22+
}
23+
24+
func NewDefaultAuthenticator(irdata IRData) (*DefaultAuthenticator, error) {
25+
if irdata == nil {
26+
return nil, errors.New("irdata is nil")
27+
}
28+
29+
return &DefaultAuthenticator{
30+
irdata: irdata,
31+
}, nil
32+
}
33+
34+
func (a *DefaultAuthenticator) Authenticate(username string, password string, _ bool) (*url.URL, []*http.Cookie, time.Time, error) {
35+
/**
36+
* data := url.Values{}
37+
* data.Set("username", d.email)
38+
* data.Set("password", d.passwordHash)
39+
* resp, err := d.client.Post(fmt.Sprintf("%s/Login", d.membersUrl), "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
40+
* if err != nil {
41+
* return &ConfigurationError{Msg: "unable to make request", Trigger: err}
42+
* }
43+
*/
44+
45+
requestBody := AuthRequest{
46+
Email: username,
47+
Password: password,
48+
}
49+
50+
body, err := json.Marshal(requestBody)
51+
if err != nil {
52+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to marshal request body", Trigger: err}
53+
}
54+
55+
bodyReader := bytes.NewReader(body)
56+
57+
resp, err := a.irdata.HttpClient().Post(fmt.Sprintf("%s/auth", a.irdata.MembersUrl()), "application/json", bodyReader)
58+
if err != nil {
59+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to make request", Trigger: err}
60+
}
61+
62+
defer resp.Body.Close()
63+
64+
if err != nil {
65+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to read authentication response", Trigger: err}
66+
}
67+
68+
err = a.irdata.RateLimit().update(resp)
69+
if err != nil {
70+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to update rate limit", Trigger: err}
71+
}
72+
73+
if resp.StatusCode == 401 {
74+
return nil, nil, time.Time{}, &AuthenticationError{Msg: "invalid credentials"}
75+
} else if resp.StatusCode == 503 {
76+
return nil, nil, time.Time{}, &ServiceUnavailableError{Msg: "service unavailable"}
77+
} else if resp.StatusCode == 209 {
78+
return nil, nil, time.Time{}, &RateLimitExceededError{Msg: "too many requests"}
79+
} else if resp.StatusCode != 200 {
80+
return nil, nil, time.Time{}, &ServiceUnavailableError{Msg: "unexpected error", Trigger: errors.New(resp.Status)}
81+
}
82+
83+
r, err := io.ReadAll(resp.Body)
84+
if err != nil {
85+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to read authentication response body", Trigger: err}
86+
}
87+
88+
responseBody := AuthResponse{}
89+
err = json.Unmarshal(r, &responseBody)
90+
if err != nil {
91+
return nil, nil, time.Time{}, &ConfigurationError{Msg: "unable to unmarshal authentication response body", Trigger: err}
92+
}
93+
94+
if responseBody.AuthCode == 0 || responseBody.AuthCode == float64(0) {
95+
return nil, nil, time.Time{}, &AuthenticationError{Msg: "authentication failed", Trigger: errors.New(responseBody.Message)}
96+
}
97+
98+
expiresAt := time.Now()
99+
membersCookie := false
100+
cookies := resp.Cookies()
101+
for _, cookie := range cookies {
102+
if cookie.Name == "authtoken_members" {
103+
membersCookie = true
104+
expiresAt = cookie.Expires
105+
break
106+
}
107+
}
108+
109+
if !membersCookie {
110+
return nil, nil, time.Time{}, &AuthenticationError{Msg: "unable to find 'authtoken_members' cookie"}
111+
}
112+
113+
return resp.Request.URL, cookies, expiresAt, nil
114+
}

authenticator_redis.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package irdata
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/sha512"
7+
"encoding/gob"
8+
"errors"
9+
"fmt"
10+
"github.com/redis/go-redis/v9"
11+
"log/slog"
12+
"net/http"
13+
"net/url"
14+
"time"
15+
)
16+
17+
type RedisAuthenticator struct {
18+
Authenticator
19+
20+
redis redis.Cmdable
21+
authenticator Authenticator
22+
23+
Logger Logger
24+
KeyPrefix string
25+
LocalCacheDuration time.Duration
26+
27+
localCache map[string]*AuthenticationResult
28+
}
29+
30+
func NewRedisDefaultAuthenticator(redis redis.Cmdable, irdata IRData) (*RedisAuthenticator, error) {
31+
authenticator, err := NewDefaultAuthenticator(irdata)
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
return NewRedisAuthenticator(redis, authenticator)
37+
}
38+
39+
func NewRedisAuthenticator(redis redis.Cmdable, authenticator Authenticator) (*RedisAuthenticator, error) {
40+
if redis == nil {
41+
return nil, errors.New("redis is nil")
42+
} else if authenticator == nil {
43+
return nil, errors.New("authenticator is nil")
44+
}
45+
46+
return &RedisAuthenticator{
47+
redis: redis,
48+
authenticator: authenticator,
49+
Logger: slog.Default(),
50+
KeyPrefix: "iracing:irdata:",
51+
LocalCacheDuration: time.Minute,
52+
localCache: make(map[string]*AuthenticationResult),
53+
}, nil
54+
}
55+
56+
type AuthenticationResult struct {
57+
Username string
58+
PasswordChecksum string
59+
URL *url.URL
60+
Cookies []*http.Cookie
61+
Expiration time.Time
62+
63+
lastUpdated time.Time
64+
}
65+
66+
func init() {
67+
gob.Register(AuthenticationResult{})
68+
}
69+
70+
func (a *RedisAuthenticator) Authenticate(username string, password string, _ bool) (*url.URL, []*http.Cookie, time.Time, error) {
71+
ctx, cancel := context.WithCancel(context.Background())
72+
defer cancel()
73+
74+
hasher := sha512.New()
75+
hasher.Write([]byte(password))
76+
hashedPassword := fmt.Sprintf("%032x", hasher.Sum(nil))
77+
78+
key := fmt.Sprintf("%s%s-%s", a.KeyPrefix, username, hashedPassword)
79+
existing, ok := a.localCache[key]
80+
if ok && existing.lastUpdated.Before(time.Now().Add(-a.LocalCacheDuration)) {
81+
return existing.URL, existing.Cookies, existing.Expiration, nil
82+
} else if ok {
83+
delete(a.localCache, key)
84+
}
85+
86+
existingResp := a.redis.Get(ctx, key)
87+
if existingResp == nil || existingResp.Err() != nil {
88+
if existingResp == nil || !errors.Is(existingResp.Err(), redis.Nil) {
89+
var err error
90+
if existingResp == nil {
91+
err = errors.New("no response from redis")
92+
} else {
93+
err = fmt.Errorf("unexpected response from redis: %s", existingResp.Err())
94+
}
95+
96+
a.Logger.Warn("unable to retrieve cached result from redis", "error", err)
97+
}
98+
99+
return a.authenticate(username, password, hashedPassword, key)
100+
}
101+
102+
result := &AuthenticationResult{}
103+
decoder := gob.NewDecoder(bytes.NewBuffer([]byte(existingResp.Val())))
104+
err := decoder.Decode(result)
105+
if err != nil {
106+
a.Logger.Warn("unable to decode cached result from redis", "error", err)
107+
return a.authenticate(username, password, hashedPassword, key)
108+
}
109+
110+
result.lastUpdated = time.Now()
111+
a.localCache[key] = result
112+
return result.URL, result.Cookies, result.Expiration, nil
113+
}
114+
115+
func (a *RedisAuthenticator) authenticate(username, password, hashedPassword, key string) (*url.URL, []*http.Cookie, time.Time, error) {
116+
cookieUrl, cookies, expiration, err := a.authenticator.Authenticate(username, password, true)
117+
if err != nil {
118+
return nil, nil, time.Time{}, err
119+
}
120+
121+
result := &AuthenticationResult{
122+
Username: username,
123+
PasswordChecksum: hashedPassword,
124+
URL: cookieUrl,
125+
Cookies: cookies,
126+
Expiration: expiration,
127+
lastUpdated: time.Now(),
128+
}
129+
130+
a.localCache[key] = result
131+
132+
buf := new(bytes.Buffer)
133+
err = gob.NewEncoder(buf).Encode(result)
134+
if err != nil {
135+
a.Logger.Warn("unable to encode cached result for redis", "error", err)
136+
return cookieUrl, cookies, expiration, nil
137+
}
138+
139+
setResp := a.redis.Set(context.Background(), key, buf.Bytes(), time.Until(result.Expiration))
140+
if setResp == nil || setResp.Err() != nil {
141+
a.Logger.Warn("unable to set cached result in redis", "error", err)
142+
}
143+
144+
return cookieUrl, cookies, expiration, nil
145+
}

0 commit comments

Comments
 (0)