forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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) []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(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(ClientPublic) {
|
||||
f.Type = string(ClientConfidential)
|
||||
}
|
||||
for _, g := range r.PostForm["grant_types"] {
|
||||
switch GrantType(g) {
|
||||
case GrantAuthorizationCode:
|
||||
f.GrantAuthCode = true
|
||||
case GrantRefreshToken:
|
||||
f.GrantRefresh = true
|
||||
case 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() []GrantType {
|
||||
var gts []GrantType
|
||||
if f.GrantAuthCode {
|
||||
gts = append(gts, GrantAuthorizationCode)
|
||||
}
|
||||
if f.GrantRefresh {
|
||||
gts = append(gts, GrantRefreshToken)
|
||||
}
|
||||
if f.GrantClientCred {
|
||||
gts = append(gts, 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 // 非空時顯示一次性成果面板(註冊)
|
||||
}
|
||||
|
||||
// adminApplicationsPageHandler 處理 GET /admin/applications:列出已註冊
|
||||
// 應用程式,僅管理員可存取;註冊表單獨立於 /admin/applications/new。
|
||||
func adminApplicationsPageHandler(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)
|
||||
}
|
||||
}
|
||||
|
||||
// adminApplicationNewPageHandler 處理 GET /admin/applications/new:顯示
|
||||
// 註冊表單(預設值),僅管理員可存取。
|
||||
func adminApplicationNewPageHandler(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 *Session, errMsg string, form applicationForm, secret *secretPanel) {
|
||||
token, err := newCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("admin applications: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderHTML(w, status, adminApplicationNewTmpl, adminApplicationNewPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
CSRF: token,
|
||||
Form: form,
|
||||
Secret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// renderAdminApplicationsPage 查詢應用程式並輸出管理列表頁;errMsg 非空時
|
||||
// 以指定 status 重繪頁面並顯示錯誤,secret 非空時顯示一次性明文面板
|
||||
// (輪替)。s 供側欄頁尾顯示使用者資訊。新註冊的排前。
|
||||
func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string, secret *secretPanel) {
|
||||
var apps []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 := newCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderHTML(w, status, adminApplicationsTmpl, adminApplicationsPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
CSRF: token,
|
||||
Apps: newAdminApplicationRows(apps),
|
||||
Secret: secret,
|
||||
})
|
||||
}
|
||||
|
||||
// adminApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並
|
||||
// 儲存新註冊。成功時直接渲染註冊頁(200)顯示 client_id 與明文 client
|
||||
// secret——secret 只在本次回應出現,重新整理後即無法再查看,故不適用 PRG。
|
||||
func adminApplicationsCreateHandler(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 !verifyCSRF(r) {
|
||||
renderAdminApplicationNewPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", newApplicationForm(), nil)
|
||||
return
|
||||
}
|
||||
form := applicationFormFromPost(r)
|
||||
app, secret, err := NewApplication(form.Name, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// adminApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret:
|
||||
// 輪替機密式 Client 的 client secret(舊 secret 立即失效),並同面板直接
|
||||
// 渲染一次性明文;公開式 Client 不持有 secret,回 409。
|
||||
func adminApplicationsRotateSecretHandler(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 !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})
|
||||
}
|
||||
}
|
||||
|
||||
// adminApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete:
|
||||
// 刪除應用程式註冊(連同其 client_id/secret 一併失效),成功後 PRG 導回
|
||||
// 管理頁。
|
||||
func adminApplicationsDeleteHandler(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 !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 *Session) (*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
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user