first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+20
View File
@@ -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
+39
View File
@@ -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
}
+205
View File
@@ -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})
}
+156
View File
@@ -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"))
}
}
+108
View File
@@ -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)
}
})
}
+181
View File
@@ -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)
}
}
+170
View File
@@ -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, "&lt;script&gt;") {
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())
}
})
}
+68
View File
@@ -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,
})
}
+203
View File
@@ -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)
}
})
}
+10
View File
@@ -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)
}
+51
View File
@@ -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())
}
}
}
+76
View File
@@ -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
}
+25
View File
@@ -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)
})
}
+39
View File
@@ -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}}
+99
View File
@@ -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}}
+34
View File
@@ -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}}
+104
View File
@@ -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>
+22
View File
@@ -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}}
+28
View File
@@ -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>
+23
View File
@@ -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>
+33
View File
@@ -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}}
+110
View File
@@ -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
}
+57
View File
@@ -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)
}
}
}