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, }) }