Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
S
sumeiqiao
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
sumeiqiao
Commits
d14cf33e
Commit
d14cf33e
authored
Aug 06, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add admin password change
parent
17154010
Pipeline
#459
passed with stage
in 15 seconds
Changes
8
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
294 additions
and
9 deletions
+294
-9
.env.example
.env.example
+1
-0
CMS_README.md
CMS_README.md
+2
-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
+29
-8
cms-auth.ts
src/lib/cms-auth.ts
+88
-0
index.astro
src/pages/admin/index.astro
+14
-0
auth.test.ts
tests/auth.test.ts
+114
-0
No files found.
.env.example
View file @
d14cf33e
# 本地开发配置:复制为 .env 后使用。
# 本地开发配置:复制为 .env 后使用。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
# 生产环境请生成独立密钥,并将数据目录改为仓库外的持久化绝对路径。
# 后台主密码始终有效,用于登录和找回;在后台修改的用户密码会另存于 CMS_DATA_DIR。
CMS_PORT=8789
CMS_PORT=8789
CMS_PASSWORD=replace-with-a-strong-password
CMS_PASSWORD=replace-with-a-strong-password
CMS_SECRET=replace-with-openssl-rand-hex-32
CMS_SECRET=replace-with-openssl-rand-hex-32
...
...
CMS_README.md
View file @
d14cf33e
...
@@ -35,7 +35,7 @@ npm start
...
@@ -35,7 +35,7 @@ npm start
启动命令会自动读取项目根目录的
`.env`
,其中包含:
启动命令会自动读取项目根目录的
`.env`
,其中包含:
-
`CMS_PORT`
:官网与后台共用的服务端口,默认
`8789`
(斯嘉丽项目端口
`8788`
+ 1)
-
`CMS_PORT`
:官网与后台共用的服务端口,默认
`8789`
(斯嘉丽项目端口
`8788`
+ 1)
-
`CMS_PASSWORD`
:后台
登录密码
-
`CMS_PASSWORD`
:后台
主密码;用户在后台修改密码后,该密码仍可用于登录和找回(修改
`.env`
后需重启应用)
-
`CMS_SECRET`
:后台会话签名密钥
-
`CMS_SECRET`
:后台会话签名密钥
-
`CMS_API_KEY`
:外部程序调用管理 API 时使用的 Bearer Token
-
`CMS_API_KEY`
:外部程序调用管理 API 时使用的 Bearer Token
-
`CMS_DATA_DIR`
:生产环境的用户数据目录,建议设置为仓库外的绝对路径
-
`CMS_DATA_DIR`
:生产环境的用户数据目录,建议设置为仓库外的绝对路径
...
@@ -50,6 +50,7 @@ npm start
...
@@ -50,6 +50,7 @@ npm start
| 会话 |
`GET /api/cms/session`
| 查询登录状态 |
| 会话 |
`GET /api/cms/session`
| 查询登录状态 |
| 会话 |
`POST /api/cms/session`
| 登录后台 |
| 会话 |
`POST /api/cms/session`
| 登录后台 |
| 会话 |
`DELETE /api/cms/session`
| 退出登录 |
| 会话 |
`DELETE /api/cms/session`
| 退出登录 |
| 账号 |
`PUT /api/cms/account/password`
| 修改后台用户密码并注销现有会话 |
| 分类 |
`GET /api/cms/categories`
| 分类列表与文章数量 |
| 分类 |
`GET /api/cms/categories`
| 分类列表与文章数量 |
| 分类 |
`POST /api/cms/categories`
| 新建分类 |
| 分类 |
`POST /api/cms/categories`
| 新建分类 |
| 分类 |
`PATCH /api/cms/categories`
| 重命名分类 |
| 分类 |
`PATCH /api/cms/categories`
| 重命名分类 |
...
...
public/admin/app.js
View file @
d14cf33e
...
@@ -65,6 +65,46 @@ $("#login-form").addEventListener("submit", async (event) => {
...
@@ -65,6 +65,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 @
d14cf33e
...
@@ -125,6 +125,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(22,174,156,.
...
@@ -125,6 +125,12 @@ input:focus, textarea:focus, select:focus { outline: 2px solid rgba(22,174,156,.
.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 @
d14cf33e
...
@@ -22,9 +22,9 @@ import {
...
@@ -22,9 +22,9 @@ import {
writeArticle
,
writeArticle
,
}
from
"./article-store"
;
}
from
"./article-store"
;
import
{
convertWordToMarkdown
,
decodeWordDataUrl
}
from
"./word-import"
;
import
{
convertWordToMarkdown
,
decodeWordDataUrl
}
from
"./word-import"
;
import
{
currentSessionVersion
,
savePassword
,
verifyPassword
}
from
"./cms-auth"
;
import
{
buildSiteAtomic
,
tryAcquireSiteBuildLock
,
withSiteBuildLock
}
from
"../../scripts/site-build.mjs"
;
import
{
buildSiteAtomic
,
tryAcquireSiteBuildLock
,
withSiteBuildLock
}
from
"../../scripts/site-build.mjs"
;
const
PASSWORD
=
process
.
env
.
CMS_PASSWORD
||
"admin"
;
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
;
...
@@ -50,7 +50,9 @@ const safeEqual = (leftValue: unknown, rightValue: unknown): boolean => {
...
@@ -50,7 +50,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
(
"sumeiqiao-cms-v1"
).
digest
(
"hex"
);
const
sessionToken
=
async
()
=>
crypto
.
createHmac
(
"sha256"
,
SECRET
)
.
update
(
`sumeiqiao-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
)
=>
{
...
@@ -66,8 +68,12 @@ function apiKeyValid(request: Request): boolean {
...
@@ -66,8 +68,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
{};
}
...
@@ -212,7 +218,7 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
...
@@ -212,7 +218,7 @@ 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"
)
{
const
blockedFor
=
loginBlockedFor
(
clientAddress
);
const
blockedFor
=
loginBlockedFor
(
clientAddress
);
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
if
(
blockedFor
)
return
json
({
error
:
`尝试过于频繁,请
${
Math
.
ceil
(
blockedFor
/
60
)}
分钟后再试`
},
429
);
...
@@ -221,20 +227,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
...
@@ -221,20 +227,35 @@ export async function handleCmsApi(request: Request, routeValue: string, clientA
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 @
d14cf33e
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
||
"admin"
;
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
>
{
// 环境变量密码始终作为主密码和找回密码保留。
if
(
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
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 @
d14cf33e
...
@@ -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>
...
@@ -95,6 +96,19 @@
...
@@ -95,6 +96,19 @@
</main>
</main>
</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=
"category-modal"
class=
"modal hidden"
>
<div
id=
"category-modal"
class=
"modal hidden"
>
<div
class=
"modal-card category-manager"
>
<div
class=
"modal-card category-manager"
>
<div
class=
"modal-head"
><div><strong>
文章分类管理
</strong><small>
重命名会同步更新该分类下的文章
</small></div><button
id=
"close-category-modal"
class=
"ghost small"
type=
"button"
>
关闭
</button></div>
<div
class=
"modal-head"
><div><strong>
文章分类管理
</strong><small>
重命名会同步更新该分类下的文章
</small></div><button
id=
"close-category-modal"
class=
"ghost small"
type=
"button"
>
关闭
</button></div>
...
...
tests/auth.test.ts
0 → 100644
View file @
d14cf33e
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
(),
"sumeiqiao-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 .env 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