Skip to content

Commit ad60a9b

Browse files
h0tak88rclaude
andcommitted
feat(ywh): auto-reauth from email+password+2FA to refresh expiring JWTs
YesWeHack JWTs expire fast, so a stored token goes invalid quickly. Store the login credentials and re-authenticate automatically instead: - db: bbp_accounts.totp_secret column (both backends + migration) + BBPAccount field; UpdateBBPAccountToken to persist a refreshed JWT. - accounts: Account.TOTPSecret, populated from the DB row and YWH_TOTP_SECRET env. - native YWH login flow (programs_ywh_auth.go): POST /login → if 2FA, generate the TOTP code from the stored base32 secret (RFC 6238, verified against the spec's test vectors) → POST /account/totp → JWT. Never log.Fatal-crashes; throttled to one login attempt per email per 20s to avoid tripping anti-bot. - fetch: fetchYWHPrograms mints a JWT when none is stored, and on a 401 re-auths and retries once, persisting the fresh JWT so the next run reuses it. - validity check: YWH 'Test' re-auths when the JWT is dead, so the tag reflects whether email/password(/2FA) can get a session. - API + UI: accounts endpoint accepts/masks totp_secret; Settings YWH form gains a '2FA secret' field; section note explains the auto-refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e5695d3 commit ad60a9b

11 files changed

Lines changed: 341 additions & 70 deletions

File tree

internal/accounts/accounts.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ import (
1414

1515
// Account is one resolved platform account ready to authenticate with.
1616
type Account struct {
17-
ID int64 `json:"id"`
18-
Platform string `json:"platform"`
19-
Label string `json:"label"`
20-
Username string `json:"username"`
21-
Token string `json:"token"`
22-
Email string `json:"email"`
23-
Password string `json:"password"`
24-
Source string `json:"source"` // "db" or "env"
17+
ID int64 `json:"id"`
18+
Platform string `json:"platform"`
19+
Label string `json:"label"`
20+
Username string `json:"username"`
21+
Token string `json:"token"`
22+
Email string `json:"email"`
23+
Password string `json:"password"`
24+
TOTPSecret string `json:"totp_secret"` // base32 2FA seed (YWH auto-reauth)
25+
Source string `json:"source"` // "db" or "env"
2526
}
2627

2728
// Canonical maps platform aliases to the short codes used across AutoAR.
@@ -75,6 +76,7 @@ func envAccount(platform string) Account {
7576
a.Token = strings.TrimSpace(os.Getenv("YWH_TOKEN"))
7677
a.Email = strings.TrimSpace(os.Getenv("YWH_EMAIL"))
7778
a.Password = strings.TrimSpace(os.Getenv("YWH_PASSWORD"))
79+
a.TOTPSecret = strings.TrimSpace(os.Getenv("YWH_TOTP_SECRET"))
7880
}
7981
return a
8082
}
@@ -98,7 +100,8 @@ func For(platform string) []Account {
98100
}
99101
a := Account{
100102
ID: r.ID, Platform: p, Label: r.Label, Username: r.Username,
101-
Token: r.Token, Email: r.Email, Password: r.Password, Source: "db",
103+
Token: r.Token, Email: r.Email, Password: r.Password,
104+
TOTPSecret: r.TOTPSecret, Source: "db",
102105
}
103106
if !hasCreds(a) {
104107
continue

internal/api/accounts_api.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,27 +39,30 @@ func apiListBBPAccounts(c *gin.Context) {
3939
TokenSet bool `json:"token_set"`
4040
Email string `json:"email"`
4141
PasswordSet bool `json:"password_set"`
42+
TOTPSet bool `json:"totp_set"`
4243
Enabled bool `json:"enabled"`
4344
}
4445
out := make([]acctOut, 0, len(rows))
4546
for _, r := range rows {
4647
out = append(out, acctOut{
4748
ID: r.ID, Platform: r.Platform, Label: r.Label, Username: r.Username,
4849
TokenMask: maskSecret(r.Token), TokenSet: r.Token != "",
49-
Email: r.Email, PasswordSet: r.Password != "", Enabled: r.Enabled,
50+
Email: r.Email, PasswordSet: r.Password != "", TOTPSet: r.TOTPSecret != "",
51+
Enabled: r.Enabled,
5052
})
5153
}
5254
c.JSON(http.StatusOK, gin.H{"accounts": out})
5355
}
5456

5557
type bbpAccountBody struct {
56-
Platform string `json:"platform"`
57-
Label string `json:"label"`
58-
Username string `json:"username"`
59-
Token string `json:"token"`
60-
Email string `json:"email"`
61-
Password string `json:"password"`
62-
Enabled *bool `json:"enabled"`
58+
Platform string `json:"platform"`
59+
Label string `json:"label"`
60+
Username string `json:"username"`
61+
Token string `json:"token"`
62+
Email string `json:"email"`
63+
Password string `json:"password"`
64+
TOTPSecret string `json:"totp_secret"`
65+
Enabled *bool `json:"enabled"`
6366
}
6467

6568
// POST /api/accounts — create or update an account (keyed by platform+label).
@@ -89,8 +92,9 @@ func apiUpsertBBPAccount(c *gin.Context) {
8992
}
9093
token := strings.TrimSpace(b.Token)
9194
password := b.Password
95+
totpSecret := strings.TrimSpace(b.TOTPSecret)
9296
// Preserve stored secrets when the body omits them (masked-edit case).
93-
if token == "" || password == "" {
97+
if token == "" || password == "" || totpSecret == "" {
9498
if existing, err := db.ListBBPAccounts(p); err == nil {
9599
for _, e := range existing {
96100
if e.Label == label {
@@ -100,6 +104,9 @@ func apiUpsertBBPAccount(c *gin.Context) {
100104
if password == "" {
101105
password = e.Password
102106
}
107+
if totpSecret == "" {
108+
totpSecret = e.TOTPSecret
109+
}
103110
break
104111
}
105112
}
@@ -112,7 +119,8 @@ func apiUpsertBBPAccount(c *gin.Context) {
112119

113120
a := db.BBPAccount{
114121
Platform: p, Label: label, Username: strings.TrimSpace(b.Username),
115-
Token: token, Email: strings.TrimSpace(b.Email), Password: password, Enabled: enabled,
122+
Token: token, Email: strings.TrimSpace(b.Email), Password: password,
123+
TOTPSecret: totpSecret, Enabled: enabled,
116124
}
117125
id, err := db.UpsertBBPAccount(a)
118126
if err != nil {

internal/api/accounts_check.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,33 @@ func checkAccountCredential(a db.BBPAccount) (status, detail string) {
8282
return interpretAPI(client.Do(req))
8383

8484
case "ywh":
85-
if a.Token == "" {
86-
return "invalid", "missing JWT token"
87-
}
8885
// /user is auth-gated (401 without a valid JWT); the /programs list is public
8986
// so it can't distinguish a good token from a bad one.
90-
req, _ := http.NewRequest("GET", "https://api.yeswehack.com/user", nil)
91-
req.Header.Set("Accept", "application/json")
92-
req.Header.Set("Authorization", "Bearer "+a.Token)
93-
return interpretAPI(client.Do(req))
87+
if a.Token != "" {
88+
req, _ := http.NewRequest("GET", "https://api.yeswehack.com/user", nil)
89+
req.Header.Set("Accept", "application/json")
90+
req.Header.Set("Authorization", "Bearer "+a.Token)
91+
if status, detail := interpretAPI(client.Do(req)); status == "valid" {
92+
return status, detail
93+
}
94+
}
95+
// JWT missing/expired — try re-authenticating with the stored credentials
96+
// (email + password + optional 2FA). On success, persist the fresh JWT so the
97+
// tag goes green and future fetches reuse it.
98+
if a.Email != "" && a.Password != "" {
99+
jwt, rerr := ywhReauth(a.Email, a.Password, a.TOTPSecret)
100+
if rerr != nil {
101+
return "invalid", "re-auth failed: "+trimErr(rerr.Error())
102+
}
103+
if a.ID > 0 {
104+
_ = db.UpdateBBPAccountToken(a.ID, jwt)
105+
}
106+
return "valid", "re-authenticated via email/password"
107+
}
108+
if a.Token == "" {
109+
return "invalid", "no token, and no email/password to re-auth"
110+
}
111+
return "invalid", "JWT expired — add email/password (+2FA) to auto-refresh"
94112

95113
case "bc":
96114
if a.Token == "" {

internal/api/programs_ywh.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/h0tak88r/AutoAR/internal/accounts"
13+
"github.com/h0tak88r/AutoAR/internal/db"
1314
)
1415

1516
// fetchYWHPrograms fetches YesWeHack programs (with in-scope assets) across every
@@ -28,10 +29,32 @@ func fetchYWHPrograms(bbpOnly, includeScope bool) ([]ProgramSummary, error) {
2829
idx := map[string]int{}
2930
var firstErr error
3031
for _, a := range accts {
31-
if a.Token == "" {
32+
token := a.Token
33+
hasCreds := a.Email != "" && a.Password != ""
34+
35+
// No stored token but we have login creds → mint a fresh JWT now.
36+
if token == "" && hasCreds {
37+
if jwt, err := ywhReauth(a.Email, a.Password, a.TOTPSecret); err == nil {
38+
token = jwt
39+
ywhPersistToken(a, jwt)
40+
} else if firstErr == nil {
41+
firstErr = err
42+
}
43+
}
44+
if token == "" {
3245
continue
3346
}
34-
progs, err := fetchYWHProgramsWithToken(a.Token, bbpOnly, includeScope)
47+
48+
progs, err := fetchYWHProgramsWithToken(token, bbpOnly, includeScope)
49+
// Stored JWT expired (401) → re-authenticate with the stored creds and retry
50+
// once, persisting the fresh token so the next run reuses it.
51+
if err != nil && isYWHUnauthorized(err) && hasCreds {
52+
if jwt, rerr := ywhReauth(a.Email, a.Password, a.TOTPSecret); rerr == nil {
53+
token = jwt
54+
ywhPersistToken(a, jwt)
55+
progs, err = fetchYWHProgramsWithToken(token, bbpOnly, includeScope)
56+
}
57+
}
3558
if err != nil {
3659
if firstErr == nil {
3760
firstErr = err
@@ -46,6 +69,18 @@ func fetchYWHPrograms(bbpOnly, includeScope bool) ([]ProgramSummary, error) {
4669
return merged, nil
4770
}
4871

72+
func isYWHUnauthorized(err error) bool {
73+
return err != nil && strings.Contains(err.Error(), "(401)")
74+
}
75+
76+
// ywhPersistToken saves a freshly-minted JWT back to a DB account so subsequent
77+
// fetches reuse it instead of logging in again.
78+
func ywhPersistToken(a accounts.Account, jwt string) {
79+
if a.ID > 0 && jwt != "" {
80+
_ = db.UpdateBBPAccountToken(a.ID, jwt)
81+
}
82+
}
83+
4984
type ywhListItem struct {
5085
Slug string `json:"slug"`
5186
Title string `json:"title"`

internal/api/programs_ywh_auth.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package api
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha1"
6+
"encoding/base32"
7+
"encoding/binary"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"strings"
13+
"sync"
14+
"time"
15+
)
16+
17+
// generateTOTP returns the current 6-digit TOTP code for a base32 secret
18+
// (RFC 6238: HMAC-SHA1, 30-second step, 6 digits) — the same algorithm Google
19+
// Authenticator and YesWeHack 2FA use. The secret is the base32 "seed" shown when
20+
// setting up 2FA, NOT a one-time code.
21+
func generateTOTP(secret string) (string, error) { return totpAt(secret, time.Now()) }
22+
23+
func totpAt(secret string, t time.Time) (string, error) {
24+
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
25+
if s == "" {
26+
return "", fmt.Errorf("empty TOTP secret")
27+
}
28+
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
29+
if err != nil {
30+
if key, err = base32.StdEncoding.DecodeString(s); err != nil {
31+
return "", fmt.Errorf("invalid base32 TOTP secret: %w", err)
32+
}
33+
}
34+
counter := uint64(t.Unix() / 30)
35+
var buf [8]byte
36+
binary.BigEndian.PutUint64(buf[:], counter)
37+
mac := hmac.New(sha1.New, key)
38+
mac.Write(buf[:])
39+
sum := mac.Sum(nil)
40+
off := sum[len(sum)-1] & 0x0f
41+
code := (uint32(sum[off]&0x7f) << 24) | (uint32(sum[off+1]) << 16) | (uint32(sum[off+2]) << 8) | uint32(sum[off+3])
42+
return fmt.Sprintf("%06d", code%1000000), nil
43+
}
44+
45+
// ywhReauthThrottle prevents a re-login storm: at most one login attempt per
46+
// email per window, so many concurrent/expired-token fetches don't hammer YWH's
47+
// /login (which could trip anti-bot / rate limits).
48+
var (
49+
ywhReauthMu sync.Mutex
50+
ywhReauthLastTry = map[string]time.Time{}
51+
ywhReauthMinAfter = 20 * time.Second
52+
)
53+
54+
func ywhReauthAllowed(email string) bool {
55+
ywhReauthMu.Lock()
56+
defer ywhReauthMu.Unlock()
57+
if last, ok := ywhReauthLastTry[email]; ok && time.Since(last) < ywhReauthMinAfter {
58+
return false
59+
}
60+
ywhReauthLastTry[email] = time.Now()
61+
return true
62+
}
63+
64+
// ywhReauth logs in to YesWeHack with email+password (and a generated TOTP code
65+
// if 2FA is enabled) and returns a fresh JWT. Native HTTP so a login failure only
66+
// returns an error — it never crashes the process the way bbscope's log.Fatal does.
67+
func ywhReauth(email, password, totpSecret string) (string, error) {
68+
if strings.TrimSpace(email) == "" || strings.TrimSpace(password) == "" {
69+
return "", fmt.Errorf("email and password required for YWH re-auth")
70+
}
71+
if !ywhReauthAllowed(email) {
72+
return "", fmt.Errorf("YWH re-auth throttled (retry shortly)")
73+
}
74+
client := &http.Client{Timeout: 30 * time.Second}
75+
76+
// Step 1: POST /login.
77+
loginBody := fmt.Sprintf(`{"email":%q,"password":%q}`, email, password)
78+
req, _ := http.NewRequest("POST", "https://api.yeswehack.com/login", strings.NewReader(loginBody))
79+
req.Header.Set("Content-Type", "application/json")
80+
req.Header.Set("Accept", "application/json")
81+
resp, err := client.Do(req)
82+
if err != nil {
83+
return "", fmt.Errorf("YWH login request: %w", err)
84+
}
85+
raw, _ := io.ReadAll(resp.Body)
86+
resp.Body.Close()
87+
if resp.StatusCode != http.StatusOK {
88+
return "", fmt.Errorf("YWH login failed (%d) — check email/password", resp.StatusCode)
89+
}
90+
var lr struct {
91+
Token string `json:"token"`
92+
TOTPToken string `json:"totp_token"`
93+
}
94+
if e := json.Unmarshal(raw, &lr); e != nil {
95+
return "", fmt.Errorf("YWH login parse: %w", e)
96+
}
97+
if lr.Token != "" {
98+
return lr.Token, nil // account has no 2FA
99+
}
100+
if lr.TOTPToken == "" {
101+
return "", fmt.Errorf("YWH login: neither token nor totp_token returned")
102+
}
103+
104+
// Step 2: 2FA — generate a code from the stored secret and confirm.
105+
if strings.TrimSpace(totpSecret) == "" {
106+
return "", fmt.Errorf("YWH account has 2FA enabled but no TOTP secret is stored")
107+
}
108+
code, err := generateTOTP(totpSecret)
109+
if err != nil {
110+
return "", err
111+
}
112+
totpBody := fmt.Sprintf(`{"token":%q,"code":%q}`, lr.TOTPToken, code)
113+
req2, _ := http.NewRequest("POST", "https://api.yeswehack.com/account/totp", strings.NewReader(totpBody))
114+
req2.Header.Set("Content-Type", "application/json")
115+
req2.Header.Set("Accept", "application/json")
116+
resp2, err := client.Do(req2)
117+
if err != nil {
118+
return "", fmt.Errorf("YWH 2FA request: %w", err)
119+
}
120+
raw2, _ := io.ReadAll(resp2.Body)
121+
resp2.Body.Close()
122+
if resp2.StatusCode != http.StatusOK {
123+
return "", fmt.Errorf("YWH 2FA verification failed (%d) — check the TOTP secret", resp2.StatusCode)
124+
}
125+
var tr struct {
126+
Token string `json:"token"`
127+
}
128+
if e := json.Unmarshal(raw2, &tr); e != nil || tr.Token == "" {
129+
return "", fmt.Errorf("YWH 2FA: no token in response")
130+
}
131+
return tr.Token, nil
132+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
// TestTOTPRFC6238 checks generateTOTP against the RFC 6238 SHA1 test vector:
9+
// ASCII secret "12345678901234567890" (base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ);
10+
// at Unix time 59 the 6-digit TOTP is 287082.
11+
func TestTOTPRFC6238(t *testing.T) {
12+
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
13+
cases := []struct {
14+
unix int64
15+
want string
16+
}{
17+
{59, "287082"},
18+
{1111111109, "081804"},
19+
{1111111111, "050471"},
20+
{2000000000, "279037"},
21+
}
22+
for _, c := range cases {
23+
got, err := totpAt(secret, time.Unix(c.unix, 0))
24+
if err != nil {
25+
t.Fatalf("totpAt(%d): %v", c.unix, err)
26+
}
27+
if got != c.want {
28+
t.Errorf("totpAt(%d) = %s, want %s", c.unix, got, c.want)
29+
}
30+
}
31+
// Lowercase + spaces (as often pasted from a 2FA setup screen) must still work.
32+
if _, err := generateTOTP("gezd gnbv gy3t qojq gezd gnbv gy3t qojq"); err != nil {
33+
t.Errorf("spaced/lowercase secret should parse: %v", err)
34+
}
35+
}

0 commit comments

Comments
 (0)