feat: twofactor bind

This commit is contained in:
henry.chen
2025-07-17 15:24:46 +08:00
parent 91e1731909
commit e5100fa018
11 changed files with 125 additions and 19 deletions

View File

@@ -19,6 +19,7 @@ general: # 常规配置
identifier: <!--more--> # 截取预览标识
length: 400 # 自动截取预览, 字符数
timezone: Asia/Shanghai # 时区
twofactor: true # 是否启用两步验证
disqus: # 评论相关
shortname: xxxxxx
publickey: wdSgxRm9rdGAlLKFcFdToBe3GT4SibmV7Y8EjJQ0r4GWXeKtxpopMAeIeoI2dTEg

View File

@@ -22,10 +22,10 @@
<label for=password class="sr-only">密码</label>
<input type=password id=password name=password class="text-l w-100" placeholder="密码">
</p>
<!-- <p>
{{if .TwoFactor}}<p>
<label for=code class="sr-only">两步验证</label>
<input type=text id=code name=code class="text-l w-100" placeholder="两步验证">
</p> -->
</p>{{end}}
<p class=submit>
<button type=submit class="btn btn-l w-100 primary">登录</button>
</p>

View File

@@ -7,11 +7,37 @@
<div class="row typecho-page-main">
<div class="col-mb-12 col-tb-3">
<p>
<img class="profile-avatar" src="//{{$.Qiniu.Domain}}/static/img/avatar.png" alt="{{.Blogger.BlogName}}" />
<img class="profile-avatar" src="//{{$.Qiniu.Domain}}/static/img/avatar.png" alt="{{.Blogger.BlogName}}" />
</p>
<h2>{{.Blogger.BlogName}}</h2>
<p>{{.Blogger.SubTitle}}</p>
<p>最后登录: {{dateformat .Account.LoginAt "2006/01/02 15:04"}}</p>
{{if .Account.TwoFactorSecret}}
<strong>2FA 已绑定 <a class="unbind-2fa" href="/admin/profile?unbind=true">解绑</a></strong>
{{end}}
{{if $.TwoFactorSecret}}
<strong>2FA 绑定</strong>
<form action="/admin/api/twofactor" method="post" enctype="application/x-www-form-urlencoded">
<ul class="typecho-option">
<li>
<img src="{{$.TwoFactorSecret}}" alt="2fa code" style="width:180px;height:180px;">
</li>
</ul>
<ul class="typecho-option">
<li>
<input id="2fa-0-1" name="code" type="text" class="text" />
<p class="description">
输入 2FA 验证码, 用于确认绑定.</p>
</li>
</ul>
<ul class="typecho-option typecho-option-submit">
<li>
<button type="submit" class="btn primary">
确认绑定</button>
</li>
</ul>
</form>
{{end}}
</div>
<div class="col-mb-12 col-tb-6 col-tb-offset-1 typecho-content-panel" role="form">
<section>

View File

@@ -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")

View File

@@ -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 客户端

View File

@@ -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
}

View File

@@ -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)
}

1
go.mod
View File

@@ -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

4
go.sum
View File

@@ -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=

View File

@@ -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 博客配置, 无需配置,程序默认初始化,可在后台更改

View File

@@ -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