feat: 2fa/otp support

pull/1604/head
Noah Hsu 2022-08-06 01:22:13 +08:00
parent b51e664543
commit a6ed4afdae
6 changed files with 83 additions and 1 deletions

2
go.mod
View File

@ -13,6 +13,7 @@ require (
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.3.0
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
github.com/winfsp/cgofuse v1.5.0 github.com/winfsp/cgofuse v1.5.0
gorm.io/driver/mysql v1.3.4 gorm.io/driver/mysql v1.3.4
@ -22,6 +23,7 @@ require (
) )
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect

4
go.sum
View File

@ -3,6 +3,8 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Xhofe/go-cache v0.0.0-20220723083548-714439c8af9a h1:RenIAa2q4H8UcS/cqmwdT1WCWIAH5aumP8m8RpbqVsE= github.com/Xhofe/go-cache v0.0.0-20220723083548-714439c8af9a h1:RenIAa2q4H8UcS/cqmwdT1WCWIAH5aumP8m8RpbqVsE=
github.com/Xhofe/go-cache v0.0.0-20220723083548-714439c8af9a/go.mod h1:sSBbaOg90XwWKtpT56kVujF0bIeVITnPlssLclogS04= github.com/Xhofe/go-cache v0.0.0-20220723083548-714439c8af9a/go.mod h1:sSBbaOg90XwWKtpT56kVujF0bIeVITnPlssLclogS04=
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/caarlos0/env/v6 v6.9.3 h1:Tyg69hoVXDnpO5Qvpsu8EoquarbPyQb+YwExWHP8wWU= github.com/caarlos0/env/v6 v6.9.3 h1:Tyg69hoVXDnpO5Qvpsu8EoquarbPyQb+YwExWHP8wWU=
github.com/caarlos0/env/v6 v6.9.3/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= github.com/caarlos0/env/v6 v6.9.3/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
@ -160,6 +162,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=

View File

@ -29,6 +29,7 @@ type User struct {
// 8: webdav read // 8: webdav read
// 9: webdav write // 9: webdav write
Permission int32 `json:"permission"` Permission int32 `json:"permission"`
OtpSecret string
} }
func (u User) IsGuest() bool { func (u User) IsGuest() bool {

View File

@ -1,6 +1,9 @@
package handles package handles
import ( import (
"bytes"
"encoding/base64"
"image/png"
"time" "time"
"github.com/Xhofe/go-cache" "github.com/Xhofe/go-cache"
@ -8,6 +11,7 @@ import (
"github.com/alist-org/alist/v3/internal/model" "github.com/alist-org/alist/v3/internal/model"
"github.com/alist-org/alist/v3/server/common" "github.com/alist-org/alist/v3/server/common"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
) )
var loginCache = cache.NewMemCache[int]() var loginCache = cache.NewMemCache[int]()
@ -19,6 +23,7 @@ var (
type LoginReq struct { type LoginReq struct {
Username string `json:"username" binding:"required"` Username string `json:"username" binding:"required"`
Password string `json:"password"` Password string `json:"password"`
OTPCode string `json:"otp_code"`
} }
func Login(c *gin.Context) { func Login(c *gin.Context) {
@ -26,7 +31,7 @@ func Login(c *gin.Context) {
ip := c.ClientIP() ip := c.ClientIP()
count, ok := loginCache.Get(ip) count, ok := loginCache.Get(ip)
if ok && count >= defaultTimes { if ok && count >= defaultTimes {
common.ErrorStrResp(c, "Too many unsuccessful sign-in attempts have been made using an incorrect username or password, Try again later.", 403) common.ErrorStrResp(c, "Too many unsuccessful sign-in attempts have been made using an incorrect username or password, Try again later.", 429)
loginCache.Expire(ip, defaultDuration) loginCache.Expire(ip, defaultDuration)
return return
} }
@ -48,6 +53,14 @@ func Login(c *gin.Context) {
loginCache.Set(ip, count+1) loginCache.Set(ip, count+1)
return return
} }
// check 2FA
if user.OtpSecret != "" {
if !totp.Validate(req.OTPCode, user.OtpSecret) {
common.ErrorStrResp(c, "Invalid 2FA code", 402)
loginCache.Set(ip, count+1)
return
}
}
// generate token // generate token
token, err := common.GenerateToken(user.Username) token, err := common.GenerateToken(user.Username)
if err != nil { if err != nil {
@ -84,3 +97,60 @@ func UpdateCurrent(c *gin.Context) {
common.SuccessResp(c) common.SuccessResp(c)
} }
} }
func Generate2FA(c *gin.Context) {
user := c.MustGet("user").(*model.User)
if user.IsGuest() {
common.ErrorStrResp(c, "Guest user can not generate 2FA code", 403)
return
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Alist",
AccountName: user.Username,
})
if err != nil {
common.ErrorResp(c, err, 500)
return
}
img, err := key.Image(400, 400)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
// to base64
var buf bytes.Buffer
png.Encode(&buf, img)
base64 := base64.StdEncoding.EncodeToString(buf.Bytes())
common.SuccessResp(c, gin.H{
"qr": "data:image/png;base64," + base64,
"secret": key.Secret(),
})
}
type Verify2FAReq struct {
Code string `json:"code" binding:"required"`
Secret string `json:"secret" binding:"required"`
}
func Verify2FA(c *gin.Context) {
var req Verify2FAReq
if err := c.ShouldBind(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}
user := c.MustGet("user").(*model.User)
if user.IsGuest() {
common.ErrorStrResp(c, "Guest user can not generate 2FA code", 403)
return
}
if !totp.Validate(req.Code, req.Secret) {
common.ErrorStrResp(c, "Invalid 2FA code", 400)
return
}
user.OtpSecret = req.Secret
if err := db.UpdateUser(user); err != nil {
common.ErrorResp(c, err, 500)
} else {
common.SuccessResp(c)
}
}

View File

@ -61,6 +61,9 @@ func UpdateUser(c *gin.Context) {
common.ErrorStrResp(c, "role can not be changed", 400) common.ErrorStrResp(c, "role can not be changed", 400)
return return
} }
if req.Password == "" {
req.Password = user.Password
}
if err := db.UpdateUser(&req); err != nil { if err := db.UpdateUser(&req); err != nil {
common.ErrorResp(c, err, 500) common.ErrorResp(c, err, 500)
} else { } else {

View File

@ -25,6 +25,8 @@ func Init(r *gin.Engine) {
api.POST("/auth/login", handles.Login) api.POST("/auth/login", handles.Login)
auth.GET("/me", handles.CurrentUser) auth.GET("/me", handles.CurrentUser)
auth.POST("/me/update", handles.UpdateCurrent) auth.POST("/me/update", handles.UpdateCurrent)
auth.POST("/auth/2fa/generate", handles.Generate2FA)
auth.POST("/auth/2fa/verify", handles.Verify2FA)
// no need auth // no need auth
public := api.Group("/public") public := api.Group("/public")