Files
alterminal/internal/auth/loginpage.go
T
2026-10-03 12:37:38 +08:00

189 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}