package ankersolix // A login has to outlive the plugin instance that made it. // // The manager builds a fresh, throwaway instance for every per-user health check // and every capability call (internal/plugins/manager.go: HealthCheckWith, // InvokeWith, InvokeBatchWith), so a token held in the instance dies with the // request that fetched it. Against Anker that is expensive: passport/login is // throttled per IP per minute and answers code 26161 ("Failed to request") once // tripped, five refusals in a row disable the account for ten minutes, and the // cloud has historically kept one token per account, so each login can evict the // one the mobile app is holding. Opening the panel — health, then the charger // list — was two logins; a page that also asked for OCPP info, three. // // Sessions therefore live in the package, keyed by the account being signed in // as, so every instance configured for that account shares one token and one // login backoff. import ( "crypto/sha256" "encoding/hex" "errors" "strings" "sync" "time" ) // session is one account's cloud state: the token it holds, and how long to // leave its login alone after a refusal. type session struct { mu sync.Mutex // guards tok and the backoff below, and serialises the login exchange tok *tokenInfo loginErr error loginRetryAt time.Time loginFails int // lastUse is touched and read under sessionsMu, never under mu. lastUse time.Time } // sessionIdle is how long an unused session is kept before being pruned — well // beyond a token's ~7-day life, so pruning never costs a login. const sessionIdle = 14 * 24 * time.Hour var ( sessionsMu sync.Mutex sessions = map[string]*session{} ) // sessionFor returns the shared session for one set of credentials, creating it // on first use. Different credentials — or the same account on the other // regional server — get their own. func sessionFor(apiBase, email, password string) *session { // The password decides which account this is, but is not kept in the key. sum := sha256.Sum256([]byte(password)) key := strings.Join([]string{ apiBase, strings.ToLower(strings.TrimSpace(email)), hex.EncodeToString(sum[:]), }, "\x00") sessionsMu.Lock() defer sessionsMu.Unlock() pruneSessionsLocked() s := sessions[key] if s == nil { s = &session{} sessions[key] = s } s.lastUse = time.Now() return s } // pruneSessionsLocked drops sessions nothing has used for sessionIdle, so a // long-lived server does not keep an entry for every credential it has ever // seen — an edited password leaves its old session behind. Caller holds // sessionsMu. func pruneSessionsLocked() { cutoff := time.Now().Add(-sessionIdle) for k, s := range sessions { if s.lastUse.Before(cutoff) { delete(sessions, k) } } } // noteLogin records the outcome of a login attempt and, on failure, how long to // leave the account alone: one Anker has already disabled gets the full penalty, // anything else backs off exponentially up to loginRetryMax. Caller holds s.mu. func (s *session) noteLogin(err error) { if err == nil { s.loginErr, s.loginRetryAt, s.loginFails = nil, time.Time{}, 0 return } s.loginFails++ wait := loginRetryBase << min(s.loginFails-1, 8) if wait > loginRetryMax { wait = loginRetryMax } var rejected *loginRejected if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait { wait = loginLockoutWait } s.loginRetryAt = time.Now().Add(wait) s.loginErr = &loginFailure{err: err, retryAt: s.loginRetryAt} }