如何使用 Go、HTMX 和 Permit.io 在书店管理系统中设置授权

2025-06-07

如何使用 Go、HTMX 和 Permit.io 在书店管理系统中设置授权

在构建应用程序时,授权非常重要,因为它决定了用户在经过身份验证后可以访问哪些操作和资源。

在本文中,我们将研究如何使用 permit.io 实现授权。为了演示,我们将使用 Golang 和 HTMX(我是它们的忠实粉丝)构建一个简单的书店应用。

目录

先决条件

要完成本教程,应满足以下先决条件:

  • 安装 Golang 并对其有基本的了解。
  • Permit.io 帐户。
  • Docker 已安装。
  • 对 HTML、HTTP 和 REST API 有基本的了解。
  • PostgreSQL(数据库)。
  • 熟悉 SQL。

项目范围

  • 为了演示方便,我们将尽量简化。我们将设置两种用户类型:管理员和标准用户。两者都将在 Permit.io 上注册。登录后,数据库将查询 Permit.io 以确定用户的角色并授权其操作。

所有用户(包括管理员)均可阅读书籍。管理员还可以添加、删除和更新书籍。普通用户仅限于阅读书籍。

本教程将指导您设置一个具有基本授权的书店应用。我们将实现:

  • 授权逻辑:使用 Permit.io 定义角色(管理员和标准用户)来限制或授予对不同资源的访问权限。

  • 数据库:设置PostgreSQL数据库,用于存储书籍和用户数据。

  • 处理程序:通过访问控制检查实现查看、添加、更新和删除书籍的路线。

  • 前端:使用 HTMX 动态加载书籍数据。

项目设置

在设置项目时,我们首先要设置 permit.io。导航到你的仪表板工作区并创建一个新项目。我将它命名为bookstore

创建项目

这将创建两个环境:开发环境和生产环境。

许可证创建的环境

由于我们在本地工作,我们将使用开发环境。在开发环境中,点击“打开仪表板”,然后点击“创建策略”。系统会要求您先创建一个新资源。点击“创建资源”。为其命名并说明操作。在本项目中,我将我的资源命名为“books”,操作包括创建、更新、删除和查看。

创建新资源

接下来,导航到策略编辑器部分。默认情况下,您应该看到已创建管理员角色。您只需勾选我们添加的查看操作,因为默认情况下无法识别该操作。您需要另一个角色。该角色将仅供具有读取权限的用户使用。

Create然后点击Role,并将其命名为user。创建后,您应该在策略编辑器中看到它,并勾选您刚刚创建的view角色user,如下所示:

创建策略编辑器

接下来是注册需要 permit.io 授权的用户。通过侧边栏菜单导航回主菜单,你应该仍然会看到类似这样的内容:

家

点击Add users,然后添加,再点击add user。填写与数据库中用户对应的详细信息。

完成后,返回你的项目。在Development书店项目环境中,点击三个点组成的图标。你会看到一个复制 API 密钥的选项。复制并保存到某个地方,因为项目需要用到它。

设置数据库

创建一个名为 的 PostgreSQL 数据库bookstore。您需要设置两个表:

  • 用户表:存储用户凭证和角色:

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

继续填充,但让每个用户分别具有管理员和用户的角色,并确保他们与在 Permit.io 上添加的用户相匹配。

  • 书籍表:存储书籍详细信息:

