forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
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"))
|
||||
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 // 驗證失敗時保留使用者輸入的帳號
|
||||
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 時導向帳號首頁 /,否則顯示登入表單。
|
||||
func loginPageHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||
_, err = getSession(db, c.Value)
|
||||
switch {
|
||||
case err == nil:
|
||||
http.Redirect(w, r, "/", 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, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
// 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(sessionCookieName); 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。
|
||||
func renderLoginPage(w http.ResponseWriter, r *http.Request, status int, errMsg, username 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, CSRF: token})
|
||||
}
|
||||
|
||||
// newCSRFToken 產生新 CSRF token 並設定對應 Cookie,與表單隱藏欄位成對。
|
||||
func newCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
token, err := newRandomToken(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user