forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// sessionCookieName 為存放 Session ID 的 Cookie 名稱。
|
||||
const sessionCookieName = "alterminal_session"
|
||||
|
||||
// loginRequest 為 POST /login 的請求欄位(JSON 與表單共用)。
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// validate 正規化並檢查欄位:username 去除首尾空白後不可為空,password 不可為空。
|
||||
func (in *loginRequest) validate() error {
|
||||
in.Username = strings.TrimSpace(in.Username)
|
||||
if in.Username == "" {
|
||||
return errors.New("username 不可為空")
|
||||
}
|
||||
if in.Password == "" {
|
||||
return errors.New("password 不可為空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// publicUser 為對外暴露的使用者欄位,不含 PasswordHash 等內部資訊。
|
||||
type publicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// loginResponse 為登入成功回應;ExpiresAt 對應 Session 與 Cookie 的到期時間。
|
||||
type loginResponse struct {
|
||||
User publicUser `json:"user"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// loginHandler 處理 POST /login,依 Content-Type 分流:application/json 走
|
||||
// API 流程(回 JSON),表單走瀏覽器流程(回 HTML)。兩者共用帳密驗證與
|
||||
// Session 建立;帳密錯誤一律回 401,不洩漏帳號是否存在。
|
||||
func loginHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var isForm bool
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "application/json"):
|
||||
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"),
|
||||
strings.HasPrefix(ct, "multipart/form-data"):
|
||||
isForm = true
|
||||
default:
|
||||
writeError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
var in loginRequest
|
||||
if isForm {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderLoginPage(w, r, http.StatusBadRequest, "無法解析表單內容", "")
|
||||
return
|
||||
}
|
||||
if !verifyCSRF(r) {
|
||||
renderLoginPage(w, r, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試", "")
|
||||
return
|
||||
}
|
||||
in = loginRequest{Username: r.PostFormValue("username"), Password: r.PostFormValue("password")}
|
||||
} else if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "無法解析請求內容")
|
||||
return
|
||||
}
|
||||
|
||||
fail := func(status int, msg string) {
|
||||
if isForm {
|
||||
renderLoginPage(w, r, status, msg, in.Username)
|
||||
return
|
||||
}
|
||||
writeError(w, status, msg)
|
||||
}
|
||||
if err := in.validate(); err != nil {
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := authenticateUser(db, in.Username, in.Password)
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
fail(http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("login: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := createSession(db, u.ID)
|
||||
if err != nil {
|
||||
log.Printf("login: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, s)
|
||||
|
||||
if isForm {
|
||||
// PRG:以 303 導向帳號首頁 / 顯示已登入狀態,避免重新整理重複送出表單。
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: newPublicUser(u), ExpiresAt: s.ExpiresAt})
|
||||
}
|
||||
}
|
||||
|
||||
// setSessionCookie 將 Session ID 寫入 HttpOnly Cookie(表單與 API 流程共用)。
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: s.ID,
|
||||
Path: "/",
|
||||
Expires: s.ExpiresAt,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
// 本機 http 開發環境不設 Secure;請求經 TLS 服務時啟用。
|
||||
Secure: r.TLS != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials 表示帳號不存在或密碼錯誤,對外訊息一致。
|
||||
var ErrInvalidCredentials = errors.New("帳號或密碼錯誤")
|
||||
|
||||
// dummyPasswordHash 供查無帳號時使用:對它做一次完整的 argon2 比對,
|
||||
// 讓回應時間與真實驗證一致,避免以時間差枚舉有效帳號。
|
||||
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
||||
return hashPassword("alterminal-timing-equalizer")
|
||||
})
|
||||
|
||||
// authenticateUser 以 username 查詢使用者並驗證密碼。
|
||||
func authenticateUser(db *gorm.DB, username, password string) (*User, error) {
|
||||
var u User
|
||||
err := db.Where("username = ?", username).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
h, _ := dummyPasswordHash()
|
||||
verifyPassword(password, h) // 結果丟棄,僅為消耗同等運算時間
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user: %w", err)
|
||||
}
|
||||
if !u.CheckPassword(password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// newPublicUser 轉出可對外暴露的使用者欄位。
|
||||
func newPublicUser(u *User) publicUser {
|
||||
return publicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
EmailVerified: u.EmailVerified,
|
||||
Name: u.Name,
|
||||
Role: u.Role,
|
||||
}
|
||||
}
|
||||
|
||||
// writeJSON 以 JSON 寫出回應。
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError 寫出 {"error": ...} 格式的錯誤回應。
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
Reference in New Issue
Block a user