Implement authentication with auto token renewal
Add authentication methods to client.go: - Authenticate(ctx) for explicit login - AuthenticateWith2FA(ctx, token) for 2FA support - refreshToken(ctx) for token renewal - ensureAuthenticated(ctx) for auto-auth before requests - CurrentUser(ctx) to get logged-in user info - getToken() for thread-safe token access Features: - Token stored with expiry time (23h for safety margin) - Auto-refresh when token expires within 1 hour - Falls back to full authentication if refresh fails - Thread-safe token access using RWMutex - Sends token via X-Client-Token header API endpoints used: - POST /auth/login.json?version=2 - POST /auth/refresh_token.json?version=2 - GET /auth/curr_user.json Closes checkvist-api-lpn
This commit is contained in:
parent
90c48d9323
commit
b91e35a684
2 changed files with 175 additions and 1 deletions
174
client.go
174
client.go
|
|
@ -11,8 +11,14 @@
|
|||
package checkvist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -81,3 +87,171 @@ func NewClient(username, remoteKey string, opts ...Option) *Client {
|
|||
|
||||
return c
|
||||
}
|
||||
|
||||
// authResponse represents the response from the authentication endpoint.
|
||||
type authResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Authenticate performs explicit authentication with the Checkvist API.
|
||||
// This is optional - the client will automatically authenticate when needed.
|
||||
// Use this to verify credentials or to pre-authenticate before making requests.
|
||||
func (c *Client) Authenticate(ctx context.Context) error {
|
||||
return c.authenticate(ctx, "")
|
||||
}
|
||||
|
||||
// AuthenticateWith2FA performs authentication with a 2FA token.
|
||||
func (c *Client) AuthenticateWith2FA(ctx context.Context, twoFAToken string) error {
|
||||
return c.authenticate(ctx, twoFAToken)
|
||||
}
|
||||
|
||||
// authenticate performs the actual authentication request.
|
||||
func (c *Client) authenticate(ctx context.Context, twoFAToken string) error {
|
||||
data := url.Values{}
|
||||
data.Set("username", c.username)
|
||||
data.Set("remote_key", c.remoteKey)
|
||||
if twoFAToken != "" {
|
||||
data.Set("totp", twoFAToken)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/auth/login.json?version=2",
|
||||
strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating auth request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return NewAPIError(resp, string(body))
|
||||
}
|
||||
|
||||
var authResp authResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return fmt.Errorf("decoding auth response: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = authResp.Token
|
||||
// Token is valid for 1 day, but we refresh earlier to be safe
|
||||
c.tokenExp = time.Now().Add(23 * time.Hour)
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logger.Debug("authenticated successfully", "username", c.username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshToken renews the authentication token.
|
||||
func (c *Client) refreshToken(ctx context.Context) error {
|
||||
c.mu.RLock()
|
||||
currentToken := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
if currentToken == "" {
|
||||
return c.Authenticate(ctx)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("old_token", currentToken)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.baseURL+"/auth/refresh_token.json?version=2",
|
||||
strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating refresh request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// If refresh fails, try full authentication
|
||||
c.logger.Debug("token refresh failed, attempting full authentication")
|
||||
return c.Authenticate(ctx)
|
||||
}
|
||||
|
||||
var authResp authResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
|
||||
return fmt.Errorf("decoding refresh response: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.token = authResp.Token
|
||||
// Refreshed tokens can be valid for up to 90 days, but we refresh more frequently
|
||||
c.tokenExp = time.Now().Add(23 * time.Hour)
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logger.Debug("token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthenticated ensures the client has a valid authentication token.
|
||||
// This is called automatically before each API request.
|
||||
func (c *Client) ensureAuthenticated(ctx context.Context) error {
|
||||
c.mu.RLock()
|
||||
token := c.token
|
||||
tokenExp := c.tokenExp
|
||||
c.mu.RUnlock()
|
||||
|
||||
if token == "" {
|
||||
return c.Authenticate(ctx)
|
||||
}
|
||||
|
||||
// Refresh token if it will expire within the next hour
|
||||
if time.Now().Add(1 * time.Hour).After(tokenExp) {
|
||||
return c.refreshToken(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getToken returns the current authentication token.
|
||||
// Thread-safe.
|
||||
func (c *Client) getToken() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.token
|
||||
}
|
||||
|
||||
// CurrentUser returns information about the currently authenticated user.
|
||||
func (c *Client) CurrentUser(ctx context.Context) (*User, error) {
|
||||
if err := c.ensureAuthenticated(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
c.baseURL+"/auth/curr_user.json", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Client-Token", c.getToken())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, NewAPIError(resp, string(body))
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue