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}}
-
+
{{.Blogger.BlogName}}
{{.Blogger.SubTitle}}
最后登录: {{dateformat .Account.LoginAt "2006/01/02 15:04"}}
+ {{if .Account.TwoFactorSecret}}
+
2FA 已绑定 解绑
+ {{end}}
+ {{if $.TwoFactorSecret}}
+
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