From c817f70f8e39213ec91392006250b06696b4a5e0 Mon Sep 17 00:00:00 2001 From: HFO4 <912394456@qq.com> Date: Fri, 21 Feb 2020 10:08:47 +0800 Subject: [PATCH] Feat: Webauthn / theme changing --- middleware/{hahsid.go => option.go} | 14 +++++++++ models/migration.go | 1 + models/user_authn.go | 33 +++++++++++++++++++--- pkg/authn/auth.go | 14 +++++---- pkg/serializer/setting.go | 2 ++ pkg/serializer/user.go | 21 ++++++++++++++ routers/controllers/site.go | 1 + routers/controllers/user.go | 26 +++++++++++++---- routers/router.go | 11 ++++++-- service/user/setting.go | 44 ++++++++++++++++++++++++++--- 10 files changed, 145 insertions(+), 22 deletions(-) rename middleware/{hahsid.go => option.go} (59%) diff --git a/middleware/hahsid.go b/middleware/option.go similarity index 59% rename from middleware/hahsid.go rename to middleware/option.go index f66d945..a4c6fcd 100644 --- a/middleware/hahsid.go +++ b/middleware/option.go @@ -1,6 +1,7 @@ package middleware import ( + model "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/hashid" "github.com/HFO4/cloudreve/pkg/serializer" "github.com/gin-gonic/gin" @@ -24,3 +25,16 @@ func HashID(IDType int) gin.HandlerFunc { c.Next() } } + +// IsFunctionEnabled 当功能未开启时阻止访问 +func IsFunctionEnabled(key string) gin.HandlerFunc { + return func(c *gin.Context) { + if !model.IsTrueVal(model.GetSettingByName(key)) { + c.JSON(200, serializer.Err(serializer.CodeNoPermissionErr, "未开启此功能", nil)) + c.Abort() + return + } + + c.Next() + } +} diff --git a/models/migration.go b/models/migration.go index daca13a..5c3cd6b 100644 --- a/models/migration.go +++ b/models/migration.go @@ -173,6 +173,7 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {Name: "cron_garbage_collect", Value: "@hourly", Type: "cron"}, {Name: "cron_notify_user", Value: "@hourly", Type: "cron"}, {Name: "cron_ban_user", Value: "@hourly", Type: "cron"}, + {Name: "authn_enabled", Value: "1", Type: "authn"}, } for _, value := range defaultSettings { diff --git a/models/user_authn.go b/models/user_authn.go index 99c09a1..d9b2be5 100644 --- a/models/user_authn.go +++ b/models/user_authn.go @@ -1,10 +1,13 @@ package model import ( + "encoding/base64" "encoding/binary" "encoding/json" "fmt" + "github.com/HFO4/cloudreve/pkg/hashid" "github.com/duo-labs/webauthn/webauthn" + "net/url" ) /* @@ -30,7 +33,10 @@ func (user User) WebAuthnDisplayName() string { // WebAuthnIcon 获得用户头像 func (user User) WebAuthnIcon() string { - return "https://cdn4.buysellads.net/uu/1/46074/1559075156-slack-carbon-red_2x.png" + avatar, _ := url.Parse("/api/v3/user/avatar/" + hashid.HashID(user.ID, hashid.UserID) + "/l") + base := GetSiteURL() + base.Scheme = "https" + return base.ResolveReference(avatar).String() } // WebAuthnCredentials 获得已注册的验证器凭证 @@ -44,10 +50,29 @@ func (user User) WebAuthnCredentials() []webauthn.Credential { } // RegisterAuthn 添加新的验证器 -func (user *User) RegisterAuthn(credential *webauthn.Credential) { - res, err := json.Marshal([]webauthn.Credential{*credential}) +func (user *User) RegisterAuthn(credential *webauthn.Credential) error { + exists := user.WebAuthnCredentials() + exists = append(exists, *credential) + res, err := json.Marshal(exists) if err != nil { - fmt.Println(err) + return err } + + return DB.Model(user).Update("authn", string(res)).Error +} + +// RemoveAuthn 删除验证器 +func (user *User) RemoveAuthn(id string) { + exists := user.WebAuthnCredentials() + for i := 0; i < len(exists); i++ { + idEncoded := base64.StdEncoding.EncodeToString(exists[i].ID) + if idEncoded == id { + exists[len(exists)-1], exists[i] = exists[i], exists[len(exists)-1] + exists = exists[:len(exists)-1] + break + } + } + + res, _ := json.Marshal(exists) DB.Model(user).Update("authn", string(res)) } diff --git a/pkg/authn/auth.go b/pkg/authn/auth.go index 5b4187a..7cd36a7 100644 --- a/pkg/authn/auth.go +++ b/pkg/authn/auth.go @@ -1,21 +1,23 @@ package authn import ( - "fmt" + model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/util" "github.com/duo-labs/webauthn/webauthn" ) var AuthnInstance *webauthn.WebAuthn +// Init 初始化webauthn func Init() { var err error + base := model.GetSiteURL() AuthnInstance, err = webauthn.New(&webauthn.Config{ - RPDisplayName: "Duo Labs", // Display Name for your site - RPID: "localhost", // Generally the FQDN for your site - RPOrigin: "http://localhost:3000", // The origin URL for WebAuthn requests - RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site + RPDisplayName: model.GetSettingByName("siteName"), // Display Name for your site + RPID: base.Hostname(), // Generally the FQDN for your site + RPOrigin: base.String(), // The origin URL for WebAuthn requests }) if err != nil { - fmt.Println(err) + util.Log().Error("无法初始化WebAuthn, %s", err) } } diff --git a/pkg/serializer/setting.go b/pkg/serializer/setting.go index 0a0453d..f39528d 100644 --- a/pkg/serializer/setting.go +++ b/pkg/serializer/setting.go @@ -16,6 +16,7 @@ type SiteConfig struct { ShareScoreRate string `json:"share_score_rate"` HomepageViewMethod string `json:"home_view_method"` ShareViewMethod string `json:"share_view_method"` + Authn bool `json:"authn"'` User User `json:"user"` } @@ -75,6 +76,7 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response { ShareScoreRate: checkSettingValue(settings, "share_score_rate"), HomepageViewMethod: checkSettingValue(settings, "home_view_method"), ShareViewMethod: checkSettingValue(settings, "share_view_method"), + Authn: model.IsTrueVal(checkSettingValue(settings, "authn_enabled")), User: userRes, }} return res diff --git a/pkg/serializer/user.go b/pkg/serializer/user.go index 3852d55..6de6e11 100644 --- a/pkg/serializer/user.go +++ b/pkg/serializer/user.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/hashid" + "github.com/duo-labs/webauthn/webauthn" ) // CheckLogin 检查登录 @@ -64,6 +65,26 @@ type storage struct { Total uint64 `json:"total"` } +// WebAuthnCredentials 外部验证器凭证 +type WebAuthnCredentials struct { + ID []byte `json:"id"` + FingerPrint string `json:"fingerprint"` +} + +// BuildWebAuthnList 构建设置页面凭证列表 +func BuildWebAuthnList(credentials []webauthn.Credential) []WebAuthnCredentials { + res := make([]WebAuthnCredentials, 0, len(credentials)) + for _, v := range credentials { + credential := WebAuthnCredentials{ + ID: v.ID, + FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID), + } + res = append(res, credential) + } + + return res +} + // BuildUser 序列化用户 func BuildUser(user model.User) User { tags, _ := model.GetTagsByUID(user.ID) diff --git a/routers/controllers/site.go b/routers/controllers/site.go index a3d64b7..b7507c6 100644 --- a/routers/controllers/site.go +++ b/routers/controllers/site.go @@ -25,6 +25,7 @@ func SiteConfig(c *gin.Context) { "share_score_rate", "home_view_method", "share_view_method", + "authn_enabled", ) // 如果已登录,则同时返回用户信息和标签 diff --git a/routers/controllers/user.go b/routers/controllers/user.go index 7f15f2b..d9b2911 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -2,6 +2,7 @@ package controllers import ( "encoding/json" + "fmt" model "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/authn" "github.com/HFO4/cloudreve/pkg/serializer" @@ -17,7 +18,7 @@ func StartLoginAuthn(c *gin.Context) { userName := c.Param("username") expectedUser, err := model.GetUserByEmail(userName) if err != nil { - c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err)) + c.JSON(200, serializer.Err(401, "用户不存在", err)) return } @@ -56,7 +57,7 @@ func FinishLoginAuthn(c *gin.Context) { _, err = authn.AuthnInstance.FinishLogin(expectedUser, sessionData, c.Request) if err != nil { - c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err)) + c.JSON(200, serializer.Err(401, "登录验证失败", err)) return } @@ -96,13 +97,24 @@ func FinishRegAuthn(c *gin.Context) { err := json.Unmarshal(sessionDataJSON, &sessionData) credential, err := authn.AuthnInstance.FinishRegistration(currUser, sessionData, c.Request) - - currUser.RegisterAuthn(credential) if err != nil { c.JSON(200, ErrorResponse(err)) return } - c.JSON(200, serializer.Response{Code: 0}) + + err = currUser.RegisterAuthn(credential) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + + c.JSON(200, serializer.Response{ + Code: 0, + Data: map[string]interface{}{ + "id": credential.ID, + "fingerprint": fmt.Sprintf("% X", credential.Authenticator.AAGUID), + }, + }) } // UserLogin 用户登录 @@ -265,6 +277,10 @@ func UpdateOption(c *gin.Context) { subService = &user.PasswordChange{} case "2fa": subService = &user.Enable2FA{} + case "authn": + subService = &user.DeleteWebAuthn{} + case "theme": + subService = &user.ThemeChose{} } subErr = c.ShouldBindJSON(subService) diff --git a/routers/router.go b/routers/router.go index fc891c3..7dc37f2 100644 --- a/routers/router.go +++ b/routers/router.go @@ -104,9 +104,13 @@ func InitMasterRouter() *gin.Engine { // 用户登录 user.POST("session", controllers.UserLogin) // WebAuthn登陆初始化 - user.GET("authn/:username", controllers.StartLoginAuthn) + user.GET("authn/:username", + middleware.IsFunctionEnabled("authn_enabled"), + controllers.StartLoginAuthn) // WebAuthn登陆 - user.POST("authn/finish/:username", controllers.FinishLoginAuthn) + user.POST("authn/finish/:username", + middleware.IsFunctionEnabled("authn_enabled"), + controllers.FinishLoginAuthn) // 获取用户主页展示用分享 user.GET("profile/:id", middleware.HashID(hashid.UserID), @@ -263,7 +267,8 @@ func InitMasterRouter() *gin.Engine { user.DELETE("session", controllers.UserSignOut) // WebAuthn 注册相关 - authn := user.Group("authn") + authn := user.Group("authn", + middleware.IsFunctionEnabled("authn_enabled")) { authn.PUT("", controllers.StartRegAuthn) authn.PUT("finish", controllers.FinishRegAuthn) diff --git a/service/user/setting.go b/service/user/setting.go index 6ccb7fc..9bfd803 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -33,7 +33,7 @@ type AvatarService struct { // SettingUpdateService 设定更改服务 type SettingUpdateService struct { - Option string `uri:"option" binding:"required,eq=nick|eq=theme|eq=homepage|eq=vip|eq=qq|eq=policy|eq=password|eq=2fa"` + Option string `uri:"option" binding:"required,eq=nick|eq=theme|eq=homepage|eq=vip|eq=qq|eq=policy|eq=password|eq=2fa|eq=authn"` } // OptionsChangeHandler 属性更改接口 @@ -75,10 +75,36 @@ type Enable2FA struct { Code string `json:"code" binding:"required"` } -// Update 更改密码 +// DeleteWebAuthn 删除WebAuthn凭证 +type DeleteWebAuthn struct { + ID string `json:"id" binding:"required"` +} + +// ThemeChose 主题选择 +type ThemeChose struct { + Theme string `json:"theme" binding:"required,hexcolor|rgb|rgba|hsl"` +} + +// Update 更新主题设定 +func (service *ThemeChose) Update(c *gin.Context, user *model.User) serializer.Response { + user.OptionsSerialized.PreferredTheme = service.Theme + if err := user.UpdateOptions(); err != nil { + return serializer.DBErr("主题切换失败", err) + } + + return serializer.Response{} +} + +// Update 删除凭证 +func (service *DeleteWebAuthn) Update(c *gin.Context, user *model.User) serializer.Response { + user.RemoveAuthn(service.ID) + return serializer.Response{} +} + +// Update 更改二步验证设定 func (service *Enable2FA) Update(c *gin.Context, user *model.User) serializer.Response { if user.TwoFactor == "" { - + // 开启2FA secret, ok := util.GetSession(c, "2fa_init").(string) if !ok { return serializer.Err(serializer.CodeParamErr, "未初始化二步验证", nil) @@ -92,6 +118,15 @@ func (service *Enable2FA) Update(c *gin.Context, user *model.User) serializer.Re return serializer.DBErr("无法更新二步验证设定", err) } + } else { + // 关闭2FA + if !totp.Validate(service.Code, user.TwoFactor) { + return serializer.ParamErr("验证码不正确", nil) + } + + if err := user.Update(map[string]interface{}{"two_factor": ""}); err != nil { + return serializer.DBErr("无法更新二步验证设定", err) + } } return serializer.Response{} @@ -318,8 +353,9 @@ func (service *SettingService) Settings(c *gin.Context, user *model.User) serial "homepage": !user.OptionsSerialized.ProfileOff, "two_factor": user.TwoFactor != "", "prefer_theme": user.OptionsSerialized.PreferredTheme, - "themes": model.GetSettingByNames("themes"), + "themes": model.GetSettingByName("themes"), "group_expires": groupExpires, + "authn": serializer.BuildWebAuthnList(user.WebAuthnCredentials()), }, } }