first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+469
View File
@@ -0,0 +1,469 @@
package admin
import (
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// adminApplicationRow 為應用程式管理頁表格的單列視圖。
type adminApplicationRow struct {
ID uint
ClientID string
Name string
Type string // confidential / public
RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現)
GrantTypes string // 以頓號分隔
Scope string
CreatedAt string // 本地時間顯示
Confidential bool // 機密式才可輪替 client secret
}
// newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。
func newAdminApplicationRows(apps []application.Application) []adminApplicationRow {
rows := make([]adminApplicationRow, 0, len(apps))
for _, a := range apps {
grants := make([]string, len(a.GrantTypes))
for i, g := range a.GrantTypes {
grants[i] = string(g)
}
rows = append(rows, adminApplicationRow{
ID: a.ID,
ClientID: a.ClientID,
Name: a.Name,
Type: string(a.Type),
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
GrantTypes: strings.Join(grants, "、"),
Scope: a.Scope,
CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Confidential: !a.IsPublic(),
})
}
return rows
}
// applicationForm 為註冊表單的視圖狀態:驗證失敗重繪時保留使用者輸入
// (含核取方塊),初次顯示(GET)採 newApplicationForm 的預設值。
type applicationForm struct {
Name string
Type string // confidential / public
RedirectURIs string // textarea 原始內容(每行一個 URI)
Scope string // 留空時使用預設
GrantAuthCode bool
GrantRefresh bool
GrantClientCred bool
}
// newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。
func newApplicationForm() applicationForm {
return applicationForm{Type: string(application.ClientConfidential), GrantAuthCode: true}
}
// applicationFormFromPost 由已解析的表單還原視圖狀態。類型限選單兩值,
// 其餘一律回復為 confidential;grant type 僅接受已知值。
func applicationFormFromPost(r *http.Request) applicationForm {
f := applicationForm{
Name: r.PostFormValue("name"),
Type: r.PostFormValue("type"),
RedirectURIs: r.PostFormValue("redirect_uris"),
Scope: r.PostFormValue("scope"),
}
if f.Type != string(application.ClientPublic) {
f.Type = string(application.ClientConfidential)
}
for _, g := range r.PostForm["grant_types"] {
switch application.GrantType(g) {
case application.GrantAuthorizationCode:
f.GrantAuthCode = true
case application.GrantRefreshToken:
f.GrantRefresh = true
case application.GrantClientCredentials:
f.GrantClientCred = true
}
}
return f
}
// applicationFormFromApp 由既有註冊資料預填表單狀態(GET 編輯頁),
// redirect URI 以每行一個還原為 textarea 內容。
func applicationFormFromApp(a *application.Application) applicationForm {
f := applicationForm{
Name: a.Name,
Type: string(a.Type),
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
Scope: a.Scope,
}
for _, g := range a.GrantTypes {
switch g {
case application.GrantAuthorizationCode:
f.GrantAuthCode = true
case application.GrantRefreshToken:
f.GrantRefresh = true
case application.GrantClientCredentials:
f.GrantClientCred = true
}
}
return f
}
// redirectURIList 解析 textarea 內容:每行一個 URI,去首尾空白(含瀏覽器
// 送出的 \r)後略過空行。
func (f applicationForm) redirectURIList() []string {
var uris []string
for _, line := range strings.Split(f.RedirectURIs, "\n") {
if u := strings.TrimSpace(line); u != "" {
uris = append(uris, u)
}
}
return uris
}
// grantTypeList 依核取狀態列出要啟用的 grant type。
func (f applicationForm) grantTypeList() []application.GrantType {
var gts []application.GrantType
if f.GrantAuthCode {
gts = append(gts, application.GrantAuthorizationCode)
}
if f.GrantRefresh {
gts = append(gts, application.GrantRefreshToken)
}
if f.GrantClientCred {
gts = append(gts, application.GrantClientCredentials)
}
return gts
}
// secretPanel 為註冊與輪替成功的一次性成果面板:直接渲染於 POST 回應
// (資料庫僅存雜湊,明文無法重現,故不採 PRG)。Rotated 區分輪替與註冊
// 的標題文案;公開式 Client 註冊時 Public 為 true 且 Secret 為空,面板
// 改顯示 PKCE 提示而非明文。
type secretPanel struct {
Name string
ClientID string
Secret string // 明文,僅顯示這一次;公開式註冊時為空
Rotated bool // true=client secret 輪替;false=應用程式註冊
Public bool // 公開式 Client(不持有 secret)
}
// adminApplicationsPageData 為應用程式管理頁(列表)的模板資料。
type adminApplicationsPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 輪替/刪除表單的 CSRF token
Apps []adminApplicationRow
Secret *secretPanel // 非空時顯示一次性明文 client secret 面板(輪替)
}
// adminApplicationNewPageData 為註冊新應用程式頁的模板資料。
type adminApplicationNewPageData struct {
Error string
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 註冊表單的 CSRF token
Form applicationForm // 表單狀態(重繪時保留輸入)
Secret *secretPanel // 非空時顯示一次性成果面板(註冊)
}
// adminApplicationEditPageData 為編輯應用程式頁的模板資料。ClientID 與
// 建立時間為唯讀顯示(client_id 不可變更);Success 於更新成功後 PRG
// 回本頁時顯示(?saved=1)。
type adminApplicationEditPageData struct {
Error string
Success string // PRG 後的成功訊息;空字串表示不顯示
Username string // 側欄頁尾使用者資訊
Email string
CSRF string // 編輯表單的 CSRF token
ID uint // 表單 action 的路徑參數
ClientID string // 唯讀顯示(不可變更)
Created string // 建立時間顯示
Form applicationForm // 表單狀態(重繪時保留輸入)
}
// ApplicationsPageHandler 處理 GET /admin/applications:列出已註冊
// 應用程式,僅管理員可存取;註冊表單獨立於 /admin/applications/new。
func ApplicationsPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "", nil)
}
}
// ApplicationNewPageHandler 處理 GET /admin/applications/new:顯示
// 註冊表單(預設值),僅管理員可存取。
func ApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), nil)
}
}
// renderAdminApplicationNewPage 輸出註冊頁;errMsg 非空時以指定 status
// 重繪表單並顯示錯誤(此時 form 保留使用者輸入);secret 非空時顯示一次
// 性成果面板(機密式含明文 client secret)。頁面不查詢列表,不需資料庫。
func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg string, form applicationForm, secret *secretPanel) {
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("admin applications: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
auth.RenderHTML(w, status, auth.AdminApplicationNewTmpl, adminApplicationNewPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Form: form,
Secret: secret,
})
}
// ApplicationEditPageHandler 處理 GET /admin/applications/{id}:顯示編輯
// 表單(預填既有註冊內容),僅管理員可存取。帶 ?saved=1 時顯示儲存成功
// 訊息(Update 成功後 PRG 回本頁)。
func ApplicationEditPageHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
var success string
if r.URL.Query().Get("saved") == "1" {
success = "已儲存變更"
}
renderAdminApplicationEditPage(w, r, http.StatusOK, s, "", success, app, applicationFormFromApp(app))
}
}
// renderAdminApplicationEditPage 輸出編輯頁;errMsg 非空時以指定 status
// 重繪表單並顯示錯誤(此時 form 保留使用者輸入),success 非空時顯示
// PRG 後的成功訊息。a 僅取 ID、client_id 與建立時間(皆不受 Update 的
// 驗證失敗影響)。頁面無一次性資料,不需資料庫。
func renderAdminApplicationEditPage(w http.ResponseWriter, r *http.Request, status int, s *auth.Session, errMsg, success string, a *application.Application, form applicationForm) {
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("admin applications: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
auth.RenderHTML(w, status, auth.AdminApplicationEditTmpl, adminApplicationEditPageData{
Error: errMsg,
Success: success,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
ID: a.ID,
ClientID: a.ClientID,
Created: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
Form: form,
})
}
// renderAdminApplicationsPage 查詢應用程式並輸出管理列表頁;errMsg 非空時
// 以指定 status 重繪頁面並顯示錯誤,secret 非空時顯示一次性明文面板
// (輪替)。s 供側欄頁尾顯示使用者資訊。新註冊的排前。
func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string, secret *secretPanel) {
var apps []application.Application
if err := db.Order("created_at DESC").Find(&apps).Error; err != nil {
log.Printf("admin applications: %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
}
auth.RenderHTML(w, status, auth.AdminApplicationsTmpl, adminApplicationsPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
CSRF: token,
Apps: newAdminApplicationRows(apps),
Secret: secret,
})
}
// ApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並
// 儲存新註冊。成功時直接渲染註冊頁(200)顯示 client_id 與明文 client
// secret——secret 只在本次回應出現,重新整理後即無法再查看,故不適用 PRG。
func ApplicationsCreateHandler(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 {
renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", newApplicationForm(), nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationNewPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", newApplicationForm(), nil)
return
}
form := applicationFormFromPost(r)
app, secret, err := application.NewApplication(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope)
if err != nil {
renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil)
return
}
if err := db.Create(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationNewPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", form, nil)
return
}
// 公開式 Client 不發配 secret,面板改以 PKCE 提示。
panel := &secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Public: secret == ""}
renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), panel)
}
}
// ApplicationUpdateHandler 處理 POST /admin/applications/{id}:驗證並儲存
// 編輯後的註冊內容——client_id 與 client secret 不在此變更(輪替另經
// /{id}/secret);由機密式改為公開式時一併清除 secret 雜湊。成功後 PRG
// 回編輯頁顯示成功訊息(編輯無一次性資料,適用 PRG)。
func ApplicationUpdateHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := requireAdmin(db, w, r)
if !ok {
return
}
// 先載入應用程式:本頁的錯誤重繪需要 client_id 等唯讀欄位。
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "", app, applicationFormFromApp(app))
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationEditPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "", app, applicationFormFromApp(app))
return
}
form := applicationFormFromPost(r)
if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope); err != nil {
renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, err.Error(), "", app, form)
return
}
if err := db.Save(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationEditPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", "", app, form)
return
}
http.Redirect(w, r, fmt.Sprintf("/admin/applications/%d?saved=1", app.ID), http.StatusSeeOther)
}
}
// ApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret:
// 輪替機密式 Client 的 client secret(舊 secret 立即失效),並同面板直接
// 渲染一次性明文;公開式 Client 不持有 secret,回 409。
func ApplicationsRotateSecretHandler(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 {
renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil)
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if app.IsPublic() {
renderAdminApplicationsPage(w, r, db, http.StatusConflict, s, "公開式 Client 不持有 client secret,無法輪替", nil)
return
}
secret, err := app.GenerateSecret()
if err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil)
return
}
if err := db.Model(app).Update("client_secret_hash", app.ClientSecretHash).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "client secret 更新失敗,請稍後再試", nil)
return
}
renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "",
&secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Rotated: true})
}
}
// ApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete:
// 刪除應用程式註冊(連同其 client_id/secret 一併失效),成功後 PRG 導回
// 管理頁。
func ApplicationsDeleteHandler(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 {
renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil)
return
}
if !auth.VerifyCSRF(r) {
renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil)
return
}
app, ok := applicationByID(w, r, db, s)
if !ok {
return
}
if err := db.Delete(app).Error; err != nil {
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "應用程式刪除失敗,請稍後再試", nil)
return
}
http.Redirect(w, r, "/admin/applications", http.StatusSeeOther)
}
}
// applicationByID 依路徑參數 {id} 查詢應用程式;id 格式錯誤或查無資料時
// 以 404 重繪管理頁(訊息「應用程式不存在」),其他錯誤以 500 重繪。
// 回傳應用程式與是否繼續處理。
func applicationByID(w http.ResponseWriter, r *http.Request, db *gorm.DB, s *auth.Session) (*application.Application, bool) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
if err != nil {
renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil)
return nil, false
}
var a application.Application
switch err := db.First(&a, id).Error; {
case errors.Is(err, gorm.ErrRecordNotFound):
renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil)
return nil, false
case err != nil:
log.Printf("admin applications: %v", err)
renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil)
return nil, false
}
return &a, true
}