Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
a8729b5c
authored
Sep 06, 2026
by
CaIon
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(security): require verification for access token management
parent
3e84ec0a
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
601 additions
and
130 deletions
+601
-130
controller/access_token.go
+38
-3
controller/access_token_audit_test.go
+8
-13
controller/security_enrollment_test.go
+208
-0
controller/user.go
+0
-30
router/api-router.go
+1
-1
service/security_verification.go
+16
-12
web/src/features/auth/secure-verification/types.ts
+2
-0
web/src/features/security/api.ts
+38
-20
web/src/features/security/components/__tests__/access-token-card.test.tsx
+192
-11
web/src/features/security/components/access-token-card.tsx
+10
-10
web/src/features/security/hooks/use-access-token.ts
+84
-25
web/src/lib/secure-verification.ts
+4
-5
No files found.
controller/access_token.go
View file @
a8729b5c
package
controller
import
(
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"strconv"
)
func
GetAccessTokenStatus
(
c
*
gin
.
Context
)
{
status
,
err
:=
model
.
GetUserAccessTokenStatus
(
c
.
GetInt
(
"id"
))
if
err
!=
nil
{
common
.
Api
Error
(
c
,
err
)
writeSecurityOperation
Error
(
c
,
err
)
return
}
common
.
ApiSuccess
(
c
,
status
)
}
func
GenerateAccessToken
(
c
*
gin
.
Context
)
{
if
middleware
.
RequireSecurityProof
(
c
,
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeAccessTokenGenerate
})
==
nil
{
return
}
id
:=
c
.
GetInt
(
"id"
)
key
,
err
:=
common
.
GenerateRandomKey
(
29
+
common
.
GetRandomInt
(
4
))
if
err
!=
nil
{
writeSecurityOperationError
(
c
,
err
)
return
}
var
existing
int64
if
err
:=
model
.
DB
.
Model
(
&
model
.
User
{})
.
Where
(
"access_token = ?"
,
key
)
.
Count
(
&
existing
)
.
Error
;
err
!=
nil
{
writeSecurityOperationError
(
c
,
err
)
return
}
if
existing
!=
0
{
common
.
ApiErrorI18n
(
c
,
i18n
.
MsgUuidDuplicate
)
return
}
if
err
:=
model
.
UpdateUserAccessToken
(
id
,
key
);
err
!=
nil
{
writeSecurityOperationError
(
c
,
err
)
return
}
recordUserSecurityAudit
(
c
,
id
,
"access_token.generate"
,
map
[
string
]
interface
{}{
"token_ref"
:
model
.
AccessTokenFingerprint
(
key
)})
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
key
})
}
func
RevokeAccessToken
(
c
*
gin
.
Context
)
{
if
middleware
.
RequireSecurityProof
(
c
,
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeAccessTokenRevoke
})
==
nil
{
return
}
ref
,
err
:=
model
.
RevokeUserAccessToken
(
c
.
GetInt
(
"id"
))
if
err
!=
nil
{
common
.
Api
Error
(
c
,
err
)
writeSecurityOperation
Error
(
c
,
err
)
return
}
if
ref
!=
""
{
...
...
controller/access_token_audit_test.go
View file @
a8729b5c
...
...
@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
assert
.
NotNil
(
t
,
status
.
CreatedAt
)
assert
.
Nil
(
t
,
status
.
LastUsedAt
,
"in-flight old requests must not mark the new generation as used"
)
assert
.
Equal
(
t
,
401
,
auditRequest
(
router
,
"GET"
,
"/api/user/token/status"
,
old
)
.
Code
)
for
_
,
method
:=
range
[]
string
{
"POST"
,
"GET"
}
{
for
_
,
method
:=
range
[]
string
{
"POST"
,
"GET"
,
"DELETE"
}
{
response
:=
auditRequest
(
router
,
method
,
"/api/user/token"
,
"new-token"
)
var
result
struct
{
Success
bool
Data
string
}
require
.
NoError
(
t
,
common
.
Unmarshal
(
response
.
Body
.
Bytes
(),
&
result
))
require
.
True
(
t
,
result
.
Success
)
require
.
GreaterOrEqual
(
t
,
len
(
result
.
Data
),
28
)
require
.
LessOrEqual
(
t
,
len
(
result
.
Data
),
32
)
assert
.
Equal
(
t
,
401
,
auditRequest
(
router
,
"GET"
,
"/api/user/token/status"
,
"new-token"
)
.
Code
)
require
.
NoError
(
t
,
model
.
UpdateUserAccessToken
(
user
.
Id
,
"new-token"
))
assert
.
Equal
(
t
,
http
.
StatusForbidden
,
response
.
Code
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"code":"SECURITY_PROOF_INVALID"`
)
stored
,
err
:=
model
.
GetUserById
(
user
.
Id
,
true
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
"new-token"
,
stored
.
GetAccessToken
(),
"a PAT cannot manage itself without a dashboard verification"
)
}
response
:=
auditRequest
(
router
,
"DELETE"
,
"/api/user/token"
,
"new-token"
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"success":true`
)
_
,
err
=
model
.
RevokeUserAccessToken
(
user
.
Id
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
401
,
auditRequest
(
router
,
"GET"
,
"/api/user/token/status"
,
"new-token"
)
.
Code
)
ref
,
err
:=
model
.
RevokeUserAccessToken
(
user
.
Id
)
require
.
NoError
(
t
,
err
)
...
...
controller/security_enrollment_test.go
View file @
a8729b5c
...
...
@@ -126,6 +126,211 @@ func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, o
return
proof
}
func
TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation
(
t
*
testing
.
T
)
{
user
,
identity
:=
setupSecurityEnrollmentTest
(
t
)
require
.
NoError
(
t
,
model
.
UpdateUserAccessToken
(
user
.
Id
,
"existing-system-token"
))
for
_
,
endpoint
:=
range
[]
struct
{
method
string
handler
gin
.
HandlerFunc
}{
{
"GET"
,
GenerateAccessToken
},
{
"POST"
,
GenerateAccessToken
},
{
"DELETE"
,
RevokeAccessToken
},
}
{
t
.
Run
(
endpoint
.
method
,
func
(
t
*
testing
.
T
)
{
response
:=
securityEnrollmentRequest
(
endpoint
.
method
,
"/api/user/token"
,
""
,
""
,
identity
,
endpoint
.
handler
)
var
body
securityEnrollmentResponse
require
.
NoError
(
t
,
common
.
Unmarshal
(
response
.
Body
.
Bytes
(),
&
body
))
assert
.
Equal
(
t
,
http
.
StatusForbidden
,
response
.
Code
)
assert
.
False
(
t
,
body
.
Success
)
assert
.
Equal
(
t
,
"SECURITY_PROOF_REQUIRED"
,
body
.
Code
)
stored
,
err
:=
model
.
ValidateAccessToken
(
"existing-system-token"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
stored
)
assert
.
Equal
(
t
,
user
.
Id
,
stored
.
Id
)
})
}
}
func
TestSecurityEnrollmentAccessTokenMethodPolicy
(
t
*
testing
.
T
)
{
for
_
,
test
:=
range
[]
struct
{
name
,
method
string
password
,
passkey
,
twoFA
,
locked
bool
disabledPasskey
,
oauth
,
wechat
bool
available
bool
}{
{
name
:
"password"
,
method
:
"password"
,
password
:
true
,
oauth
:
true
,
available
:
true
},
{
name
:
"existing passkey"
,
method
:
"passkey"
,
password
:
true
,
passkey
:
true
,
available
:
true
},
{
name
:
"existing twofa"
,
method
:
"2fa"
,
password
:
true
,
passkey
:
true
,
twoFA
:
true
,
available
:
true
},
{
name
:
"locked twofa blocks fallback"
,
method
:
"2fa"
,
password
:
true
,
twoFA
:
true
,
locked
:
true
},
{
name
:
"disabled passkey blocks fallback"
,
method
:
"passkey"
,
password
:
true
,
passkey
:
true
,
disabledPasskey
:
true
},
{
name
:
"disabled passkey does not block password"
,
method
:
"password"
,
password
:
true
,
disabledPasskey
:
true
,
available
:
true
},
{
name
:
"linked oauth"
,
method
:
"oauth"
,
oauth
:
true
,
available
:
true
},
{
name
:
"wechat session cannot manage tokens"
,
method
:
"oauth"
,
wechat
:
true
},
}
{
t
.
Run
(
test
.
name
,
func
(
t
*
testing
.
T
)
{
user
,
identity
:=
setupSecurityEnrollmentTest
(
t
)
if
!
test
.
password
{
require
.
NoError
(
t
,
model
.
DB
.
Model
(
user
)
.
Update
(
"password"
,
""
)
.
Error
)
}
if
test
.
passkey
{
require
.
NoError
(
t
,
model
.
DB
.
Create
(
&
model
.
PasskeyCredential
{
UserID
:
user
.
Id
,
CredentialID
:
"existing-key"
,
PublicKey
:
"public-key"
})
.
Error
)
}
if
test
.
twoFA
{
twoFA
:=
&
model
.
TwoFA
{
UserId
:
user
.
Id
,
Secret
:
"JBSWY3DPEHPK3PXP"
,
IsEnabled
:
true
}
if
test
.
locked
{
until
:=
time
.
Now
()
.
Add
(
time
.
Minute
)
twoFA
.
LockedUntil
=
&
until
}
require
.
NoError
(
t
,
model
.
DB
.
Create
(
twoFA
)
.
Error
)
}
if
test
.
oauth
{
require
.
NoError
(
t
,
model
.
DB
.
Model
(
user
)
.
Update
(
"github_id"
,
"linked-user"
)
.
Error
)
oauth
.
Register
(
"access-token-oauth"
,
&
enrollmentOAuthProvider
{
externalID
:
"linked-user"
})
t
.
Cleanup
(
func
()
{
oauth
.
Unregister
(
"access-token-oauth"
)
})
}
if
test
.
wechat
{
require
.
NoError
(
t
,
model
.
DB
.
Model
(
user
)
.
Update
(
"wechat_id"
,
"wechat-user"
)
.
Error
)
}
system_setting
.
GetPasskeySettings
()
.
Enabled
=
!
test
.
disabledPasskey
for
_
,
scope
:=
range
[]
string
{
service
.
VerificationScopeAccessTokenGenerate
,
service
.
VerificationScopeAccessTokenRevoke
}
{
requirements
,
err
:=
service
.
GetVerificationRequirements
(
identity
,
scope
)
require
.
NoError
(
t
,
err
)
require
.
Len
(
t
,
requirements
.
Methods
,
1
)
assert
.
Equal
(
t
,
test
.
method
,
requirements
.
Methods
[
0
]
.
Method
)
assert
.
Equal
(
t
,
test
.
available
,
requirements
.
Methods
[
0
]
.
Available
)
if
test
.
wechat
{
_
,
err
:=
service
.
VerifySecurityInput
(
identity
,
service
.
VerificationInput
{
Scope
:
scope
,
Method
:
"session"
})
assert
.
ErrorIs
(
t
,
err
,
service
.
ErrProofMethod
)
}
}
})
}
}
func
TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs
(
t
*
testing
.
T
)
{
user
,
identity
:=
setupSecurityEnrollmentTest
(
t
)
require
.
NoError
(
t
,
model
.
UpdateUserAccessToken
(
user
.
Id
,
"previous-token"
))
previousToken
:=
"previous-token"
for
_
,
method
:=
range
[]
string
{
"GET"
,
"POST"
}
{
proof
,
err
:=
service
.
VerifySecurityInput
(
identity
,
service
.
VerificationInput
{
Scope
:
service
.
VerificationScopeAccessTokenGenerate
,
Method
:
"password"
,
Password
:
"enrollment-password"
,
})
require
.
NoError
(
t
,
err
)
wrongScope
:=
securityEnrollmentRequest
(
"DELETE"
,
"/api/user/token"
,
""
,
proof
.
ProofToken
,
identity
,
RevokeAccessToken
)
assert
.
Contains
(
t
,
wrongScope
.
Body
.
String
(),
`"code":"SECURITY_PROOF_SCOPE_MISMATCH"`
)
response
:=
securityEnrollmentRequest
(
method
,
"/api/user/token"
,
""
,
proof
.
ProofToken
,
identity
,
GenerateAccessToken
)
var
body
securityEnrollmentResponse
require
.
NoError
(
t
,
common
.
Unmarshal
(
response
.
Body
.
Bytes
(),
&
body
))
require
.
True
(
t
,
body
.
Success
,
body
.
Message
)
var
token
string
require
.
NoError
(
t
,
common
.
Unmarshal
(
body
.
Data
,
&
token
))
assert
.
GreaterOrEqual
(
t
,
len
(
token
),
28
)
assert
.
LessOrEqual
(
t
,
len
(
token
),
32
)
assert
.
NotEqual
(
t
,
previousToken
,
token
)
stored
,
err
:=
model
.
ValidateAccessToken
(
token
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
stored
)
assert
.
Equal
(
t
,
user
.
Id
,
stored
.
Id
)
assert
.
Equal
(
t
,
model
.
AccessTokenFingerprint
(
token
),
model
.
AccessTokenFingerprint
(
stored
.
GetAccessToken
()))
oldUser
,
err
:=
model
.
ValidateAccessToken
(
previousToken
)
assert
.
Nil
(
t
,
oldUser
)
require
.
NoError
(
t
,
err
)
response
=
securityEnrollmentRequest
(
method
,
"/api/user/token"
,
""
,
proof
.
ProofToken
,
identity
,
GenerateAccessToken
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"code":"SECURITY_PROOF_CONSUMED"`
)
previousToken
=
token
}
proof
:=
issueSecurityEnrollmentProof
(
t
,
identity
,
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeAccessTokenRevoke
},
"password"
)
response
:=
securityEnrollmentRequest
(
"DELETE"
,
"/api/user/token"
,
""
,
proof
,
identity
,
RevokeAccessToken
)
var
body
securityEnrollmentResponse
require
.
NoError
(
t
,
common
.
Unmarshal
(
response
.
Body
.
Bytes
(),
&
body
))
require
.
True
(
t
,
body
.
Success
,
body
.
Message
)
stored
,
err
:=
model
.
GetUserById
(
user
.
Id
,
true
)
require
.
NoError
(
t
,
err
)
assert
.
Empty
(
t
,
stored
.
GetAccessToken
())
revokedUser
,
err
:=
model
.
ValidateAccessToken
(
previousToken
)
require
.
NoError
(
t
,
err
)
assert
.
Nil
(
t
,
revokedUser
)
response
=
securityEnrollmentRequest
(
"DELETE"
,
"/api/user/token"
,
""
,
proof
,
identity
,
RevokeAccessToken
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"code":"SECURITY_PROOF_CONSUMED"`
)
response
=
securityEnrollmentRequest
(
"GET"
,
"/api/user/token/status"
,
""
,
""
,
identity
,
GetAccessTokenStatus
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"exists":false`
)
var
audits
[]
model
.
AuditLog
require
.
NoError
(
t
,
model
.
LOG_DB
.
Find
(
&
audits
)
.
Error
)
require
.
Len
(
t
,
audits
,
3
)
encoded
,
err
:=
common
.
Marshal
(
audits
)
require
.
NoError
(
t
,
err
)
assert
.
Contains
(
t
,
string
(
encoded
),
"access_token.generate"
)
assert
.
Contains
(
t
,
string
(
encoded
),
"access_token.revoke"
)
assert
.
NotContains
(
t
,
string
(
encoded
),
previousToken
)
assert
.
NotContains
(
t
,
string
(
encoded
),
proof
)
}
func
TestSecurityEnrollmentAccessTokenRejectsInvalidProofs
(
t
*
testing
.
T
)
{
user
,
identity
:=
setupSecurityEnrollmentTest
(
t
)
require
.
NoError
(
t
,
model
.
UpdateUserAccessToken
(
user
.
Id
,
"unchanged-token"
))
for
_
,
endpoint
:=
range
[]
struct
{
method
,
scope
string
handler
gin
.
HandlerFunc
}{
{
"GET"
,
service
.
VerificationScopeAccessTokenGenerate
,
GenerateAccessToken
},
{
"POST"
,
service
.
VerificationScopeAccessTokenGenerate
,
GenerateAccessToken
},
{
"DELETE"
,
service
.
VerificationScopeAccessTokenRevoke
,
RevokeAccessToken
},
}
{
for
_
,
failure
:=
range
[]
string
{
"session"
,
"user"
,
"expired"
}
{
t
.
Run
(
endpoint
.
method
+
"/"
+
failure
,
func
(
t
*
testing
.
T
)
{
proof
:=
issueSecurityEnrollmentProof
(
t
,
identity
,
service
.
VerificationOperation
{
Scope
:
endpoint
.
scope
},
"password"
)
requestIdentity
:=
identity
code
:=
"SECURITY_PROOF_INVALID"
switch
failure
{
case
"session"
:
requestIdentity
.
SessionID
=
"other-session"
case
"user"
:
requestIdentity
.
UserID
++
case
"expired"
:
require
.
NoError
(
t
,
model
.
DB
.
Model
(
&
model
.
AuthFlow
{})
.
Where
(
"purpose = ?"
,
model
.
AuthFlowPurposeSecurityProof
)
.
Update
(
"expires_at"
,
time
.
Now
()
.
Add
(
-
time
.
Minute
))
.
Error
)
code
=
"SECURITY_PROOF_EXPIRED"
}
response
:=
securityEnrollmentRequest
(
endpoint
.
method
,
"/api/user/token"
,
""
,
proof
,
requestIdentity
,
endpoint
.
handler
)
assert
.
Equal
(
t
,
http
.
StatusForbidden
,
response
.
Code
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
code
)
stored
,
err
:=
model
.
ValidateAccessToken
(
"unchanged-token"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
stored
)
assert
.
Equal
(
t
,
user
.
Id
,
stored
.
Id
)
})
}
}
}
func
TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof
(
t
*
testing
.
T
)
{
user
,
identity
:=
setupSecurityEnrollmentTest
(
t
)
require
.
NoError
(
t
,
model
.
UpdateUserAccessToken
(
user
.
Id
,
"unchanged-token"
))
require
.
NoError
(
t
,
model
.
DB
.
Callback
()
.
Update
()
.
Before
(
"gorm:update"
)
.
Register
(
"access_token_write_failure"
,
func
(
tx
*
gorm
.
DB
)
{
if
tx
.
Statement
.
Table
==
"users"
{
tx
.
AddError
(
errors
.
New
(
"private database failure"
))
}
}))
for
_
,
endpoint
:=
range
[]
struct
{
method
,
scope
string
handler
gin
.
HandlerFunc
}{
{
"POST"
,
service
.
VerificationScopeAccessTokenGenerate
,
GenerateAccessToken
},
{
"DELETE"
,
service
.
VerificationScopeAccessTokenRevoke
,
RevokeAccessToken
},
}
{
proof
:=
issueSecurityEnrollmentProof
(
t
,
identity
,
service
.
VerificationOperation
{
Scope
:
endpoint
.
scope
},
"password"
)
response
:=
securityEnrollmentRequest
(
endpoint
.
method
,
"/api/user/token"
,
""
,
proof
,
identity
,
endpoint
.
handler
)
assert
.
Equal
(
t
,
http
.
StatusInternalServerError
,
response
.
Code
)
assert
.
NotContains
(
t
,
response
.
Body
.
String
(),
"private database"
)
response
=
securityEnrollmentRequest
(
endpoint
.
method
,
"/api/user/token"
,
""
,
proof
,
identity
,
endpoint
.
handler
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"code":"SECURITY_PROOF_CONSUMED"`
)
}
stored
,
err
:=
model
.
ValidateAccessToken
(
"unchanged-token"
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
stored
)
assert
.
Equal
(
t
,
user
.
Id
,
stored
.
Id
)
}
func
authorizeSecurityEnrollment
(
t
*
testing
.
T
,
identity
service
.
AuthIdentity
)
*
model
.
AuthFlowAuthorization
{
t
.
Helper
()
operation
:=
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeTwoFASetup
}
...
...
@@ -314,6 +519,9 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
{
"array context"
,
"passkey.register"
,
`[]`
,
service
.
ErrVerificationContextInvalid
},
{
"empty enrollment"
,
"passkey.register"
,
`{}`
,
nil
},
{
"implicit enrollment"
,
"passkey.register"
,
``
,
nil
},
{
"generate access token"
,
"access_token.generate"
,
`{}`
,
nil
},
{
"revoke access token"
,
"access_token.revoke"
,
``
,
nil
},
{
"access token target injection"
,
"access_token.revoke"
,
`{"user_id":42}`
,
service
.
ErrVerificationContextInvalid
},
{
"enrollment target injection"
,
"passkey.register"
,
`{"user_id":42}`
,
service
.
ErrVerificationContextInvalid
},
{
"unknown scope"
,
"user.email.change"
,
`{}`
,
service
.
ErrProofScope
},
}
{
...
...
controller/user.go
View file @
a8729b5c
...
...
@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) {
return
}
func
GenerateAccessToken
(
c
*
gin
.
Context
)
{
id
:=
c
.
GetInt
(
"id"
)
// get rand int 28-32
randI
:=
common
.
GetRandomInt
(
4
)
key
,
err
:=
common
.
GenerateRandomKey
(
29
+
randI
)
if
err
!=
nil
{
common
.
ApiErrorI18n
(
c
,
i18n
.
MsgGenerateFailed
)
common
.
SysLog
(
"failed to generate key: "
+
err
.
Error
())
return
}
if
model
.
DB
.
Where
(
"access_token = ?"
,
key
)
.
First
(
&
model
.
User
{})
.
RowsAffected
!=
0
{
common
.
ApiErrorI18n
(
c
,
i18n
.
MsgUuidDuplicate
)
return
}
if
err
:=
model
.
UpdateUserAccessToken
(
id
,
key
);
err
!=
nil
{
common
.
ApiError
(
c
,
err
)
return
}
recordUserSecurityAudit
(
c
,
id
,
"access_token.generate"
,
map
[
string
]
interface
{}{
"token_ref"
:
model
.
AccessTokenFingerprint
(
key
)})
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"success"
:
true
,
"message"
:
""
,
"data"
:
key
,
})
return
}
type
TransferAffQuotaRequest
struct
{
Quota
int
`json:"quota" binding:"required"`
}
...
...
router/api-router.go
View file @
a8729b5c
...
...
@@ -97,7 +97,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute
.
GET
(
"/token"
,
middleware
.
CriticalRateLimit
(),
middleware
.
UserCriticalRateLimit
(
"access-token"
),
middleware
.
DisableCache
(),
controller
.
GenerateAccessToken
)
selfRoute
.
GET
(
"/token/status"
,
middleware
.
DisableCache
(),
controller
.
GetAccessTokenStatus
)
selfRoute
.
POST
(
"/token"
,
middleware
.
CriticalRateLimit
(),
middleware
.
UserCriticalRateLimit
(
"access-token"
),
middleware
.
DisableCache
(),
controller
.
GenerateAccessToken
)
selfRoute
.
DELETE
(
"/token"
,
middleware
.
CriticalRateLimit
(),
middleware
.
DisableCache
(),
controller
.
RevokeAccessToken
)
selfRoute
.
DELETE
(
"/token"
,
middleware
.
CriticalRateLimit
(),
middleware
.
UserCriticalRateLimit
(
"access-token"
),
middleware
.
DisableCache
(),
controller
.
RevokeAccessToken
)
selfRoute
.
GET
(
"/passkey"
,
controller
.
PasskeyStatus
)
selfRoute
.
POST
(
"/passkey/register/begin"
,
middleware
.
UserCriticalRateLimit
(
"security-verification"
),
middleware
.
DisableCache
(),
controller
.
PasskeyRegisterBegin
)
selfRoute
.
POST
(
"/passkey/register/finish"
,
middleware
.
UserCriticalRateLimit
(
"security-verification"
),
middleware
.
DisableCache
(),
controller
.
PasskeyRegisterFinish
)
...
...
service/security_verification.go
View file @
a8729b5c
...
...
@@ -15,15 +15,17 @@ import (
)
const
(
VerificationMethodTwoFA
=
"2fa"
VerificationMethodPasskey
=
"passkey"
VerificationMethodPassword
=
"password"
VerificationMethodOAuth
=
"oauth"
VerificationMethodSession
=
"session"
VerificationScopeChannelKeyRead
=
"channel.key.read"
VerificationScopePasskeyRegister
=
"passkey.register"
VerificationScopePasskeyDelete
=
"passkey.delete"
VerificationScopeTwoFASetup
=
"2fa.setup"
VerificationMethodTwoFA
=
"2fa"
VerificationMethodPasskey
=
"passkey"
VerificationMethodPassword
=
"password"
VerificationMethodOAuth
=
"oauth"
VerificationMethodSession
=
"session"
VerificationScopeChannelKeyRead
=
"channel.key.read"
VerificationScopePasskeyRegister
=
"passkey.register"
VerificationScopePasskeyDelete
=
"passkey.delete"
VerificationScopeTwoFASetup
=
"2fa.setup"
VerificationScopeAccessTokenGenerate
=
"access_token.generate"
VerificationScopeAccessTokenRevoke
=
"access_token.revoke"
)
var
(
...
...
@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return
VerificationBinding
{},
ErrVerificationContextInvalid
}
normalized
=
context
case
VerificationScopePasskeyRegister
,
VerificationScopePasskeyDelete
,
VerificationScopeTwoFASetup
:
case
VerificationScopePasskeyRegister
,
VerificationScopePasskeyDelete
,
VerificationScopeTwoFASetup
,
VerificationScopeAccessTokenGenerate
,
VerificationScopeAccessTokenRevoke
:
if
len
(
fields
)
!=
0
{
return
VerificationBinding
{},
ErrVerificationContextInvalid
}
...
...
@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
}
else
if
state
.
HasPasskey
{
methods
=
[]
string
{
VerificationMethodPasskey
}
}
case
VerificationScopePasskeyRegister
,
VerificationScopeTwoFASetup
:
case
VerificationScopePasskeyRegister
,
VerificationScopeTwoFASetup
,
VerificationScopeAccessTokenGenerate
,
VerificationScopeAccessTokenRevoke
:
if
scope
==
VerificationScopeTwoFASetup
&&
state
.
HasTwoFA
{
return
nil
,
model
.
ErrTwoFAAlreadyEnabled
}
...
...
@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods
=
[]
string
{
VerificationMethodPasskey
}
case
state
.
HasPassword
:
methods
=
[]
string
{
VerificationMethodPassword
}
case
state
.
WeChatEnrollment
:
case
state
.
WeChatEnrollment
&&
(
scope
==
VerificationScopePasskeyRegister
||
scope
==
VerificationScopeTwoFASetup
)
:
methods
=
[]
string
{
VerificationMethodSession
}
default
:
methods
=
[]
string
{
VerificationMethodOAuth
}
...
...
web/src/features/auth/secure-verification/types.ts
View file @
a8729b5c
...
...
@@ -27,6 +27,8 @@ export type SecurityProofScope =
|
'passkey.register'
|
'passkey.delete'
|
'2fa.setup'
|
'access_token.generate'
|
'access_token.revoke'
export
type
VerificationOperation
=
|
{
scope
:
'channel.key.read'
;
context
:
{
channel_id
:
number
}
}
...
...
web/src/features/security/api.ts
View file @
a8729b5c
...
...
@@ -16,9 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
type
{
ApiResponse
,
TwoFAStatus
}
from
'@/features/profile/types'
import
type
{
TwoFAStatus
}
from
'@/features/profile/types'
import
{
api
}
from
'@/lib/api'
import
{
authRequestOptions
,
authResult
}
from
'@/lib/secure-verification'
import
{
AuthOperationError
,
authRequestOptions
,
authResult
,
}
from
'@/lib/secure-verification'
export
interface
AccessTokenStatus
{
exists
:
boolean
...
...
@@ -28,29 +32,43 @@ export interface AccessTokenStatus {
last_used_ip
:
string
}
export
async
function
getAccessTokenStatus
():
Promise
<
AccessTokenStatus
>
{
const
response
=
await
api
.
get
<
ApiResponse
<
AccessTokenStatus
>>
(
'/api/user/token/status'
export
function
getAccessTokenStatus
():
Promise
<
AccessTokenStatus
>
{
return
authResult
(
api
.
get
(
'/api/user/token/status'
,
authRequestOptions
),
'Failed to load token status'
)
if
(
!
response
.
data
.
success
||
!
response
.
data
.
data
)
{
throw
new
Error
(
response
.
data
.
message
||
'Failed to load token status'
)
}
return
response
.
data
.
data
}
export
async
function
createAccessToken
():
Promise
<
string
>
{
const
response
=
await
api
.
post
<
ApiResponse
<
string
>>
(
'/api/user/token'
)
if
(
!
response
.
data
.
success
||
!
response
.
data
.
data
)
{
throw
new
Error
(
response
.
data
.
message
||
'Failed to generate token'
)
}
return
response
.
data
.
data
export
async
function
createAccessToken
(
proofToken
:
string
,
signal
:
AbortSignal
):
Promise
<
string
>
{
const
token
=
await
authResult
<
string
>
(
api
.
post
(
'/api/user/token'
,
undefined
,
{
...
authRequestOptions
,
headers
:
{
'X-Security-Proof'
:
proofToken
},
singleUseAuthorization
:
true
,
signal
,
}),
'Failed to generate token'
)
if
(
!
token
)
throw
new
AuthOperationError
(
'Failed to generate token'
)
return
token
}
export
async
function
revokeAccessToken
():
Promise
<
void
>
{
const
response
=
await
api
.
delete
<
ApiResponse
>
(
'/api/user/token'
)
if
(
!
response
.
data
.
success
)
{
throw
new
Error
(
response
.
data
.
message
||
'Failed to revoke token'
)
}
export
async
function
revokeAccessToken
(
proofToken
:
string
,
signal
:
AbortSignal
):
Promise
<
void
>
{
await
authResult
<
null
>
(
api
.
delete
(
'/api/user/token'
,
{
...
authRequestOptions
,
headers
:
{
'X-Security-Proof'
:
proofToken
},
singleUseAuthorization
:
true
,
signal
,
}),
'Failed to revoke token'
)
}
export
interface
TwoFASetupData
{
...
...
web/src/features/security/components/__tests__/access-token-card.test.tsx
View file @
a8729b5c
...
...
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import
{
QueryClient
,
QueryClientProvider
}
from
'@tanstack/react-query'
import
{
act
,
cleanup
,
render
,
screen
,
...
...
@@ -35,7 +36,34 @@ import type { AccessTokenStatus } from '../../api'
import
{
AccessTokenCard
}
from
'../access-token-card'
let
status
:
AccessTokenStatus
let
proofCount
:
number
function
passwordProof
(
scope
:
string
)
{
proofCount
+=
1
return
{
data
:
{
success
:
true
,
data
:
{
proof_token
:
`one-use-proof-
${
proofCount
}
`
,
scope
,
method
:
'password'
,
expires_at
:
Math
.
floor
(
Date
.
now
()
/
1000
)
+
60
,
},
},
}
}
async
function
verifyPassword
(
user
:
ReturnType
<
typeof
userEvent
.
setup
>
)
{
await
user
.
type
(
await
screen
.
findByLabelText
(
'Password'
,
{
selector
:
'input'
}),
'current-password'
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Verify'
}))
}
beforeEach
(()
=>
{
proofCount
=
0
useAuthStore
.
getState
().
auth
.
setUser
({
id
:
1
,
username
:
'admin'
,
role
:
100
})
vi
.
stubGlobal
(
'localStorage'
,
{
getItem
:
()
=>
null
,
setItem
:
()
=>
undefined
,
...
...
@@ -48,12 +76,31 @@ beforeEach(() => {
last_used_at
:
null
,
last_used_ip
:
''
,
}
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
)
=>
{
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
,
config
)
=>
{
if
(
url
===
'/api/audit/self'
)
{
return
{
data
:
{
success
:
true
,
data
:
{
items
:
[],
total
:
0
}
}
}
}
if
(
url
===
'/api/verify/methods'
)
{
return
{
data
:
{
success
:
true
,
data
:
{
scope
:
config
?.
params
?.
scope
,
methods
:
[{
method
:
'password'
,
available
:
true
}],
oauth_providers
:
[],
password_encryption_enabled
:
false
,
},
},
}
}
return
{
data
:
{
success
:
true
,
data
:
status
}
}
})
vi
.
spyOn
(
api
,
'post'
).
mockImplementation
(
async
(
url
,
data
)
=>
{
if
(
url
===
'/api/verify'
)
{
return
passwordProof
((
data
as
{
scope
:
string
}).
scope
)
}
throw
new
Error
(
`Unexpected POST
${
url
}
`
)
})
})
afterEach
(()
=>
{
cleanup
()
...
...
@@ -76,9 +123,109 @@ function renderCard() {
}
describe
(
'system access token management'
,
()
=>
{
it
(
'does not generate a token when identity verification is cancelled'
,
async
()
=>
{
const
post
=
vi
.
mocked
(
api
.
post
)
renderCard
()
const
user
=
userEvent
.
setup
()
await
user
.
dblClick
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
screen
.
findByLabelText
(
'Password'
,
{
selector
:
'input'
})
expect
(
post
).
not
.
toHaveBeenCalled
()
expect
(
vi
.
mocked
(
api
.
get
)
.
mock
.
calls
.
filter
(([
url
])
=>
url
===
'/api/verify/methods'
)
).
toHaveLength
(
1
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Cancel'
}))
expect
(
post
).
not
.
toHaveBeenCalled
()
expect
(
screen
.
queryByLabelText
(
'Token'
)).
not
.
toBeInTheDocument
()
})
it
(
'ignores a successful verification response that arrives after cancellation'
,
async
()
=>
{
let
complete
!
:
(
value
:
ReturnType
<
typeof
passwordProof
>
)
=>
void
const
pending
=
new
Promise
<
ReturnType
<
typeof
passwordProof
>>
((
resolve
)
=>
{
complete
=
resolve
})
const
post
=
vi
.
mocked
(
api
.
post
).
mockImplementation
(
async
(
url
)
=>
{
if
(
url
===
'/api/verify'
)
return
pending
throw
new
Error
(
`Unexpected POST
${
url
}
`
)
})
renderCard
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
verifyPassword
(
user
)
await
waitFor
(()
=>
expect
(
post
).
toHaveBeenCalledWith
(
'/api/verify'
,
expect
.
anything
(),
expect
.
anything
()
)
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Cancel'
}))
await
act
(
async
()
=>
{
complete
(
passwordProof
(
'access_token.generate'
))
await
pending
})
expect
(
post
.
mock
.
calls
.
filter
(([
url
])
=>
url
===
'/api/user/token'
)
).
toHaveLength
(
0
)
expect
(
screen
.
queryByLabelText
(
'Token'
)).
not
.
toBeInTheDocument
()
expect
(
screen
.
getByRole
(
'button'
,
{
name
:
'Generate'
})).
toBeEnabled
()
})
it
(
'aborts and ignores a token response for an account that is no longer active'
,
async
()
=>
{
const
token
=
'previous-account-private-token'
let
complete
!
:
(
value
:
{
data
:
{
success
:
boolean
;
data
:
string
}
})
=>
void
const
pending
=
new
Promise
<
{
data
:
{
success
:
boolean
;
data
:
string
}
}
>
(
(
resolve
)
=>
{
complete
=
resolve
}
)
let
signal
:
{
readonly
aborted
:
boolean
}
|
undefined
vi
.
mocked
(
api
.
post
).
mockImplementation
(
async
(
url
,
data
,
config
)
=>
{
if
(
url
===
'/api/verify'
)
{
return
passwordProof
((
data
as
{
scope
:
string
}).
scope
)
}
if
(
url
===
'/api/user/token'
)
{
signal
=
config
?.
signal
return
pending
}
throw
new
Error
(
`Unexpected POST
${
url
}
`
)
})
const
client
=
renderCard
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
verifyPassword
(
user
)
await
waitFor
(()
=>
expect
(
signal
).
toBeDefined
())
await
act
(
async
()
=>
{
useAuthStore
.
getState
()
.
auth
.
setUser
({
id
:
2
,
username
:
'other-user'
,
role
:
100
})
})
expect
(
signal
?.
aborted
).
toBe
(
true
)
await
act
(
async
()
=>
{
complete
({
data
:
{
success
:
true
,
data
:
token
}
})
await
pending
})
expect
(
screen
.
queryByDisplayValue
(
token
)).
not
.
toBeInTheDocument
()
expect
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
})
).
toBeEnabled
()
expect
(
JSON
.
stringify
(
client
.
getQueryCache
()
.
getAll
()
.
map
((
entry
)
=>
entry
.
state
.
data
)
)
).
not
.
toContain
(
token
)
})
it
(
'generates a missing token and clears the one-time plaintext on Escape'
,
async
()
=>
{
const
token
=
'one-time-private-token'
vi
.
spyOn
(
api
,
'post'
).
mockImplementation
(
async
()
=>
{
vi
.
mocked
(
api
.
post
).
mockImplementation
(
async
(
url
,
data
)
=>
{
if
(
url
===
'/api/verify'
)
{
return
passwordProof
((
data
as
{
scope
:
string
}).
scope
)
}
status
=
{
...
status
,
exists
:
true
,
...
...
@@ -90,9 +237,17 @@ describe('system access token management', () => {
const
client
=
renderCard
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
verifyPassword
(
user
)
const
dialog
=
await
screen
.
findByRole
(
'dialog'
,
{
name
:
'Access Token'
})
expect
(
within
(
dialog
).
getByLabelText
(
'Token'
)).
toHaveValue
(
token
)
expect
(
api
.
post
).
toHaveBeenCalledWith
(
'/api/user/token'
)
expect
(
api
.
post
).
toHaveBeenCalledWith
(
'/api/user/token'
,
undefined
,
expect
.
objectContaining
({
headers
:
{
'X-Security-Proof'
:
'one-use-proof-1'
},
singleUseAuthorization
:
true
,
})
)
await
user
.
keyboard
(
'{Escape}'
)
await
waitFor
(()
=>
expect
(
screen
.
queryByDisplayValue
(
token
)).
not
.
toBeInTheDocument
()
...
...
@@ -105,6 +260,9 @@ describe('system access token management', () => {
.
map
((
entry
)
=>
entry
.
state
.
data
)
)
).
not
.
toContain
(
token
)
expect
(
JSON
.
stringify
(
client
.
getMutationCache
().
getAll
())).
not
.
toContain
(
'one-use-proof-1'
)
expect
(
JSON
.
stringify
(
client
...
...
@@ -141,11 +299,14 @@ describe('system access token management', () => {
expect
(
await
screen
.
findByText
(
'Not generated'
)).
toBeVisible
()
})
it
(
'rotation requires confirmation and
a failed rotation
keeps the existing token state'
,
async
()
=>
{
it
(
'rotation requires confirmation and
verification, and failure
keeps the existing token state'
,
async
()
=>
{
status
=
{
...
status
,
exists
:
true
,
token_ref
:
'a'
.
repeat
(
64
)
}
const
post
=
vi
.
spyOn
(
api
,
'post'
)
.
mockResolvedValue
({
data
:
{
success
:
false
}
})
const
post
=
vi
.
mocked
(
api
.
post
).
mockImplementation
(
async
(
url
,
data
)
=>
{
if
(
url
===
'/api/verify'
)
{
return
passwordProof
((
data
as
{
scope
:
string
}).
scope
)
}
return
{
data
:
{
success
:
false
}
}
})
renderCard
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Regenerate'
}))
...
...
@@ -154,6 +315,8 @@ describe('system access token management', () => {
await
user
.
click
(
within
(
confirmation
).
getByRole
(
'button'
,
{
name
:
'Regenerate token'
})
)
expect
(
screen
.
queryByRole
(
'alertdialog'
)).
not
.
toBeInTheDocument
()
await
verifyPassword
(
user
)
expect
(
await
screen
.
findByText
(
'Failed to generate token'
)).
toBeVisible
()
expect
(
screen
.
queryByRole
(
'dialog'
,
{
name
:
'Access Token'
})
...
...
@@ -164,10 +327,10 @@ describe('system access token management', () => {
it
(
'revocation failures can be retried and success restores the generate action'
,
async
()
=>
{
status
=
{
...
status
,
exists
:
true
,
token_ref
:
'a'
.
repeat
(
64
)
}
vi
.
spyOn
(
api
,
'delete'
)
.
mockRe
jectedValueOnce
(
new
Error
(
'offline'
)
)
.
mockRe
solvedValueOnce
({
data
:
{
success
:
false
}
}
)
.
mockImplementation
(
async
()
=>
{
status
=
{
...
status
,
exists
:
false
,
token_ref
:
''
}
return
{
data
:
{
success
:
true
}
}
return
{
data
:
{
success
:
true
,
data
:
null
}
}
})
renderCard
()
const
user
=
userEvent
.
setup
()
...
...
@@ -177,9 +340,27 @@ describe('system access token management', () => {
{
name
:
'Revoke'
}
)
await
user
.
click
(
confirm
)
await
verifyPassword
(
user
)
expect
(
await
screen
.
findByText
(
'Failed to revoke token'
)).
toBeVisible
()
await
waitFor
(()
=>
expect
(
confirm
).
toBeEnabled
())
await
user
.
click
(
confirm
)
expect
(
api
.
delete
).
toHaveBeenLastCalledWith
(
'/api/user/token'
,
expect
.
objectContaining
({
headers
:
{
'X-Security-Proof'
:
'one-use-proof-1'
},
})
)
await
user
.
click
(
screen
.
getByRole
(
'button'
,
{
name
:
'Revoke'
}))
await
user
.
click
(
within
(
await
screen
.
findByRole
(
'alertdialog'
)).
getByRole
(
'button'
,
{
name
:
'Revoke'
,
})
)
await
verifyPassword
(
user
)
expect
(
api
.
delete
).
toHaveBeenLastCalledWith
(
'/api/user/token'
,
expect
.
objectContaining
({
headers
:
{
'X-Security-Proof'
:
'one-use-proof-2'
},
})
)
expect
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
})
).
toBeVisible
()
...
...
web/src/features/security/components/access-token-card.tsx
View file @
a8729b5c
...
...
@@ -30,6 +30,7 @@ import {
SheetHeader
,
SheetTitle
,
}
from
'@/components/ui/sheet'
import
{
SecureVerificationDialog
}
from
'@/features/auth/secure-verification'
import
{
AuditLogViewer
}
from
'@/features/usage-logs/audit/components/audit-log-viewer'
import
dayjs
from
'@/lib/dayjs'
...
...
@@ -43,21 +44,19 @@ export function AccessTokenCard() {
null
)
const
[
historyOpen
,
setHistoryOpen
]
=
useState
(
false
)
const
pending
=
access
.
generate
.
isPending
||
access
.
revoke
.
isP
ending
const
pending
=
access
.
p
ending
const
status
=
access
.
status
.
data
const
ready
=
!
access
.
status
.
isError
&&
!
access
.
status
.
isPending
&&
!!
status
let
lastUsed
=
t
(
'Unknown'
)
if
(
status
?.
last_used_at
)
{
lastUsed
=
dayjs
.
unix
(
status
.
last_used_at
).
format
(
'YYYY-MM-DD HH:mm:ss'
)
}
else
if
(
status
?.
created_at
)
lastUsed
=
t
(
'Not used yet'
)
const
confirm
=
async
()
=>
{
try
{
if
(
confirmation
===
'revoke'
)
await
access
.
revoke
.
mutateAsync
()
else
await
access
.
generate
.
mutateAsync
()
setConfirmation
(
null
)
}
catch
{
/* The mutation displays the error and preserves the confirmation. */
}
const
confirm
=
()
=>
{
if
(
pending
||
!
confirmation
)
return
const
operation
=
confirmation
setConfirmation
(
null
)
if
(
operation
===
'revoke'
)
void
access
.
revoke
()
else
void
access
.
generate
()
}
return
(
<>
...
...
@@ -152,7 +151,7 @@ export function AccessTokenCard() {
<
Button
size=
'sm'
disabled=
{
pending
}
onClick=
{
()
=>
access
.
generate
.
mut
ate
()
}
onClick=
{
()
=>
void
access
.
gener
ate
()
}
>
{
t
(
'Generate'
)
}
</
Button
>
...
...
@@ -164,6 +163,7 @@ export function AccessTokenCard() {
{
access
.
token
&&
(
<
AccessTokenDialog
token=
{
access
.
token
}
onClose=
{
access
.
clearToken
}
/>
)
}
<
SecureVerificationDialog
{
...
access
.
verificationDialogProps
}
/>
<
ConfirmDialog
open=
{
confirmation
!==
null
}
onOpenChange=
{
(
open
)
=>
{
...
...
web/src/features/security/hooks/use-access-token.ts
View file @
a8729b5c
...
...
@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import
{
use
Mutation
,
use
Query
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
useState
}
from
'react'
import
{
useQuery
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
use
Callback
,
useEffect
,
useRef
,
use
State
}
from
'react'
import
{
useTranslation
}
from
'react-i18next'
import
{
toast
}
from
'sonner'
import
{
useSecureVerification
}
from
'@/features/auth/secure-verification'
import
{
AuthOperationError
}
from
'@/lib/secure-verification'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
...
...
@@ -33,36 +35,93 @@ export function useAccessToken() {
const
{
t
}
=
useTranslation
()
const
client
=
useQueryClient
()
const
userId
=
useAuthStore
((
state
)
=>
state
.
auth
.
user
?.
id
)
const
sessionId
=
useAuthStore
((
state
)
=>
state
.
auth
.
session
?.
sid
)
const
statusKey
=
[
'security'
,
'access-token'
,
'status'
,
userId
]
as
const
const
[
token
,
setToken
]
=
useState
(
''
)
const
[
generatedToken
,
setGeneratedToken
]
=
useState
<
{
value
:
string
userId
:
number
|
undefined
sessionId
:
string
|
undefined
}
|
null
>
(
null
)
const
[
pending
,
setPending
]
=
useState
(
false
)
const
currentOperation
=
useRef
<
AbortController
|
null
>
(
null
)
const
verification
=
useSecureVerification
()
const
requestVerification
=
verification
.
requestVerification
const
cancelVerification
=
verification
.
cancel
const
status
=
useQuery
({
queryKey
:
statusKey
,
queryFn
:
getAccessTokenStatus
,
retry
:
false
,
})
const
refresh
=
()
=>
client
.
invalidateQueries
({
queryKey
:
statusKey
})
const
generate
=
useMutation
({
// Keep plaintext out of the query/mutation cache and persistent storage.
mutationFn
:
async
()
=>
{
setToken
(
await
createAccessToken
())
useEffect
(
()
=>
()
=>
{
const
current
=
currentOperation
.
current
currentOperation
.
current
=
null
current
?.
abort
()
cancelVerification
()
setPending
(
false
)
setGeneratedToken
(
null
)
},
onSuccess
:
refresh
,
onError
:
()
=>
{
toast
.
error
(
t
(
'Failed to generate token'
))
void
refresh
()
},
})
const
revoke
=
useMutation
({
mutationFn
:
revokeAccessToken
,
onSuccess
:
()
=>
{
setToken
(
''
)
toast
.
success
(
t
(
'Access token revoked'
))
return
refresh
()
[
cancelVerification
,
userId
,
sessionId
]
)
const
performOperation
=
useCallback
(
async
(
operation
:
'generate'
|
'revoke'
)
=>
{
if
(
currentOperation
.
current
)
return
const
controller
=
new
AbortController
()
currentOperation
.
current
=
controller
setPending
(
true
)
try
{
const
proof
=
await
requestVerification
({
scope
:
`access_token.
${
operation
}
`
,
})
if
(
currentOperation
.
current
!==
controller
||
!
proof
)
return
// Proofs and plaintext stay local to this action, outside React Query caches.
if
(
operation
===
'generate'
)
{
const
generated
=
await
createAccessToken
(
proof
.
proof_token
,
controller
.
signal
)
if
(
currentOperation
.
current
!==
controller
)
return
setGeneratedToken
({
value
:
generated
,
userId
,
sessionId
})
}
else
{
await
revokeAccessToken
(
proof
.
proof_token
,
controller
.
signal
)
if
(
currentOperation
.
current
!==
controller
)
return
setGeneratedToken
(
null
)
toast
.
success
(
t
(
'Access token revoked'
))
}
void
client
.
invalidateQueries
({
queryKey
:
[
'security'
,
'access-token'
,
'status'
,
userId
],
})
}
catch
(
error
)
{
if
(
currentOperation
.
current
!==
controller
)
return
const
failure
=
AuthOperationError
.
from
(
error
)
if
(
failure
.
code
!==
'AUTH_CANCELLED'
)
toast
.
error
(
t
(
failure
.
message
))
void
client
.
invalidateQueries
({
queryKey
:
[
'security'
,
'access-token'
,
'status'
,
userId
],
})
}
finally
{
if
(
currentOperation
.
current
===
controller
)
{
currentOperation
.
current
=
null
setPending
(
false
)
}
}
},
onError
:
()
=>
{
toast
.
error
(
t
(
'Failed to revoke token'
))
void
refresh
()
[
client
,
requestVerification
,
t
,
userId
,
sessionId
]
)
return
{
status
,
token
:
generatedToken
?.
userId
===
userId
&&
generatedToken
?.
sessionId
===
sessionId
?
(
generatedToken
?.
value
??
''
)
:
''
,
pending
,
clearToken
:
()
=>
{
setGeneratedToken
(
null
)
},
})
return
{
status
,
token
,
clearToken
:
()
=>
setToken
(
''
),
generate
,
revoke
}
generate
:
()
=>
performOperation
(
'generate'
),
revoke
:
()
=>
performOperation
(
'revoke'
),
verificationDialogProps
:
verification
.
dialogProps
,
}
}
web/src/lib/secure-verification.ts
View file @
a8729b5c
...
...
@@ -64,20 +64,19 @@ export const authRequestOptions = {
export
async
function
authResult
<
T
>
(
request
:
Promise
<
{
data
:
{
success
:
boolean
;
message
?:
string
;
code
?:
string
;
data
?:
T
}
}
>
}
>
,
fallback
=
'Verification failed. Please try again.'
):
Promise
<
T
>
{
try
{
const
{
data
:
response
}
=
await
request
if
(
!
response
.
success
||
response
.
data
===
undefined
)
{
throw
new
AuthOperationError
(
getServerErrorMessageKey
(
response
)
||
response
.
message
||
'Verification failed. Please try again.'
,
getServerErrorMessageKey
(
response
)
||
response
.
message
||
fallback
,
response
.
code
)
}
return
response
.
data
}
catch
(
error
)
{
throw
AuthOperationError
.
from
(
error
)
throw
AuthOperationError
.
from
(
error
,
fallback
)
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment