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 呈現) PostLogoutRedirectURIs string // 同上;空時模板顯示 — 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"), PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\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) PostLogoutRedirectURIs string // 同上(選填) 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"), PostLogoutRedirectURIs: r.PostFormValue("post_logout_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"), PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\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 解析 redirect URI textarea 內容:每行一個 URI,去首尾 // 空白(含瀏覽器送出的 \r)後略過空行。 func (f applicationForm) redirectURIList() []string { return uriLines(f.RedirectURIs) } // postLogoutURIList 解析登出後返回 URI textarea 內容(同 redirectURIList)。 func (f applicationForm) postLogoutURIList() []string { return uriLines(f.PostLogoutRedirectURIs) } // uriLines 將 textarea 內容拆為非空行清單。 func uriLines(raw string) []string { var uris []string for _, line := range strings.Split(raw, "\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, form.postLogoutURIList()...) 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, form.postLogoutURIList()...); 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 }