Skip to content

Commit e97f0a2

Browse files
authored
feat(cloudreve_v4): enhance token management (#1171)
* fix(cloudreve_v4): improve error handling in request method Signed-off-by: MadDogOwner <[email protected]> * feat(cloudreve_v4): enhance token management with expiration checks and refresh logic Signed-off-by: MadDogOwner <[email protected]> * feat(cloudreve_v4): add JWT structures for access and refresh tokens; validate access token on initialization Signed-off-by: MadDogOwner <[email protected]> * fix(cloudreve_v4): improve error messages Signed-off-by: MadDogOwner <[email protected]> --------- Signed-off-by: MadDogOwner <[email protected]>
1 parent 89f3517 commit e97f0a2

File tree

3 files changed

+183
-20
lines changed

3 files changed

+183
-20
lines changed

drivers/cloudreve_v4/driver.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import (
2020
type CloudreveV4 struct {
2121
model.Storage
2222
Addition
23-
ref *CloudreveV4
23+
ref *CloudreveV4
24+
AccessExpires string
25+
RefreshExpires string
2426
}
2527

2628
func (d *CloudreveV4) Config() driver.Config {
@@ -44,13 +46,17 @@ func (d *CloudreveV4) Init(ctx context.Context) error {
4446
if d.ref != nil {
4547
return nil
4648
}
47-
if d.AccessToken == "" && d.RefreshToken != "" {
49+
if d.canLogin() {
50+
return d.login()
51+
}
52+
if d.RefreshToken != "" {
4853
return d.refreshToken()
4954
}
50-
if d.Username != "" {
51-
return d.login()
55+
if d.AccessToken == "" {
56+
return errors.New("no way to authenticate. At least AccessToken is required")
5257
}
53-
return nil
58+
// ensure AccessToken is valid
59+
return d.parseJWT(d.AccessToken, &AccessJWT{})
5460
}
5561

5662
func (d *CloudreveV4) InitReference(storage driver.Driver) error {

drivers/cloudreve_v4/types.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,27 @@ type CaptchaResp struct {
6666
Ticket string `json:"ticket"`
6767
}
6868

69+
type AccessJWT struct {
70+
TokenType string `json:"token_type"`
71+
Sub string `json:"sub"`
72+
Exp int64 `json:"exp"`
73+
Nbf int64 `json:"nbf"`
74+
}
75+
76+
type RefreshJWT struct {
77+
TokenType string `json:"token_type"`
78+
Sub string `json:"sub"`
79+
Exp int `json:"exp"`
80+
Nbf int `json:"nbf"`
81+
StateHash string `json:"state_hash"`
82+
RootTokenID string `json:"root_token_id"`
83+
}
84+
6985
type Token struct {
70-
AccessToken string `json:"access_token"`
71-
RefreshToken string `json:"refresh_token"`
72-
AccessExpires time.Time `json:"access_expires"`
73-
RefreshExpires time.Time `json:"refresh_expires"`
86+
AccessToken string `json:"access_token"`
87+
RefreshToken string `json:"refresh_token"`
88+
AccessExpires string `json:"access_expires"`
89+
RefreshExpires string `json:"refresh_expires"`
7490
}
7591

7692
type TokenResponse struct {

drivers/cloudreve_v4/util.go

Lines changed: 152 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ import (
2828

2929
// do others that not defined in Driver interface
3030

31+
const (
32+
CodeLoginRequired = http.StatusUnauthorized
33+
CodeCredentialInvalid = 40020 // Failed to issue token
34+
)
35+
36+
var (
37+
ErrorIssueToken = errors.New("failed to issue token")
38+
)
39+
3140
func (d *CloudreveV4) getUA() string {
3241
if d.CustomUA != "" {
3342
return d.CustomUA
@@ -39,6 +48,23 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb
3948
if d.ref != nil {
4049
return d.ref.request(method, path, callback, out)
4150
}
51+
52+
// ensure token
53+
if d.isTokenExpired() {
54+
err := d.refreshToken()
55+
if err != nil {
56+
return err
57+
}
58+
}
59+
60+
return d._request(method, path, callback, out)
61+
}
62+
63+
func (d *CloudreveV4) _request(method string, path string, callback base.ReqCallback, out any) error {
64+
if d.ref != nil {
65+
return d.ref._request(method, path, callback, out)
66+
}
67+
4268
u := d.Address + "/api/v4" + path
4369
req := base.RestyClient.R()
4470
req.SetHeaders(map[string]string{
@@ -65,15 +91,17 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb
6591
}
6692

6793
if r.Code != 0 {
68-
if r.Code == 401 && d.RefreshToken != "" && path != "/session/token/refresh" {
69-
// try to refresh token
70-
err = d.refreshToken()
94+
if r.Code == CodeLoginRequired && d.canLogin() && path != "/session/token/refresh" {
95+
err = d.login()
7196
if err != nil {
7297
return err
7398
}
7499
return d.request(method, path, callback, out)
75100
}
76-
return errors.New(r.Msg)
101+
if r.Code == CodeCredentialInvalid {
102+
return ErrorIssueToken
103+
}
104+
return fmt.Errorf("%d: %s", r.Code, r.Msg)
77105
}
78106

79107
if out != nil && r.Data != nil {
@@ -91,14 +119,18 @@ func (d *CloudreveV4) request(method string, path string, callback base.ReqCallb
91119
return nil
92120
}
93121

122+
func (d *CloudreveV4) canLogin() bool {
123+
return d.Username != "" && d.Password != ""
124+
}
125+
94126
func (d *CloudreveV4) login() error {
95127
var siteConfig SiteLoginConfigResp
96-
err := d.request(http.MethodGet, "/site/config/login", nil, &siteConfig)
128+
err := d._request(http.MethodGet, "/site/config/login", nil, &siteConfig)
97129
if err != nil {
98130
return err
99131
}
100132
var prepareLogin PrepareLoginResp
101-
err = d.request(http.MethodGet, "/session/prepare?email="+d.Addition.Username, nil, &prepareLogin)
133+
err = d._request(http.MethodGet, "/session/prepare?email="+d.Addition.Username, nil, &prepareLogin)
102134
if err != nil {
103135
return err
104136
}
@@ -128,15 +160,15 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error {
128160
}
129161
if needCaptcha {
130162
var config BasicConfigResp
131-
err = d.request(http.MethodGet, "/site/config/basic", nil, &config)
163+
err = d._request(http.MethodGet, "/site/config/basic", nil, &config)
132164
if err != nil {
133165
return err
134166
}
135167
if config.CaptchaType != "normal" {
136168
return fmt.Errorf("captcha type %s not support", config.CaptchaType)
137169
}
138170
var captcha CaptchaResp
139-
err = d.request(http.MethodGet, "/site/captcha", nil, &captcha)
171+
err = d._request(http.MethodGet, "/site/captcha", nil, &captcha)
140172
if err != nil {
141173
return err
142174
}
@@ -162,41 +194,150 @@ func (d *CloudreveV4) doLogin(needCaptcha bool) error {
162194
loginBody["captcha"] = captchaCode
163195
}
164196
var token TokenResponse
165-
err = d.request(http.MethodPost, "/session/token", func(req *resty.Request) {
197+
err = d._request(http.MethodPost, "/session/token", func(req *resty.Request) {
166198
req.SetBody(loginBody)
167199
}, &token)
168200
if err != nil {
169201
return err
170202
}
171203
d.AccessToken, d.RefreshToken = token.Token.AccessToken, token.Token.RefreshToken
204+
d.AccessExpires, d.RefreshExpires = token.Token.AccessExpires, token.Token.RefreshExpires
172205
op.MustSaveDriverStorage(d)
173206
return nil
174207
}
175208

176209
func (d *CloudreveV4) refreshToken() error {
210+
// if no refresh token, try to login if possible
177211
if d.RefreshToken == "" {
178-
if d.Username != "" {
212+
if d.canLogin() {
179213
err := d.login()
180214
if err != nil {
181215
return fmt.Errorf("cannot login to get refresh token, error: %s", err)
182216
}
183217
}
184218
return nil
185219
}
220+
221+
// parse jwt to check if refresh token is valid
222+
var jwt RefreshJWT
223+
err := d.parseJWT(d.RefreshToken, &jwt)
224+
if err != nil {
225+
// if refresh token is invalid, try to login if possible
226+
if d.canLogin() {
227+
return d.login()
228+
}
229+
d.GetStorage().SetStatus(fmt.Sprintf("Invalid RefreshToken: %s", err.Error()))
230+
op.MustSaveDriverStorage(d)
231+
return fmt.Errorf("invalid refresh token: %w", err)
232+
}
233+
234+
// do refresh token
186235
var token Token
187-
err := d.request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) {
236+
err = d._request(http.MethodPost, "/session/token/refresh", func(req *resty.Request) {
188237
req.SetBody(base.Json{
189238
"refresh_token": d.RefreshToken,
190239
})
191240
}, &token)
192241
if err != nil {
242+
if errors.Is(err, ErrorIssueToken) {
243+
if d.canLogin() {
244+
// try to login again
245+
return d.login()
246+
}
247+
d.GetStorage().SetStatus("This session is no longer valid")
248+
op.MustSaveDriverStorage(d)
249+
return ErrorIssueToken
250+
}
193251
return err
194252
}
195253
d.AccessToken, d.RefreshToken = token.AccessToken, token.RefreshToken
254+
d.AccessExpires, d.RefreshExpires = token.AccessExpires, token.RefreshExpires
196255
op.MustSaveDriverStorage(d)
197256
return nil
198257
}
199258

259+
func (d *CloudreveV4) parseJWT(token string, jwt any) error {
260+
split := strings.Split(token, ".")
261+
if len(split) != 3 {
262+
return fmt.Errorf("invalid token length: %d, ensure the token is a valid JWT", len(split))
263+
}
264+
data, err := base64.RawURLEncoding.DecodeString(split[1])
265+
if err != nil {
266+
return fmt.Errorf("invalid token encoding: %w, ensure the token is a valid JWT", err)
267+
}
268+
err = json.Unmarshal(data, &jwt)
269+
if err != nil {
270+
return fmt.Errorf("invalid token content: %w, ensure the token is a valid JWT", err)
271+
}
272+
return nil
273+
}
274+
275+
// check if token is expired
276+
// https://github.com/cloudreve/frontend/blob/ddfacc1c31c49be03beb71de4cc114c8811038d6/src/session/index.ts#L177-L200
277+
func (d *CloudreveV4) isTokenExpired() bool {
278+
if d.RefreshToken == "" {
279+
// login again if username and password is set
280+
if d.canLogin() {
281+
return true
282+
}
283+
// no refresh token, cannot refresh
284+
return false
285+
}
286+
if d.AccessToken == "" {
287+
return true
288+
}
289+
var (
290+
err error
291+
expires time.Time
292+
)
293+
// check if token is expired
294+
if d.AccessExpires != "" {
295+
// use expires field if possible to prevent timezone issue
296+
// only available after login or refresh token
297+
// 2025-08-28T02:43:07.645109985+08:00
298+
expires, err = time.Parse(time.RFC3339Nano, d.AccessExpires)
299+
if err != nil {
300+
return false
301+
}
302+
} else {
303+
// fallback to parse jwt
304+
// if failed, disable the storage
305+
var jwt AccessJWT
306+
err = d.parseJWT(d.AccessToken, &jwt)
307+
if err != nil {
308+
d.GetStorage().SetStatus(fmt.Sprintf("Invalid AccessToken: %s", err.Error()))
309+
op.MustSaveDriverStorage(d)
310+
return false
311+
}
312+
// may be have timezone issue
313+
expires = time.Unix(jwt.Exp, 0)
314+
}
315+
// add a 10 minutes safe margin
316+
ddl := time.Now().Add(10 * time.Minute)
317+
if expires.Before(ddl) {
318+
// current access token expired, check if refresh token is expired
319+
// warning: cannot parse refresh token from jwt, because the exp field is not standard
320+
if d.RefreshExpires != "" {
321+
refreshExpires, err := time.Parse(time.RFC3339Nano, d.RefreshExpires)
322+
if err != nil {
323+
return false
324+
}
325+
if refreshExpires.Before(time.Now()) {
326+
// This session is no longer valid
327+
if d.canLogin() {
328+
// try to login again
329+
return true
330+
}
331+
d.GetStorage().SetStatus("This session is no longer valid")
332+
op.MustSaveDriverStorage(d)
333+
return false
334+
}
335+
}
336+
return true
337+
}
338+
return false
339+
}
340+
200341
func (d *CloudreveV4) upLocal(ctx context.Context, file model.FileStreamer, u FileUploadResp, up driver.UpdateProgress) error {
201342
var finish int64 = 0
202343
var chunk int = 0

0 commit comments

Comments
 (0)