CREATE TABLE books (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title VARCHAR(255) NOT NULL,
  author VARCHAR(255) NOT NULL,
  published_at DATE,
  created_at TIMESTAMPTZ DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

您不需要填充这个,我们会在代码中执行此操作。

安装依赖项

您需要安装以下依赖项:

  • github.com/permitio/permit-golang:提供在 Go 应用程序中使用 Permit.io 处理基于角色的访问控制 (RBAC) 和权限管理的工具。

  • github.com/google/uuid:提供生成和使用通用唯一标识符 (UUID) 的功能。

  • github.com/gorilla/mux:帮助实现 HTTP 请求路由器和调度程序,以处理 Web 应用程序中的路由。

  • github.com/joho/godotenv:这会将 . 文件中的环境变量加载.env到应用程序中,从而更容易管理配置设置。

  • github.com/lib/pq:这是 Go 的 Postgres 驱动程序,用于与 PostgreSQL 数据库通信。

  • golang.org/x/crypto:实现 Go 标准库中未包含的补充加密算法和库。

要安装这些依赖项,您需要初始化一个新的 Go 模块。这是 Go 中依赖项管理的起点。

运行此命令:

go mod init bookstore
Enter fullscreen mode Exit fullscreen mode

接下来运行此命令:


go get github.com/google/uuid \
       github.com/gorilla/mux \
       github.com/joho/godotenv \
       github.com/lib/pq \
       github.com/permitio/permit-golang \
       golang.org/x/crypto
Enter fullscreen mode Exit fullscreen mode

这将安装上面列出的所有依赖项。

设置您的 PDP(策略决策点)容器

要设置PDP,你需要启动 docker。启动后,打开终端并运行以下命令:

docker pull permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

之后您需要使用以下命令运行容器:

 docker run -it -p 7766:7000 --env PDP_DEBUG=True --env PDP_API_KEY=<YOUR_API_KEY> permitio/pdp-v2:latest
Enter fullscreen mode Exit fullscreen mode

将上述部分替换<YOUR_API_KEY>为你的实际 API 密钥。现在,我们开始构建吧。

构建应用程序

为了构建应用程序,我们的项目结构将是这样的:


Bookstore                
├── config               
│   └── config.go        
├── handlers             
│   └── handlers.go      
├── middleware           
│   └── middleware.go    
├── models               
│   └── models.go        
├── templates            
│   ├── add.html         
│   ├── books.html       
│   ├── index.html       
│   ├── layout.html      
│   ├── login.html       
│   └── update.html      
├── main.go              
└── .env
Enter fullscreen mode Exit fullscreen mode

首先,我们需要在 .env 文件中添加 API 密钥。创建一个 .env 文件,然后像这样添加您的许可 API 密钥:

export PERMIT_API_KEY=”your_api_key”
Enter fullscreen mode Exit fullscreen mode

配置数据库连接

创建一个名为 的文件夹config。在其中创建一个名为 的文件config.go。添加以下代码:


package config

import (
  "database/sql"
  "fmt"

  _ "github.com/lib/pq"
)

type Config struct {
  DB       *sql.DB
  Port     string
  DBConfig PostgresConfig
}

type PostgresConfig struct {
  Host     string
  Port     string
  User     string
  Password string
  DBName   string
}

func NewConfig() *Config {
  return &Config{
    Port: "8080",
    DBConfig: PostgresConfig{
      Host:     "localhost",
      Port:     "5432",
      User:     "bookstore_user",
      Password: "your_password",
      DBName:   "bookstore_db",
    },
  }
}

func (c *Config) ConnectDB() error {
  connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
    c.DBConfig.Host,
    c.DBConfig.Port,
    c.DBConfig.User,
    c.DBConfig.Password,
    c.DBConfig.DBName,
  )

  db, err := sql.Open("postgres", connStr)
  if err != nil {
    return fmt.Errorf("error opening database: %v", err)
  }

  if err := db.Ping(); err != nil {
    return fmt.Errorf("error connecting to database: %v", err)
  }

  c.DB = db
  return nil
}
Enter fullscreen mode Exit fullscreen mode

这只是我们设置一个配置来连接到 PostgreSQL 数据库。

创建处理程序

接下来,创建一个名为 handlers 的文件夹,并在其中创建一个名为 handlers.go 的文件。在其中添加以下代码:


package handlers

import (
  "bookstore/middleware"
  "bookstore/models" 
  "context"
  "database/sql"
  "fmt"
  "html/template"
  "net/http"
  "strings"
  "time"

  "github.com/google/uuid"
  "github.com/permitio/permit-golang/pkg/config"
  "github.com/permitio/permit-golang/pkg/enforcement"
  permitModels "github.com/permitio/permit-golang/pkg/models"
  "github.com/permitio/permit-golang/pkg/permit"
)

var tmpl = template.Must(template.ParseGlob("templates/*.html"))

func StringPtr(s string) *string {
  return &s
}

type Handlers struct {
  db           *sql.DB
  permitClient *permit.Client
}

