From 0eed88c78345a576280ee80ead083087ff6efde5 Mon Sep 17 00:00:00 2001 From: Kristof Daja Date: Wed, 3 Aug 2022 22:00:31 +0200 Subject: [PATCH] Feature refresh token (#9) AuthService.RefreshAuthToken() implemented * GitHub Workflows - enabling workflows on feature and issue branches - stepping go version to 1.18 * basic evaluation added to `TestTokenRefreshRoutine` * CHANGELOG file added to the repo * verbose mode added to `Client` --- .github/workflows/go.yml | 4 +- .vscode/settings.example.json | 3 + CHANGELOG.md | 95 ++++++++++++++++++++++++++++ README.md | 14 +++++ auth.go | 25 +++++++- auth.models.go | 24 +++++++ client.go | 114 +++++++++++++++++++++++++++------- go.mod | 5 +- go.sum | 3 + tests/auth_test.go | 97 ++++++++++++++++++++++++++++- tests/init.go | 31 ++++++--- tests/milestones_test.go | 12 ++-- tests/taiga-docker | 2 +- users.models.go | 19 ------ 14 files changed, 383 insertions(+), 65 deletions(-) create mode 100644 .vscode/settings.example.json create mode 100644 CHANGELOG.md diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e4fc8a6..3144671 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,7 +2,7 @@ name: Go on: push: - branches: [ 'master', 'ci-docker-test' ] + branches: [ 'master', 'ci-docker-test', 'feature_*', 'issue_*' ] pull_request: branches: [ 'master', 'ci-docker-test' ] @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.15 + go-version: 1.18 - name: Build run: go build -v ./ diff --git a/.vscode/settings.example.json b/.vscode/settings.example.json new file mode 100644 index 0000000..02b7cd0 --- /dev/null +++ b/.vscode/settings.example.json @@ -0,0 +1,3 @@ +{ + "go.testFlags": ["-v"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..745ee27 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,95 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [1.5.0] - 2022-08-03 + +Taiga has changed its authentication system to a more sophisticated JWT implementation. This requires the user to refresh their token every 24 hours (default setting). If you're using Taigo in a system which tends to run for longer than 24 hours, such as, a webserver where your `taigo.Client` instance is preserved for days/weeks/months, then you need a way to keep your stored token fresh. + +Taigo gets this task done automatically by polling a ticker in a goroutine and refreshing the stored tokens every 12 hours. + +If you'd like to implement your own token refreshing mechanism, you have two options: +- implement your own routine based on `defaultTokenRefreshRoutine(c *Client, ticker *time.Ticker)` +- disable the `RefreshTokenRoutine` by calling `DisableAutomaticTokenRefresh()` and do the Token update your way (don't forget to update the contents of `Client.Headers`). + +### Added +- `AuthService.RefreshAuthToken()` implemented +- New fields added to `Client`: + - `AutoRefreshDisabled` + - `AutoRefreshTickerDuration` + - `TokenRefreshTicker` +- New methods added to `Client`: + - `DisableAutomaticTokenRefresh()` + +### Changed + +- MAJOR Changed the signature of `*Client.AuthByToken(tokenType, token, refreshToken string) error`. + It now requires `refreshToken` too. +- GitHub Workflows: Stepped go version to 1.18 +- GitHub Workflows: Tests enabled on `feature_*` and `issue_*` branches + +### Fixed + +- [TAIGO-8](https://github.com/theriverman/taigo/issues/8) + MAJOR Add missing Auth/Refresh auth token + +## [1.4.0] - 2021-02-28 + +### Added + +- Support for getting, creating and editing Taiga Issue objects +- Added custom attribute get/update example for user stories +- Added support for working easily in a specific project's scope via *ProjectService + +### Changed + +- TgObjectCustomAttributeValues are now exported and can be extended on-the-fly + +### Fixed + +- Struct members representing various agile points have been changed from regular int to float64 + +## [1.3.0] - 2020-10-04 + +### Added + +- Support for custom attribute handling +- Support for working easily in a specific project's scope via `*ProjectService` + +### Changed + +### Fixed + +## [1.2.2] - 2020-10-04 + +### Added + +- A simplified init was implemented + +### Changed + +- update `contribute/main.go` + +### Fixed + +## [1.1.0] - 2020-09-23 + +### Added + +### Changed + +- Models have been improved for better accuracy + +### Fixed + +## [1.0.0] - 2020-09-18 + +### Added + +- First version of the product + +### Changed + +### Fixed diff --git a/README.md b/README.md index 35173d6..38062e5 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Taiga is an Agile, Free and Open Source Project Management Tool. Should you have any ideas or recommendations, feel free to report it as an issue or open a pull request. +For the most recent changes, see [CHANGELOG](./CHANGELOG.md). + ## Known Limitations * Some model members holding integers (i.e. `backlog_order`) were implemented using `int` instead of `int64` or rather `uint64` in some cases which results in runtime errors on machines with **32-bit arm** CPUs. @@ -308,6 +310,17 @@ if err != nil { } ``` +# Logging & Verbose Mode +## Logging + +## Verbose Mode +Verbose mode can be enabled by setting the `Verbose` field to `true` in `taiga.Client`. For example: +```go +client := taiga.Client{ + Verbose: true +} +``` + # Contribution You're contribution would be much appreciated!
Feel free to open Issue tickets or create Pull requests.
@@ -319,6 +332,7 @@ For more details, see [TAIGO Contribution](CONTRIBUTION.md). ## Branching Strategy * **master** is always stable * **develop** is *not* always stable + * **feature_\*** branches are used for introducing larger changes # Licenses & Recognitions | Product | License | Author | diff --git a/auth.go b/auth.go index 3240d63..0b4f5af 100644 --- a/auth.go +++ b/auth.go @@ -1,7 +1,5 @@ package taigo -import "log" - // AuthService is a handle to actions related to Auths // // https://taigaio.github.io/taiga-doc/dist/api.html#auths @@ -11,6 +9,27 @@ type AuthService struct { Endpoint string } +// RefreshAuthToken => https://docs.taiga.io/api.html#auth-refresh +// +// Generates a new pair of bearer and refresh token +// If `selfUpdate` is true, `*Client` is refreshed with the returned token values +func (s *AuthService) RefreshAuthToken(selfUpdate bool) (RefreshResponse *RefreshToken, err error) { + url := s.client.MakeURL(s.Endpoint, "refresh") + data := RefreshToken{ + AuthToken: s.client.Token, + Refresh: s.client.RefreshToken, + } + _, err = s.client.Request.Post(url, &data, &RefreshResponse) + if err != nil { + return nil, err + } + if selfUpdate { + s.client.Token = RefreshResponse.AuthToken + s.client.RefreshToken = RefreshResponse.Refresh + } + return +} + // PublicRegistry => https://taigaio.github.io/taiga-doc/dist/api.html#auth-public-registry /* type with value "public" @@ -45,10 +64,10 @@ func (s *AuthService) login(credentials *Credentials) (*UserAuthenticationDetail _, err := s.client.Request.Post(url, &credentials, &u) if err != nil { - log.Println("Failed to authenticate to Taiga.") return nil, err } s.client.Token = u.AuthToken + s.client.RefreshToken = u.Refresh s.client.setToken() return &u, nil } diff --git a/auth.models.go b/auth.models.go index d22f847..f4f7a26 100644 --- a/auth.models.go +++ b/auth.models.go @@ -12,3 +12,27 @@ type Credentials struct { Token string `json:"token,omitempty"` AcceptedTerms bool `json:"accepted_terms,omitempty"` // Required for registration only } + +// UserAuthenticationDetail is a superset of User extended by an AuthToken field +type UserAuthenticationDetail struct { + AuthToken string `json:"auth_token"` + Refresh string `json:"refresh"` + User // Embedding type User struct +} + +// AsUser returns a *User from *UserAuthenticationDetail +// The AuthToken can be accessed from `User` via `.GetToken()` +func (u *UserAuthenticationDetail) AsUser() *User { + user := &User{} + err := convertStructViaJSON(u, user) + if err != nil { + return nil + } + user.authToken = u.AuthToken + return user +} + +type RefreshToken struct { + AuthToken string `json:"auth_token,omitempty"` + Refresh string `json:"refresh,omitempty"` +} diff --git a/client.go b/client.go index 32a4a4d..907a69a 100644 --- a/client.go +++ b/client.go @@ -2,9 +2,14 @@ package taigo import ( "fmt" + "log" "net/http" "strconv" "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // TokenBearer is the standard token type for authentication in Taiga @@ -17,18 +22,23 @@ const TokenApplication string = "Application" // Client is the session manager of Taiga Driver type Client struct { - Credentials *Credentials - APIURL string // set by system - APIversion string // default: "v1" - BaseURL string // i.e.: "http://taiga.test" | Same value as `api` in `taiga-front-dist/dist/conf.json` - Headers *http.Header // mostly set by system - HTTPClient *http.Client // set by user - Token string // set by system; can be set manually - TokenType string // default=Bearer; options:Bearer,Application - Self *User // User logged in - pagination *Pagination // Pagination details extracted from the LAST http response - paginationDisabled bool // indicates pagination status - isInitialised bool // indicates if taiga.Client has been initialised already + Credentials *Credentials + APIURL string // set by system + APIversion string // default: "v1" + BaseURL string // i.e.: "http://taiga.test" | Same value as `api` in `taiga-front-dist/dist/conf.json` + Headers *http.Header // mostly set by system + HTTPClient *http.Client // set by user + Token string // set by system; can be set manually + TokenType string // default=Bearer; options:Bearer,Application + RefreshToken string // set by system; can be set manually + RefreshTokenRoutine func(c *Client, ticker *time.Ticker) // routine periodically refreshing the token + Self *User // User logged in + pagination *Pagination // Pagination details extracted from the LAST http response + paginationDisabled bool // indicates pagination status + isInitialised bool // indicates if taiga.Client has been initialised already + Verbose bool // internal Taigo events are logged in a more verbose fashion + AutoRefreshDisabled bool // if true before initialisation, RefreshTokenRoutine never gets called + AutoRefreshTickerDuration time.Duration // time.Duration between two token refresh requests // Core Services Request *RequestService @@ -46,6 +56,10 @@ type Client struct { User *UserService Webhook *WebhookService Wiki *WikiService + + // Token Refresh Helpers + TokenRefreshTicker *time.Ticker + tokenRefreshDone chan bool } // MakeURL accepts an Endpoint URL and returns a compiled absolute URL @@ -92,16 +106,11 @@ func (c *Client) Initialise() error { // Bootstrapping Services c.Request = &RequestService{c} - - pServices := ProjectService{} - pServices.client = c - pServices.Endpoint = "projects" - c.Auth = &AuthService{c, 0, "auth"} c.Epic = &EpicService{c, 0, "epics"} c.Issue = &IssueService{c, 0, "issues"} c.Milestone = &MilestoneService{c, 0, "milestones"} - c.Project = &pServices + c.Project = &ProjectService{client: c, Endpoint: "projects"} c.Resolver = &ResolverService{c, 0, "resolver"} c.Stats = &StatsService{c, 0, "stats"} c.Task = &TaskService{c, 0, "tasks"} @@ -110,11 +119,45 @@ func (c *Client) Initialise() error { c.Webhook = &WebhookService{c, 0, "webhooks", "webhooklogs"} c.Wiki = &WikiService{c, 0, "wiki"} - // Final steps c.isInitialised = true + + // Token Refresh Routine + if c.AutoRefreshDisabled { + if c.Verbose { + log.Println("automatic token refresh subroutine will not be started because AutoRefreshDisabled = false") + } + return nil + } + if c.AutoRefreshTickerDuration == 0 { + /* + https://github.com/kaleidos-ventures/taiga-back/blob/0be90e6a661de51bf9e95744322060f33dafa347/taiga/auth/settings.py#L50 + According to the base settings in Taiga, the default `REFRESH_TOKEN_LIFETIME` is `timedelta(days=1)`. + Let's play safe, and take only half of that, so we refresh our tokens every 12 hours. + */ + c.AutoRefreshTickerDuration = 12 * time.Hour + } + if c.Verbose { + log.Printf("AutoRefreshTickerDuration: %s\n", c.AutoRefreshTickerDuration) + } + c.tokenRefreshDone = make(chan bool) + if c.TokenRefreshTicker == nil { + c.TokenRefreshTicker = time.NewTicker(c.AutoRefreshTickerDuration) + } + if c.RefreshTokenRoutine == nil { + c.RefreshTokenRoutine = defaultTokenRefreshRoutine + } + c.RefreshTokenRoutine(c, c.TokenRefreshTicker) // calling the Token Refresh Routine return nil } +func (c *Client) DisableAutomaticTokenRefresh() { + c.TokenRefreshTicker.Stop() + c.tokenRefreshDone <- true + if c.Verbose { + log.Println("automatic token refresh has been disabled") + } +} + // AuthByCredentials authenticates to Taiga using the provided basic credentials func (c *Client) AuthByCredentials(credentials *Credentials) error { if err := c.Initialise(); err != nil { @@ -135,12 +178,13 @@ func (c *Client) AuthByCredentials(credentials *Credentials) error { } // AuthByToken authenticates to Taiga using provided Token by requesting users/me -func (c *Client) AuthByToken(tokenType, token string) error { +func (c *Client) AuthByToken(tokenType, token, refreshToken string) error { if err := c.Initialise(); err != nil { return err } c.TokenType = tokenType c.Token = token + c.RefreshToken = refreshToken c.setToken() // Add to headers var err error @@ -153,9 +197,8 @@ func (c *Client) AuthByToken(tokenType, token string) error { // DisablePagination controls the value of header `x-disable-pagination`. func (c *Client) DisablePagination(b bool) { - var decision string = strings.Title(strconv.FormatBool(b)) m := map[string]string{ - "x-disable-pagination": decision, + "x-disable-pagination": cases.Title(language.BritishEnglish).String(strconv.FormatBool(b)), } c.LoadExternalHeaders(m) c.paginationDisabled = b @@ -183,6 +226,7 @@ func (c *Client) setContentTypeToJSON() { } func (c *Client) setToken() { + c.Headers.Del("Authorization") // avoid header duplication c.Headers.Add("Authorization", c.TokenType+" "+c.Token) } @@ -194,3 +238,29 @@ func (c *Client) loadHeaders(request *http.Request) { } } } + +func defaultTokenRefreshRoutine(c *Client, ticker *time.Ticker) { + go func() { + for { + select { + case <-c.tokenRefreshDone: + if c.Verbose { + log.Println("TokenRefreshRoutine has been stopped") + } + return + case t := <-ticker.C: + if c.Verbose { + log.Println("TokenRefreshRoutine tick at", t, "-> Refreshing the stored tokens") + } + if refreshData, err := c.Auth.RefreshAuthToken(true); err != nil { + log.Println(err) + return + } else { + c.Token = refreshData.AuthToken + c.RefreshToken = refreshData.Refresh + c.setToken() + } + } + } + }() +} diff --git a/go.mod b/go.mod index ce02530..995acba 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module github.com/theriverman/taigo go 1.14 -require github.com/google/go-querystring v1.0.0 +require ( + github.com/google/go-querystring v1.0.0 + golang.org/x/text v0.3.7 +) diff --git a/go.sum b/go.sum index 0bb18dc..19eac13 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,5 @@ github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/tests/auth_test.go b/tests/auth_test.go index a60a28f..faea075 100644 --- a/tests/auth_test.go +++ b/tests/auth_test.go @@ -1,7 +1,10 @@ package main import ( + "net/http" + "reflect" "testing" + "time" taiga "github.com/theriverman/taigo" ) @@ -11,7 +14,7 @@ func TestAuth(t *testing.T) { t.Cleanup(teardownClient) // Test Public Registry - randomString := randomString(12) + randomString := RandStringBytesMaskImprSrcUnsafe(12) username := "test_" + randomString fullName := "Taigo Test User" credentials := taiga.Credentials{ @@ -31,3 +34,95 @@ func TestAuth(t *testing.T) { } } + +func TestAuthService_RefreshAuthToken(t *testing.T) { + setupClient() + t.Cleanup(teardownClient) + + type fields struct { + client *taiga.Client + defaultProjectID int + Endpoint string + } + type args struct { + selfUpdate bool + } + tests := []struct { + name string + fields fields + args args + oldClientToken string + wantErr bool + }{ + { + name: "Succesful manual token refresh", + fields: fields{ + client: Client, + defaultProjectID: 0, + Endpoint: "auth", + }, + args: args{selfUpdate: true}, + oldClientToken: Client.Token, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Client.Auth.RefreshAuthToken(tt.args.selfUpdate) + if (err != nil) != tt.wantErr { + t.Errorf("AuthService.RefreshAuthToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + if reflect.DeepEqual(tt.oldClientToken, Client.Token) { + t.Errorf("Value of Client.Token did not change which means that .RefreshAuthToken(true) has failed") + t.Errorf("AuthService.RefreshAuthToken().AuthToken = %v, want different than %v", Client.Token, tt.oldClientToken) + } + }) + } +} + +func TestTokenRefreshRoutine(t *testing.T) { + // we need a custom client here to set `AutoRefreshTickerDuration` to 5 seconds + // otherwise the the test would fail b/c the default ticker duration is 12hrs + customWaitTime := 5 * time.Second + + client := &taiga.Client{ + BaseURL: testHostURL, + HTTPClient: &http.Client{}, + AutoRefreshTickerDuration: customWaitTime, + } + // Initialise client (authenticates to Taiga) + err := client.Initialise() + if err != nil { + panic(err) + } + err = client.AuthByCredentials(&taiga.Credentials{ + Type: "normal", + Username: testUsername, + Password: testPassword, + }) + if err != nil { + panic(err) + } + + testLoopLength := 5 // if you increase this, tests may fail due to timeout (usually 30s) + tokens := make(map[string]struct{}) + tokenCounter := 0 + + // Let's loop for 35 seconds to observe the routine working + for i := 0; i < testLoopLength; i++ { + if i == testLoopLength { + break + } + t.Logf("Loop %d | client.Token : %s\n", i, client.Token) + t.Logf("Loop %d | client.Refresh: %s\n", i, client.RefreshToken) + t.Logf("----------------------------\n") + tokens[client.Token] = struct{}{} + tokenCounter++ + time.Sleep(customWaitTime) + } + client = nil + if len(tokens) != tokenCounter { + t.Errorf("Total Unique Tokens: %d wanted unique tokens count: %d", len(tokens), tokenCounter) + } +} diff --git a/tests/init.go b/tests/init.go index 0a4c3cd..ceda823 100644 --- a/tests/init.go +++ b/tests/init.go @@ -4,6 +4,7 @@ import ( "math/rand" "net/http" "time" + "unsafe" taiga "github.com/theriverman/taigo" ) @@ -15,7 +16,12 @@ const testProjSlug string = "taigo-test" const testProjID int = 2 const testUserID int = 5 -const pool = "abcdefghijklmnopqrstuvwxyzABCEFGHIJKLMNOPQRSTUVWXYZ" +const ( + letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = src.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(letterBytes) { + b[i] = letterBytes[idx] + i-- + } + cache >>= letterIdxBits + remain-- } - return string(bytes) + return *(*string)(unsafe.Pointer(&b)) } diff --git a/tests/milestones_test.go b/tests/milestones_test.go index e1006b8..0b02d6e 100644 --- a/tests/milestones_test.go +++ b/tests/milestones_test.go @@ -11,7 +11,7 @@ func TestMilestones(t *testing.T) { t.Cleanup(teardownClient) // Create a milestone(sprint) - randomString := randomString(12) // we need unique milestone names + randomString := RandStringBytesMaskImprSrcUnsafe(12) // we need unique milestone names milestone, err := Client.Milestone.Create(&taiga.Milestone{ Name: "A test milestone_" + randomString, Project: testProjID, @@ -25,7 +25,7 @@ func TestMilestones(t *testing.T) { } // List milestones - milestones, msTotalInfo, err := Client.Milestone.List(&taiga.MilestonesQueryParams{Project: testProjID}) + milestones, msTotalInfo, err := Client.Milestone.List(&taiga.MilestonesQueryParams{Project: testProjID}) if err != nil { t.Error(err) } else { @@ -39,8 +39,8 @@ func TestMilestones(t *testing.T) { if err != nil { t.Error(err) } - if ms.Name != "A test milestone_" + randomString { - t.Errorf("got %s, want %s", ms.Name, "A test milestone_" + randomString) + if ms.Name != "A test milestone_"+randomString { + t.Errorf("got %s, want %s", ms.Name, "A test milestone_"+randomString) } // Edit a milestone @@ -50,8 +50,8 @@ func TestMilestones(t *testing.T) { if err != nil { t.Error(err) } - if msEdited.Name != "A test milestone_" + randomString + "_EDITED" { - t.Errorf("got %s, want %s", msEdited.Name, "A test milestone_" + randomString + "_EDITED") + if msEdited.Name != "A test milestone_"+randomString+"_EDITED" { + t.Errorf("got %s, want %s", msEdited.Name, "A test milestone_"+randomString+"_EDITED") } // Delete Milestone diff --git a/tests/taiga-docker b/tests/taiga-docker index 9e2e1e1..0657343 160000 --- a/tests/taiga-docker +++ b/tests/taiga-docker @@ -1 +1 @@ -Subproject commit 9e2e1e136e853b854959d302186196814b15f051 +Subproject commit 0657343e116b6b3af56faf5f0b4709163a8e5383 diff --git a/users.models.go b/users.models.go index aec45c7..30445f9 100644 --- a/users.models.go +++ b/users.models.go @@ -42,25 +42,6 @@ func (u User) GetToken() string { return u.authToken } -// UserAuthenticationDetail is a superset of User extended by an AuthToken field -type UserAuthenticationDetail struct { - AuthToken string `json:"auth_token"` - User // Embedding type User struct -} - -// AsUser returns a *User from *UserAuthenticationDetail -// -// The AuthToken can be accessed from *User via . -func (u *UserAuthenticationDetail) AsUser() *User { - user := &User{} - err := convertStructViaJSON(u, user) - if err != nil { - return nil - } - user.authToken = u.AuthToken - return user -} - // Liked represents Liked | https://taigaio.github.io/taiga-doc/dist/api.html#object-liked-detail type Liked struct { AssignedTo int `json:"assigned_to,omitempty"`