|
| 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 | +} |
0 commit comments