Files
alterminal/internal/auth/loginpage.go
T
2026-10-03 10:44:29 +08:00

182 lines
7.1 KiB
Go

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)
}
}