package admin import ( "errors" "log" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "gorm.io/gorm" "alterminal/internal/auth" "alterminal/internal/jwk" ) // adminKeyRow 為金鑰管理頁表格的單列視圖。 type adminKeyRow struct { ID uint Kid string Algorithm string CreatedAt string // 本地時間顯示 Active bool // 使用中(未退休) LastActive bool // 使用中且為最後一把:退休按鈕停用 } // adminKeysPageData 為金鑰管理頁的模板資料。 type adminKeysPageData struct { Error string Username string // 側欄頁尾使用者資訊 Email string CSRF string // 產生/退休表單的 CSRF token Keys []adminKeyRow ActiveCount int // 使用中金鑰數;0 時頁面顯示警告 } // newAdminKeyRows 將金鑰模型轉為表格視圖,並回傳使用中的金鑰數。 // 純函式,便於單元測試。 func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) { for _, k := range keys { if k.Active() { active++ } } rows = make([]adminKeyRow, 0, len(keys)) for _, k := range keys { rows = append(rows, adminKeyRow{ ID: k.ID, Kid: k.Kid, Algorithm: k.Algorithm, CreatedAt: k.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), Active: k.Active(), // 僅剩一把使用中金鑰時禁止退休,確保隨時都有金鑰可簽發 JWT。 LastActive: k.Active() && active == 1, }) } return rows, active } // requireAdmin 驗證請求來自持有效 auth.Session 的管理員:未登入或 auth.Session // 過期時導向 /login(登入後可再試),已登入但非管理員回 403。 // 回傳 auth.Session(含 auth.User)與是否繼續處理。 func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) { c, err := r.Cookie(auth.CookieName) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return nil, false } s, err := auth.GetSession(db, c.Value) switch { case errors.Is(err, auth.ErrSessionExpired): http.Redirect(w, r, "/login", http.StatusSeeOther) return nil, false case err != nil: log.Printf("admin: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return nil, false } if s.User.Role != auth.RoleAdmin { log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username) http.Error(w, "需要管理員權限", http.StatusForbidden) return nil, false } return s, true } // KeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、 // 建立時間、狀態)與產生/退休表單,僅管理員可存取。 func KeysPageHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { return } renderAdminKeysPage(w, r, db, http.StatusOK, s, "") } } // renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status // 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。 // 使用中的金鑰排前、新者在前。 func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string) { var keys []jwk.SigningKey if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil { log.Printf("admin keys: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } token, err := auth.NewCSRFToken(w, r) if err != nil { log.Printf("csrf token: %v", err) http.Error(w, "內部錯誤", http.StatusInternalServerError) return } rows, active := newAdminKeyRows(keys) auth.RenderHTML(w, status, auth.AdminKeysTmpl, adminKeysPageData{ Error: errMsg, Username: s.User.Username, Email: s.User.Email, CSRF: token, Keys: rows, ActiveCount: active, }) } // KeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA // 簽章金鑰,成功後 PRG 導回管理頁。 func KeysCreateHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { return } if err := r.ParseForm(); err != nil { renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") return } if !auth.VerifyCSRF(r) { renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") return } k, err := jwk.NewSigningKey() if err != nil { log.Printf("admin keys: %v", err) renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰產生失敗,請稍後再試") return } if err := db.Create(k).Error; err != nil { log.Printf("admin keys: %v", err) renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰儲存失敗,請稍後再試") return } http.Redirect(w, r, "/admin/keys", http.StatusSeeOther) } } // KeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。 // 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在 // 的金鑰以錯誤訊息重繪頁面。 func KeysRetireHandler(db *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s, ok := requireAdmin(db, w, r) if !ok { return } if err := r.ParseForm(); err != nil { renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") return } if !auth.VerifyCSRF(r) { renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") return } id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) if err != nil { renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在") return } var k jwk.SigningKey switch err := db.First(&k, id).Error; { case errors.Is(err, gorm.ErrRecordNotFound): renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在") return case err != nil: log.Printf("admin keys: %v", err) renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤") return } if !k.Active() { renderAdminKeysPage(w, r, db, http.StatusConflict, s, "金鑰已處於退休狀態") return } var active int64 if err := db.Model(&jwk.SigningKey{}).Where("retired_at IS NULL").Count(&active).Error; err != nil { log.Printf("admin keys: %v", err) renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤") return } if active <= 1 { renderAdminKeysPage(w, r, db, http.StatusConflict, s, "至少須保留一把使用中的金鑰") return } if err := db.Model(&k).Update("retired_at", time.Now()).Error; err != nil { log.Printf("admin keys: %v", err) renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰更新失敗,請稍後再試") return } http.Redirect(w, r, "/admin/keys", http.StatusSeeOther) } }