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")) // 更新密碼頁(登入者自助變更)以 layout 組合,密碼欄位不預填、 // 無一次性面板。 passwordTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/password.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")) // 登出確認頁(layout 組合)與已登出頁(獨立頁,使用者已無 Session // 脈絡)供 oidc 套件的 /logout(RP-Initiated Logout)使用。 LogoutConfirmTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/logoutconfirm.html")) LoggedOutTmpl = template.Must(template.ParseFS(templateFS, "templates/loggedout.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) } }