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
package
controller
import
(
import
(
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"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/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
"strconv"
)
)
func
GetAccessTokenStatus
(
c
*
gin
.
Context
)
{
func
GetAccessTokenStatus
(
c
*
gin
.
Context
)
{
status
,
err
:=
model
.
GetUserAccessTokenStatus
(
c
.
GetInt
(
"id"
))
status
,
err
:=
model
.
GetUserAccessTokenStatus
(
c
.
GetInt
(
"id"
))
if
err
!=
nil
{
if
err
!=
nil
{
common
.
Api
Error
(
c
,
err
)
writeSecurityOperation
Error
(
c
,
err
)
return
return
}
}
common
.
ApiSuccess
(
c
,
status
)
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
)
{
func
RevokeAccessToken
(
c
*
gin
.
Context
)
{
if
middleware
.
RequireSecurityProof
(
c
,
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeAccessTokenRevoke
})
==
nil
{
return
}
ref
,
err
:=
model
.
RevokeUserAccessToken
(
c
.
GetInt
(
"id"
))
ref
,
err
:=
model
.
RevokeUserAccessToken
(
c
.
GetInt
(
"id"
))
if
err
!=
nil
{
if
err
!=
nil
{
common
.
Api
Error
(
c
,
err
)
writeSecurityOperation
Error
(
c
,
err
)
return
return
}
}
if
ref
!=
""
{
if
ref
!=
""
{
...
...
controller/access_token_audit_test.go
View file @
a8729b5c
...
@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
...
@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
assert
.
NotNil
(
t
,
status
.
CreatedAt
)
assert
.
NotNil
(
t
,
status
.
CreatedAt
)
assert
.
Nil
(
t
,
status
.
LastUsedAt
,
"in-flight old requests must not mark the new generation as used"
)
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
)
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"
)
response
:=
auditRequest
(
router
,
method
,
"/api/user/token"
,
"new-token"
)
var
result
struct
{
assert
.
Equal
(
t
,
http
.
StatusForbidden
,
response
.
Code
)
Success
bool
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"code":"SECURITY_PROOF_INVALID"`
)
Data
string
stored
,
err
:=
model
.
GetUserById
(
user
.
Id
,
true
)
}
require
.
NoError
(
t
,
err
)
require
.
NoError
(
t
,
common
.
Unmarshal
(
response
.
Body
.
Bytes
(),
&
result
))
assert
.
Equal
(
t
,
"new-token"
,
stored
.
GetAccessToken
(),
"a PAT cannot manage itself without a dashboard verification"
)
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"
))
}
}
response
:=
auditRequest
(
router
,
"DELETE"
,
"/api/user/token"
,
"new-token"
)
_
,
err
=
model
.
RevokeUserAccessToken
(
user
.
Id
)
assert
.
Contains
(
t
,
response
.
Body
.
String
(),
`"success":true`
)
require
.
NoError
(
t
,
err
)
assert
.
Equal
(
t
,
401
,
auditRequest
(
router
,
"GET"
,
"/api/user/token/status"
,
"new-token"
)
.
Code
)
assert
.
Equal
(
t
,
401
,
auditRequest
(
router
,
"GET"
,
"/api/user/token/status"
,
"new-token"
)
.
Code
)
ref
,
err
:=
model
.
RevokeUserAccessToken
(
user
.
Id
)
ref
,
err
:=
model
.
RevokeUserAccessToken
(
user
.
Id
)
require
.
NoError
(
t
,
err
)
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
...
@@ -126,6 +126,211 @@ func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, o
return
proof
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
{
func
authorizeSecurityEnrollment
(
t
*
testing
.
T
,
identity
service
.
AuthIdentity
)
*
model
.
AuthFlowAuthorization
{
t
.
Helper
()
t
.
Helper
()
operation
:=
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeTwoFASetup
}
operation
:=
service
.
VerificationOperation
{
Scope
:
service
.
VerificationScopeTwoFASetup
}
...
@@ -314,6 +519,9 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
...
@@ -314,6 +519,9 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
{
"array context"
,
"passkey.register"
,
`[]`
,
service
.
ErrVerificationContextInvalid
},
{
"array context"
,
"passkey.register"
,
`[]`
,
service
.
ErrVerificationContextInvalid
},
{
"empty enrollment"
,
"passkey.register"
,
`{}`
,
nil
},
{
"empty enrollment"
,
"passkey.register"
,
`{}`
,
nil
},
{
"implicit 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
},
{
"enrollment target injection"
,
"passkey.register"
,
`{"user_id":42}`
,
service
.
ErrVerificationContextInvalid
},
{
"unknown scope"
,
"user.email.change"
,
`{}`
,
service
.
ErrProofScope
},
{
"unknown scope"
,
"user.email.change"
,
`{}`
,
service
.
ErrProofScope
},
}
{
}
{
...
...
controller/user.go
View file @
a8729b5c
...
@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) {
...
@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) {
return
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
{
type
TransferAffQuotaRequest
struct
{
Quota
int
`json:"quota" binding:"required"`
Quota
int
`json:"quota" binding:"required"`
}
}
...
...
router/api-router.go
View file @
a8729b5c
...
@@ -97,7 +97,7 @@ func SetApiRouter(router *gin.Engine) {
...
@@ -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"
,
middleware
.
CriticalRateLimit
(),
middleware
.
UserCriticalRateLimit
(
"access-token"
),
middleware
.
DisableCache
(),
controller
.
GenerateAccessToken
)
selfRoute
.
GET
(
"/token/status"
,
middleware
.
DisableCache
(),
controller
.
GetAccessTokenStatus
)
selfRoute
.
GET
(
"/token/status"
,
middleware
.
DisableCache
(),
controller
.
GetAccessTokenStatus
)
selfRoute
.
POST
(
"/token"
,
middleware
.
CriticalRateLimit
(),
middleware
.
UserCriticalRateLimit
(
"access-token"
),
middleware
.
DisableCache
(),
controller
.
GenerateAccessToken
)
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
.
GET
(
"/passkey"
,
controller
.
PasskeyStatus
)
selfRoute
.
POST
(
"/passkey/register/begin"
,
middleware
.
UserCriticalRateLimit
(
"security-verification"
),
middleware
.
DisableCache
(),
controller
.
PasskeyRegisterBegin
)
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
)
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 (
...
@@ -15,15 +15,17 @@ import (
)
)
const
(
const
(
VerificationMethodTwoFA
=
"2fa"
VerificationMethodTwoFA
=
"2fa"
VerificationMethodPasskey
=
"passkey"
VerificationMethodPasskey
=
"passkey"
VerificationMethodPassword
=
"password"
VerificationMethodPassword
=
"password"
VerificationMethodOAuth
=
"oauth"
VerificationMethodOAuth
=
"oauth"
VerificationMethodSession
=
"session"
VerificationMethodSession
=
"session"
VerificationScopeChannelKeyRead
=
"channel.key.read"
VerificationScopeChannelKeyRead
=
"channel.key.read"
VerificationScopePasskeyRegister
=
"passkey.register"
VerificationScopePasskeyRegister
=
"passkey.register"
VerificationScopePasskeyDelete
=
"passkey.delete"
VerificationScopePasskeyDelete
=
"passkey.delete"
VerificationScopeTwoFASetup
=
"2fa.setup"
VerificationScopeTwoFASetup
=
"2fa.setup"
VerificationScopeAccessTokenGenerate
=
"access_token.generate"
VerificationScopeAccessTokenRevoke
=
"access_token.revoke"
)
)
var
(
var
(
...
@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
...
@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return
VerificationBinding
{},
ErrVerificationContextInvalid
return
VerificationBinding
{},
ErrVerificationContextInvalid
}
}
normalized
=
context
normalized
=
context
case
VerificationScopePasskeyRegister
,
VerificationScopePasskeyDelete
,
VerificationScopeTwoFASetup
:
case
VerificationScopePasskeyRegister
,
VerificationScopePasskeyDelete
,
VerificationScopeTwoFASetup
,
VerificationScopeAccessTokenGenerate
,
VerificationScopeAccessTokenRevoke
:
if
len
(
fields
)
!=
0
{
if
len
(
fields
)
!=
0
{
return
VerificationBinding
{},
ErrVerificationContextInvalid
return
VerificationBinding
{},
ErrVerificationContextInvalid
}
}
...
@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
...
@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
}
else
if
state
.
HasPasskey
{
}
else
if
state
.
HasPasskey
{
methods
=
[]
string
{
VerificationMethodPasskey
}
methods
=
[]
string
{
VerificationMethodPasskey
}
}
}
case
VerificationScopePasskeyRegister
,
VerificationScopeTwoFASetup
:
case
VerificationScopePasskeyRegister
,
VerificationScopeTwoFASetup
,
VerificationScopeAccessTokenGenerate
,
VerificationScopeAccessTokenRevoke
:
if
scope
==
VerificationScopeTwoFASetup
&&
state
.
HasTwoFA
{
if
scope
==
VerificationScopeTwoFASetup
&&
state
.
HasTwoFA
{
return
nil
,
model
.
ErrTwoFAAlreadyEnabled
return
nil
,
model
.
ErrTwoFAAlreadyEnabled
}
}
...
@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
...
@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods
=
[]
string
{
VerificationMethodPasskey
}
methods
=
[]
string
{
VerificationMethodPasskey
}
case
state
.
HasPassword
:
case
state
.
HasPassword
:
methods
=
[]
string
{
VerificationMethodPassword
}
methods
=
[]
string
{
VerificationMethodPassword
}
case
state
.
WeChatEnrollment
:
case
state
.
WeChatEnrollment
&&
(
scope
==
VerificationScopePasskeyRegister
||
scope
==
VerificationScopeTwoFASetup
)
:
methods
=
[]
string
{
VerificationMethodSession
}
methods
=
[]
string
{
VerificationMethodSession
}
default
:
default
:
methods
=
[]
string
{
VerificationMethodOAuth
}
methods
=
[]
string
{
VerificationMethodOAuth
}
...
...
web/src/features/auth/secure-verification/types.ts
View file @
a8729b5c
...
@@ -27,6 +27,8 @@ export type SecurityProofScope =
...
@@ -27,6 +27,8 @@ export type SecurityProofScope =
|
'passkey.register'
|
'passkey.register'
|
'passkey.delete'
|
'passkey.delete'
|
'2fa.setup'
|
'2fa.setup'
|
'access_token.generate'
|
'access_token.revoke'
export
type
VerificationOperation
=
export
type
VerificationOperation
=
|
{
scope
:
'channel.key.read'
;
context
:
{
channel_id
:
number
}
}
|
{
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/>.
...
@@ -16,9 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
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
{
api
}
from
'@/lib/api'
import
{
authRequestOptions
,
authResult
}
from
'@/lib/secure-verification'
import
{
AuthOperationError
,
authRequestOptions
,
authResult
,
}
from
'@/lib/secure-verification'
export
interface
AccessTokenStatus
{
export
interface
AccessTokenStatus
{
exists
:
boolean
exists
:
boolean
...
@@ -28,29 +32,43 @@ export interface AccessTokenStatus {
...
@@ -28,29 +32,43 @@ export interface AccessTokenStatus {
last_used_ip
:
string
last_used_ip
:
string
}
}
export
async
function
getAccessTokenStatus
():
Promise
<
AccessTokenStatus
>
{
export
function
getAccessTokenStatus
():
Promise
<
AccessTokenStatus
>
{
const
response
=
await
api
.
get
<
ApiResponse
<
AccessTokenStatus
>>
(
return
authResult
(
'/api/user/token/status'
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
>
{
export
async
function
createAccessToken
(
const
response
=
await
api
.
post
<
ApiResponse
<
string
>>
(
'/api/user/token'
)
proofToken
:
string
,
if
(
!
response
.
data
.
success
||
!
response
.
data
.
data
)
{
signal
:
AbortSignal
throw
new
Error
(
response
.
data
.
message
||
'Failed to generate token'
)
):
Promise
<
string
>
{
}
const
token
=
await
authResult
<
string
>
(
return
response
.
data
.
data
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
>
{
export
async
function
revokeAccessToken
(
const
response
=
await
api
.
delete
<
ApiResponse
>
(
'/api/user/token'
)
proofToken
:
string
,
if
(
!
response
.
data
.
success
)
{
signal
:
AbortSignal
throw
new
Error
(
response
.
data
.
message
||
'Failed to revoke token'
)
):
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
{
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
...
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
QueryClient
,
QueryClientProvider
}
from
'@tanstack/react-query'
import
{
QueryClient
,
QueryClientProvider
}
from
'@tanstack/react-query'
import
{
import
{
act
,
cleanup
,
cleanup
,
render
,
render
,
screen
,
screen
,
...
@@ -35,7 +36,34 @@ import type { AccessTokenStatus } from '../../api'
...
@@ -35,7 +36,34 @@ import type { AccessTokenStatus } from '../../api'
import
{
AccessTokenCard
}
from
'../access-token-card'
import
{
AccessTokenCard
}
from
'../access-token-card'
let
status
:
AccessTokenStatus
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
(()
=>
{
beforeEach
(()
=>
{
proofCount
=
0
useAuthStore
.
getState
().
auth
.
setUser
({
id
:
1
,
username
:
'admin'
,
role
:
100
})
vi
.
stubGlobal
(
'localStorage'
,
{
vi
.
stubGlobal
(
'localStorage'
,
{
getItem
:
()
=>
null
,
getItem
:
()
=>
null
,
setItem
:
()
=>
undefined
,
setItem
:
()
=>
undefined
,
...
@@ -48,12 +76,31 @@ beforeEach(() => {
...
@@ -48,12 +76,31 @@ beforeEach(() => {
last_used_at
:
null
,
last_used_at
:
null
,
last_used_ip
:
''
,
last_used_ip
:
''
,
}
}
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
)
=>
{
vi
.
spyOn
(
api
,
'get'
).
mockImplementation
(
async
(
url
,
config
)
=>
{
if
(
url
===
'/api/audit/self'
)
{
if
(
url
===
'/api/audit/self'
)
{
return
{
data
:
{
success
:
true
,
data
:
{
items
:
[],
total
:
0
}
}
}
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
}
}
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
(()
=>
{
afterEach
(()
=>
{
cleanup
()
cleanup
()
...
@@ -76,9 +123,109 @@ function renderCard() {
...
@@ -76,9 +123,109 @@ function renderCard() {
}
}
describe
(
'system access token management'
,
()
=>
{
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
()
=>
{
it
(
'generates a missing token and clears the one-time plaintext on Escape'
,
async
()
=>
{
const
token
=
'one-time-private-token'
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
=
{
...
status
,
...
status
,
exists
:
true
,
exists
:
true
,
...
@@ -90,9 +237,17 @@ describe('system access token management', () => {
...
@@ -90,9 +237,17 @@ describe('system access token management', () => {
const
client
=
renderCard
()
const
client
=
renderCard
()
const
user
=
userEvent
.
setup
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
}))
await
verifyPassword
(
user
)
const
dialog
=
await
screen
.
findByRole
(
'dialog'
,
{
name
:
'Access Token'
})
const
dialog
=
await
screen
.
findByRole
(
'dialog'
,
{
name
:
'Access Token'
})
expect
(
within
(
dialog
).
getByLabelText
(
'Token'
)).
toHaveValue
(
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
user
.
keyboard
(
'{Escape}'
)
await
waitFor
(()
=>
await
waitFor
(()
=>
expect
(
screen
.
queryByDisplayValue
(
token
)).
not
.
toBeInTheDocument
()
expect
(
screen
.
queryByDisplayValue
(
token
)).
not
.
toBeInTheDocument
()
...
@@ -105,6 +260,9 @@ describe('system access token management', () => {
...
@@ -105,6 +260,9 @@ describe('system access token management', () => {
.
map
((
entry
)
=>
entry
.
state
.
data
)
.
map
((
entry
)
=>
entry
.
state
.
data
)
)
)
).
not
.
toContain
(
token
)
).
not
.
toContain
(
token
)
expect
(
JSON
.
stringify
(
client
.
getMutationCache
().
getAll
())).
not
.
toContain
(
'one-use-proof-1'
)
expect
(
expect
(
JSON
.
stringify
(
JSON
.
stringify
(
client
client
...
@@ -141,11 +299,14 @@ describe('system access token management', () => {
...
@@ -141,11 +299,14 @@ describe('system access token management', () => {
expect
(
await
screen
.
findByText
(
'Not generated'
)).
toBeVisible
()
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
)
}
status
=
{
...
status
,
exists
:
true
,
token_ref
:
'a'
.
repeat
(
64
)
}
const
post
=
vi
const
post
=
vi
.
mocked
(
api
.
post
).
mockImplementation
(
async
(
url
,
data
)
=>
{
.
spyOn
(
api
,
'post'
)
if
(
url
===
'/api/verify'
)
{
.
mockResolvedValue
({
data
:
{
success
:
false
}
})
return
passwordProof
((
data
as
{
scope
:
string
}).
scope
)
}
return
{
data
:
{
success
:
false
}
}
})
renderCard
()
renderCard
()
const
user
=
userEvent
.
setup
()
const
user
=
userEvent
.
setup
()
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Regenerate'
}))
await
user
.
click
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Regenerate'
}))
...
@@ -154,6 +315,8 @@ describe('system access token management', () => {
...
@@ -154,6 +315,8 @@ describe('system access token management', () => {
await
user
.
click
(
await
user
.
click
(
within
(
confirmation
).
getByRole
(
'button'
,
{
name
:
'Regenerate token'
})
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
(
await
screen
.
findByText
(
'Failed to generate token'
)).
toBeVisible
()
expect
(
expect
(
screen
.
queryByRole
(
'dialog'
,
{
name
:
'Access Token'
})
screen
.
queryByRole
(
'dialog'
,
{
name
:
'Access Token'
})
...
@@ -164,10 +327,10 @@ describe('system access token management', () => {
...
@@ -164,10 +327,10 @@ describe('system access token management', () => {
it
(
'revocation failures can be retried and success restores the generate action'
,
async
()
=>
{
it
(
'revocation failures can be retried and success restores the generate action'
,
async
()
=>
{
status
=
{
...
status
,
exists
:
true
,
token_ref
:
'a'
.
repeat
(
64
)
}
status
=
{
...
status
,
exists
:
true
,
token_ref
:
'a'
.
repeat
(
64
)
}
vi
.
spyOn
(
api
,
'delete'
)
vi
.
spyOn
(
api
,
'delete'
)
.
mockRe
jectedValueOnce
(
new
Error
(
'offline'
)
)
.
mockRe
solvedValueOnce
({
data
:
{
success
:
false
}
}
)
.
mockImplementation
(
async
()
=>
{
.
mockImplementation
(
async
()
=>
{
status
=
{
...
status
,
exists
:
false
,
token_ref
:
''
}
status
=
{
...
status
,
exists
:
false
,
token_ref
:
''
}
return
{
data
:
{
success
:
true
}
}
return
{
data
:
{
success
:
true
,
data
:
null
}
}
})
})
renderCard
()
renderCard
()
const
user
=
userEvent
.
setup
()
const
user
=
userEvent
.
setup
()
...
@@ -177,9 +340,27 @@ describe('system access token management', () => {
...
@@ -177,9 +340,27 @@ describe('system access token management', () => {
{
name
:
'Revoke'
}
{
name
:
'Revoke'
}
)
)
await
user
.
click
(
confirm
)
await
user
.
click
(
confirm
)
await
verifyPassword
(
user
)
expect
(
await
screen
.
findByText
(
'Failed to revoke token'
)).
toBeVisible
()
expect
(
await
screen
.
findByText
(
'Failed to revoke token'
)).
toBeVisible
()
await
waitFor
(()
=>
expect
(
confirm
).
toBeEnabled
())
expect
(
api
.
delete
).
toHaveBeenLastCalledWith
(
await
user
.
click
(
confirm
)
'/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
(
expect
(
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
})
await
screen
.
findByRole
(
'button'
,
{
name
:
'Generate'
})
).
toBeVisible
()
).
toBeVisible
()
...
...
web/src/features/security/components/access-token-card.tsx
View file @
a8729b5c
...
@@ -30,6 +30,7 @@ import {
...
@@ -30,6 +30,7 @@ import {
SheetHeader
,
SheetHeader
,
SheetTitle
,
SheetTitle
,
}
from
'@/components/ui/sheet'
}
from
'@/components/ui/sheet'
import
{
SecureVerificationDialog
}
from
'@/features/auth/secure-verification'
import
{
AuditLogViewer
}
from
'@/features/usage-logs/audit/components/audit-log-viewer'
import
{
AuditLogViewer
}
from
'@/features/usage-logs/audit/components/audit-log-viewer'
import
dayjs
from
'@/lib/dayjs'
import
dayjs
from
'@/lib/dayjs'
...
@@ -43,21 +44,19 @@ export function AccessTokenCard() {
...
@@ -43,21 +44,19 @@ export function AccessTokenCard() {
null
null
)
)
const
[
historyOpen
,
setHistoryOpen
]
=
useState
(
false
)
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
status
=
access
.
status
.
data
const
ready
=
!
access
.
status
.
isError
&&
!
access
.
status
.
isPending
&&
!!
status
const
ready
=
!
access
.
status
.
isError
&&
!
access
.
status
.
isPending
&&
!!
status
let
lastUsed
=
t
(
'Unknown'
)
let
lastUsed
=
t
(
'Unknown'
)
if
(
status
?.
last_used_at
)
{
if
(
status
?.
last_used_at
)
{
lastUsed
=
dayjs
.
unix
(
status
.
last_used_at
).
format
(
'YYYY-MM-DD HH:mm:ss'
)
lastUsed
=
dayjs
.
unix
(
status
.
last_used_at
).
format
(
'YYYY-MM-DD HH:mm:ss'
)
}
else
if
(
status
?.
created_at
)
lastUsed
=
t
(
'Not used yet'
)
}
else
if
(
status
?.
created_at
)
lastUsed
=
t
(
'Not used yet'
)
const
confirm
=
async
()
=>
{
const
confirm
=
()
=>
{
try
{
if
(
pending
||
!
confirmation
)
return
if
(
confirmation
===
'revoke'
)
await
access
.
revoke
.
mutateAsync
()
const
operation
=
confirmation
else
await
access
.
generate
.
mutateAsync
()
setConfirmation
(
null
)
setConfirmation
(
null
)
if
(
operation
===
'revoke'
)
void
access
.
revoke
()
}
catch
{
else
void
access
.
generate
()
/* The mutation displays the error and preserves the confirmation. */
}
}
}
return
(
return
(
<>
<>
...
@@ -152,7 +151,7 @@ export function AccessTokenCard() {
...
@@ -152,7 +151,7 @@ export function AccessTokenCard() {
<
Button
<
Button
size=
'sm'
size=
'sm'
disabled=
{
pending
}
disabled=
{
pending
}
onClick=
{
()
=>
access
.
generate
.
mut
ate
()
}
onClick=
{
()
=>
void
access
.
gener
ate
()
}
>
>
{
t
(
'Generate'
)
}
{
t
(
'Generate'
)
}
</
Button
>
</
Button
>
...
@@ -164,6 +163,7 @@ export function AccessTokenCard() {
...
@@ -164,6 +163,7 @@ export function AccessTokenCard() {
{
access
.
token
&&
(
{
access
.
token
&&
(
<
AccessTokenDialog
token=
{
access
.
token
}
onClose=
{
access
.
clearToken
}
/>
<
AccessTokenDialog
token=
{
access
.
token
}
onClose=
{
access
.
clearToken
}
/>
)
}
)
}
<
SecureVerificationDialog
{
...
access
.
verificationDialogProps
}
/>
<
ConfirmDialog
<
ConfirmDialog
open=
{
confirmation
!==
null
}
open=
{
confirmation
!==
null
}
onOpenChange=
{
(
open
)
=>
{
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/>.
...
@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
For commercial licensing, please contact support@quantumnous.com
*/
*/
import
{
use
Mutation
,
use
Query
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
useQuery
,
useQueryClient
}
from
'@tanstack/react-query'
import
{
useState
}
from
'react'
import
{
use
Callback
,
useEffect
,
useRef
,
use
State
}
from
'react'
import
{
useTranslation
}
from
'react-i18next'
import
{
useTranslation
}
from
'react-i18next'
import
{
toast
}
from
'sonner'
import
{
toast
}
from
'sonner'
import
{
useSecureVerification
}
from
'@/features/auth/secure-verification'
import
{
AuthOperationError
}
from
'@/lib/secure-verification'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
useAuthStore
}
from
'@/stores/auth-store'
import
{
import
{
...
@@ -33,36 +35,93 @@ export function useAccessToken() {
...
@@ -33,36 +35,93 @@ export function useAccessToken() {
const
{
t
}
=
useTranslation
()
const
{
t
}
=
useTranslation
()
const
client
=
useQueryClient
()
const
client
=
useQueryClient
()
const
userId
=
useAuthStore
((
state
)
=>
state
.
auth
.
user
?.
id
)
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
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
({
const
status
=
useQuery
({
queryKey
:
statusKey
,
queryKey
:
statusKey
,
queryFn
:
getAccessTokenStatus
,
queryFn
:
getAccessTokenStatus
,
retry
:
false
,
retry
:
false
,
})
})
const
refresh
=
()
=>
client
.
invalidateQueries
({
queryKey
:
statusKey
})
useEffect
(
const
generate
=
useMutation
({
()
=>
()
=>
{
// Keep plaintext out of the query/mutation cache and persistent storage.
const
current
=
currentOperation
.
current
mutationFn
:
async
()
=>
{
currentOperation
.
current
=
null
setToken
(
await
createAccessToken
())
current
?.
abort
()
cancelVerification
()
setPending
(
false
)
setGeneratedToken
(
null
)
},
},
onSuccess
:
refresh
,
[
cancelVerification
,
userId
,
sessionId
]
onError
:
()
=>
{
)
toast
.
error
(
t
(
'Failed to generate token'
))
void
refresh
()
const
performOperation
=
useCallback
(
},
async
(
operation
:
'generate'
|
'revoke'
)
=>
{
})
if
(
currentOperation
.
current
)
return
const
revoke
=
useMutation
({
const
controller
=
new
AbortController
()
mutationFn
:
revokeAccessToken
,
currentOperation
.
current
=
controller
onSuccess
:
()
=>
{
setPending
(
true
)
setToken
(
''
)
try
{
toast
.
success
(
t
(
'Access token revoked'
))
const
proof
=
await
requestVerification
({
return
refresh
()
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
:
()
=>
{
[
client
,
requestVerification
,
t
,
userId
,
sessionId
]
toast
.
error
(
t
(
'Failed to revoke token'
))
)
void
refresh
()
return
{
status
,
token
:
generatedToken
?.
userId
===
userId
&&
generatedToken
?.
sessionId
===
sessionId
?
(
generatedToken
?.
value
??
''
)
:
''
,
pending
,
clearToken
:
()
=>
{
setGeneratedToken
(
null
)
},
},
})
generate
:
()
=>
performOperation
(
'generate'
),
return
{
status
,
token
,
clearToken
:
()
=>
setToken
(
''
),
generate
,
revoke
}
revoke
:
()
=>
performOperation
(
'revoke'
),
verificationDialogProps
:
verification
.
dialogProps
,
}
}
}
web/src/lib/secure-verification.ts
View file @
a8729b5c
...
@@ -64,20 +64,19 @@ export const authRequestOptions = {
...
@@ -64,20 +64,19 @@ export const authRequestOptions = {
export
async
function
authResult
<
T
>
(
export
async
function
authResult
<
T
>
(
request
:
Promise
<
{
request
:
Promise
<
{
data
:
{
success
:
boolean
;
message
?:
string
;
code
?:
string
;
data
?:
T
}
data
:
{
success
:
boolean
;
message
?:
string
;
code
?:
string
;
data
?:
T
}
}
>
}
>
,
fallback
=
'Verification failed. Please try again.'
):
Promise
<
T
>
{
):
Promise
<
T
>
{
try
{
try
{
const
{
data
:
response
}
=
await
request
const
{
data
:
response
}
=
await
request
if
(
!
response
.
success
||
response
.
data
===
undefined
)
{
if
(
!
response
.
success
||
response
.
data
===
undefined
)
{
throw
new
AuthOperationError
(
throw
new
AuthOperationError
(
getServerErrorMessageKey
(
response
)
||
getServerErrorMessageKey
(
response
)
||
response
.
message
||
fallback
,
response
.
message
||
'Verification failed. Please try again.'
,
response
.
code
response
.
code
)
)
}
}
return
response
.
data
return
response
.
data
}
catch
(
error
)
{
}
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