func NewHandlers(db *sql.DB, apiKey string) *Handlers {
  permitConfig := config.NewConfigBuilder(apiKey).
    WithPdpUrl("http://localhost:7766").
    Build()
  permitClient := permit.NewPermit(permitConfig)
  if permitClient == nil {
    panic("Failed to initialize Permit.io client")
  }

  return &Handlers{
    db:           db,
    permitClient: permitClient,
  }
}
Enter fullscreen mode Exit fullscreen mode

除了导入软件包之外,我们还要创建一个结构体来保存数据库连接和 permit.io。我们还提供了一个初始化函数,用于使用本地 PDP 设置 Permit.io。

添加完后立即NewHandlers输入:


func (h *Handlers) LoginHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                if r.Method == http.MethodGet {
                        if err := tmpl.ExecuteTemplate(w, "login.html", nil); err != nil {
                                http.Error(w, "Error rendering template", http.StatusInternalServerError)
                                return
                        }
                        return
                }

                username := r.FormValue("username")
                password := r.FormValue("password")

                user, err := middleware.LoginUser(h.db, username, password)
                if err != nil {
                        http.Error(w, "Invalid login credentials", http.StatusUnauthorized)
                        return
                }
                role := user.Role

                http.SetCookie(w, &http.Cookie{
                        Name:     "username",
                        Value:    username,
                        Path:     "/",
                        Expires:  time.Now().Add(24 * time.Hour),
                        HttpOnly: true,
                        Secure:   r.TLS != nil,
                })

                ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
                defer cancel()

                permitUser := permitModels.NewUserCreate(username)
                permitUser.SetAttributes(map[string]interface{}{
                        "role": role,
                })

                _, _ = h.permitClient.SyncUser(ctx, *permitUser)

                data := struct {
                        Username string
                        Role     string
                }{
                        Username: username,
                        Role:     role,
                }

                if err := tmpl.ExecuteTemplate(w, "index.html", data); err != nil {
                        http.Error(w, "Error displaying page", http.StatusInternalServerError)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

执行LoginHandler以下操作:

  • 处理 GET(显示登录表单)和 POST(处理登录)。
  • 根据数据库验证用户身份。
  • 为经过身份验证的用户设置会话 cookie。
  • 将用户数据与 Permit.io 同步以进行授权。
  • 根据登录成功/失败呈现适当的模板。

下一步是添加一个图书处理程序来访问图书。它还将使用 permit.io 来验证用户的角色。在 LoginHandler 之后添加以下代码:


func (h *Handlers) BooksHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "view", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        http.Error(w, "Access denied", http.StatusForbidden)
                        return
                }

                rows, err := h.db.Query("SELECT id, title, author, published_at, created_at FROM books")
                if err != nil {
                        http.Error(w, "Error fetching books", http.StatusInternalServerError)
                        return
                }
                defer rows.Close()

                var books []models.Book
                for rows.Next() {
                        var book models.Book
                        var publishedAt sql.NullTime

                        err := rows.Scan(&book.ID, &book.Title, &book.Author, &publishedAt, &book.CreatedAt)
                        if err != nil {
                                http.Error(w, "Error reading book data", http.StatusInternalServerError)
                                return
                        }

                        if publishedAt.Valid {
                                book.PublishedAt = &publishedAt.Time
                        }

                        books = append(books, book)
                }

                if err = rows.Err(); err != nil {
                        http.Error(w, "Error reading book data", http.StatusInternalServerError)
                        return
                }

                if err := tmpl.ExecuteTemplate(w, "books.html", books); err != nil {
                        http.Error(w, "Error displaying books", http.StatusInternalServerError)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

执行BookHandler以下操作:

  • 通过 cookie 检查用户身份验证。
  • 使用 Permit.io 验证用户角色和权限。
  • 如果获得授权,则从数据库中获取书籍。
  • 使用获取的数据呈现书籍模板。
  • 适当处理授权失败。

接下来,您需要一个处理程序来添加书籍。它还将通过 Permit.io 验证用户的角色,以确保只有授权用户才能添加书籍:


func (h *Handlers) AddBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
        func (h *Handlers) AddBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "create", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        w.Header().Set("Content-Type", "text/html")
                        fmt.Fprint(w, `
                                <!DOCTYPE html>
                                <html lang="en">
                                <head>
                                        <meta charset="UTF-8">
                                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                                        <title>Access Denied</title>
                                        <script>alert('You cannot add a book as you are an admin.')</script>
                                </head>
                                <body>
                                        <p>You do not have permission to add books.</p>
                                        <a href="/books">Back to Books</a>
                                </body>
                                </html>
                        `)
                        return
                }

                if r.Method == http.MethodGet {
                        if err := tmpl.ExecuteTemplate(w, "add.html", nil); err != nil {
                                http.Error(w, "Error displaying page", http.StatusInternalServerError)
                        }
                        return
                }

                if r.Method == http.MethodPost {
                        title := strings.TrimSpace(r.FormValue("title"))
                        author := strings.TrimSpace(r.FormValue("author"))
                        publishedAt := strings.TrimSpace(r.FormValue("published_at"))

                        var pubDate sql.NullTime
                        if publishedAt != "" {
                                parsedDate, err := time.Parse("2006-01-02", publishedAt)
                                if err == nil {
                                        pubDate = sql.NullTime{Time: parsedDate, Valid: true}
                                }
                        }

                        _, err := h.db.Exec("INSERT INTO books (id, title, author, published_at, created_at) VALUES ($1, $2, $3, $4, NOW())",
                                uuid.New(), title, author, pubDate)
                        if err != nil {
                                http.Error(w, "Error adding book", http.StatusInternalServerError)
                                return
                        }

                        http.Redirect(w, r, "/books", http.StatusSeeOther)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

执行AddBookHandler以下操作:

  • 检查用户创建书籍的权限。
  • 处理 GET(显示表单)和 POST(添加书籍)。
  • 验证输入数据。
  • 为新书生成 UUID。
  • 处理出版日期的日期解析。
  • 添加成功后重定向到书籍列表。

您还需要两个处理程序,一个用于删除,另一个用于更新。在 AddBookHandler 函数之后添加以下代码:


func (h *Handlers) DeleteBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "delete", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        http.Error(w, "Access denied", http.StatusForbidden)
                        return
                }

                bookIDStr := r.FormValue("id")
                bookID, err := uuid.Parse(bookIDStr)
                if err != nil {
                        http.Error(w, "Invalid book ID", http.StatusBadRequest)
                        return
                }

                _, err = h.db.Exec("DELETE FROM books WHERE id = $1", bookID)
                if err != nil {
                        http.Error(w, "Error deleting book", http.StatusInternalServerError)
                        return
                }

                http.Redirect(w, r, "/books", http.StatusSeeOther)
        }
}
Enter fullscreen mode Exit fullscreen mode

