fixup
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Package auth 提供以 HMAC 簽名 cookie 實作的輕量 session,
|
||||
// 不需額外的 session 儲存,適合目前無狀態的 SSR 架構。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// CookieName session cookie 的名稱。
|
||||
CookieName = "nestly_session"
|
||||
|
||||
defaultTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ErrInvalidSession 代表 cookie 不存在、簽名不符或已過期;
|
||||
// 呼叫端可將其視為「未登入」而非錯誤。
|
||||
var ErrInvalidSession = errors.New("session 無效或已過期")
|
||||
|
||||
// SessionManager 以伺服器端密鑰簽發並驗證 session cookie。
|
||||
// cookie 值格式為 "uid.expUnix.signature"(HMAC-SHA256),
|
||||
// 驗證僅保證簽名正確且未過期,帳號是否仍存在須由呼叫端查詢。
|
||||
type SessionManager struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
}
|
||||
|
||||
// NewSessionManager 以 secret 簽章建立 SessionManager;
|
||||
// secure 控制 cookie 是否僅經 HTTPS 傳送(本機開發為 false)。
|
||||
func NewSessionManager(secret string, secure bool) *SessionManager {
|
||||
return &SessionManager{
|
||||
secret: []byte(secret),
|
||||
ttl: defaultTTL,
|
||||
secure: secure,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SessionManager) sign(payload string) []byte {
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// Login 為 uid 簽發 session cookie。
|
||||
func (m *SessionManager) Login(w http.ResponseWriter, uid uint) {
|
||||
payload := fmt.Sprintf("%d.%d", uid, time.Now().Add(m.ttl).Unix())
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: payload + "." + hex.EncodeToString(m.sign(payload)),
|
||||
Path: "/",
|
||||
MaxAge: int(m.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 使 session cookie 立即失效。
|
||||
func (m *SessionManager) Logout(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: m.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// UserID 驗證請求中的 session cookie 並取出使用者 ID。
|
||||
func (m *SessionManager) UserID(r *http.Request) (uint, error) {
|
||||
c, err := r.Cookie(CookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
// 值格式為 uid.exp.sig,簽名為最後一段,從右側切分以免誤切數值中的點。
|
||||
dot := strings.LastIndexByte(c.Value, '.')
|
||||
if dot <= 0 {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
payload, sigHex := c.Value[:dot], c.Value[dot+1:]
|
||||
uidStr, expStr, ok := strings.Cut(payload, ".")
|
||||
if !ok {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil || time.Now().Unix() >= exp {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
sig, err := hex.DecodeString(sigHex)
|
||||
if err != nil || !hmac.Equal(sig, m.sign(payload)) {
|
||||
return 0, ErrInvalidSession
|
||||
}
|
||||
return uint(uid), nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nestly/internal/models"
|
||||
)
|
||||
|
||||
// loginView 登入頁的模板資料。
|
||||
type loginView struct {
|
||||
Email string
|
||||
Error string
|
||||
}
|
||||
|
||||
// AuthHandler 處理登入與登出。
|
||||
type AuthHandler struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
// Login 渲染登入頁;已登入者直接導向首頁。
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.deps.Sessions.UserID(r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := h.deps.Templates.Render(w, http.StatusOK, "login.html", loginView{}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// DoLogin 驗證 Email 與密碼,成功後建立 session 並導向首頁;
|
||||
// 失敗時重新渲染表單並保留使用者輸入的 Email。
|
||||
func (h *AuthHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.PostFormValue("email"))
|
||||
password := r.PostFormValue("password")
|
||||
|
||||
// 帳號不存在與密碼錯誤回應同一訊息,避免列舉有效 Email。
|
||||
view := loginView{Email: email}
|
||||
acct, err := h.deps.Accounts.FindByEmail(r.Context(), email)
|
||||
if err != nil && !errors.Is(err, models.ErrNotFound) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == nil && acct.VerifyPassword(password) {
|
||||
h.deps.Sessions.Login(w, acct.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
view.Error = "Email 或密碼不正確。"
|
||||
if err := h.deps.Templates.Render(w, http.StatusUnauthorized, "login.html", view); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout 清除 session 並回到登入頁。
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
h.deps.Sessions.Logout(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nestly/internal/auth"
|
||||
"nestly/internal/models"
|
||||
"nestly/internal/templates"
|
||||
"nestly/web"
|
||||
)
|
||||
|
||||
// newTestServer 建立以記憶體 SQLite 為後端的測試路由與示範帳號。
|
||||
func newTestServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("開啟記憶體資料庫失敗: %v", err)
|
||||
}
|
||||
accounts := models.NewAccountStore(db)
|
||||
if err := accounts.AutoMigrate(context.Background()); err != nil {
|
||||
t.Fatalf("自動遷移失敗: %v", err)
|
||||
}
|
||||
acct := &models.Account{Email: "demo@nestly.test", Name: "示範帳號", Role: models.RoleMember}
|
||||
if err := acct.SetPassword("nestly1234"); err != nil {
|
||||
t.Fatalf("設定密碼失敗: %v", err)
|
||||
}
|
||||
if err := accounts.Create(context.Background(), acct); err != nil {
|
||||
t.Fatalf("建立帳號失敗: %v", err)
|
||||
}
|
||||
|
||||
templateFS, err := fs.Sub(web.TemplatesFS, "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("建立模板子目錄失敗: %v", err)
|
||||
}
|
||||
engine, err := templates.New(templateFS)
|
||||
if err != nil {
|
||||
t.Fatalf("載入模板失敗: %v", err)
|
||||
}
|
||||
return NewRouter(Dependencies{
|
||||
Templates: engine,
|
||||
Accounts: accounts,
|
||||
Sessions: auth.NewSessionManager("test-secret", false),
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login 狀態碼 = %d, 想要 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"登入", `name="email"`, `name="password"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("GET /login 回應缺少 %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
form := url.Values{"email": {"demo@nestly.test"}, "password": {"wrong-password"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("POST /login(錯誤密碼)狀態碼 = %d, 想要 401", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Email 或密碼不正確") {
|
||||
t.Error("錯誤密碼應顯示錯誤訊息")
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, `value="demo@nestly.test"`) {
|
||||
t.Error("錯誤後應保留使用者輸入的 Email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessFlow(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
form := url.Values{"email": {"Demo@Nestly.test "}, "password": {"nestly1234"}} // email 大小寫與空白應被容忍
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /login(正確)狀態碼 = %d, 想要 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("登入成功應導向 /, 實際 %q", loc)
|
||||
}
|
||||
var cookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
if cookie == nil {
|
||||
t.Fatal("登入成功應設定 session cookie")
|
||||
}
|
||||
|
||||
// 帶著 session cookie 造訪首頁,應顯示帳號名稱而非登入連結。
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /(已登入)狀態碼 = %d, 想要 200", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "示範帳號") {
|
||||
t.Error("已登入的首頁應顯示帳號名稱")
|
||||
}
|
||||
|
||||
// 已登入者造訪 /login 應被導回首頁。
|
||||
req = httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("GET /login(已登入)狀態碼 = %d, 想要 303", rec.Code)
|
||||
}
|
||||
|
||||
// 登出後 cookie 失效,首頁不再顯示帳號。
|
||||
req = httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
var cleared *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieName {
|
||||
cleared = c
|
||||
}
|
||||
}
|
||||
if cleared == nil || cleared.MaxAge >= 0 {
|
||||
t.Fatal("登出應清除 session cookie")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if cleared != nil {
|
||||
req.AddCookie(cleared)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
if body := rec.Body.String(); strings.Contains(body, "示範帳號") {
|
||||
t.Error("登出後首頁不應顯示帳號名稱")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedSessionRejected(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: "1.9999999999.deadbeef"})
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
if body := rec.Body.String(); strings.Contains(body, "示範帳號") {
|
||||
t.Error("偽造的 session 不應被接受")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticCSSServed(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/static/css/app.css", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /static/css/app.css 狀態碼 = %d, 想要 200(記得先執行 make css)", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"nestly/internal/models"
|
||||
)
|
||||
|
||||
// homeView 首頁的模板資料;未登入時 Account 為 nil。
|
||||
type homeView struct {
|
||||
Account *models.Account
|
||||
}
|
||||
|
||||
// HomeHandler 渲染首頁。
|
||||
type HomeHandler struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
// Show 顯示首頁;持有有效 session 時一併帶出帳號資料。
|
||||
func (h *HomeHandler) Show(w http.ResponseWriter, r *http.Request) {
|
||||
view := homeView{}
|
||||
if uid, err := h.deps.Sessions.UserID(r); err == nil {
|
||||
if acct, err := h.deps.Accounts.FindByID(r.Context(), uid); err == nil {
|
||||
view.Account = acct
|
||||
}
|
||||
}
|
||||
if err := h.deps.Templates.Render(w, http.StatusOK, "home.html", view); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package handlers 實作 Nestly 的 HTTP handlers 與路由。
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"nestly/internal/auth"
|
||||
"nestly/internal/models"
|
||||
"nestly/internal/templates"
|
||||
"nestly/web"
|
||||
)
|
||||
|
||||
// Dependencies 集中所有 handler 依賴的週邊設施。
|
||||
type Dependencies struct {
|
||||
Templates *templates.Engine
|
||||
Accounts *models.AccountStore
|
||||
Sessions *auth.SessionManager
|
||||
}
|
||||
|
||||
// NewRouter 建立應用程式的完整路由與中介軟體。
|
||||
func NewRouter(deps Dependencies) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// 靜態資源由內嵌 FS 提供,部署不依賴磁碟上的 web/ 目錄。
|
||||
staticFS, err := fs.Sub(web.StaticFS, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
home := &HomeHandler{deps: deps}
|
||||
authh := &AuthHandler{deps: deps}
|
||||
|
||||
r.Get("/", home.Show)
|
||||
r.Get("/login", authh.Login)
|
||||
r.Post("/login", authh.DoLogin)
|
||||
r.Post("/logout", authh.Logout)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Package models 定義 Nestly 的資料結構與資料存取邏輯。
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AccountRole 使用者在系統中的角色。
|
||||
type AccountRole string
|
||||
|
||||
const (
|
||||
// RoleMember 一般會員(買方/租客)。
|
||||
RoleMember AccountRole = "member"
|
||||
// RoleOwner 屋主,可刊登自有物件。
|
||||
RoleOwner AccountRole = "owner"
|
||||
// RoleAgent 房仲經紀人,可代管多筆物件。
|
||||
RoleAgent AccountRole = "agent"
|
||||
// RoleAdmin 管理員,擁有系統全部權限。
|
||||
RoleAdmin AccountRole = "admin"
|
||||
)
|
||||
|
||||
// Valid 回傳角色是否為系統定義的合法值。
|
||||
func (r AccountRole) Valid() bool {
|
||||
switch r {
|
||||
case RoleMember, RoleOwner, RoleAgent, RoleAdmin:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String 實作 fmt.Stringer。
|
||||
func (r AccountRole) String() string { return string(r) }
|
||||
|
||||
const (
|
||||
// MinPasswordLength 密碼最小長度。
|
||||
MinPasswordLength = 8
|
||||
// MaxPasswordLength 密碼最大長度,bcrypt 僅使用前 72 個位元組。
|
||||
MaxPasswordLength = 72
|
||||
|
||||
// maxEmailLength 為 RFC 5321 允許的 email 最大長度。
|
||||
maxEmailLength = 254
|
||||
maxNameLength = 100
|
||||
maxPhoneLength = 30
|
||||
maxAvatarURLLength = 512
|
||||
|
||||
// DefaultPageSize 與 MaxPageSize 限制 List 的分頁大小。
|
||||
DefaultPageSize = 20
|
||||
MaxPageSize = 100
|
||||
)
|
||||
|
||||
// 帳號相關的 sentinel errors,handler 可用 errors.Is 判斷後轉為對應的回應。
|
||||
var (
|
||||
ErrNotFound = errors.New("帳號不存在")
|
||||
ErrEmailExists = errors.New("email 已被註冊")
|
||||
ErrEmailRequired = errors.New("email 為必填")
|
||||
ErrEmailInvalid = errors.New("email 格式不正確")
|
||||
ErrNameRequired = errors.New("name 為必填")
|
||||
ErrNameTooLong = errors.New("name 長度過長")
|
||||
ErrRoleInvalid = errors.New("role 不合法")
|
||||
ErrPhoneTooLong = errors.New("phone 長度過長")
|
||||
ErrAvatarURLTooLong = errors.New("avatar_url 長度過長")
|
||||
ErrPasswordRequired = errors.New("尚未設定密碼")
|
||||
ErrPasswordTooShort = errors.New("密碼長度至少需 8 個字元")
|
||||
ErrPasswordTooLong = errors.New("密碼長度不可超過 72 個字元")
|
||||
)
|
||||
|
||||
var emailRegex = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
// Account 使用者帳號,對應資料庫中的 accounts 資料表。
|
||||
type Account struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;size:254;not null" json:"email"`
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||
Name string `gorm:"size:100;not null" json:"name"`
|
||||
Phone string `gorm:"size:30" json:"phone"`
|
||||
Role AccountRole `gorm:"size:20;not null;default:member" json:"role"`
|
||||
AvatarURL string `gorm:"size:512" json:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Normalize 去除欄位多餘空白、將 email 統一為小寫,並補上預設角色。
|
||||
func (a *Account) Normalize() {
|
||||
a.Email = strings.ToLower(strings.TrimSpace(a.Email))
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
a.Phone = strings.TrimSpace(a.Phone)
|
||||
a.AvatarURL = strings.TrimSpace(a.AvatarURL)
|
||||
if a.Role == "" {
|
||||
a.Role = RoleMember
|
||||
}
|
||||
}
|
||||
|
||||
// SetPassword 驗證明文密碼長度後以 bcrypt 產生雜湊存入 PasswordHash。
|
||||
func (a *Account) SetPassword(plain string) error {
|
||||
n := len(plain)
|
||||
if n < MinPasswordLength {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
if n > MaxPasswordLength {
|
||||
return ErrPasswordTooLong
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.PasswordHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyPassword 比對明文密碼與儲存的雜湊是否相符。
|
||||
func (a *Account) VerifyPassword(plain string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(plain)) == nil
|
||||
}
|
||||
|
||||
// IsAdmin 回傳帳號是否為管理員。
|
||||
func (a *Account) IsAdmin() bool {
|
||||
return a.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// Validate 檢查欄位是否合法(會先呼叫 Normalize),不可通過時回傳對應的 sentinel error。
|
||||
// 寫入資料庫前帳號必須已透過 SetPassword 設定密碼。
|
||||
func (a *Account) Validate() error {
|
||||
a.Normalize()
|
||||
switch {
|
||||
case a.Email == "":
|
||||
return ErrEmailRequired
|
||||
case !emailRegex.MatchString(a.Email) || len(a.Email) > maxEmailLength:
|
||||
return ErrEmailInvalid
|
||||
}
|
||||
switch {
|
||||
case a.Name == "":
|
||||
return ErrNameRequired
|
||||
case len(a.Name) > maxNameLength:
|
||||
return ErrNameTooLong
|
||||
}
|
||||
if !a.Role.Valid() {
|
||||
return ErrRoleInvalid
|
||||
}
|
||||
if a.Phone != "" && len(a.Phone) > maxPhoneLength {
|
||||
return ErrPhoneTooLong
|
||||
}
|
||||
if a.AvatarURL != "" && len(a.AvatarURL) > maxAvatarURLLength {
|
||||
return ErrAvatarURLTooLong
|
||||
}
|
||||
if a.PasswordHash == "" {
|
||||
return ErrPasswordRequired
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AccountStore 封裝 Account 的資料庫存取,所有方法皆帶 context。
|
||||
type AccountStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAccountStore 建立以 db 為後端的 AccountStore。
|
||||
func NewAccountStore(db *gorm.DB) *AccountStore {
|
||||
return &AccountStore{db: db}
|
||||
}
|
||||
|
||||
// AutoMigrate 建立或更新 accounts 資料表。
|
||||
func (s *AccountStore) AutoMigrate(ctx context.Context) error {
|
||||
return s.db.WithContext(ctx).AutoMigrate(&Account{})
|
||||
}
|
||||
|
||||
// Create 驗證並新增帳號;email 已被註冊時回傳 ErrEmailExists。
|
||||
func (s *AccountStore) Create(ctx context.Context, acct *Account) error {
|
||||
if err := acct.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("email = ?", acct.Email).
|
||||
First(&Account{}).Error
|
||||
if err == nil {
|
||||
return ErrEmailExists
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return s.db.WithContext(ctx).Create(acct).Error
|
||||
}
|
||||
|
||||
// FindByID 依主鍵查詢帳號,查無資料時回傳 ErrNotFound。
|
||||
func (s *AccountStore) FindByID(ctx context.Context, id uint) (*Account, error) {
|
||||
var acct Account
|
||||
err := s.db.WithContext(ctx).First(&acct, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &acct, nil
|
||||
}
|
||||
|
||||
// FindByEmail 依 email 查詢帳號(不分大小寫),查無資料時回傳 ErrNotFound。
|
||||
func (s *AccountStore) FindByEmail(ctx context.Context, email string) (*Account, error) {
|
||||
var acct Account
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("email = ?", strings.ToLower(strings.TrimSpace(email))).
|
||||
First(&acct).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &acct, nil
|
||||
}
|
||||
|
||||
// List 分頁列出帳號(新註冊在前),回傳帳號清單與符合條件的總數;page 從 1 開始。
|
||||
func (s *AccountStore) List(ctx context.Context, page, pageSize int) ([]Account, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaxPageSize {
|
||||
pageSize = MaxPageSize
|
||||
}
|
||||
var total int64
|
||||
if err := s.db.WithContext(ctx).Model(&Account{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var accounts []Account
|
||||
err := s.db.WithContext(ctx).
|
||||
Order("id DESC").
|
||||
Limit(pageSize).
|
||||
Offset((page - 1) * pageSize).
|
||||
Find(&accounts).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return accounts, total, nil
|
||||
}
|
||||
|
||||
// Update 驗證並儲存整個帳號;目標不存在時回傳 ErrNotFound,
|
||||
// email 改成其他帳號已使用的值時回傳 ErrEmailExists。
|
||||
func (s *AccountStore) Update(ctx context.Context, acct *Account) error {
|
||||
if err := acct.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("email = ? AND id <> ?", acct.Email, acct.ID).
|
||||
First(&Account{}).Error
|
||||
if err == nil {
|
||||
return ErrEmailExists
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
result := s.db.WithContext(ctx).Save(acct)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 軟刪除帳號,目標不存在時回傳 ErrNotFound。
|
||||
func (s *AccountStore) Delete(ctx context.Context, id uint) error {
|
||||
result := s.db.WithContext(ctx).Delete(&Account{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountRole_Valid(t *testing.T) {
|
||||
valid := []AccountRole{RoleMember, RoleOwner, RoleAgent, RoleAdmin, ""}
|
||||
invalid := []AccountRole{"superuser", "MEMBER", "member ", "0"}
|
||||
|
||||
for _, role := range valid {
|
||||
if got := role.Valid(); role != "" && !got {
|
||||
t.Errorf("AccountRole(%q).Valid() = false, want true", role)
|
||||
}
|
||||
}
|
||||
for _, role := range invalid {
|
||||
if role.Valid() {
|
||||
t.Errorf("AccountRole(%q).Valid() = true, want false", role)
|
||||
}
|
||||
}
|
||||
if !RoleMember.Valid() {
|
||||
t.Error("RoleMember.Valid() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_SetPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "合法密碼", password: "s3cret!pass"},
|
||||
{name: "剛好 8 字元", password: "12345678"},
|
||||
{name: "太短", password: "1234567", wantErr: ErrPasswordTooShort},
|
||||
{name: "空白", password: "", wantErr: ErrPasswordTooShort},
|
||||
{name: "太長", password: strings.Repeat("a", MaxPasswordLength+1), wantErr: ErrPasswordTooLong},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acct := &Account{}
|
||||
err := acct.SetPassword(tt.password)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("SetPassword() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr == nil {
|
||||
if acct.PasswordHash == "" {
|
||||
t.Fatal("SetPassword() 後 PasswordHash 為空")
|
||||
}
|
||||
if acct.PasswordHash == tt.password {
|
||||
t.Fatal("PasswordHash 不應儲存明文密碼")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_VerifyPassword(t *testing.T) {
|
||||
acct := &Account{}
|
||||
if err := acct.SetPassword("s3cret!pass"); err != nil {
|
||||
t.Fatalf("SetPassword() error = %v", err)
|
||||
}
|
||||
|
||||
if !acct.VerifyPassword("s3cret!pass") {
|
||||
t.Error("VerifyPassword(正確密碼) = false, want true")
|
||||
}
|
||||
if acct.VerifyPassword("wrong-pass") {
|
||||
t.Error("VerifyPassword(錯誤密碼) = true, want false")
|
||||
}
|
||||
if acct.VerifyPassword("") {
|
||||
t.Error("VerifyPassword(空字串) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_VerifyPassword_每次雜湊不同(t *testing.T) {
|
||||
a1, a2 := &Account{}, &Account{}
|
||||
if err := a1.SetPassword("same-password"); err != nil {
|
||||
t.Fatalf("a1.SetPassword() error = %v", err)
|
||||
}
|
||||
if err := a2.SetPassword("same-password"); err != nil {
|
||||
t.Fatalf("a2.SetPassword() error = %v", err)
|
||||
}
|
||||
if a1.PasswordHash == a2.PasswordHash {
|
||||
t.Error("相同密碼的兩次雜湊應不同(bcrypt 應加鹽)")
|
||||
}
|
||||
if !a1.VerifyPassword("same-password") || !a2.VerifyPassword("same-password") {
|
||||
t.Error("兩個帳號都應能以原始密碼通過驗證")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Validate(t *testing.T) {
|
||||
valid := func() *Account {
|
||||
acct := &Account{Email: "dan@example.com", Name: "Dan"}
|
||||
if err := acct.SetPassword("s3cret!pass"); err != nil {
|
||||
t.Fatalf("SetPassword() error = %v", err)
|
||||
}
|
||||
return acct
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Account)
|
||||
wantErr error
|
||||
}{
|
||||
{name: "合法帳號", mutate: func(*Account) {}},
|
||||
{
|
||||
name: "缺 email",
|
||||
mutate: func(a *Account) { a.Email = "" },
|
||||
wantErr: ErrEmailRequired,
|
||||
},
|
||||
{
|
||||
name: "email 格式錯誤",
|
||||
mutate: func(a *Account) { a.Email = "not-an-email" },
|
||||
wantErr: ErrEmailInvalid,
|
||||
},
|
||||
{
|
||||
name: "email 過長",
|
||||
mutate: func(a *Account) { a.Email = strings.Repeat("a", 250) + "@example.com" },
|
||||
wantErr: ErrEmailInvalid,
|
||||
},
|
||||
{
|
||||
name: "缺 name",
|
||||
mutate: func(a *Account) { a.Name = "" },
|
||||
wantErr: ErrNameRequired,
|
||||
},
|
||||
{
|
||||
name: "name 過長",
|
||||
mutate: func(a *Account) { a.Name = strings.Repeat("名", 101) },
|
||||
wantErr: ErrNameTooLong,
|
||||
},
|
||||
{
|
||||
name: "role 不合法",
|
||||
mutate: func(a *Account) { a.Role = "hacker" },
|
||||
wantErr: ErrRoleInvalid,
|
||||
},
|
||||
{
|
||||
name: "phone 過長",
|
||||
mutate: func(a *Account) { a.Phone = strings.Repeat("0", 31) },
|
||||
wantErr: ErrPhoneTooLong,
|
||||
},
|
||||
{
|
||||
name: "avatar_url 過長",
|
||||
mutate: func(a *Account) { a.AvatarURL = strings.Repeat("x", 513) },
|
||||
wantErr: ErrAvatarURLTooLong,
|
||||
},
|
||||
{
|
||||
name: "未設定密碼",
|
||||
mutate: func(a *Account) { a.PasswordHash = "" },
|
||||
wantErr: ErrPasswordRequired,
|
||||
},
|
||||
{
|
||||
name: "所有合法角色可通過",
|
||||
mutate: func(a *Account) {
|
||||
a.Role = RoleAdmin
|
||||
a.Phone = "0912345678"
|
||||
a.AvatarURL = "https://example.com/a.png"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acct := valid()
|
||||
tt.mutate(acct)
|
||||
err := acct.Validate()
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Normalize(t *testing.T) {
|
||||
acct := &Account{
|
||||
Email: " Dan@Example.COM ",
|
||||
Name: " 陳大同 ",
|
||||
Phone: " 0912345678 ",
|
||||
AvatarURL: " https://example.com/a.png ",
|
||||
}
|
||||
acct.Normalize()
|
||||
|
||||
if acct.Email != "dan@example.com" {
|
||||
t.Errorf("Email = %q, want %q", acct.Email, "dan@example.com")
|
||||
}
|
||||
if acct.Name != "陳大同" {
|
||||
t.Errorf("Name = %q, want %q", acct.Name, "陳大同")
|
||||
}
|
||||
if acct.Phone != "0912345678" {
|
||||
t.Errorf("Phone = %q, want %q", acct.Phone, "0912345678")
|
||||
}
|
||||
if acct.AvatarURL != "https://example.com/a.png" {
|
||||
t.Errorf("AvatarURL = %q, want %q", acct.AvatarURL, "https://example.com/a.png")
|
||||
}
|
||||
if acct.Role != RoleMember {
|
||||
t.Errorf("空角色應預設為 RoleMember, got %q", acct.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Normalize_不覆寫已設角色(t *testing.T) {
|
||||
acct := &Account{Role: RoleAgent}
|
||||
acct.Normalize()
|
||||
if acct.Role != RoleAgent {
|
||||
t.Errorf("Role = %q, want %q", acct.Role, RoleAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_IsAdmin(t *testing.T) {
|
||||
admin := &Account{Role: RoleAdmin}
|
||||
member := &Account{Role: RoleMember}
|
||||
if !admin.IsAdmin() {
|
||||
t.Error("admin.IsAdmin() = false, want true")
|
||||
}
|
||||
if member.IsAdmin() {
|
||||
t.Error("member.IsAdmin() = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package storage 負責資料庫連線的建立與初始化。
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Open 以 dsn 開啟 SQLite 資料庫(glebarez/sqlite 為純 Go driver,無需 cgo)。
|
||||
// Postgres 支援待正式環境導入時再透過 driver 抽象銜接。
|
||||
func Open(dsn string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: 開啟資料庫 %q: %w", dsn, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package templates 負責載入與渲染 html/template 模板。
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
)
|
||||
|
||||
// layoutFile 基礎版型,定義 "layout" 模板並引用各頁面的 "content" 區塊。
|
||||
const layoutFile = "layout.html"
|
||||
|
||||
// Engine 依頁面名稱持有各自獨立的模板集合(layout + 單一頁面),
|
||||
// 讓每個頁面都能定義自己的 "content"、"title" 區塊而不互相覆蓋。
|
||||
type Engine struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// New 載入 fsys 根目錄的 layout.html 與 pages/ 下所有頁面模板。
|
||||
func New(fsys fs.FS) (*Engine, error) {
|
||||
pageFiles, err := fs.Glob(fsys, path.Join("pages", "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("templates: pages/ 下找不到任何模板")
|
||||
}
|
||||
e := &Engine{pages: make(map[string]*template.Template, len(pageFiles))}
|
||||
for _, page := range pageFiles {
|
||||
t, err := template.New(layoutFile).ParseFS(fsys, layoutFile, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("templates: 解析 %s: %w", page, err)
|
||||
}
|
||||
e.pages[path.Base(page)] = t
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Render 先將頁面完整渲染至緩衝區再以 status 寫出,
|
||||
// 避免中途失敗時輸出殘缺的 HTML。頁面名稱為檔名,如 "login.html"。
|
||||
func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) error {
|
||||
t, ok := e.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("templates: 找不到頁面 %q", page)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("templates: 渲染 %q: %w", page, err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user