Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
J
jiayun
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
jiayun
Commits
d87904df
Commit
d87904df
authored
Aug 06, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 支持后台修改登录密码
parent
b8a11003
Pipeline
#458
passed with stage
in 20 seconds
Changes
7
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
298 additions
and
13 deletions
+298
-13
CMS_README.md
CMS_README.md
+1
-1
app.js
public/admin/app.js
+40
-0
style.css
public/admin/style.css
+6
-0
cms-api.ts
src/lib/cms-api.ts
+32
-12
cms-auth.ts
src/lib/cms-auth.ts
+91
-0
index.astro
src/pages/admin/index.astro
+14
-0
auth.test.ts
test/auth.test.ts
+114
-0
No files found.
CMS_README.md
View file @
d87904df
...
@@ -32,7 +32,7 @@ npm start
...
@@ -32,7 +32,7 @@ npm start
-
分类:
`src/content/categories.json`
-
分类:
`src/content/categories.json`
-
上传图片:
`public/uploads/`
-
上传图片:
`public/uploads/`
生产环境建议设置
`CMS_DATA_DIR`
指向部署目录之外的持久化路径。后台支持资讯新建、保存草稿、预览、发布、下架、删除、图片上传
和分类管理
。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
生产环境建议设置
`CMS_DATA_DIR`
指向部署目录之外的持久化路径。后台支持资讯新建、保存草稿、预览、发布、下架、删除、图片上传
、分类管理和修改后台密码。修改后的密码以哈希形式保存在
`CMS_DATA_DIR/auth/password.json`
;
`CMS_PASSWORD`
始终保留为登录和找回密码
。发布操作会在共享锁内原子构建站点,失败时恢复内容快照。
## 必需环境变量
## 必需环境变量
...
...
public/admin/app.js
View file @
d87904df
...
@@ -64,6 +64,46 @@ $("#login-form").addEventListener("submit", async (event) => {
...
@@ -64,6 +64,46 @@ $("#login-form").addEventListener("submit", async (event) => {
$
(
"#logout"
).
addEventListener
(
"click"
,
async
()
=>
{
await
api
(
"/session"
,
{
method
:
"DELETE"
});
location
.
reload
();
});
$
(
"#logout"
).
addEventListener
(
"click"
,
async
()
=>
{
await
api
(
"/session"
,
{
method
:
"DELETE"
});
location
.
reload
();
});
function
closePasswordModal
()
{
hide
(
"#password-modal"
);
$
(
"#password-form"
).
reset
();
$
(
"#password-error"
).
textContent
=
""
;
}
$
(
"#change-password"
).
addEventListener
(
"click"
,
()
=>
{
$
(
"#password-form"
).
reset
();
$
(
"#password-error"
).
textContent
=
""
;
show
(
"#password-modal"
);
$
(
"#current-password"
).
focus
();
});
$
(
"#close-password-modal"
).
addEventListener
(
"click"
,
closePasswordModal
);
$
(
"#cancel-password"
).
addEventListener
(
"click"
,
closePasswordModal
);
$
(
"#password-form"
).
addEventListener
(
"submit"
,
async
(
event
)
=>
{
event
.
preventDefault
();
const
currentPassword
=
$
(
"#current-password"
).
value
;
const
newPassword
=
$
(
"#new-password"
).
value
;
const
confirmPassword
=
$
(
"#confirm-password"
).
value
;
const
submit
=
event
.
submitter
;
$
(
"#password-error"
).
textContent
=
""
;
if
(
newPassword
!==
confirmPassword
)
{
$
(
"#password-error"
).
textContent
=
"两次输入的新密码不一致"
;
return
;
}
if
(
submit
)
submit
.
disabled
=
true
;
try
{
await
api
(
"/account/password"
,
{
method
:
"PUT"
,
body
:
{
currentPassword
,
newPassword
}
});
closePasswordModal
();
alert
(
"密码修改成功,请重新登录后台"
);
showLogin
();
}
catch
(
error
)
{
$
(
"#password-error"
).
textContent
=
error
.
message
;
}
finally
{
if
(
submit
)
submit
.
disabled
=
false
;
}
});
const
statusMap
=
{
const
statusMap
=
{
"new-unsaved"
:
[
"尚未保存"
,
"new-draft"
,
"填写完成后先保存草稿,线上网站不会发生变化。"
],
"new-unsaved"
:
[
"尚未保存"
,
"new-draft"
,
"填写完成后先保存草稿,线上网站不会发生变化。"
],
"new-draft"
:
[
"新建未发布"
,
"new-draft"
,
"这篇资讯目前只有草稿版本,发布后才会出现在官网。"
],
"new-draft"
:
[
"新建未发布"
,
"new-draft"
,
"这篇资讯目前只有草稿版本,发布后才会出现在官网。"
],
...
...
public/admin/style.css
View file @
d87904df
...
@@ -122,6 +122,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,.
...
@@ -122,6 +122,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(255,125,65,.
.modal-card
>
.modal-head
{
padding
:
18px
20px
;
}
.modal-card
>
.modal-head
{
padding
:
18px
20px
;
}
.modal-head
>
div
{
display
:
grid
;
gap
:
4px
;
}
.modal-head
>
div
{
display
:
grid
;
gap
:
4px
;
}
.modal-head
small
{
color
:
var
(
--muted
);
font-size
:
12px
;
font-weight
:
400
;
}
.modal-head
small
{
color
:
var
(
--muted
);
font-size
:
12px
;
font-weight
:
400
;
}
.password-card
{
width
:
min
(
500px
,
100%
);
}
.modal-card
>
.password-form
{
padding
:
20px
;
display
:
grid
;
gap
:
14px
;
border
:
0
;
}
.password-form
label
{
display
:
grid
;
gap
:
7px
;
color
:
var
(
--muted
);
font-size
:
13px
;
}
.password-hint
{
margin
:
0
;
color
:
var
(
--muted
);
font-size
:
12px
;
line-height
:
1.6
;
}
.password-form
.error
{
margin
:
-4px
0
0
;
}
.password-actions
{
display
:
flex
;
justify-content
:
flex-end
;
gap
:
9px
;
}
.modal-card
>
.category-manager-body
{
max-height
:
68vh
;
padding
:
20px
;
display
:
block
;
overflow-y
:
auto
;
border
:
0
;
}
.modal-card
>
.category-manager-body
{
max-height
:
68vh
;
padding
:
20px
;
display
:
block
;
overflow-y
:
auto
;
border
:
0
;
}
.manager-create
{
display
:
flex
;
gap
:
9px
;
}
.manager-create
{
display
:
flex
;
gap
:
9px
;
}
.manager-create
input
{
min-width
:
0
;
}
.manager-create
input
{
min-width
:
0
;
}
...
...
src/lib/cms-api.ts
View file @
d87904df
...
@@ -22,8 +22,8 @@ import {
...
@@ -22,8 +22,8 @@ import {
}
from
"./article-store"
;
}
from
"./article-store"
;
import
{
buildSiteAtomic
,
tryAcquireSiteBuildLock
,
withSiteBuildLock
}
from
"../../scripts/site-build.mjs"
;
import
{
buildSiteAtomic
,
tryAcquireSiteBuildLock
,
withSiteBuildLock
}
from
"../../scripts/site-build.mjs"
;
import
{
importWordArticle
}
from
"./word-import"
;
import
{
importWordArticle
}
from
"./word-import"
;
import
{
currentSessionVersion
,
passwordConfigured
,
savePassword
,
verifyPassword
}
from
"./cms-auth"
;
const
PASSWORD
=
process
.
env
.
CMS_PASSWORD
||
""
;
const
SECRET
=
process
.
env
.
CMS_SECRET
||
crypto
.
randomBytes
(
32
).
toString
(
"hex"
);
const
SECRET
=
process
.
env
.
CMS_SECRET
||
crypto
.
randomBytes
(
32
).
toString
(
"hex"
);
const
API_KEY
=
process
.
env
.
CMS_API_KEY
||
""
;
const
API_KEY
=
process
.
env
.
CMS_API_KEY
||
""
;
const
CAPTCHA_TTL
=
5
*
60
*
1000
;
const
CAPTCHA_TTL
=
5
*
60
*
1000
;
...
@@ -46,7 +46,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
...
@@ -46,7 +46,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
return
left
.
length
===
right
.
length
&&
crypto
.
timingSafeEqual
(
left
,
right
);
return
left
.
length
===
right
.
length
&&
crypto
.
timingSafeEqual
(
left
,
right
);
};
};
const
sessionToken
=
()
=>
crypto
.
createHmac
(
"sha256"
,
SECRET
).
update
(
"jiayun-cms-v1"
).
digest
(
"hex"
);
const
sessionToken
=
async
()
=>
crypto
.
createHmac
(
"sha256"
,
SECRET
)
.
update
(
`jiayun-cms-v2:
${
await
currentSessionVersion
()}
`
)
.
digest
(
"hex"
);
function
cookies
(
request
:
Request
):
Record
<
string
,
string
>
{
function
cookies
(
request
:
Request
):
Record
<
string
,
string
>
{
return
Object
.
fromEntries
((
request
.
headers
.
get
(
"cookie"
)
||
""
).
split
(
";"
).
filter
(
Boolean
).
map
((
part
)
=>
{
return
Object
.
fromEntries
((
request
.
headers
.
get
(
"cookie"
)
||
""
).
split
(
";"
).
filter
(
Boolean
).
map
((
part
)
=>
{
...
@@ -62,8 +64,12 @@ function apiKeyValid(request: Request): boolean {
...
@@ -62,8 +64,12 @@ function apiKeyValid(request: Request): boolean {
return
key
?
safeEqual
(
key
,
API_KEY
)
:
false
;
return
key
?
safeEqual
(
key
,
API_KEY
)
:
false
;
}
}
const
authed
=
(
request
:
Request
):
boolean
=>
const
sessionAuthed
=
async
(
request
:
Request
):
Promise
<
boolean
>
=>
{
Boolean
(
cookies
(
request
).
cms_session
&&
safeEqual
(
cookies
(
request
).
cms_session
,
sessionToken
()))
||
apiKeyValid
(
request
);
const
token
=
cookies
(
request
).
cms_session
;
return
Boolean
(
token
&&
safeEqual
(
token
,
await
sessionToken
()));
};
const
authed
=
async
(
request
:
Request
):
Promise
<
boolean
>
=>
await
sessionAuthed
(
request
)
||
apiKeyValid
(
request
);
async
function
bodyOf
(
request
:
Request
):
Promise
<
Record
<
string
,
unknown
>>
{
async
function
bodyOf
(
request
:
Request
):
Promise
<
Record
<
string
,
unknown
>>
{
try
{
return
await
request
.
json
();
}
catch
{
return
{};
}
try
{
return
await
request
.
json
();
}
catch
{
return
{};
}
...
@@ -75,9 +81,9 @@ function cookieHeader(request: Request, name: string, value: string, maxAge: num
...
@@ -75,9 +81,9 @@ function cookieHeader(request: Request, name: string, value: string, maxAge: num
return
`
${
name
}
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
return
`
${
name
}
=
${
value
}
; HttpOnly; SameSite=Strict; Path=/; Max-Age=
${
maxAge
}${
secure
?
"; Secure"
:
""
}
`
;
}
}
function
login
Allowed
(
ip
:
string
):
boolean
{
function
login
BlockedFor
(
ip
:
string
):
number
{
const
state
=
loginAttempts
.
get
(
ip
);
const
state
=
loginAttempts
.
get
(
ip
);
return
!
state
||
state
.
until
<
Date
.
now
()
;
return
state
?.
until
&&
state
.
until
>
Date
.
now
()
?
Math
.
ceil
((
state
.
until
-
Date
.
now
())
/
1000
)
:
0
;
}
}
function
loginFailed
(
ip
:
string
):
void
{
function
loginFailed
(
ip
:
string
):
void
{
...
@@ -202,29 +208,43 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
...
@@ -202,29 +208,43 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
}
}
if
(
route
===
"session"
)
{
if
(
route
===
"session"
)
{
if
(
method
===
"GET"
)
return
json
({
authed
:
authed
(
request
)
});
if
(
method
===
"GET"
)
return
json
({
authed
:
a
wait
a
uthed
(
request
)
});
if
(
method
===
"POST"
)
{
if
(
method
===
"POST"
)
{
if
(
!
PASSWORD
)
throw
new
StoreError
(
"资讯中心后台尚未配置管理密码"
,
503
);
if
(
!
await
passwordConfigured
())
throw
new
StoreError
(
"资讯中心后台尚未配置管理密码"
,
503
);
if
(
!
loginAllowed
(
clientAddress
))
throw
new
StoreError
(
"登录尝试过于频繁,请稍后再试"
,
429
);
const
blockedFor
=
loginBlockedFor
(
clientAddress
);
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
const
body
=
await
bodyOf
(
request
);
const
body
=
await
bodyOf
(
request
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
const
clearCaptcha
=
cookieHeader
(
request
,
"cms_captcha"
,
""
,
0
);
if
(
!
captchaValid
(
request
,
body
.
captcha
))
{
if
(
!
captchaValid
(
request
,
body
.
captcha
))
{
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
return
json
({
error
:
"验证码错误或已过期"
},
400
,
{
"Set-Cookie"
:
clearCaptcha
});
}
}
if
(
!
safeEqual
(
body
.
password
||
""
,
PASSWORD
))
{
if
(
!
await
verifyPassword
(
body
.
password
))
{
loginFailed
(
clientAddress
);
loginFailed
(
clientAddress
);
return
json
({
error
:
"管理密码错误"
},
401
,
{
"Set-Cookie"
:
clearCaptcha
});
return
json
({
error
:
"管理密码错误"
},
401
,
{
"Set-Cookie"
:
clearCaptcha
});
}
}
loginAttempts
.
delete
(
clientAddress
);
loginAttempts
.
delete
(
clientAddress
);
const
headers
=
new
Headers
();
const
headers
=
new
Headers
();
headers
.
append
(
"Set-Cookie"
,
clearCaptcha
);
headers
.
append
(
"Set-Cookie"
,
clearCaptcha
);
headers
.
append
(
"Set-Cookie"
,
cookieHeader
(
request
,
"cms_session"
,
sessionToken
(),
86400
));
headers
.
append
(
"Set-Cookie"
,
cookieHeader
(
request
,
"cms_session"
,
await
sessionToken
(),
86400
));
return
json
({
ok
:
true
},
200
,
headers
);
return
json
({
ok
:
true
},
200
,
headers
);
}
}
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_session"
,
""
,
0
)
});
if
(
method
===
"DELETE"
)
return
json
({
ok
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_session"
,
""
,
0
)
});
}
}
if
(
!
authed
(
request
))
throw
new
StoreError
(
"请先登录资讯中心后台"
,
401
);
if
(
route
===
"account/password"
&&
method
===
"PUT"
)
{
if
(
!
await
sessionAuthed
(
request
))
throw
new
StoreError
(
"请先登录资讯中心后台"
,
401
);
const
body
=
await
bodyOf
(
request
);
const
currentPassword
=
String
(
body
.
currentPassword
||
""
);
const
newPassword
=
String
(
body
.
newPassword
||
""
);
if
(
!
await
verifyPassword
(
currentPassword
))
throw
new
StoreError
(
"当前密码错误"
,
401
);
if
(
newPassword
.
length
<
5
)
throw
new
StoreError
(
"新密码至少需要 5 个字符"
);
if
(
newPassword
.
length
>
128
)
throw
new
StoreError
(
"新密码不能超过 128 个字符"
);
if
(
safeEqual
(
currentPassword
,
newPassword
))
throw
new
StoreError
(
"新密码不能与当前密码相同"
);
await
savePassword
(
newPassword
);
return
json
({
ok
:
true
,
requiresLogin
:
true
},
200
,
{
"Set-Cookie"
:
cookieHeader
(
request
,
"cms_session"
,
""
,
0
)
});
}
if
(
!
await
authed
(
request
))
throw
new
StoreError
(
"请先登录资讯中心后台"
,
401
);
if
(
route
===
"categories"
)
{
if
(
route
===
"categories"
)
{
if
(
method
===
"GET"
)
return
json
({
categories
:
await
getCategoryNames
(),
stats
:
await
getCategoryStats
()
});
if
(
method
===
"GET"
)
return
json
({
categories
:
await
getCategoryNames
(),
stats
:
await
getCategoryStats
()
});
...
...
src/lib/cms-auth.ts
0 → 100644
View file @
d87904df
import
crypto
from
"node:crypto"
;
import
fs
from
"node:fs/promises"
;
import
path
from
"node:path"
;
const
ROOT
=
process
.
cwd
();
const
DATA_ROOT
=
process
.
env
.
CMS_DATA_DIR
?
path
.
resolve
(
process
.
env
.
CMS_DATA_DIR
)
:
path
.
join
(
ROOT
,
".runtime"
);
const
AUTH_DIR
=
path
.
join
(
DATA_ROOT
,
"auth"
);
const
PASSWORD_FILE
=
path
.
join
(
AUTH_DIR
,
"password.json"
);
const
ENV_PASSWORD
=
process
.
env
.
CMS_PASSWORD
||
""
;
const
HASH_LENGTH
=
64
;
const
SCRYPT_OPTIONS
=
{
N
:
16384
,
r
:
8
,
p
:
1
,
maxmem
:
32
*
1024
*
1024
};
type
PasswordState
=
{
version
:
1
;
salt
:
string
;
hash
:
string
;
sessionVersion
:
number
;
updatedAt
:
string
;
};
const
safeEqual
=
(
leftValue
:
unknown
,
rightValue
:
unknown
):
boolean
=>
{
const
left
=
Buffer
.
from
(
String
(
leftValue
));
const
right
=
Buffer
.
from
(
String
(
rightValue
));
return
left
.
length
===
right
.
length
&&
crypto
.
timingSafeEqual
(
left
,
right
);
};
async
function
readPasswordState
():
Promise
<
PasswordState
|
null
>
{
try
{
const
parsed
=
JSON
.
parse
(
await
fs
.
readFile
(
PASSWORD_FILE
,
"utf8"
))
as
Partial
<
PasswordState
>
;
if
(
parsed
.
version
!==
1
||
typeof
parsed
.
salt
!==
"string"
||
typeof
parsed
.
hash
!==
"string"
||
!
Number
.
isInteger
(
parsed
.
sessionVersion
))
{
throw
new
Error
(
"后台密码文件格式无效"
);
}
return
parsed
as
PasswordState
;
}
catch
(
error
)
{
if
((
error
as
NodeJS
.
ErrnoException
).
code
===
"ENOENT"
)
return
null
;
throw
error
;
}
}
async
function
hashPassword
(
password
:
string
,
salt
:
Buffer
):
Promise
<
Buffer
>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
crypto
.
scrypt
(
password
,
salt
,
HASH_LENGTH
,
SCRYPT_OPTIONS
,
(
error
,
derivedKey
)
=>
{
if
(
error
)
reject
(
error
);
else
resolve
(
derivedKey
);
});
});
}
export
async
function
verifyPassword
(
password
:
unknown
):
Promise
<
boolean
>
{
// Keep the environment password available as a recovery credential.
if
(
ENV_PASSWORD
&&
safeEqual
(
password
||
""
,
ENV_PASSWORD
))
return
true
;
const
state
=
await
readPasswordState
();
if
(
!
state
)
return
false
;
const
hash
=
await
hashPassword
(
String
(
password
||
""
),
Buffer
.
from
(
state
.
salt
,
"base64"
));
return
safeEqual
(
hash
.
toString
(
"base64"
),
state
.
hash
);
}
export
async
function
passwordConfigured
():
Promise
<
boolean
>
{
return
Boolean
(
ENV_PASSWORD
||
await
readPasswordState
());
}
export
async
function
currentSessionVersion
():
Promise
<
number
>
{
try
{
return
(
await
readPasswordState
())?.
sessionVersion
||
0
;
}
catch
(
error
)
{
console
.
error
(
"读取后台密码文件失败:"
,
error
);
return
0
;
}
}
export
async
function
savePassword
(
password
:
string
):
Promise
<
void
>
{
const
previous
=
await
readPasswordState
().
catch
(()
=>
null
);
const
salt
=
crypto
.
randomBytes
(
16
);
const
hash
=
await
hashPassword
(
password
,
salt
);
const
state
:
PasswordState
=
{
version
:
1
,
salt
:
salt
.
toString
(
"base64"
),
hash
:
hash
.
toString
(
"base64"
),
sessionVersion
:
(
previous
?.
sessionVersion
||
0
)
+
1
,
updatedAt
:
new
Date
().
toISOString
(),
};
await
fs
.
mkdir
(
AUTH_DIR
,
{
recursive
:
true
,
mode
:
0o700
});
const
temporaryFile
=
`
${
PASSWORD_FILE
}
.
${
process
.
pid
}
.
${
crypto
.
randomBytes
(
6
).
toString
(
"hex"
)}
.tmp`
;
try
{
await
fs
.
writeFile
(
temporaryFile
,
`
${
JSON
.
stringify
(
state
,
null
,
2
)}
\n`
,
{
mode
:
0o600
,
flag
:
"wx"
});
await
fs
.
rename
(
temporaryFile
,
PASSWORD_FILE
);
}
catch
(
error
)
{
await
fs
.
rm
(
temporaryFile
,
{
force
:
true
}).
catch
(()
=>
{});
throw
error
;
}
}
src/pages/admin/index.astro
View file @
d87904df
...
@@ -30,6 +30,7 @@
...
@@ -30,6 +30,7 @@
<div
class=
"top-actions"
>
<div
class=
"top-actions"
>
<span
id=
"pending-badge"
class=
"pending-badge hidden"
></span>
<span
id=
"pending-badge"
class=
"pending-badge hidden"
></span>
<a
class=
"button ghost"
href=
"/articles/"
target=
"_blank"
rel=
"noopener"
>
查看前台
</a>
<a
class=
"button ghost"
href=
"/articles/"
target=
"_blank"
rel=
"noopener"
>
查看前台
</a>
<button
id=
"change-password"
class=
"ghost"
type=
"button"
>
修改密码
</button>
<button
id=
"logout"
class=
"ghost"
>
退出
</button>
<button
id=
"logout"
class=
"ghost"
>
退出
</button>
</div>
</div>
</header>
</header>
...
@@ -99,6 +100,19 @@
...
@@ -99,6 +100,19 @@
</div>
</div>
</div>
</div>
</div>
</div>
<div
id=
"password-modal"
class=
"modal hidden"
>
<div
class=
"modal-card password-card"
>
<div
class=
"modal-head"
><div><strong>
修改后台密码
</strong><small>
修改成功后需要使用新密码重新登录
</small></div><button
id=
"close-password-modal"
class=
"ghost small"
type=
"button"
>
关闭
</button></div>
<form
id=
"password-form"
class=
"password-form"
>
<label>
当前密码
<input
id=
"current-password"
type=
"password"
autocomplete=
"current-password"
required
/></label>
<label>
新密码
<input
id=
"new-password"
type=
"password"
autocomplete=
"new-password"
minlength=
"5"
maxlength=
"128"
required
/></label>
<label>
确认新密码
<input
id=
"confirm-password"
type=
"password"
autocomplete=
"new-password"
minlength=
"5"
maxlength=
"128"
required
/></label>
<p
class=
"password-hint"
>
新密码至少 5 个字符。
</p>
<p
id=
"password-error"
class=
"error"
></p>
<div
class=
"password-actions"
><button
id=
"cancel-password"
class=
"ghost"
type=
"button"
>
取消
</button><button
class=
"primary"
type=
"submit"
>
确认修改
</button></div>
</form>
</div>
</div>
<div
id=
"build-modal"
class=
"modal hidden"
>
<div
id=
"build-modal"
class=
"modal hidden"
>
<div
class=
"modal-card build-card"
>
<div
class=
"modal-card build-card"
>
<div><strong
id=
"build-title"
>
正在更新官网
</strong><button
id=
"close-modal"
class=
"ghost small hidden"
type=
"button"
>
关闭
</button></div>
<div><strong
id=
"build-title"
>
正在更新官网
</strong><button
id=
"close-modal"
class=
"ghost small hidden"
type=
"button"
>
关闭
</button></div>
...
...
test/auth.test.ts
0 → 100644
View file @
d87904df
import
assert
from
"node:assert/strict"
;
import
fs
from
"node:fs/promises"
;
import
os
from
"node:os"
;
import
path
from
"node:path"
;
import
test
,
{
after
}
from
"node:test"
;
const
dataDirectory
=
await
fs
.
mkdtemp
(
path
.
join
(
os
.
tmpdir
(),
"jiayun-auth-"
));
process
.
env
.
CMS_DATA_DIR
=
dataDirectory
;
process
.
env
.
CMS_PASSWORD
=
"environment-master-password"
;
process
.
env
.
CMS_SECRET
=
"auth-test-session-secret"
;
process
.
env
.
CMS_API_KEY
=
"auth-test-api-key"
;
const
{
handleCmsApi
}
=
await
import
(
"../src/lib/cms-api"
);
let
requestNumber
=
0
;
after
(
async
()
=>
{
await
fs
.
rm
(
dataDirectory
,
{
recursive
:
true
,
force
:
true
});
});
function
cookieFrom
(
response
:
Response
,
name
:
string
):
string
{
const
header
=
response
.
headers
.
get
(
"set-cookie"
)
||
""
;
const
match
=
header
.
match
(
new
RegExp
(
`(?:^|,\\s*)
${
name
}
=([^;]*)`
));
assert
.
ok
(
match
,
`missing
${
name
}
cookie in
${
header
}
`
);
return
`
${
name
}
=
${
match
[
1
]}
`
;
}
async
function
login
(
password
:
string
):
Promise
<
{
response
:
Response
;
cookie
?:
string
}
>
{
requestNumber
+=
1
;
const
captchaResponse
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/captcha"
),
"captcha"
,
`captcha-
${
requestNumber
}
`
,
);
const
code
=
[...(
await
captchaResponse
.
text
()).
matchAll
(
/<text
\b[^
>
]
*>
([^
<
])
<
\/
text>/g
)]
.
map
((
match
)
=>
match
[
1
])
.
join
(
""
);
assert
.
equal
(
code
.
length
,
4
);
const
response
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/session"
,
{
method
:
"POST"
,
headers
:
{
"Content-Type"
:
"application/json"
,
Cookie
:
cookieFrom
(
captchaResponse
,
"cms_captcha"
),
},
body
:
JSON
.
stringify
({
password
,
captcha
:
code
}),
}),
"session"
,
`login-
${
requestNumber
}
`
);
return
{
response
,
cookie
:
response
.
ok
?
cookieFrom
(
response
,
"cms_session"
)
:
undefined
,
};
}
async
function
sessionStatus
(
cookie
:
string
):
Promise
<
boolean
>
{
const
response
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/session"
,
{
headers
:
{
Cookie
:
cookie
},
}),
"session"
);
return
Boolean
((
await
response
.
json
()).
authed
);
}
async
function
changePassword
(
cookie
:
string
,
currentPassword
:
string
,
newPassword
:
string
):
Promise
<
Response
>
{
return
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/account/password"
,
{
method
:
"PUT"
,
headers
:
{
"Content-Type"
:
"application/json"
,
Cookie
:
cookie
},
body
:
JSON
.
stringify
({
currentPassword
,
newPassword
}),
}),
"account/password"
);
}
test
(
"users can change the persisted password while the environment password remains valid"
,
async
()
=>
{
const
initialLogin
=
await
login
(
"environment-master-password"
);
assert
.
equal
(
initialLogin
.
response
.
status
,
200
);
assert
.
ok
(
initialLogin
.
cookie
);
const
tooShort
=
await
changePassword
(
initialLogin
.
cookie
,
"environment-master-password"
,
"abcd"
);
assert
.
equal
(
tooShort
.
status
,
400
);
assert
.
match
((
await
tooShort
.
json
()).
error
,
/至少需要 5 个字符/
);
assert
.
equal
(
await
sessionStatus
(
initialLogin
.
cookie
),
true
);
const
firstPassword
=
"first-user-password"
;
const
changed
=
await
changePassword
(
initialLogin
.
cookie
,
"environment-master-password"
,
firstPassword
);
assert
.
equal
(
changed
.
status
,
200
);
assert
.
match
(
changed
.
headers
.
get
(
"set-cookie"
)
||
""
,
/cms_session=;/
);
assert
.
equal
((
await
changed
.
json
()).
requiresLogin
,
true
);
assert
.
equal
(
await
sessionStatus
(
initialLogin
.
cookie
),
false
);
const
stored
=
await
fs
.
readFile
(
path
.
join
(
dataDirectory
,
"auth"
,
"password.json"
),
"utf8"
);
assert
.
doesNotMatch
(
stored
,
new
RegExp
(
firstPassword
));
const
userLogin
=
await
login
(
firstPassword
);
assert
.
equal
(
userLogin
.
response
.
status
,
200
);
assert
.
ok
(
userLogin
.
cookie
);
assert
.
equal
((
await
login
(
"environment-master-password"
)).
response
.
status
,
200
);
const
secondPassword
=
"abcde"
;
const
changedAgain
=
await
changePassword
(
userLogin
.
cookie
,
firstPassword
,
secondPassword
);
assert
.
equal
(
changedAgain
.
status
,
200
);
assert
.
equal
(
await
sessionStatus
(
userLogin
.
cookie
),
false
);
assert
.
equal
((
await
login
(
firstPassword
)).
response
.
status
,
401
);
assert
.
equal
((
await
login
(
secondPassword
)).
response
.
status
,
200
);
assert
.
equal
((
await
login
(
"environment-master-password"
)).
response
.
status
,
200
);
});
test
(
"an API key alone cannot change the browser login password"
,
async
()
=>
{
const
response
=
await
handleCmsApi
(
new
Request
(
"http://localhost/api/cms/account/password"
,
{
method
:
"PUT"
,
headers
:
{
Authorization
:
"Bearer auth-test-api-key"
,
"Content-Type"
:
"application/json"
,
},
body
:
JSON
.
stringify
({
currentPassword
:
"environment-master-password"
,
newPassword
:
"api-key-must-not-change-this"
,
}),
}),
"account/password"
);
assert
.
equal
(
response
.
status
,
401
);
});
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