执行DeleteBookHandler以下操作:

  • 验证用户的删除权限。
  • 验证书ID.
  • 执行数据库删除。
  • 处理错误并适当重定向。

在该函数之后立即DeleteBookHandler添加以下内容:


func (h *Handlers) UpdateBookHandler() http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
                cookie, err := r.Cookie("username")
                if err != nil {
                        http.Error(w, "Unauthorized access: no username found", http.StatusUnauthorized)
                        return
                }
                username := cookie.Value

                role, err := middleware.GetUserRole(h.db, username)
                if err != nil {
                        http.Error(w, "Error retrieving user role", http.StatusInternalServerError)
                        return
                }

                user := enforcement.UserBuilder(username).
                        WithAttributes(map[string]interface{}{
                                "role": role,
                        }).
                        Build()

                resource := enforcement.ResourceBuilder("books").
                        WithTenant("default").
                        Build()

                permitted, err := h.permitClient.Check(user, "update", resource)
                if err != nil {
                        http.Error(w, "Error checking permissions", http.StatusInternalServerError)
                        return
                }

                if !permitted {
                        w.Header().Set("Content-Type", "text/html")
                        fmt.Fprint(w, `
                                <!DOCTYPE html>
                                <html lang="en">
                                <head>
                                        <meta charset="UTF-8">
                                        <meta name="viewport" content="width=device-width, initial-scale=1.0">
                                        <title>Access Denied</title>
                                        <script>alert('You are not authorized to update books.')</script>
                                </head>
                                <body>
                                        <p>Access Denied. You do not have permission to update books.</p>
                                        <a href="/books">Back to Books</a>
                                </body>
                                </html>
                        `)
                        return
                }

                if r.Method == http.MethodGet {
                        bookID := r.FormValue("id")
                        var book models.Book
                        err := h.db.QueryRow("SELECT id, title, author, published_at FROM books WHERE id = $1", bookID).
                                Scan(&book.ID, &book.Title, &book.Author, &book.PublishedAt)
                        if err != nil {
                                http.Error(w, "Error fetching book", http.StatusInternalServerError)
                                return
                        }

                        if err := tmpl.ExecuteTemplate(w, "update.html", book); err != nil {
                                http.Error(w, "Error displaying update page", http.StatusInternalServerError)
                        }
                        return
                }

                if r.Method == http.MethodPost {
                        bookID := r.FormValue("id")
                        title := r.FormValue("title")
                        author := r.FormValue("author")
                        publishedAt := r.FormValue("published_at")

                        var pubDate sql.NullTime
                        if publishedAt != "" {
                                parsedDate, err := time.Parse("2006-01-02", publishedAt)
                                if err == nil {
                                        pubDate = sql.NullTime{Time: parsedDate, Valid: true}
                                }
                        }

                        _, err := h.db.Exec("UPDATE books SET title = $1, author = $2, published_at = $3 WHERE id = $4",
                                title, author, pubDate, bookID)
                        if err != nil {
                                http.Error(w, "Error updating book", http.StatusInternalServerError)
                                return
                        }

                        http.Redirect(w, r, "/books", http.StatusSeeOther)
                }
        }
}
Enter fullscreen mode Exit fullscreen mode

执行UpdateHandler以下操作:

  • 检查更新权限。
  • 处理 GET(显示编辑表单)和 POST(更新书籍)。
  • 获取现有书籍数据进行编辑。
  • 验证并处理更新。
  • 处理日期格式和数据库更新。

在整个代码中,您会注意到授权系统是围绕 Permit.io 的基于角色的访问控制框架构建的,该框架提供了复杂的权限管理。

该系统还能对用户操作进行细粒度控制,并允许不同级别的访问权限来查看、创建、更新和删除资源。应用程序中的每个操作都经过详细的权限检查,确保用户只能执行其被授权的操作。

创建授权中间件

现在我们已经完成了处理程序。创建一个名为 middleware 的文件夹,并在其中创建一个名为 的文件middleware.go。添加以下代码:


package middleware

import (
        "bookstore/models"
        "database/sql"
        "fmt"
        "github.com/google/uuid"
        "golang.org/x/crypto/bcrypt"
        "time"
)

func HashPassword(password string) (string, error) {
        bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
        return string(bytes), err
}

func CheckPasswordHash(password, hash string) bool {
        err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
        return err == nil
}

func LoginUser(db *sql.DB, username, password string) (*models.User, error) {
        fmt.Printf("Attempting login for username: %s\n", username)

        var user models.User
        var passwordHash string

        err := db.QueryRow(`
       SELECT id, username, password_hash, role, email, first_name, last_name, created_at
       FROM users
       WHERE username = $1
   `, username).Scan(
                &user.ID,
                &user.Username,
                &passwordHash,
                &user.Role,
                &user.Email,
                &user.FirstName,
                &user.LastName,
                &user.CreatedAt,
        )

        if err != nil {
                if err == sql.ErrNoRows {
                        fmt.Printf("No user found with username: %s\n", username)
                        return nil, fmt.Errorf("invalid credentials")
                }
                fmt.Printf("Database error: %v\n", err)
                return nil, err
        }

        if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
                fmt.Printf("Password comparison failed: %v\n", err)
                return nil, fmt.Errorf("invalid credentials")
        }

        user.PasswordHash = ""
        return &user, nil
}

func GetBooks(db *sql.DB) ([]models.Book, error) {
        rows, err := db.Query(`
       SELECT id, title, author, published_at, created_by, created_at
       FROM books
       ORDER BY created_at DESC
   `)
        if err != nil {
                return nil, err
        }
        defer rows.Close()

        var books []models.Book
        for rows.Next() {
                var book models.Book
                err := rows.Scan(
                        &book.ID,
                        &book.Title,
                        &book.Author,
                        &book.PublishedAt,
                        &book.CreatedAt,
                )
                if err != nil {
                        return nil, err
                }
                books = append(books, book)
        }

        return books, nil
}

func CreateBook(db *sql.DB, book *models.Book) error {
        book.ID = uuid.New()
        book.CreatedAt = time.Now()

        _, err := db.Exec(`
       INSERT INTO books (id, title, author, published_at, created_by, created_at)
       VALUES ($1, $2, $3, $4, $5, $6)
   `,
                book.ID,
                book.Title,
                book.Author,
                book.PublishedAt,
                book.CreatedAt,
        )

        return err
}

func UpdateBook(db *sql.DB, book *models.Book) error {
        result, err := db.Exec(`
       UPDATE books
       SET title = $1, author = $2, published_at = $3
       WHERE id = $4 AND created_by = $5
   `,
                book.Title,
                book.Author,
                book.PublishedAt,
                book.ID,
        )
        if err != nil {
                return err
        }

        rowsAffected, err := result.RowsAffected()
        if err != nil {
                return err
        }

        if rowsAffected == 0 {
                return fmt.Errorf("book not found or user not authorized")
        }

        return nil
}

func DeleteBook(db *sql.DB, bookID uuid.UUID, userID uuid.UUID) error {
        result, err := db.Exec(`
       DELETE FROM books
       WHERE id = $1 AND created_by = $2
   `, bookID, userID)
        if err != nil {
                return err
        }

        rowsAffected, err := result.RowsAffected()
        if err != nil {
                return err
        }

        if rowsAffected == 0 {
                return fmt.Errorf("book not found or user not authorized")
        }

        return nil
}

func GetUserRole(db *sql.DB, username string) (string, error) {
        var role string
        err := db.QueryRow("SELECT role FROM users WHERE username = $1", username).Scan(&role)
        if err != nil {
                if err == sql.ErrNoRows {
                        return "", fmt.Errorf("no user found with username %s", username)
                }
                return "", err
        }
        return role, nil
}
Enter fullscreen mode Exit fullscreen mode

middleware软件包有助于提供安全的密码哈希和身份验证,以及用于在书店应用程序中管理图书的 CRUD 操作。它用于bcrypt对密码进行哈希处理以确保安全存储,并在登录时验证密码哈希值。它还可以防止敏感数据泄露。

LoginUser功能通过将用户输入与存储的密码哈希进行比较来对用户进行身份验证,并在成功登录时检索完整的用户配置文件,但不包括密码哈希以增加安全性。

此外,CRUD 操作允许您在数据库中创建、更新、检索和删除书籍记录,并通过访问控制来确保只有授权用户才能修改或删除其创建的条目。该软件包还包含一个 GetUserRole 函数来检索用户角色,从而实现基于角色的访问控制。

创建模型

创建另一个名为 的文件夹models,并在其中创建一个名为 的文件models.go。并添加以下内容:


package models

import (
        "time"

        "database/sql/driver"

        "github.com/google/uuid"
)

type NullUUID struct {
        UUID  uuid.UUID
        Valid bool
}

func (n *NullUUID) Scan(value interface{}) error {
        if value == nil {
                n.UUID, n.Valid = uuid.UUID{}, false
                return nil
        }
        n.Valid = true
        return n.UUID.Scan(value)
}

func (n NullUUID) Value() (driver.Value, error) {
        if !n.Valid {
                return nil, nil
        }
        return n.UUID[:], nil
}

type User struct {
        ID           uuid.UUID `json:"id"`
        Username     string    `json:"username"`
        PasswordHash string    `json:"-"`
        Role         string    `json:"role"`
        Email        string    `json:"email"`
        FirstName    string    `json:"first_name"`
        LastName     string    `json:"last_name"`
        CreatedAt    time.Time `json:"created_at"`
}

type Book struct {
        ID          uuid.UUID  `json:"id"`
        Title       string     `json:"title"`
        Author      string     `json:"author"`
        PublishedAt *time.Time `json:"published_at,omitempty"`

        CreatedAt time.Time `json:"created_at"`
}

type LoginRequest struct {
        Username string `json:"username"`
        Password string `json:"password"`
}
Enter fullscreen mode Exit fullscreen mode

该包为书店应用程序定义了几个数据模型,包括UserBookLoginRequest结构,以及用于NullUUID处理数据库中可空 UUID 的自定义类型。

快完成了。接下来你需要为你的项目创建模板。你需要创建用于登录和索引的模板,以及用于添加图书、查看图书、删除图书和更新图书的模板。

创建 HTML 模板

创建一个名为 的文件夹templates。你的 HTML 模板将存放在这里。
对于登录,创建一个名为 的文件login.html,,并在其中粘贴以下内容:


    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Login</title>
      </head>
      <body>
        <h2>Login</h2>
        <form method="POST" action="/login">
          <label for="username">Username:</label>
          <input type="text" id="username" name="username" required /><br />
          <label for="password">Password:</label>
          <input type="password" id="password" name="password" required /><br />
          <button type="submit">Login</button>
        </form>
      </body>
    </html>

For index, create a file called `index.html`. Add this:


    <!doctype html>
    <html>
      <head>
        <title>Welcome</title>
      </head>
      <body>
        <h1>Welcome {{.Username}}!</h1>
        <p>You are logged in successfully.</p>
        <p>Your role is: {{.Role}}</p>
        <a href="/books">Go to Books</a>
        <br />
        <a href="/add">Add Book</a>
      </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

为了查看书籍,请创建一个名为 的文件books.html。添加以下内容:


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Books</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
    />
  </head>
  <body class="bg-gray-100">
    <div class="container mx-auto px-4">
      <h1 class="text-3xl font-bold text-center my-8">Books</h1>
      <div class="text-center mb-4">
        <a
          href="/add"
          class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
          >Add Book</a
        >
      </div>

      {{if eq (len .) 0}}
      <p class="text-center text-gray-600">No books to fetch</p>
      {{else}}
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {{range .}}
        <div class="bg-white shadow-md rounded-lg p-6 relative">
          <h2 class="text-xl font-bold mb-2">{{.Title}}</h2>
          <p><strong>Author:</strong> {{.Author}}</p>
          {{if .PublishedAt}}
          <p>
            <strong>Published Date:</strong> {{.PublishedAt.Format
            "2006-01-02"}}
          </p>
          {{else}}
          <p><strong>Published Date:</strong> Unknown</p>
          {{end}}
          <p><strong>Created At:</strong> {{.CreatedAt.Format "2006-01-02"}}</p>

          <!-- Update and Delete Buttons -->
          <div class="mt-4 flex space-x-2">
            <form action="/update" method="GET">
              <input type="hidden" name="id" value="{{.ID}}" />
              <button
                type="submit"
                class="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600 focus:outline-none"
              >
                Update
              </button>
            </form>
            <form action="/delete" method="POST">
              <input type="hidden" name="id" value="{{.ID}}" />
              <button
                type="submit"
                class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 focus:outline-none"
              >
                Delete
              </button>
            </form>
          </div>
        </div>
        {{end}}
      </div>
      {{end}}
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

要添加书籍,请创建一个名为 add.html 的文件。添加以下内容:


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Add a New Book</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
    />
  </head>
  <body class="bg-gray-100">
    <div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6 mt-10">
      <h2 class="text-2xl font-bold mb-6">Add a New Book</h2>

      <!-- Form to add a new book -->
      <form action="/add" method="POST">
        <div class="mb-4">
          <label class="block text-gray-700 text-sm font-bold mb-2" for="title"
            >Title</label
          >
          <input
            type="text"
            id="title"
            name="title"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            required
          />
        </div>

        <div class="mb-4">
          <label class="block text-gray-700 text-sm font-bold mb-2" for="author"
            >Author</label
          >
          <input
            type="text"
            id="author"
            name="author"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            required
          />
        </div>

        <div class="mb-6">
          <label
            class="block text-gray-700 text-sm font-bold mb-2"
            for="published_at"
            >Published Date</label
          >
          <input
            type="date"
            id="published_at"
            name="published_at"
            class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          />
        </div>

        <div class="flex items-center justify-between">
          <button
            type="submit"
            class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:shadow-outline"
          >
            Add Book
          </button>
        </div>
      </form>

      <!-- Link back to books page -->
      <div class="mt-4">
        <a href="/books" class="text-indigo-600 hover:underline"
          >Back to Books</a
        >
      </div>
    </div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

