From e5100fa018e2955c3d649b70245e3a379658d7ba Mon Sep 17 00:00:00 2001 From: "henry.chen" Date: Thu, 17 Jul 2025 15:24:46 +0800 Subject: [PATCH] feat: twofactor bind --- cmd/eiblog/etc/app.yml | 1 + cmd/eiblog/etc/website/admin/login.html | 4 +-- cmd/eiblog/etc/website/admin/profile.html | 28 ++++++++++++++- cmd/eiblog/handler/admin/admin.go | 42 +++++++++++++++++++++- cmd/eiblog/handler/internal/internal.go | 5 +-- cmd/eiblog/handler/internal/store/rdbms.go | 6 +++- cmd/eiblog/handler/page/be.go | 34 +++++++++++++++++- go.mod | 1 + go.sum | 4 +++ pkg/config/enums.go | 8 ++--- pkg/model/account.go | 11 +++--- 11 files changed, 125 insertions(+), 19 deletions(-) diff --git a/cmd/eiblog/etc/app.yml b/cmd/eiblog/etc/app.yml index 2bd7ae6..4630503 100644 --- a/cmd/eiblog/etc/app.yml +++ b/cmd/eiblog/etc/app.yml @@ -19,6 +19,7 @@ general: # 常规配置 identifier: # 截取预览标识 length: 400 # 自动截取预览, 字符数 timezone: Asia/Shanghai # 时区 + twofactor: true # 是否启用两步验证 disqus: # 评论相关 shortname: xxxxxx publickey: wdSgxRm9rdGAlLKFcFdToBe3GT4SibmV7Y8EjJQ0r4GWXeKtxpopMAeIeoI2dTEg diff --git a/cmd/eiblog/etc/website/admin/login.html b/cmd/eiblog/etc/website/admin/login.html index cda3471..a23da9f 100644 --- a/cmd/eiblog/etc/website/admin/login.html +++ b/cmd/eiblog/etc/website/admin/login.html @@ -22,10 +22,10 @@

- +

{{end}}

diff --git a/cmd/eiblog/etc/website/admin/profile.html b/cmd/eiblog/etc/website/admin/profile.html index 0487e95..8b23131 100644 --- a/cmd/eiblog/etc/website/admin/profile.html +++ b/cmd/eiblog/etc/website/admin/profile.html @@ -7,11 +7,37 @@

- {{.Blogger.BlogName}} + {{.Blogger.BlogName}}

{{.Blogger.BlogName}}

{{.Blogger.SubTitle}}

最后登录: {{dateformat .Account.LoginAt "2006/01/02 15:04"}}

+ {{if .Account.TwoFactorSecret}} + 2FA 已绑定 解绑 + {{end}} + {{if $.TwoFactorSecret}} + 2FA 绑定 +
+
    +
  • + 2fa code +
  • +
+
    +
  • + +

    + 输入 2FA 验证码, 用于确认绑定.

    +
  • +
+
    +
  • + +
  • +
