forked from alterminal/alterminal
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
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,
|
|
})
|
|
}
|