mirror of https://github.com/cloudreve/Cloudreve
Feat: Webauthn / theme changing
parent
80268e33bf
commit
c817f70f8e
|
@ -1,6 +1,7 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
model "github.com/HFO4/cloudreve/models"
|
||||||
"github.com/HFO4/cloudreve/pkg/hashid"
|
"github.com/HFO4/cloudreve/pkg/hashid"
|
||||||
"github.com/HFO4/cloudreve/pkg/serializer"
|
"github.com/HFO4/cloudreve/pkg/serializer"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
@ -24,3 +25,16 @@ func HashID(IDType int) gin.HandlerFunc {
|
||||||
c.Next()
|
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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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_garbage_collect", Value: "@hourly", Type: "cron"},
|
||||||
{Name: "cron_notify_user", Value: "@hourly", Type: "cron"},
|
{Name: "cron_notify_user", Value: "@hourly", Type: "cron"},
|
||||||
{Name: "cron_ban_user", Value: "@hourly", Type: "cron"},
|
{Name: "cron_ban_user", Value: "@hourly", Type: "cron"},
|
||||||
|
{Name: "authn_enabled", Value: "1", Type: "authn"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, value := range defaultSettings {
|
for _, value := range defaultSettings {
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/HFO4/cloudreve/pkg/hashid"
|
||||||
"github.com/duo-labs/webauthn/webauthn"
|
"github.com/duo-labs/webauthn/webauthn"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -30,7 +33,10 @@ func (user User) WebAuthnDisplayName() string {
|
||||||
|
|
||||||
// WebAuthnIcon 获得用户头像
|
// WebAuthnIcon 获得用户头像
|
||||||
func (user User) WebAuthnIcon() string {
|
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 获得已注册的验证器凭证
|
// WebAuthnCredentials 获得已注册的验证器凭证
|
||||||
|
@ -44,10 +50,29 @@ func (user User) WebAuthnCredentials() []webauthn.Credential {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterAuthn 添加新的验证器
|
// RegisterAuthn 添加新的验证器
|
||||||
func (user *User) RegisterAuthn(credential *webauthn.Credential) {
|
func (user *User) RegisterAuthn(credential *webauthn.Credential) error {
|
||||||
res, err := json.Marshal([]webauthn.Credential{*credential})
|
exists := user.WebAuthnCredentials()
|
||||||
|
exists = append(exists, *credential)
|
||||||
|
res, err := json.Marshal(exists)
|
||||||
if err != nil {
|
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))
|
DB.Model(user).Update("authn", string(res))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,23 @@
|
||||||
package authn
|
package authn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
model "github.com/HFO4/cloudreve/models"
|
||||||
|
"github.com/HFO4/cloudreve/pkg/util"
|
||||||
"github.com/duo-labs/webauthn/webauthn"
|
"github.com/duo-labs/webauthn/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AuthnInstance *webauthn.WebAuthn
|
var AuthnInstance *webauthn.WebAuthn
|
||||||
|
|
||||||
|
// Init 初始化webauthn
|
||||||
func Init() {
|
func Init() {
|
||||||
var err error
|
var err error
|
||||||
|
base := model.GetSiteURL()
|
||||||
AuthnInstance, err = webauthn.New(&webauthn.Config{
|
AuthnInstance, err = webauthn.New(&webauthn.Config{
|
||||||
RPDisplayName: "Duo Labs", // Display Name for your site
|
RPDisplayName: model.GetSettingByName("siteName"), // Display Name for your site
|
||||||
RPID: "localhost", // Generally the FQDN for your site
|
RPID: base.Hostname(), // Generally the FQDN for your site
|
||||||
RPOrigin: "http://localhost:3000", // The origin URL for WebAuthn requests
|
RPOrigin: base.String(), // The origin URL for WebAuthn requests
|
||||||
RPIcon: "https://duo.com/logo.png", // Optional icon URL for your site
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
util.Log().Error("无法初始化WebAuthn, %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ type SiteConfig struct {
|
||||||
ShareScoreRate string `json:"share_score_rate"`
|
ShareScoreRate string `json:"share_score_rate"`
|
||||||
HomepageViewMethod string `json:"home_view_method"`
|
HomepageViewMethod string `json:"home_view_method"`
|
||||||
ShareViewMethod string `json:"share_view_method"`
|
ShareViewMethod string `json:"share_view_method"`
|
||||||
|
Authn bool `json:"authn"'`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +76,7 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response {
|
||||||
ShareScoreRate: checkSettingValue(settings, "share_score_rate"),
|
ShareScoreRate: checkSettingValue(settings, "share_score_rate"),
|
||||||
HomepageViewMethod: checkSettingValue(settings, "home_view_method"),
|
HomepageViewMethod: checkSettingValue(settings, "home_view_method"),
|
||||||
ShareViewMethod: checkSettingValue(settings, "share_view_method"),
|
ShareViewMethod: checkSettingValue(settings, "share_view_method"),
|
||||||
|
Authn: model.IsTrueVal(checkSettingValue(settings, "authn_enabled")),
|
||||||
User: userRes,
|
User: userRes,
|
||||||
}}
|
}}
|
||||||
return res
|
return res
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/HFO4/cloudreve/models"
|
"github.com/HFO4/cloudreve/models"
|
||||||
"github.com/HFO4/cloudreve/pkg/hashid"
|
"github.com/HFO4/cloudreve/pkg/hashid"
|
||||||
|
"github.com/duo-labs/webauthn/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckLogin 检查登录
|
// CheckLogin 检查登录
|
||||||
|
@ -64,6 +65,26 @@ type storage struct {
|
||||||
Total uint64 `json:"total"`
|
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 序列化用户
|
// BuildUser 序列化用户
|
||||||
func BuildUser(user model.User) User {
|
func BuildUser(user model.User) User {
|
||||||
tags, _ := model.GetTagsByUID(user.ID)
|
tags, _ := model.GetTagsByUID(user.ID)
|
||||||
|
|
|
@ -25,6 +25,7 @@ func SiteConfig(c *gin.Context) {
|
||||||
"share_score_rate",
|
"share_score_rate",
|
||||||
"home_view_method",
|
"home_view_method",
|
||||||
"share_view_method",
|
"share_view_method",
|
||||||
|
"authn_enabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
// 如果已登录,则同时返回用户信息和标签
|
// 如果已登录,则同时返回用户信息和标签
|
||||||
|
|
|
@ -2,6 +2,7 @@ package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
model "github.com/HFO4/cloudreve/models"
|
model "github.com/HFO4/cloudreve/models"
|
||||||
"github.com/HFO4/cloudreve/pkg/authn"
|
"github.com/HFO4/cloudreve/pkg/authn"
|
||||||
"github.com/HFO4/cloudreve/pkg/serializer"
|
"github.com/HFO4/cloudreve/pkg/serializer"
|
||||||
|
@ -17,7 +18,7 @@ func StartLoginAuthn(c *gin.Context) {
|
||||||
userName := c.Param("username")
|
userName := c.Param("username")
|
||||||
expectedUser, err := model.GetUserByEmail(userName)
|
expectedUser, err := model.GetUserByEmail(userName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err))
|
c.JSON(200, serializer.Err(401, "用户不存在", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@ func FinishLoginAuthn(c *gin.Context) {
|
||||||
_, err = authn.AuthnInstance.FinishLogin(expectedUser, sessionData, c.Request)
|
_, err = authn.AuthnInstance.FinishLogin(expectedUser, sessionData, c.Request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err))
|
c.JSON(200, serializer.Err(401, "登录验证失败", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -96,13 +97,24 @@ func FinishRegAuthn(c *gin.Context) {
|
||||||
err := json.Unmarshal(sessionDataJSON, &sessionData)
|
err := json.Unmarshal(sessionDataJSON, &sessionData)
|
||||||
|
|
||||||
credential, err := authn.AuthnInstance.FinishRegistration(currUser, sessionData, c.Request)
|
credential, err := authn.AuthnInstance.FinishRegistration(currUser, sessionData, c.Request)
|
||||||
|
|
||||||
currUser.RegisterAuthn(credential)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(200, ErrorResponse(err))
|
c.JSON(200, ErrorResponse(err))
|
||||||
return
|
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 用户登录
|
// UserLogin 用户登录
|
||||||
|
@ -265,6 +277,10 @@ func UpdateOption(c *gin.Context) {
|
||||||
subService = &user.PasswordChange{}
|
subService = &user.PasswordChange{}
|
||||||
case "2fa":
|
case "2fa":
|
||||||
subService = &user.Enable2FA{}
|
subService = &user.Enable2FA{}
|
||||||
|
case "authn":
|
||||||
|
subService = &user.DeleteWebAuthn{}
|
||||||
|
case "theme":
|
||||||
|
subService = &user.ThemeChose{}
|
||||||
}
|
}
|
||||||
|
|
||||||
subErr = c.ShouldBindJSON(subService)
|
subErr = c.ShouldBindJSON(subService)
|
||||||
|
|
|
@ -104,9 +104,13 @@ func InitMasterRouter() *gin.Engine {
|
||||||
// 用户登录
|
// 用户登录
|
||||||
user.POST("session", controllers.UserLogin)
|
user.POST("session", controllers.UserLogin)
|
||||||
// WebAuthn登陆初始化
|
// WebAuthn登陆初始化
|
||||||
user.GET("authn/:username", controllers.StartLoginAuthn)
|
user.GET("authn/:username",
|
||||||
|
middleware.IsFunctionEnabled("authn_enabled"),
|
||||||
|
controllers.StartLoginAuthn)
|
||||||
// WebAuthn登陆
|
// WebAuthn登陆
|
||||||
user.POST("authn/finish/:username", controllers.FinishLoginAuthn)
|
user.POST("authn/finish/:username",
|
||||||
|
middleware.IsFunctionEnabled("authn_enabled"),
|
||||||
|
controllers.FinishLoginAuthn)
|
||||||
// 获取用户主页展示用分享
|
// 获取用户主页展示用分享
|
||||||
user.GET("profile/:id",
|
user.GET("profile/:id",
|
||||||
middleware.HashID(hashid.UserID),
|
middleware.HashID(hashid.UserID),
|
||||||
|
@ -263,7 +267,8 @@ func InitMasterRouter() *gin.Engine {
|
||||||
user.DELETE("session", controllers.UserSignOut)
|
user.DELETE("session", controllers.UserSignOut)
|
||||||
|
|
||||||
// WebAuthn 注册相关
|
// WebAuthn 注册相关
|
||||||
authn := user.Group("authn")
|
authn := user.Group("authn",
|
||||||
|
middleware.IsFunctionEnabled("authn_enabled"))
|
||||||
{
|
{
|
||||||
authn.PUT("", controllers.StartRegAuthn)
|
authn.PUT("", controllers.StartRegAuthn)
|
||||||
authn.PUT("finish", controllers.FinishRegAuthn)
|
authn.PUT("finish", controllers.FinishRegAuthn)
|
||||||
|
|
|
@ -33,7 +33,7 @@ type AvatarService struct {
|
||||||
|
|
||||||
// SettingUpdateService 设定更改服务
|
// SettingUpdateService 设定更改服务
|
||||||
type SettingUpdateService struct {
|
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 属性更改接口
|
// OptionsChangeHandler 属性更改接口
|
||||||
|
@ -75,10 +75,36 @@ type Enable2FA struct {
|
||||||
Code string `json:"code" binding:"required"`
|
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 {
|
func (service *Enable2FA) Update(c *gin.Context, user *model.User) serializer.Response {
|
||||||
if user.TwoFactor == "" {
|
if user.TwoFactor == "" {
|
||||||
|
// 开启2FA
|
||||||
secret, ok := util.GetSession(c, "2fa_init").(string)
|
secret, ok := util.GetSession(c, "2fa_init").(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return serializer.Err(serializer.CodeParamErr, "未初始化二步验证", nil)
|
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)
|
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{}
|
return serializer.Response{}
|
||||||
|
@ -318,8 +353,9 @@ func (service *SettingService) Settings(c *gin.Context, user *model.User) serial
|
||||||
"homepage": !user.OptionsSerialized.ProfileOff,
|
"homepage": !user.OptionsSerialized.ProfileOff,
|
||||||
"two_factor": user.TwoFactor != "",
|
"two_factor": user.TwoFactor != "",
|
||||||
"prefer_theme": user.OptionsSerialized.PreferredTheme,
|
"prefer_theme": user.OptionsSerialized.PreferredTheme,
|
||||||
"themes": model.GetSettingByNames("themes"),
|
"themes": model.GetSettingByName("themes"),
|
||||||
"group_expires": groupExpires,
|
"group_expires": groupExpires,
|
||||||
|
"authn": serializer.BuildWebAuthnList(user.WebAuthnCredentials()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue