forked from alterminal/alterminal
210 lines
6.8 KiB
Go
210 lines
6.8 KiB
Go
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)
|
||
}
|
||
}
|