From f35c585edfdbfa01007fa6abd7a2c9af94cd7dd3 Mon Sep 17 00:00:00 2001 From: HFO4 <912394456@qq.com> Date: Sun, 8 Dec 2019 22:17:36 +0800 Subject: [PATCH] Feat experimental WebAuth API --- go.mod | 1 + main.go | 3 + models/user.go | 39 ++++++++++++ pkg/authn/auth.go | 21 +++++++ pkg/filesystem/filesystem.go | 3 + pkg/filesystem/image.go | 22 ++++++- pkg/filesystem/local/handler.go | 14 +++++ pkg/filesystem/response/common.go | 12 ++++ pkg/filesystem/upload_test.go | 6 ++ pkg/thumb/image.go | 2 +- routers/controllers/file.go | 36 ++++++++++++ routers/controllers/user.go | 98 +++++++++++++++++++++++++++++++ routers/router.go | 15 ++++- service/user/login.go | 3 - 14 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 pkg/authn/auth.go create mode 100644 pkg/filesystem/response/common.go diff --git a/go.mod b/go.mod index 7314e54..97caab2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.12 require ( github.com/DATA-DOG/go-sqlmock v1.3.3 + github.com/duo-labs/webauthn v0.0.0-20191119193225-4bf9a0f776d4 github.com/fatih/color v1.7.0 github.com/garyburd/redigo v1.6.0 github.com/gin-contrib/cors v1.3.0 diff --git a/main.go b/main.go index 51b0782..1296782 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/authn" "github.com/HFO4/cloudreve/pkg/conf" "github.com/HFO4/cloudreve/routers" "github.com/gin-gonic/gin" @@ -19,6 +20,8 @@ func init() { if !conf.SystemConfig.Debug { gin.SetMode(gin.ReleaseMode) } + + authn.Init() } func main() { diff --git a/models/user.go b/models/user.go index daedf12..e436575 100644 --- a/models/user.go +++ b/models/user.go @@ -2,9 +2,12 @@ package model import ( "crypto/sha1" + "encoding/binary" "encoding/hex" "encoding/json" + "fmt" "github.com/HFO4/cloudreve/pkg/util" + "github.com/duo-labs/webauthn/webauthn" "github.com/jinzhu/gorm" "github.com/pkg/errors" "strings" @@ -38,6 +41,7 @@ type User struct { Delay int Avatar string Options string `json:"-",gorm:"size:4096"` + Authn string `gorm:"size:8192"` // 关联模型 Group Group `gorm:"association_autoupdate:false"` @@ -55,6 +59,41 @@ type UserOption struct { PreferredTheme string `json:"preferred_theme"` } +func (user User) WebAuthnID() []byte { + bs := make([]byte, 8) + binary.LittleEndian.PutUint64(bs, uint64(user.ID)) + return bs +} + +func (user User) WebAuthnName() string { + return user.Email +} + +func (user User) WebAuthnDisplayName() string { + return user.Nick +} + +func (user User) WebAuthnIcon() string { + return "https://cdn4.buysellads.net/uu/1/46074/1559075156-slack-carbon-red_2x.png" +} + +func (user User) WebAuthnCredentials() []webauthn.Credential { + var res []webauthn.Credential + err := json.Unmarshal([]byte(user.Authn), &res) + if err != nil { + fmt.Println(err) + } + return res +} + +func (user *User) RegisterAuthn(credential *webauthn.Credential) { + res, err := json.Marshal([]webauthn.Credential{*credential}) + if err != nil { + fmt.Println(err) + } + DB.Model(user).UpdateColumn("authn", string(res)) +} + // Root 获取用户的根目录 func (user *User) Root() (*Folder, error) { var folder Folder diff --git a/pkg/authn/auth.go b/pkg/authn/auth.go new file mode 100644 index 0000000..1d1ebff --- /dev/null +++ b/pkg/authn/auth.go @@ -0,0 +1,21 @@ +package authn + +import ( + "fmt" + "github.com/duo-labs/webauthn/webauthn" +) + +var Authn *webauthn.WebAuthn + +func Init() { + var err error + Authn, 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 + }) + if err != nil { + fmt.Println(err) + } +} diff --git a/pkg/filesystem/filesystem.go b/pkg/filesystem/filesystem.go index ff4afd2..ca0f1e0 100644 --- a/pkg/filesystem/filesystem.go +++ b/pkg/filesystem/filesystem.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/filesystem/local" + "github.com/HFO4/cloudreve/pkg/filesystem/response" "github.com/gin-gonic/gin" "io" ) @@ -27,6 +28,8 @@ type Handler interface { Delete(ctx context.Context, files []string) ([]string, error) // 获取文件 Get(ctx context.Context, path string) (io.ReadSeeker, error) + // 获取缩略图 + Thumb(ctx context.Context, path string) (*response.ContentResponse, error) } // FileSystem 管理文件的文件系统 diff --git a/pkg/filesystem/image.go b/pkg/filesystem/image.go index 75290c1..3b1bfbe 100644 --- a/pkg/filesystem/image.go +++ b/pkg/filesystem/image.go @@ -4,6 +4,7 @@ import ( "context" "fmt" model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/filesystem/response" "github.com/HFO4/cloudreve/pkg/thumb" "github.com/HFO4/cloudreve/pkg/util" ) @@ -16,6 +17,24 @@ import ( // HandledExtension 可以生成缩略图的文件扩展名 var HandledExtension = []string{"jpg", "jpeg", "png", "gif"} +// GetThumb 获取文件的缩略图 +func (fs *FileSystem) GetThumb(ctx context.Context, id uint) (*response.ContentResponse, error) { + // 根据 ID 查找文件 + file, err := model.GetFilesByIDs([]uint{id}, fs.User.ID) + if err != nil || len(file) == 0 || file[0].PicInfo == "" { + return &response.ContentResponse{ + Redirect: false, + }, ErrObjectNotExist + } + + fs.FileTarget = []model.File{file[0]} + + res, err := fs.Handler.Thumb(ctx, file[0].SourceName) + + // TODO 出错时重新生成缩略图 + return res, err +} + // GenerateThumbnail 尝试为本地策略文件生成缩略图并获取图像原始大小 func (fs *FileSystem) GenerateThumbnail(ctx context.Context, file *model.File) { // 判断是否可以生成缩略图 @@ -61,6 +80,7 @@ func (fs *FileSystem) GenerateThumbnail(ctx context.Context, file *model.File) { } // GenerateThumbnailSize 获取要生成的缩略图的尺寸 +// TODO 从配置文件读取 func (fs *FileSystem) GenerateThumbnailSize(w, h int) (uint, uint) { - return 230, 200 + return 400, 300 } diff --git a/pkg/filesystem/local/handler.go b/pkg/filesystem/local/handler.go index efa1362..fc10bc9 100644 --- a/pkg/filesystem/local/handler.go +++ b/pkg/filesystem/local/handler.go @@ -2,6 +2,7 @@ package local import ( "context" + "github.com/HFO4/cloudreve/pkg/filesystem/response" "github.com/HFO4/cloudreve/pkg/util" "io" "os" @@ -82,3 +83,16 @@ func (handler Handler) Delete(ctx context.Context, files []string) ([]string, er return deleteFailed, retErr } + +// Thumb 获取文件缩略图 +func (handler Handler) Thumb(ctx context.Context, path string) (*response.ContentResponse, error) { + file, err := handler.Get(ctx, path+"._thumb") + if err != nil { + return nil, err + } + + return &response.ContentResponse{ + Redirect: false, + Content: file, + }, nil +} diff --git a/pkg/filesystem/response/common.go b/pkg/filesystem/response/common.go new file mode 100644 index 0000000..5ef7d54 --- /dev/null +++ b/pkg/filesystem/response/common.go @@ -0,0 +1,12 @@ +package response + +import "io" + +// ContentResponse 获取文件内容类方法的通用返回值。 +// 有些上传策略需要重定向, +// 有些直接写文件数据到浏览器 +type ContentResponse struct { + Redirect bool + Content io.ReadSeeker + URL string +} diff --git a/pkg/filesystem/upload_test.go b/pkg/filesystem/upload_test.go index 8d0c475..f4181d9 100644 --- a/pkg/filesystem/upload_test.go +++ b/pkg/filesystem/upload_test.go @@ -5,6 +5,7 @@ import ( "errors" model "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/filesystem/local" + "github.com/HFO4/cloudreve/pkg/filesystem/response" "github.com/gin-gonic/gin" "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" @@ -34,6 +35,11 @@ func (m FileHeaderMock) Delete(ctx context.Context, files []string) ([]string, e return args.Get(0).([]string), args.Error(1) } +func (m FileHeaderMock) Thumb(ctx context.Context, files string) (*response.ContentResponse, error) { + args := m.Called(ctx, files) + return args.Get(0).(*response.ContentResponse), args.Error(1) +} + func TestFileSystem_Upload(t *testing.T) { asserts := assert.New(t) diff --git a/pkg/thumb/image.go b/pkg/thumb/image.go index 1545a49..5078248 100644 --- a/pkg/thumb/image.go +++ b/pkg/thumb/image.go @@ -73,7 +73,7 @@ func (image *Thumb) Save(path string) (err error) { return err } - err = jpeg.Encode(out, image.src, nil) + err = png.Encode(out, image.src) return err } diff --git a/routers/controllers/file.go b/routers/controllers/file.go index 7e92e65..4acbf9b 100644 --- a/routers/controllers/file.go +++ b/routers/controllers/file.go @@ -1,5 +1,6 @@ package controllers +import "C" import ( "context" "github.com/HFO4/cloudreve/models" @@ -9,10 +10,45 @@ import ( "github.com/HFO4/cloudreve/pkg/util" "github.com/HFO4/cloudreve/service/explorer" "github.com/gin-gonic/gin" + "net/http" "net/url" "strconv" ) +// Thumb 获取文件缩略图 +func Thumb(c *gin.Context) { + // 创建上下文 + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + fs, err := filesystem.NewFileSystemFromContext(c) + if err != nil { + c.JSON(200, serializer.Err(serializer.CodePolicyNotAllowed, err.Error(), err)) + return + } + + // 获取文件ID + fileID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(200, serializer.ParamErr("无法解析文件ID", err)) + return + } + + // 获取缩略图 + resp, err := fs.GetThumb(ctx, uint(fileID)) + if err != nil { + c.JSON(200, serializer.Err(serializer.CodeNotSet, "无法获取缩略图", err)) + return + } + + if resp.Redirect { + c.Redirect(http.StatusMovedPermanently, resp.URL) + return + } + http.ServeContent(c.Writer, c.Request, "thumb.png", fs.FileTarget[0].UpdatedAt, resp.Content) + +} + // Download 文件下载 func Download(c *gin.Context) { // 创建上下文 diff --git a/routers/controllers/user.go b/routers/controllers/user.go index 4463cdc..c4a3b15 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -1,11 +1,109 @@ package controllers import ( + "encoding/json" + model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/authn" "github.com/HFO4/cloudreve/pkg/serializer" + "github.com/HFO4/cloudreve/pkg/util" "github.com/HFO4/cloudreve/service/user" + "github.com/duo-labs/webauthn/webauthn" "github.com/gin-gonic/gin" ) +// StartLoginAuthn 开始注册WebAuthn登录 +func StartLoginAuthn(c *gin.Context) { + userName := c.Param("username") + expectedUser, err := model.GetUserByEmail(userName) + if err != nil { + c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err)) + return + } + + options, sessionData, err := authn.Authn.BeginLogin(expectedUser) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + + val, err := json.Marshal(sessionData) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + + util.SetSession(c, map[string]interface{}{ + "registration-session": val, + }) + c.JSON(200, serializer.Response{Code: 0, Data: options}) +} + +// FinishLoginAuthn 完成注册WebAuthn登录 +func FinishLoginAuthn(c *gin.Context) { + userName := c.Param("username") + expectedUser, err := model.GetUserByEmail(userName) + if err != nil { + c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err)) + return + } + + sessionDataJSON := util.GetSession(c, "registration-session").([]byte) + + var sessionData webauthn.SessionData + err = json.Unmarshal(sessionDataJSON, &sessionData) + + _, err = authn.Authn.FinishLogin(expectedUser, sessionData, c.Request) + + if err != nil { + c.JSON(200, serializer.Err(401, "用户邮箱或密码错误", err)) + return + } + + util.SetSession(c, map[string]interface{}{ + "user_id": expectedUser.ID, + }) + c.JSON(200, serializer.BuildUserResponse(expectedUser)) +} + +// StartRegAuthn 开始注册WebAuthn信息 +func StartRegAuthn(c *gin.Context) { + currUser := CurrentUser(c) + options, sessionData, err := authn.Authn.BeginRegistration(currUser) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + + val, err := json.Marshal(sessionData) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + + util.SetSession(c, map[string]interface{}{ + "registration-session": val, + }) + c.JSON(200, serializer.Response{Code: 0, Data: options}) +} + +// FinishRegAuthn 完成注册WebAuthn信息 +func FinishRegAuthn(c *gin.Context) { + currUser := CurrentUser(c) + sessionDataJSON := util.GetSession(c, "registration-session").([]byte) + + var sessionData webauthn.SessionData + err := json.Unmarshal(sessionDataJSON, &sessionData) + + credential, err := authn.Authn.FinishRegistration(currUser, sessionData, c.Request) + + currUser.RegisterAuthn(credential) + if err != nil { + c.JSON(200, ErrorResponse(err)) + return + } + c.JSON(200, serializer.Response{Code: 0}) +} + // UserLogin 用户登录 func UserLogin(c *gin.Context) { var service user.UserLoginService diff --git a/routers/router.go b/routers/router.go index b64e24e..b384666 100644 --- a/routers/router.go +++ b/routers/router.go @@ -43,6 +43,10 @@ func InitRouter() *gin.Engine { v3.GET("site/ping", controllers.Ping) // 用户登录 v3.POST("user/session", controllers.UserLogin) + // WebAuthn登陆初始化 + v3.GET("user/authn/:username", controllers.StartLoginAuthn) + // WebAuthn登陆 + v3.POST("user/authn/finish/:username", controllers.FinishLoginAuthn) // 验证码 v3.GET("captcha", controllers.Captcha) // 站点全局配置 @@ -58,6 +62,13 @@ func InitRouter() *gin.Engine { // 当前登录用户信息 user.GET("me", controllers.UserMe) user.GET("storage", controllers.UserStorage) + + // WebAuthn 注册相关 + authn := user.Group("authn") + { + authn.PUT("", controllers.StartRegAuthn) + authn.PUT("finish", controllers.FinishRegAuthn) + } } // 文件 @@ -66,7 +77,9 @@ func InitRouter() *gin.Engine { // 文件上传 file.POST("upload", controllers.FileUploadStream) // 下载文件 - file.GET("*path", controllers.Download) + file.GET("download/*path", controllers.Download) + // 下载文件 + file.GET("thumb/:id", controllers.Thumb) } // 目录 diff --git a/service/user/login.go b/service/user/login.go index f851750..2769fd0 100644 --- a/service/user/login.go +++ b/service/user/login.go @@ -1,7 +1,6 @@ package user import ( - "fmt" "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/serializer" "github.com/HFO4/cloudreve/pkg/util" @@ -53,8 +52,6 @@ func (service *UserLoginService) Login(c *gin.Context) serializer.Response { "user_id": expectedUser.ID, }) - fmt.Println(expectedUser) - return serializer.BuildUserResponse(expectedUser) }