forked from alterminal/alterminal
189 lines
7.7 KiB
Go
189 lines
7.7 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"))
|
||
// 更新密碼頁(登入者自助變更)以 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)
|
||
}
|
||
}
|