+
+ {{end}}
diff --git a/cmd/eiblog/handler/admin/admin.go b/cmd/eiblog/handler/admin/admin.go index fe208ab..2b19223 100644 --- a/cmd/eiblog/handler/admin/admin.go +++ b/cmd/eiblog/handler/admin/admin.go @@ -9,11 +9,13 @@ import ( "strings" "time" + "github.com/eiblog/eiblog/cmd/eiblog/config" "github.com/eiblog/eiblog/cmd/eiblog/handler/internal" "github.com/eiblog/eiblog/pkg/middleware" "github.com/eiblog/eiblog/pkg/model" "github.com/eiblog/eiblog/pkg/third/qiniu" "github.com/eiblog/eiblog/tools" + "github.com/pquerna/otp/totp" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" @@ -46,13 +48,14 @@ func RegisterRoutesAuthz(group gin.IRoutes) { group.POST("/api/trash-recover", handleAPITrashRecover) group.POST("/api/file-upload", handleAPIQiniuUpload) group.POST("/api/file-delete", handleAPIQiniuDelete) + group.POST("/api/twofactor", handleAPITwoFactor) } // handleAcctLogin 登录接口 func handleAcctLogin(c *gin.Context) { user := c.PostForm("user") pwd := c.PostForm("password") - // code := c.PostForm("code") // 二次验证 + code := c.PostForm("code") if user == "" || pwd == "" { logrus.Warnf("参数错误: %s %s", user, pwd) c.Redirect(http.StatusFound, "/admin/login") @@ -64,6 +67,16 @@ func handleAcctLogin(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/login") return } + // 两步验证 + if config.Conf.General.TwoFactor && + internal.Ei.Account.TwoFactorSecret != "" { + valid := totp.Validate(code, internal.Ei.Account.TwoFactorSecret) + if !valid { + logrus.Warnf("两步验证: %s", code) + c.Redirect(http.StatusFound, "/admin/login") + return + } + } // 登录成功 middleware.SetLogin(c, user) @@ -76,6 +89,33 @@ func handleAcctLogin(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/profile") } +// handleAPITwoFactor 两步验证 +func handleAPITwoFactor(c *gin.Context) { + code := c.PostForm("code") + if code == "" { + responseNotice(c, NoticeNotice, "验证码不能为空", "") + return + } + valid := totp.Validate(code, internal.TwoFactorSecret) + if !valid { + responseNotice(c, NoticeNotice, "验证码错误", "") + return + } + err := internal.Store.UpdateAccount(context.Background(), internal.Ei.Account.Username, + map[string]interface{}{ + "two_factor_secret": internal.TwoFactorSecret, + }) + if err != nil { + logrus.Error("handleAPITwoFactor.UpdateAccount: ", err) + responseNotice(c, NoticeNotice, err.Error(), "") + return + } + internal.Ei.Account.TwoFactorSecret = internal.TwoFactorSecret + internal.TwoFactorSecret = "" + c.Request.Header.Set("Referer", "/admin/profile") + responseNotice(c, NoticeSuccess, "绑定成功", "") +} + // handleAPIBlogger 更新博客信息 func handleAPIBlogger(c *gin.Context) { bn := c.PostForm("blogName") diff --git a/cmd/eiblog/handler/internal/internal.go b/cmd/eiblog/handler/internal/internal.go index 53e44c0..74b9e04 100644 --- a/cmd/eiblog/handler/internal/internal.go +++ b/cmd/eiblog/handler/internal/internal.go @@ -22,8 +22,9 @@ var ( XMLTemplate *template.Template // template/xml模板 HTMLTemplate *template.Template // website/html模板 - Store store.Store // 数据库存储 - Ei *Cache // 博客数据缓存 + Store store.Store // 数据库存储 + Ei *Cache // 博客数据缓存 + TwoFactorSecret string // 缓存两步验证密钥 ESClient *es.ESClient // es 客户端 DisqusClient *disqus.DisqusClient // disqus 客户端 diff --git a/cmd/eiblog/handler/internal/store/rdbms.go b/cmd/eiblog/handler/internal/store/rdbms.go index a15e0d1..476f65a 100644 --- a/cmd/eiblog/handler/internal/store/rdbms.go +++ b/cmd/eiblog/handler/internal/store/rdbms.go @@ -8,6 +8,7 @@ import ( "github.com/eiblog/eiblog/pkg/model" + "github.com/sirupsen/logrus" "gorm.io/driver/clickhouse" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -63,12 +64,15 @@ func (db *rdbms) Init(name, source string) (Store, error) { return nil, err } // auto migrate - gormDB.AutoMigrate( + err = gormDB.AutoMigrate( &model.Account{}, &model.Blogger{}, &model.Article{}, &model.Serie{}, ) + if err != nil { + logrus.Error("rdbms.AutoMigrate: ", err) + } db.DB = gormDB return db, nil } diff --git a/cmd/eiblog/handler/page/be.go b/cmd/eiblog/handler/page/be.go index ef18d9e..6fd6985 100644 --- a/cmd/eiblog/handler/page/be.go +++ b/cmd/eiblog/handler/page/be.go @@ -3,9 +3,11 @@ package page import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "html/template" + "image/png" "net/http" "strconv" @@ -13,6 +15,7 @@ import ( "github.com/eiblog/eiblog/cmd/eiblog/handler/internal" "github.com/eiblog/eiblog/cmd/eiblog/handler/internal/store" "github.com/eiblog/eiblog/pkg/middleware" + "github.com/pquerna/otp/totp" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" @@ -35,7 +38,11 @@ func handleLoginPage(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/profile") return } - params := gin.H{"BTitle": internal.Ei.Blogger.BTitle} + params := gin.H{ + "BTitle": internal.Ei.Blogger.BTitle, + "TwoFactor": config.Conf.General.TwoFactor && + internal.Ei.Account.TwoFactorSecret != "", + } renderHTMLAdminLayout(c, "login.html", params) } @@ -46,6 +53,31 @@ func handleAdminProfile(c *gin.Context) { params["Path"] = c.Request.URL.Path params["Console"] = true params["Ei"] = internal.Ei + if c.Query("unbind") == "true" { + internal.Ei.Account.TwoFactorSecret = "" + _ = internal.Store.UpdateAccount(context.Background(), internal.Ei.Account.Username, + map[string]interface{}{ + "two_factor_secret": "", + }) + } + if config.Conf.General.TwoFactor && + internal.Ei.Account.TwoFactorSecret == "" { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: config.Conf.Host, + AccountName: internal.Ei.Account.Username, + }) + internal.TwoFactorSecret = key.Secret() + if err == nil { + var buf bytes.Buffer + img, err := key.Image(200, 200) + if err != nil { + panic(err) + } + png.Encode(&buf, img) + b64 := base64.StdEncoding.EncodeToString(buf.Bytes()) + params["TwoFactorSecret"] = "data:image/png;base64," + b64 + } + } renderHTMLAdminLayout(c, "admin-profile", params) } diff --git a/go.mod b/go.mod index b4fdd87..c84fc33 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-contrib/sessions v0.0.4 github.com/gin-gonic/gin v1.9.1 github.com/lib/pq v1.10.9 + github.com/pquerna/otp v1.5.0 github.com/qiniu/go-sdk/v7 v7.11.0 github.com/satori/go.uuid v1.2.0 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index e9d9c33..53ffe65 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/aws/aws-sdk-go v1.34.28 h1:sscPpn/Ns3i0F4HPEWAVcwdIRaZZCuL7llJ2/60yPI github.com/aws/aws-sdk-go v1.34.28/go.mod h1:H7NKnBqNVzoTJpGfLrQkkD+ytBA93eiDYi/+8rV9s48= github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= @@ -280,6 +282,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= github.com/qiniu/go-sdk/v7 v7.11.0 h1:Cdx/1E3ybv0OFKnkGwoDN/t6bCCntjrWhwWuRaqI3XQ= github.com/qiniu/go-sdk/v7 v7.11.0/go.mod h1:btsaOc8CA3hdVloULfFdDgDc+g4f3TDZEFsDY0BLE+w= diff --git a/pkg/config/enums.go b/pkg/config/enums.go index 3433513..9ba469c 100644 --- a/pkg/config/enums.go +++ b/pkg/config/enums.go @@ -93,6 +93,8 @@ type General struct { Length int // 时区, 一般配置为 Asia/Shanghai Timezone string + // 是否启用两步验证 + TwoFactor bool } // Account 账户配置 @@ -101,12 +103,6 @@ type Account struct { Username string // *必须配置, 后台登录密码。登录后请后台立即修改 Password string - // 邮箱, 用于显示 - Email string - // 手机号, 用于显示 - PhoneNumber string - // 地址, 用于显示 - Address string } // Blogger 博客配置, 无需配置,程序默认初始化,可在后台更改 diff --git a/pkg/model/account.go b/pkg/model/account.go index 4edcc25..8647851 100644 --- a/pkg/model/account.go +++ b/pkg/model/account.go @@ -7,11 +7,12 @@ import "time" // Account 博客账户 type Account struct { - Username string `gorm:"column:username;primaryKey" bson:"username"` // 用户名 - Password string `gorm:"column:password;not null" bson:"password"` // 密码 - Email string `gorm:"column:email;not null" bson:"email"` // 邮件地址 - PhoneN string `gorm:"column:phone_n;not null" bson:"phone_n"` // 手机号 - Address string `gorm:"column:address;not null" bson:"address"` // 地址信息 + Username string `gorm:"column:username;primaryKey" bson:"username"` // 用户名 + Password string `gorm:"column:password;not null" bson:"password"` // 密码 + Email string `gorm:"column:email;not null" bson:"email"` // 邮件地址 + PhoneN string `gorm:"column:phone_n;not null" bson:"phone_n"` // 手机号 + Address string `gorm:"column:address;not null" bson:"address"` // 地址信息 + TwoFactorSecret string `gorm:"column:two_factor_secret" bson:"two_factor_secret"` // 两步验证密钥 LogoutAt time.Time `gorm:"column:logout_at;not null" bson:"logout_at"` // 登出时间 LoginIP string `gorm:"column:login_ip;not null" bson:"login_ip"` // 最近登录IP