Files
alterminal/internal/admin/adminapplications.go
T
2026-10-03 12:37:38 +08:00

485 lines
19 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 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
}