forked from alterminal/alterminal
174 lines
6.4 KiB
Go
174 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|