forked from alterminal/alterminal
first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/testdb"
|
||||
)
|
||||
|
||||
// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
|
||||
// 因此 handler 可傳入 nil db。
|
||||
func TestAdminApplicationsHandlersRequireLogin(t *testing.T) {
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"GET 列表": ApplicationsPageHandler(nil),
|
||||
"GET 註冊頁": ApplicationNewPageHandler(nil),
|
||||
"POST 註冊": ApplicationsCreateHandler(nil),
|
||||
"GET 編輯頁": ApplicationEditPageHandler(nil),
|
||||
"POST 更新": ApplicationUpdateHandler(nil),
|
||||
"POST 輪替": ApplicationsRotateSecretHandler(nil),
|
||||
"POST 刪除": ApplicationsDeleteHandler(nil),
|
||||
}
|
||||
for name, h := range handlers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/admin/applications", nil))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdminApplicationRows(t *testing.T) {
|
||||
apps := []application.Application{
|
||||
{
|
||||
ID: 1, ClientID: "cid-a", Name: "官方網站", Type: application.ClientConfidential,
|
||||
RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"},
|
||||
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken},
|
||||
Scope: "openid offline_access", CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
ID: 2, ClientID: "cid-b", Name: "行動 App", Type: application.ClientPublic,
|
||||
RedirectURIs: application.RedirectURIs{"com.example.app:/cb"},
|
||||
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode},
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
rows := newAdminApplicationRows(apps)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %d 筆, want 2", len(rows))
|
||||
}
|
||||
if rows[0].RedirectURIs != "https://a.example.com/cb\nhttps://a.example.com/alt" {
|
||||
t.Errorf("RedirectURIs 應以換行分隔,得到 %q", rows[0].RedirectURIs)
|
||||
}
|
||||
if rows[0].GrantTypes != "authorization_code、refresh_token" {
|
||||
t.Errorf("GrantTypes 應以頓號分隔,得到 %q", rows[0].GrantTypes)
|
||||
}
|
||||
if rows[0].CreatedAt == "" {
|
||||
t.Error("CreatedAt 應格式化為本地時間字串")
|
||||
}
|
||||
if !rows[0].Confidential {
|
||||
t.Error("機密式應標記 Confidential(顯示輪替表單)")
|
||||
}
|
||||
if rows[1].Confidential || rows[1].Type != "public" {
|
||||
t.Errorf("公開式 row 不應標記 Confidential,Type = %q", rows[1].Type)
|
||||
}
|
||||
if rows[1].GrantTypes != "authorization_code" {
|
||||
t.Errorf("單一 grant type 不應有分隔符,得到 %q", rows[1].GrantTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationFormDefaults(t *testing.T) {
|
||||
f := newApplicationForm()
|
||||
if f.Type != string(application.ClientConfidential) {
|
||||
t.Errorf("預設類型應為 confidential,得到 %q", f.Type)
|
||||
}
|
||||
if !f.GrantAuthCode || f.GrantRefresh || f.GrantClientCred {
|
||||
t.Error("預設應僅勾選 authorization_code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationFormFromPost(t *testing.T) {
|
||||
vals := url.Values{
|
||||
"name": {"示範應用"},
|
||||
"type": {"public"},
|
||||
"redirect_uris": {"https://a.example.com/cb\r\ncom.example.app:/cb\r\n\r\n https://b.example.com/cb \n"},
|
||||
"grant_types": {"refresh_token"},
|
||||
"scope": {"openid"},
|
||||
}
|
||||
vals.Add("grant_types", "client_credentials")
|
||||
vals.Add("grant_types", "implicit") // 未知值應略過
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/applications", strings.NewReader(vals.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
f := applicationFormFromPost(req)
|
||||
if f.Name != "示範應用" || f.Type != "public" || f.Scope != "openid" {
|
||||
t.Errorf("基本欄位還原不符:%+v", f)
|
||||
}
|
||||
if !f.GrantRefresh || !f.GrantClientCred || f.GrantAuthCode {
|
||||
t.Errorf("核取狀態還原不符:%+v", f)
|
||||
}
|
||||
wantURIs := []string{"https://a.example.com/cb", "com.example.app:/cb", "https://b.example.com/cb"}
|
||||
if got := f.redirectURIList(); !reflect.DeepEqual(got, wantURIs) {
|
||||
t.Errorf("redirectURIList = %v, want %v(每行一個、去空白、略過空行)", got, wantURIs)
|
||||
}
|
||||
wantGrants := []application.GrantType{application.GrantRefreshToken, application.GrantClientCredentials}
|
||||
if got := f.grantTypeList(); !reflect.DeepEqual(got, wantGrants) {
|
||||
t.Errorf("grantTypeList = %v, want %v", got, wantGrants)
|
||||
}
|
||||
}
|
||||
|
||||
// 類型選單僅兩值,偽造的值一律回復為 confidential。
|
||||
func TestApplicationFormFromPostInvalidType(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/applications",
|
||||
strings.NewReader("name=A&type=webapp"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if f := applicationFormFromPost(req); f.Type != string(application.ClientConfidential) {
|
||||
t.Errorf("非法類型應回復 confidential,得到 %q", f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// applicationFormFromApp:預填既有註冊資料(redirect URI 每行一個)。
|
||||
func TestApplicationFormFromApp(t *testing.T) {
|
||||
a := &application.Application{
|
||||
Name: "官方網站",
|
||||
Type: application.ClientConfidential,
|
||||
RedirectURIs: application.RedirectURIs{"https://a.example.com/cb", "com.example.app:/cb"},
|
||||
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken, application.GrantClientCredentials},
|
||||
Scope: "openid profile offline_access",
|
||||
}
|
||||
f := applicationFormFromApp(a)
|
||||
if f.Name != "官方網站" || f.Type != "confidential" || f.Scope != "openid profile offline_access" {
|
||||
t.Errorf("基本欄位預填不符:%+v", f)
|
||||
}
|
||||
if f.RedirectURIs != "https://a.example.com/cb\ncom.example.app:/cb" {
|
||||
t.Errorf("RedirectURIs 應以換行分隔預填,得到 %q", f.RedirectURIs)
|
||||
}
|
||||
if !f.GrantAuthCode || !f.GrantRefresh || !f.GrantClientCred {
|
||||
t.Errorf("核取狀態預填不符:%+v", f)
|
||||
}
|
||||
// 預填後再以 redirectURIList 解析應還原為原清單(textarea 往返)。
|
||||
want := []string{"https://a.example.com/cb", "com.example.app:/cb"}
|
||||
if got := f.redirectURIList(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("redirectURIList = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// renderAdminApplicationsPage 需要資料庫,模板輸出直接以假資料渲染測試。
|
||||
func TestAdminApplicationsTemplate(t *testing.T) {
|
||||
data := adminApplicationsPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
|
||||
Apps: []adminApplicationRow{
|
||||
{ID: 9, ClientID: "cid-conf", Name: "官方網站", Type: "confidential",
|
||||
RedirectURIs: "https://app.example.com/cb", GrantTypes: "authorization_code、refresh_token",
|
||||
Scope: "openid offline_access", CreatedAt: "2026-10-02 12:00:00 +08:00", Confidential: true},
|
||||
{ID: 5, ClientID: "cid-pub", Name: "行動 App", Type: "public",
|
||||
RedirectURIs: "com.example.app:/cb", GrantTypes: "authorization_code",
|
||||
Scope: "openid", CreatedAt: "2026-10-01 12:00:00 +08:00"},
|
||||
},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"應用程式管理", // 標題
|
||||
`href="/admin/applications/new"`, // 註冊新應用程式按鈕(獨立頁)
|
||||
`href="/admin/applications/9"`, // 編輯連結(機密式)
|
||||
`href="/admin/applications/5"`, // 編輯連結(公開式)
|
||||
`value="token-A"`, // CSRF 隱藏欄位
|
||||
`action="/admin/applications/9/secret"`, // 機密式的輪替表單
|
||||
`action="/admin/applications/9/delete"`, // 刪除表單
|
||||
`action="/admin/applications/5/delete"`,
|
||||
"cid-conf", "cid-pub", // client_id 欄
|
||||
"機密式", "公開式", // 類型徽章
|
||||
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁)
|
||||
`href="/admin/keys"`, // 導覽(金鑰管理)
|
||||
`href="/login"`, // 導覽(帳號資訊)
|
||||
`action="/logout"`, // 側欄頁尾登出表單(版面預設)
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("應用程式管理頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
for _, absent := range []string{
|
||||
"/admin/applications/5/secret", // 公開式無 secret,不應出現輪替表單
|
||||
"尚無應用程式",
|
||||
"只顯示這一次", // 未輪替 secret 時不出現明文面板
|
||||
`name="redirect_uris"`, // 註冊表單已獨立於 /admin/applications/new
|
||||
`action="/admin/applications"`, // 註冊不再 POST 到列表頁
|
||||
} {
|
||||
if strings.Contains(body, absent) {
|
||||
t.Errorf("頁面不應出現 %s", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊頁模板:表單欄位與送出目標,導覽同管理頁。
|
||||
func TestAdminApplicationNewTemplate(t *testing.T) {
|
||||
data := adminApplicationNewPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
|
||||
Form: newApplicationForm(),
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"註冊新應用程式", // 標題
|
||||
`action="/admin/applications/new"`, // 表單送回本頁
|
||||
`value="token-N"`, // CSRF 隱藏欄位
|
||||
`name="name"`,
|
||||
`name="redirect_uris"`,
|
||||
`value="authorization_code"`, // grant type 核取方塊(預設勾選)
|
||||
`checked`, // 預設勾選狀態
|
||||
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁)
|
||||
`href="/admin/keys"`,
|
||||
`href="/login"`,
|
||||
`action="/logout"`,
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("註冊頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "只顯示這一次") {
|
||||
t.Error("未註冊成功時不應出現明文面板")
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊機密式成功的一次性明文 client secret 面板(渲染於註冊頁)。
|
||||
func TestAdminApplicationNewTemplateSecretPanel(t *testing.T) {
|
||||
data := adminApplicationNewPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
|
||||
Form: newApplicationForm(),
|
||||
Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value"},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"已註冊", "只顯示這一次", "cid-conf", "plain-secret-value"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("secret 面板缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "已輪替") {
|
||||
t.Error("註冊面板不應出現輪替文案")
|
||||
}
|
||||
}
|
||||
|
||||
// 註冊公開式成功的面板:無明文 secret,改顯示 PKCE 提示。
|
||||
func TestAdminApplicationNewTemplatePublicPanel(t *testing.T) {
|
||||
data := adminApplicationNewPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-N",
|
||||
Form: newApplicationForm(),
|
||||
Secret: &secretPanel{Name: "行動 App", ClientID: "cid-pub", Public: true},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationNewTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"行動 App 已註冊", "PKCE", "cid-pub"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("公開式面板缺少 %s", want)
|
||||
}
|
||||
}
|
||||
for _, absent := range []string{"只顯示這一次", "client_secret"} {
|
||||
if strings.Contains(body, absent) {
|
||||
t.Errorf("公開式面板不應出現 %s", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 編輯頁模板:唯讀欄位(client_id)、預填表單與送出目標;無一次性面板。
|
||||
func TestAdminApplicationEditTemplate(t *testing.T) {
|
||||
data := adminApplicationEditPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-E",
|
||||
ID: 9, ClientID: "cid-conf", Created: "2026-10-02 12:00:00 +08:00",
|
||||
Form: applicationFormFromApp(&application.Application{
|
||||
Name: "官方網站",
|
||||
Type: application.ClientConfidential,
|
||||
RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"},
|
||||
GrantTypes: application.GrantTypes{application.GrantAuthorizationCode, application.GrantRefreshToken},
|
||||
Scope: "openid offline_access",
|
||||
}),
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"編輯應用程式", // 標題
|
||||
`action="/admin/applications/9"`, // 表單送回本頁
|
||||
`value="token-E"`, // CSRF 隱藏欄位
|
||||
"cid-conf", // client_id 唯讀顯示
|
||||
"2026-10-02 12:00:00 +08:00", // 建立時間(html/template 將 + 轉義)
|
||||
`value="官方網站"`, // 名稱預填
|
||||
`>https://app.example.com/cb</textarea>`, // redirect URI 預填
|
||||
`value="openid offline_access"`, // scope 預填
|
||||
"checked", // 已啟用 grant type 的核取狀態
|
||||
"儲存變更", // 送出按鈕
|
||||
`href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁)
|
||||
`href="/admin/keys"`,
|
||||
`href="/login"`,
|
||||
`action="/logout"`,
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("編輯頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
for _, absent := range []string{
|
||||
"只顯示這一次", // 編輯無一次性面板
|
||||
"已儲存變更", // 未帶 ?saved=1 時不出現成功訊息
|
||||
`action="/admin/applications/new"`, // 不應送回註冊頁
|
||||
} {
|
||||
if strings.Contains(body, absent) {
|
||||
t.Errorf("編輯頁不應出現 %s", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRG(?saved=1)後的編輯頁顯示成功訊息。
|
||||
func TestAdminApplicationEditTemplateSaved(t *testing.T) {
|
||||
data := adminApplicationEditPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-E",
|
||||
ID: 9, ClientID: "cid-conf", Success: "已儲存變更",
|
||||
Form: applicationFormFromApp(&application.Application{
|
||||
Name: "官方網站", Type: application.ClientConfidential,
|
||||
RedirectURIs: application.RedirectURIs{"https://app.example.com/cb"},
|
||||
}),
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationEditTmpl, data)
|
||||
if body := rec.Body.String(); !strings.Contains(body, "已儲存變更") {
|
||||
t.Error("帶 Success 時應顯示成功訊息")
|
||||
}
|
||||
}
|
||||
|
||||
// 輪替成功的一次性明文面板(渲染於管理列表頁)。
|
||||
func TestAdminApplicationsTemplateRotatePanel(t *testing.T) {
|
||||
data := adminApplicationsPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
|
||||
Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value", Rotated: true},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"已輪替", "只顯示這一次", "cid-conf", "plain-secret-value"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("輪替面板缺少 %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 無應用程式時顯示空狀態提示(註冊表單已獨立,不再內嵌於列表頁)。
|
||||
func TestAdminApplicationsTemplateEmpty(t *testing.T) {
|
||||
data := adminApplicationsPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminApplicationsTmpl, data)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "尚無應用程式") {
|
||||
t.Error("應顯示空狀態提示")
|
||||
}
|
||||
if !strings.Contains(body, `href="/admin/applications/new"`) {
|
||||
t.Error("空狀態仍應提供前往註冊頁的按鈕")
|
||||
}
|
||||
if strings.Contains(body, `name="redirect_uris"`) {
|
||||
t.Error("列表頁不應內嵌註冊表單")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
|
||||
|
||||
// secretInBody 從頁面抽出一次性明文 client secret:面板以 <code>/<dd> 包裹
|
||||
// 43 字元 base64url(CSRF token 在屬性值內、client_id 僅 22 字元,皆不符)。
|
||||
var secretInBody = regexp.MustCompile(`>([A-Za-z0-9_-]{43})<`)
|
||||
|
||||
func TestAdminApplicationsIntegration(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
|
||||
admin := &auth.User{Username: "appadmin", Email: "appadmin@example.com", Role: auth.RoleAdmin}
|
||||
if err := admin.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member := &auth.User{Username: "appuser", Email: "appuser@example.com", Role: auth.RoleUser}
|
||||
if err := member.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(member).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSess, err := auth.CreateSession(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberSess, err := auth.CreateSession(db, member.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/admin/applications", ApplicationsPageHandler(db))
|
||||
r.Get("/admin/applications/new", ApplicationNewPageHandler(db))
|
||||
r.Post("/admin/applications/new", ApplicationsCreateHandler(db))
|
||||
r.Get("/admin/applications/{id}", ApplicationEditPageHandler(db))
|
||||
r.Post("/admin/applications/{id}", ApplicationUpdateHandler(db))
|
||||
r.Post("/admin/applications/{id}/secret", ApplicationsRotateSecretHandler(db))
|
||||
r.Post("/admin/applications/{id}/delete", ApplicationsDeleteHandler(db))
|
||||
|
||||
appCount := func(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.Model(&application.Application{}).Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
t.Run("非 admin 存取回 403", func(t *testing.T) {
|
||||
for _, path := range []string{"/admin/applications", "/admin/applications/new", "/admin/applications/1"} {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet(path, memberSess))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("GET %s status = %d, want 403", path, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin 首次檢視為空狀態", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/applications", adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "尚無應用程式") {
|
||||
t.Fatalf("應顯示空狀態提示:%s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin 檢視註冊頁含表單", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `action="/admin/applications/new"`) || !strings.Contains(body, `name="redirect_uris"`) {
|
||||
t.Fatal("註冊頁應含送回本頁的表單")
|
||||
}
|
||||
})
|
||||
|
||||
// currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次
|
||||
// 渲染都會輪替);註冊表單位於 /admin/applications/new。
|
||||
currentCSRF := func(t *testing.T) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/applications/new status = %d", rec.Code)
|
||||
}
|
||||
return csrfCookieOf(t, rec)
|
||||
}
|
||||
|
||||
postForm := func(t *testing.T, path string, vals url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
cookie := currentCSRF(t)
|
||||
vals.Set("csrf_token", cookie.Value)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost(path, vals.Encode(), adminSess, cookie))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("CSRF 不符回 403", func(t *testing.T) {
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost("/admin/applications/new", "csrf_token=wrong", adminSess, cookie))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應顯示 CSRF 錯誤訊息")
|
||||
}
|
||||
})
|
||||
|
||||
var secret1 string
|
||||
t.Run("註冊機密式應用程式顯示一次性 secret", func(t *testing.T) {
|
||||
rec := postForm(t, "/admin/applications/new", url.Values{
|
||||
"name": {"官方網站"},
|
||||
"type": {"confidential"},
|
||||
"redirect_uris": {"https://app.example.com/oidc/callback"},
|
||||
"grant_types": {"authorization_code", "refresh_token"},
|
||||
"scope": {"openid offline_access"},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "只顯示這一次") {
|
||||
t.Fatal("應顯示一次性 secret 面板")
|
||||
}
|
||||
m := secretInBody.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatal("頁面應包含 43 字元明文 client secret")
|
||||
}
|
||||
secret1 = m[1]
|
||||
|
||||
var app application.Application
|
||||
if err := db.First(&app).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if app.Name != "官方網站" || app.IsPublic() || !app.CheckSecret(secret1) {
|
||||
t.Errorf("儲存的應用程式與表單輸入不符或 secret 驗證失敗:%+v", app)
|
||||
}
|
||||
if !app.GrantTypes.Contains(application.GrantRefreshToken) {
|
||||
t.Errorf("應啟用 refresh_token,得到 %v", app.GrantTypes)
|
||||
}
|
||||
if !strings.Contains(body, app.ClientID) {
|
||||
t.Error("頁面應顯示新註冊的 client_id")
|
||||
}
|
||||
if n := appCount(t); n != 1 {
|
||||
t.Fatalf("資料庫應用程式數 = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("註冊驗證失敗回 400 並保留輸入", func(t *testing.T) {
|
||||
rec := postForm(t, "/admin/applications/new", url.Values{
|
||||
"name": {"後台系統"},
|
||||
"type": {"confidential"},
|
||||
"redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "loopback") {
|
||||
t.Fatal("應顯示 redirect URI 驗證錯誤")
|
||||
}
|
||||
if !strings.Contains(body, `value="後台系統"`) {
|
||||
t.Fatal("重繪時應保留已輸入的名稱")
|
||||
}
|
||||
if n := appCount(t); n != 1 {
|
||||
t.Fatalf("驗證失敗不應寫入,資料庫應用程式數 = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
|
||||
var publicApp application.Application
|
||||
t.Run("註冊公開式應用程式顯示 PKCE 面板", func(t *testing.T) {
|
||||
rec := postForm(t, "/admin/applications/new", url.Values{
|
||||
"name": {"行動 App"},
|
||||
"type": {"public"},
|
||||
"redirect_uris": {"com.example.app:/cb"},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "行動 App 已註冊") || !strings.Contains(body, "PKCE") {
|
||||
t.Fatal("公開式註冊成功應顯示 PKCE 面板")
|
||||
}
|
||||
if strings.Contains(body, "只顯示這一次") {
|
||||
t.Fatal("公開式無 client secret,不應顯示明文警告")
|
||||
}
|
||||
if err := db.Where("type = ?", application.ClientPublic).First(&publicApp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(body, publicApp.ClientID) {
|
||||
t.Error("面板應顯示新註冊的 client_id")
|
||||
}
|
||||
if n := appCount(t); n != 2 {
|
||||
t.Fatalf("資料庫應用程式數 = %d, want 2", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("輪替機密式 secret", func(t *testing.T) {
|
||||
var conf application.Application
|
||||
if err := db.Where("type = ?", application.ClientConfidential).First(&conf).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
m := secretInBody.FindStringSubmatch(rec.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("輪替後應顯示新的明文 client secret")
|
||||
}
|
||||
|
||||
var reloaded application.Application
|
||||
if err := db.First(&reloaded, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.CheckSecret(secret1) {
|
||||
t.Error("輪替後舊 client secret 應失效")
|
||||
}
|
||||
if !reloaded.CheckSecret(m[1]) {
|
||||
t.Error("新 client secret 應可驗證")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("輪替公開式回 409", func(t *testing.T) {
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", publicApp.ID), url.Values{})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "公開式 Client 不持有 client secret") {
|
||||
t.Fatal("應顯示公開式不可輪替的訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("刪除應用程式後 PRG 導回", func(t *testing.T) {
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/delete", publicApp.ID), url.Values{})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/admin/applications" {
|
||||
t.Fatalf("Location = %q, want /admin/applications", loc)
|
||||
}
|
||||
if n := appCount(t); n != 1 {
|
||||
t.Fatalf("刪除後資料庫應用程式數 = %d, want 1", n)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/applications", adminSess))
|
||||
if strings.Contains(rec.Body.String(), "行動 App") {
|
||||
t.Error("刪除後列表不應再出現該應用程式")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("刪除不存在的應用程式回 404", func(t *testing.T) {
|
||||
rec := postForm(t, "/admin/applications/99999/delete", url.Values{})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "應用程式不存在") {
|
||||
t.Fatal("應顯示應用程式不存在")
|
||||
}
|
||||
})
|
||||
|
||||
// 以下編輯流程子測試:此時資料庫僅剩註冊時輪替過一次 secret 的機密式
|
||||
// 應用程式(公開式已於前述子測試刪除)。
|
||||
confidential := func(t *testing.T) application.Application {
|
||||
t.Helper()
|
||||
var a application.Application
|
||||
if err := db.Where("type = ?", application.ClientConfidential).First(&a).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
t.Run("編輯頁預填既有註冊內容", func(t *testing.T) {
|
||||
conf := confidential(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d", conf.ID), adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf(`action="/admin/applications/%d"`, conf.ID),
|
||||
conf.ClientID, // 唯讀顯示
|
||||
`value="` + conf.Name + `"`, // 名稱預填
|
||||
conf.RedirectURIs[0], // redirect URI 預填
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("編輯頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("編輯不存在的應用程式回 404", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/applications/99999", adminSess))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "應用程式不存在") {
|
||||
t.Fatal("應顯示應用程式不存在")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("編輯儲存後 PRG 並更新資料庫", func(t *testing.T) {
|
||||
conf := confidential(t)
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
|
||||
"name": {"官方網站 2.0"},
|
||||
"type": {"confidential"},
|
||||
"redirect_uris": {"https://app.example.com/oidc/callback\nhttps://alt.example.com/cb"},
|
||||
"grant_types": {"authorization_code", "refresh_token", "client_credentials"},
|
||||
"scope": {"openid profile email offline_access"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if want := fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID); rec.Header().Get("Location") != want {
|
||||
t.Fatalf("Location = %q, want %q", rec.Header().Get("Location"), want)
|
||||
}
|
||||
|
||||
var reloaded application.Application
|
||||
if err := db.First(&reloaded, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Name != "官方網站 2.0" || reloaded.ClientID != conf.ClientID {
|
||||
t.Errorf("名稱應更新且 client_id 不變:%+v", reloaded)
|
||||
}
|
||||
if len(reloaded.RedirectURIs) != 2 || !reloaded.RedirectURIs.Contains("https://alt.example.com/cb") {
|
||||
t.Errorf("RedirectURIs 應更新,得到 %v", reloaded.RedirectURIs)
|
||||
}
|
||||
if !reloaded.GrantTypes.Contains(application.GrantClientCredentials) {
|
||||
t.Errorf("GrantTypes 應更新,得到 %v", reloaded.GrantTypes)
|
||||
}
|
||||
if reloaded.ClientSecretHash != conf.ClientSecretHash {
|
||||
t.Error("編輯不應更動 client secret 雜湊")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PRG 後的編輯頁顯示成功訊息與新值", func(t *testing.T) {
|
||||
conf := confidential(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet(fmt.Sprintf("/admin/applications/%d?saved=1", conf.ID), adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"已儲存變更", `value="官方網站 2.0"`, "https://alt.example.com/cb"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("儲存後的編輯頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("編輯驗證失敗回 400 保留輸入且不寫入", func(t *testing.T) {
|
||||
conf := confidential(t)
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
|
||||
"name": {"壞 URI 練習"},
|
||||
"type": {"confidential"},
|
||||
"redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "loopback") {
|
||||
t.Fatal("應顯示 redirect URI 驗證錯誤")
|
||||
}
|
||||
if !strings.Contains(body, `value="壞 URI 練習"`) {
|
||||
t.Fatal("重繪時應保留已輸入的名稱")
|
||||
}
|
||||
var reloaded application.Application
|
||||
if err := db.First(&reloaded, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Name != "官方網站 2.0" {
|
||||
t.Errorf("驗證失敗不應寫入,名稱 = %q", reloaded.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("改為公開式清除 secret 並停用輪替", func(t *testing.T) {
|
||||
conf := confidential(t)
|
||||
edit := func(t *testing.T, typ string) {
|
||||
t.Helper()
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d", conf.ID), url.Values{
|
||||
"name": {"官方網站 2.0"},
|
||||
"type": {typ},
|
||||
"redirect_uris": {"https://app.example.com/oidc/callback"},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("改為 %s status = %d, body = %s", typ, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
edit(t, "public")
|
||||
var pub application.Application
|
||||
if err := db.First(&pub, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pub.IsPublic() || pub.ClientSecretHash != "" {
|
||||
t.Fatalf("改為公開式後應清除 secret 雜湊:%+v", pub)
|
||||
}
|
||||
|
||||
// 公開式不持有 secret,輪替回 409。
|
||||
rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("公開式輪替 status = %d, want 409", rec.Code)
|
||||
}
|
||||
|
||||
// 改回機密式:雜湊不應復活,須重新輪替取得新 secret。
|
||||
edit(t, "confidential")
|
||||
var back application.Application
|
||||
if err := db.First(&back, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.IsPublic() || back.ClientSecretHash != "" {
|
||||
t.Fatalf("改回機密式不應復活舊 secret 雜湊:%+v", back)
|
||||
}
|
||||
rec = postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("改回機密式後輪替 status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
m := secretInBody.FindStringSubmatch(rec.Body.String())
|
||||
if m == nil {
|
||||
t.Fatal("輪替後應顯示新的明文 client secret")
|
||||
}
|
||||
var rotated application.Application
|
||||
if err := db.First(&rotated, conf.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotated.CheckSecret(m[1]) {
|
||||
t.Error("重新輪替的新 client secret 應可驗證")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
)
|
||||
|
||||
// adminKeyRow 為金鑰管理頁表格的單列視圖。
|
||||
type adminKeyRow struct {
|
||||
ID uint
|
||||
Kid string
|
||||
Algorithm string
|
||||
CreatedAt string // 本地時間顯示
|
||||
Active bool // 使用中(未退休)
|
||||
LastActive bool // 使用中且為最後一把:退休按鈕停用
|
||||
}
|
||||
|
||||
// adminKeysPageData 為金鑰管理頁的模板資料。
|
||||
type adminKeysPageData struct {
|
||||
Error string
|
||||
Username string // 側欄頁尾使用者資訊
|
||||
Email string
|
||||
CSRF string // 產生/退休表單的 CSRF token
|
||||
Keys []adminKeyRow
|
||||
ActiveCount int // 使用中金鑰數;0 時頁面顯示警告
|
||||
}
|
||||
|
||||
// newAdminKeyRows 將金鑰模型轉為表格視圖,並回傳使用中的金鑰數。
|
||||
// 純函式,便於單元測試。
|
||||
func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) {
|
||||
for _, k := range keys {
|
||||
if k.Active() {
|
||||
active++
|
||||
}
|
||||
}
|
||||
rows = make([]adminKeyRow, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
rows = append(rows, adminKeyRow{
|
||||
ID: k.ID,
|
||||
Kid: k.Kid,
|
||||
Algorithm: k.Algorithm,
|
||||
CreatedAt: k.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
|
||||
Active: k.Active(),
|
||||
// 僅剩一把使用中金鑰時禁止退休,確保隨時都有金鑰可簽發 JWT。
|
||||
LastActive: k.Active() && active == 1,
|
||||
})
|
||||
}
|
||||
return rows, active
|
||||
}
|
||||
|
||||
// requireAdmin 驗證請求來自持有效 auth.Session 的管理員:未登入或 auth.Session
|
||||
// 過期時導向 /login(登入後可再試),已登入但非管理員回 403。
|
||||
// 回傳 auth.Session(含 auth.User)與是否繼續處理。
|
||||
func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) {
|
||||
c, err := r.Cookie(auth.CookieName)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return nil, false
|
||||
}
|
||||
s, err := auth.GetSession(db, c.Value)
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrSessionExpired):
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return nil, false
|
||||
case err != nil:
|
||||
log.Printf("admin: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
if s.User.Role != auth.RoleAdmin {
|
||||
log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username)
|
||||
http.Error(w, "需要管理員權限", http.StatusForbidden)
|
||||
return nil, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// KeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、
|
||||
// 建立時間、狀態)與產生/退休表單,僅管理員可存取。
|
||||
func KeysPageHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := requireAdmin(db, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
renderAdminKeysPage(w, r, db, http.StatusOK, s, "")
|
||||
}
|
||||
}
|
||||
|
||||
// renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status
|
||||
// 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。
|
||||
// 使用中的金鑰排前、新者在前。
|
||||
func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *auth.Session, errMsg string) {
|
||||
var keys []jwk.SigningKey
|
||||
if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil {
|
||||
log.Printf("admin keys: %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
|
||||
}
|
||||
rows, active := newAdminKeyRows(keys)
|
||||
auth.RenderHTML(w, status, auth.AdminKeysTmpl, adminKeysPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
CSRF: token,
|
||||
Keys: rows,
|
||||
ActiveCount: active,
|
||||
})
|
||||
}
|
||||
|
||||
// KeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA
|
||||
// 簽章金鑰,成功後 PRG 導回管理頁。
|
||||
func KeysCreateHandler(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 {
|
||||
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyCSRF(r) {
|
||||
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
|
||||
return
|
||||
}
|
||||
k, err := jwk.NewSigningKey()
|
||||
if err != nil {
|
||||
log.Printf("admin keys: %v", err)
|
||||
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰產生失敗,請稍後再試")
|
||||
return
|
||||
}
|
||||
if err := db.Create(k).Error; err != nil {
|
||||
log.Printf("admin keys: %v", err)
|
||||
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰儲存失敗,請稍後再試")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// KeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。
|
||||
// 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在
|
||||
// 的金鑰以錯誤訊息重繪頁面。
|
||||
func KeysRetireHandler(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 {
|
||||
renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容")
|
||||
return
|
||||
}
|
||||
if !auth.VerifyCSRF(r) {
|
||||
renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
|
||||
return
|
||||
}
|
||||
var k jwk.SigningKey
|
||||
switch err := db.First(&k, id).Error; {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在")
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("admin keys: %v", err)
|
||||
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
|
||||
return
|
||||
}
|
||||
if !k.Active() {
|
||||
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "金鑰已處於退休狀態")
|
||||
return
|
||||
}
|
||||
var active int64
|
||||
if err := db.Model(&jwk.SigningKey{}).Where("retired_at IS NULL").Count(&active).Error; err != nil {
|
||||
log.Printf("admin keys: %v", err)
|
||||
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤")
|
||||
return
|
||||
}
|
||||
if active <= 1 {
|
||||
renderAdminKeysPage(w, r, db, http.StatusConflict, s, "至少須保留一把使用中的金鑰")
|
||||
return
|
||||
}
|
||||
if err := db.Model(&k).Update("retired_at", time.Now()).Error; err != nil {
|
||||
log.Printf("admin keys: %v", err)
|
||||
renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰更新失敗,請稍後再試")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/keys", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
"alterminal/internal/testdb"
|
||||
)
|
||||
|
||||
// 未帶 auth.Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫,
|
||||
// 因此 handler 可傳入 nil db。
|
||||
func TestAdminKeysHandlersRequireLogin(t *testing.T) {
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"GET 列表": KeysPageHandler(nil),
|
||||
"POST 產生": KeysCreateHandler(nil),
|
||||
"POST 退休": KeysRetireHandler(nil),
|
||||
}
|
||||
for name, h := range handlers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/admin/keys", nil))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdminKeyRows(t *testing.T) {
|
||||
retired := time.Now().Add(-24 * time.Hour)
|
||||
keys := []jwk.SigningKey{
|
||||
{ID: 1, Kid: "kid-a", Algorithm: "RS256", RetiredAt: &retired},
|
||||
{ID: 2, Kid: "kid-b", Algorithm: "RS256"},
|
||||
{ID: 3, Kid: "kid-c", Algorithm: "RS256"},
|
||||
}
|
||||
rows, active := newAdminKeyRows(keys)
|
||||
if active != 2 {
|
||||
t.Fatalf("active = %d, want 2", active)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("rows = %d 筆, want 3", len(rows))
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Active != (r.Kid != "kid-a") {
|
||||
t.Errorf("row %q Active = %v 與退休狀態不符", r.Kid, r.Active)
|
||||
}
|
||||
if r.LastActive {
|
||||
t.Errorf("兩把使用中金鑰時 row %q 不應標記 LastActive", r.Kid)
|
||||
}
|
||||
}
|
||||
|
||||
rows, active = newAdminKeyRows(keys[:2]) // 一把使用中+一把退休
|
||||
if active != 1 {
|
||||
t.Fatalf("active = %d, want 1", active)
|
||||
}
|
||||
if !rows[1].LastActive {
|
||||
t.Error("僅剩一把使用中金鑰時應標記 LastActive")
|
||||
}
|
||||
}
|
||||
|
||||
// renderAdminKeysPage 需要資料庫,模板輸出直接以假資料渲染測試。
|
||||
func TestAdminKeysTemplate(t *testing.T) {
|
||||
data := adminKeysPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 2,
|
||||
Keys: []adminKeyRow{
|
||||
{ID: 7, Kid: "kid-active", Algorithm: "RS256", CreatedAt: "2026-10-02 12:00:00 +08:00", Active: true},
|
||||
{ID: 3, Kid: "kid-retired", Algorithm: "RS256", CreatedAt: "2026-09-01 12:00:00 +08:00"},
|
||||
},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"金鑰管理", // 標題
|
||||
`action="/admin/keys"`, // 產生新金鑰表單
|
||||
`value="token-A"`, // CSRF 隱藏欄位
|
||||
`action="/admin/keys/7/retire"`, // 使用中金鑰的退休表單
|
||||
"kid-active", "kid-retired", // kid 欄
|
||||
"使用中", "已退休", // 狀態徽章
|
||||
`href="/admin/keys" aria-current="page"`, // 導覽(目前頁)
|
||||
`href="/login"`, // 導覽(帳號資訊)
|
||||
`action="/logout"`, // 側欄頁尾登出表單(版面預設)
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("金鑰管理頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/admin/keys/3/retire") {
|
||||
t.Error("已退休的金鑰不應出現退休表單")
|
||||
}
|
||||
if !strings.Contains(body, "使用中 2 把 / 共 2 把") {
|
||||
t.Error("應顯示使用中/總數統計")
|
||||
}
|
||||
}
|
||||
|
||||
// LastActive(唯一使用中金鑰)不輸出退休表單,改顯示提示。
|
||||
func TestAdminKeysTemplateLastActive(t *testing.T) {
|
||||
data := adminKeysPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 1,
|
||||
Keys: []adminKeyRow{{ID: 7, Kid: "kid-only", Algorithm: "RS256", Active: true, LastActive: true}},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "/retire") {
|
||||
t.Error("唯一使用中金鑰不應出現退休表單")
|
||||
}
|
||||
if !strings.Contains(body, "唯一使用中金鑰") {
|
||||
t.Error("應顯示無法退休的提示")
|
||||
}
|
||||
}
|
||||
|
||||
// 無使用中金鑰時顯示警告。
|
||||
func TestAdminKeysTemplateNoActiveWarning(t *testing.T) {
|
||||
data := adminKeysPageData{
|
||||
Username: "alice", Email: "alice@example.com", CSRF: "token-A",
|
||||
Keys: []adminKeyRow{{ID: 7, Kid: "kid-old", Algorithm: "RS256"}},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
auth.RenderHTML(rec, http.StatusOK, auth.AdminKeysTmpl, data)
|
||||
if !strings.Contains(rec.Body.String(), "目前沒有使用中的金鑰") {
|
||||
t.Error("無使用中金鑰時應顯示警告")
|
||||
}
|
||||
}
|
||||
|
||||
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
|
||||
// 測試資料庫(alterminal_test)的準備見 internal/testdb。
|
||||
|
||||
func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CSRFCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("回應未設定 CSRF Cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminGet 建立帶 auth.Session Cookie 的 GET 請求(管理頁共用)。
|
||||
func adminGet(path string, sess *auth.Session) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID})
|
||||
return req
|
||||
}
|
||||
|
||||
// adminPost 建立帶 auth.Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。
|
||||
func adminPost(path, body string, sess *auth.Session, csrf *http.Cookie) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sess.ID})
|
||||
if csrf != nil {
|
||||
req.AddCookie(csrf)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestAdminKeysIntegration(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
|
||||
admin := &auth.User{Username: "keyadmin", Email: "keyadmin@example.com", Role: auth.RoleAdmin}
|
||||
if err := admin.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
member := &auth.User{Username: "keyuser", Email: "keyuser@example.com", Role: auth.RoleUser}
|
||||
if err := member.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(member).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminSess, err := auth.CreateSession(db, admin.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memberSess, err := auth.CreateSession(db, member.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/admin/keys", KeysPageHandler(db))
|
||||
r.Post("/admin/keys", KeysCreateHandler(db))
|
||||
r.Post("/admin/keys/{id}/retire", KeysRetireHandler(db))
|
||||
|
||||
t.Run("非 admin 存取回 403", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/keys", memberSess))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "需要管理員權限") {
|
||||
t.Fatalf("應回需要管理員權限:%s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("admin 首次檢視為空狀態", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/keys", adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "尚無簽章金鑰") {
|
||||
t.Fatalf("應顯示空狀態提示:%s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次
|
||||
// 渲染都會輪替)。
|
||||
currentCSRF := func(t *testing.T) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/keys", adminSess))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/keys status = %d", rec.Code)
|
||||
}
|
||||
return csrfCookieOf(t, rec)
|
||||
}
|
||||
|
||||
t.Run("CSRF 不符回 403", func(t *testing.T) {
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token=wrong", adminSess, cookie))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應顯示 CSRF 錯誤訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("產生新金鑰", func(t *testing.T) {
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/admin/keys" {
|
||||
t.Fatalf("Location = %q, want /admin/keys", loc)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&jwk.SigningKey{}).Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("資料庫金鑰數 = %d, want 1", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("退休最後一把使用中金鑰回 409", func(t *testing.T) {
|
||||
var k jwk.SigningKey
|
||||
if err := db.First(&k).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", k.ID), "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "至少須保留一把使用中的金鑰") {
|
||||
t.Fatal("應顯示最後一把不可退休的訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("產生第二把後可退休舊金鑰", func(t *testing.T) {
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("產生第二把 status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var old, latest jwk.SigningKey
|
||||
if err := db.Order("id").First(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Order("id DESC").First(&latest).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cookie = currentCSRF(t)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("退休 status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := db.First(&old, old.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.RetiredAt == nil {
|
||||
t.Fatal("退休後 RetiredAt 應有值")
|
||||
}
|
||||
|
||||
// 頁面顯示兩種狀態與統計。
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminGet("/admin/keys", adminSess))
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{old.Kid, latest.Kid, "使用中 1 把 / 共 2 把", "已退休", "唯一使用中金鑰"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("管理頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("退休不存在的金鑰回 404", func(t *testing.T) {
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost("/admin/keys/99999/retire", "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "金鑰不存在") {
|
||||
t.Fatal("應顯示金鑰不存在")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("再退休已退休金鑰回 409", func(t *testing.T) {
|
||||
var old jwk.SigningKey
|
||||
if err := db.Where("retired_at IS NOT NULL").First(&old).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := currentCSRF(t)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "已處於退休狀態") {
|
||||
t.Fatal("應顯示已退休訊息")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// ClientType 為 OAuth 2.0 Client 類型(RFC 6749 §2.1):confidential 能
|
||||
// 安全保管 client secret(後端網頁應用),public 不能(SPA、行動應用),
|
||||
// 授權流程必須以 PKCE 彌補,不簽發 client secret。
|
||||
type ClientType string
|
||||
|
||||
// 允許的類型值。
|
||||
const (
|
||||
ClientConfidential ClientType = "confidential"
|
||||
ClientPublic ClientType = "public"
|
||||
)
|
||||
|
||||
// valid 回傳類型是否為允許的值。
|
||||
func (t ClientType) valid() bool {
|
||||
return t == ClientConfidential || t == ClientPublic
|
||||
}
|
||||
|
||||
// GrantType 為 OAuth 2.0 grant type。
|
||||
type GrantType string
|
||||
|
||||
// 允許的 grant type 值。
|
||||
const (
|
||||
GrantAuthorizationCode GrantType = "authorization_code" // 授權碼流程(建議搭配 PKCE)
|
||||
GrantRefreshToken GrantType = "refresh_token" // 以 Refresh Token 換發新權杖
|
||||
GrantClientCredentials GrantType = "client_credentials" // 機器對機器,僅機密式 Client 可用
|
||||
)
|
||||
|
||||
// valid 回傳 grant type 是否為允許的值。
|
||||
func (g GrantType) valid() bool {
|
||||
return g == GrantAuthorizationCode || g == GrantRefreshToken || g == GrantClientCredentials
|
||||
}
|
||||
|
||||
// supportedScopes 為本服務支援的 scope(與 README「支援的 Scope」一致)。
|
||||
var supportedScopes = map[string]bool{
|
||||
"openid": true,
|
||||
"profile": true,
|
||||
"email": true,
|
||||
"offline_access": true,
|
||||
}
|
||||
|
||||
// defaultScope 為註冊時未指定 scope 的預設值。
|
||||
const defaultScope = "openid profile email"
|
||||
|
||||
// ScopesSupported 回傳支援的 scope 清單(已排序),供 Discovery 端點的
|
||||
// scopes_supported 發佈(與本套件的註冊驗證共用同一份清單)。
|
||||
func ScopesSupported() []string {
|
||||
out := make([]string, 0, len(supportedScopes))
|
||||
for s := range supportedScopes {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// RedirectURIs 為已註冊的 redirect URI 清單(JSON 陣列儲存)。RFC 6749
|
||||
// §3.1.2.3 要求端點比對時與註冊值完全相同(字串相等,不做正規化),
|
||||
// 故以字串清單逐一比對。
|
||||
type RedirectURIs []string
|
||||
|
||||
// Contains 回傳 uri 是否與任一註冊的 redirect URI 完全相同。
|
||||
func (r RedirectURIs) Contains(uri string) bool {
|
||||
for _, u := range r {
|
||||
if u == uri {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GrantTypes 為允許的 grant type 清單(JSON 陣列儲存)。
|
||||
type GrantTypes []GrantType
|
||||
|
||||
// Contains 回傳 gt 是否為允許的 grant type。
|
||||
func (g GrantTypes) Contains(gt GrantType) bool {
|
||||
for _, x := range g {
|
||||
if x == gt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Application 為接入 OIDC 的應用程式(Relying Party)註冊資料,對應
|
||||
// applications 資料表。ClientID 由本服務產生、全域唯一;client secret
|
||||
// 與使用者密碼採同一套 argon2id 雜湊儲存,明文只在建立/輪替當下回傳
|
||||
// 一次;公開式 Client 不持有 secret。
|
||||
type Application struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠)
|
||||
Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等)
|
||||
Type ClientType `gorm:"size:16;not null"` // confidential 或 public
|
||||
ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串
|
||||
RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對)
|
||||
GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type
|
||||
Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。
|
||||
func (a *Application) IsPublic() bool {
|
||||
return a.Type == ClientPublic
|
||||
}
|
||||
|
||||
// fill 套用註冊表單欄位並補上預設值(grantTypes 空時預設僅
|
||||
// authorization_code;scope 空時預設「openid profile email」)後驗證,
|
||||
// 供 NewApplication 與 Update 共用。驗證失敗時 a 可能已被部分修改,
|
||||
// 呼叫方不應將其儲存。
|
||||
func (a *Application) fill(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
|
||||
a.Name = strings.TrimSpace(name)
|
||||
a.Type = typ
|
||||
a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null
|
||||
a.GrantTypes = grantTypes
|
||||
a.Scope = strings.TrimSpace(scope)
|
||||
if len(a.GrantTypes) == 0 {
|
||||
a.GrantTypes = GrantTypes{GrantAuthorizationCode}
|
||||
}
|
||||
if a.Scope == "" {
|
||||
a.Scope = defaultScope
|
||||
}
|
||||
return a.Validate()
|
||||
}
|
||||
|
||||
// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的
|
||||
// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一
|
||||
// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。
|
||||
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) {
|
||||
a := &Application{}
|
||||
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
id, err := auth.NewToken(16)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate client id: %w", err)
|
||||
}
|
||||
a.ClientID = id
|
||||
secret := ""
|
||||
if !a.IsPublic() {
|
||||
if secret, err = a.GenerateSecret(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
return a, secret, nil
|
||||
}
|
||||
|
||||
// Update 以新的註冊內容更新既有應用程式:client_id 為公開識別碼,已
|
||||
// 嵌入各 RP 的設定,不可變更;client secret 亦不受影響(輪替另經
|
||||
// GenerateSecret)。由機密式改為公開式時一併清除既有 secret 雜湊——
|
||||
// 舊 secret 隨型別切換立即失效,日後改回機密式也不會復活,須重新輪替
|
||||
// 取得新 secret。驗證失敗時 a 可能已被部分修改,呼叫方不應將其儲存。
|
||||
func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
|
||||
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.IsPublic() {
|
||||
a.ClientSecretHash = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate 檢查註冊內容:名稱與類型必填、grant type 受支援且組合合法
|
||||
// (client_credentials 僅限機密式 Client(RFC 6749 §4.4.3)、
|
||||
// refresh_token 須伴隨授權碼流程)、使用授權碼流程時至少註冊一個格式
|
||||
// 正確的 redirect URI、scope 皆受支援且 offline_access 須有
|
||||
// refresh_token grant。
|
||||
func (a *Application) Validate() error {
|
||||
if a.Name == "" {
|
||||
return errors.New("應用程式名稱不可為空")
|
||||
}
|
||||
if !a.Type.valid() {
|
||||
return fmt.Errorf("不支援的 client 類型 %q", a.Type)
|
||||
}
|
||||
if len(a.GrantTypes) == 0 {
|
||||
return errors.New("至少須啟用一種 grant type")
|
||||
}
|
||||
for _, g := range a.GrantTypes {
|
||||
if !g.valid() {
|
||||
return fmt.Errorf("不支援的 grant type %q", g)
|
||||
}
|
||||
}
|
||||
if a.GrantTypes.Contains(GrantClientCredentials) && a.IsPublic() {
|
||||
return errors.New("公開式 Client 不可使用 client_credentials(RFC 6749 §4.4.3)")
|
||||
}
|
||||
if a.GrantTypes.Contains(GrantRefreshToken) && !a.GrantTypes.Contains(GrantAuthorizationCode) {
|
||||
return errors.New("refresh_token 須伴隨 authorization_code 使用")
|
||||
}
|
||||
if a.GrantTypes.Contains(GrantAuthorizationCode) {
|
||||
if len(a.RedirectURIs) == 0 {
|
||||
return errors.New("使用授權碼流程須至少註冊一個 redirect URI")
|
||||
}
|
||||
for _, uri := range a.RedirectURIs {
|
||||
if err := validateRedirectURI(uri); err != nil {
|
||||
return fmt.Errorf("redirect URI %q:%w", uri, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.Scope == "" {
|
||||
return errors.New("scope 不可為空")
|
||||
}
|
||||
for _, s := range strings.Fields(a.Scope) {
|
||||
if !supportedScopes[s] {
|
||||
return fmt.Errorf("不支援的 scope %q", s)
|
||||
}
|
||||
if s == "offline_access" && !a.GrantTypes.Contains(GrantRefreshToken) {
|
||||
return errors.New("offline_access 須啟用 refresh_token grant")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecret 產生並雜湊新的 client secret(明文為 32 bytes 亂數的
|
||||
// base64url,43 字元),回傳明文——僅此一次,資料庫只存雜湊。再次呼叫
|
||||
// 即輪替,舊 secret 立即失效。公開式 Client 不持有 secret,回傳錯誤。
|
||||
func (a *Application) GenerateSecret() (string, error) {
|
||||
if a.IsPublic() {
|
||||
return "", errors.New("公開式 Client 不持有 client secret")
|
||||
}
|
||||
secret, err := auth.NewToken(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate client secret: %w", err)
|
||||
}
|
||||
hash, err := auth.HashPassword(secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash client secret: %w", err)
|
||||
}
|
||||
a.ClientSecretHash = hash
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// CheckSecret 回傳 client secret 是否相符;公開式 Client 一律不相符,
|
||||
// 雜湊格式無效時亦視為不相符。
|
||||
func (a *Application) CheckSecret(secret string) bool {
|
||||
if a.IsPublic() {
|
||||
return false
|
||||
}
|
||||
ok, err := auth.VerifyPassword(secret, a.ClientSecretHash)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// validateRedirectURI 檢查 redirect URI 格式:須為絕對 URI 且不含
|
||||
// fragment 與 userinfo(RFC 6749 §3.1.2);http 僅允許 loopback(本機
|
||||
// 開發,RFC 8252 §7.3),Web 應用一律使用 https;非 http(s) 的自訂
|
||||
// scheme(如 com.example.app:/cb)供原生應用程式使用。
|
||||
func validateRedirectURI(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析失敗:%w", err)
|
||||
}
|
||||
if !u.IsAbs() {
|
||||
return errors.New("須為絕對 URI(含 scheme)")
|
||||
}
|
||||
if u.Fragment != "" || u.RawFragment != "" {
|
||||
return errors.New("不可包含 fragment")
|
||||
}
|
||||
if u.User != nil {
|
||||
return errors.New("不可包含 userinfo")
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http", "https":
|
||||
if u.Host == "" {
|
||||
return errors.New("缺少 host")
|
||||
}
|
||||
if u.Scheme == "http" {
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
default:
|
||||
return errors.New("http 僅允許 loopback(localhost、127.0.0.1、::1),其餘請使用 https")
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 自訂 scheme:僅有 scheme 而無其餘部分(如 "myapp:")無法作為回呼位址。
|
||||
if u.Opaque == "" && u.Host == "" && u.Path == "" {
|
||||
return errors.New("自訂 scheme 的 URI 須包含 scheme 以外的部分")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByClientID 以 client_id 查詢應用程式,供 /authorize、
|
||||
// /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound
|
||||
// 的錯誤(以 errors.Is 判斷)。
|
||||
func GetByClientID(db *gorm.DB, clientID string) (*Application, error) {
|
||||
var a Application
|
||||
if err := db.Where("client_id = ?", clientID).First(&a).Error; err != nil {
|
||||
return nil, fmt.Errorf("query application: %w", err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNewApplicationConfidential(t *testing.T) {
|
||||
a, secret, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/oidc/callback"},
|
||||
[]GrantType{GrantAuthorizationCode, GrantRefreshToken},
|
||||
"openid profile offline_access")
|
||||
if err != nil {
|
||||
t.Fatal("NewApplication: ", err)
|
||||
}
|
||||
if len(a.ClientID) != 22 {
|
||||
t.Errorf("ClientID 應為 16 bytes 亂數的 base64url(22 字元),得到 %d 字元", len(a.ClientID))
|
||||
}
|
||||
if a.IsPublic() {
|
||||
t.Error("機密式 Client 的 IsPublic() 應為 false")
|
||||
}
|
||||
if len(secret) != 43 {
|
||||
t.Errorf("client secret 應為 32 bytes 亂數的 base64url(43 字元),得到 %d 字元", len(secret))
|
||||
}
|
||||
if !strings.HasPrefix(a.ClientSecretHash, "$argon2id$") {
|
||||
t.Errorf("ClientSecretHash 應為 argon2id PHC 字串,得到 %q", a.ClientSecretHash)
|
||||
}
|
||||
if strings.Contains(a.ClientSecretHash, secret) {
|
||||
t.Error("client secret 不應以明文出現在雜湊欄位")
|
||||
}
|
||||
if !a.CheckSecret(secret) {
|
||||
t.Error("正確的 client secret 應驗證成功")
|
||||
}
|
||||
if a.CheckSecret("wrong-secret") {
|
||||
t.Error("錯誤的 client secret 不應驗證成功")
|
||||
}
|
||||
if err := a.Validate(); err != nil {
|
||||
t.Error("新建立的註冊資料應通過驗證: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationPublic(t *testing.T) {
|
||||
a, secret, err := NewApplication("行動應用", ClientPublic,
|
||||
[]string{"com.example.app:/oidc/callback"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal("NewApplication: ", err)
|
||||
}
|
||||
if !a.IsPublic() {
|
||||
t.Error("公開式 Client 的 IsPublic() 應為 true")
|
||||
}
|
||||
if secret != "" {
|
||||
t.Errorf("公開式 Client 不應簽發 client secret,得到 %q", secret)
|
||||
}
|
||||
if a.ClientSecretHash != "" {
|
||||
t.Errorf("公開式 Client 不應存 secret 雜湊,得到 %q", a.ClientSecretHash)
|
||||
}
|
||||
for _, s := range []string{"", "anything"} {
|
||||
if a.CheckSecret(s) {
|
||||
t.Errorf("公開式 Client 的 CheckSecret(%q) 應為 false", s)
|
||||
}
|
||||
}
|
||||
if _, err := a.GenerateSecret(); err == nil {
|
||||
t.Error("公開式 Client 呼叫 GenerateSecret 應回傳錯誤")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationDefaults(t *testing.T) {
|
||||
a, _, err := NewApplication(" 示範應用 ", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Name != "示範應用" {
|
||||
t.Errorf("名稱應去除首尾空白,得到 %q", a.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) {
|
||||
t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes)
|
||||
}
|
||||
if a.Scope != defaultScope {
|
||||
t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationClientIDUnique(t *testing.T) {
|
||||
a, _, err := NewApplication("A", ClientPublic, []string{"https://a.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := NewApplication("B", ClientPublic, []string{"https://b.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.ClientID == b.ClientID {
|
||||
t.Error("兩次建立的 client_id 不應相同")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationClientCredentialsWithoutRedirectURIs(t *testing.T) {
|
||||
// 僅 client_credentials 的機器對機器應用不經過瀏覽器,無須 redirect URI。
|
||||
a, secret, err := NewApplication("批次服務", ClientConfidential, nil,
|
||||
[]GrantType{GrantClientCredentials}, "openid")
|
||||
if err != nil {
|
||||
t.Fatal("僅 client_credentials 註冊不應要求 redirect URI: ", err)
|
||||
}
|
||||
if secret == "" || !a.CheckSecret(secret) {
|
||||
t.Error("機密式 Client 應簽發可驗證的 client secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApplicationInvalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
name string
|
||||
typ ClientType
|
||||
uris []string
|
||||
grants []GrantType
|
||||
scope string
|
||||
want string // 錯誤訊息應包含的子字串
|
||||
}{
|
||||
{"空名稱", "", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "", "名稱"},
|
||||
{"不支援的類型", "A", "webapp", []string{"https://a.example.com/cb"}, nil, "", "類型"},
|
||||
{"不支援的 grant type", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{"implicit"}, "", "grant type"},
|
||||
{"公開式使用 client_credentials", "A", ClientPublic, []string{"https://a.example.com/cb"}, []GrantType{GrantClientCredentials}, "", "client_credentials"},
|
||||
{"refresh_token 未伴隨授權碼", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantRefreshToken}, "", "refresh_token"},
|
||||
{"授權碼流程無 redirect URI", "A", ClientConfidential, nil, []GrantType{GrantAuthorizationCode}, "", "redirect URI"},
|
||||
{"相對 URI", "A", ClientConfidential, []string{"app.example.com/cb"}, nil, "", "絕對 URI"},
|
||||
{"非 loopback 的 http", "A", ClientConfidential, []string{"http://app.example.com/cb"}, nil, "", "loopback"},
|
||||
{"含 fragment", "A", ClientConfidential, []string{"https://app.example.com/cb#frag"}, nil, "", "fragment"},
|
||||
{"含 userinfo", "A", ClientConfidential, []string{"https://user@app.example.com/cb"}, nil, "", "userinfo"},
|
||||
{"缺少 host", "A", ClientConfidential, []string{"https:///cb"}, nil, "", "host"},
|
||||
{"不支援的 scope", "A", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "openid admin", "scope"},
|
||||
{"offline_access 無 refresh_token grant", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantAuthorizationCode}, "openid offline_access", "offline_access"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, err := NewApplication(c.name, c.typ, c.uris, c.grants, c.scope)
|
||||
if err == nil {
|
||||
t.Errorf("%s:應回傳錯誤", c.desc)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s:錯誤訊息 %q 應包含 %q", c.desc, err.Error(), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationSecretRotation(t *testing.T) {
|
||||
a, secret1, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret2, err := a.GenerateSecret()
|
||||
if err != nil {
|
||||
t.Fatal("GenerateSecret: ", err)
|
||||
}
|
||||
if secret1 == secret2 {
|
||||
t.Error("輪替後的 client secret 不應與舊值相同")
|
||||
}
|
||||
if a.CheckSecret(secret1) {
|
||||
t.Error("輪替後舊 client secret 應立即失效")
|
||||
}
|
||||
if !a.CheckSecret(secret2) {
|
||||
t.Error("新 client secret 應驗證成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationUpdate(t *testing.T) {
|
||||
a, secret, err := NewApplication("舊名稱", ClientConfidential,
|
||||
[]string{"https://old.example.com/cb"},
|
||||
[]GrantType{GrantAuthorizationCode}, "openid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldID := a.ClientID
|
||||
if err := a.Update(" 新名稱 ", ClientConfidential,
|
||||
[]string{"https://new.example.com/cb", "https://alt.example.com/cb"},
|
||||
[]GrantType{GrantAuthorizationCode, GrantRefreshToken}, "openid profile offline_access"); err != nil {
|
||||
t.Fatal("Update: ", err)
|
||||
}
|
||||
if a.Name != "新名稱" {
|
||||
t.Errorf("名稱應更新並去除首尾空白,得到 %q", a.Name)
|
||||
}
|
||||
if a.ClientID != oldID {
|
||||
t.Errorf("client_id 不可因更新而變更:%q → %q", oldID, a.ClientID)
|
||||
}
|
||||
wantURIs := RedirectURIs{"https://new.example.com/cb", "https://alt.example.com/cb"}
|
||||
if !reflect.DeepEqual(a.RedirectURIs, wantURIs) {
|
||||
t.Errorf("RedirectURIs = %v, want %v", a.RedirectURIs, wantURIs)
|
||||
}
|
||||
if !a.GrantTypes.Contains(GrantRefreshToken) {
|
||||
t.Errorf("GrantTypes 應更新,得到 %v", a.GrantTypes)
|
||||
}
|
||||
if !a.CheckSecret(secret) {
|
||||
t.Error("更新註冊內容不應影響既有 client secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationUpdateDefaultsAndInvalid(t *testing.T) {
|
||||
a, _, err := NewApplication("示範應用", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// grant type 與 scope 留空時沿用註冊時的預設行為。
|
||||
if err := a.Update("更新後", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
|
||||
t.Fatal("Update: ", err)
|
||||
}
|
||||
if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) {
|
||||
t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes)
|
||||
}
|
||||
if a.Scope != defaultScope {
|
||||
t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope)
|
||||
}
|
||||
if err := a.Update("示範應用", ClientConfidential, []string{"http://a.example.com/cb"}, nil, ""); err == nil {
|
||||
t.Error("非法 redirect URI 的 Update 應回傳錯誤")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationUpdateToPublicClearsSecret(t *testing.T) {
|
||||
a, secret, err := NewApplication("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Update("後端服務", ClientPublic, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
|
||||
t.Fatal("Update: ", err)
|
||||
}
|
||||
if !a.IsPublic() {
|
||||
t.Error("更新為公開式後 IsPublic() 應為 true")
|
||||
}
|
||||
if a.ClientSecretHash != "" {
|
||||
t.Errorf("改為公開式應清除 secret 雜湊,得到 %q", a.ClientSecretHash)
|
||||
}
|
||||
if a.CheckSecret(secret) {
|
||||
t.Error("改為公開式後舊 client secret 應失效")
|
||||
}
|
||||
// 改回機密式:雜湊不應復活,須以 GenerateSecret 重新輪替。
|
||||
if err := a.Update("後端服務", ClientConfidential, []string{"https://a.example.com/cb"}, nil, ""); err != nil {
|
||||
t.Fatal("Update 回機密式: ", err)
|
||||
}
|
||||
if a.ClientSecretHash != "" || a.CheckSecret(secret) {
|
||||
t.Error("由公開式改回機密式不應復活舊 secret,須重新輪替")
|
||||
}
|
||||
newSecret, err := a.GenerateSecret()
|
||||
if err != nil || !a.CheckSecret(newSecret) {
|
||||
t.Error("改回機密式後應可重新輪替取得有效 secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationCheckSecretMalformedHash(t *testing.T) {
|
||||
for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} {
|
||||
a := &Application{Type: ClientConfidential, ClientSecretHash: hash}
|
||||
if a.CheckSecret("whatever") {
|
||||
t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectURIsContains(t *testing.T) {
|
||||
uris := RedirectURIs{"https://app.example.com/cb", "com.example.app:/cb"}
|
||||
for _, uri := range []string{"https://app.example.com/cb", "com.example.app:/cb"} {
|
||||
if !uris.Contains(uri) {
|
||||
t.Errorf("已註冊的 %q 應比對成功", uri)
|
||||
}
|
||||
}
|
||||
for _, uri := range []string{
|
||||
"https://app.example.com/cb?x=1", // 未註冊的 query
|
||||
"https://app.example.com/cb/", // 結尾斜線不同即不同字串
|
||||
"https://evil.example.com/cb",
|
||||
"HTTPS://APP.EXAMPLE.COM/cb",
|
||||
"",
|
||||
} {
|
||||
if uris.Contains(uri) {
|
||||
t.Errorf("未註冊的 %q 不應比對成功(須完全相同)", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantTypesContains(t *testing.T) {
|
||||
gts := GrantTypes{GrantAuthorizationCode, GrantRefreshToken}
|
||||
if !gts.Contains(GrantAuthorizationCode) || !gts.Contains(GrantRefreshToken) {
|
||||
t.Error("已啟用的 grant type 應比對成功")
|
||||
}
|
||||
if gts.Contains(GrantClientCredentials) {
|
||||
t.Error("未啟用的 grant type 不應比對成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRedirectURI(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://app.example.com/oidc/callback",
|
||||
"https://app.example.com", // 無 path
|
||||
"https://app.example.com:8443/cb", // 帶 port
|
||||
"http://localhost:8080/cb", // loopback 例外
|
||||
"http://127.0.0.1/cb", // loopback IP
|
||||
"http://[::1]:8080/cb", // IPv6 loopback
|
||||
"com.example.app:/oidc/callback", // 原生應用自訂 scheme
|
||||
"urn:ietf:wg:oauth:2.0:oob", // opaque URI
|
||||
}
|
||||
for _, uri := range valid {
|
||||
if err := validateRedirectURI(uri); err != nil {
|
||||
t.Errorf("redirect URI %q 應有效,得到錯誤:%v", uri, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"", // 空字串
|
||||
"app.example.com/cb", // 相對 URI(無 scheme)
|
||||
"/cb", // path only
|
||||
"https://app.example.com/cb#frag", // fragment(RFC 6749 §3.1.2 禁止)
|
||||
"https://user@app.example.com/cb", // userinfo
|
||||
"https:///cb", // 無 host
|
||||
"http://app.example.com/cb", // 非 loopback 的 http
|
||||
"myapp:", // 僅有 scheme
|
||||
}
|
||||
for _, uri := range invalid {
|
||||
if err := validateRedirectURI(uri); err == nil {
|
||||
t.Errorf("redirect URI %q 應無效", uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 ---
|
||||
|
||||
// envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯;
|
||||
// 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。
|
||||
func envOrTest(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫
|
||||
// (與開發資料庫 alterminal 隔離),僅遷移與清空 applications 資料表
|
||||
// (本套件測試不涉及其他模型)。
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
admin, err := gorm.Open(postgres.Open(fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC",
|
||||
envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"),
|
||||
envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"),
|
||||
)), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err)
|
||||
}
|
||||
if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Skipf("無法建立測試資料庫:%v", err)
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=alterminal_test sslmode=disable TimeZone=UTC",
|
||||
envOrTest("DB_HOST", "localhost"), envOrTest("DB_PORT", "5432"),
|
||||
envOrTest("DB_USER", "postgres"), envOrTest("DB_PASSWORD", "postgres"),
|
||||
)), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
t.Skipf("連線測試資料庫失敗:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&Application{}); err != nil {
|
||||
t.Fatalf("遷移測試資料表失敗:%v", err)
|
||||
}
|
||||
if err := db.Exec("TRUNCATE applications RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("清空測試資料失敗:%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestApplicationPersistence(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
a, secret, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/oidc/callback", "https://app.example.com/other"},
|
||||
[]GrantType{GrantAuthorizationCode, GrantRefreshToken},
|
||||
"openid profile email offline_access")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(a).Error; err != nil {
|
||||
t.Fatalf("建立應用程式失敗:%v", err)
|
||||
}
|
||||
|
||||
got, err := GetByClientID(db, a.ClientID)
|
||||
if err != nil {
|
||||
t.Fatalf("以 client_id 查詢失敗:%v", err)
|
||||
}
|
||||
if got.ID == 0 || got.Name != a.Name || got.Type != a.Type || got.Scope != a.Scope {
|
||||
t.Errorf("基本欄位往返不一致:got %+v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.RedirectURIs, a.RedirectURIs) {
|
||||
t.Errorf("RedirectURIs 往返不一致:got %v want %v", got.RedirectURIs, a.RedirectURIs)
|
||||
}
|
||||
if !reflect.DeepEqual(got.GrantTypes, a.GrantTypes) {
|
||||
t.Errorf("GrantTypes 往返不一致:got %v want %v", got.GrantTypes, a.GrantTypes)
|
||||
}
|
||||
if !got.CheckSecret(secret) {
|
||||
t.Error("資料庫往返後 client secret 應仍可驗證")
|
||||
}
|
||||
if !got.RedirectURIs.Contains("https://app.example.com/oidc/callback") {
|
||||
t.Error("往返後 redirect URI 比對應仍可用")
|
||||
}
|
||||
|
||||
dup, _, err := NewApplication("重複測試", ClientPublic,
|
||||
[]string{"https://dup.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dup.ClientID = a.ClientID
|
||||
if err := db.Create(dup).Error; !errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
t.Errorf("重複的 client_id 應回 gorm.ErrDuplicatedKey,得到 %v", err)
|
||||
}
|
||||
|
||||
if _, err := GetByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Tailwind 進入點。建置(輸出 assets/css/main.css,已提交並由 go:embed 內嵌):
|
||||
*
|
||||
* tools/tailwindcss -i assets/css/input.css -o assets/css/main.css --minify
|
||||
*
|
||||
* CLI 為官方 standalone 執行檔(v4,見 tools/tailwindcss-version.txt),
|
||||
* 一般開發不需 Node;僅在調整樣式時需要重新建置。
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 模板在此目錄,掃描它以產生用到的 utility class。 */
|
||||
@source "../../templates/*.html";
|
||||
|
||||
@theme {
|
||||
/* 中文字型優先,兼顧 zh-Hant 顯示品質 */
|
||||
--font-sans: system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif;
|
||||
/* 品牌色:延續原登入頁的藍 */
|
||||
--color-brand: #0071e3;
|
||||
--color-brand-strong: #0077ed;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// envOrTest 讀取環境變數,空值時回傳 fallback(與 store.EnvOr 同邏輯;
|
||||
// 測試不可匯入 internal/store——其 AutoMigrate 匯入本套件,會形成測試循環)。
|
||||
func envOrTest(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// openTestDB 連線 DB_* 環境變數指定的資料庫並遷移 users、sessions 資料表,
|
||||
// 供 login/logout 整合測試使用(測試自行建立資料並於 t.Cleanup 清理)。
|
||||
func openTestDB() (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=UTC",
|
||||
envOrTest("DB_HOST", "localhost"),
|
||||
envOrTest("DB_PORT", "5432"),
|
||||
envOrTest("DB_USER", "postgres"),
|
||||
envOrTest("DB_PASSWORD", "postgres"),
|
||||
envOrTest("DB_NAME", "alterminal"),
|
||||
)
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&User{}, &Session{}); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CookieName 為存放 Session ID 的 Cookie 名稱。
|
||||
const CookieName = "alterminal_session"
|
||||
|
||||
// SafeNext 檢查登入成功後的返回路徑:僅接受站內路徑——以 / 開頭且不
|
||||
// 以 // 開頭(協定相對 URL 會導向外部網站,構成 open redirect),不
|
||||
// 合格或未提供者一律回 /。/authorize 導向登入時以 next 攜帶完整授權
|
||||
// 請求(OIDC Core §3.1.2.2)。
|
||||
func SafeNext(next string) string {
|
||||
if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") {
|
||||
return next
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// loginRequest 為 POST /login 的請求欄位(JSON 與表單共用)。
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// validate 正規化並檢查欄位:username 去除首尾空白後不可為空,password 不可為空。
|
||||
func (in *loginRequest) validate() error {
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
if in.Username == "" {
|
||||
return errors.New("username 不可為空")
|
||||
}
|
||||
if in.Password == "" {
|
||||
return errors.New("password 不可為空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicUser 為對外暴露的使用者欄位,不含 PasswordHash 等內部資訊。
|
||||
type publicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// loginResponse 為登入成功回應;ExpiresAt 對應 Session 與 Cookie 的到期時間。
|
||||
type loginResponse struct {
|
||||
User publicUser `json:"user"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// LoginHandler 處理 POST /login,依 Content-Type 分流:application/json 走
|
||||
// API 流程(回 JSON),表單走瀏覽器流程(回 HTML)。兩者共用帳密驗證與
|
||||
// Session 建立;帳密錯誤一律回 401,不洩漏帳號是否存在。
|
||||
func LoginHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var isForm bool
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "application/json"):
|
||||
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"),
|
||||
strings.HasPrefix(ct, "multipart/form-data"):
|
||||
isForm = true
|
||||
default:
|
||||
WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
var in loginRequest
|
||||
// next 為表單流程的登入後返回路徑(JSON API 流程不適用)。
|
||||
next := "/"
|
||||
if isForm {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderLoginPage(w, r, http.StatusBadRequest, "無法解析表單內容", "", "/")
|
||||
return
|
||||
}
|
||||
if !VerifyCSRF(r) {
|
||||
renderLoginPage(w, r, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試", "", "/")
|
||||
return
|
||||
}
|
||||
in = loginRequest{Username: r.PostFormValue("username"), Password: r.PostFormValue("password")}
|
||||
next = SafeNext(r.PostFormValue("next"))
|
||||
} else if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "無法解析請求內容")
|
||||
return
|
||||
}
|
||||
|
||||
fail := func(status int, msg string) {
|
||||
if isForm {
|
||||
renderLoginPage(w, r, status, msg, in.Username, next)
|
||||
return
|
||||
}
|
||||
WriteError(w, status, msg)
|
||||
}
|
||||
if err := in.validate(); err != nil {
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := authenticateUser(db, in.Username, in.Password)
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
fail(http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("login: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
log.Printf("login: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, s)
|
||||
|
||||
if isForm {
|
||||
// PRG:以 303 導向登入前的返回路徑(無 next 時為帳號首頁 /)
|
||||
// 顯示已登入狀態,避免重新整理重複送出表單。
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, loginResponse{User: newPublicUser(u), ExpiresAt: s.ExpiresAt})
|
||||
}
|
||||
}
|
||||
|
||||
// setSessionCookie 將 Session ID 寫入 HttpOnly Cookie(表單與 API 流程共用)。
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: s.ID,
|
||||
Path: "/",
|
||||
Expires: s.ExpiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
// 本機 http 開發環境不設 Secure;請求經 TLS 服務時啟用。
|
||||
Secure: r.TLS != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials 表示帳號不存在或密碼錯誤,對外訊息一致。
|
||||
var ErrInvalidCredentials = errors.New("帳號或密碼錯誤")
|
||||
|
||||
// dummyPasswordHash 供查無帳號時使用:對它做一次完整的 argon2 比對,
|
||||
// 讓回應時間與真實驗證一致,避免以時間差枚舉有效帳號。
|
||||
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
||||
return HashPassword("alterminal-timing-equalizer")
|
||||
})
|
||||
|
||||
// authenticateUser 以 username 查詢使用者並驗證密碼。
|
||||
func authenticateUser(db *gorm.DB, username, password string) (*User, error) {
|
||||
var u User
|
||||
err := db.Where("username = ?", username).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
h, _ := dummyPasswordHash()
|
||||
VerifyPassword(password, h) // 結果丟棄,僅為消耗同等運算時間
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
if !u.CheckPassword(password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// newPublicUser 轉出可對外暴露的使用者欄位。
|
||||
func newPublicUser(u *User) publicUser {
|
||||
return publicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
EmailVerified: u.EmailVerified,
|
||||
Name: u.Name,
|
||||
Role: u.Role,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON 以 JSON 寫出回應。
|
||||
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// WriteError 寫出 {"error": ...} 格式的錯誤回應。
|
||||
func WriteError(w http.ResponseWriter, status int, msg string) {
|
||||
WriteJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginRequestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in loginRequest
|
||||
wantErr string // 空字串表示應通過
|
||||
}{
|
||||
{"最小欄位", loginRequest{Username: "alice", Password: "sup3r-secret"}, ""},
|
||||
{"username 帶首尾空白", loginRequest{Username: " alice ", Password: "sup3r-secret"}, ""},
|
||||
{"缺 username", loginRequest{Password: "sup3r-secret"}, "username"},
|
||||
{"username 僅空白", loginRequest{Username: " ", Password: "sup3r-secret"}, "username"},
|
||||
{"缺 password", loginRequest{Username: "alice"}, "password"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.in.validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRequestValidateTrimsUsername(t *testing.T) {
|
||||
in := loginRequest{Username: " alice\t", Password: "sup3r-secret"}
|
||||
if err := in.validate(); err != nil {
|
||||
t.Fatal("validate: ", err)
|
||||
}
|
||||
if in.Username != "alice" {
|
||||
t.Fatalf("validate 後 username = %q, want %q", in.Username, "alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRandomToken(t *testing.T) {
|
||||
for _, n := range []int{16, 32} {
|
||||
wantLen := (n*8 + 5) / 6 // base64url 無填充的編碼長度
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
token, err := NewToken(n)
|
||||
if err != nil {
|
||||
t.Fatal("NewToken: ", err)
|
||||
}
|
||||
if len(token) != wantLen {
|
||||
t.Fatalf("n=%d token 長度 = %d, want %d", n, len(token), wantLen)
|
||||
}
|
||||
if seen[token] {
|
||||
t.Fatalf("n=%d token 重複: %s", n, token)
|
||||
}
|
||||
seen[token] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 無效請求應在查詢資料庫前就回應,因此 handler 可以傳入 nil db 進行測試。
|
||||
func TestLoginHandlerRejectsInvalidInput(t *testing.T) {
|
||||
h := LoginHandler(nil)
|
||||
plainReq := httptest.NewRequest(http.MethodPost, "/login",
|
||||
strings.NewReader(`{"username":"alice","password":"sup3r-secret"}`))
|
||||
jsonReq := func(body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
wantStatus int
|
||||
}{
|
||||
{"Content-Type 非 JSON", plainReq, http.StatusUnsupportedMediaType},
|
||||
{"JSON 格式錯誤", jsonReq(`{username:`), http.StatusBadRequest},
|
||||
{"缺 username", jsonReq(`{"password":"sup3r-secret"}`), http.StatusBadRequest},
|
||||
{"缺 password", jsonReq(`{"username":"alice"}`), http.StatusBadRequest},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, tt.req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d, body = %s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"error"`) {
|
||||
t.Fatalf("回應應為 JSON error 格式: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPublicUserOmitsPasswordHash(t *testing.T) {
|
||||
u := &User{ID: 7, Username: "alice", Email: "alice@example.com", Name: "Alice", Role: RoleAdmin, PasswordHash: "$argon2id$secret"}
|
||||
pu := newPublicUser(u)
|
||||
if pu.ID != 7 || pu.Username != "alice" || pu.Email != "alice@example.com" || pu.Name != "Alice" || pu.Role != RoleAdmin {
|
||||
t.Fatalf("newPublicUser() = %+v, 欄位不符", pu)
|
||||
}
|
||||
b, err := json.Marshal(pu)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), "argon2") {
|
||||
t.Fatalf("回應不得含密碼雜湊: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 表單登入成功後以 303 導向帳號首頁 /,而非停留在 /login。
|
||||
func TestLoginHandlerFormSuccessRedirectsHome(t *testing.T) {
|
||||
db, err := openTestDB()
|
||||
if err != nil {
|
||||
t.Skipf("資料庫不可用,略過整合測試: %v", err)
|
||||
}
|
||||
suffix, err := NewToken(6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &User{Username: "login-" + suffix, Email: "login-" + suffix + "@example.com", Name: "Login Test"}
|
||||
if err := u.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Delete(&Session{}, "user_id = ?", u.ID)
|
||||
db.Delete(&User{}, u.ID)
|
||||
})
|
||||
|
||||
body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret"
|
||||
req := formPost(body, &http.Cookie{Name: CSRFCookieName, Value: "token-A"})
|
||||
rec := httptest.NewRecorder()
|
||||
LoginHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Fatalf("Location = %q, want /", loc)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Set-Cookie"), CookieName) {
|
||||
t.Fatalf("登入成功應設定 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SafeNext 僅接受站內路徑,阻擋外站與協定相對 URL(open redirect)。
|
||||
func TestSafeNext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"站內路徑", "/authorize?client_id=x", "/authorize?client_id=x"},
|
||||
{"未提供", "", "/"},
|
||||
{"外站絕對 URL", "https://evil.example/phish", "/"},
|
||||
{"協定相對 URL", "//evil.example", "/"},
|
||||
{"相對路徑", "admin/keys", "/"},
|
||||
{"僅 scheme", "javascript:alert(1)", "/"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SafeNext(tt.in); got != tt.want {
|
||||
t.Fatalf("SafeNext(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /login?next=... 應在表單保留 next;已登入時導向 next 而非 /。
|
||||
func TestLoginPageNext(t *testing.T) {
|
||||
next := "/authorize%3Fclient_id%3Dabc" // 已編碼的 query 值
|
||||
|
||||
t.Run("表單含隱藏 next 欄位", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login?next="+next, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `name="next"`) {
|
||||
t.Fatalf("登入表單應保留 next 隱藏欄位: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未帶 next 時不出現隱藏欄位", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
LoginPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if strings.Contains(rec.Body.String(), `name="next"`) {
|
||||
t.Fatal("無 next 時不需要隱藏欄位")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 表單登入成功後導向 next;惡意的 next 一律回到 /。
|
||||
func TestLoginHandlerFormNextRedirect(t *testing.T) {
|
||||
db, err := openTestDB()
|
||||
if err != nil {
|
||||
t.Skipf("資料庫不可用,略過整合測試: %v", err)
|
||||
}
|
||||
suffix, err := NewToken(6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &User{Username: "next-" + suffix, Email: "next-" + suffix + "@example.com"}
|
||||
if err := u.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Delete(&Session{}, "user_id = ?", u.ID)
|
||||
db.Delete(&User{}, u.ID)
|
||||
})
|
||||
|
||||
login := func(next string) *httptest.ResponseRecorder {
|
||||
body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret"
|
||||
if next != "" {
|
||||
body += "&next=" + next
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
LoginHandler(db)(rec, formPost(body, &http.Cookie{Name: CSRFCookieName, Value: "token-A"}))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("合法 next 導向原路徑", func(t *testing.T) {
|
||||
rec := login("%2Fauthorize%3Fclient_id%3Dabc")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/authorize?client_id=abc" {
|
||||
t.Fatalf("Location = %q, want /authorize?client_id=abc", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("惡意 next 導向首頁", func(t *testing.T) {
|
||||
rec := login("https%3A%2F%2Fevil.example")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Fatalf("Location = %q, want /", loc)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
var (
|
||||
loginTmpl = template.Must(template.ParseFS(templateFS, "templates/login.html"))
|
||||
// 已登入頁與管理頁透過 layout.html(側邊導覽欄版面)組合:layout 為
|
||||
// 第一個(根)模板,頁面模板僅定義 title/content 等區塊覆寫之,
|
||||
// 故 Execute 仍輸出版面本身。註冊頁與管理列表頁另解析 secretpanel.html
|
||||
// 的一次性成果面板;編輯頁無一次性面板,不在解析之列。
|
||||
loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html"))
|
||||
AdminKeysTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminkeys.html"))
|
||||
AdminApplicationsTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplications.html", "templates/secretpanel.html"))
|
||||
AdminApplicationNewTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationnew.html", "templates/secretpanel.html"))
|
||||
AdminApplicationEditTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationedit.html"))
|
||||
// 授權同意頁供 oidc 套件的 /authorize 使用,與管理頁同以 layout 組合。
|
||||
ConsentTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/consent.html"))
|
||||
notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html"))
|
||||
)
|
||||
|
||||
// CSRFCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱:
|
||||
// token 同時存在 Cookie 與表單隱藏欄位,送出時兩者必須相符。
|
||||
const (
|
||||
CSRFCookieName = "alterminal_csrf"
|
||||
csrfTTL = time.Hour
|
||||
)
|
||||
|
||||
// loginPageData 為登入表單頁的模板資料。
|
||||
type loginPageData struct {
|
||||
Error string // 驗證失敗訊息;空字串表示不顯示
|
||||
Username string // 驗證失敗時保留使用者輸入的帳號
|
||||
Next string // 登入成功後的返回路徑(如 /authorize 請求),空表示 /
|
||||
CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替
|
||||
}
|
||||
|
||||
// loggedInPageData 為已登入狀態頁的模板資料。
|
||||
type loggedInPageData struct {
|
||||
Error string // 錯誤訊息(如登出表單驗證失敗);空字串表示不顯示
|
||||
Username string
|
||||
Email string
|
||||
ExpiresAt string
|
||||
IsAdmin bool // admin 另顯示管理頁(金鑰/應用程式)導覽連結
|
||||
CSRF string // 登出表單隱藏欄位用 CSRF token,與 Cookie 成對輪替
|
||||
}
|
||||
|
||||
// LoginPageHandler 處理 GET /login(POST /login 的瀏覽器入口):登入頁
|
||||
// 僅供未登入者使用——持有效 Session 時導向 next 指定的返回路徑(無則
|
||||
// 帳號首頁 /),否則顯示登入表單。next 由 /authorize 於導向登入時
|
||||
// 攜入(OIDC Core §3.1.2.2)。
|
||||
func LoginPageHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
next := SafeNext(r.URL.Query().Get("next"))
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
_, err = GetSession(db, c.Value)
|
||||
switch {
|
||||
case err == nil:
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
return
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
// Session 過期,顯示登入表單
|
||||
default:
|
||||
log.Printf("login page: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
renderLoginPage(w, r, http.StatusOK, "", "", next)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountPageHandler 處理 GET /(帳號首頁):持有效 Session 顯示已登入
|
||||
// 狀態(含登出表單),否則顯示登入表單。
|
||||
func AccountPageHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
renderAccountPage(w, r, db, http.StatusOK, "")
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccountPage 依 Session 狀態輸出帳號頁:持有效 Session 顯示已登入
|
||||
// 狀態(含登出表單),否則顯示登入表單。errMsg 非空時顯示於輸出的頁面,
|
||||
// 供登出表單驗證失敗等錯誤以指定 status 重繪目前狀態。
|
||||
func renderAccountPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, errMsg string) {
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
s, err := GetSession(db, c.Value)
|
||||
switch {
|
||||
case err == nil:
|
||||
renderLoggedInPage(w, r, status, s, errMsg)
|
||||
return
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
// Session 過期,回到登入表單
|
||||
default:
|
||||
log.Printf("login page: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
renderLoginPage(w, r, status, errMsg, "", "")
|
||||
}
|
||||
|
||||
// renderLoggedInPage 輸出已登入狀態頁;每次輸出都輪替 CSRF token,
|
||||
// 供登出表單 double-submit 驗證。
|
||||
func renderLoggedInPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg string) {
|
||||
token, err := NewCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
RenderHTML(w, status, loggedInTmpl, loggedInPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
ExpiresAt: s.ExpiresAt.Local().Format("2006-01-02 15:04:05 MST"),
|
||||
IsAdmin: s.User.Role == RoleAdmin,
|
||||
CSRF: token,
|
||||
})
|
||||
}
|
||||
|
||||
// renderLoginPage 輸出登入表單頁;每次輸出都輪替 CSRF token 並重設對應 Cookie。
|
||||
// next 為登入成功後的返回路徑,以隱藏欄位隨表單保留。
|
||||
func renderLoginPage(w http.ResponseWriter, r *http.Request, status int, errMsg, username, next string) {
|
||||
token, err := NewCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
RenderHTML(w, status, loginTmpl, loginPageData{Error: errMsg, Username: username, Next: next, CSRF: token})
|
||||
}
|
||||
|
||||
// NewCSRFToken 產生新 CSRF token 並設定對應 Cookie,與表單隱藏欄位成對。
|
||||
func NewCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
token, err := NewToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CSRFCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(csrfTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// VerifyCSRF 以 constant-time 比對表單隱藏欄位與 Cookie 中的 CSRF token。
|
||||
func VerifyCSRF(r *http.Request) bool {
|
||||
c, err := r.Cookie(CSRFCookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return false
|
||||
}
|
||||
token := r.PostFormValue("csrf_token")
|
||||
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(c.Value)) == 1
|
||||
}
|
||||
|
||||
// RenderHTML 以 text/html 輸出模板;模板執行錯誤僅記錄(此時表頭已送出)。
|
||||
// CSP 停用外部資源載入(樣式僅允許本站 /static/),表單僅可送出到本站。
|
||||
func RenderHTML(w http.ResponseWriter, status int, tmpl *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'")
|
||||
w.WriteHeader(status)
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("render template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formPost(body string, cookie *http.Cookie) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// 未帶 Session Cookie 時不會查詢資料庫,因此 handler 可以傳入 nil db。
|
||||
func TestLoginPageRendersForm(t *testing.T) {
|
||||
h := LoginPageHandler(nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "style-src 'self'") {
|
||||
t.Fatalf("Content-Security-Policy = %q, 應停用外部資源載入且樣式僅允許本站", csp)
|
||||
}
|
||||
for _, want := range []string{`<form`, `name="username"`, `name="password"`, `name="csrf_token"`, `/static/css/main.css`} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("登入表單缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) {
|
||||
t.Fatal("輸出表單時應設定 CSRF Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// renderLoggedInPage 不查詢資料庫,可直接以虛構 Session 測試側邊導覽欄版面。
|
||||
func TestRenderLoggedInPageSidebar(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
s := &Session{
|
||||
ID: "test-session",
|
||||
User: User{Username: "alice", Email: "alice@example.com"},
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
renderLoggedInPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, s, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"<aside", // 側邊導覽欄
|
||||
`aria-label="側邊導覽列"`,
|
||||
"alterminal", // 品牌區
|
||||
`href="/"`, // 導覽項目(帳號首頁)
|
||||
`aria-current="page"`,
|
||||
"帳號資訊",
|
||||
`id="sidebar-toggle"`, // 手機版純 CSS 開合(CSP 不允許 JS)
|
||||
`action="/logout"`, // 側欄頁尾的登出表單
|
||||
`name="csrf_token"`,
|
||||
"alice@example.com",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("已登入頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLoginPageStaysStandalone(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, "", "", "/")
|
||||
if strings.Contains(rec.Body.String(), "<aside") {
|
||||
t.Fatal("登入表單頁應維持獨立版面,不含側邊導覽欄")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderLoginPageEscapesPrefill(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil),
|
||||
http.StatusUnauthorized, "帳號或密碼錯誤", "<script>alert(1)</script>", "/")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Fatal("預填帳號須經 HTML 轉義")
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Fatal("預填帳號應以轉義後的值輸出")
|
||||
}
|
||||
if !strings.Contains(body, "帳號或密碼錯誤") {
|
||||
t.Fatal("應顯示錯誤訊息")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCSRF(t *testing.T) {
|
||||
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
|
||||
tests := []struct {
|
||||
name string
|
||||
req *http.Request
|
||||
want bool
|
||||
}{
|
||||
{"相符", formPost("csrf_token=token-A&username=a&password=b", cookie), true},
|
||||
{"不相符", formPost("csrf_token=token-B&username=a&password=b", cookie), false},
|
||||
{"缺少 Cookie", formPost("csrf_token=token-A&username=a&password=b", nil), false},
|
||||
{"缺少欄位", formPost("username=a&password=b", cookie), false},
|
||||
{"空欄位", formPost("csrf_token=&username=a&password=b", cookie), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := VerifyCSRF(tt.req); got != tt.want {
|
||||
t.Fatalf("VerifyCSRF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 表單流程的錯誤路徑都在查詢資料庫前回應,可用 nil db 測試。
|
||||
func TestLoginHandlerFormRejections(t *testing.T) {
|
||||
h := LoginHandler(nil)
|
||||
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
|
||||
|
||||
t.Run("CSRF 不符回 403 表單", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=wrong&username=alice&password=sup3r-secret", cookie))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應在表單中顯示 CSRF 錯誤訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("缺 password 回 400 表單並保留帳號", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=token-A&username=alice&password=", cookie))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "password 不可為空") {
|
||||
t.Fatal("應顯示驗證錯誤訊息")
|
||||
}
|
||||
if !strings.Contains(body, `value="alice"`) {
|
||||
t.Fatal("應保留使用者輸入的帳號")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不支援的 Content-Type 回 JSON 415", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("x=1"))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("status = %d, want 415", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"error"`) {
|
||||
t.Fatalf("非表單流程應回 JSON 錯誤: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LogoutHandler 處理 POST /logout,依 Content-Type 分流(與登入一致):
|
||||
// 表單走瀏覽器流程(需通過 CSRF 驗證,失敗時以 403 重繪目前狀態頁),
|
||||
// JSON 走 API 流程。登出為冪等操作——查無 Session 亦視為成功;資料庫
|
||||
// 刪除失敗僅記錄,仍清除 Cookie 並回應成功(Session 最遲於效期到期失效)。
|
||||
func LogoutHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var isForm bool
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "application/json"):
|
||||
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"),
|
||||
strings.HasPrefix(ct, "multipart/form-data"):
|
||||
isForm = true
|
||||
default:
|
||||
WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單")
|
||||
return
|
||||
}
|
||||
|
||||
if isForm {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderAccountPage(w, r, db, http.StatusBadRequest, "無法解析表單內容")
|
||||
return
|
||||
}
|
||||
if !VerifyCSRF(r) {
|
||||
renderAccountPage(w, r, db, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
if err := DeleteSession(db, c.Value); err != nil {
|
||||
log.Printf("logout: %v", err)
|
||||
}
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
|
||||
if isForm {
|
||||
// PRG:以 303 導向 /login 顯示登入表單,避免重新整理重複送出。
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// clearSessionCookie 以 Max-Age=0 清除瀏覽器的 Session Cookie(與
|
||||
// setSessionCookie 對稱,屬性一致以免因 Path 或 Secure 差異清不掉)。
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sessionCookieCleared 檢查回應是否以 Max-Age=0 清除 Session Cookie。
|
||||
func sessionCookieCleared(rec *httptest.ResponseRecorder) bool {
|
||||
for _, sc := range rec.Header().Values("Set-Cookie") {
|
||||
if strings.HasPrefix(sc, CookieName+"=") && strings.Contains(sc, "Max-Age=0") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 以下拒絕路徑皆不觸及資料庫,可用 nil db 測試。
|
||||
func TestLogoutHandlerJSONWithoutCookie(t *testing.T) {
|
||||
h := LogoutHandler(nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !sessionCookieCleared(rec) {
|
||||
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutHandlerRejectsUnsupportedContentType(t *testing.T) {
|
||||
h := LogoutHandler(nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1"))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("status = %d, want 415", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"error"`) {
|
||||
t.Fatalf("應回 JSON 錯誤: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutHandlerFormCSRFRejections(t *testing.T) {
|
||||
h := LogoutHandler(nil)
|
||||
|
||||
t.Run("CSRF 不符回 403 並重繪登入表單", func(t *testing.T) {
|
||||
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=token-B", cookie))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應顯示 CSRF 錯誤訊息")
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) {
|
||||
t.Fatal("重繪表單時應輪替 CSRF Cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("缺 CSRF Cookie 回 403", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=token-A", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("表單無法解析回 400", func(t *testing.T) {
|
||||
cookie := &http.Cookie{Name: CSRFCookieName, Value: "token-A"}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, formPost("csrf_token=%zz", cookie))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "無法解析表單內容") {
|
||||
t.Fatal("應顯示解析錯誤訊息")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogoutIntegration(t *testing.T) {
|
||||
db, err := openTestDB()
|
||||
if err != nil {
|
||||
t.Skipf("資料庫不可用,略過整合測試: %v", err)
|
||||
}
|
||||
suffix, err := NewToken(6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &User{Username: "logout-" + suffix, Email: "logout-" + suffix + "@example.com", Name: "Logout Test"}
|
||||
if err := u.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Delete(&Session{}, "user_id = ?", u.ID)
|
||||
db.Delete(&User{}, u.ID)
|
||||
})
|
||||
|
||||
t.Run("已登入首頁含登出表單", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
AccountPageHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`action="/logout"`, `name="csrf_token"`, "登出"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("已登入頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已登入者造訪 /login 導向 /", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
LoginPageHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Fatalf("Location = %q, want /", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("表單登出刪除 Session 並導向 /login", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"})
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
LogoutHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
if !sessionCookieCleared(rec) {
|
||||
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
|
||||
}
|
||||
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("重複登出冪等", func(t *testing.T) {
|
||||
req := formPost("csrf_token=token-A", &http.Cookie{Name: CSRFCookieName, Value: "token-A"})
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: "already-deleted"})
|
||||
rec := httptest.NewRecorder()
|
||||
LogoutHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON 登出回 204", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
LogoutHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !sessionCookieCleared(rec) {
|
||||
t.Fatal("應清除 Session Cookie")
|
||||
}
|
||||
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("登出後 GetSession() = %v, want ErrSessionExpired", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package auth
|
||||
|
||||
import "net/http"
|
||||
|
||||
// NotFoundHandler 回應自訂 404 頁,作為 chi 的 NotFound handler:僅在
|
||||
// 沒有任何路由匹配時觸發(如 /static/ 下不存在的檔案由檔案伺服器
|
||||
// 自行回應純文字 404),且不分方法——POST 到未知路徑同樣輸出本頁。
|
||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
||||
RenderHTML(w, http.StatusNotFound, notFoundTmpl, nil)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestNotFoundHandlerRendersPage(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
NotFoundHandler(rec, httptest.NewRequest(http.MethodGet, "/no-such-page", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Fatalf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "style-src 'self'") {
|
||||
t.Fatalf("Content-Security-Policy = %q, 應停用外部資源載入且樣式僅允許本站", csp)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"404", "找不到頁面", `href="/login"`, `/static/css/main.css`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("404 頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<aside") {
|
||||
t.Fatal("404 頁應為獨立版面,不含側邊導覽欄")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterNotFoundUsesCustomPage(t *testing.T) {
|
||||
// chi 的 NotFound 不分方法:GET 與 POST 到未匹配路徑都應輸出自訂頁。
|
||||
r := chi.NewRouter()
|
||||
r.Get("/login", func(w http.ResponseWriter, r *http.Request) {})
|
||||
r.NotFound(NotFoundHandler)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, httptest.NewRequest(method, "/definitely-not-a-route", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status = %d, want 404", method, rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "找不到頁面") {
|
||||
t.Fatalf("%s 應輸出自訂 404 頁,body = %s", method, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Session 為使用者瀏覽器 Session(SSO 核心):ID 為加密安全亂數,
|
||||
// 存於 HttpOnly Cookie,效期內使用者再經任何 RP 發起授權請求時
|
||||
// 無須重新輸入帳密。
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;size:43"` // 32 bytes 亂數的 base64url
|
||||
UserID uint `gorm:"not null;index"`
|
||||
User User
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// sessionTTL 為 Session 有效時間,到期後 Cookie 失效、列為可清除。
|
||||
const sessionTTL = 24 * time.Hour
|
||||
|
||||
// ErrSessionExpired 表示 Session 不存在或已過期。
|
||||
var ErrSessionExpired = errors.New("session 不存在或已過期")
|
||||
|
||||
// NewToken 產生 n bytes 加密安全亂數的 base64url 字串(無填充;
|
||||
// n=32 時為 43 字元),供 Session ID 與 CSRF token 共用。
|
||||
func NewToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateSession 為使用者建立新 Session,順帶刪除所有已過期 Session
|
||||
// (最佳清除,失敗不影響登入結果)。
|
||||
func CreateSession(db *gorm.DB, userID uint) (*Session, error) {
|
||||
id, err := NewToken(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Session{ID: id, UserID: userID, ExpiresAt: time.Now().Add(sessionTTL)}
|
||||
if err := db.Create(s).Error; err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
db.Where("expires_at < ?", time.Now()).Delete(&Session{})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// DeleteSession 以 ID 刪除 Session(登出用)。查無該 Session 不視為
|
||||
// 錯誤,讓登出維持冪等。
|
||||
func DeleteSession(db *gorm.DB, id string) error {
|
||||
if err := db.Delete(&Session{}, "id = ?", id).Error; err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession 以 ID 查詢效期內的 Session(含所屬使用者)。
|
||||
func GetSession(db *gorm.DB, id string) (*Session, error) {
|
||||
var s Session
|
||||
err := db.Preload("User").Where("id = ? AND expires_at > ?", id, time.Now()).First(&s).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query session: %w", err)
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// StaticHandler 以 /static/ 前綴提供 assets 內的靜態檔案
|
||||
// (Tailwind 建置輸出的 CSS 等),並允許瀏覽器快取。
|
||||
func StaticHandler() http.Handler {
|
||||
sub, err := fs.Sub(assetsFS, "assets")
|
||||
if err != nil {
|
||||
panic(err) // embed 路徑固定,僅防呆
|
||||
}
|
||||
fileServer := http.StripPrefix("/static/", http.FileServerFS(sub))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 內容隨執行檔重建,過期重抓即可。
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStaticHandlerServesCSS(t *testing.T) {
|
||||
h := StaticHandler()
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/css/main.css", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
|
||||
t.Fatalf("Content-Type = %q, want text/css", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
|
||||
t.Fatalf("Cache-Control = %q, want public, max-age=3600", cc)
|
||||
}
|
||||
if rec.Body.Len() == 0 {
|
||||
t.Fatal("CSS 內容不應為空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHandlerRejectsTraversal(t *testing.T) {
|
||||
h := StaticHandler()
|
||||
// FileServer 以路徑對應 embed FS,目錄外不存在任何檔案,穿越應得到 404。
|
||||
req := httptest.NewRequest(http.MethodGet, "/static/../main.go", nil)
|
||||
req.URL.Path = "/static/../main.go"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{{/* 編輯應用程式頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板
|
||||
組合渲染:本檔僅定義區塊,不應單獨解析執行。導覽覆寫同管理列表頁
|
||||
(「應用程式管理」標記 aria-current)。表單預填既有註冊內容,驗證失敗
|
||||
重繪時保留輸入(含核取方塊);client_id 為公開識別碼、已嵌入各 RP
|
||||
設定而不可變更,與建立時間一併唯讀顯示;輪替 client secret 另經列表
|
||||
頁。儲存成功後 PRG 回本頁以 ?saved=1 顯示成功訊息(編輯無一次性
|
||||
資料)。 */}}
|
||||
{{define "title"}}編輯應用程式 - alterminal{{end}}
|
||||
|
||||
{{define "navitems"}}
|
||||
<li>
|
||||
<a href="/login"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/keys"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications" aria-current="page"
|
||||
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="mb-1 text-xl font-semibold">編輯應用程式</h1>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">更新接入 OIDC 的應用程式(Relying Party)註冊內容</p>
|
||||
</div>
|
||||
<a href="/admin/applications"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/>
|
||||
</svg>
|
||||
返回列表
|
||||
</a>
|
||||
</div>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Success}}<p class="mb-4 rounded-lg bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-700 dark:text-emerald-400" role="status">{{.Success}}</p>{{end}}
|
||||
|
||||
<dl class="mb-6 space-y-2 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<dt class="font-medium text-neutral-700 dark:text-neutral-300">client_id(不可變更)</dt>
|
||||
<dd class="font-mono text-xs break-all">{{.ClientID}}</dd>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<dt class="font-medium text-neutral-700 dark:text-neutral-300">建立時間</dt>
|
||||
<dd class="text-xs text-neutral-500 dark:text-neutral-400">{{.Created}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form method="post" action="/admin/applications/{{.ID}}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<div>
|
||||
<label for="app-name" class="mb-1 block text-sm font-medium">名稱</label>
|
||||
<input id="app-name" name="name" type="text" required maxlength="255" value="{{.Form.Name}}"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-type" class="mb-1 block text-sm font-medium">類型</label>
|
||||
<select id="app-type" name="type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
<option value="confidential" {{if eq .Form.Type "confidential"}}selected{{end}}>機密式 confidential(後端應用,發配 client secret)</option>
|
||||
<option value="public" {{if eq .Form.Type "public"}}selected{{end}}>公開式 public(SPA/行動應用,無 secret,須用 PKCE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">改為公開式將清除既有 client secret(立即失效);由公開式改回機密式後,請於列表頁輪替取得新 secret。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-redirect-uris" class="mb-1 block text-sm font-medium">Redirect URI(每行一個)</label>
|
||||
<textarea id="app-redirect-uris" name="redirect_uris" rows="3"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。</p>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-1 text-sm font-medium">允許的 grant type</legend>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="authorization_code" class="size-4" {{if .Form.GrantAuthCode}}checked{{end}}> authorization_code(授權碼流程)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="refresh_token" class="size-4" {{if .Form.GrantRefresh}}checked{{end}}> refresh_token(Refresh Token)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="client_credentials" class="size-4" {{if .Form.GrantClientCred}}checked{{end}}> client_credentials(機器對機器,僅機密式)</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">全不勾選時視為僅 authorization_code。</p>
|
||||
</fieldset>
|
||||
<div>
|
||||
<label for="app-scope" class="mb-1 block text-sm font-medium">Scope</label>
|
||||
<input id="app-scope" name="scope" type="text" value="{{.Form.Scope}}" placeholder="openid profile email"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">空格分隔,可用:openid、profile、email、offline_access;留空使用預設 openid profile email(offline_access 須勾選 refresh_token)。</p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">儲存變更</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,97 @@
|
||||
{{/* 註冊新應用程式頁(僅 admin,獨立於管理列表頁)。以 layout.html(側邊
|
||||
導覽欄版面)為根模板組合渲染:本檔僅定義區塊,不應單獨解析執行;
|
||||
並引用 secretpanel.html 的一次性成果面板。導覽覆寫同管理列表頁
|
||||
(「應用程式管理」標記 aria-current)。註冊成功時於 POST 回應直接
|
||||
顯示成果(明文 client secret 僅此一次,故不採 PRG);驗證失敗重繪
|
||||
時保留輸入(含核取方塊)。 */}}
|
||||
{{define "title"}}註冊新應用程式 - alterminal{{end}}
|
||||
|
||||
{{define "navitems"}}
|
||||
<li>
|
||||
<a href="/login"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/keys"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications" aria-current="page"
|
||||
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="mb-1 text-xl font-semibold">註冊新應用程式</h1>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">接入 OIDC 的應用程式(Relying Party)註冊</p>
|
||||
</div>
|
||||
<a href="/admin/applications"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/>
|
||||
</svg>
|
||||
返回列表
|
||||
</a>
|
||||
</div>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Secret}}{{template "secretpanel" .Secret}}{{end}}
|
||||
|
||||
<form method="post" action="/admin/applications/new" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<div>
|
||||
<label for="app-name" class="mb-1 block text-sm font-medium">名稱</label>
|
||||
<input id="app-name" name="name" type="text" required maxlength="255" value="{{.Form.Name}}"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-type" class="mb-1 block text-sm font-medium">類型</label>
|
||||
<select id="app-type" name="type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
<option value="confidential" {{if eq .Form.Type "confidential"}}selected{{end}}>機密式 confidential(後端應用,發配 client secret)</option>
|
||||
<option value="public" {{if eq .Form.Type "public"}}selected{{end}}>公開式 public(SPA/行動應用,無 secret,須用 PKCE)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-redirect-uris" class="mb-1 block text-sm font-medium">Redirect URI(每行一個)</label>
|
||||
<textarea id="app-redirect-uris" name="redirect_uris" rows="3"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。</p>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-1 text-sm font-medium">允許的 grant type</legend>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="authorization_code" class="size-4" {{if .Form.GrantAuthCode}}checked{{end}}> authorization_code(授權碼流程)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="refresh_token" class="size-4" {{if .Form.GrantRefresh}}checked{{end}}> refresh_token(Refresh Token)</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="client_credentials" class="size-4" {{if .Form.GrantClientCred}}checked{{end}}> client_credentials(機器對機器,僅機密式)</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">全不勾選時預設 authorization_code。</p>
|
||||
</fieldset>
|
||||
<div>
|
||||
<label for="app-scope" class="mb-1 block text-sm font-medium">Scope</label>
|
||||
<input id="app-scope" name="scope" type="text" value="{{.Form.Scope}}" placeholder="openid profile email"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">空格分隔,可用:openid、profile、email、offline_access;留空使用預設 openid profile email(offline_access 須勾選 refresh_token)。</p>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">註冊應用程式</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,114 @@
|
||||
{{/* 應用程式管理頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板
|
||||
組合渲染:本檔僅定義區塊,不應單獨解析執行;並引用 secretpanel.html
|
||||
的一次性成果面板。導覽覆寫為「帳號資訊+金鑰管理+應用程式管理」
|
||||
(後者標記 aria-current),側欄頁尾沿用版面預設。每列提供編輯(連往
|
||||
/admin/applications/{id})、輪替 secret 與刪除操作;註冊表單獨立於
|
||||
/admin/applications/new(本頁按鈕進入);輪替成功時於 POST 回應直接
|
||||
顯示明文 client secret(僅此一次,故不採 PRG)。 */}}
|
||||
{{define "title"}}應用程式管理 - alterminal{{end}}
|
||||
|
||||
{{define "navitems"}}
|
||||
<li>
|
||||
<a href="/login"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/keys"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications" aria-current="page"
|
||||
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="mb-1 text-xl font-semibold">應用程式管理</h1>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">接入 OIDC 的應用程式(Relying Party)註冊</p>
|
||||
</div>
|
||||
<a href="/admin/applications/new"
|
||||
class="flex items-center gap-2 rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
註冊新應用程式
|
||||
</a>
|
||||
</div>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Secret}}{{template "secretpanel" .Secret}}{{end}}
|
||||
|
||||
{{if .Apps}}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">名稱</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">client_id</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">類型</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">redirect URI</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">grant type</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">scope</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">建立時間</th>
|
||||
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-100 dark:divide-neutral-700/60">
|
||||
{{range .Apps}}
|
||||
<tr>
|
||||
<td class="px-3 py-3 font-medium">{{.Name}}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all">{{.ClientID}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
{{if .Confidential}}
|
||||
<span class="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-400">機密式</span>
|
||||
{{else}}
|
||||
<span class="rounded-full bg-violet-500/10 px-2.5 py-0.5 text-xs font-medium text-violet-700 dark:text-violet-400">公開式</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all whitespace-pre-line">{{.RedirectURIs}}</td>
|
||||
<td class="px-3 py-3 text-xs">{{.GrantTypes}}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all">{{.Scope}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap text-neutral-500 dark:text-neutral-400">{{.CreatedAt}}</td>
|
||||
<td class="px-3 py-3">
|
||||
<a href="/admin/applications/{{.ID}}" title="編輯註冊內容"
|
||||
class="mb-1 block rounded-lg border border-neutral-300 px-3 py-1.5 text-center text-xs font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">編輯</a>
|
||||
{{if .Confidential}}
|
||||
<form method="post" action="/admin/applications/{{.ID}}/secret" class="mb-1">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<button type="submit" title="產生新 client secret(舊的立即失效)"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">輪替 secret</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/applications/{{.ID}}/delete">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<button type="submit" title="刪除後此應用程式無法再登入"
|
||||
class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:border-red-500/60 dark:text-red-400 dark:hover:bg-red-500/10">刪除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="py-6 text-sm text-neutral-500 dark:text-neutral-400">尚無應用程式,點選上方「註冊新應用程式」建立第一個。</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,99 @@
|
||||
{{/* 金鑰管理頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板組合
|
||||
渲染:本檔僅定義區塊,不應單獨解析執行。導覽覆寫為「帳號資訊+金鑰
|
||||
管理(aria-current)+應用程式管理」,側欄頁尾沿用版面預設(使用者
|
||||
資訊與登出表單)。 */}}
|
||||
{{define "title"}}金鑰管理 - alterminal{{end}}
|
||||
|
||||
{{define "navitems"}}
|
||||
<li>
|
||||
<a href="/login"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/keys" aria-current="page"
|
||||
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mb-1 text-xl font-semibold">金鑰管理</h1>
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">JWT 簽章金鑰(RSA-2048 · RS256)</p>
|
||||
</div>
|
||||
<form method="post" action="/admin/keys">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">產生新金鑰</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if not .ActiveCount}}<p class="mb-4 rounded-lg bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400" role="alert">目前沒有使用中的金鑰,將無法簽發 JWT,請立即產生新金鑰。</p>{{end}}
|
||||
{{if .Keys}}
|
||||
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">使用中 {{.ActiveCount}} 把 / 共 {{len .Keys}} 把</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">kid</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">演算法</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">建立時間</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">狀態</th>
|
||||
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-100 dark:divide-neutral-700/60">
|
||||
{{range .Keys}}
|
||||
<tr>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all">{{.Kid}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">{{.Algorithm}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap text-neutral-500 dark:text-neutral-400">{{.CreatedAt}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
{{if .Active}}
|
||||
<span class="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">使用中</span>
|
||||
{{else}}
|
||||
<span class="rounded-full bg-neutral-500/10 px-2.5 py-0.5 text-xs font-medium text-neutral-600 dark:text-neutral-400">已退休</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap">
|
||||
{{if .Active}}{{if .LastActive}}
|
||||
<span class="text-xs text-neutral-400 dark:text-neutral-500" title="最後一把使用中金鑰,無法退休">唯一使用中金鑰</span>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/keys/{{.ID}}/retire">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
|
||||
<button type="submit"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">退休</button>
|
||||
</form>
|
||||
{{end}}{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">輪替方式:先「產生新金鑰」並以新金鑰簽發,舊金鑰確認無人使用後再「退休」(退休後仍發佈於 JWKS 一段時間供驗證)。</p>
|
||||
{{else}}
|
||||
<p class="py-6 text-sm text-neutral-500 dark:text-neutral-400">尚無簽章金鑰,點上方「產生新金鑰」建立第一把。</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{/* 授權同意頁(/authorize)。以 layout.html(側邊導覽欄版面)為根模板
|
||||
組合渲染,本檔僅定義區塊。使用者已登入(Session 有效)才會看到本
|
||||
頁:顯示發起授權的應用程式名稱與其要求的 scope 清單,送出同意或
|
||||
拒絕。原始授權請求的每個參數以隱藏欄位原封帶回 POST /authorize。 */}}
|
||||
{{define "title"}}授權存取 - alterminal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">授權存取</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
應用程式 <strong class="text-neutral-900 dark:text-neutral-100">{{.AppName}}</strong> 要求以下權限:
|
||||
</p>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
<ul class="mb-6 list-none space-y-2 p-0">
|
||||
{{range .Scopes}}
|
||||
<li class="flex flex-col gap-0.5 rounded-lg border border-neutral-200 px-3.5 py-2.5 dark:border-neutral-700">
|
||||
<span class="font-mono text-sm font-medium">{{.Scope}}</span>
|
||||
<span class="text-sm text-neutral-500 dark:text-neutral-400">{{.Description}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<form method="post" action="/authorize">
|
||||
{{range $k, $vs := .Params}}{{range $vs}}<input type="hidden" name="{{$k}}" value="{{.}}">{{end}}{{end}}
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<button type="submit" name="decision" value="allow"
|
||||
class="rounded-lg bg-brand px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">同意</button>
|
||||
<button type="submit" name="decision" value="deny"
|
||||
class="rounded-lg border border-neutral-300 px-5 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">拒絕</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 text-xs text-neutral-400 dark:text-neutral-500">同意後,之後來自同一應用程式且範圍相同的授權請求將不再詢問。</p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>{{block "title" .}}alterminal{{end}}</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
</head>
|
||||
{{/*
|
||||
側邊導覽欄版面(app shell):桌面版(md+)側欄固定展開,主內容以
|
||||
md:pl-72 偏移;手機版側欄預設移出畫面外,以隱藏 checkbox(peer)搭配
|
||||
peer-checked: 變體開合——CSP 停用 JavaScript,故開合必須是純 CSS。
|
||||
頁面模板需定義 "content",可另定義 "title"、"navitems"、"sidebarfooter"
|
||||
(後三者未定義時使用此處的預設)。
|
||||
*/}}
|
||||
<body class="min-h-screen bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<div class="min-h-screen">
|
||||
<input type="checkbox" id="sidebar-toggle" class="peer sr-only" aria-label="切換側邊導覽列">
|
||||
<label for="sidebar-toggle" title="開啟導覽列"
|
||||
class="fixed left-4 top-4 z-50 flex size-11 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 shadow-sm md:hidden peer-checked:hidden peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-brand dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
|
||||
</svg>
|
||||
</label>
|
||||
<label for="sidebar-toggle" aria-hidden="true"
|
||||
class="fixed inset-0 z-30 hidden cursor-pointer bg-neutral-900/40 peer-checked:block md:hidden!"></label>
|
||||
<aside aria-label="側邊導覽列"
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-72 -translate-x-full flex-col border-r border-neutral-200 bg-white transition-transform duration-200 ease-in-out peer-checked:translate-x-0 md:translate-x-0 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<div class="flex items-center gap-3 border-b border-neutral-200 px-6 py-5 dark:border-neutral-700">
|
||||
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand text-white">
|
||||
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-base font-semibold leading-tight">alterminal</span>
|
||||
<span class="block text-xs text-neutral-500 dark:text-neutral-400">單一登入服務</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav aria-label="主要導覽" class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul class="space-y-1">
|
||||
{{block "navitems" .}}
|
||||
<li>
|
||||
<a href="/" aria-current="page"
|
||||
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
{{if .IsAdmin}}
|
||||
<li>
|
||||
<a href="/admin/keys"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="border-t border-neutral-200 px-4 py-4 dark:border-neutral-700">
|
||||
{{/* 預設頁尾:使用者資訊與登出表單(頁面資料需含 Username/Email/CSRF),
|
||||
未登入脈絡的頁面不應使用本版面。 */}}
|
||||
{{block "sidebarfooter" .}}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{{.Username}}</p>
|
||||
<p class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{.Email}}</p>
|
||||
</div>
|
||||
<form method="post" action="/logout" class="mt-3">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<button type="submit"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-300 py-2 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0L18 12m0 0 2.25-2.25M18 12H9"/>
|
||||
</svg>
|
||||
登出
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="flex min-h-screen flex-col md:pl-72">
|
||||
<main class="mx-auto w-full max-w-3xl flex-1 p-4 md:p-10">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{{/* 已登入狀態頁。以 layout.html(側邊導覽欄版面)為根模板組合渲染:
|
||||
本檔僅定義區塊,不應單獨解析執行。導覽與側欄頁尾沿用版面預設
|
||||
(帳號資訊標記 aria-current;admin 另顯示金鑰管理連結;
|
||||
頁尾為使用者資訊與登出表單)。 */}}
|
||||
{{define "title"}}帳號資訊 - alterminal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">帳號資訊</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">已登入:單一登入服務</p>
|
||||
{{if .Error}}<p class="mb-2 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
<dl class="m-0">
|
||||
<dt class="text-sm text-neutral-500 dark:text-neutral-400">帳號</dt>
|
||||
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.Username}}</dd>
|
||||
<dt class="text-sm text-neutral-500 dark:text-neutral-400">Email</dt>
|
||||
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.Email}}</dd>
|
||||
<dt class="text-sm text-neutral-500 dark:text-neutral-400">Session 到期</dt>
|
||||
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.ExpiresAt}}</dd>
|
||||
</dl>
|
||||
<p class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">授權流程(/authorize)完成後,登入將自動導回應用程式。</p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>登入 - alterminal</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<main class="m-4 w-full max-w-88 rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">登入 alterminal</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">單一登入服務</p>
|
||||
{{if .Error}}<p class="mb-2 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
{{if ne .Next "/"}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
|
||||
<label for="username" class="mb-1 mt-4 block text-sm">帳號</label>
|
||||
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-transparent px-3 py-2.5 text-base focus:border-transparent focus:outline-2 focus:outline-offset-1 focus:outline-brand dark:border-neutral-600">
|
||||
<label for="password" class="mb-1 mt-4 block text-sm">密碼</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-transparent px-3 py-2.5 text-base focus:border-transparent focus:outline-2 focus:outline-offset-1 focus:outline-brand dark:border-neutral-600">
|
||||
<button type="submit" class="mt-6 w-full rounded-lg bg-brand py-2.5 text-base font-semibold text-white hover:bg-brand-strong">登入</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>找不到頁面 - alterminal</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
</head>
|
||||
{{/*
|
||||
404 頁採獨立版面(同登入頁):訪客可能未登入,無法提供側邊導覽欄
|
||||
版面所需的 Session 資料。CSP 停用 JavaScript,無法用 history.back(),
|
||||
僅提供回到 /login 的連結(未登入顯示登入表單、已登入顯示帳號資訊)。
|
||||
*/}}
|
||||
<body class="flex min-h-screen items-center justify-center bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<main class="m-4 w-full max-w-88 rounded-xl bg-white p-8 text-center shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<p class="text-5xl font-bold tracking-tight text-brand">404</p>
|
||||
<h1 class="mb-1 mt-3 text-xl font-semibold">找不到頁面</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">要求的頁面不存在,可能已被移動或網址有誤。</p>
|
||||
<a href="/login" class="inline-block w-full rounded-lg bg-brand px-4 py-2.5 text-base font-semibold text-white hover:bg-brand-strong">回到登入頁</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{{/* 一次性成果面板(共用片段):註冊應用程式與輪替 client secret 成功時,
|
||||
於 POST 回應直接渲染——資料庫僅存雜湊,明文無法重現,故不採 PRG。
|
||||
僅定義 "secretpanel" 區塊供頁面模板以 {{template "secretpanel" .Secret}}
|
||||
引用,不應單獨解析執行。機密式顯示明文 client_secret 與保存警告;
|
||||
公開式無 secret,改提示以 PKCE 驗證授權請求。 */}}
|
||||
{{define "secretpanel"}}
|
||||
<div class="mb-6 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-4" role="status">
|
||||
{{if .Rotated}}
|
||||
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}}:client secret 已輪替</h2>
|
||||
{{else if .Public}}
|
||||
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}} 已註冊(公開式 Client)</h2>
|
||||
{{else}}
|
||||
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}} 已註冊:client secret 已發配</h2>
|
||||
{{end}}
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<dt class="font-medium text-neutral-700 dark:text-neutral-300">client_id</dt>
|
||||
<dd class="font-mono text-xs break-all">{{.ClientID}}</dd>
|
||||
</div>
|
||||
{{if .Secret}}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<dt class="font-medium text-neutral-700 dark:text-neutral-300">client_secret</dt>
|
||||
<dd class="font-mono text-xs break-all">{{.Secret}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
{{if .Secret}}
|
||||
<p class="mt-3 text-sm font-medium text-red-600 dark:text-red-400">此 client secret 只顯示這一次,關閉或重新整理頁面後將無法再查看,請立即交付給應用程式管理者妥善保存。</p>
|
||||
{{else}}
|
||||
<p class="mt-3 text-sm text-neutral-600 dark:text-neutral-300">公開式 Client 不持有 client secret,授權請求須以 PKCE(code_challenge)驗證。</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,110 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Role 為使用者角色:admin 具管理權限,user 為一般權限。
|
||||
type Role string
|
||||
|
||||
// 允許的角色值。
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
// Valid 回傳角色是否為允許的值。
|
||||
func (r Role) Valid() bool {
|
||||
return r == RoleAdmin || r == RoleUser
|
||||
}
|
||||
|
||||
// User 為使用者帳號模型,對應 users 資料表。
|
||||
// Username 與 Email 皆為唯一;密碼以 argon2id(PHC 格式)雜湊儲存,永不存明文。
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex;size:64;not null"` // 登入帳號
|
||||
Email string `gorm:"uniqueIndex;size:255;not null"` // OIDC email scope
|
||||
EmailVerified bool `gorm:"not null;default:false"` // OIDC email_verified claim
|
||||
PasswordHash string `gorm:"size:255;not null"` // argon2id PHC 字串
|
||||
Name string `gorm:"size:255"` // 顯示名稱(profile scope 的 name claim)
|
||||
Role Role `gorm:"size:16;not null;default:user"` // admin 或 user
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// SetPassword 以 argon2id 雜湊密碼並寫入 PasswordHash。
|
||||
func (u *User) SetPassword(password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword 回傳密碼是否與 PasswordHash 相符;雜湊格式無效時一律視為不相符。
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
ok, err := VerifyPassword(password, u.PasswordHash)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// 參數採 OWASP 對 Argon2id 的建議:m=19 MiB、t=2、p=1,salt 16 bytes、key 32 bytes。
|
||||
const (
|
||||
argon2MemoryKB = 19 * 1024
|
||||
argon2Time = 2
|
||||
argon2Threads = 1
|
||||
argon2SaltLen = 16
|
||||
argon2KeyLen = 32
|
||||
)
|
||||
|
||||
// HashPassword 產生格式如 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<key> 的 PHC 字串。
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("read salt: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argon2Time, argon2MemoryKB, argon2Threads, argon2KeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argon2MemoryKB, argon2Time, argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 解析 PHC 字串並以 constant-time 比對重算結果。
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("malformed password hash")
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, fmt.Errorf("parse version: %w", err)
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, fmt.Errorf("unsupported argon2id version %d", version)
|
||||
}
|
||||
var memoryKB, timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memoryKB, &timeCost, &threads); err != nil {
|
||||
return false, fmt.Errorf("parse parameters: %w", err)
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode salt: %w", err)
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("decode key: %w", err)
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, timeCost, memoryKB, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetAndCheckPassword(t *testing.T) {
|
||||
u := &User{}
|
||||
if err := u.SetPassword("correct horse battery staple"); err != nil {
|
||||
t.Fatal("SetPassword: ", err)
|
||||
}
|
||||
if u.PasswordHash == "" || strings.Contains(u.PasswordHash, "correct horse") {
|
||||
t.Fatalf("密碼不應以明文儲存: %q", u.PasswordHash)
|
||||
}
|
||||
if !u.CheckPassword("correct horse battery staple") {
|
||||
t.Error("正確密碼應驗證成功")
|
||||
}
|
||||
if u.CheckPassword("Tr0ub4dor&3") {
|
||||
t.Error("錯誤密碼不應驗證成功")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordUsesRandomSalt(t *testing.T) {
|
||||
a, b := &User{}, &User{}
|
||||
if err := a.SetPassword("same password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.SetPassword("same password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.PasswordHash == b.PasswordHash {
|
||||
t.Error("相同密碼應因隨機 salt 產生不同雜湊")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPasswordMalformedHash(t *testing.T) {
|
||||
for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} {
|
||||
u := &User{PasswordHash: hash}
|
||||
if u.CheckPassword("whatever") {
|
||||
t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleValid(t *testing.T) {
|
||||
for _, r := range []Role{RoleAdmin, RoleUser} {
|
||||
if !r.Valid() {
|
||||
t.Errorf("Role(%q).Valid() = false, want true", r)
|
||||
}
|
||||
}
|
||||
for _, r := range []Role{"", "Admin", "superuser", "root"} {
|
||||
if r.Valid() {
|
||||
t.Errorf("Role(%q).Valid() = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/term"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/store"
|
||||
)
|
||||
|
||||
// Run 分派 CLI 子指令;不帶任何參數時 main 直接啟動 HTTP 伺服器。
|
||||
func Run(args []string) {
|
||||
switch args[0] {
|
||||
case "create-account":
|
||||
if err := runCreateAccount(args[1:]); err != nil {
|
||||
log.Fatal("create-account: ", err)
|
||||
}
|
||||
case "update-password":
|
||||
if err := runUpdatePassword(args[1:]); err != nil {
|
||||
log.Fatal("update-password: ", err)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "未知指令 %q\n\n用法:\n alterminal create-account [-username 帳號] [-email Email] [-name 顯示名稱] [-email-verified] [-role admin|user] [-password 密碼]\n alterminal update-password -username 帳號 [-password 新密碼]\n", args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// runCreateAccount 解析旗標、驗證輸入並建立使用者帳號。
|
||||
func runCreateAccount(args []string) error {
|
||||
fs := flag.NewFlagSet("create-account", flag.ExitOnError)
|
||||
username := fs.String("username", "", "登入帳號(必填)")
|
||||
email := fs.String("email", "", "Email(必填)")
|
||||
name := fs.String("name", "", "顯示名稱(選填)")
|
||||
emailVerified := fs.Bool("email-verified", false, "Email 已驗證(選填)")
|
||||
role := fs.String("role", "user", "角色:admin 或 user(選填,預設 user)")
|
||||
password := fs.String("password", "", "密碼(選填;省略時於終端機輸入)")
|
||||
fs.Parse(args)
|
||||
|
||||
in := accountInput{
|
||||
Username: strings.TrimSpace(*username),
|
||||
Email: strings.TrimSpace(*email),
|
||||
Name: strings.TrimSpace(*name),
|
||||
Role: auth.Role(strings.TrimSpace(*role)),
|
||||
}
|
||||
if err := in.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := resolvePassword(*password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := store.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database: %w", err)
|
||||
}
|
||||
|
||||
u := &auth.User{Username: in.Username, Email: in.Email, Name: in.Name, EmailVerified: *emailVerified, Role: in.Role}
|
||||
if err := u.SetPassword(pw); err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if err := createUser(db, u); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("帳號建立成功:id=%d username=%s email=%s role=%s\n", u.ID, u.Username, u.Email, u.Role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// accountInput 為 create-account 的輸入欄位,長度限制對應 users 資料表欄位定義。
|
||||
type accountInput struct {
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Role auth.Role
|
||||
}
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func (in *accountInput) validate() error {
|
||||
if in.Username == "" {
|
||||
return errors.New("username 不可為空")
|
||||
}
|
||||
if len(in.Username) > 64 {
|
||||
return errors.New("username 長度不可超過 64")
|
||||
}
|
||||
if !usernamePattern.MatchString(in.Username) {
|
||||
return errors.New("username 僅接受英文字母、數字與 . _ -")
|
||||
}
|
||||
if in.Email == "" {
|
||||
return errors.New("email 不可為空")
|
||||
}
|
||||
if len(in.Email) > 255 {
|
||||
return errors.New("email 長度不可超過 255")
|
||||
}
|
||||
// 僅接受純位址,排除 "Alice <alice@example.com>" 這類含顯示名稱的寫法。
|
||||
if addr, err := mail.ParseAddress(in.Email); err != nil || addr.Address != in.Email {
|
||||
return errors.New("email 格式無效")
|
||||
}
|
||||
if len(in.Name) > 255 {
|
||||
return errors.New("name 長度不可超過 255")
|
||||
}
|
||||
if in.Role == "" {
|
||||
in.Role = auth.RoleUser // 未指定時預設一般使用者
|
||||
}
|
||||
if !in.Role.Valid() {
|
||||
return errors.New("role 僅接受 admin 或 user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPasswordLen = 8
|
||||
|
||||
// resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用,
|
||||
// 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。
|
||||
func resolvePassword(flagPassword string) (string, error) {
|
||||
password := flagPassword
|
||||
if password == "" {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return "", errors.New("非互動環境無法提示輸入密碼,請以 -password 提供")
|
||||
}
|
||||
var err error
|
||||
password, err = promptPasswordTwice(readHiddenLine)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(password) < minPasswordLen {
|
||||
return "", fmt.Errorf("密碼長度至少 %d 字元", minPasswordLen)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// promptPasswordTwice 以 read 提示讀取密碼兩次,一致時回傳。
|
||||
func promptPasswordTwice(read func(string) ([]byte, error)) (string, error) {
|
||||
first, err := read("輸入密碼: ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("讀取密碼: %w", err)
|
||||
}
|
||||
second, err := read("再次輸入密碼: ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("讀取密碼: %w", err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
return "", errors.New("兩次輸入的密碼不一致")
|
||||
}
|
||||
return string(first), nil
|
||||
}
|
||||
|
||||
// readHiddenLine 在終端機顯示 prompt 並無回顯讀取一行輸入。
|
||||
func readHiddenLine(prompt string) ([]byte, error) {
|
||||
fmt.Print(prompt)
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
return b, err
|
||||
}
|
||||
|
||||
// createUser 將帳號寫入資料庫;寫入前預查提供友善的重複錯誤,
|
||||
// 寫入時再以唯一索引(gorm.ErrDuplicatedRows)兜底並發情境。
|
||||
func createUser(db *gorm.DB, u *auth.User) error {
|
||||
var count int64
|
||||
if err := db.Model(&auth.User{}).Where("username = ?", u.Username).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("query username: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("username %q 已被使用", u.Username)
|
||||
}
|
||||
if err := db.Model(&auth.User{}).Where("email = ?", u.Email).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("query email: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("email %q 已被使用", u.Email)
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return fmt.Errorf("username %q 或 email %q 已被使用", u.Username, u.Email)
|
||||
}
|
||||
return fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
func TestAccountInputValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in accountInput
|
||||
wantErr string // 空字串表示應通過
|
||||
}{
|
||||
{"最小欄位", accountInput{Username: "alice", Email: "alice@example.com"}, ""},
|
||||
{"含顯示名稱", accountInput{Username: "alice", Email: "alice@example.com", Name: "Alice"}, ""},
|
||||
{"role 為 admin", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleAdmin}, ""},
|
||||
{"role 為 user", accountInput{Username: "alice", Email: "alice@example.com", Role: auth.RoleUser}, ""},
|
||||
{"role 為空", accountInput{Username: "alice", Email: "alice@example.com"}, ""},
|
||||
{"role 不允許的值", accountInput{Username: "alice", Email: "alice@example.com", Role: "superuser"}, "role"},
|
||||
{"role 為 Admin(大寫)", accountInput{Username: "alice", Email: "alice@example.com", Role: "Admin"}, "role"},
|
||||
{"username 允許的符號", accountInput{Username: "a_li-ce.01", Email: "alice@example.com"}, ""},
|
||||
{"缺 username", accountInput{Email: "alice@example.com"}, "username"},
|
||||
{"username 過長", accountInput{Username: strings.Repeat("a", 65), Email: "alice@example.com"}, "64"},
|
||||
{"username 含空白", accountInput{Username: "alice wang", Email: "alice@example.com"}, "username"},
|
||||
{"username 含 @", accountInput{Username: "alice@example.com", Email: "alice@example.com"}, "username"},
|
||||
{"缺 email", accountInput{Username: "alice"}, "email"},
|
||||
{"email 過長", accountInput{Username: "alice", Email: strings.Repeat("a", 250) + "@example.com"}, "255"},
|
||||
{"email 格式無效", accountInput{Username: "alice", Email: "example.com"}, "email"},
|
||||
{"email 帶顯示名稱", accountInput{Username: "alice", Email: "Alice <alice@example.com>"}, "email"},
|
||||
{"name 過長", accountInput{Username: "alice", Email: "alice@example.com", Name: strings.Repeat("名", 256)}, "255"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.in.validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePasswordFromFlag(t *testing.T) {
|
||||
got, err := resolvePassword("sup3r-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePassword() = %v, want nil", err)
|
||||
}
|
||||
if got != "sup3r-secret" {
|
||||
t.Fatalf("resolvePassword() = %q, want %q", got, "sup3r-secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePasswordTooShort(t *testing.T) {
|
||||
_, err := resolvePassword("1234567")
|
||||
if err == nil || !strings.Contains(err.Error(), "8") {
|
||||
t.Fatalf("resolvePassword(\"1234567\") = %v, want 長度錯誤", err)
|
||||
}
|
||||
}
|
||||
|
||||
// go test 執行時 stdin 不是終端機,省略 -password 應直接報錯而非等待輸入。
|
||||
func TestResolvePasswordRequiresFlagWithoutTerminal(t *testing.T) {
|
||||
_, err := resolvePassword("")
|
||||
if err == nil || !strings.Contains(err.Error(), "-password") {
|
||||
t.Fatalf("resolvePassword(\"\") = %v, want 提示改用 -password 的錯誤", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptPasswordTwice(t *testing.T) {
|
||||
t.Run("兩次一致", func(t *testing.T) {
|
||||
got, err := promptPasswordTwice(func(string) ([]byte, error) { return []byte("sup3r-secret"), nil })
|
||||
if err != nil {
|
||||
t.Fatalf("promptPasswordTwice() = %v, want nil", err)
|
||||
}
|
||||
if got != "sup3r-secret" {
|
||||
t.Fatalf("promptPasswordTwice() = %q, want %q", got, "sup3r-secret")
|
||||
}
|
||||
})
|
||||
t.Run("兩次不一致", func(t *testing.T) {
|
||||
inputs := []string{"sup3r-secret", "sup3r-secret2"}
|
||||
calls := 0
|
||||
_, err := promptPasswordTwice(func(string) ([]byte, error) {
|
||||
b := []byte(inputs[calls])
|
||||
calls++
|
||||
return b, nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("promptPasswordTwice() = %v, want 不一致錯誤", err)
|
||||
}
|
||||
})
|
||||
t.Run("讀取失敗", func(t *testing.T) {
|
||||
_, err := promptPasswordTwice(func(string) ([]byte, error) { return nil, errors.New("boom") })
|
||||
if err == nil {
|
||||
t.Fatal("promptPasswordTwice() = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/store"
|
||||
)
|
||||
|
||||
// runUpdatePassword 解析旗標並重設指定帳號的密碼。此為管理用指令,
|
||||
// 不需驗證舊密碼;密碼更新成功後一併撤銷該使用者所有 Session,
|
||||
// 避免既有登入在密碼重設後續存。
|
||||
func runUpdatePassword(args []string) error {
|
||||
fs := flag.NewFlagSet("update-password", flag.ExitOnError)
|
||||
username := fs.String("username", "", "登入帳號(必填)")
|
||||
password := fs.String("password", "", "新密碼(選填;省略時於終端機輸入)")
|
||||
fs.Parse(args)
|
||||
|
||||
name := strings.TrimSpace(*username)
|
||||
if name == "" {
|
||||
return errors.New("username 不可為空")
|
||||
}
|
||||
pw, err := resolvePassword(*password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := store.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database: %w", err)
|
||||
}
|
||||
|
||||
u, err := findUserByUsername(db, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.SetPassword(pw); err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
revoked, err := updateUserPassword(db, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("密碼更新成功:id=%d username=%s(已撤銷 %d 個 Session)\n", u.ID, u.Username, revoked)
|
||||
return nil
|
||||
}
|
||||
|
||||
// findUserByUsername 以帳號查詢使用者,查無時回傳可讀的錯誤。
|
||||
func findUserByUsername(db *gorm.DB, username string) (*auth.User, error) {
|
||||
var u auth.User
|
||||
err := db.Where("username = ?", username).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("username %q 不存在", username)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有
|
||||
// Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。
|
||||
func updateUserPassword(db *gorm.DB, u *auth.User) (int64, error) {
|
||||
var revoked int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
res := tx.Where("user_id = ?", u.ID).Delete(&auth.Session{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("delete sessions: %w", res.Error)
|
||||
}
|
||||
revoked = res.RowsAffected
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// go test 執行時 stdin 不是終端機,輸入驗證應在連線資料庫前就失敗。
|
||||
func TestRunUpdatePasswordValidatesInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{"缺 username", nil, "username"},
|
||||
{"username 僅空白", []string{"-username", " "}, "username"},
|
||||
{"省略 -password 且非終端機", []string{"-username", "alice"}, "-password"},
|
||||
{"新密碼過短", []string{"-username", "alice", "-password", "1234567"}, "8"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runUpdatePassword(tt.args)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("runUpdatePassword(%v) = %v, want error containing %q", tt.args, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// scopeDescriptions 為同意頁上各 scope 的人類可讀說明。
|
||||
var scopeDescriptions = map[string]string{
|
||||
"openid": "確認您的身分(取得登入狀態)",
|
||||
"profile": "讀取您的顯示名稱與帳號",
|
||||
"email": "讀取您的電子郵件地址",
|
||||
"offline_access": "您離線時持續存取(換發新權杖)",
|
||||
}
|
||||
|
||||
// authorizeRequest 為 /authorize 的請求參數(RFC 6749 §4.1.1 與 OIDC
|
||||
// Core §3.1.2.1 的授權請求參數;GET query 與同意表單 POST 共用)。
|
||||
type authorizeRequest struct {
|
||||
ResponseType string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Scope string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
}
|
||||
|
||||
// authorizeRequestFromValues 由 query 或表單值還原請求參數。
|
||||
func authorizeRequestFromValues(v url.Values) authorizeRequest {
|
||||
return authorizeRequest{
|
||||
ResponseType: v.Get("response_type"),
|
||||
ClientID: v.Get("client_id"),
|
||||
RedirectURI: v.Get("redirect_uri"),
|
||||
Scope: v.Get("scope"),
|
||||
State: v.Get("state"),
|
||||
Nonce: v.Get("nonce"),
|
||||
CodeChallenge: v.Get("code_challenge"),
|
||||
CodeChallengeMethod: v.Get("code_challenge_method"),
|
||||
}
|
||||
}
|
||||
|
||||
// values 重建請求的原始參數(同意表單的隱藏欄位與登入後返回時使用)。
|
||||
func (req authorizeRequest) values() url.Values {
|
||||
v := url.Values{}
|
||||
set := func(k, s string) {
|
||||
if s != "" {
|
||||
v.Set(k, s)
|
||||
}
|
||||
}
|
||||
set("response_type", req.ResponseType)
|
||||
set("client_id", req.ClientID)
|
||||
set("redirect_uri", req.RedirectURI)
|
||||
set("scope", req.Scope)
|
||||
set("state", req.State)
|
||||
set("nonce", req.Nonce)
|
||||
set("code_challenge", req.CodeChallenge)
|
||||
set("code_challenge_method", req.CodeChallengeMethod)
|
||||
return v
|
||||
}
|
||||
|
||||
// query 回傳重建的授權請求 query 字串(不含 ?)。
|
||||
func (req authorizeRequest) query() string {
|
||||
return req.values().Encode()
|
||||
}
|
||||
|
||||
// redirectError 為可安全重導回 redirect_uri 的授權請求錯誤(RFC 6749
|
||||
// §4.1.2.1:凡 client_id 與 redirect_uri 可確認者,錯誤以重導回傳)。
|
||||
type redirectError struct {
|
||||
Code string
|
||||
Description string
|
||||
}
|
||||
|
||||
// validateAuthorizeRequest 驗證授權請求並載入應用程式註冊資料。驗證
|
||||
// 順序刻意安排:client_id 與 redirect_uri 無法確認時呼叫方必須直接
|
||||
// 顯示錯誤頁、不得重導(RFC 6749 §4.1.2.1,防止授權請求做為開放
|
||||
// 重導向器);redirect_uri 通過精確比對(§3.1.2.3,字串相等不正规化)
|
||||
// 後,其餘錯誤才以 redirectError 重導回 RP。
|
||||
func validateAuthorizeRequest(db *gorm.DB, req authorizeRequest) (*application.Application, *redirectError, error) {
|
||||
app, err := application.GetByClientID(db, req.ClientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, fmt.Errorf("未知或不存在的 client_id %q", req.ClientID)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !app.RedirectURIs.Contains(req.RedirectURI) {
|
||||
return nil, nil, fmt.Errorf("redirect_uri 未註冊於 client_id %s", req.ClientID)
|
||||
}
|
||||
if req.ResponseType != "code" {
|
||||
return app, &redirectError{Code: "unsupported_response_type", Description: "僅支援 response_type=code"}, nil
|
||||
}
|
||||
if !app.GrantTypes.Contains(application.GrantAuthorizationCode) {
|
||||
return app, &redirectError{Code: "unauthorized_client", Description: "應用程式未啟用授權碼流程"}, nil
|
||||
}
|
||||
|
||||
// scope:必含 openid(OIDC Core §3.1.2.1),且每個請求的 scope 皆
|
||||
// 鈙於應用程式註冊範圍。
|
||||
if !scopeHas(req.Scope, "openid") {
|
||||
return app, &redirectError{Code: "invalid_scope", Description: "scope 必須包含 openid"}, nil
|
||||
}
|
||||
for _, s := range strings.Fields(req.Scope) {
|
||||
if !scopeHas(app.Scope, s) {
|
||||
return app, &redirectError{Code: "invalid_scope", Description: "scope " + s + " 未授權此應用程式"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// PKCE(RFC 7636 §4.2、§4.3):code_challenge_method 僅允許 S256
|
||||
// (plain 不安全,本服務不接受,亦不採規格的 plain 預設——省略
|
||||
// method 視同無效)。公開式 Client 無 client secret 可驗,PKCE 為
|
||||
// 必要防護。
|
||||
switch {
|
||||
case req.CodeChallengeMethod != "" && req.CodeChallengeMethod != "S256":
|
||||
return app, &redirectError{Code: "invalid_request", Description: "code_challenge_method 僅支援 S256"}, nil
|
||||
case req.CodeChallengeMethod == "S256" && req.CodeChallenge == "":
|
||||
return app, &redirectError{Code: "invalid_request", Description: "code_challenge 不可為空"}, nil
|
||||
case app.IsPublic() && req.CodeChallenge == "":
|
||||
return app, &redirectError{Code: "invalid_request", Description: "公開式 Client 必須使用 PKCE"}, nil
|
||||
case req.CodeChallenge != "" && req.CodeChallengeMethod == "":
|
||||
return app, &redirectError{Code: "invalid_request", Description: "提供 code_challenge 時必須指定 code_challenge_method=S256"}, nil
|
||||
}
|
||||
return app, nil, nil
|
||||
}
|
||||
|
||||
// AuthorizeHandler 處理 /authorize(RFC 6749 §4.1.1 授權碼流程的授權
|
||||
// 端點):GET 驗證請求後依登入與同意狀態發碼或顯示同意頁,POST 處理
|
||||
// 同意頁的決定。
|
||||
func AuthorizeHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleAuthorizeGet(db, w, r)
|
||||
case http.MethodPost:
|
||||
handleAuthorizePost(db, w, r)
|
||||
default:
|
||||
auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthorizeGet 處理 GET /authorize。
|
||||
func handleAuthorizeGet(db *gorm.DB, w http.ResponseWriter, r *http.Request) {
|
||||
req := authorizeRequestFromValues(r.URL.Query())
|
||||
app, rerr, err := validateAuthorizeRequest(db, req)
|
||||
if !authorizeValidated(w, r, req, rerr, err) {
|
||||
return
|
||||
}
|
||||
s, ok := authorizeSession(db, w, r, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 已同意的 scope 涵蓋本次請求時靜默通過,直接發碼;否則顯示同意頁。
|
||||
c, err := GetConsent(db, s.UserID, app.ID)
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// 首次授權,顯示同意頁
|
||||
case err != nil:
|
||||
log.Printf("authorize: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
case scopeCovered(c.Scope, req.Scope):
|
||||
issueCodeAndRedirect(db, w, r, req, app, s)
|
||||
return
|
||||
}
|
||||
renderConsentPage(w, r, http.StatusOK, req, app, s, "")
|
||||
}
|
||||
|
||||
// handleAuthorizePost 處理 POST /authorize(同意頁決定)。
|
||||
func handleAuthorizePost(db *gorm.DB, w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "無法解析表單內容", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req := authorizeRequestFromValues(r.PostForm)
|
||||
app, rerr, err := validateAuthorizeRequest(db, req)
|
||||
if !authorizeValidated(w, r, req, rerr, err) {
|
||||
return
|
||||
}
|
||||
|
||||
// POST 期間 Session 失效時,以原始參數重建 GET 回到授權流程開頭
|
||||
// (會再導向登入頁),不直接渲染需要登入脈絡的同意頁。
|
||||
s, ok := authorizeSession(db, w, r, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.VerifyCSRF(r) {
|
||||
renderConsentPage(w, r, http.StatusForbidden, req, app, s, "表單驗證失敗,請重新操作")
|
||||
return
|
||||
}
|
||||
switch r.PostFormValue("decision") {
|
||||
case "allow":
|
||||
if err := SaveConsent(db, s.UserID, app.ID, req.Scope); err != nil {
|
||||
log.Printf("authorize: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
issueCodeAndRedirect(db, w, r, req, app, s)
|
||||
case "deny":
|
||||
// 使用者拒絕授權(RFC 6749 §4.1.2.1 access_denied)。
|
||||
redirectAuthorizeError(w, r, req, "access_denied", "使用者拒絕授權")
|
||||
default:
|
||||
renderConsentPage(w, r, http.StatusBadRequest, req, app, s, "請選擇同意或拒絕")
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeValidated 統一處理驗證結果:無法確認 client/redirect_uri 的
|
||||
// 錯誤直接顯示 400 錯誤頁(不重導);可重導的錯誤回到 redirect_uri。
|
||||
// 回傳是否繼續後續流程。
|
||||
func authorizeValidated(w http.ResponseWriter, r *http.Request, req authorizeRequest, rerr *redirectError, err error) bool {
|
||||
if err != nil {
|
||||
log.Printf("authorize: %v", err)
|
||||
http.Error(w, "授權請求無效:"+err.Error(), http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
if rerr != nil {
|
||||
redirectAuthorizeError(w, r, req, rerr.Code, rerr.Description)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeSession 檢查使用者 Session:有效回傳 (session, true);未登入
|
||||
// 時 303 導向 /login?next=<完整授權請求 URL> 後回傳 (nil, false);查詢
|
||||
// 錯誤回 500。POST 同意表單時改為 303 導回重建的 GET /authorize,
|
||||
// 讓流程重新從登入檢查開始。
|
||||
func authorizeSession(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest) (*auth.Session, bool) {
|
||||
c, err := r.Cookie(auth.CookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
authorizeLoginRedirect(w, r, req)
|
||||
return nil, false
|
||||
}
|
||||
s, err := auth.GetSession(db, c.Value)
|
||||
if errors.Is(err, auth.ErrSessionExpired) {
|
||||
authorizeLoginRedirect(w, r, req)
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("authorize: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// authorizeLoginRedirect 依請求方法把使用者送往登入頁:GET 直接以原始
|
||||
// URI 為 next;POST 以表單參數重建 query,讓登入後回到等效的 GET。
|
||||
func authorizeLoginRedirect(w http.ResponseWriter, r *http.Request, req authorizeRequest) {
|
||||
next := "/authorize?" + req.query()
|
||||
if r.Method == http.MethodGet {
|
||||
next = r.URL.RequestURI()
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// consentPageData 為同意頁的模板資料。Params 保存原始授權請求參數,
|
||||
// 模板以隱藏欄位逐項帶回 POST /authorize。IsAdmin/Username/Email/CSRF
|
||||
// 供 layout 側欄版面使用(與其他已登入頁面一致)。
|
||||
type consentPageData struct {
|
||||
Error string
|
||||
Username string
|
||||
Email string
|
||||
IsAdmin bool
|
||||
CSRF string
|
||||
AppName string
|
||||
Scopes []scopeItem
|
||||
Params url.Values
|
||||
}
|
||||
|
||||
// scopeItem 為同意頁清單中的單一 scope 及其說明。
|
||||
type scopeItem struct {
|
||||
Scope string
|
||||
Description string
|
||||
}
|
||||
|
||||
// renderConsentPage 輸出授權同意頁;每次輸出都輪替 CSRF token。
|
||||
func renderConsentPage(w http.ResponseWriter, r *http.Request, status int, req authorizeRequest, app *application.Application, s *auth.Session, errMsg string) {
|
||||
token, err := auth.NewCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
scopes := make([]scopeItem, 0, 4)
|
||||
for _, s := range normalizeScope(req.Scope) {
|
||||
scopes = append(scopes, scopeItem{Scope: s, Description: scopeDescriptions[s]})
|
||||
}
|
||||
auth.RenderHTML(w, status, auth.ConsentTmpl, consentPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
IsAdmin: s.User.Role == auth.RoleAdmin,
|
||||
CSRF: token,
|
||||
AppName: app.Name,
|
||||
Scopes: scopes,
|
||||
Params: req.values(),
|
||||
})
|
||||
}
|
||||
|
||||
// issueCodeAndRedirect 產生授權碼並 302 重導回 redirect_uri(附加 code
|
||||
// 與原 state;RFC 6749 §4.1.2 與 §3.1.2 的回呼格式)。
|
||||
func issueCodeAndRedirect(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest, app *application.Application, s *auth.Session) {
|
||||
_, code, err := NewAuthorizationCode(db, app.ID, s.UserID, req.RedirectURI, strings.Join(normalizeScope(req.Scope), " "), req.Nonce, req.CodeChallenge, req.CodeChallengeMethod, s.CreatedAt)
|
||||
if err != nil {
|
||||
log.Printf("authorize: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
redirectWithParams(w, r, req, "code", code)
|
||||
}
|
||||
|
||||
// redirectAuthorizeError 以 302 將錯誤重導回 redirect_uri(RFC 6749
|
||||
// §4.1.2.1:error、error_description 與原 state)。
|
||||
func redirectAuthorizeError(w http.ResponseWriter, r *http.Request, req authorizeRequest, code, description string) {
|
||||
redirectWithParams(w, r, req, "error", code, "error_description", description)
|
||||
}
|
||||
|
||||
// redirectWithParams 在 redirect_uri 既有 query 之外附加 key/value 對
|
||||
// (值成對出現:key1, val1, key2, val2),state 非空時一併回填,最後
|
||||
// 302 重導。
|
||||
func redirectWithParams(w http.ResponseWriter, r *http.Request, req authorizeRequest, kv ...string) {
|
||||
u, err := url.Parse(req.RedirectURI)
|
||||
if err != nil {
|
||||
log.Printf("authorize: 解析 redirect_uri: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
q.Set(kv[i], kv[i+1])
|
||||
}
|
||||
if req.State != "" {
|
||||
q.Set("state", req.State)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
// client_id 或 redirect_uri 無法確認時不得重導(RFC 6749 §4.1.2.1),
|
||||
// 直接回 400 錯誤頁。
|
||||
func TestAuthorizeRejectsWithoutRedirect(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.AuthorizeHandler(e.db)
|
||||
|
||||
t.Run("未知 client_id", func(t *testing.T) {
|
||||
q := authorizeQuery(e.app, "openid", "", "", "")
|
||||
q = strings.Replace(q, url.QueryEscape(e.app.ClientID), url.QueryEscape("no-such-client"), 1)
|
||||
rec := getAuthorize(h, q, e.sessionCookie())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Location") != "" {
|
||||
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redirect_uri 未註冊", func(t *testing.T) {
|
||||
q := authorizeQuery(e.app, "openid", "", "", "")
|
||||
q = strings.Replace(q, url.QueryEscape(e.app.RedirectURIs[0]), url.QueryEscape("https://evil.example/cb"), 1)
|
||||
rec := getAuthorize(h, q, e.sessionCookie())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Location") != "" {
|
||||
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// redirect_uri 可確認後,其餘請求錯誤以 302 重導回 RP,附 error 與
|
||||
// 原 state(RFC 6749 §4.1.2.1)。
|
||||
func TestAuthorizeRedirectsParameterErrors(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.AuthorizeHandler(e.db)
|
||||
redirectURI := e.app.RedirectURIs[0]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantErrCode string
|
||||
wantRedirect string
|
||||
}{
|
||||
{
|
||||
"response_type 不支援",
|
||||
"response_type=token&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&scope=openid&state=xyz",
|
||||
"unsupported_response_type",
|
||||
redirectURI,
|
||||
},
|
||||
{
|
||||
"scope 缺 openid",
|
||||
authorizeQuery(e.app, "profile email", "xyz", "", ""),
|
||||
"invalid_scope",
|
||||
redirectURI,
|
||||
},
|
||||
{
|
||||
"scope 超出註冊範圍",
|
||||
authorizeQuery(e.app, "openid profile email offline_access unknown-scope", "xyz", "", ""),
|
||||
"invalid_scope",
|
||||
redirectURI,
|
||||
},
|
||||
{
|
||||
"code_challenge_method=plain",
|
||||
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
|
||||
"&scope=openid&state=xyz&code_challenge=whatever&code_challenge_method=plain",
|
||||
"invalid_request",
|
||||
redirectURI,
|
||||
},
|
||||
{
|
||||
"有 challenge 未指定 method",
|
||||
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
|
||||
"&scope=openid&state=xyz&code_challenge=whatever",
|
||||
"invalid_request",
|
||||
redirectURI,
|
||||
},
|
||||
{
|
||||
"公開式 Client 未使用 PKCE",
|
||||
authorizeQuery(e.pub, "openid", "xyz", "", ""),
|
||||
"invalid_request",
|
||||
e.pub.RedirectURIs[0],
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := getAuthorize(h, tt.query, e.sessionCookie())
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != tt.wantRedirect {
|
||||
t.Fatalf("Location 基準 URL = %q, want %q", base, tt.wantRedirect)
|
||||
}
|
||||
if got := loc.Query().Get("error"); got != tt.wantErrCode {
|
||||
t.Errorf("error = %q, want %q", got, tt.wantErrCode)
|
||||
}
|
||||
if got := loc.Query().Get("state"); got != "xyz" {
|
||||
t.Errorf("state 應原樣回填, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未登入時導向 /login,next 攜帶完整授權請求(OIDC Core §3.1.2.2)。
|
||||
func TestAuthorizeRedirectsToLoginWhenNotLoggedIn(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
q := authorizeQuery(e.app, "openid profile", "xyz", "n-1", "")
|
||||
|
||||
rec := getAuthorize(oidc.AuthorizeHandler(e.db), q) // 不帶 Session Cookie
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
if loc.Path != "/login" {
|
||||
t.Fatalf("應導向 /login, got %q", loc)
|
||||
}
|
||||
next, err := url.QueryUnescape(loc.Query().Get("next"))
|
||||
if err != nil {
|
||||
t.Fatalf("next 未編碼: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(next, "/authorize?") || !strings.Contains(next, "state=xyz") || !strings.Contains(next, "nonce=n-1") {
|
||||
t.Fatalf("next 應為完整 /authorize URL: %q", next)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次授權顯示同意頁;同意後記住,同範圍的後續請求靜默通過;範圍
|
||||
// 擴大時再次詢問。
|
||||
func TestAuthorizeConsentFlow(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.AuthorizeHandler(e.db)
|
||||
|
||||
t.Run("首次顯示同意頁", func(t *testing.T) {
|
||||
rec := getAuthorize(h, authorizeQuery(e.app, "openid profile", "xyz", "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"授權存取", "機密式測試應用", "openid", "profile", `value="allow"`, `value="deny"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("同意頁缺少 %q", want)
|
||||
}
|
||||
}
|
||||
// offline_access 未請求,不應出現於說明清單。
|
||||
if strings.Count(body, "offline_access") != 0 {
|
||||
t.Error("未請求的 scope 不應顯示")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("同意後發碼並記住", func(t *testing.T) {
|
||||
loc, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile", "xyz", "", ""))
|
||||
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != e.app.RedirectURIs[0] {
|
||||
t.Fatalf("Location 基準 URL = %q, want %q", base, e.app.RedirectURIs[0])
|
||||
}
|
||||
if loc.Query().Get("state") != "xyz" {
|
||||
t.Errorf("state 應原樣回填, got %q", loc.Query().Get("state"))
|
||||
}
|
||||
if len(code) < 20 {
|
||||
t.Errorf("code 長度 %d 過短", len(code))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("同範圍再請求靜默通過", func(t *testing.T) {
|
||||
rec := getAuthorize(h, authorizeQuery(e.app, "openid", "s2", "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
if loc.Query().Get("code") == "" || loc.Query().Get("state") != "s2" {
|
||||
t.Fatalf("應直接發碼: %s", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("範圍擴大再次詢問", func(t *testing.T) {
|
||||
rec := getAuthorize(h, authorizeQuery(e.app, "openid email", "s3", "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200(應再顯示同意頁), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "email") {
|
||||
t.Fatal("同意頁應顯示新請求的 scope")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("拒絕授權回 access_denied", func(t *testing.T) {
|
||||
q := authorizeQuery(e.pub, "openid", "xyz", "", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
|
||||
rec := getAuthorize(h, q, e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("公開式首授權也應先顯示同意頁, status = %d", rec.Code)
|
||||
}
|
||||
rec = postAuthorize(h, q, "deny", e.sessionCookie(), csrfCookieOf(t, rec))
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", rec.Code)
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
if loc.Query().Get("error") != "access_denied" || loc.Query().Get("state") != "xyz" {
|
||||
t.Fatalf("應回 access_denied 與原 state: %s", loc)
|
||||
}
|
||||
// 拒絕不應記錄同意:再次請求仍顯示同意頁。
|
||||
rec = getAuthorize(h, q, e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("拒絕後不應記住, status = %d, want 200", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF 不符回 403", func(t *testing.T) {
|
||||
q := authorizeQuery(e.app, "openid email", "", "", "") // email 尚未同意
|
||||
rec := getAuthorize(h, q, e.sessionCookie())
|
||||
csrf := csrfCookieOf(t, rec)
|
||||
// Cookie 保持正確值,但表單送出不符的 token。
|
||||
form, _ := url.ParseQuery(q)
|
||||
form.Set("decision", "allow")
|
||||
form.Set("csrf_token", "wrong-token")
|
||||
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(e.sessionCookie())
|
||||
req.AddCookie(csrf)
|
||||
rec = httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// discoveryMaxAge 為 Discovery 文件的建議快取秒數,與 JWKS 一致:內容
|
||||
// 僅在部署設定變更時改變。
|
||||
const discoveryMaxAge = 3600
|
||||
|
||||
// discoveryDocument 為 OIDC Discovery 文件(OIDC Discovery 1.0 §3)。
|
||||
// 本服務僅支援授權碼流程(RFC 6749 §4.1.1)與 refresh token grant
|
||||
// (§6);subject type 僅 public(sub 對使用者恆為同一值)。
|
||||
type discoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
}
|
||||
|
||||
// DiscoveryHandler 處理 GET /.well-known/openid-configuration:發佈本
|
||||
// 服務的 OIDC 端點與能力中繼資料,供 RP 以標準方式取得組態。issuer 於
|
||||
// main 讀取 ISSUER 環境變數後注入——issuer 字串須與簽入 token 的 iss
|
||||
// claim 完全一致(OIDC Core §3.1.3.7 的 issuer 驗證)。
|
||||
func DiscoveryHandler(issuer string) http.HandlerFunc {
|
||||
doc := discoveryDocument{
|
||||
Issuer: issuer,
|
||||
AuthorizationEndpoint: issuer + "/authorize",
|
||||
TokenEndpoint: issuer + "/token",
|
||||
UserInfoEndpoint: issuer + "/userinfo",
|
||||
JWKSURI: issuer + "/.well-known/jwks.json",
|
||||
ScopesSupported: application.ScopesSupported(),
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
ResponseModesSupported: []string{"query"},
|
||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||
SubjectTypesSupported: []string{"public"},
|
||||
IDTokenSigningAlgValuesSupported: []string{"RS256"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
CodeChallengeMethodsSupported: []string{"S256"},
|
||||
ClaimsSupported: []string{
|
||||
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
|
||||
"name", "preferred_username", "email", "email_verified",
|
||||
},
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", discoveryMaxAge))
|
||||
auth.WriteJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
// Discovery 文件應揭露本服務的全部端點與能力。
|
||||
func TestDiscoveryHandler(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
oidc.DiscoveryHandler(testIssuer)(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
|
||||
t.Errorf("Cache-Control = %q, want public, max-age=3600", cc)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("解析 Discovery 文件: %v", err)
|
||||
}
|
||||
endpoints := map[string]string{
|
||||
"issuer": testIssuer,
|
||||
"authorization_endpoint": testIssuer + "/authorize",
|
||||
"token_endpoint": testIssuer + "/token",
|
||||
"userinfo_endpoint": testIssuer + "/userinfo",
|
||||
"jwks_uri": testIssuer + "/.well-known/jwks.json",
|
||||
}
|
||||
for field, want := range endpoints {
|
||||
got, _ := doc[field].(string)
|
||||
if got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
lists := map[string][]string{
|
||||
"scopes_supported": {"email", "offline_access", "openid", "profile"},
|
||||
"response_types_supported": {"code"},
|
||||
"grant_types_supported": {"authorization_code", "refresh_token"},
|
||||
"subject_types_supported": {"public"},
|
||||
"id_token_signing_alg_values_supported": {"RS256"},
|
||||
"token_endpoint_auth_methods_supported": {"client_secret_basic", "client_secret_post", "none"},
|
||||
"code_challenge_methods_supported": {"S256"},
|
||||
}
|
||||
for field, want := range lists {
|
||||
got, _ := doc[field].([]any)
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s = %v, want %v", field, got, want)
|
||||
continue
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i] != w {
|
||||
t.Errorf("%s[%d] = %v, want %v", field, i, got[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// 外部測試套件(與 jwks_test.go 同理):oidc 模型由 store 遷移,內部
|
||||
// 測試套件匯入 testdb 會形成循環。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
"alterminal/internal/oidc"
|
||||
"alterminal/internal/testdb"
|
||||
)
|
||||
|
||||
// testIssuer 為測試用 issuer(與本機 http 開發環境一致)。
|
||||
const testIssuer = "http://localhost:8080"
|
||||
|
||||
// testEnv 打包端點整合測試的共用物件:簽章金鑰、使用者與 Session、
|
||||
// 一個機密式與一個公開式應用程式。
|
||||
type testEnv struct {
|
||||
db *gorm.DB
|
||||
key *jwk.SigningKey
|
||||
user *auth.User
|
||||
session *auth.Session
|
||||
app *application.Application // 機密式:openid profile email offline_access
|
||||
secret string // app 的 client secret 明文
|
||||
pub *application.Application // 公開式:預設 grant 與 scope
|
||||
}
|
||||
|
||||
// newTestEnv 建立測試環境(含所有相依資料列)。
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
db := testdb.New(t)
|
||||
|
||||
key := mustNewKey(t, false)
|
||||
if err := db.Create(key).Error; err != nil {
|
||||
t.Fatal("建立測試金鑰: ", err)
|
||||
}
|
||||
|
||||
user := &auth.User{
|
||||
Username: "oidc-test", Email: "oidc-test@example.com",
|
||||
Name: "測試使用者", EmailVerified: true,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatal("建立測試使用者: ", err)
|
||||
}
|
||||
session, err := auth.CreateSession(db, user.ID)
|
||||
if err != nil {
|
||||
t.Fatal("建立測試 Session: ", err)
|
||||
}
|
||||
|
||||
app, secret, err := application.NewApplication(
|
||||
"機密式測試應用", application.ClientConfidential,
|
||||
[]string{"https://rp.example/callback"},
|
||||
[]application.GrantType{application.GrantAuthorizationCode, application.GrantRefreshToken},
|
||||
"openid profile email offline_access",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal("建立測試應用程式: ", err)
|
||||
}
|
||||
if err := db.Create(app).Error; err != nil {
|
||||
t.Fatal("建立測試應用程式: ", err)
|
||||
}
|
||||
|
||||
pub, _, err := application.NewApplication(
|
||||
"公開式測試應用", application.ClientPublic,
|
||||
[]string{"http://localhost:3000/cb"},
|
||||
nil, "", // fill 會補預設值:grant 僅 authorization_code、scope 為 openid profile email
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal("建立公開式測試應用: ", err)
|
||||
}
|
||||
if err := db.Create(pub).Error; err != nil {
|
||||
t.Fatal("建立公開式測試應用: ", err)
|
||||
}
|
||||
|
||||
return &testEnv{db: db, key: key, user: user, session: session, app: app, secret: secret, pub: pub}
|
||||
}
|
||||
|
||||
// sessionCookie 回傳環境使用者的 Session Cookie。
|
||||
func (e *testEnv) sessionCookie() *http.Cookie {
|
||||
return &http.Cookie{Name: auth.CookieName, Value: e.session.ID}
|
||||
}
|
||||
|
||||
// authorizeQuery 組出對指定應用程式的授權請求 query(redirect URI 取
|
||||
// 第一個註冊值)。challenge 為空時不帶 PKCE 參數。
|
||||
func authorizeQuery(app *application.Application, scope, state, nonce, challenge string) string {
|
||||
v := url.Values{}
|
||||
v.Set("response_type", "code")
|
||||
v.Set("client_id", app.ClientID)
|
||||
v.Set("redirect_uri", app.RedirectURIs[0])
|
||||
v.Set("scope", scope)
|
||||
if state != "" {
|
||||
v.Set("state", state)
|
||||
}
|
||||
if nonce != "" {
|
||||
v.Set("nonce", nonce)
|
||||
}
|
||||
if challenge != "" {
|
||||
v.Set("code_challenge", challenge)
|
||||
v.Set("code_challenge_method", "S256")
|
||||
}
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
// getAuthorize 對 GET /authorize 發出請求(可選帶 Cookie)並回傳記錄器。
|
||||
func getAuthorize(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// csrfCookieOf 從回應的 Set-Cookie 取得輪替後的 CSRF token。
|
||||
func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CSRFCookieName {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatal("回應未設定 CSRF Cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
// hiddenFieldValue 由表單頁 HTML 取出指定隱藏欄位的 value( simplistic
|
||||
// 剖析,僅供測試使用)。
|
||||
func hiddenFieldValue(t *testing.T, body, name string) string {
|
||||
t.Helper()
|
||||
marker := `name="` + name + `" value="`
|
||||
i := strings.Index(body, marker)
|
||||
if i < 0 {
|
||||
t.Fatalf("頁面缺少隱藏欄位 %s", name)
|
||||
}
|
||||
rest := body[i+len(marker):]
|
||||
return rest[:strings.Index(rest, `"`)]
|
||||
}
|
||||
|
||||
// postAuthorize 送出同意頁決定(帶原始授權參數與 CSRF),回傳記錄器。
|
||||
func postAuthorize(h http.HandlerFunc, query, decision string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
form, err := url.ParseQuery(query) // 正確解碼一次,Encode 時再編碼
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
form.Set("decision", decision)
|
||||
var csrf string
|
||||
for _, c := range cookies {
|
||||
if c.Name == auth.CSRFCookieName {
|
||||
csrf = c.Value
|
||||
}
|
||||
}
|
||||
form.Set("csrf_token", csrf)
|
||||
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// consentAllow 走授權流程取得授權碼,回傳 redirect Location 與其中的
|
||||
// code:scope 已同意過時直接使用靜默通過的 302,否則顯示同意頁後按
|
||||
// 同意。登入 Session 由 e 提供。
|
||||
func consentAllow(t *testing.T, e *testEnv, query string) (*url.URL, string) {
|
||||
t.Helper()
|
||||
h := oidc.AuthorizeHandler(e.db)
|
||||
|
||||
rec := getAuthorize(h, query, e.sessionCookie())
|
||||
if rec.Code == http.StatusFound {
|
||||
loc := redirectLocation(t, rec)
|
||||
if code := loc.Query().Get("code"); code != "" {
|
||||
return loc, code // 已同意,靜默通過
|
||||
}
|
||||
t.Fatalf("未預期的 302(無 code): %s", loc)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("同意頁 status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "授權存取") {
|
||||
t.Fatalf("應顯示同意頁: %s", rec.Body.String())
|
||||
}
|
||||
csrf := csrfCookieOf(t, rec)
|
||||
|
||||
rec = postAuthorize(h, query, "allow", e.sessionCookie(), csrf)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("同意後 status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
code := loc.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatalf("redirect URI 缺少 code: %s", loc)
|
||||
}
|
||||
return loc, code
|
||||
}
|
||||
|
||||
// redirectLocation 解析 302/303 回應的 Location 標頭。
|
||||
func redirectLocation(t *testing.T, rec *httptest.ResponseRecorder) *url.URL {
|
||||
t.Helper()
|
||||
raw := rec.Header().Get("Location")
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 Location %q: %v", raw, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// postToken 對 POST /token 送出表單;basicID/basicSecret 非空時改用
|
||||
// HTTP Basic 認證。
|
||||
func postToken(h http.HandlerFunc, form url.Values, basicID, basicSecret string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if basicID != "" {
|
||||
req.SetBasicAuth(basicID, basicSecret)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// tokenErrorBody 解析 token 錯誤回應。
|
||||
type tokenErrorBody struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// decodeTokenError 解析錯誤回應 JSON。
|
||||
func decodeTokenError(t *testing.T, rec *httptest.ResponseRecorder) tokenErrorBody {
|
||||
t.Helper()
|
||||
var e tokenErrorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("解析錯誤回應: %v, body = %s", err, rec.Body.String())
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// jwtParts 拆解 JWT 三段。
|
||||
func jwtParts(t *testing.T, token string) (header, payload []byte) {
|
||||
t.Helper()
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("JWT 應有三段: %s", token)
|
||||
}
|
||||
var err error
|
||||
if header, err = base64.RawURLEncoding.DecodeString(parts[0]); err != nil {
|
||||
t.Fatalf("解碼 header: %v", err)
|
||||
}
|
||||
if payload, err = base64.RawURLEncoding.DecodeString(parts[1]); err != nil {
|
||||
t.Fatalf("解碼 payload: %v", err)
|
||||
}
|
||||
return header, payload
|
||||
}
|
||||
|
||||
// forgeJWT 以指定金鑰與自訂 header/claims 造出 JWT(供負面測試:
|
||||
// alg 混淆、過期 claims 等)。
|
||||
func forgeJWT(t *testing.T, key *jwk.SigningKey, header map[string]string, claims any) string {
|
||||
t.Helper()
|
||||
priv, err := key.PrivateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(pb)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// idTokenClaims 為測試斷言用的 ID token claims。
|
||||
type idTokenClaims struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud string `json:"aud"`
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
AuthTime int64 `json:"auth_time"`
|
||||
Nonce string `json:"nonce"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerf *bool `json:"email_verified"`
|
||||
}
|
||||
|
||||
// userInfoBody 為測試斷言用的 /userinfo 回應。
|
||||
type userInfoBody struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified *bool `json:"email_verified"`
|
||||
}
|
||||
|
||||
// subjectOf 回傳使用者 ID 的字串形式(與正式碼的 sub 生成一致)。
|
||||
func subjectOf(id uint) string {
|
||||
return fmt.Sprintf("%d", id)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package oidc 實作 OIDC 端點:/.well-known/jwks.json,以及之後的
|
||||
// Discovery、/authorize、/token 等,供 RP(Application)整合。
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
)
|
||||
|
||||
// jwksMaxAge 為 JWKS 回應的建議快取秒數。金鑰輪替流程為「先產生並
|
||||
// 發佈新金鑰,舊金鑰退休前仍留在 JWKS 供已簽發的 token 驗證」,因此
|
||||
// RP 快取一小時並不影響驗證:快取期間內新舊金鑰皆可取得。
|
||||
const jwksMaxAge = 3600
|
||||
|
||||
// JWKSHandler 處理 GET /.well-known/jwks.json(RFC 7517 §5):發佈所有
|
||||
// 使用中簽章金鑰的公開 JWK,供 RP 驗證 ID Token/Access Token 的
|
||||
// 簽章。已退休金鑰不再發佈;無使用中金鑰時回應空的 keys 陣列。
|
||||
func JWKSHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var keys []jwk.SigningKey
|
||||
if err := db.Where("retired_at IS NULL").Order("created_at DESC").Find(&keys).Error; err != nil {
|
||||
log.Printf("jwks: %v", err)
|
||||
auth.WriteError(w, http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
set := jwk.JWKS{Keys: make([]jwk.JWK, 0, len(keys))}
|
||||
for i := range keys {
|
||||
k, err := keys[i].PublicJWK()
|
||||
if err != nil {
|
||||
// 單一金鑰的私鑰儲存毀損時跳過該金鑰並記錄待查,不讓整個
|
||||
// 端點失靈——其餘金鑰照常發佈,RP 仍可驗證其簽發的 token。
|
||||
log.Printf("jwks: 金鑰 %d(kid=%s)無法轉為公開 JWK: %v", keys[i].ID, keys[i].Kid, err)
|
||||
continue
|
||||
}
|
||||
set.Keys = append(set.Keys, *k)
|
||||
}
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", jwksMaxAge))
|
||||
auth.WriteJSON(w, http.StatusOK, set)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 外部測試套件(package oidc_test):store 為遷移 OIDC 模型而匯入
|
||||
// oidc 套件,內部測試套件匯入 testdb(→ store → oidc)會形成循環。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"alterminal/internal/jwk"
|
||||
"alterminal/internal/oidc"
|
||||
"alterminal/internal/testdb"
|
||||
)
|
||||
|
||||
// jwksGet 對 handler 發出 GET /.well-known/jwks.json 並回傳回應記錄器。
|
||||
func jwksGet(h http.HandlerFunc) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
// mustNewKey 產生一把簽章金鑰;retired 為 true 時標記為已退休。
|
||||
func mustNewKey(t *testing.T, retired bool) *jwk.SigningKey {
|
||||
t.Helper()
|
||||
k, err := jwk.NewSigningKey()
|
||||
if err != nil {
|
||||
t.Fatal("NewSigningKey: ", err)
|
||||
}
|
||||
if retired {
|
||||
now := time.Now()
|
||||
k.RetiredAt = &now
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
func TestJWKSHandlerIntegration(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
|
||||
active1 := mustNewKey(t, false)
|
||||
active2 := mustNewKey(t, false)
|
||||
retired := mustNewKey(t, true)
|
||||
for _, k := range []*jwk.SigningKey{active1, active2, retired} {
|
||||
if err := db.Create(k).Error; err != nil {
|
||||
t.Fatal("建立測試金鑰: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := oidc.JWKSHandler(db)
|
||||
|
||||
t.Run("僅發佈使用中的金鑰", func(t *testing.T) {
|
||||
rec := jwksGet(h)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
|
||||
t.Errorf("Cache-Control = %q, want public, max-age=3600", cc)
|
||||
}
|
||||
var set jwk.JWKS
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil {
|
||||
t.Fatalf("解析 JWKS: %v", err)
|
||||
}
|
||||
if len(set.Keys) != 2 {
|
||||
t.Fatalf("keys = %d 把, want 2(退休金鑰不應發佈): %s", len(set.Keys), rec.Body.String())
|
||||
}
|
||||
kids := map[string]bool{}
|
||||
for _, k := range set.Keys {
|
||||
kids[k.Kid] = true
|
||||
if k.Kty != jwk.KeyTypeRSA || k.Use != jwk.KeyUseSig || k.Alg != jwk.AlgRS256 {
|
||||
t.Errorf("kid %s 參數 = kty:%q use:%q alg:%q", k.Kid, k.Kty, k.Use, k.Alg)
|
||||
}
|
||||
if k.E != "AQAB" {
|
||||
t.Errorf("kid %s e = %q, want AQAB", k.Kid, k.E)
|
||||
}
|
||||
n, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||
if err != nil || len(n) == 0 {
|
||||
t.Errorf("kid %s 的 n 應為可解碼的非空 base64url: %q (err=%v)", k.Kid, k.N, err)
|
||||
}
|
||||
}
|
||||
if !kids[active1.Kid] || !kids[active2.Kid] {
|
||||
t.Errorf("應發佈兩把使用中金鑰 %q、%q,實際 %v", active1.Kid, active2.Kid, kids)
|
||||
}
|
||||
if kids[retired.Kid] {
|
||||
t.Error("退休金鑰不應出現於 JWKS")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET 為冪等", func(t *testing.T) {
|
||||
first, second := jwksGet(h), jwksGet(h)
|
||||
if first.Body.String() != second.Body.String() {
|
||||
t.Error("兩次 GET 的 JWKS 應相同")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 空資料庫時 keys 為空陣列而非 null(RFC 7517 §5.1:keys 必要)。
|
||||
func TestJWKSHandlerEmpty(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
rec := jwksGet(oidc.JWKSHandler(db))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if s := rec.Body.String(); s != "{\"keys\":[]}\n" && s != `{"keys":[]}` {
|
||||
t.Fatalf("空 JWKS 應為 {\"keys\":[]},得到 %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 私鑰儲存毀損的金鑰被跳過,其餘金鑰照常發佈。
|
||||
func TestJWKSHandlerSkipsCorruptKey(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
|
||||
good := mustNewKey(t, false)
|
||||
if err := db.Create(good).Error; err != nil {
|
||||
t.Fatal("建立測試金鑰: ", err)
|
||||
}
|
||||
bad := &jwk.SigningKey{Kid: "corrupt-kid", Algorithm: jwk.AlgRS256, PrivateKeyPEM: "not a pem"}
|
||||
if err := db.Create(bad).Error; err != nil {
|
||||
t.Fatal("建立毀損金鑰: ", err)
|
||||
}
|
||||
|
||||
rec := jwksGet(oidc.JWKSHandler(db))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var set jwk.JWKS
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil {
|
||||
t.Fatalf("解析 JWKS: %v", err)
|
||||
}
|
||||
if len(set.Keys) != 1 || set.Keys[0].Kid != good.Kid {
|
||||
t.Fatalf("應僅發佈完好的 %q,得到 %s", good.Kid, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
)
|
||||
|
||||
// jwtHeader 為 JWT 的 protected header(RFC 7515 §4.1;alg 固定 RS256,
|
||||
// kid 對應 JWKS 的金鑰識別碼,typ 標示為 JWT)。
|
||||
type jwtHeader struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
// AccessTokenClaims 為 Access Token(JWT profile,RFC 9068)的 claims:
|
||||
// 自包含、不落庫,資源端點(/userinfo)以 JWKS 驗證簽章與效期。Sub 為
|
||||
// 使用者 ID 的字串形式(OIDC Core §2 的 sub claim),Aud 為 client_id。
|
||||
type AccessTokenClaims struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud string `json:"aud"`
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
}
|
||||
|
||||
// idTokenClaims 為 ID Token 的 claims(OIDC Core §2)。Name/Email 等
|
||||
// 個人資料 claim 僅在授權 scope 含對應值時加入;Nonce 回填授權請求的
|
||||
// 原值供 RP 綁結(OIDC Core §3.1.3.7.4),AuthTime 為使用者本次
|
||||
// Session 的建立時間(§2 的 auth_time claim,單位秒)。
|
||||
type idTokenClaims struct {
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
Aud string `json:"aud"`
|
||||
Exp int64 `json:"exp"`
|
||||
Iat int64 `json:"iat"`
|
||||
AuthTime int64 `json:"auth_time,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified *bool `json:"email_verified,omitempty"`
|
||||
}
|
||||
|
||||
// ErrInvalidToken 表示 Access Token 無效(格式、簽章、金鑰或效期不符)。
|
||||
var ErrInvalidToken = errors.New("access token 無效")
|
||||
|
||||
// signJWT 以金鑰簽發 RS256 JWT:header 與 claims 各自 JSON 序列化為
|
||||
// 無填充 base64url,再對兩段連結值以 RSASSA-PKCS1-v1_5 + SHA-256
|
||||
// 簽章(RFC 7518 §3.3),輸出 header.payload.signature 三段。
|
||||
func signJWT(key *jwk.SigningKey, claims any) (string, error) {
|
||||
priv, err := key.PrivateKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse signing key: %w", err)
|
||||
}
|
||||
header, err := json.Marshal(jwtHeader{Alg: jwk.AlgRS256, Kid: key.Kid, Typ: "JWT"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal jwt header: %w", err)
|
||||
}
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal jwt claims: %w", err)
|
||||
}
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign jwt: %w", err)
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// currentSigningKey 取最新的使用中簽章金鑰供簽發(輪替時新金鑰在前)。
|
||||
func currentSigningKey(db *gorm.DB) (*jwk.SigningKey, error) {
|
||||
var k jwk.SigningKey
|
||||
if err := db.Where("retired_at IS NULL").Order("created_at DESC").First(&k).Error; err != nil {
|
||||
return nil, fmt.Errorf("query signing key: %w", err)
|
||||
}
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
// subject 為使用者的 sub claim 值:使用者 ID 的十進位字串(OIDC Core
|
||||
// §2 要求 sub 在 issuer 範圍內穩定且唯一)。
|
||||
func subject(userID uint) string {
|
||||
return fmt.Sprintf("%d", userID)
|
||||
}
|
||||
|
||||
// IssueAccessToken 簽發 Access Token(效期 15 分鐘)。
|
||||
func IssueAccessToken(db *gorm.DB, issuer string, userID uint, app *application.Application, scope string) (string, error) {
|
||||
key, err := currentSigningKey(db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
return signJWT(key, AccessTokenClaims{
|
||||
Iss: issuer,
|
||||
Sub: subject(userID),
|
||||
Aud: app.ClientID,
|
||||
Exp: now.Add(accessTokenTTL).Unix(),
|
||||
Iat: now.Unix(),
|
||||
Scope: scope,
|
||||
ClientID: app.ClientID,
|
||||
})
|
||||
}
|
||||
|
||||
// IssueIDToken 簽發 ID Token(效期 15 分鐘)。authTime 為使用者
|
||||
// Session 的建立時間;nonce 為授權請求攜帶的原值(無則空)。個人資料
|
||||
// claim 依授權 scope 決定(profile:name、preferred_username;email:
|
||||
// email、email_verified——OIDC Core §5.4)。
|
||||
func IssueIDToken(db *gorm.DB, issuer string, u *auth.User, app *application.Application, scope, nonce string, authTime time.Time) (string, error) {
|
||||
key, err := currentSigningKey(db)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
claims := idTokenClaims{
|
||||
Iss: issuer,
|
||||
Sub: subject(u.ID),
|
||||
Aud: app.ClientID,
|
||||
Exp: now.Add(idTokenTTL).Unix(),
|
||||
Iat: now.Unix(),
|
||||
AuthTime: authTime.Unix(),
|
||||
Nonce: nonce,
|
||||
}
|
||||
if scopeHas(scope, "profile") {
|
||||
claims.Name = u.Name
|
||||
claims.PreferredUsername = u.Username
|
||||
}
|
||||
if scopeHas(scope, "email") {
|
||||
claims.Email = u.Email
|
||||
verified := u.EmailVerified
|
||||
claims.EmailVerified = &verified
|
||||
}
|
||||
return signJWT(key, claims)
|
||||
}
|
||||
|
||||
// scopeHas 回傳 scope 集合是否包含 s。
|
||||
func scopeHas(scope, s string) bool {
|
||||
for _, f := range strings.Fields(scope) {
|
||||
if f == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VerifyAccessToken 驗證 Access Token 並回傳其 claims:拆解三段 JWT、
|
||||
// 拒絕非 RS256 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid
|
||||
// 對應的簽章金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰)、比對 issuer
|
||||
// 與效期(OIDC Core §3.1.3.7 的 iss/exp 驗證項)。任何一項不符即回
|
||||
// ErrInvalidToken,不洩漏細節。
|
||||
func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構")
|
||||
}
|
||||
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
|
||||
}
|
||||
var h jwtHeader
|
||||
if err := json.Unmarshal(headerJSON, &h); err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
|
||||
}
|
||||
if h.Alg != jwk.AlgRS256 || h.Kid == "" {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "alg")
|
||||
}
|
||||
|
||||
var key jwk.SigningKey
|
||||
if err := db.Where("kid = ?", h.Kid).First(&key).Error; err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
|
||||
}
|
||||
priv, err := key.PrivateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if err := rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, digest[:], sig); err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
|
||||
}
|
||||
|
||||
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
|
||||
}
|
||||
var claims AccessTokenClaims
|
||||
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
|
||||
}
|
||||
if claims.Iss != issuer {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "issuer")
|
||||
}
|
||||
if claims.Exp <= time.Now().Unix() {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "exp")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
// IssueAccessToken 簽出的 token 應可由 VerifyAccessToken 驗回,且 claims
|
||||
// 正確(iss/sub/aud/scope)。
|
||||
func TestAccessTokenRoundTrip(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid profile")
|
||||
if err != nil {
|
||||
t.Fatal("IssueAccessToken: ", err)
|
||||
}
|
||||
claims, err := oidc.VerifyAccessToken(e.db, testIssuer, token)
|
||||
if err != nil {
|
||||
t.Fatal("VerifyAccessToken: ", err)
|
||||
}
|
||||
if claims.Iss != testIssuer {
|
||||
t.Errorf("iss = %q, want %q", claims.Iss, testIssuer)
|
||||
}
|
||||
if claims.Sub != subjectOf(e.user.ID) {
|
||||
t.Errorf("sub = %q, want %q", claims.Sub, subjectOf(e.user.ID))
|
||||
}
|
||||
if claims.Aud != e.app.ClientID || claims.ClientID != e.app.ClientID {
|
||||
t.Errorf("aud/client_id = %q/%q, want %q", claims.Aud, claims.ClientID, e.app.ClientID)
|
||||
}
|
||||
if claims.Scope != "openid profile" {
|
||||
t.Errorf("scope = %q", claims.Scope)
|
||||
}
|
||||
// header 應含正確的 alg 與 kid(RP 以 kid 對應 JWKS)。
|
||||
header, _ := jwtParts(t, token)
|
||||
var h struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
if err := json.Unmarshal(header, &h); err != nil {
|
||||
t.Fatal("解析 header: ", err)
|
||||
}
|
||||
if h.Alg != "RS256" || h.Kid != e.key.Kid || h.Typ != "JWT" {
|
||||
t.Errorf("header = %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// 各種無效 token 都應回 ErrInvalidToken,不洩漏細節。
|
||||
func TestVerifyAccessTokenRejectsInvalid(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
good, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rs256Header := func(kid string) map[string]string {
|
||||
return map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"}
|
||||
}
|
||||
// 竄改 payload:改動第一個字元後以原簽章送出,驗章應失敗。
|
||||
parts := strings.Split(good, ".")
|
||||
payloadBytes := []byte(parts[1])
|
||||
if payloadBytes[0] == 'e' {
|
||||
payloadBytes[0] = 'e' + 1
|
||||
} else {
|
||||
payloadBytes[0] = 'e'
|
||||
}
|
||||
parts[1] = string(payloadBytes)
|
||||
tampered := strings.Join(parts, ".")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
}{
|
||||
{"非 JWT 結構", "not-a-jwt"},
|
||||
{"alg=none(演算法混淆)", forgeJWT(t, e.key, map[string]string{"alg": "none", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
|
||||
{"alg=HS256", forgeJWT(t, e.key, map[string]string{"alg": "HS256", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
|
||||
{"kid 不存在", forgeJWT(t, e.key, rs256Header("unknown-kid"), map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
|
||||
{"issuer 不符", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": "https://other.example", "exp": time.Now().Add(time.Hour).Unix()})},
|
||||
{"已過期", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": testIssuer, "exp": time.Now().Add(-time.Minute).Unix()})},
|
||||
{"竄改 payload", tampered},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := oidc.VerifyAccessToken(e.db, testIssuer, tt.token)
|
||||
if !errors.Is(err, oidc.ErrInvalidToken) {
|
||||
t.Fatalf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 已退休金鑰簽發的 token 在效期內仍應驗證成功(輪替過渡期,JWKS 同步
|
||||
// 發佈新舊金鑰的模型)。
|
||||
func TestVerifyAccessTokenRetiredKey(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := e.db.Model(e.key).Update("retired_at", now).Error; err != nil {
|
||||
t.Fatal("退休金鑰: ", err)
|
||||
}
|
||||
if _, err := oidc.VerifyAccessToken(e.db, testIssuer, token); err != nil {
|
||||
t.Fatalf("退休金鑰在效期內仍應可驗證: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// 各種憑證的有效時間:授權碼為一次性短時效憑證(RFC 6749 §4.1.2 建議
|
||||
// 最長 10 分鐘,取 5 分鐘);Access/ID Token 15 分鐘為業界常見值;
|
||||
// Refresh Token 30 天,兌換時輪替。
|
||||
const (
|
||||
authorizationCodeTTL = 5 * time.Minute
|
||||
accessTokenTTL = 15 * time.Minute
|
||||
idTokenTTL = 15 * time.Minute
|
||||
refreshTokenTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// sha256Token 回傳字串的 SHA-256 雜湊(無填充 base64url,43 字元)。
|
||||
// 授權碼與 refresh token 本身即高熵亂數,兌換時僅能以憑證值查詢、無
|
||||
// 其他鍵可用(不同於 client secret 以 client_id 為鍵後再做慢雜湊),
|
||||
// 故以 SHA-256 作為可索引的確定性雜湊儲存;資料庫外洩時攻擊者亦無法
|
||||
// 還原明文憑證(RFC 6819 §5.2.2.1 的憑證儲存建議)。
|
||||
func sha256Token(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// normalizeScope 將空格分隔的 scope 拆解、去重並排序後回傳。比對與
|
||||
// 儲存皆使用正規化形式,避免「openid profile」與「profile openid」
|
||||
// 被視為不同集合。
|
||||
func normalizeScope(scope string) []string {
|
||||
fields := strings.Fields(scope)
|
||||
sort.Strings(fields)
|
||||
seen := make(map[string]bool, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scopeCovered 回傳 requested 中的每個 scope 皆存在於 granted。
|
||||
func scopeCovered(granted, requested string) bool {
|
||||
g := make(map[string]bool)
|
||||
for _, s := range strings.Fields(granted) {
|
||||
g[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(requested) {
|
||||
if !g[s] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AuthorizationCode 為授權碼流程的一次性憑證(RFC 6749 §4.1.2),對應
|
||||
// authorization_codes 資料表。CodeHash 為授權碼明文的 SHA-256,明文僅
|
||||
// 在發行當下出現於 redirect URI 一次;兌換後設定 UsedAt,之後再次兌換
|
||||
// 即為重用——除拒絕外並撤銷該碼發行的一切 refresh token。RedirectURI、
|
||||
// Scope、CodeChallenge 等發行當下的授權內容隨碼凍結,兌換時逐項比對。
|
||||
type AuthorizationCode struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
CodeHash string `gorm:"uniqueIndex;size:43;not null"`
|
||||
ApplicationID uint `gorm:"not null;index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
RedirectURI string `gorm:"size:2048;not null"`
|
||||
Scope string `gorm:"size:255;not null"`
|
||||
Nonce string `gorm:"size:255;not null;default:''"` // OIDC Core §3.1.2.1 nonce,未提供為空
|
||||
CodeChallenge string `gorm:"size:255;not null;default:''"` // RFC 7636 §4.3 的 challenge(S256),未使用 PKCE 為空
|
||||
CodeChallengeMethod string `gorm:"size:16;not null;default:''"` // "S256" 或空字串
|
||||
AuthTime time.Time `gorm:"not null"` // 使用者 Session 建立時間(ID token auth_time 的依據,OIDC Core §2)
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewAuthorizationCode 產生並儲存授權碼,回傳模型與明文——明文僅此一次,
|
||||
// 呼叫方隨即放入 redirect URI,不得留存。authTime 為使用者 Session 的
|
||||
// 建立時間,隨碼保存供兌換時簽入 ID token。順帶刪除已過期的授權碼
|
||||
// (最佳清除,失敗不影響發碼)。
|
||||
func NewAuthorizationCode(db *gorm.DB, applicationID, userID uint, redirectURI, scope, nonce, codeChallenge, codeChallengeMethod string, authTime time.Time) (*AuthorizationCode, string, error) {
|
||||
code, err := auth.NewToken(32)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate code: %w", err)
|
||||
}
|
||||
ac := &AuthorizationCode{
|
||||
CodeHash: sha256Token(code),
|
||||
ApplicationID: applicationID,
|
||||
UserID: userID,
|
||||
RedirectURI: redirectURI,
|
||||
Scope: scope,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: codeChallengeMethod,
|
||||
AuthTime: authTime,
|
||||
ExpiresAt: time.Now().Add(authorizationCodeTTL),
|
||||
}
|
||||
if err := db.Create(ac).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("create authorization code: %w", err)
|
||||
}
|
||||
db.Where("expires_at < ?", time.Now()).Delete(&AuthorizationCode{})
|
||||
return ac, code, nil
|
||||
}
|
||||
|
||||
// GetAuthorizationCode 以授權碼明文(雜湊後)查詢對應資料列;查無資料
|
||||
// 時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
|
||||
func GetAuthorizationCode(db *gorm.DB, code string) (*AuthorizationCode, error) {
|
||||
var ac AuthorizationCode
|
||||
if err := db.Where("code_hash = ?", sha256Token(code)).First(&ac).Error; err != nil {
|
||||
return nil, fmt.Errorf("query authorization code: %w", err)
|
||||
}
|
||||
return &ac, nil
|
||||
}
|
||||
|
||||
// ConsumeAuthorizationCode 以條件更新(used_at 仍為 NULL 且未過期)標記
|
||||
// 授權碼已兌換,回傳是否成功。條件更新保證並發的第二次兌換必然失敗
|
||||
// (RFC 6749 §4.1.2 的一次性要求;先查後寫在並發下會有競態)。
|
||||
func ConsumeAuthorizationCode(db *gorm.DB, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
res := db.Model(&AuthorizationCode{}).
|
||||
Where("id = ? AND used_at IS NULL AND expires_at > ?", id, now).
|
||||
Update("used_at", now)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("consume authorization code: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// RefreshToken 為換發新權杖的長效憑證(RFC 6749 §6),對應
|
||||
// refresh_tokens 資料表。TokenHash 為明文的 SHA-256。輪替模型為
|
||||
// 「兌換即作廢舊 token 並發行新 token」(OAuth 2.0 Security BCP
|
||||
// §4.14.2):RotatedAt 標記已輪替、RevokedAt 標記已撤銷;兌換已輪替
|
||||
// 的 token 視為重用,撤銷該使用者於該應用程式的全部 refresh token。
|
||||
// AuthorizationID 記錄發行來源的授權碼,授權碼重用時據此撤銷。
|
||||
type RefreshToken struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:43;not null"`
|
||||
ApplicationID uint `gorm:"not null;index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
AuthorizationID uint `gorm:"not null;index"`
|
||||
Scope string `gorm:"size:255;not null"`
|
||||
AuthTime time.Time `gorm:"not null"` // 沿用發行來源授權碼的值;來源授權碼到期清除後仍可簽發 ID token
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
RotatedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewRefreshToken 產生並儲存 refresh token,回傳模型與明文——明文僅在
|
||||
// token 回應中出現一次。順帶刪除已過期的 refresh token(最佳清除,
|
||||
// 失敗不影響發行)。
|
||||
func NewRefreshToken(db *gorm.DB, applicationID, userID, authorizationID uint, scope string, authTime time.Time) (*RefreshToken, string, error) {
|
||||
token, err := auth.NewToken(32)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
rt := &RefreshToken{
|
||||
TokenHash: sha256Token(token),
|
||||
ApplicationID: applicationID,
|
||||
UserID: userID,
|
||||
AuthorizationID: authorizationID,
|
||||
Scope: scope,
|
||||
AuthTime: authTime,
|
||||
ExpiresAt: time.Now().Add(refreshTokenTTL),
|
||||
}
|
||||
if err := db.Create(rt).Error; err != nil {
|
||||
return nil, "", fmt.Errorf("create refresh token: %w", err)
|
||||
}
|
||||
db.Where("expires_at < ?", time.Now()).Delete(&RefreshToken{})
|
||||
return rt, token, nil
|
||||
}
|
||||
|
||||
// GetRefreshToken 以 refresh token 明文(雜湊後)查詢對應資料列;查無
|
||||
// 資料時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
|
||||
func GetRefreshToken(db *gorm.DB, token string) (*RefreshToken, error) {
|
||||
var rt RefreshToken
|
||||
if err := db.Where("token_hash = ?", sha256Token(token)).First(&rt).Error; err != nil {
|
||||
return nil, fmt.Errorf("query refresh token: %w", err)
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
// RotateRefreshToken 以條件更新(rotated_at 與 revoked_at 仍為 NULL 且
|
||||
// 未過期)標記 refresh token 已輪替,回傳是否成功;並發的重複兌換僅
|
||||
// 一個成功,失敗方即為重用。
|
||||
func RotateRefreshToken(db *gorm.DB, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
res := db.Model(&RefreshToken{}).
|
||||
Where("id = ? AND rotated_at IS NULL AND revoked_at IS NULL AND expires_at > ?", id, now).
|
||||
Update("rotated_at", now)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("rotate refresh token: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshTokensByAuthorization 撤銷指定授權碼發行的所有 refresh
|
||||
// token(授權碼重用時的防護,RFC 6749 §4.1.2)。
|
||||
func RevokeRefreshTokensByAuthorization(db *gorm.DB, authorizationID uint) error {
|
||||
if err := db.Model(&RefreshToken{}).
|
||||
Where("authorization_id = ? AND revoked_at IS NULL", authorizationID).
|
||||
Update("revoked_at", time.Now()).Error; err != nil {
|
||||
return fmt.Errorf("revoke refresh tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeRefreshTokensFor 撤銷使用者於指定應用程式的所有 refresh token
|
||||
// (refresh token 重用偵測時的整鏈撤銷,OAuth 2.0 Security BCP §4.14.2)。
|
||||
func RevokeRefreshTokensFor(db *gorm.DB, userID, applicationID uint) error {
|
||||
if err := db.Model(&RefreshToken{}).
|
||||
Where("user_id = ? AND application_id = ? AND revoked_at IS NULL", userID, applicationID).
|
||||
Update("revoked_at", time.Now()).Error; err != nil {
|
||||
return fmt.Errorf("revoke refresh tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consent 為使用者對應用程式的授權同意記錄,對應 consents 資料表:
|
||||
// 同意頁首次同意後記住 scope 聯集,之後請求的 scope 全部涵蓋於已同意
|
||||
// 集合時靜默通過,不再顯示同意頁;請求範圍擴大時再次詢問。
|
||||
type Consent struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:1"`
|
||||
ApplicationID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:2"`
|
||||
Scope string `gorm:"size:255;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetConsent 查詢使用者對應用程式的同意記錄;查無資料時回傳包裹
|
||||
// gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
|
||||
func GetConsent(db *gorm.DB, userID, applicationID uint) (*Consent, error) {
|
||||
var c Consent
|
||||
if err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error; err != nil {
|
||||
return nil, fmt.Errorf("query consent: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// SaveConsent 記錄同意:首次建立,之後將新的 scope 併入既有聯集(同意
|
||||
// 頁勾選一律代表「允許全部請求的 scope」)。
|
||||
func SaveConsent(db *gorm.DB, userID, applicationID uint, scope string) error {
|
||||
var c Consent
|
||||
err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c = Consent{
|
||||
UserID: userID,
|
||||
ApplicationID: applicationID,
|
||||
Scope: strings.Join(normalizeScope(scope), " "),
|
||||
}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
return fmt.Errorf("create consent: %w", err)
|
||||
}
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("query consent: %w", err)
|
||||
}
|
||||
merged := normalizeScope(c.Scope + " " + scope)
|
||||
c.Scope = strings.Join(merged, " ")
|
||||
if err := db.Save(&c).Error; err != nil {
|
||||
return fmt.Errorf("update consent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// normalizeScope 應拆解、去重並排序 scope。
|
||||
func TestNormalizeScope(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope string
|
||||
want string
|
||||
}{
|
||||
{"空字串", "", ""},
|
||||
{"多餘空白", " openid profile ", "openid profile"},
|
||||
{"去除重複", "profile openid profile", "openid profile"},
|
||||
{"排序", "email openid", "email openid"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := strings.Join(normalizeScope(tt.scope), " "); got != tt.want {
|
||||
t.Fatalf("normalizeScope(%q) = %q, want %q", tt.scope, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// scopeCovered 判斷請求 scope 是否全數涵蓋於已同意集合。
|
||||
func TestScopeCovered(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
granted string
|
||||
requested string
|
||||
want bool
|
||||
}{
|
||||
{"完全相同", "openid profile", "openid profile", true},
|
||||
{"請求子集", "openid profile email", "openid email", true},
|
||||
{"請求超出", "openid", "openid email", false},
|
||||
{"完全無關", "openid", "profile", false},
|
||||
{"空請求", "openid", "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := scopeCovered(tt.granted, tt.requested); got != tt.want {
|
||||
t.Fatalf("scopeCovered(%q, %q) = %v, want %v", tt.granted, tt.requested, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sha256Token 輸出長度應為 43 字元(32 bytes 的 base64url)。
|
||||
func TestSha256Token(t *testing.T) {
|
||||
got := sha256Token("test")
|
||||
if len(got) != 43 {
|
||||
t.Fatalf("SHA-256 base64url 長度 = %d, want 43", len(got))
|
||||
}
|
||||
if sha256Token("test") != got {
|
||||
t.Fatal("同一輸入應得相同雜湊")
|
||||
}
|
||||
if sha256Token("other") == got {
|
||||
t.Fatal("不同輸入應得不同雜湊")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// tokenResponse 為 token 端點的成功回應(RFC 6749 §5.1;ID token 與
|
||||
// refresh token 僅在對應條件成立時出現——ID token 於簽發對象為使用者
|
||||
// 且 scope 含 openid 時、refresh token 於 scope 含 offline_access 時,
|
||||
// OIDC Core §3.1.3.3)。
|
||||
type tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// tokenError 為 RFC 6749 §5.2 的錯誤回應格式。
|
||||
type tokenError struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// writeTokenError 輸出 token 端點錯誤;client 認證失敗(invalid_client)
|
||||
// 回 401,其餘依規格回 400。
|
||||
func writeTokenError(w http.ResponseWriter, status int, code, description string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
if status == http.StatusUnauthorized {
|
||||
// 以 Basic 認證的請求須提示 Basic(RFC 6749 §5.2),一律附上不影響。
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="alterminal"`)
|
||||
}
|
||||
auth.WriteJSON(w, status, tokenError{Error: code, ErrorDescription: description})
|
||||
}
|
||||
|
||||
// TokenHandler 處理 POST /token(RFC 6749 §3.2):以授權碼(§4.1.3)
|
||||
// 或 refresh token(§6)換發 Access/ID/Refresh Token。
|
||||
func TokenHandler(db *gorm.DB, issuer string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
writeTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "僅支援 POST")
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Content-Type 須為 application/x-www-form-urlencoded")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容")
|
||||
return
|
||||
}
|
||||
|
||||
app := authenticateTokenClient(db, w, r)
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch r.PostFormValue("grant_type") {
|
||||
case "authorization_code":
|
||||
tokenAuthorizationCode(db, issuer, w, r, app)
|
||||
case "refresh_token":
|
||||
tokenRefreshToken(db, issuer, w, r, app)
|
||||
case "":
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 grant_type")
|
||||
default:
|
||||
writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "不支援的 grant_type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// authenticateTokenClient 驗證 Client 身分(RFC 6749 §2.3.1):client
|
||||
// 認證可經 HTTP Basic(推薦)或表單欄位,兩處同時出現的 client_id 必須
|
||||
// 一致(§3.2.1)。機密式 Client 必須提出正確的 client secret;公開式
|
||||
// Client 不持有 secret,僅以 client_id 識別(PKCE 承擔防護)。驗證失敗
|
||||
// 已寫出 401 回應並回傳 nil。
|
||||
func authenticateTokenClient(db *gorm.DB, w http.ResponseWriter, r *http.Request) *application.Application {
|
||||
basicID, basicSecret, hasBasic := r.BasicAuth()
|
||||
postID := r.PostFormValue("client_id")
|
||||
postSecret := r.PostFormValue("client_secret")
|
||||
// RFC 6749 §2.3.1 要求 Basic 中的 client_id/secret 先以表單編碼;
|
||||
// 本服務產生的識別值僅含 base64url 字元,解碼失敗時退回原值以相容
|
||||
// 未編碼的實作。
|
||||
if basicID != "" {
|
||||
if unescaped, err := url.QueryUnescape(basicID); err == nil {
|
||||
basicID = unescaped
|
||||
}
|
||||
if unescaped, err := url.QueryUnescape(basicSecret); err == nil {
|
||||
basicSecret = unescaped
|
||||
}
|
||||
}
|
||||
if hasBasic && postID != "" && basicID != postID {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Basic 與表單的 client_id 不一致")
|
||||
return nil
|
||||
}
|
||||
clientID := postID
|
||||
if clientID == "" {
|
||||
clientID = basicID
|
||||
}
|
||||
if clientID == "" {
|
||||
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "缺少 client_id")
|
||||
return nil
|
||||
}
|
||||
|
||||
app, err := application.GetByClientID(db, clientID)
|
||||
if err != nil {
|
||||
// 查無 client 或查詢失敗一律 401,不洩漏 client 是否存在。
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Printf("token: %v", err)
|
||||
}
|
||||
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
|
||||
return nil
|
||||
}
|
||||
secret := postSecret
|
||||
if hasBasic && secret == "" {
|
||||
secret = basicSecret
|
||||
}
|
||||
if !app.IsPublic() && !app.CheckSecret(secret) {
|
||||
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
|
||||
return nil
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// tokenAuthorizationCode 處理 grant_type=authorization_code(RFC 6749
|
||||
// §4.1.3):兌換一次性授權碼,逐項比對兌換條件後簽發權杖。
|
||||
func tokenAuthorizationCode(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
|
||||
if !app.GrantTypes.Contains(application.GrantAuthorizationCode) {
|
||||
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用授權碼流程")
|
||||
return
|
||||
}
|
||||
code := r.PostFormValue("code")
|
||||
if code == "" {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 code")
|
||||
return
|
||||
}
|
||||
|
||||
ac, err := GetAuthorizationCode(db, code)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Printf("token: %v", err)
|
||||
}
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
|
||||
return
|
||||
}
|
||||
if ac.UsedAt != nil {
|
||||
// 授權碼重用:撤銷其發行的一切 refresh token(RFC 6749 §4.1.2
|
||||
// 一次性要求;OAuth 2.0 Security BCP §4.5.3.3 的防護)。
|
||||
if err := RevokeRefreshTokensByAuthorization(db, ac.ID); err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
}
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
|
||||
return
|
||||
}
|
||||
if ac.ExpiresAt.Before(time.Now()) {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼已過期")
|
||||
return
|
||||
}
|
||||
// 授權碼與 client 及 redirect_uri 的綁定逐項比對(RFC 6749 §4.1.3);
|
||||
// 不符一律回 invalid_grant,不洩漏原因。
|
||||
if ac.ApplicationID != app.ID || r.PostFormValue("redirect_uri") != ac.RedirectURI {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
|
||||
return
|
||||
}
|
||||
// PKCE(RFC 7636 §4.6):發碼時有 challenge 者,兌換必須提出比對
|
||||
// 相符的 code_verifier。
|
||||
if ac.CodeChallenge != "" {
|
||||
verifier := r.PostFormValue("code_verifier")
|
||||
if !validCodeVerifier(verifier) {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier 格式無效")
|
||||
return
|
||||
}
|
||||
if pkceChallenge(verifier) != ac.CodeChallenge {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "PKCE 驗證失敗")
|
||||
return
|
||||
}
|
||||
}
|
||||
ok, err := ConsumeAuthorizationCode(db, ac.ID)
|
||||
if err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
// 並發兌換的輸家;勝者已完成撤銷防護,比照重用處理。
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
|
||||
return
|
||||
}
|
||||
|
||||
var u auth.User
|
||||
if err := db.First(&u, ac.UserID).Error; err != nil {
|
||||
log.Printf("token: 查詢使用者 %d: %v", ac.UserID, err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
issueTokenResponse(db, issuer, w, &u, app, ac.Scope, ac.Nonce, ac.ID, ac.AuthTime)
|
||||
}
|
||||
|
||||
// tokenRefreshToken 處理 grant_type=refresh_token(RFC 6749 §6):以
|
||||
// refresh token 換發新權杖組,舊 token 立即輪替作廢;偵測到重用已輪替
|
||||
// 的 token 時撤銷該使用者於該應用程式的全部 refresh token。
|
||||
func tokenRefreshToken(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
|
||||
if !app.GrantTypes.Contains(application.GrantRefreshToken) {
|
||||
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用 refresh_token")
|
||||
return
|
||||
}
|
||||
token := r.PostFormValue("refresh_token")
|
||||
if token == "" {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
rt, err := GetRefreshToken(db, token)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Printf("token: %v", err)
|
||||
}
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
|
||||
return
|
||||
}
|
||||
if rt.RotatedAt != nil {
|
||||
// 已輪替的 token 再次出現即為重用:整鏈撤銷(OAuth 2.0
|
||||
// Security BCP §4.14.2)。
|
||||
if err := RevokeRefreshTokensFor(db, rt.UserID, rt.ApplicationID); err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
}
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 重用,相關權杖已撤銷")
|
||||
return
|
||||
}
|
||||
if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 已失效")
|
||||
return
|
||||
}
|
||||
if rt.ApplicationID != app.ID {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
|
||||
return
|
||||
}
|
||||
|
||||
// RFC 6749 §6:請求可縮小 scope,不可擴大。
|
||||
scope := rt.Scope
|
||||
if req := strings.TrimSpace(r.PostFormValue("scope")); req != "" {
|
||||
if !scopeCovered(rt.Scope, req) {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_scope", "請求的 scope 超出原授權範圍")
|
||||
return
|
||||
}
|
||||
scope = strings.Join(normalizeScope(req), " ")
|
||||
}
|
||||
|
||||
ok, err := RotateRefreshToken(db, rt.ID)
|
||||
if err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
|
||||
return
|
||||
}
|
||||
|
||||
var u auth.User
|
||||
if err := db.First(&u, rt.UserID).Error; err != nil {
|
||||
log.Printf("token: 查詢使用者 %d: %v", rt.UserID, err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
issueTokenResponse(db, issuer, w, &u, app, scope, "", rt.AuthorizationID, rt.AuthTime)
|
||||
}
|
||||
|
||||
// issueTokenResponse 簽發權杖組並寫出成功回應:Access Token 必發;scope
|
||||
// 含 openid 時簽發 ID token;scope 含 offline_access 時簽發 refresh
|
||||
// token 並作廢舊授權碼鏈的後繼(由輪替模型保證單一現行 token)。
|
||||
// authorizationID 為本次授權鏈的源頭授權碼 ID,refresh token 沿用記錄。
|
||||
func issueTokenResponse(db *gorm.DB, issuer string, w http.ResponseWriter, u *auth.User, app *application.Application, scope, nonce string, authorizationID uint, authTime time.Time) {
|
||||
access, err := IssueAccessToken(db, issuer, u.ID, app, scope)
|
||||
if err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
resp := tokenResponse{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(accessTokenTTL.Seconds()),
|
||||
Scope: scope,
|
||||
}
|
||||
if scopeHas(scope, "openid") {
|
||||
idToken, err := IssueIDToken(db, issuer, u, app, scope, nonce, authTime)
|
||||
if err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
resp.IDToken = idToken
|
||||
}
|
||||
if scopeHas(scope, "offline_access") {
|
||||
_, plain, err := NewRefreshToken(db, app.ID, u.ID, authorizationID, scope, authTime)
|
||||
if err != nil {
|
||||
log.Printf("token: %v", err)
|
||||
writeTokenError(w, http.StatusInternalServerError, "", "")
|
||||
return
|
||||
}
|
||||
resp.RefreshToken = plain
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
auth.WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// validCodeVerifier 檢查 code_verifier 格式(RFC 7636 §4.1):
|
||||
// 43–128 個字元,僅含 [A-Za-z0-9-._~]。
|
||||
func validCodeVerifier(v string) bool {
|
||||
if len(v) < 43 || len(v) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, c := range v {
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
case c == '-' || c == '.' || c == '_' || c == '~':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// pkceChallenge 計算 code_verifier 的 S256 challenge(RFC 7636 §4.2):
|
||||
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))。
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"alterminal/internal/oidc"
|
||||
"alterminal/internal/testdb"
|
||||
)
|
||||
|
||||
// RFC 7636 附錄 B 的官方測試向量:code_verifier 與其 S256 challenge。
|
||||
const (
|
||||
testVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
testChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
wrongVerifier = "wJ-B4LdB4kNOXK32ONwPccn9YMHcGgnbHDB1jXtsCXc" // 格式合法但與 challenge 不符
|
||||
)
|
||||
|
||||
// tokenBody 為成功回應的斷言結構。
|
||||
type tokenBody struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
IDToken string `json:"id_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// exchangeCode 兌換授權碼,回傳記錄器。basic=true 時以 HTTP Basic 認證
|
||||
// (表單不帶 client 欄位),否則以 client_secret_post 送出。
|
||||
func exchangeCode(h http.HandlerFunc, code, redirectURI, clientID, clientSecret, verifier string, basic bool) *httptest.ResponseRecorder {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
}
|
||||
if verifier != "" {
|
||||
form.Set("code_verifier", verifier)
|
||||
}
|
||||
if basic {
|
||||
return postToken(h, form, clientID, clientSecret)
|
||||
}
|
||||
form.Set("client_id", clientID)
|
||||
if clientSecret != "" {
|
||||
form.Set("client_secret", clientSecret)
|
||||
}
|
||||
return postToken(h, form, "", "")
|
||||
}
|
||||
|
||||
// 完整兌換:機密式 Client + PKCE + Basic 認證,核發 Access/ID/Refresh
|
||||
// Token,ID token 各 claim 依授權內容簽入(OIDC Core §3.1.3.3、§5.4)。
|
||||
func TestTokenAuthorizationCodeFull(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.TokenHandler(e.db, testIssuer)
|
||||
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile email offline_access", "xyz", "nonce-42", testChallenge))
|
||||
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
|
||||
t.Errorf("Cache-Control = %q, want no-store", cc)
|
||||
}
|
||||
|
||||
var body tokenBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal("解析回應: ", err)
|
||||
}
|
||||
if body.AccessToken == "" || body.TokenType != "Bearer" || body.ExpiresIn != 900 {
|
||||
t.Errorf("access token 欄位不符: %+v", body)
|
||||
}
|
||||
if body.Scope != "email offline_access openid profile" {
|
||||
t.Errorf("scope = %q(應為正規化排序形式)", body.Scope)
|
||||
}
|
||||
if body.IDToken == "" {
|
||||
t.Fatal("scope 含 openid 應核發 id_token")
|
||||
}
|
||||
if body.RefreshToken == "" {
|
||||
t.Fatal("scope 含 offline_access 應核發 refresh_token")
|
||||
}
|
||||
|
||||
_, payload := jwtParts(t, body.IDToken)
|
||||
var idc idTokenClaims
|
||||
if err := json.Unmarshal(payload, &idc); err != nil {
|
||||
t.Fatal("解析 ID token: ", err)
|
||||
}
|
||||
if idc.Iss != testIssuer || idc.Aud != e.app.ClientID {
|
||||
t.Errorf("iss/aud = %q/%q", idc.Iss, idc.Aud)
|
||||
}
|
||||
if idc.Sub != subjectOf(e.user.ID) {
|
||||
t.Errorf("sub = %q, want %q", idc.Sub, subjectOf(e.user.ID))
|
||||
}
|
||||
if idc.Nonce != "nonce-42" {
|
||||
t.Errorf("nonce = %q, want nonce-42", idc.Nonce)
|
||||
}
|
||||
if idc.AuthTime == 0 {
|
||||
t.Error("auth_time 應簽入 Session 建立時間")
|
||||
}
|
||||
if idc.Name != e.user.Name || idc.Email != e.user.Email || idc.EmailVerf == nil || !*idc.EmailVerf {
|
||||
t.Errorf("profile/email claims 不符: %+v", idc)
|
||||
}
|
||||
|
||||
// 無 offline_access 的 scope 不應拿到 refresh token。
|
||||
_, code2 := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
|
||||
rec = exchangeCode(h, code2, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("第二次兌換 status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body2 tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &body2)
|
||||
if body2.RefreshToken != "" {
|
||||
t.Error("未請求 offline_access 不應核發 refresh_token")
|
||||
}
|
||||
if body2.IDToken == "" {
|
||||
t.Error("scope 含 openid 應核發 id_token")
|
||||
}
|
||||
}
|
||||
|
||||
// 公開式 Client 無 secret,以 PKCE 兌換(client_secret_post 欄位不送)。
|
||||
func TestTokenPublicClientPKCE(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.TokenHandler(e.db, testIssuer)
|
||||
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.pub, "openid", "", "", testChallenge))
|
||||
rec := exchangeCode(h, code, e.pub.RedirectURIs[0], e.pub.ClientID, "", testVerifier, false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if body.AccessToken == "" {
|
||||
t.Fatal("應核發 access_token")
|
||||
}
|
||||
}
|
||||
|
||||
// client 認證失敗與參數錯誤。
|
||||
func TestTokenClientAuthentication(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.TokenHandler(e.db, testIssuer)
|
||||
|
||||
t.Run("client secret 錯誤回 401 invalid_client", func(t *testing.T) {
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
|
||||
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, "wrong-secret", testVerifier, true)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_client" {
|
||||
t.Errorf("error = %q, want invalid_client", got)
|
||||
}
|
||||
if rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("Basic 認證失敗應附 WWW-Authenticate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未知 client_id 回 401", func(t *testing.T) {
|
||||
rec := exchangeCode(h, "any", e.app.RedirectURIs[0], "no-such", "x", "", false)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Basic 與表單 client_id 不一致", func(t *testing.T) {
|
||||
form := url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {e.app.ClientID}}
|
||||
rec := postToken(h, form, "no-such", "secret")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_request" {
|
||||
t.Errorf("error = %q, want invalid_request", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不支援的 grant_type", func(t *testing.T) {
|
||||
form := url.Values{"grant_type": {"password"}, "client_id": {e.app.ClientID}, "client_secret": {e.secret}}
|
||||
rec := postToken(h, form, "", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
if got := decodeTokenError(t, rec).Error; got != "unsupported_grant_type" {
|
||||
t.Errorf("error = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Content-Type 非 form 回 400", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/token", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 授權碼兌換的條件比對與一次性(RFC 6749 §4.1.3)。
|
||||
func TestTokenCodeRedemptionErrors(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.TokenHandler(e.db, testIssuer)
|
||||
redirectURI := e.app.RedirectURIs[0]
|
||||
|
||||
mustCode := func(t *testing.T) string {
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
|
||||
return code
|
||||
}
|
||||
|
||||
t.Run("code_verifier 不符回 invalid_grant", func(t *testing.T) {
|
||||
rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, wrongVerifier, true)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("code_verifier 格式無效回 invalid_request", func(t *testing.T) {
|
||||
rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, "short", true)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_request" {
|
||||
t.Fatalf("error = %q, want invalid_request", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redirect_uri 與發碼時不符回 invalid_grant", func(t *testing.T) {
|
||||
rec := exchangeCode(h, mustCode(t), "https://rp.example/other", e.app.ClientID, e.secret, testVerifier, true)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("error = %q, want invalid_grant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("換別的 client 也回 invalid_grant", func(t *testing.T) {
|
||||
rec := exchangeCode(h, mustCode(t), redirectURI, e.pub.ClientID, "", testVerifier, false)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("error = %q, want invalid_grant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不存在的 code", func(t *testing.T) {
|
||||
rec := exchangeCode(h, "no-such-code", redirectURI, e.app.ClientID, e.secret, "", true)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("error = %q, want invalid_grant", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("重用撤銷其 refresh token", func(t *testing.T) {
|
||||
code := mustCode(t)
|
||||
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "code_verifier": {testVerifier}}
|
||||
rec := postToken(h, form, e.app.ClientID, e.secret)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("首次兌換失敗: %s", rec.Body.String())
|
||||
}
|
||||
var first tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &first)
|
||||
|
||||
// 同一碼再兌換:invalid_grant,且首次拿到的 refresh token 應被撤銷。
|
||||
rec = postToken(h, form, e.app.ClientID, e.secret)
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("重用 error = %q, want invalid_grant", got)
|
||||
}
|
||||
refreshForm := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {first.RefreshToken}}
|
||||
rec = postToken(h, refreshForm, e.app.ClientID, e.secret)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("被撤銷的 refresh token 不應可用: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh token 輪替與重用整鏈撤銷(OAuth 2.0 Security BCP §4.14.2)。
|
||||
func TestTokenRefreshRotationAndReuse(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.TokenHandler(e.db, testIssuer)
|
||||
|
||||
// 取得一組含 offline_access 的權杖。
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", testChallenge))
|
||||
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("兌換失敗: %s", rec.Body.String())
|
||||
}
|
||||
var first tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &first)
|
||||
|
||||
refresh := func(token, scope string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}}
|
||||
if scope != "" {
|
||||
form.Set("scope", scope)
|
||||
}
|
||||
return postToken(h, form, e.app.ClientID, e.secret)
|
||||
}
|
||||
|
||||
t.Run("輪替發新權杖組", func(t *testing.T) {
|
||||
rec := refresh(first.RefreshToken, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var second tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &second)
|
||||
if second.AccessToken == "" || second.RefreshToken == "" || second.RefreshToken == first.RefreshToken {
|
||||
t.Fatalf("應核發新的 access 與 refresh token: %+v", second)
|
||||
}
|
||||
if second.Scope != "offline_access openid profile" {
|
||||
t.Errorf("scope 應沿用原授權: %q", second.Scope)
|
||||
}
|
||||
if second.IDToken == "" {
|
||||
t.Error("原 scope 含 openid 應續發 id_token")
|
||||
}
|
||||
|
||||
// 舊 token 重用:invalid_grant,且整鏈(含新 token)撤銷。
|
||||
rec = refresh(first.RefreshToken, "")
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("重用 error = %q, body = %s", got, rec.Body.String())
|
||||
}
|
||||
rec = refresh(second.RefreshToken, "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("重用偵測後整鏈應撤銷(新 token 亦不可用): %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope 僅可縮小", func(t *testing.T) {
|
||||
// 取一組原授權為「openid profile offline_access」的鏈。
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", ""))
|
||||
rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("兌換失敗: %s", rec.Body.String())
|
||||
}
|
||||
var body tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
|
||||
// 縮小為不含 profile:成功,新鏈的授權範圍即縮小後的值。
|
||||
rec = refresh(body.RefreshToken, "openid offline_access")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("縮小 scope 應成功: %s", rec.Body.String())
|
||||
}
|
||||
var narrowed tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &narrowed)
|
||||
if narrowed.Scope != "offline_access openid" {
|
||||
t.Errorf("縮小後 scope = %q", narrowed.Scope)
|
||||
}
|
||||
|
||||
// 對縮小後的鏈再請求原範圍(含 profile)即為擴大:invalid_scope。
|
||||
rec = refresh(narrowed.RefreshToken, "openid profile offline_access")
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_scope" {
|
||||
t.Fatalf("擴大 scope error = %q, want invalid_scope, body = %s", got, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("過期 refresh token 回 invalid_grant", func(t *testing.T) {
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid offline_access", "", "", ""))
|
||||
rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret)
|
||||
var body tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
// 直接把最新一筆 refresh token 的效期改為過去。
|
||||
if err := e.db.Model(&oidc.RefreshToken{}).
|
||||
Where("id = (SELECT MAX(id) FROM refresh_tokens)").
|
||||
Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = refresh(body.RefreshToken, "")
|
||||
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
|
||||
t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未啟用 refresh grant 的應用回 unauthorized_client", func(t *testing.T) {
|
||||
rec := postToken(h, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"x"}}, e.pub.ClientID, "")
|
||||
if got := decodeTokenError(t, rec).Error; got != "unauthorized_client" {
|
||||
t.Fatalf("error = %q, want unauthorized_client", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 空資料庫時 token 端點仍應正常拒絕(不 panic)。
|
||||
func TestTokenHandlerEmptyDB(t *testing.T) {
|
||||
db := testdb.New(t)
|
||||
rec := postToken(oidc.TokenHandler(db, testIssuer), url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {"nobody"}, "client_secret": {"s"}}, "", "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// UserInfoHandler 處理 GET/POST /userinfo(OIDC Core §5.3):以 Bearer
|
||||
// Access Token 取得已授權的使用者 claims。token 取自 Authorization
|
||||
// 標頭(RFC 6750 §2.1),POST 另接受表單的 access_token 欄位(§2.2)。
|
||||
func UserInfoHandler(db *gorm.DB, issuer string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodPost:
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
writeBearerError(w, http.StatusMethodNotAllowed, "", "僅支援 GET 與 POST")
|
||||
return
|
||||
}
|
||||
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeBearerError(w, http.StatusUnauthorized, "", "缺少 Access Token")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeBearerError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容")
|
||||
return
|
||||
}
|
||||
if t := r.PostFormValue("access_token"); t != "" {
|
||||
token = t
|
||||
}
|
||||
}
|
||||
|
||||
claims, err := VerifyAccessToken(db, issuer, token)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
log.Printf("userinfo: %v", err)
|
||||
}
|
||||
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
|
||||
return
|
||||
}
|
||||
userID, err := strconv.ParseUint(claims.Sub, 10, 64)
|
||||
if err != nil {
|
||||
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
|
||||
return
|
||||
}
|
||||
var u auth.User
|
||||
if err := db.First(&u, userID).Error; err != nil {
|
||||
log.Printf("userinfo: 查詢使用者 %d: %v", userID, err)
|
||||
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
|
||||
return
|
||||
}
|
||||
|
||||
// claims 依授權 scope 決定(OIDC Core §5.4):sub 恆有;profile
|
||||
// 加 name 與 preferred_username;email 加 email 與
|
||||
// email_verified。Access Token 未含 openid scope(非授權碼流程
|
||||
// 核發)者不得存取(RFC 6750 insufficient_scope)。
|
||||
if !scopeHas(claims.Scope, "openid") {
|
||||
writeBearerError(w, http.StatusForbidden, "insufficient_scope", "缺少 openid scope")
|
||||
return
|
||||
}
|
||||
out := struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified *bool `json:"email_verified,omitempty"`
|
||||
}{Sub: claims.Sub}
|
||||
if scopeHas(claims.Scope, "profile") {
|
||||
out.Name = u.Name
|
||||
out.PreferredUsername = u.Username
|
||||
}
|
||||
if scopeHas(claims.Scope, "email") {
|
||||
out.Email = u.Email
|
||||
verified := u.EmailVerified
|
||||
out.EmailVerified = &verified
|
||||
}
|
||||
auth.WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken 剖析 Authorization: Bearer 標頭(RFC 6750 §2.1)。
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
const scheme = "bearer "
|
||||
if len(h) < len(scheme) || !strings.EqualFold(h[:len(scheme)], scheme) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(h[len(scheme):])
|
||||
}
|
||||
|
||||
// writeBearerError 輸出 /userinfo 的 Bearer 錯誤,並以
|
||||
// WWW-Authenticate 標頭回報錯誤細節(RFC 6750 §3)。
|
||||
func writeBearerError(w http.ResponseWriter, status int, code, description string) {
|
||||
if status != http.StatusBadRequest {
|
||||
challenge := `Bearer realm="alterminal"`
|
||||
if code != "" {
|
||||
challenge += `, error="` + code + `"`
|
||||
if description != "" {
|
||||
challenge += `, error_description="` + description + `"`
|
||||
}
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", challenge)
|
||||
}
|
||||
auth.WriteError(w, status, description)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// 外部測試套件:見 jwks_test.go 開頭說明。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
// getUserinfo 以 Bearer token 呼叫 /userinfo。
|
||||
func getUserinfo(h http.HandlerFunc, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/userinfo", nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// obtainAccessToken 走完授權碼流程(PKCE)並回傳 access token。
|
||||
func obtainAccessToken(t *testing.T, e *testEnv, scope string) string {
|
||||
t.Helper()
|
||||
_, code := consentAllow(t, e, authorizeQuery(e.app, scope, "", "", testChallenge))
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {e.app.RedirectURIs[0]},
|
||||
"code_verifier": {testVerifier},
|
||||
}
|
||||
rec := postToken(oidc.TokenHandler(e.db, testIssuer), form, e.app.ClientID, e.secret)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("兌換失敗: %s", rec.Body.String())
|
||||
}
|
||||
var body tokenBody
|
||||
json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
return body.AccessToken
|
||||
}
|
||||
|
||||
// 有效 token 回依 scope 的 claims(OIDC Core §5.4)。
|
||||
func TestUserInfoClaims(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.UserInfoHandler(e.db, testIssuer)
|
||||
|
||||
t.Run("profile 與 email scope", func(t *testing.T) {
|
||||
token := obtainAccessToken(t, e, "openid profile email")
|
||||
rec := getUserinfo(h, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var u userInfoBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil {
|
||||
t.Fatal("解析回應: ", err)
|
||||
}
|
||||
if u.Sub != subjectOf(e.user.ID) {
|
||||
t.Errorf("sub = %q, want %q", u.Sub, subjectOf(e.user.ID))
|
||||
}
|
||||
if u.Name != e.user.Name || u.PreferredUsername != e.user.Username {
|
||||
t.Errorf("profile claims = %q/%q", u.Name, u.PreferredUsername)
|
||||
}
|
||||
if u.Email != e.user.Email || u.EmailVerified == nil || !*u.EmailVerified {
|
||||
t.Errorf("email claims = %q/%v", u.Email, u.EmailVerified)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("僅 openid 不含個人資料 claims", func(t *testing.T) {
|
||||
token := obtainAccessToken(t, e, "openid")
|
||||
rec := getUserinfo(h, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, `"email"`) || strings.Contains(body, `"name"`) {
|
||||
t.Errorf("未授權的 scope 不應輸出對應 claim: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// token 缺失、無效或授權不足時的錯誤回應(RFC 6750 §3)。
|
||||
func TestUserInfoErrors(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
h := oidc.UserInfoHandler(e.db, testIssuer)
|
||||
|
||||
t.Run("缺少 token 回 401 與 WWW-Authenticate", func(t *testing.T) {
|
||||
rec := getUserinfo(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(wa, "Bearer") {
|
||||
t.Errorf("WWW-Authenticate = %q", wa)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("無效 token 回 401 invalid_token", func(t *testing.T) {
|
||||
rec := getUserinfo(h, "not-a-token")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) {
|
||||
t.Errorf("WWW-Authenticate = %q", wa)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope 無 openid 回 403", func(t *testing.T) {
|
||||
// 手造僅 profile scope 的 token(正式流程必含 openid,此處模擬
|
||||
// 其他來源的 token)。
|
||||
token := forgeJWT(t, e.key,
|
||||
map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"},
|
||||
oidc.AccessTokenClaims{
|
||||
Iss: testIssuer, Sub: subjectOf(e.user.ID), Aud: e.app.ClientID,
|
||||
Exp: time.Now().Add(time.Hour).Unix(), Iat: time.Now().Unix(),
|
||||
Scope: "profile", ClientID: e.app.ClientID,
|
||||
})
|
||||
rec := getUserinfo(h, token)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "insufficient_scope") {
|
||||
t.Errorf("WWW-Authenticate = %q", wa)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/jwk"
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
func Open() (*gorm.DB, error) {
|
||||
dsn := fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=UTC",
|
||||
EnvOr("DB_HOST", "localhost"),
|
||||
EnvOr("DB_PORT", "5432"),
|
||||
EnvOr("DB_USER", "postgres"),
|
||||
EnvOr("DB_PASSWORD", "postgres"),
|
||||
EnvOr("DB_NAME", "alterminal"),
|
||||
)
|
||||
|
||||
// TranslateError 讓唯一鍵違規轉為 gorm.ErrDuplicatedKey,create-account 等指令以此辨識重複。
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&auth.User{}, &auth.Session{}, &jwk.SigningKey{}, &application.Application{},
|
||||
&oidc.AuthorizationCode{}, &oidc.RefreshToken{}, &oidc.Consent{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func EnvOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package testdb 準備整合測試專用的測試資料庫(與開發資料庫
|
||||
// alterminal 隔離),供需要完整資料表(users、sessions、
|
||||
// signing_keys、applications、authorization_codes、refresh_tokens、
|
||||
// consents)的套件測試使用。go test 以套件為單位並行執行,各套件
|
||||
// 取得各自的資料庫(alterminal_test_<套件名>),避免並行測試相互
|
||||
// TRUNCATE。oidc 套件的測試須以外部測試套件(package oidc_test)
|
||||
// 匯入本套件——store 為遷移而匯入 oidc 模型,內部測試套件匯入本
|
||||
// 套件會形成循環。
|
||||
package testdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/store"
|
||||
)
|
||||
|
||||
// New 連線本機 PostgreSQL,建立(若不存在)並遷移呼叫方套件專屬的
|
||||
// 測試資料庫、清空所有資料表後回傳連線;本機 PostgreSQL 不可用時
|
||||
// 跳過測試。
|
||||
func New(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
// 以呼叫方(測試檔)所在目錄為套件識別,各套件一個資料庫。
|
||||
_, file, _, ok := runtime.Caller(1)
|
||||
dbName := "alterminal_test"
|
||||
if ok {
|
||||
dbName += "_" + filepath.Base(filepath.Dir(file))
|
||||
}
|
||||
admin, err := gorm.Open(postgres.Open(fmt.Sprintf(
|
||||
"host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC",
|
||||
store.EnvOr("DB_HOST", "localhost"), store.EnvOr("DB_PORT", "5432"),
|
||||
store.EnvOr("DB_USER", "postgres"), store.EnvOr("DB_PASSWORD", "postgres"),
|
||||
)), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err)
|
||||
}
|
||||
if err := admin.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Skipf("無法建立測試資料庫:%v", err)
|
||||
}
|
||||
t.Setenv("DB_NAME", dbName)
|
||||
db, err := store.Open()
|
||||
if err != nil {
|
||||
t.Skipf("連線測試資料庫失敗:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.Exec("TRUNCATE users, sessions, signing_keys, applications, authorization_codes, refresh_tokens, consents RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("清空測試資料失敗:%v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
Reference in New Issue
Block a user