forked from alterminal/alterminal
485 lines
19 KiB
Go
485 lines
19 KiB
Go
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
|
||
}
|