Files
alterminal/adminkeys.go
T

210 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}