forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
+209
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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 驗證請求來自持有效 Session 的管理員:未登入或 Session
|
||||
// 過期時導向 /login(登入後可再試),已登入但非管理員回 403。
|
||||
// 回傳 Session(含 User)與是否繼續處理。
|
||||
func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session, bool) {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return nil, false
|
||||
}
|
||||
s, err := getSession(db, c.Value)
|
||||
switch {
|
||||
case errors.Is(err, 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 != RoleAdmin {
|
||||
log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username)
|
||||
http.Error(w, "需要管理員權限", http.StatusForbidden)
|
||||
return nil, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// adminKeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、
|
||||
// 建立時間、狀態)與產生/退休表單,僅管理員可存取。
|
||||
func adminKeysPageHandler(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 *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 := newCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, active := newAdminKeyRows(keys)
|
||||
renderHTML(w, status, adminKeysTmpl, adminKeysPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
CSRF: token,
|
||||
Keys: rows,
|
||||
ActiveCount: active,
|
||||
})
|
||||
}
|
||||
|
||||
// adminKeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA
|
||||
// 簽章金鑰,成功後 PRG 導回管理頁。
|
||||
func adminKeysCreateHandler(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 !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)
|
||||
}
|
||||
}
|
||||
|
||||
// adminKeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。
|
||||
// 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在
|
||||
// 的金鑰以錯誤訊息重繪頁面。
|
||||
func adminKeysRetireHandler(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 !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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user