diff --git a/go.mod b/go.mod index a7faa9e8..4150c4d3 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.3.0 github.com/sirupsen/logrus v1.8.1 github.com/winfsp/cgofuse v1.5.0 gorm.io/driver/mysql v1.3.4 @@ -22,6 +23,7 @@ require ( ) require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect diff --git a/go.sum b/go.sum index 01539cfc..811575f6 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= 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/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.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.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= diff --git a/internal/model/user.go b/internal/model/user.go index a79509a3..28b762dc 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -29,6 +29,7 @@ type User struct { // 8: webdav read // 9: webdav write Permission int32 `json:"permission"` + OtpSecret string } func (u User) IsGuest() bool { diff --git a/server/handles/auth.go b/server/handles/auth.go index 9a5b48fa..a7807627 100644 --- a/server/handles/auth.go +++ b/server/handles/auth.go @@ -1,6 +1,9 @@ package handles import ( + "bytes" + "encoding/base64" + "image/png" "time" "github.com/Xhofe/go-cache" @@ -8,6 +11,7 @@ import ( "github.com/alist-org/alist/v3/internal/model" "github.com/alist-org/alist/v3/server/common" "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" ) var loginCache = cache.NewMemCache[int]() @@ -19,6 +23,7 @@ var ( type LoginReq struct { Username string `json:"username" binding:"required"` Password string `json:"password"` + OTPCode string `json:"otp_code"` } func Login(c *gin.Context) { @@ -26,7 +31,7 @@ func Login(c *gin.Context) { ip := c.ClientIP() count, ok := loginCache.Get(ip) 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) return } @@ -48,6 +53,14 @@ func Login(c *gin.Context) { loginCache.Set(ip, count+1) 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 token, err := common.GenerateToken(user.Username) if err != nil { @@ -84,3 +97,60 @@ func UpdateCurrent(c *gin.Context) { 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) + } +} diff --git a/server/handles/user.go b/server/handles/user.go index b1d6fc54..5aa67d3a 100644 --- a/server/handles/user.go +++ b/server/handles/user.go @@ -61,6 +61,9 @@ func UpdateUser(c *gin.Context) { common.ErrorStrResp(c, "role can not be changed", 400) return } + if req.Password == "" { + req.Password = user.Password + } if err := db.UpdateUser(&req); err != nil { common.ErrorResp(c, err, 500) } else { diff --git a/server/router.go b/server/router.go index 068bd9dd..e3512af3 100644 --- a/server/router.go +++ b/server/router.go @@ -25,6 +25,8 @@ func Init(r *gin.Engine) { api.POST("/auth/login", handles.Login) auth.GET("/me", handles.CurrentUser) auth.POST("/me/update", handles.UpdateCurrent) + auth.POST("/auth/2fa/generate", handles.Generate2FA) + auth.POST("/auth/2fa/verify", handles.Verify2FA) // no need auth public := api.Group("/public")