最后,您需要创建主文件,即main.go。它将位于根文件夹中。创建它并添加以下代码:


package main

import (
        "bookstore/handlers"
        "database/sql"
        "fmt"
        "log"
        "net/http"
        "os"

        "github.com/gorilla/mux"
        "github.com/joho/godotenv"
        _ "github.com/lib/pq"
)

func connectDB() *sql.DB {

        connStr := "user=bookstore_user password=1111 dbname=bookstore_db sslmode=disable"
        db, err := sql.Open("postgres", connStr)
        if err != nil {
                log.Fatal("Error connecting to the database:", err)
        }
        if err := db.Ping(); err != nil {
                log.Fatal("Cannot ping the database:", err)
        }
        fmt.Println("Successfully connected to the database!")
        return db
}

func main() {

        err := godotenv.Load()
        if err != nil {
                log.Print("Error loading .env file")
        }

        permitApiKey := os.Getenv("PERMIT_API_KEY")
        if permitApiKey == "" {
                log.Fatal("PERMIT_API_KEY environment variable is not set")
        }

        db := connectDB()
        defer db.Close()

        r := mux.NewRouter()

        h := handlers.NewHandlers(db, permitApiKey)

        r.HandleFunc("/login", h.LoginHandler()).Methods("GET", "POST")
        r.HandleFunc("/books", h.BooksHandler()).Methods("GET")
        r.HandleFunc("/add", h.AddBookHandler()).Methods("GET", "POST")
        r.HandleFunc("/delete", h.DeleteBookHandler()).Methods("POST")
        r.HandleFunc("/update", h.UpdateBookHandler()).Methods("GET", "POST")

        fmt.Println("Server starting on :8080")
        if err := http.ListenAndServe(":8080", r); err != nil {
                log. Fatal(err)
        }
}
Enter fullscreen mode Exit fullscreen mode

main包是书店应用程序的入口点。它设置数据库连接、环境配置以及用于处理用户登录和图书管理的 HTTP 路由。

在该main函数中,使用 Gorilla Mux 路由器注册路由。该handlers.NewHandlers函数使用数据库和 Permit.io API 密钥初始化处理程序。它启用了用户身份验证 ( /login) 和图书管理 ( /books, /add, /delete, /update) 等功能。每个路由都映射到特定的 HTTP 方法,从而组织不同操作的端点。

最后,服务器在端口 上启动8080,监听传入的请求并记录任何发生的错误。此设置可确保结构化的 API 端点配置和环境变量的安全处理。

测试应用程序

现在一切都完成了!让我们启动应用程序来查看结果。要启动服务器,请运行以下命令:

go run main.go
Enter fullscreen mode Exit fullscreen mode

http://localhost:8080/login在浏览器中访问。

让我们首先测试以下权限standard_user

测试 standard_user 的权限

您会发现 standard_user 仅限于查看书籍,而不能添加、删除或更新书籍。

现在让我们使用 admin_user 登录来看看会发生什么:

管理员用户

你会发现管理员几乎拥有所有权限!许可证就是这么可靠易用!

您可以查看这些资源来了解有关许可证授权的更多信息:

结论

在本教程中,我们构建了一个简单的书店管理应用程序,使用 Go、HTMX 和 Permit.io 实现基于角色的访问控制。授权是应用程序安全的一个基本方面,因为它可以确保用户只能访问他们被允许访问的内容。

在您的应用程序中实施有效的访问控制模型(如 RBAC 或 ABAC)不仅可以保护您的应用程序,还可以增强其可扩展性和合规性。

文章来源:https://dev.to/oyedeletemitope/how-to-set-up-authorization-in-a-bookstore-management-system-with-go-htmx-and-permitio-1nan
PREV
10 个 VS Code 扩展,最大程度提高前端开发效率
NEXT
如何在 Visual Studio Code 中显示 Nerd 字体