forked from alterminal/alterminal
first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user