This commit is contained in:
2026-10-03 12:37:38 +08:00
parent bcf3d3769c
commit 5fc1755c17
22 changed files with 1845 additions and 100 deletions
+237
View File
@@ -0,0 +1,237 @@
package auth
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"gorm.io/gorm"
)
// MinPasswordLen 為密碼最小長度,網頁更新流程與 CLI 帳號作業共用。
const MinPasswordLen = 8
// passwordPageData 為更新密碼頁的模板資料。
type passwordPageData struct {
Error string // 驗證失敗訊息;空字串表示不顯示
Success string // PRG 成功訊息(?saved=1);空字串表示不顯示
Username string // 側欄頁尾使用者資訊
Email string
IsAdmin bool // admin 另顯示管理頁導覽連結
CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替
}
// PasswordPageHandler 處理 GET /password(更新密碼頁):持有效 Session 顯示
// 表單,未登入導向 /login?next=/password(登入後返回)。?saved=1 為 PRG
// 的成功旗標,顯示成功訊息。
func PasswordPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, err := lookupSession(db, r)
if err != nil {
respondSessionError(w, r, err, true)
return
}
var success string
if r.URL.Query().Get("saved") == "1" {
success = "密碼已更新,其他裝置的登入已全部登出。"
}
renderPasswordPage(w, r, http.StatusOK, s, "", success)
}
}
// PasswordChangeHandler 處理 POST /password,依 Content-Type 分流(與
// 登入/登出一致):表單走瀏覽器流程(需通過 CSRF 驗證),JSON 走 API
// 流程。驗證目前密碼後更新密碼,並於單一交易內撤銷該使用者所有
// Session(與 CLI update-password 同一套交易邏輯),再為目前瀏覽器重建
// Session(輪替 Session ID,避免 fixation)——其他裝置立即登出,目前
// 瀏覽器保持登入。成功後表單流程以 303 導回 /password?saved=1(PRG)。
func PasswordChangeHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
var isForm bool
switch {
case strings.HasPrefix(ct, "application/json"):
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"),
strings.HasPrefix(ct, "multipart/form-data"):
isForm = true
default:
WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單")
return
}
s, err := lookupSession(db, r)
if err != nil {
respondSessionError(w, r, err, isForm)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
var in passwordRequest
if isForm {
if err := r.ParseForm(); err != nil {
renderPasswordPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "")
return
}
if !VerifyCSRF(r) {
renderPasswordPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "")
return
}
in = passwordRequest{
CurrentPassword: r.PostFormValue("current_password"),
NewPassword: r.PostFormValue("new_password"),
Confirm: r.PostFormValue("confirm_password"),
}
} else if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
WriteError(w, http.StatusBadRequest, "無法解析請求內容")
return
}
fail := func(status int, msg string) {
if isForm {
renderPasswordPage(w, r, status, s, msg, "")
return
}
WriteError(w, status, msg)
}
if err := in.validate(isForm); err != nil {
fail(http.StatusBadRequest, err.Error())
return
}
if !s.User.CheckPassword(in.CurrentPassword) {
fail(http.StatusUnauthorized, "目前的密碼錯誤")
return
}
if err := s.User.SetPassword(in.NewPassword); err != nil {
log.Printf("password: %v", err)
fail(http.StatusInternalServerError, "內部錯誤")
return
}
if _, err := UpdateUserPassword(db, &s.User); err != nil {
log.Printf("password: %v", err)
fail(http.StatusInternalServerError, "內部錯誤")
return
}
// 舊 Session 已隨交易撤銷,重建目前瀏覽器的 Session;失敗時密碼
// 已更新,只能請使用者以新密碼重新登入。
ns, err := CreateSession(db, s.User.ID)
if err != nil {
log.Printf("password: %v", err)
clearSessionCookie(w, r)
if isForm {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
WriteError(w, http.StatusInternalServerError, "密碼已更新,請重新登入")
return
}
setSessionCookie(w, r, ns)
if isForm {
// PRG:以 303 導回本頁以 ?saved=1 顯示成功訊息,避免重新整理
// 重複送出表單。
http.Redirect(w, r, "/password?saved=1", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// passwordRequest 為 POST /password 的請求欄位(JSON 與表單共用);
// 表單流程另以 Confirm 重複輸入新密碼,JSON 流程由呼叫端自行確認。
type passwordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
Confirm string `json:"-"` // 僅表單流程的 confirm_password 欄位
}
// validate 檢查欄位:目前與新密碼不可為空、新密碼長度須達最小值且不得
// 與目前密碼相同;表單流程另檢查確認欄位與新密碼一致。
func (in *passwordRequest) validate(isForm bool) error {
if in.CurrentPassword == "" {
return errors.New("目前的密碼不可為空")
}
if in.NewPassword == "" {
return errors.New("新密碼不可為空")
}
if len(in.NewPassword) < MinPasswordLen {
return fmt.Errorf("密碼長度至少 %d 字元", MinPasswordLen)
}
if isForm && in.Confirm != in.NewPassword {
return errors.New("兩次輸入的新密碼不一致")
}
if in.NewPassword == in.CurrentPassword {
return errors.New("新密碼不可與目前的密碼相同")
}
return nil
}
// UpdateUserPassword 於單一交易內寫入新密碼雜湊(須先 SetPassword)並
// 刪除該使用者所有 Session,回傳撤銷的 Session 數;交易確保密碼與
// Session 不會只更新一半。供網頁的更新密碼流程與 CLI update-password
// 共用同一套邏輯。
func UpdateUserPassword(db *gorm.DB, u *User) (int64, error) {
var revoked int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
return fmt.Errorf("update password: %w", err)
}
res := tx.Where("user_id = ?", u.ID).Delete(&Session{})
if res.Error != nil {
return fmt.Errorf("delete sessions: %w", res.Error)
}
revoked = res.RowsAffected
return nil
})
return revoked, err
}
// lookupSession 由 Cookie 查詢效期內 Session(含使用者);未帶 Cookie
// 與 Session 無效一併回 ErrSessionExpired,不洩漏差異。
func lookupSession(db *gorm.DB, r *http.Request) (*Session, error) {
c, err := r.Cookie(CookieName)
if err != nil {
return nil, ErrSessionExpired
}
return GetSession(db, c.Value)
}
// respondSessionError 依流程處理 lookupSession 的錯誤:未登入在表單
// 流程導向 /login?next=/password(登入後返回原頁),JSON 流程回 401;
// 其餘錯誤記錄後回 500。
func respondSessionError(w http.ResponseWriter, r *http.Request, err error, isForm bool) {
switch {
case errors.Is(err, ErrSessionExpired):
if isForm {
http.Redirect(w, r, "/login?next="+url.QueryEscape("/password"), http.StatusSeeOther)
return
}
WriteError(w, http.StatusUnauthorized, "須登入")
default:
log.Printf("password: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
}
}
// renderPasswordPage 輸出更新密碼頁;每次輸出都輪替 CSRF token。密碼
// 欄位一律不預填,驗證失敗重繪時也不保留輸入。
func renderPasswordPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg, success string) {
token, err := NewCSRFToken(w, r)
if err != nil {
log.Printf("csrf token: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
RenderHTML(w, status, passwordTmpl, passwordPageData{
Error: errMsg,
Success: success,
Username: s.User.Username,
Email: s.User.Email,
IsAdmin: s.User.Role == RoleAdmin,
CSRF: token,
})
}