package auth import ( "encoding/json" "errors" "fmt" "log" "net/http" "strings" "sync" "time" "gorm.io/gorm" ) // CookieName 為存放 Session ID 的 Cookie 名稱。 const CookieName = "alterminal_session" // SafeNext 檢查登入成功後的返回路徑:僅接受站內路徑——以 / 開頭且不 // 以 // 開頭(協定相對 URL 會導向外部網站,構成 open redirect),不 // 合格或未提供者一律回 /。/authorize 導向登入時以 next 攜帶完整授權 // 請求(OIDC Core §3.1.2.2)。 func SafeNext(next string) string { if strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") { return next } return "/" } // 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 // next 為表單流程的登入後返回路徑(JSON API 流程不適用)。 next := "/" 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")} next = SafeNext(r.PostFormValue("next")) } 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, next) 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 導向登入前的返回路徑(無 next 時為帳號首頁 /) // 顯示已登入狀態,避免重新整理重複送出表單。 http.Redirect(w, r, next, 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: CookieName, 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}) }